Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion backend/app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from datetime import timedelta

from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
from sqlalchemy import or_
from sqlmodel import Session, select
Expand All @@ -22,6 +22,7 @@
from app.db import engine, get_session
from app.db.models import (
AgentEvent,
AgentOutputFile,
AgentProfile,
ChatSession,
HumanHandoffRequest,
Expand All @@ -37,6 +38,7 @@
utc_now,
)
from app.feedback import enqueue_feedback_analysis
from app.general_skills.output_files import output_file_abs_path
from app.knowledge.citations import CITATION_EXCERPT_CHAR_LIMIT, compact_knowledge_citation_labels
from app.llm import LLMClient, LLMError
from app.observability.spans import (
Expand Down Expand Up @@ -64,6 +66,29 @@
)

router = APIRouter(prefix="/api/chat", tags=["chat"])


@router.get("/outputs/{file_id}/download")
def download_output_file(
file_id: str,
tenant_id: str = Query(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_session),
) -> FileResponse:
"""下载数字员工产出文件(如通用技能生成的 PPT);仅本人或管理员可下。"""
_ensure_request_tenant(tenant_id, current_user)
row = db.get(AgentOutputFile, file_id)
if not row or row.tenant_id != tenant_id:
raise HTTPException(status_code=404, detail="产出文件不存在")
if not is_admin_user(current_user) and row.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权下载该产出文件")
try:
path = output_file_abs_path(row)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="产出文件路径非法") from None
if not path.exists():
raise HTTPException(status_code=404, detail="产出文件已丢失")
return FileResponse(path, filename=row.filename, media_type=row.content_type)
logger = logging.getLogger(__name__)
CANCELLED_ASSISTANT_REPLY = "已停止生成"
INTERRUPTED_ASSISTANT_REPLY = "本次响应中断,请重试发送。"
Expand Down
134 changes: 132 additions & 2 deletions backend/app/api/general_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@
GeneralSkillRunResponse,
)
from app.general_skills.runner import GeneralSkillReader, GeneralSkillRunner
from app.general_skills.output_files import persist_output_files
from app.general_skills.schema import GeneralSkillFile
from app.general_skills.standard import (
compose_skill_markdown,
frontmatter_for_skill,
split_frontmatter,
standard_package_files,
validate_skill_description,
validate_skill_name,
)
from app.llm.model_config_resolver import resolve_model_config_for_runtime
from app.security.auth import get_current_user
from app.security.permissions import (
Expand Down Expand Up @@ -81,13 +90,17 @@ def _agent_id_or_none(agent_id: object | None) -> str | None:


def general_skill_read(row: GeneralSkill, status_override: str | None = None) -> GeneralSkillRead:
frontmatter = frontmatter_for_skill(row)
return GeneralSkillRead(
id=row.id,
tenant_id=row.tenant_id,
slug=row.slug,
name=row.name,
description=row.description,
homepage=row.homepage,
license=frontmatter["license"] or None,
compatibility=frontmatter["compatibility"] or None,
allowed_tools=frontmatter["allowed_tools"] or None,
skill_markdown=row.skill_markdown,
skill_files=[
GeneralSkillFile.model_validate(item) for item in _skill_files_or_markdown(row)
Expand Down Expand Up @@ -126,6 +139,29 @@ def import_general_skill(
)
_validate_slug(slug)
lookup_slug = _optional_text(request.original_slug)
# Agent Skills 规范校验:新建时 slug(即规范 name,与目录名一致)必须合规;
# 存量 slug 不可修改,编辑时宽限(只拦新格式违规的新建)
if not lookup_slug and (error := validate_skill_name(slug)):
raise HTTPException(status_code=400, detail=f"Slug(name):{error}")
if error := validate_skill_description(description, required=request.status == "published"):
raise HTTPException(status_code=400, detail=error)
# SKILL.md 归一化:frontmatter 以表单/规范字段重组(name 恒等于 slug),正文保留
existing_frontmatter, markdown_body = split_frontmatter(markdown)
markdown = compose_skill_markdown(
name=slug,
description=description or "",
body=markdown_body,
license=_optional_text(request.license) or str(existing_frontmatter.get("license") or ""),
compatibility=_optional_text(request.compatibility)
or str(existing_frontmatter.get("compatibility") or ""),
allowed_tools=_optional_text(request.allowed_tools)
or str(existing_frontmatter.get("allowed-tools") or ""),
metadata=(
existing_frontmatter.get("metadata")
if isinstance(existing_frontmatter.get("metadata"), dict)
else {}
),
)
agent_id = _agent_id_or_none(request.agent_id)
agent = ensure_agent_scope_manager(db, request.tenant_id, agent_id, current_user)
is_private_agent_scope = bool(agent and not agent.is_overall)
Expand Down Expand Up @@ -355,6 +391,26 @@ def _create_imported_general_skill(
or _clawhub_homepage_from_source(import_source)
)
_validate_slug(resolved_slug)
# Agent Skills 规范:slug 必须合规范(name 形态);不合规的导入名经 _slugify 清洗
if validate_skill_name(resolved_slug):
resolved_slug = _unique_slug(db, tenant_id, _slugify(resolved_slug))
if error := validate_skill_description(resolved_description, required=status == "published"):
raise HTTPException(status_code=400, detail=error)
# SKILL.md 归一化:frontmatter 以解析/规范字段重组(name 恒等于 slug),正文保留
existing_frontmatter, markdown_body = split_frontmatter(markdown)
markdown = compose_skill_markdown(
name=resolved_slug,
description=resolved_description or "",
body=markdown_body,
license=str(existing_frontmatter.get("license") or ""),
compatibility=str(existing_frontmatter.get("compatibility") or ""),
allowed_tools=str(existing_frontmatter.get("allowed-tools") or ""),
metadata=(
existing_frontmatter.get("metadata")
if isinstance(existing_frontmatter.get("metadata"), dict)
else {}
),
)
now = utc_now()
resolved_agent_id = _agent_id_or_none(agent_id)
agent = ensure_agent_scope_manager(db, tenant_id, resolved_agent_id, current_user)
Expand Down Expand Up @@ -482,6 +538,28 @@ def get_general_skill(
return general_skill_read(row)


@router.get("/{slug}/export", dependencies=[Depends(require_agent_scope_viewer)])
def export_general_skill(
slug: str,
tenant_id: str = Query(...),
db: Session = Depends(get_session),
agent_id: str | None = Query(None),
) -> StreamingResponse:
"""导出标准 Agent Skills 包(zip):根目录为 slug,SKILL.md 为规范化版本。"""
row = _get_general_skill(db, tenant_id, slug)
_ensure_general_skill_visible(db, tenant_id, row, agent_id)
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for file in standard_package_files(row):
archive.writestr(f"{row.slug}/{file['path']}", file["content"])
buffer.seek(0)
return StreamingResponse(
buffer,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{row.slug}.zip"'},
)


@router.post("/{slug}/publish", response_model=GeneralSkillRead)
def publish_general_skill(
slug: str,
Expand All @@ -507,6 +585,11 @@ def publish_general_skill(
db.commit()
return general_skill_read(row, status_override="published")
ensure_open_gallery_admin(tenant_id, current_user)
# 发布前必须通过 Agent Skills 规范校验(name/description 必填且合规)
if error := validate_skill_name(row.slug):
raise HTTPException(status_code=400, detail=f"发布失败:slug(name):{error}")
if error := validate_skill_description(row.description, required=True):
raise HTTPException(status_code=400, detail=f"发布失败:{error}")
row.status = "published"
mark_resource_open_gallery(row, row.metadata_json or {})
row.updated_at = utc_now()
Expand Down Expand Up @@ -601,12 +684,35 @@ def run_general_skill(
_ensure_general_skill_visible(db, request.tenant_id, skill, request.agent_id)
model_config = _get_request_model(db, request.tenant_id, request.model_config_id)
skill_snapshot = _general_skill_snapshot(skill)
return _run_general_skill_operation(

output_refs: list[dict[str, object]] = []

def sink(files: list[dict[str, object]]) -> None:
output_refs.extend(
persist_output_files(
db,
tenant_id=request.tenant_id,
session_id=request.session_id,
agent_id=request.agent_id,
user_id=current_user.id,
source="general_skill",
files=files,
)
)

response = _run_general_skill_operation(
skill_snapshot,
request,
model_config,
current_user.id,
output_sink=sink,
)
if output_refs:
response.structured_result = {
**(response.structured_result or {}),
"output_files": output_refs,
}
return response


@router.post("/{slug}/run/stream")
Expand All @@ -631,6 +737,21 @@ def stream_events() -> Iterator[str]:
def sink(item: dict[str, object]) -> None:
events.put(("trace", item))

output_refs: list[dict[str, object]] = []

def file_sink(files: list[dict[str, object]]) -> None:
output_refs.extend(
persist_output_files(
db,
tenant_id=request.tenant_id,
session_id=request.session_id,
agent_id=request.agent_id,
user_id=current_user.id,
source="general_skill",
files=files,
)
)

def worker() -> None:
try:
response = _run_general_skill_operation(
Expand All @@ -639,8 +760,15 @@ def worker() -> None:
model_snapshot,
current_user.id,
sink,
output_sink=file_sink,
)
events.put(("complete", response.model_dump(mode="json")))
payload = response.model_dump(mode="json")
if output_refs:
payload["structured_result"] = {
**(payload.get("structured_result") or {}),
"output_files": output_refs,
}
events.put(("complete", payload))
except Exception as exc: # pragma: no cover - defensive stream boundary
events.put(("error", {"message": str(exc)}))
finally:
Expand Down Expand Up @@ -689,6 +817,7 @@ def _run_general_skill_operation(
model_config: ModelConfig,
user_id: str,
event_sink: Callable[[dict[str, object]], None] | None = None,
output_sink: Callable[[list[dict[str, object]]], None] | None = None,
) -> GeneralSkillRunResponse:
if request.operation == "read":
response = GeneralSkillReader().read(skill, request.query, model_config)
Expand All @@ -703,6 +832,7 @@ def _run_general_skill_operation(
user_id,
request.max_attempts,
event_sink,
output_sink=output_sink,
)


Expand Down
48 changes: 48 additions & 0 deletions backend/app/core/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@
utc_now,
)
from app.general_skills import GeneralSkillReader, GeneralSkillRunner, GeneralSkillSelector
from app.general_skills.output_files import (
attach_session_outputs_to_message,
persist_output_files,
)
from app.general_skills.schema import GeneralSkillRunResponse, GeneralSkillSelection
from app.knowledge import KnowledgeService
from app.knowledge.citations import (
Expand Down Expand Up @@ -620,6 +624,7 @@ def _run_general_skill_operation(
conversation_context: dict[str, object] | None = None,
memory_context: list[dict[str, object]] | None = None,
event_sink: Callable[[dict[str, Any]], None] | None = None,
output_sink: Callable[[list[dict[str, Any]]], None] | None = None,
) -> GeneralSkillRunResponse:
if operation == "read":
response = self.general_skill_reader.read(
Expand All @@ -641,8 +646,35 @@ def _run_general_skill_operation(
event_sink=event_sink,
conversation_context=conversation_context,
memory_context=memory_context,
output_sink=output_sink,
)

def _make_output_sink(
self, request: ChatTurnRequest, chat_session: ChatSession
) -> Callable[[list[dict[str, Any]]], None]:
"""通用技能产出持久化回调:写盘+落行+记事件(引用计数供挂载/排查)。"""

def sink(files: list[dict[str, Any]]) -> None:
refs = persist_output_files(
self.db,
tenant_id=request.tenant_id,
session_id=chat_session.id,
agent_id=chat_session.agent_id,
user_id=request.user_id,
source="general_skill",
files=files,
)
if refs:
self.events.record(
request.tenant_id,
chat_session.id,
"output_files_produced",
{"files": refs, "source": "general_skill"},
)
self.db.commit()

return sink

@staticmethod
def _merge_capability_knowledge(
step_result: StepAgentResult,
Expand Down Expand Up @@ -802,6 +834,7 @@ def general_skill_worker() -> None:
event_sink=general_skill_sink,
conversation_context=conversation_context,
memory_context=memory_context,
output_sink=self._make_output_sink(request, chat_session),
)
general_skill_events.put(("complete", response))
except Exception as exc: # pragma: no cover - defensive stream boundary
Expand Down Expand Up @@ -4598,6 +4631,7 @@ def _execute_general_skill_tool_call(
self._general_skill_runtime_snapshot(request, chat_session, selected_skill),
*args,
operation=operation,
output_sink=self._make_output_sink(request, chat_session),
**kwargs,
),
)
Expand Down Expand Up @@ -5770,6 +5804,20 @@ def _finalize_turn(
reply,
metadata=assistant_metadata,
)
# 挂载本轮产出文件(通用技能等):写回消息 metadata,聊天气泡渲染下载卡片
output_refs = attach_session_outputs_to_message(
self.db,
tenant_id=tenant_id,
session_id=chat_session.id,
user_message_id=user_message_id or "",
assistant_message_id=assistant_message.id,
)
if output_refs:
assistant_message.metadata_json = {
**(assistant_message.metadata_json or {}),
"output_files": output_refs,
}
self.db.add(assistant_message)
stage_channel_delivery(self.db, chat_session, assistant_message)
event_payload: dict[str, Any] = {
"message_id": assistant_message.id,
Expand Down
Loading