diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py
index 3950df25..4268144e 100644
--- a/backend/app/api/chat.py
+++ b/backend/app/api/chat.py
@@ -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
@@ -22,6 +22,7 @@
from app.db import engine, get_session
from app.db.models import (
AgentEvent,
+ AgentOutputFile,
AgentProfile,
ChatSession,
HumanHandoffRequest,
@@ -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 (
@@ -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 = "本次响应中断,请重试发送。"
diff --git a/backend/app/api/general_skills.py b/backend/app/api/general_skills.py
index f47a3f19..d387cc45 100644
--- a/backend/app/api/general_skills.py
+++ b/backend/app/api/general_skills.py
@@ -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 (
@@ -81,6 +90,7 @@ 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,
@@ -88,6 +98,9 @@ def general_skill_read(row: GeneralSkill, status_override: str | None = None) ->
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)
@@ -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)
@@ -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)
@@ -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,
@@ -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()
@@ -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")
@@ -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(
@@ -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:
@@ -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)
@@ -703,6 +832,7 @@ def _run_general_skill_operation(
user_id,
request.max_attempts,
event_sink,
+ output_sink=output_sink,
)
diff --git a/backend/app/core/agent_loop.py b/backend/app/core/agent_loop.py
index eed2cedb..a5925b1e 100644
--- a/backend/app/core/agent_loop.py
+++ b/backend/app/core/agent_loop.py
@@ -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 (
@@ -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(
@@ -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,
@@ -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
@@ -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,
),
)
@@ -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,
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 4aff05c0..87141059 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -862,6 +862,31 @@ class AgentEvent(SQLModel, table=True):
created_at: datetime = Field(default_factory=utc_now)
+class AgentOutputFile(SQLModel, table=True):
+ """数字员工产出文件(如通用技能生成的 PPT/PDF):本体落磁盘,本表存元数据与归属。
+
+ message_id 挂载到本轮 assistant 消息后,聊天气泡按它渲染下载卡片;
+ 渠道侧外发(微信/企微推文件)留后续扩展。
+ """
+
+ __tablename__ = "agent_output_files"
+
+ id: str = Field(default_factory=lambda: new_id("aof"), primary_key=True)
+ tenant_id: str = Field(index=True)
+ session_id: Optional[str] = Field(default=None, index=True)
+ agent_id: Optional[str] = Field(default=None, index=True)
+ user_id: Optional[str] = Field(default=None, index=True)
+ message_id: Optional[str] = Field(default=None, index=True)
+ source: str = Field(default="general_skill", index=True)
+ filename: str
+ content_type: str = "application/octet-stream"
+ size: int
+ sha256: str
+ # 相对用户数据目录的存储路径(outputs/...)
+ storage_path: str
+ created_at: datetime = Field(default_factory=utc_now)
+
+
class MemoryRecord(SQLModel, table=True):
__tablename__ = "memories"
diff --git a/backend/app/general_skills/output_files.py b/backend/app/general_skills/output_files.py
new file mode 100644
index 00000000..532fd1d5
--- /dev/null
+++ b/backend/app/general_skills/output_files.py
@@ -0,0 +1,222 @@
+"""数字员工产出文件服务:捕获、持久化、消息挂载与下载路径。
+
+设计要点:
+- 本体落磁盘(用户数据目录 outputs/{session|nosession}/{file_id}_{filename}),
+ 不内联 base64(PPT 等二进制可达数 MB,内联会撑爆消息表);
+- 元数据与归属落 AgentOutputFile 表(tenant/session/agent/user/message);
+- 通用技能运行时按"净新增文件"捕获(排除物化进去的技能包输入);
+- assistant 消息落库时按会话+时间挂载到本轮,聊天气泡渲染下载卡片。
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import re
+from pathlib import Path
+from typing import Any, Optional
+
+from sqlmodel import Session, select
+
+from app import paths
+from app.db.models import AgentOutputFile, Message
+
+logger = logging.getLogger(__name__)
+
+MAX_OUTPUT_FILE_BYTES = 12 * 1024 * 1024
+_MAX_OUTPUT_FILES_PER_RUN = 20
+_UNSAFE_FILENAME_CHARS = re.compile(r"[^\w.\-]+", re.UNICODE)
+
+_MIME_BY_EXT = {
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ".pdf": "application/pdf",
+ ".md": "text/markdown",
+ ".markdown": "text/markdown",
+ ".txt": "text/plain",
+ ".csv": "text/csv",
+ ".json": "application/json",
+ ".html": "text/html",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".zip": "application/zip",
+ ".py": "text/plain",
+ ".sh": "text/plain",
+ ".mp3": "audio/mpeg",
+ ".mp4": "video/mp4",
+}
+
+
+def _safe_filename(name: str) -> str:
+ cleaned = _UNSAFE_FILENAME_CHARS.sub("_", (name or "").strip())
+ cleaned = cleaned.strip("._")
+ return (cleaned or "output")[:120]
+
+
+def _guess_content_type(filename: str) -> str:
+ return _MIME_BY_EXT.get(Path(filename).suffix.lower(), "application/octet-stream")
+
+
+def collect_workspace_outputs(
+ workspace: Path,
+ input_paths: set[str],
+) -> list[dict[str, Any]]:
+ """扫描运行工作区,返回净新增产出文件(排除技能包输入与 runner 脚本)。
+
+ 返回 [{"filename": 相对路径, "content": bytes, "content_type": str}];
+ 单文件超过上限或数量过多时跳过并记 warning,不阻断运行。
+ """
+ produced: list[dict[str, Any]] = []
+ if not workspace.exists():
+ return produced
+ for path in sorted(workspace.rglob("*")):
+ if not path.is_file():
+ continue
+ relative = path.relative_to(workspace).as_posix()
+ # 技能包物化在 skill/ 子目录下:统一去掉该前缀后再与输入清单比较,
+ # 产出文件名也以去前缀后的形式呈现(scripts 写到工作区的文件即工作区文件)
+ if relative.startswith("skill/"):
+ relative = relative[len("skill/"):]
+ if relative in input_paths or relative in {"runner.sh", "runner.py"}:
+ continue
+ try:
+ size = path.stat().st_size
+ except OSError:
+ continue
+ if size == 0:
+ continue
+ if size > MAX_OUTPUT_FILE_BYTES:
+ logger.warning("产出文件超过大小上限,跳过 %s (%d bytes)", relative, size)
+ continue
+ try:
+ content = path.read_bytes()
+ except OSError:
+ continue
+ produced.append(
+ {
+ "filename": relative,
+ "content": content,
+ "content_type": _guess_content_type(relative),
+ }
+ )
+ if len(produced) >= _MAX_OUTPUT_FILES_PER_RUN:
+ logger.warning("产出文件数量超过上限 %d,其余忽略", _MAX_OUTPUT_FILES_PER_RUN)
+ break
+ return produced
+
+
+def _outputs_root() -> Path:
+ root = paths.user_data_dir() / "outputs"
+ root.mkdir(parents=True, exist_ok=True)
+ return root
+
+
+def persist_output_files(
+ db: Session,
+ *,
+ tenant_id: str,
+ session_id: Optional[str],
+ agent_id: Optional[str],
+ user_id: Optional[str],
+ source: str,
+ files: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """把产出文件写盘并落 AgentOutputFile 行,返回消息挂载用 refs。"""
+ refs: list[dict[str, Any]] = []
+ if not files:
+ return refs
+ bucket = session_id or "nosession"
+ for item in files:
+ filename = _safe_filename(str(item.get("filename") or "output"))
+ content = bytes(item.get("content") or b"")
+ if not content:
+ continue
+ digest = hashlib.sha256(content).hexdigest()
+ row = AgentOutputFile(
+ tenant_id=tenant_id,
+ session_id=session_id,
+ agent_id=agent_id,
+ user_id=user_id,
+ source=source,
+ filename=filename,
+ content_type=str(item.get("content_type") or _guess_content_type(filename)),
+ size=len(content),
+ sha256=digest,
+ storage_path="",
+ )
+ storage_rel = f"outputs/{bucket}/{row.id}_{filename}"
+ target = _outputs_root().parent / storage_rel
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(content)
+ row.storage_path = storage_rel
+ db.add(row)
+ refs.append(
+ {
+ "file_id": row.id,
+ "filename": row.filename,
+ "size": row.size,
+ "content_type": row.content_type,
+ }
+ )
+ if refs:
+ db.commit()
+ return refs
+
+
+def attach_session_outputs_to_message(
+ db: Session,
+ *,
+ tenant_id: str,
+ session_id: str,
+ user_message_id: str,
+ assistant_message_id: str,
+) -> list[dict[str, Any]]:
+ """把本轮产生的未挂载产出挂到 assistant 消息上,返回 refs(写入消息 metadata)。
+
+ 按会话内未挂载且创建时间不早于本轮用户消息的产出行挂载,幂等。
+ """
+ if not user_message_id:
+ return []
+ user_message = db.get(Message, user_message_id)
+ if not user_message:
+ return []
+ rows = db.exec(
+ select(AgentOutputFile)
+ .where(
+ AgentOutputFile.tenant_id == tenant_id,
+ AgentOutputFile.session_id == session_id,
+ AgentOutputFile.message_id.is_(None), # type: ignore[union-attr]
+ )
+ .order_by(AgentOutputFile.created_at)
+ ).all()
+ refs: list[dict[str, Any]] = []
+ for row in rows:
+ if row.created_at and user_message.created_at and row.created_at < user_message.created_at:
+ continue
+ row.message_id = assistant_message_id
+ db.add(row)
+ refs.append(
+ {
+ "file_id": row.id,
+ "filename": row.filename,
+ "size": row.size,
+ "content_type": row.content_type,
+ }
+ )
+ if refs:
+ db.commit()
+ return refs
+
+
+def output_file_abs_path(row: AgentOutputFile) -> Path:
+ """产出文件的磁盘绝对路径(校验在 outputs 根下,防路径逃逸)。"""
+ root = _outputs_root().resolve()
+ target = (paths.user_data_dir() / row.storage_path).resolve()
+ if root not in target.parents and target != root:
+ raise FileNotFoundError(row.storage_path)
+ return target
diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py
index 6f805fd0..463af142 100644
--- a/backend/app/general_skills/runner.py
+++ b/backend/app/general_skills/runner.py
@@ -1,9 +1,11 @@
from __future__ import annotations
import json
+import logging
import os
import queue
import selectors
+import shutil
import subprocess
import sys
import threading
@@ -16,6 +18,7 @@
from app import paths
from app.db.models import GeneralSkill, ModelConfig
from app.general_skills.runtime_env import (
+
GeneralSkillRuntimeError,
ensure_runtime_python,
runtime_environment,
@@ -27,11 +30,15 @@
GeneralSkillRunResponse,
GeneralSkillSelection,
)
+from app.general_skills.standard import allowed_tools_list, standard_package_files
+from app.general_skills.output_files import collect_workspace_outputs
from app.llm import LLMClient, LLMError
from app.llm.model_config_resolver import snapshot_model_config
from app.llm.stage_protocol import stage_payload, unified_system_prompt
from app.observability.spans import llm_operation
+logger = logging.getLogger(__name__)
+
PROMPT_DIR = paths.resource_dir() / "app" / "llm" / "prompts"
SELECTOR_PROMPT = PROMPT_DIR / "general_skill_selector_prompt.md"
RUNNER_PROMPT = PROMPT_DIR / "general_skill_runner_prompt.md"
@@ -116,6 +123,24 @@ def decide(
return decision.model_copy(update={"use_general_skill": False, "selected_slug": None})
+def _bash_allowed(skill: GeneralSkill) -> bool:
+ """allowed-tools 声明了 Bash 才放行 bash runtime;未声明 allowed-tools 不限制。"""
+ tools = allowed_tools_list(skill)
+ if not tools:
+ return True
+ return any(tool.startswith("Bash") for tool in tools)
+
+
+def _runtime_languages(skill: GeneralSkill) -> list[str]:
+ return ["bash", "python"] if _bash_allowed(skill) else ["python"]
+
+
+def _enforce_allowed_tools_runtime(skill: GeneralSkill, plan: GeneralSkillExecutionPlan) -> None:
+ """allowed-tools 门控:未声明 Bash 而模型仍给出 bash 计划时拒绝,促使其改用 python。"""
+ if plan.runtime == "bash" and not _bash_allowed(skill):
+ raise LLMError("该技能的 allowed-tools 未声明 Bash,请改用 python runtime 重新生成")
+
+
class GeneralSkillReader:
"""Explain a skill package without generating or executing runner code."""
@@ -211,6 +236,7 @@ def run(
event_sink: TraceSink | None = None,
conversation_context: dict[str, object] | None = None,
memory_context: list[dict[str, object]] | None = None,
+ output_sink: Callable[[list[dict[str, Any]]], None] | None = None,
) -> GeneralSkillRunResponse:
trace: list[dict[str, Any]] = []
max_attempts = max(1, min(max_attempts, GENERAL_SKILL_MAX_ATTEMPTS))
@@ -242,12 +268,14 @@ def run(
stdout = ""
stderr = ""
structured_result: dict[str, Any] = {}
+ latest_produced: list[dict[str, Any]] = []
for attempt in range(1, max_attempts + 1):
_emit(
trace,
{"phase": "attempt_started", "message": f"开始第 {attempt} 次运行", "attempt": attempt},
event_sink,
)
+ latest_produced = []
stdout, stderr, structured_result = self._execute_plan(
skill,
query,
@@ -256,6 +284,7 @@ def run(
trace,
event_sink,
attempt,
+ produced=latest_produced,
)
_normalize_failure_diagnostics(structured_result)
review = self._review_execution_result(
@@ -347,6 +376,27 @@ def run(
)
break
+ # 采用结果可用且有净新增产出时,回调持久化(失败/被废弃尝试的产出随临时目录清理)
+ if (
+ output_sink is not None
+ and latest_produced
+ and structured_result.get("success") is not False
+ and review.get("result_sufficient") is not False
+ ):
+ try:
+ output_sink(latest_produced)
+ _emit(
+ trace,
+ {
+ "phase": "outputs_captured",
+ "message": f"已捕获 {len(latest_produced)} 个产出文件",
+ "files": [item["filename"] for item in latest_produced],
+ },
+ event_sink,
+ )
+ except Exception:
+ logger.exception("产出文件持久化失败 skill=%s", skill.slug)
+
try:
reply = self._generate_reply(
skill,
@@ -394,7 +444,7 @@ def _generate_plan(
"package": _skill_package_payload(skill),
},
"runtime": {
- "languages": ["bash", "python"],
+ "languages": _runtime_languages(skill),
"stdin_json": {
"query": query,
"skill_slug": skill.slug,
@@ -421,6 +471,7 @@ def _generate_plan(
)
plan = GeneralSkillExecutionPlan.model_validate(raw)
plan.runtime = _plan_runtime(plan)
+ _enforce_allowed_tools_runtime(skill, plan)
if not plan.code.strip():
raise LLMError("General skill runner code is empty")
runtime_label = _runtime_label(plan.runtime)
@@ -554,7 +605,7 @@ def _repair_plan(
"package": _skill_package_payload(skill),
},
"runtime": {
- "languages": ["bash", "python"],
+ "languages": _runtime_languages(skill),
"stdin_json": {
"query": query,
"skill_slug": skill.slug,
@@ -582,6 +633,7 @@ def _repair_plan(
)
plan = GeneralSkillExecutionPlan.model_validate(raw)
plan.runtime = _plan_runtime(plan)
+ _enforce_allowed_tools_runtime(skill, plan)
if not plan.code.strip():
raise LLMError("General skill repaired runner code is empty")
runtime_label = _runtime_label(plan.runtime)
@@ -609,8 +661,35 @@ def _execute_plan(
trace: list[dict[str, Any]],
event_sink: TraceSink | None = None,
attempt: int = 1,
+ produced: list[dict[str, Any]] | None = None,
) -> tuple[str, str, dict[str, Any]]:
run_dir = Path(mkdtemp(prefix="ultrarag_general_skill_"))
+ try:
+ return self._execute_plan_inner(
+ skill, query, plan, user_id, trace, run_dir, event_sink, attempt
+ )
+ finally:
+ if produced is not None:
+ try:
+ # 捕获净新增产出(排除物化进去的技能包输入与 runner 脚本)
+ input_paths = {file["path"] for file in _skill_files(skill)}
+ produced.extend(collect_workspace_outputs(run_dir, input_paths))
+ except Exception:
+ logger.exception("产出文件捕获失败 run_dir=%s", run_dir)
+ # 临时工作区用完即清(此前一直残留)
+ shutil.rmtree(run_dir, ignore_errors=True)
+
+ def _execute_plan_inner(
+ self,
+ skill: GeneralSkill,
+ query: str,
+ plan: GeneralSkillExecutionPlan,
+ user_id: str,
+ trace: list[dict[str, Any]],
+ run_dir: Path,
+ event_sink: TraceSink | None = None,
+ attempt: int = 1,
+ ) -> tuple[str, str, dict[str, Any]]:
skill_dir = run_dir / "skill"
_materialize_skill_package(skill, skill_dir)
runtime = _plan_runtime(plan)
@@ -946,7 +1025,8 @@ def _skill_package_payload(skill: GeneralSkill, preview_limit: int = 12000) -> d
def _materialize_skill_package(skill: GeneralSkill, target_dir: Path) -> None:
target_dir.mkdir(parents=True, exist_ok=True)
- for file in _skill_files(skill):
+ # SKILL.md 一律物化为规范化版本(frontmatter 齐全),存量无 frontmatter 的技能同样生效
+ for file in standard_package_files(skill):
relative_path = _safe_package_path(str(file["path"]))
output_path = target_dir / relative_path
output_path.parent.mkdir(parents=True, exist_ok=True)
diff --git a/backend/app/general_skills/schema.py b/backend/app/general_skills/schema.py
index 61de07bd..a89b0c6d 100644
--- a/backend/app/general_skills/schema.py
+++ b/backend/app/general_skills/schema.py
@@ -19,6 +19,10 @@ class GeneralSkillImportRequest(BaseModel):
slug: Optional[str] = None
description: Optional[str] = None
homepage: Optional[str] = None
+ # Agent Skills 规范可选 frontmatter 字段(保存时写入 SKILL.md)
+ license: Optional[str] = None
+ compatibility: Optional[str] = None
+ allowed_tools: Optional[str] = None
markdown: Optional[str] = None
files: list[GeneralSkillFile] = Field(default_factory=list)
status: str = "published"
@@ -55,6 +59,10 @@ class GeneralSkillRead(BaseModel):
name: str
description: Optional[str] = None
homepage: Optional[str] = None
+ # Agent Skills 规范可选 frontmatter 字段(从 SKILL.md 解析)
+ license: Optional[str] = None
+ compatibility: Optional[str] = None
+ allowed_tools: Optional[str] = None
skill_markdown: str
skill_files: list[GeneralSkillFile] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
diff --git a/backend/app/general_skills/standard.py b/backend/app/general_skills/standard.py
new file mode 100644
index 00000000..c6231b62
--- /dev/null
+++ b/backend/app/general_skills/standard.py
@@ -0,0 +1,189 @@
+"""Agent Skills 官方规范的落地实现(agentskills.io/specification)。
+
+规范要点:
+- 技能 = 目录,至少含 SKILL.md(YAML frontmatter + Markdown 正文);
+- frontmatter:name(必填,1-64,小写字母/数字/连字符,不可首尾/连续连字符,
+ 须与目录名一致)、description(必填,1-1024,做什么+何时用);
+ 可选 license / compatibility(≤500) / metadata(键值对) / allowed-tools(空格分隔);
+- 可选目录 scripts/ references/ assets/;渐进披露。
+
+本模块提供校验、frontmatter 解析与 SKILL.md 组装,后端各处保存/导出/运行统一收口。
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+# name:1-64,小写字母/数字/连字符,不可首尾连字符,不可连续连字符
+SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
+SKILL_NAME_MAX = 64
+SKILL_DESCRIPTION_MAX = 1024
+SKILL_COMPATIBILITY_MAX = 500
+# frontmatter 中透传的可选标量字段
+_OPTIONAL_SCALAR_KEYS = ("license", "compatibility", "allowed-tools")
+
+
+def validate_skill_name(name: str) -> str | None:
+ """校验规范 name 字段,返回错误信息;合法返回 None。"""
+ if not name or len(name) > SKILL_NAME_MAX:
+ return f"name 必填且不超过 {SKILL_NAME_MAX} 字符"
+ if not SKILL_NAME_PATTERN.fullmatch(name):
+ return "name 只能包含小写字母、数字和连字符,且不可首尾或连续使用连字符"
+ return None
+
+
+def validate_skill_description(description: str, *, required: bool) -> str | None:
+ """校验规范 description 字段;required 时必填,非必填时允许为空但限长。"""
+ text = (description or "").strip()
+ if not text:
+ return "description 必填(描述技能做什么、什么时候使用)" if required else None
+ if len(text) > SKILL_DESCRIPTION_MAX:
+ return f"description 不能超过 {SKILL_DESCRIPTION_MAX} 字符"
+ return None
+
+
+def split_frontmatter(markdown: str) -> tuple[dict[str, Any], str]:
+ """拆出 YAML frontmatter 与正文;无 frontmatter 时返回 ({}, 原文)。
+
+ 轻量解析:支持标量、键值嵌套(metadata:)与简单列表,与既有导入解析口径一致。
+ """
+ lines = (markdown or "").splitlines()
+ if not lines or lines[0].strip() != "---":
+ return {}, markdown or ""
+ metadata: dict[str, Any] = {}
+ body_start = len(lines)
+ current_map_key = ""
+ for index, line in enumerate(lines[1:], start=1):
+ stripped = line.strip()
+ if stripped == "---":
+ body_start = index + 1
+ break
+ if not stripped or stripped.startswith("#"):
+ continue
+ # metadata: 下的嵌套键值(缩进)
+ if line.startswith((" ", "\t")) and current_map_key and ":" in stripped:
+ key, value = stripped.split(":", 1)
+ key = key.strip()
+ if key and isinstance(metadata.get(current_map_key), dict):
+ metadata[current_map_key][key] = _parse_value(value.strip())
+ continue
+ current_map_key = ""
+ if ":" not in stripped:
+ continue
+ key, value = stripped.split(":", 1)
+ key = key.strip()
+ if not key:
+ continue
+ value = value.strip()
+ if not value:
+ # 可能是嵌套映射(如 metadata:)——先占位字典
+ metadata[key] = {}
+ current_map_key = key
+ else:
+ metadata[key] = _parse_value(value)
+ return metadata, "\n".join(lines[body_start:]).lstrip("\n")
+
+
+def _parse_value(value: str) -> Any:
+ cleaned = value.strip().strip("'\"")
+ if cleaned.startswith("[") and cleaned.endswith("]"):
+ return [item.strip().strip("'\"") for item in cleaned[1:-1].split(",") if item.strip()]
+ return cleaned
+
+
+def _yaml_scalar(value: str) -> str:
+ """输出安全的 YAML 标量(含特殊字符时加引号)。"""
+ text = str(value)
+ if re.search(r"[:#\[\]{}&*!|>'\"%@`\s]", text):
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
+ return f'"{escaped}"'
+ return text
+
+
+def compose_skill_markdown(
+ *,
+ name: str,
+ description: str,
+ body: str,
+ license: str = "",
+ compatibility: str = "",
+ allowed_tools: str = "",
+ metadata: dict[str, Any] | None = None,
+) -> str:
+ """按规范组装 SKILL.md(frontmatter + 正文);metadata 中的保留键被忽略。"""
+ lines = ["---", f"name: {_yaml_scalar(name)}", f"description: {_yaml_scalar(description)}"]
+ optional = {
+ "license": license.strip(),
+ "compatibility": compatibility.strip(),
+ "allowed-tools": allowed_tools.strip(),
+ }
+ for key in _OPTIONAL_SCALAR_KEYS:
+ if optional[key]:
+ lines.append(f"{key}: {_yaml_scalar(optional[key])}")
+ extra_metadata = {
+ str(key): value
+ for key, value in (metadata or {}).items()
+ if str(key) not in {"name", "description", *_OPTIONAL_SCALAR_KEYS} and str(key).strip()
+ }
+ if extra_metadata:
+ lines.append("metadata:")
+ for key, value in extra_metadata.items():
+ lines.append(f" {key}: {_yaml_scalar(value)}")
+ lines.append("---")
+ lines.append("")
+ lines.append((body or "").strip())
+ return "\n".join(lines).rstrip("\n") + "\n"
+
+
+def frontmatter_for_skill(skill: Any) -> dict[str, Any]:
+ """从 GeneralSkill 行组装规范 frontmatter 字段(name 取 slug,与目录名一致)。"""
+ metadata, _ = split_frontmatter(getattr(skill, "skill_markdown", "") or "")
+ return {
+ "name": skill.slug,
+ "description": (skill.description or "").strip(),
+ "license": str(metadata.get("license") or ""),
+ "compatibility": str(metadata.get("compatibility") or ""),
+ "allowed_tools": str(metadata.get("allowed-tools") or ""),
+ "metadata": metadata.get("metadata") if isinstance(metadata.get("metadata"), dict) else {},
+ }
+
+
+def standard_skill_markdown(skill: Any) -> str:
+ """返回规范化后的完整 SKILL.md:frontmatter 以表单/数据库字段为准重组,正文保留。
+
+ 存量技能(无 frontmatter 或字段缺失)在读路径同样输出规范形态。
+ """
+ fields = frontmatter_for_skill(skill)
+ _, body = split_frontmatter(getattr(skill, "skill_markdown", "") or "")
+ return compose_skill_markdown(body=body, **fields)
+
+
+def standard_package_files(skill: Any) -> list[dict[str, Any]]:
+ """导出/物化用的标准文件清单:SKILL.md 为规范化版本,其余文件原样(去重 SKILL.md)。"""
+ files = [
+ {
+ "path": "SKILL.md",
+ "content": standard_skill_markdown(skill),
+ "mime_type": "text/markdown",
+ }
+ ]
+ for file in getattr(skill, "skill_files_json", None) or []:
+ path = str(file.get("path") or "").strip()
+ if not path or path.upper() == "SKILL.MD":
+ continue
+ files.append(
+ {
+ "path": path,
+ "content": str(file.get("content") or ""),
+ "mime_type": file.get("mime_type") or "text/plain",
+ }
+ )
+ return files
+
+
+def allowed_tools_list(skill: Any) -> list[str]:
+ """从 frontmatter 解析 allowed-tools 声明(空格分隔);未声明返回空列表。"""
+ fields = frontmatter_for_skill(skill)
+ raw = fields.get("allowed_tools") or ""
+ return [item for item in raw.split() if item]
diff --git a/backend/tests/test_agent_permissions.py b/backend/tests/test_agent_permissions.py
index 7c189676..91ae59b9 100644
--- a/backend/tests/test_agent_permissions.py
+++ b/backend/tests/test_agent_permissions.py
@@ -701,6 +701,7 @@ def test_private_general_skill_edit_does_not_mutate_open_gallery_skill() -> None
original_slug=updated.slug,
slug="weather-renamed",
name="员工天气技能",
+ description="员工天气技能描述",
markdown="# 员工天气技能\n",
),
db=db,
diff --git a/backend/tests/test_general_skill_standard.py b/backend/tests/test_general_skill_standard.py
new file mode 100644
index 00000000..4eedd242
--- /dev/null
+++ b/backend/tests/test_general_skill_standard.py
@@ -0,0 +1,150 @@
+"""Agent Skills 规范层单测:name/description 校验、frontmatter 解析与组装、标准化输出。"""
+
+from types import SimpleNamespace
+
+from app.general_skills.standard import (
+ allowed_tools_list,
+ compose_skill_markdown,
+ frontmatter_for_skill,
+ split_frontmatter,
+ standard_package_files,
+ standard_skill_markdown,
+ validate_skill_description,
+ validate_skill_name,
+)
+
+
+def test_validate_skill_name_spec_rules() -> None:
+ assert validate_skill_name("pdf-processing") is None
+ assert validate_skill_name("a") is None
+ assert validate_skill_name("data-analysis-2") is None
+ assert validate_skill_name("") is not None
+ assert validate_skill_name("x" * 65) is not None
+ assert validate_skill_name("PDF-Processing") is not None # 大写不允许
+ assert validate_skill_name("-pdf") is not None # 首连字符
+ assert validate_skill_name("pdf-") is not None # 尾连字符
+ assert validate_skill_name("pdf--processing") is not None # 连续连字符
+ assert validate_skill_name("pdf_processing") is not None # 下划线
+ assert validate_skill_name("pdf processing") is not None # 空格
+
+
+def test_validate_skill_description_rules() -> None:
+ assert validate_skill_description("做什么、何时用", required=True) is None
+ assert validate_skill_description("", required=True) is not None
+ assert validate_skill_description("", required=False) is None
+ assert validate_skill_description("x" * 1024, required=True) is None
+ assert validate_skill_description("x" * 1025, required=True) is not None
+
+
+def test_split_frontmatter_full_fields() -> None:
+ markdown = (
+ "---\n"
+ "name: pdf-processing\n"
+ "description: Extract PDF text. Use when handling PDFs.\n"
+ "license: Apache-2.0\n"
+ "compatibility: Requires git, docker\n"
+ "allowed-tools: Bash(git:*) Read\n"
+ "metadata:\n"
+ " author: example-org\n"
+ " version: \"1.0\"\n"
+ "---\n"
+ "\n"
+ "# 正文标题\n"
+ "正文内容\n"
+ )
+ metadata, body = split_frontmatter(markdown)
+ assert metadata["name"] == "pdf-processing"
+ assert metadata["description"].startswith("Extract PDF text")
+ assert metadata["license"] == "Apache-2.0"
+ assert metadata["compatibility"] == "Requires git, docker"
+ assert metadata["allowed-tools"] == "Bash(git:*) Read"
+ assert metadata["metadata"] == {"author": "example-org", "version": "1.0"}
+ assert body.startswith("# 正文标题")
+
+
+def test_split_frontmatter_absent_returns_original() -> None:
+ metadata, body = split_frontmatter("# 没有 frontmatter\n正文")
+ assert metadata == {}
+ assert body.startswith("# 没有 frontmatter")
+
+
+def test_compose_and_split_roundtrip() -> None:
+ composed = compose_skill_markdown(
+ name="weather-zh",
+ description="查询天气。当用户问天气时使用。",
+ body="# 天气技能\n按步骤查询。",
+ license="Apache-2.0",
+ compatibility="Requires network",
+ allowed_tools="Bash(curl:*) Read",
+ metadata={"author": "staffdeck", "version": "1.0"},
+ )
+ metadata, body = split_frontmatter(composed)
+ assert metadata["name"] == "weather-zh"
+ assert metadata["description"] == "查询天气。当用户问天气时使用。"
+ assert metadata["license"] == "Apache-2.0"
+ assert metadata["compatibility"] == "Requires network"
+ assert metadata["allowed-tools"] == "Bash(curl:*) Read"
+ assert metadata["metadata"]["author"] == "staffdeck"
+ assert body == "# 天气技能\n按步骤查询。"
+
+
+def test_compose_escapes_special_scalars() -> None:
+ composed = compose_skill_markdown(name="a-b", description='含: 冒号与 "引号"', body="x")
+ metadata, _ = split_frontmatter(composed)
+ assert metadata["name"] == "a-b"
+ assert "冒号" in metadata["description"]
+
+
+def _skill(**overrides) -> SimpleNamespace:
+ base = {
+ "slug": "weather-zh",
+ "description": "查询天气",
+ "skill_markdown": "# 旧正文\n没有 frontmatter。",
+ "skill_files_json": [
+ {"path": "SKILL.md", "content": "旧的", "mime_type": "text/markdown"},
+ {"path": "scripts/run.py", "content": "print(1)", "mime_type": "text/plain"},
+ ],
+ }
+ base.update(overrides)
+ return SimpleNamespace(**base)
+
+
+def test_standard_skill_markdown_synthesizes_frontmatter() -> None:
+ skill = _skill()
+ markdown = standard_skill_markdown(skill)
+ metadata, body = split_frontmatter(markdown)
+ # name 恒等于 slug(规范:与目录名一致);正文保留
+ assert metadata["name"] == "weather-zh"
+ assert metadata["description"] == "查询天气"
+ assert body.startswith("# 旧正文")
+
+
+def test_standard_package_files_replaces_skill_md_and_keeps_rest() -> None:
+ files = standard_package_files(_skill())
+ assert files[0]["path"] == "SKILL.md"
+ assert "name: weather-zh" in files[0]["content"]
+ # 旧 SKILL.md 被规范化版本替换,scripts 保留
+ assert [file["path"] for file in files] == ["SKILL.md", "scripts/run.py"]
+
+
+def test_allowed_tools_list() -> None:
+ skill = _skill(
+ skill_markdown="---\nname: a-b\ndescription: x\nallowed-tools: Bash(git:*) Read\n---\n正文"
+ )
+ assert allowed_tools_list(skill) == ["Bash(git:*)", "Read"]
+ assert allowed_tools_list(_skill()) == []
+
+
+def test_frontmatter_for_skill_reads_existing_optional_fields() -> None:
+ skill = _skill(
+ skill_markdown=(
+ "---\nname: old\ndescription: old\nlicense: MIT\ncompatibility: Needs docker\n"
+ "allowed-tools: Bash\nmetadata:\n author: me\n---\n正文"
+ )
+ )
+ fields = frontmatter_for_skill(skill)
+ assert fields["name"] == "weather-zh" # 以 slug 为准,不采信旧 name
+ assert fields["license"] == "MIT"
+ assert fields["compatibility"] == "Needs docker"
+ assert fields["allowed_tools"] == "Bash"
+ assert fields["metadata"] == {"author": "me"}
diff --git a/backend/tests/test_general_skills.py b/backend/tests/test_general_skills.py
index e4159e85..1b56ba39 100644
--- a/backend/tests/test_general_skills.py
+++ b/backend/tests/test_general_skills.py
@@ -267,7 +267,8 @@ def test_import_general_skill_uses_user_supplied_metadata() -> None:
assert rows[0].name == "用户改名天气技能"
assert rows[0].description == "用户改写描述"
assert rows[0].homepage == "https://example.com/weather-cn"
- assert rows[0].skill_markdown.startswith("# 天气 demo")
+ assert rows[0].skill_markdown.startswith("---\nname: weather-zh")
+ assert "# 天气 demo" in rows[0].skill_markdown
try:
import_general_skill(
@@ -275,6 +276,7 @@ def test_import_general_skill_uses_user_supplied_metadata() -> None:
tenant_id="tenant_demo",
name="非法改 slug",
slug="weather-cn",
+ description="中国城市天气查询",
original_slug="weather-zh",
markdown=WEATHER_SKILL_MD,
),
@@ -302,6 +304,7 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing(
tenant_id="tenant_demo",
name="已有天气技能",
slug="weather-zh",
+ description="中国城市天气查询",
markdown=WEATHER_SKILL_MD,
),
db,
@@ -314,6 +317,7 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing(
tenant_id="tenant_demo",
name="新导入天气技能",
slug="weather-zh",
+ description="中国城市天气查询",
markdown="# 新内容",
),
db,
@@ -328,7 +332,10 @@ def test_import_general_skill_without_original_slug_does_not_overwrite_existing(
assert len(rows) == 1
assert rows[0].id == first.id
assert rows[0].name == "已有天气技能"
- assert rows[0].skill_markdown == WEATHER_SKILL_MD.strip()
+ from app.general_skills.standard import split_frontmatter
+
+ _, saved_body = split_frontmatter(rows[0].skill_markdown)
+ assert saved_body.strip() == WEATHER_SKILL_MD.strip()
def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure() -> None:
@@ -346,6 +353,7 @@ def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure()
tenant_id="tenant_demo",
name="天气技能",
slug="weather-zh",
+ description="中国城市天气查询",
markdown=WEATHER_SKILL_MD,
),
db,
@@ -396,6 +404,7 @@ def test_deleted_open_gallery_general_skill_is_hidden_from_agent_branch_binding(
tenant_id="tenant_demo",
name="天气技能",
slug="weather-zh",
+ description="中国城市天气查询",
markdown=WEATHER_SKILL_MD,
),
db,
@@ -465,7 +474,7 @@ def test_import_general_skill_folder_reads_skill_md_metadata() -> None:
assert row.homepage == "https://example.com/weather"
assert row.metadata["name"] == "中国城市天气"
assert [file.path for file in row.skill_files] == ["SKILL.md", "data/cities.json"]
- assert row.skill_markdown.startswith("---\nname: 中国城市天气")
+ assert row.skill_markdown.startswith("---\nname: weather-zh")
def test_import_clawhub_skill_reads_zip_package_without_overwriting(monkeypatch) -> None:
@@ -473,7 +482,7 @@ def test_import_clawhub_skill_reads_zip_package_without_overwriting(monkeypatch)
with ZipFile(package, "w") as archive:
archive.writestr(
"skill-pack-main/weather/SKILL.md",
- "---\nname: 天气包\nslug: weather-pack\n---\n\n# 天气包\n",
+ "---\nname: 天气包\nslug: weather-pack\ndescription: 天气查询技能\n---\n\n# 天气包\n",
)
archive.writestr("skill-pack-main/weather/scripts/run.py", "print('ok')\n")
archive.writestr("skill-pack-main/weather/data/cities.json", '{"北京": "101010100"}')
@@ -508,7 +517,7 @@ def fake_download(url: str): # noqa: ANN001
"scripts/run.py",
"data/cities.json",
]
- assert first.skill_markdown.startswith("---\nname: 天气包")
+ assert first.skill_markdown.startswith("---\nname: weather-pack")
def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None:
@@ -516,7 +525,7 @@ def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None:
with ZipFile(package, "w") as archive:
archive.writestr(
"nuwa-skill-main/skill/SKILL.md",
- "---\nname: Nuwa Skill\nslug: nuwa-skill\n---\n\n# Nuwa Skill\n",
+ "---\nname: Nuwa Skill\nslug: nuwa-skill\ndescription: Nuwa 示例技能\n---\n\n# Nuwa Skill\n",
)
archive.writestr("nuwa-skill-main/skill/scripts/run.py", "print('nuwa')\n")
archive.writestr("nuwa-skill-main/skill/assets/config.json", '{"mode":"demo"}')
@@ -541,11 +550,11 @@ def test_import_general_skill_package_upload_keeps_full_zip_folder() -> None:
"scripts/run.py",
"assets/config.json",
]
- assert row.skill_markdown.startswith("---\nname: Nuwa Skill")
+ assert row.skill_markdown.startswith("---\nname: nuwa-skill")
def test_import_general_skill_package_upload_treats_single_markdown_as_skill_md() -> None:
- markdown = "---\nname: 单文件技能\nslug: single-file-skill\n---\n\n# 单文件技能\n"
+ markdown = "---\nname: 单文件技能\nslug: single-file-skill\ndescription: 单文件示例技能\n---\n\n# 单文件技能\n"
with _test_session() as db:
_seed_minimal_tenant(db)
@@ -601,7 +610,7 @@ def fake_json(url: str): # noqa: ANN001
def fake_download(url: str): # noqa: ANN001
content = {
- "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 目录天气\nslug: weather-dir\n---\n\n# 天气\n",
+ "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 目录天气\nslug: weather-dir\ndescription: 目录天气技能\n---\n\n# 天气\n",
"https://raw.githubusercontent.com/example/skill-pack/main/weather/scripts/run.py": "print('ok')\n",
"https://raw.githubusercontent.com/example/skill-pack/main/weather/data/cities.json": '{"北京":"101010100"}',
}.get(url)
@@ -640,7 +649,7 @@ def fake_download(url: str): # noqa: ANN001
"text/html",
)
content = {
- "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 页面天气\nslug: weather-page\n---\n\n# 天气\n",
+ "https://raw.githubusercontent.com/example/skill-pack/main/weather/SKILL.md": "---\nname: 页面天气\nslug: weather-page\ndescription: 页面天气技能\n---\n\n# 天气\n",
}.get(url)
if content is None:
raise AssertionError(f"unexpected url: {url}")
@@ -679,7 +688,7 @@ def test_import_clawhub_skill_uses_clawhub_download_api_for_page_url(monkeypatch
with ZipFile(package, "w") as archive:
archive.writestr(
"SKILL.md",
- "---\nname: weather\n---\n\n# 天气\n",
+ "---\nname: weather\ndescription: 天气技能\n---\n\n# 天气\n",
)
archive.writestr("scripts/weather.py", "print('weather')\n")
archive.writestr("references/weather_details.md", "# details\n")
@@ -718,7 +727,7 @@ def fake_download(url: str): # noqa: ANN001
def test_import_clawhub_skill_accepts_cli_slug(monkeypatch) -> None:
package = BytesIO()
with ZipFile(package, "w") as archive:
- archive.writestr("SKILL.md", "---\nname: weather\n---\n\n# 天气\n")
+ archive.writestr("SKILL.md", "---\nname: weather\ndescription: 天气技能\n---\n\n# 天气\n")
def fake_download(url: str): # noqa: ANN001
assert url == "https://wry-manatee-359.convex.site/api/v1/download?slug=maomao-weather"
@@ -767,7 +776,7 @@ def test_general_skill_archive_publish_and_delete_api(monkeypatch) -> None:
read_queries: list[str] = []
def fake_run(
- self, skill, query, model_config, user_id="enterprise_demo", max_attempts=5, event_sink=None
+ self, skill, query, model_config, user_id="enterprise_demo", max_attempts=5, event_sink=None, **kwargs
): # noqa: ANN001
captured_model_ids.append(model_config.id)
return {
@@ -816,6 +825,7 @@ def fake_read(self, skill, query, model_config, **kwargs): # noqa: ANN001
tenant_id="tenant_demo",
name="天气",
slug="weather-zh",
+ description="中国城市天气查询",
markdown=WEATHER_SKILL_MD,
),
db,
@@ -917,6 +927,7 @@ def test_non_overall_agent_delete_hides_general_skill_only_in_branch() -> None:
tenant_id="tenant_demo",
name="天气",
slug="weather-zh",
+ description="中国城市天气查询",
markdown=WEATHER_SKILL_MD,
),
db,
@@ -1217,6 +1228,7 @@ def fake_general_run( # noqa: ANN001
event_sink=None,
conversation_context=None,
memory_context=None,
+ output_sink=None,
):
runner_calls.append(query)
return GeneralSkillRunResponse(
@@ -1536,6 +1548,7 @@ def fake_run( # noqa: ANN001
event_sink=None,
conversation_context=None,
memory_context=None,
+ **kwargs,
):
received_contexts.append(conversation_context)
trace = [
@@ -1667,6 +1680,7 @@ def fake_run( # noqa: ANN001
event_sink=None,
conversation_context=None,
memory_context=None,
+ **kwargs,
):
trace = [
{"phase": "skill_loaded", "message": "已加载通用技能 中国城市天气", "slug": skill.slug},
@@ -1784,6 +1798,7 @@ def fake_run( # noqa: ANN001
event_sink=None,
conversation_context=None,
memory_context=None,
+ **kwargs,
):
runner_calls.append(query)
return GeneralSkillRunResponse(
@@ -2522,3 +2537,201 @@ def _test_session():
)
SQLModel.metadata.create_all(engine)
return Session(engine)
+
+
+# ---------- Agent Skills 规范对齐:归一化/发布校验/导出/allowed-tools 门控 ----------
+
+
+def _seed_standard_skill_tenant(db: Session) -> None:
+ _seed_minimal_tenant(db)
+ db.add(AgentProfile(id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True))
+ db.commit()
+
+
+def test_save_normalizes_skill_md_frontmatter_and_preserves_optional_fields() -> None:
+ with _test_session() as db:
+ _seed_standard_skill_tenant(db)
+ row = import_general_skill(
+ GeneralSkillImportRequest(
+ tenant_id="tenant_demo",
+ name="天气技能",
+ slug="weather-zh",
+ description="中国城市天气查询",
+ license="Apache-2.0",
+ compatibility="Requires network",
+ allowed_tools="Bash(curl:*) Read",
+ markdown="# 使用说明\n按城市查询天气。",
+ ),
+ db,
+ _admin_user(),
+ )
+ from app.general_skills.standard import split_frontmatter
+
+ metadata, body = split_frontmatter(row.skill_markdown)
+ # frontmatter 以表单字段重组:name 恒等于 slug;可选字段透传;正文保留
+ assert metadata["name"] == "weather-zh"
+ assert metadata["description"] == "中国城市天气查询"
+ assert metadata["license"] == "Apache-2.0"
+ assert metadata["compatibility"] == "Requires network"
+ assert metadata["allowed-tools"] == "Bash(curl:*) Read"
+ assert body == "# 使用说明\n按城市查询天气。"
+ # DTO 透出三个可选字段
+ assert row.license == "Apache-2.0"
+ assert row.compatibility == "Requires network"
+ assert row.allowed_tools == "Bash(curl:*) Read"
+
+
+def test_save_rejects_invalid_slug_and_published_without_description() -> None:
+ with _test_session() as db:
+ _seed_standard_skill_tenant(db)
+ try:
+ import_general_skill(
+ GeneralSkillImportRequest(
+ tenant_id="tenant_demo",
+ name="坏技能",
+ slug="Weather_ZH",
+ description="x",
+ markdown="# x",
+ ),
+ db,
+ _admin_user(),
+ )
+ raise AssertionError("expected invalid slug rejected")
+ except HTTPException as error:
+ assert error.status_code == 400
+ assert "连字符" in error.detail or "小写" in error.detail
+
+ try:
+ import_general_skill(
+ GeneralSkillImportRequest(
+ tenant_id="tenant_demo",
+ name="无描述技能",
+ slug="no-desc",
+ markdown="# x",
+ status="published",
+ ),
+ db,
+ _admin_user(),
+ )
+ raise AssertionError("expected published-without-description rejected")
+ except HTTPException as error:
+ assert error.status_code == 400
+ assert "description 必填" in error.detail
+
+ # 草稿允许无描述;发布时再校验
+ draft = import_general_skill(
+ GeneralSkillImportRequest(
+ tenant_id="tenant_demo",
+ name="草稿技能",
+ slug="draft-skill",
+ markdown="# x",
+ status="draft",
+ ),
+ db,
+ _admin_user(),
+ )
+ assert draft.status == "draft"
+ try:
+ publish_general_skill(draft.slug, "tenant_demo", db, current_user=_admin_user())
+ raise AssertionError("expected publish without description rejected")
+ except HTTPException as error:
+ assert error.status_code == 400
+ assert "发布失败" in error.detail and "description 必填" in error.detail
+
+
+def test_export_produces_standard_zip_roundtrip() -> None:
+ with _test_session() as db:
+ _seed_standard_skill_tenant(db)
+ row = import_general_skill(
+ GeneralSkillImportRequest(
+ tenant_id="tenant_demo",
+ name="天气技能",
+ slug="weather-zh",
+ description="中国城市天气查询",
+ markdown="# 使用说明",
+ files=[
+ {"path": "SKILL.md", "content": "# 使用说明", "mime_type": "text/markdown"},
+ {"path": "scripts/query.py", "content": "print('q')", "mime_type": "text/plain"},
+ ],
+ ),
+ db,
+ _admin_user(),
+ )
+ from app.api.general_skills import export_general_skill
+
+ response = export_general_skill(row.slug, "tenant_demo", db)
+ assert response.media_type == "application/zip"
+ # StreamingResponse 直接迭代 body_iterator 收集 zip 字节
+ import asyncio
+
+ async def _collect() -> bytes:
+ chunks = []
+ async for chunk in response.body_iterator:
+ chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
+ return b"".join(chunks)
+
+ data = asyncio.run(_collect())
+ with ZipFile(BytesIO(data)) as archive:
+ names = set(archive.namelist())
+ assert names == {"weather-zh/SKILL.md", "weather-zh/scripts/query.py"}
+ exported_md = archive.read("weather-zh/SKILL.md").decode("utf-8")
+ from app.general_skills.standard import split_frontmatter
+
+ metadata, body = split_frontmatter(exported_md)
+ # 根目录=slug,frontmatter name 与目录名一致(规范硬性要求)
+ assert metadata["name"] == "weather-zh"
+ assert metadata["description"] == "中国城市天气查询"
+ assert body == "# 使用说明"
+
+
+def test_materialized_workspace_skill_md_is_standardized() -> None:
+ """存量无 frontmatter 的技能:运行时物化到工作区的 SKILL.md 也是规范形态。"""
+ from app.general_skills.runner import _materialize_skill_package
+
+ legacy = GeneralSkill(
+ tenant_id="tenant_demo",
+ slug="legacy-skill",
+ name="存量技能",
+ description="存量描述",
+ skill_markdown="# 旧正文\n没有 frontmatter。",
+ skill_files_json=[{"path": "data/x.txt", "content": "v", "mime_type": "text/plain"}],
+ metadata_json={},
+ status="published",
+ )
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ _materialize_skill_package(legacy, Path(tmp))
+ materialized = (Path(tmp) / "SKILL.md").read_text(encoding="utf-8")
+ assert (Path(tmp) / "data" / "x.txt").exists()
+ from app.general_skills.standard import split_frontmatter
+
+ metadata, body = split_frontmatter(materialized)
+ assert metadata["name"] == "legacy-skill"
+ assert metadata["description"] == "存量描述"
+ assert body.startswith("# 旧正文")
+
+
+def test_allowed_tools_gates_bash_runtime() -> None:
+ from app.general_skills.runner import _bash_allowed, _runtime_languages
+
+ with_bash = SimpleNamespace(
+ slug="a",
+ description="d",
+ skill_markdown="---\nname: a\ndescription: d\nallowed-tools: Bash(git:*) Read\n---\n正文",
+ skill_files_json=[],
+ )
+ without_bash = SimpleNamespace(
+ slug="b",
+ description="d",
+ skill_markdown="---\nname: b\ndescription: d\nallowed-tools: Read\n---\n正文",
+ skill_files_json=[],
+ )
+ undeclared = SimpleNamespace(slug="c", description="d", skill_markdown="# 正文", skill_files_json=[])
+
+ assert _bash_allowed(with_bash) is True
+ assert _runtime_languages(with_bash) == ["bash", "python"]
+ assert _bash_allowed(without_bash) is False
+ assert _runtime_languages(without_bash) == ["python"]
+ # 未声明 allowed-tools:不限制
+ assert _bash_allowed(undeclared) is True
diff --git a/backend/tests/test_output_files.py b/backend/tests/test_output_files.py
new file mode 100644
index 00000000..550ef508
--- /dev/null
+++ b/backend/tests/test_output_files.py
@@ -0,0 +1,262 @@
+"""数字员工产出文件:净新增捕获、持久化、消息挂载、下载权限与临时目录清理。"""
+
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy.pool import StaticPool
+from sqlmodel import Session, SQLModel, create_engine
+
+from app.db.models import AgentOutputFile, ChatSession, Message, Tenant, User
+from app.general_skills.output_files import (
+ attach_session_outputs_to_message,
+ collect_workspace_outputs,
+ output_file_abs_path,
+ persist_output_files,
+)
+from app.general_skills.runner import GeneralSkillRunner
+from app.general_skills.schema import GeneralSkillExecutionPlan
+from app.security.auth import create_access_token, hash_password
+
+
+def _test_engine():
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ SQLModel.metadata.create_all(engine)
+ return engine
+
+
+def _seed_user(engine, *, user_id="user_web", role="member", with_tenant=True) -> User:
+ with Session(engine) as db:
+ if with_tenant and not db.get(Tenant, "tenant_demo"):
+ db.add(Tenant(id="tenant_demo", name="Demo"))
+ user = User(
+ id=user_id,
+ tenant_id="tenant_demo",
+ username=user_id,
+ password_hash=hash_password("x"),
+ role=role,
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ db.expunge(user)
+ return user
+
+
+def test_collect_workspace_outputs_excludes_inputs_and_runner(tmp_path: Path) -> None:
+ (tmp_path / "skill" / "scripts").mkdir(parents=True)
+ (tmp_path / "skill" / "SKILL.md").write_text("# doc", encoding="utf-8")
+ (tmp_path / "skill" / "scripts" / "run.py").write_text("print(1)", encoding="utf-8")
+ (tmp_path / "runner.py").write_text("x = 1", encoding="utf-8")
+ (tmp_path / "skill" / "report.pptx").write_bytes(b"pk-ppt")
+ (tmp_path / "out.txt").write_text("hello", encoding="utf-8")
+ (tmp_path / "empty.md").write_text("", encoding="utf-8")
+
+ produced = collect_workspace_outputs(tmp_path, {"SKILL.md", "scripts/run.py"})
+ by_name = {item["filename"]: item for item in produced}
+ # 输入文件与 runner 脚本被排除,空文件跳过;净新增全部捕获
+ assert set(by_name) == {"report.pptx", "out.txt"}
+ assert by_name["report.pptx"]["content"] == b"pk-ppt"
+ assert by_name["report.pptx"]["content_type"].endswith("presentationml.presentation")
+
+
+def test_collect_workspace_outputs_skips_oversize(tmp_path: Path) -> None:
+ big = tmp_path / "big.bin"
+ big.write_bytes(b"x" * (12 * 1024 * 1024 + 1))
+ assert collect_workspace_outputs(tmp_path, set()) == []
+
+
+def test_persist_and_attach_outputs(tmp_path: Path, monkeypatch) -> None:
+ engine = _test_engine()
+ _seed_user(engine)
+ monkeypatch.setattr("app.general_skills.output_files.paths", SimpleNamespace(user_data_dir=lambda: tmp_path))
+
+ with Session(engine) as db:
+ db.add(
+ ChatSession(
+ id="s_1",
+ tenant_id="tenant_demo",
+ user_id="user_web",
+ agent_id="agent_1",
+ channel="web",
+ )
+ )
+ db.add(
+ Message(
+ id="m_user",
+ tenant_id="tenant_demo",
+ session_id="s_1",
+ role="user",
+ content="生成 PPT",
+ )
+ )
+ db.commit()
+ # 产出在用户消息之后创建(真实时序:工具在用户提问后运行)
+ refs = persist_output_files(
+ db,
+ tenant_id="tenant_demo",
+ session_id="s_1",
+ agent_id="agent_1",
+ user_id="user_web",
+ source="general_skill",
+ files=[{"filename": "报告.pptx", "content": b"pk", "content_type": "application/zip"}],
+ )
+ assert len(refs) == 1
+ row = db.get(AgentOutputFile, refs[0]["file_id"])
+ assert row is not None
+ assert row.filename == "报告.pptx"
+ assert row.size == 2
+ # 本体落盘且路径在 outputs 根下
+ assert output_file_abs_path(row).read_bytes() == b"pk"
+
+ attached = attach_session_outputs_to_message(
+ db,
+ tenant_id="tenant_demo",
+ session_id="s_1",
+ user_message_id="m_user",
+ assistant_message_id="m_assistant",
+ )
+ assert len(attached) == 1
+ assert db.get(AgentOutputFile, refs[0]["file_id"]).message_id == "m_assistant"
+ # 幂等:再挂一次为空
+ assert (
+ attach_session_outputs_to_message(
+ db,
+ tenant_id="tenant_demo",
+ session_id="s_1",
+ user_message_id="m_user",
+ assistant_message_id="m_other",
+ )
+ == []
+ )
+
+
+def test_output_file_abs_path_rejects_escape(tmp_path: Path, monkeypatch) -> None:
+ monkeypatch.setattr("app.general_skills.output_files.paths", SimpleNamespace(user_data_dir=lambda: tmp_path))
+ row = AgentOutputFile(
+ tenant_id="t",
+ filename="x",
+ content_type="text/plain",
+ size=1,
+ sha256="0" * 64,
+ storage_path="../escape.txt",
+ )
+ with pytest.raises(FileNotFoundError):
+ output_file_abs_path(row)
+
+
+def test_download_output_file_permissions(tmp_path: Path, monkeypatch) -> None:
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+
+ import app.api.chat as chat_api
+ from app.db import get_session
+
+ engine = _test_engine()
+ owner = _seed_user(engine, user_id="user_web")
+ _seed_user(engine, user_id="user_other")
+ admin = _seed_user(engine, user_id="user_admin", role="admin")
+ monkeypatch.setattr("app.general_skills.output_files.paths", SimpleNamespace(user_data_dir=lambda: tmp_path))
+
+ app = FastAPI()
+ app.include_router(chat_api.router)
+
+ def override_get_session():
+ with Session(engine) as session:
+ yield session
+
+ app.dependency_overrides[get_session] = override_get_session
+ client = TestClient(app)
+
+ with Session(engine) as db:
+ refs = persist_output_files(
+ db,
+ tenant_id="tenant_demo",
+ session_id="s_1",
+ agent_id="agent_1",
+ user_id="user_web",
+ source="general_skill",
+ files=[{"filename": "out.pptx", "content": b"pk-ppt", "content_type": "application/zip"}],
+ )
+ file_id = refs[0]["file_id"]
+
+ def auth(user: User) -> dict[str, str]:
+ return {"Authorization": f"Bearer {create_access_token(user)}"}
+
+ ok = client.get(f"/api/chat/outputs/{file_id}/download?tenant_id=tenant_demo", headers=auth(owner))
+ assert ok.status_code == 200
+ assert ok.content == b"pk-ppt"
+
+ forbidden = client.get(
+ f"/api/chat/outputs/{file_id}/download?tenant_id=tenant_demo",
+ headers=auth(_fetch(engine, "user_other")),
+ )
+ assert forbidden.status_code == 403
+
+ admin_ok = client.get(f"/api/chat/outputs/{file_id}/download?tenant_id=tenant_demo", headers=auth(admin))
+ assert admin_ok.status_code == 200
+
+ missing = client.get("/api/chat/outputs/nope/download?tenant_id=tenant_demo", headers=auth(owner))
+ assert missing.status_code == 404
+
+
+def _fetch(engine, user_id: str) -> User:
+ with Session(engine) as db:
+ return db.get(User, user_id)
+
+
+def test_runner_execute_plan_collects_produced_and_cleans_up(monkeypatch) -> None:
+ """_execute_plan:工作区净新增进 produced,临时目录执行后删除(此前一直残留)。"""
+ monkeypatch.setattr(
+ "app.general_skills.runner.ensure_runtime_python", lambda: Path(sys.executable)
+ )
+ monkeypatch.setattr(
+ "app.general_skills.runner.runtime_environment",
+ lambda env: dict(env),
+ )
+ created_dirs: list[Path] = []
+
+ import app.general_skills.runner as runner_module
+
+ real_mkdtemp = runner_module.mkdtemp
+
+ def tracking_mkdtemp(prefix: str = "") -> str:
+ path = real_mkdtemp(prefix=prefix)
+ created_dirs.append(Path(path))
+ return path
+
+ monkeypatch.setattr(runner_module, "mkdtemp", tracking_mkdtemp)
+
+ skill = SimpleNamespace(
+ slug="demo",
+ name="demo",
+ description="d",
+ skill_markdown="# doc",
+ skill_files_json=[],
+ )
+ plan = GeneralSkillExecutionPlan(
+ runtime="python",
+ code=(
+ "from pathlib import Path\n"
+ "Path('result.txt').write_text('done', encoding='utf-8')\n"
+ "import json\n"
+ "print(json.dumps({'success': True}))"
+ ),
+ rationale="t",
+ expected_output="ok",
+ )
+ produced: list[dict] = []
+ runner = GeneralSkillRunner()
+ stdout, stderr, structured = runner._execute_plan(
+ skill, "q", plan, "u1", [], attempt=1, produced=produced
+ )
+ assert structured.get("success") is True
+ assert [item["filename"] for item in produced] == ["result.txt"]
+ assert produced[0]["content"] == b"done"
+ # 临时目录已清理
+ assert created_dirs and not created_dirs[0].exists()
diff --git a/backend/tests/test_resource_creator_metadata.py b/backend/tests/test_resource_creator_metadata.py
index a3ddcb52..de829e2d 100644
--- a/backend/tests/test_resource_creator_metadata.py
+++ b/backend/tests/test_resource_creator_metadata.py
@@ -79,6 +79,7 @@ def test_user_created_resource_metadata_is_bound_to_current_user() -> None:
agent_id=agent.id,
name="用户通用技能",
slug="user-general-skill",
+ description="用户通用技能描述",
markdown="# 用户通用技能\n\n用于测试 creator metadata。",
),
db=db,
@@ -133,6 +134,7 @@ def test_user_created_resource_metadata_is_bound_to_current_user() -> None:
agent_id=agent.id,
name="更新后的用户通用技能",
slug="user-general-skill",
+ description="用户通用技能描述",
original_slug="user-general-skill",
markdown="# 更新后的用户通用技能\n\n用于测试 creator metadata。",
),
diff --git a/backend/tests/test_unified_agent_prompt.py b/backend/tests/test_unified_agent_prompt.py
index 9ae51d8f..72abb0a1 100644
--- a/backend/tests/test_unified_agent_prompt.py
+++ b/backend/tests/test_unified_agent_prompt.py
@@ -148,6 +148,7 @@ def fake_execute_plan( # noqa: ANN001
trace,
event_sink=None,
attempt=1,
+ produced=None,
):
return '{"success": true}', "", {"success": True}
diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json
index 58359a82..646a09ea 100644
--- a/frontend-enterprise/src/i18n/en.json
+++ b/frontend-enterprise/src/i18n/en.json
@@ -2064,5 +2064,42 @@
"六": "Sat",
"…等": "…and ",
"还有": "+ ",
- "成员": "Member"
+ "成员": "Member",
+ "描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "Describe what this skill does and when to use it (regenerated from the form fields above on save)",
+ "在这里编写技能的使用说明(步骤、示例、边界情况)。正文会写入 SKILL.md,": "Write the skill instructions here (steps, examples, edge cases). This body goes into SKILL.md,",
+ "frontmatter 由上方表单生成:name 取 Slug,description 取描述。": "frontmatter is generated from the form above: name from Slug, description from Description.",
+ "许可证(license)": "License",
+ "可选,如 Apache-2.0、MIT": "Optional, e.g. Apache-2.0, MIT",
+ "运行环境要求(compatibility)": "Compatibility",
+ "可选,如 Requires network、Python 3.12+": "Optional, e.g. Requires network, Python 3.12+",
+ "预授权工具(allowed-tools)": "Allowed tools",
+ "可选,空格分隔,如 Bash(curl:*) Read;不含 Bash 则只用 Python 运行": "Optional, space-separated, e.g. Bash(curl:*) Read; without Bash it runs with Python only",
+ "导出技能包": "Export Skill Package",
+ "已导出技能包": "Skill package exported",
+ "导出技能包失败": "Failed to export skill package",
+ "如 scripts/run.py、references/guide.md": "e.g. scripts/run.py, references/guide.md",
+ "文件路径不合法(不能包含 .. 或空段)": "Invalid file path (must not contain .. or empty segments)",
+ "文件 {1} 已存在": "File {1} already exists",
+ "展开运行结果": "Expand run result",
+ "收起运行结果": "Collapse run result",
+ "description: 描述这个技能做什么、什么时候使用(保存时由上方表单字段重新生成)": "description: Describe what this skill does and when to use it (regenerated from the form fields above on save)",
+ "# 技能说明": "# Skill Instructions",
+ "新文件路径,如 scripts/run.py": "New file path, e.g. scripts/run.py",
+ "新文件路径": "New file path",
+ "scripts/run.py(脚本)": "scripts/run.py (script)",
+ "references/guide.md(参考文档)": "references/guide.md (reference doc)",
+ "assets/data.json(资源文件)": "assets/data.json (resource)",
+ "自定义路径…": "Custom path…",
+ "请输入或选择文件路径": "Enter or choose a file path",
+ "如 tools/helper.sh": "e.g. tools/helper.sh",
+ "自定义文件路径": "Custom file path",
+ "产出文件": "Output Files",
+ "产出文件 · {1} · 点击下载": "Output file · {1} · Click to download",
+ "{1} · 点击下载": "{1} · Click to download",
+ "下载失败": "Download failed",
+ "下载失败: {1}": "Download failed: {1}",
+ "下载 {1}": "Download {1}",
+ "· 点击下载": "· Click to download",
+ "删除排队消息": "Delete queued message",
+ "产出文件 ·": "Output file ·"
}
diff --git a/frontend-enterprise/src/lib/outputFiles.ts b/frontend-enterprise/src/lib/outputFiles.ts
new file mode 100644
index 00000000..69842f07
--- /dev/null
+++ b/frontend-enterprise/src/lib/outputFiles.ts
@@ -0,0 +1,38 @@
+import { notify } from '@/components/ui/app-toast';
+import { TENANT_ID } from '@/api/client';
+import { getEnterpriseAuthSession } from '@/auth';
+
+export type OutputFileRef = {
+ file_id: string;
+ filename: string;
+ size: number;
+ content_type: string;
+};
+
+export function formatFileSize(size: number): string {
+ if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
+ if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${size} B`;
+}
+
+/** 下载数字员工产出文件:Bearer 认证 fetch → blob → 触发浏览器保存。 */
+export async function downloadOutputFile(file: OutputFileRef) {
+ try {
+ const session = getEnterpriseAuthSession();
+ const apiBase = import.meta.env.VITE_API_BASE_URL || '';
+ const response = await fetch(
+ `${apiBase}/api/chat/outputs/${encodeURIComponent(file.file_id)}/download?tenant_id=${TENANT_ID}`,
+ { headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {} },
+ );
+ if (!response.ok) throw new Error('下载失败');
+ const blob = await response.blob();
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = file.filename;
+ link.click();
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ notify.error(error instanceof Error ? `下载失败: ${error.message}` : '下载失败');
+ }
+}
diff --git a/frontend-enterprise/src/pages/GeneralSkillsPage.tsx b/frontend-enterprise/src/pages/GeneralSkillsPage.tsx
index 7437c574..9a7f267e 100644
--- a/frontend-enterprise/src/pages/GeneralSkillsPage.tsx
+++ b/frontend-enterprise/src/pages/GeneralSkillsPage.tsx
@@ -16,7 +16,7 @@ import { Ban, CircleCheck, Copy, Users } from 'lucide-react';
import { ContextMenu } from 'radix-ui';
import { api, streamPost, TENANT_ID } from '../api/client';
-import { isEnterpriseAdmin, type EnterpriseAuthUser } from '../auth';
+import { getEnterpriseAuthSession, isEnterpriseAdmin, type EnterpriseAuthUser } from '../auth';
import AppHeader from '@/components/AppHeader';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { DataTable, type DataTableColumn } from '@/components/DataTable';
@@ -41,6 +41,7 @@ import {
import { Button as UIButton } from '@/components/ui/button';
import { notify } from '@/components/ui/app-toast';
import { cn } from '@/lib/utils';
+import { downloadOutputFile, formatFileSize, type OutputFileRef } from '@/lib/outputFiles';
import {
MENU_CONTENT_CLASS,
MENU_ITEM_CLASS,
@@ -87,9 +88,17 @@ const STATUS_BADGE: Record