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 { return result; } +type SkillFileTreeFile = { path: string; content: string; size?: number; mime_type?: string }; + +type SkillFileTreeFolder = { + name: string; + path: string; + folders: SkillFileTreeFolder[]; + files: SkillFileTreeFile[]; +}; + +/** 把平铺的文件路径列表组织成目录树(文件夹按名称排序,文件按路径排序)。 */ +function buildSkillFileTree(files: SkillFileTreeFile[]): SkillFileTreeFolder { + const root: SkillFileTreeFolder = { name: '', path: '', folders: [], files: [] }; + for (const file of files) { + const parts = file.path.split('/').filter(Boolean); + let node = root; + for (const part of parts.slice(0, -1)) { + const nextPath = node.path ? `${node.path}/${part}` : part; + let child = node.folders.find((folder) => folder.name === part); + if (!child) { + child = { name: part, path: nextPath, folders: [], files: [] }; + node.folders.push(child); + } + node = child; + } + node.files.push(file); + } + const sortFolder = (folder: SkillFileTreeFolder) => { + folder.folders.sort((a, b) => a.name.localeCompare(b.name)); + folder.files.sort((a, b) => a.path.localeCompare(b.path)); + folder.folders.forEach(sortFolder); + }; + sortFolder(root); + return root; +} + function applyMetadata( markdownText: string, setters: { @@ -1174,6 +1218,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | const [skillSlug, setSkillSlug] = useState(''); const [skillDescription, setSkillDescription] = useState(''); const [skillHomepage, setSkillHomepage] = useState(''); + const [skillLicense, setSkillLicense] = useState(''); + const [skillCompatibility, setSkillCompatibility] = useState(''); + const [skillAllowedTools, setSkillAllowedTools] = useState(''); const [skillFiles, setSkillFiles] = useState([ { path: 'SKILL.md', content: EMPTY_SKILL_MARKDOWN, size: EMPTY_SKILL_MARKDOWN.length, mime_type: 'text/markdown' }, ]); @@ -1191,6 +1238,78 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | const [saving, setSaving] = useState(false); const [dragActive, setDragActive] = useState(false); const [selectedFilePath, setSelectedFilePath] = useState('SKILL.md'); + const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); + const fileTree = useMemo(() => buildSkillFileTree(skillFiles), [skillFiles]); + + function toggleFolder(path: string) { + setCollapsedFolders((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + } + + // 树形文件面板:文件夹可折叠,文件显示 basename,路径缩进体现层级 + function renderFileNode(file: SkillFileTreeFile, depth: number) { + const name = file.path.split('/').pop() || file.path; + return ( + + + + + + + renameSkillFile(file)}> + + 重命名 + + deleteSkillFile(file)}> + + 删除 + + + + + ); + } + + function renderFolderNode(folder: SkillFileTreeFolder, depth: number): ReactNode { + const collapsed = collapsedFolders.has(folder.path); + return ( +
+ + {!collapsed && ( + <> + {folder.folders.map((child) => renderFolderNode(child, depth + 1))} + {folder.files.map((file) => renderFileNode(file, depth + 1))} + + )} +
+ ); + } const [editorScroll, setEditorScroll] = useState({ top: 0, left: 0 }); const [clawhubModalOpen, setClawhubModalOpen] = useState(false); const [clawhubSource, setClawhubSource] = useState(''); @@ -1353,6 +1472,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | slug: editingSlug || skillSlug.trim() || undefined, description: skillDescription.trim() || undefined, homepage: skillHomepage.trim() || undefined, + license: skillLicense.trim() || undefined, + compatibility: skillCompatibility.trim() || undefined, + allowed_tools: skillAllowedTools.trim() || undefined, markdown, files: skillFiles.length ? skillFiles : [{ path: 'SKILL.md', content: markdown }], status: 'published', @@ -1366,6 +1488,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillSlug(row.slug); setSkillDescription(row.description || ''); setSkillHomepage(row.homepage || ''); + setSkillLicense(row.license || ''); + setSkillCompatibility(row.compatibility || ''); + setSkillAllowedTools(row.allowed_tools || ''); setSkillFiles(row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md', content: row.skill_markdown }]); setSelectedFilePath((row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md' }])[0].path); setRows((current) => { @@ -1383,12 +1508,39 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | } } + // 导出标准 Agent Skills 包(zip):SKILL.md 为后端规范化版本,目录名即 slug + async function exportSkillPackage() { + if (!editingSlug) return; + try { + const session = getEnterpriseAuthSession(); + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch( + `${apiBase}/api/enterprise/general-skills/${encodeURIComponent(editingSlug)}/export?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 = `${editingSlug}.zip`; + link.click(); + URL.revokeObjectURL(url); + notify.success('已导出技能包'); + } catch (error) { + notify.error(error instanceof Error ? error.message : '导出技能包失败'); + } + } + function newSkill() { setMarkdown(EMPTY_SKILL_MARKDOWN); setSkillName(''); setSkillSlug(''); setSkillDescription(''); setSkillHomepage(''); + setSkillLicense(''); + setSkillCompatibility(''); + setSkillAllowedTools(''); setSkillFiles([{ path: 'SKILL.md', content: EMPTY_SKILL_MARKDOWN, size: EMPTY_SKILL_MARKDOWN.length, mime_type: 'text/markdown' }]); setSelectedFilePath('SKILL.md'); setEditingSlug(null); @@ -1405,6 +1557,9 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | setSkillSlug(row.slug); setSkillDescription(row.description || ''); setSkillHomepage(row.homepage || ''); + setSkillLicense(row.license || ''); + setSkillCompatibility(row.compatibility || ''); + setSkillAllowedTools(row.allowed_tools || ''); setSkillFiles(row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md', content: row.skill_markdown }]); setSelectedFilePath((row.skill_files?.length ? row.skill_files : [{ path: 'SKILL.md' }])[0].path); setSelectedSlug(row.slug); @@ -1688,16 +1843,52 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | } } - function addSkillFile() { - const base = 'notes.md'; - let candidate = base; + const [newFilePath, setNewFilePath] = useState(''); + const [showCustomPath, setShowCustomPath] = useState(false); + + function guessMimeType(path: string): string { + const lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown'; + return 'text/plain'; + } + + /** 预置路径冲突时自动追加序号:scripts/run.py → scripts/run-2.py。 */ + function uniqueFilePath(path: string): string { + if (!skillFiles.some((file) => file.path === path)) return path; + const slash = path.lastIndexOf('/'); + const dir = slash >= 0 ? path.slice(0, slash + 1) : ''; + const filename = slash >= 0 ? path.slice(slash + 1) : path; + const dot = filename.lastIndexOf('.'); + const stem = dot > 0 ? filename.slice(0, dot) : filename; + const ext = dot > 0 ? filename.slice(dot) : ''; let index = 2; + let candidate = `${dir}${stem}-${index}${ext}`; while (skillFiles.some((file) => file.path === candidate)) { - candidate = `notes-${index}.md`; index += 1; + candidate = `${dir}${stem}-${index}${ext}`; } - setSkillFiles((current) => [...current, { path: candidate, content: '', size: 0, mime_type: 'text/markdown' }]); + return candidate; + } + + // 新建文件:预置规范目录(scripts/references/assets)或自定义路径 + function addSkillFile(explicitPath?: string) { + let candidate = (explicitPath || newFilePath).trim().replace(/\\/g, '/').replace(/^\/+/, ''); + if (!candidate) { + notify.warning('请输入或选择文件路径'); + return; + } + if (candidate.split('/').some((part) => !part || part === '.' || part === '..')) { + notify.warning('文件路径不合法(不能包含 .. 或空段)'); + return; + } + candidate = uniqueFilePath(candidate); + setSkillFiles((current) => [ + ...current, + { path: candidate, content: '', size: 0, mime_type: guessMimeType(candidate) }, + ]); setSelectedFilePath(candidate); + setNewFilePath(''); + setShowCustomPath(false); } function deleteSelectedFile() { @@ -2032,6 +2223,11 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | 新建技能 )} + {!isNew && editingSlug && ( + void exportSkillPackage()}> + 导出技能包 + + )} {importMenu} {canManageCurrentScope && ( void importSkill()}> @@ -2078,6 +2274,30 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | placeholder="可选,参考文档或项目主页" /> + + setSkillLicense(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,如 Apache-2.0、MIT" + /> + + + setSkillCompatibility(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,如 Requires network、Python 3.12+" + /> + + + setSkillAllowedTools(event.target.value)} + disabled={!canManageCurrentScope} + placeholder="可选,空格分隔,如 Bash(curl:*) Read;不含 Bash 则只用 Python 运行" + /> + @@ -2175,48 +2395,72 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | 文件
- {skillFiles.map((file) => ( - - - - - - - renameSkillFile(file)}> - - 重命名 - - deleteSkillFile(file)}> - - 删除 - - - - - ))} + {fileTree.folders.map((folder) => renderFolderNode(folder, 0))} + {fileTree.files.map((file) => renderFileNode(file, 0))}
- - - 新建文件 - - - - 删除 - +
+ + + + + 新建文件 + + + + addSkillFile('scripts/run.py')} + > + scripts/run.py(脚本) + + addSkillFile('references/guide.md')} + > + references/guide.md(参考文档) + + addSkillFile('assets/data.json')} + > + assets/data.json(资源文件) + + setShowCustomPath(true)} + > + 自定义路径… + + + + + + 删除 + +
+ {showCustomPath && ( +
+ setNewFilePath(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') addSkillFile(); + if (event.key === 'Escape') setShowCustomPath(false); + }} + placeholder="如 tools/helper.sh" + aria-label="自定义文件路径" + autoFocus + /> + addSkillFile()}> + 创建 + +
+ )}
@@ -2297,6 +2541,50 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' |

+ {(() => { + const rawFiles = activeResult.structured_result?.output_files; + const outputFiles = Array.isArray(rawFiles) + ? rawFiles.filter( + (entry): entry is OutputFileRef => + Boolean( + entry && + typeof entry === 'object' && + typeof (entry as OutputFileRef).file_id === 'string' && + typeof (entry as OutputFileRef).filename === 'string', + ), + ) + : []; + if (outputFiles.length === 0) return null; + return ( +
+
产出文件
+
+ {outputFiles.map((file) => ( + + ))} +
+
+ ); + })()} +
执行流程
diff --git a/frontend-enterprise/src/pages/chat/components/MessageBubble.tsx b/frontend-enterprise/src/pages/chat/components/MessageBubble.tsx index a7374e36..b7e324b7 100644 --- a/frontend-enterprise/src/pages/chat/components/MessageBubble.tsx +++ b/frontend-enterprise/src/pages/chat/components/MessageBubble.tsx @@ -2,6 +2,7 @@ import StaffdeckIcon from '@/components/StaffdeckIcon'; import IconThumbUp from '@/assets/icons/thumb-up.svg?react'; import IconThumbDown from '@/assets/icons/thumb-down.svg?react'; import { cn } from '@/lib/utils'; +import { downloadOutputFile, formatFileSize, type OutputFileRef } from '@/lib/outputFiles'; import type { ChatAttachmentRead, ChatMessage, @@ -71,6 +72,21 @@ type MessageBubbleProps = { render: MessageRender; }; +/** 消息 metadata 中的产出文件引用(数字员工产出,如通用技能生成的 PPT)。 */ +function messageOutputFiles(item: ChatMessage): OutputFileRef[] { + const raw = item.metadata?.output_files; + if (!Array.isArray(raw)) return []; + return raw.filter( + (entry): entry is OutputFileRef => + Boolean( + entry && + typeof entry === 'object' && + typeof (entry as OutputFileRef).file_id === 'string' && + typeof (entry as OutputFileRef).filename === 'string', + ), + ); +} + export default function MessageBubble({ chat, item, render }: MessageBubbleProps) { const { toggleTrace, @@ -95,6 +111,7 @@ export default function MessageBubble({ chat, item, render }: MessageBubbleProps statusOnly, } = render; const queuedMessage = item.role === 'user' && item.metadata?.queued === true; + const outputFiles = messageOutputFiles(item); return (
@@ -169,6 +186,30 @@ export default function MessageBubble({ chat, item, render }: MessageBubbleProps
)} + {!statusOnly && outputFiles.length > 0 && ( +
+ {outputFiles.map((file) => ( + + ))} +
+ )} + {item.role === 'assistant' && citations.length > 0 && (
diff --git a/frontend-enterprise/src/types/index.ts b/frontend-enterprise/src/types/index.ts index 91e40e78..719536fa 100644 --- a/frontend-enterprise/src/types/index.ts +++ b/frontend-enterprise/src/types/index.ts @@ -266,6 +266,9 @@ export type GeneralSkillRead = { name: string; description?: string; homepage?: string; + license?: string; + compatibility?: string; + allowed_tools?: string; skill_markdown: string; skill_files: Array<{ path: string;