diff --git a/.gitignore b/.gitignore index a24da76..d7e0d23 100755 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,4 @@ node_modules/ # 建站模板必须完整入库(覆盖上面的 lib/ 与 AGENTS.md 规则),但依赖树除外 !docker/site-template/** docker/site-template/react-vite/node_modules/ +.jobtest/ diff --git a/document/en/architecture/frontend.md b/document/en/architecture/frontend.md index 6219ddc..02b7f1e 100644 --- a/document/en/architecture/frontend.md +++ b/document/en/architecture/frontend.md @@ -78,6 +78,7 @@ SSE streams bypass the JSON channel of `api.ts`; they are consumed directly from | `pageConfigStore` | Page configuration (branding, navigation, copy — drives white-labeling) | | `editionStore` | Consumer of the `/v1/meta/edition` probe: edition and license feature-bit map | | `modelCapabilitiesStore` | Main-model capability probing (thinking / vision etc.) | +| `sidebarOrderStore` | Manual drag-and-drop order of the sidebar chat list (persisted locally + in `users_shadow.metadata`) | ## Hooks diff --git a/document/zh-CN/architecture/frontend.md b/document/zh-CN/architecture/frontend.md index 4b07a47..6536ac7 100644 --- a/document/zh-CN/architecture/frontend.md +++ b/document/zh-CN/architecture/frontend.md @@ -78,6 +78,7 @@ SSE 流式不走 `api.ts` 的 JSON 通道,由 `hooks/useStreaming.ts` 直接 | `pageConfigStore` | 页面配置(品牌、导航、文案——驱动 white-label) | | `editionStore` | `/v1/meta/edition` 探针的消费端:版本与 license 能力位布尔表 | | `modelCapabilitiesStore` | 主模型能力探测(思考 / 视觉等) | +| `sidebarOrderStore` | 侧边栏对话列表的手动拖拽顺序(本地 + `users_shadow.metadata` 双层持久化) | ## Hooks diff --git a/src/backend/alembic/versions/ce_0006_job_runtime.py b/src/backend/alembic/versions/ce_0006_job_runtime.py new file mode 100644 index 0000000..a6d6c41 --- /dev/null +++ b/src/backend/alembic/versions/ce_0006_job_runtime.py @@ -0,0 +1,101 @@ +"""CE: add job runtime tables (jobs / job_items / job_calls) + +Revision ID: ce_0006 +Revises: ce_0005 +Create Date: 2026-08-17 + +作业编排运行时的持久化底座。CE 的 agent_factory 一直注册着 `run_job` 工具、 +工作流模式的提示词也在,但这三张表从来没进过 CE 迁移链——作业一提交就会在 +建台账那一步炸掉。这条迁移把 CE 补齐到与主仓一致。 + +台账(job_items)放 DB 而不是沙箱文件:沙箱池化复用会让新 job 读到旧 job 残留的 +账本,以 (job_id, item_key) 作复合主键从结构上避免。 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "ce_0006" +down_revision = "ce_0005" +branch_labels = None +depends_on = None + +JSONB = postgresql.JSONB(astext_type=sa.Text()).with_variant(sa.JSON(), "sqlite") + + +def upgrade() -> None: + op.create_table( + "jobs", + sa.Column("job_id", sa.String(64), primary_key=True), + sa.Column("user_id", sa.String(64), nullable=False), + sa.Column("chat_id", sa.String(64)), + sa.Column("name", sa.String(255), server_default=""), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("script_path", sa.Text(), server_default=""), + sa.Column("script_text", sa.Text(), server_default=""), + sa.Column("sandbox_session_id", sa.String(128)), + sa.Column("budget", JSONB), + sa.Column("usage", JSONB), + sa.Column("metadata", JSONB), + sa.Column("error_message", sa.Text()), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()), + sa.Column("started_at", sa.TIMESTAMP(timezone=True)), + sa.Column("completed_at", sa.TIMESTAMP(timezone=True)), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["user_id"], ["users_shadow.user_id"], ondelete="CASCADE"), + sa.CheckConstraint( + "status IN ('pending','running','paused','completed','failed','cancelled','interrupted')", + name="jobs_status_check", + ), + ) + op.create_index("idx_jobs_user_id", "jobs", ["user_id"]) + op.create_index("idx_jobs_chat_status", "jobs", ["chat_id", "status"]) + op.create_index("idx_jobs_status", "jobs", ["status"]) + + op.create_table( + "job_items", + sa.Column("job_id", sa.String(64), primary_key=True, nullable=False), + sa.Column("item_key", sa.String(128), primary_key=True, nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("payload", JSONB), + sa.Column("result", JSONB), + sa.Column("review", JSONB), + sa.Column("attempts", sa.Integer(), server_default="0"), + sa.Column("error", sa.Text()), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["job_id"], ["jobs.job_id"], ondelete="CASCADE"), + sa.CheckConstraint( + "status IN ('pending','running','done','not_found','failed','needs_review')", + name="job_items_status_check", + ), + ) + op.create_index("idx_job_items_job_status", "job_items", ["job_id", "status"]) + + op.create_table( + "job_calls", + sa.Column("call_id", sa.String(64), primary_key=True), + sa.Column("job_id", sa.String(64), nullable=False), + sa.Column("item_key", sa.String(128)), + sa.Column("seq", sa.Integer(), server_default="0"), + sa.Column("prompt_hash", sa.String(64)), + sa.Column("model", sa.String(128)), + sa.Column("tokens", JSONB), + sa.Column("duration_ms", sa.BigInteger(), server_default="0"), + sa.Column("status", sa.String(20), server_default="running"), + sa.Column("error", sa.Text()), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["job_id"], ["jobs.job_id"], ondelete="CASCADE"), + ) + op.create_index("idx_job_calls_job_id", "job_calls", ["job_id"]) + + +def downgrade() -> None: + op.drop_index("idx_job_calls_job_id", table_name="job_calls") + op.drop_table("job_calls") + op.drop_index("idx_job_items_job_status", table_name="job_items") + op.drop_table("job_items") + op.drop_index("idx_jobs_status", table_name="jobs") + op.drop_index("idx_jobs_chat_status", table_name="jobs") + op.drop_index("idx_jobs_user_id", table_name="jobs") + op.drop_table("jobs") diff --git a/src/backend/api/app.py b/src/backend/api/app.py index 7676358..80d3c68 100644 --- a/src/backend/api/app.py +++ b/src/backend/api/app.py @@ -56,6 +56,8 @@ async def lifespan(app: FastAPI): await _startup_local_sidecars() await _startup_recover_chat_runs() await _startup_resume_loops() + await _startup_recover_jobs() + await _startup_orphan_job_reaper() await _startup_stale_run_reaper() await _startup_warm_sandbox_pool() await _startup_idle_session_reaper() @@ -78,6 +80,7 @@ async def lifespan(app: FastAPI): yield # ── shutdown ── await _shutdown_stale_run_reaper() + await _shutdown_orphan_job_reaper() await _shutdown_kb_wiki_worker() await _shutdown_channel_manager() await _shutdown_datasource_sidecar_recovery() @@ -472,6 +475,36 @@ async def _startup_resume_loops(): logger.warning("[startup] autonomous loop resume failed: %s", exc) +async def _startup_recover_jobs(): + """作业编排:进程重启后活跃 job 全是孤儿(进程内没有 driver task),归位 interrupted。 + + 与 chat_run 不同的是,job 的工作项台账在 DB —— 归位后 ``run_job action=resume`` + 可直接断点续跑,已完成的项不会重做。 + """ + try: + from orchestration import job_runtime + + count = await job_runtime.resume_running_jobs() + if count: + logger.info("[startup] orphan jobs recovered: %d", count) + except Exception as exc: + logger.warning("[startup] job orphan recovery failed: %s", exc) + + +async def _startup_orphan_job_reaper(): + """周期性对账失联的批量作业(驱动没了就没人管,护栏跟着一起没)。""" + try: + import asyncio + + from orchestration import job_runtime + + task = asyncio.create_task(job_runtime.run_job_reaper_loop()) + app.state.orphan_job_reaper_task = task + logger.info("[startup] job orphan reaper started") + except Exception as exc: + logger.warning("[startup] job orphan reaper start failed: %s", exc) + + async def _startup_stale_run_reaper(): """Periodically reap zombie chat_runs stuck in running (fallback behind the watchdog).""" try: @@ -497,6 +530,17 @@ async def _shutdown_stale_run_reaper(): await task +async def _shutdown_orphan_job_reaper(): + import asyncio + import contextlib + + task = getattr(app.state, "orphan_job_reaper_task", None) + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def _shutdown_channel_manager(): try: from core.channels.manager import get_manager diff --git a/src/backend/api/routes/v1/__init__.py b/src/backend/api/routes/v1/__init__.py index a37ddcb..83e288d 100644 --- a/src/backend/api/routes/v1/__init__.py +++ b/src/backend/api/routes/v1/__init__.py @@ -32,6 +32,11 @@ ("myspace_folders", "router"), ("batch", "router"), ("internal_batch", "router"), + # 作业编排:沙箱里的作业脚本经 internal_jobs 回调后端(建台账、派子智能体、报终态), + # jobs 是给人看的只读进度视图(输入框上方那条状态条)。两条都必须在——CE 的 + # agent_factory 已经注册了 run_job 工具,少了回调路由,作业会在第一发回调 404 当场死掉。 + ("internal_jobs", "router"), + ("jobs", "router"), ("internal_sites", "router"), ("projects", "router"), ("api_keys", "router"), diff --git a/src/backend/api/routes/v1/chats.py b/src/backend/api/routes/v1/chats.py index 38090e0..76d4495 100644 --- a/src/backend/api/routes/v1/chats.py +++ b/src/backend/api/routes/v1/chats.py @@ -94,6 +94,18 @@ class UpdateChatRequest(BaseModel): metadata: Optional[dict] = Field(None, description="Additional metadata") +class UpdateSidebarOrderRequest(BaseModel): + """Request model for persisting the sidebar's manual (drag-and-drop) order.""" + + order: List[str] = Field(default_factory=list, description="Chat ids in manual order") + + +# Sidebar manual order lives in users_shadow.metadata (no schema migration needed): +# it is a pure UI preference of "which chat sits where", not session state. +SIDEBAR_ORDER_KEY = "sidebar_chat_order" +SIDEBAR_ORDER_MAX = 500 + + def _session_to_dict(s) -> dict: """Convert a ChatSession ORM object to the edition-neutral API response.""" return { @@ -287,6 +299,53 @@ async def list_pending_confirms( return success_response(data={"items": items}) +def _dedup_id_list(raw: Optional[list]) -> List[str]: + """Clean + de-duplicate an id list, preserving first-seen order.""" + seen: set = set() + out: List[str] = [] + for cid in _clean_id_list(raw): + if cid in seen: + continue + seen.add(cid) + out.append(cid) + return out + + +@router.get("/sidebar-order", summary="获取侧边栏手动排序") +async def get_sidebar_order( + user: UserContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """侧边栏对话列表的手动拖拽顺序(chat_id 序列)。 + + 没拖过的账号返回空数组——前端据此退回「置顶 + 最近更新」默认排序。 + + 注意:本路由必须声明在 ``GET /{chat_id}`` **之前**,否则会被 path 参数 + 路由吞掉(FastAPI 按声明顺序匹配)。 + """ + user_settings = UserService(db).get_user_settings(str(user.user_id)) + order = _dedup_id_list(user_settings.get(SIDEBAR_ORDER_KEY))[:SIDEBAR_ORDER_MAX] + return success_response(data={"order": order}) + + +@router.put("/sidebar-order", summary="保存侧边栏手动排序") +async def update_sidebar_order( + request: UpdateSidebarOrderRequest, + user: UserContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """整表覆盖写入手动顺序;空数组 = 恢复默认排序。 + + 不校验 chat_id 是否存在:顺序表是纯 UI 偏好,已删除的会话留在表里也只是 + 查不到对应项而被忽略,反倒省掉一次全表校验。超出上限的尾部直接截断。 + """ + order = _dedup_id_list(request.order)[:SIDEBAR_ORDER_MAX] + UserService(db).update_user_metadata( + user_id=str(user.user_id), patch={SIDEBAR_ORDER_KEY: order} + ) + return success_response(data={"order": order}) + + @router.get("/{chat_id}", summary="获取会话详情") async def get_chat( chat_id: str, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db) @@ -788,6 +847,7 @@ def _build_ctx( "plugin_name": request.plugin_name, "plan_chat": request.plan_chat, "batch_chat": request.batch_chat, + "workflow_chat": request.workflow_chat, "disable_batch_plan": request.disable_batch_plan, **project_ctx, } @@ -803,6 +863,7 @@ def _ensure_chat_session( agent_name: Optional[str] = None, plan_chat: bool = False, batch_chat: bool = False, + workflow_chat: bool = False, project_id: Optional[str] = None, ): extra_data: Dict[str, Any] = {"chat_id": chat_id} @@ -814,6 +875,8 @@ def _ensure_chat_session( extra_data["plan_chat"] = True if batch_chat: extra_data["batch_chat"] = True + if workflow_chat: + extra_data["workflow_chat"] = True # Prefer the edition-aware access resolver before creating a session. pair = chat_service.get_session_with_access(chat_id, user_id) if pair is not None: @@ -850,6 +913,9 @@ def _ensure_chat_session( if batch_chat and not existing_meta.get("batch_chat"): merged["batch_chat"] = True dirty = True + if workflow_chat and not existing_meta.get("workflow_chat"): + merged["workflow_chat"] = True + dirty = True if dirty: chat_service.update_session(chat_id, user_id, {"extra_data": merged}) return session @@ -928,6 +994,7 @@ async def chat_send( agent_name=_agent_name, plan_chat=request.plan_chat, batch_chat=request.batch_chat, + workflow_chat=request.workflow_chat, project_id=request.project_id, ) # Link orphan artifacts (uploaded before session existed) to this chat @@ -1129,6 +1196,7 @@ def _read_messages(): agent_name=_agent_name_stream, plan_chat=request.plan_chat, batch_chat=request.batch_chat, + workflow_chat=request.workflow_chat, project_id=request.project_id, ) diff --git a/src/backend/api/routes/v1/internal_jobs.py b/src/backend/api/routes/v1/internal_jobs.py new file mode 100644 index 0000000..db48f6e --- /dev/null +++ b/src/backend/api/routes/v1/internal_jobs.py @@ -0,0 +1,462 @@ +"""作业脚本的回调端点 —— 沙箱里的作业脚本经此请求后端。 + +这是「模型凭据不进沙箱」的关键:脚本自己没有任何 key,需要一次模型判断时就带 job token +POST 到这里,由后端代持凭据派出子智能体。token 能做的事只有三件——派子作业、读写本 job +的台账、报进度;换不到模型端点、碰不到别的 job、碰不到任何用户数据接口。 + +端点: + POST /v1/internal/jobs/{job_id}/agent 派一个无历史子智能体,同步返回结构化结果 + POST /v1/internal/jobs/{job_id}/ledger seed / pending / update / stats / budget + POST /v1/internal/jobs/{job_id}/log 进度上报与生命周期(running/completed/failed) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import time +from typing import Any, Dict, List, Optional, Union + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from core.db.engine import SessionLocal +from core.infra.responses import success_response +from core.services.job_service import JobService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1/internal/jobs", tags=["internal-jobs"]) + +# 每个 job 的在途子作业闸门(进程内):脚本可能一次打出 concurrency 个并发回调, +# 这里按 job 预算收口,全局闸再防单会话吃光后端。 +_job_gates: Dict[str, asyncio.Semaphore] = {} +_GLOBAL_GATE = asyncio.Semaphore(24) + + +def _gate(job_id: str, concurrency: int) -> asyncio.Semaphore: + gate = _job_gates.get(job_id) + if gate is None: + gate = asyncio.Semaphore(max(1, min(int(concurrency or 8), 16))) + _job_gates[job_id] = gate + return gate + + +# ── 请求体 ──────────────────────────────────────────────────────────── + + +class AgentBody(BaseModel): + # 字段名叫 schema_ 是为了避开 BaseModel 的保留名,对外仍是 "schema" + model_config = ConfigDict(populate_by_name=True) + + prompt: str = "" + schema_: Optional[Dict[str, Any]] = Field(default=None, alias="schema") + tools: List[str] = [] + model: Optional[str] = None + max_attempts: int = 2 + # 必须收 int:业务主键十有八九是行号/序号,脚本自然会写 item_key=it["seq"]。 + # Pydantic v2 不做 int→str 强转,只声明 str 会让整轮作业被 422 挡在门外—— + # 事故里 568 项全军覆没、模型一次都没被调用,就是这个类型洁癖的代价。 + item_key: Optional[Union[str, int]] = None + + @field_validator("item_key") + @classmethod + def _key_to_str(cls, v: Optional[Union[str, int]]) -> Optional[str]: + return None if v is None or v == "" else str(v) + + +class LedgerBody(BaseModel): + op: str + items: Optional[List[Dict[str, Any]]] = None + # 同 AgentBody.item_key:主键常常是整数序号,别让类型把回写挡在门外 + key: Optional[Union[str, int]] = None + status: Optional[str] = None + limit: Optional[int] = None + result: Optional[Any] = None + review: Optional[Dict[str, Any]] = None + error: Optional[str] = None + bump_attempts: bool = False + + +class LogBody(BaseModel): + message: Optional[str] = None + lifecycle: Optional[str] = None + error: Optional[str] = None + + +# ── 鉴权 ────────────────────────────────────────────────────────────── + + +def _auth(job_id: str, token: Optional[str]): + """token 校验 → 返回 (user_id, chat_id, budget, allowed_tools, model_params)。 + + 终态 job 一律拒绝:沙箱会被后续会话复用,旧脚本不得借尸还魂。 + """ + if not token: + raise HTTPException(status_code=401, detail="缺少 job token") + with SessionLocal() as db: + job = JobService(db).verify_token(job_id, token) + if job is None: + raise HTTPException(status_code=403, detail="job token 无效或作业已结束") + start_params = dict((job.extra_data or {}).get("start_params") or {}) + return { + "user_id": job.user_id, + "chat_id": job.chat_id, + "budget": dict(job.budget or {}), + "allowed_tools": list(start_params.get("allowed_tools") or []), + "model_name": start_params.get("model_name"), + "model_provider_id": start_params.get("model_provider_id"), + } + + +# ── 结构化输出 ───────────────────────────────────────────────────────── + + +def _json_candidates(text: str): + if not text: + return + yield text.strip() + m = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL) + if m: + yield m.group(1).strip() + s, e = text.find("{"), text.rfind("}") + if s != -1 and e != -1 and e > s: + yield text[s : e + 1] + s, e = text.find("["), text.rfind("]") + if s != -1 and e != -1 and e > s: + yield text[s : e + 1] + + +def _parse_schema(text: str, schema: Dict[str, Any]) -> Optional[Any]: + """宽进严出:先尽力从自由文本里抠出 JSON,再按 schema 校验。""" + for cand in _json_candidates(text): + try: + obj = json.loads(cand) + except (json.JSONDecodeError, ValueError): + continue + try: + import jsonschema + + jsonschema.validate(obj, schema) + except ImportError: + required = list((schema or {}).get("required") or []) + if isinstance(obj, dict) and all(k in obj for k in required): + return obj + continue + except Exception: + continue + return obj + return None + + +def _schema_hint(schema: Dict[str, Any]) -> str: + return ( + "\n\n【输出格式(强制)】只输出一个 JSON 对象,不要任何解释文字、不要代码块以外的内容。" + "必须严格满足以下 JSON Schema:\n" + + json.dumps(schema, ensure_ascii=False)[:2000] + ) + + +# ── 轻量子作业执行 ────────────────────────────────────────────────────── + + +# 工具级基础设施故障的特征串。命中时这一项不能被当成业务结论("查无"), +# 必须作为可重试的失败抛回脚本——否则环境故障会被固化成数据。 +# (实测踩过:搜索配额打爆后 337/354 项被写成"未查询到公开展品信息"并标 done。) +_INFRA_FAILURE_MARKERS = ( + "用量限制", + "quota", + "rate limit", + "too many requests", + "调用失败", + "connection error", + "timeout", + "timed out", + "503", + "502", + "429", +) + + +def _looks_like_infra_failure(text: str) -> bool: + low = (text or "").lower() + return any(m in low for m in _INFRA_FAILURE_MARKERS) + + +async def _run_light_agent( + prompt: str, + *, + tools: List[str], + user_id: str, + model_name: Optional[str], + model_provider_id: Optional[str], +) -> tuple[str, bool]: + """无历史、无沙箱、无技能、无子智能体的一次性子作业。 + + 这正是「去掉共享上下文 / 流式回灌 / 历史累积」后的执行路径:每次调用的上下文只有 + 这一条 prompt,成本与工作项总数无关。 + """ + from core.llm.agent_factory import create_agent_executor + from core.llm.mcp_manager import close_clients + from orchestration.streaming import StreamingAgent + + agent, clients = await create_agent_executor( + enabled_skill_ids=[], + enabled_mcp_ids=list(tools or []), + enabled_kb_ids=[], + disable_tools=not tools, + chat_mode="fast", + isolated=True, + allow_bash=False, + read_only=True, + max_iters=6, + model_name=model_name, + model_provider_id=model_provider_id, + current_user_id=user_id, + top_level_chat=False, + ) + text = "" + tool_calls = 0 + tool_failures = 0 + infra_failure = False + try: + sa = StreamingAgent(agent, clients) + async for et, payload in sa.stream( + [{"role": "user", "content": prompt}], + {"user_id": user_id, "enable_thinking": False, "chat_mode": "fast"}, + ): + if et == "text_delta": + text += payload + elif et == "tool_result": + tool_calls += 1 + content = str((payload or {}).get("content") or "") + failed = str((payload or {}).get("status") or "") in ("error", "denied", "interrupted") + if failed or _looks_like_infra_failure(content): + tool_failures += 1 + if _looks_like_infra_failure(content): + infra_failure = True + elif et == "error": + logger.warning("[job-agent] sub-agent error: %s", payload) + infra_failure = True + break + finally: + await close_clients(clients) + + # 声明了工具却每一次都失败 → 这一轮没有任何证据,结论不可信 + if tools and tool_calls > 0 and tool_failures == tool_calls: + infra_failure = True + # 模型自己在正文里如实说了"工具调用失败/超限",同样不能当业务结论 + if _looks_like_infra_failure(text): + infra_failure = True + return text, infra_failure + + +# ── 端点 ────────────────────────────────────────────────────────────── + + +@router.post("/{job_id}/agent") +async def job_agent( + job_id: str, + body: AgentBody, + x_job_token: Optional[str] = Header(None, alias="X-Job-Token"), +): + ctx = _auth(job_id, x_job_token) + + with SessionLocal() as db: + left = JobService(db).budget_left(job_id) + if left["calls_left"] <= 0: + raise HTTPException(status_code=429, detail="作业子调用预算已用尽") + if left["seconds_left"] <= 0: + raise HTTPException(status_code=429, detail="作业墙钟预算已用尽") + + # 工具面 = 脚本声明 ∩ 发起会话的工具面。脚本不能提权到会话本身没有的能力。 + declared = [t for t in (body.tools or []) if isinstance(t, str)] + allowed = ctx["allowed_tools"] + tools = [t for t in declared if t in allowed] if allowed else [] + dropped = sorted(set(declared) - set(tools)) + if dropped: + logger.info("[job-agent] job=%s dropped out-of-scope tools=%s", job_id, dropped) + + prompt = body.prompt or "" + schema = body.schema_ if isinstance(body.schema_, dict) else None + if schema: + prompt = prompt + _schema_hint(schema) + + attempts = max(1, min(int(body.max_attempts or 2), 4)) + started = time.monotonic() + last_text = "" + result: Any = None + + # 调用即落账(服务端安全网):脚本自己的 ledger.update 才是权威,但脚本可能压根没写回 + # ——历史事故正是如此:568 项全程 pending、调用真在跑、成果全丢,外面看不出任何异常。 + # 所以只要带了 item_key,服务端先把该项标成 running 并累加 attempts;脚本随后的回写会 + # 覆盖它(顺序天然如此),进度条与中途唤醒因此永远有真实分母。 + if body.item_key: + try: + with SessionLocal() as db: + JobService(db).update_item( + job_id, body.item_key, status="running", bump_attempts=True + ) + except Exception: # noqa: BLE001 —— 落账失败绝不能挡住真正的作业 + logger.warning("[job-agent] job=%s pre-mark failed key=%s", job_id, body.item_key) + + gate = _gate(job_id, left.get("concurrency", 8)) + infra_failed = False + async with _GLOBAL_GATE, gate: + for i in range(attempts): + try: + last_text, infra_failed = await _run_light_agent( + prompt if i == 0 else prompt + "\n\n上一次输出无法解析为合法 JSON,请只输出 JSON。", + tools=tools, + user_id=ctx["user_id"], + model_name=body.model or ctx["model_name"], + model_provider_id=ctx["model_provider_id"], + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[job-agent] job=%s attempt=%d failed: %s", job_id, i, exc) + last_text, infra_failed = "", True + continue + # 工具全挂/配额打爆:这一轮没有任何证据,**不能**把它当成业务结论回给脚本, + # 否则"搜不到"会被固化进数据。抛可重试错误,让该项留在台账里待续跑。 + if infra_failed: + logger.warning("[job-agent] job=%s attempt=%d infra failure", job_id, i) + continue + if not schema: + result = last_text + break + parsed = _parse_schema(last_text, schema) + if parsed is not None: + result = parsed + break + + duration_ms = int((time.monotonic() - started) * 1000) + ok = result is not None + # 归因必须分流:工具挂了和模型输出不合 schema 是两回事,共用一条文案会把排障线索抹平 + # (历史事故:17 次失败全记「结构化输出解析失败」,实际分不清是搜索不通还是模型不听话)。 + reason = ( + None + if ok + else ("工具不可用/配额/超时,本项未取得证据" if infra_failed else "结构化输出解析失败") + ) + with SessionLocal() as db: + svc = JobService(db) + svc.add_usage(job_id, calls=1) + svc.record_call( + job_id, + item_key=body.item_key, + prompt=prompt, + model=body.model or ctx["model_name"], + duration_ms=duration_ms, + status="success" if ok else "failed", + error=reason, + ) + # 结账:脚本的回写在这之后发生、会覆盖这里的判断——这只是脚本没回写时的兜底真相 + if body.item_key: + try: + if ok: + svc.update_item( + job_id, + body.item_key, + status="done", + result=result if isinstance(result, dict) else {"text": result}, + ) + else: + svc.update_item(job_id, body.item_key, status="failed", error=reason) + except Exception: # noqa: BLE001 + logger.warning("[job-agent] job=%s post-mark failed key=%s", job_id, body.item_key) + if not ok: + if infra_failed: + # 503 = 可重试:SDK 会退避重试,仍失败则该项记 failed 留在台账等续跑, + # 而不是被写成"查无"。环境故障绝不能变成业务结论。 + raise HTTPException( + status_code=503, + detail=f"工具不可用(配额/连接/超时),本项未取得证据:{(last_text or '')[:200]}", + ) + raise HTTPException( + status_code=422, + detail=f"子作业未产出合法结果({attempts} 次尝试):{(last_text or '')[:200]}", + ) + return success_response(data=result) + + +@router.post("/{job_id}/ledger") +async def job_ledger( + job_id: str, + body: LedgerBody, + x_job_token: Optional[str] = Header(None, alias="X-Job-Token"), +): + _auth(job_id, x_job_token) + op = (body.op or "").strip() + + with SessionLocal() as db: + svc = JobService(db) + if op == "seed": + return success_response(data=svc.seed(job_id, body.items or [])) + if op == "pending": + return success_response( + data=svc.pending(job_id, status=body.status or "pending", limit=body.limit) + ) + if op == "update": + if body.key in (None, ""): + raise HTTPException(status_code=400, detail="update 需要 key") + ok = svc.update_item( + job_id, + str(body.key), + status=body.status, + result=body.result, + review=body.review, + error=body.error, + bump_attempts=body.bump_attempts, + ) + # ok=False 表示台账里根本没有这个 key(多半是漏了 seed)。这个信号必须回给脚本: + # 事故中脚本直连 job.map 没建台账,568 次回写全部打空,外面看到的只有"进度 0"。 + return success_response(data={"ok": ok, "known_key": ok}) + if op == "stats": + stats = svc.stats(job_id) + # progressed:本次统计相对上次是否有推进,脚本据此判停滞(不必自己记账) + prev = int((svc.get(job_id).extra_data or {}).get("last_settled", -1)) + stats["progressed"] = stats["settled"] != prev + job = svc.get(job_id) + meta = dict(job.extra_data or {}) + meta["last_settled"] = stats["settled"] + job.extra_data = meta + from sqlalchemy.orm.attributes import flag_modified + + flag_modified(job, "extra_data") + db.commit() + return success_response(data=stats) + if op == "budget": + return success_response(data=svc.budget_left(job_id)) + + raise HTTPException(status_code=400, detail=f"未知 ledger op: {op}") + + +@router.post("/{job_id}/log") +async def job_log( + job_id: str, + body: LogBody, + x_job_token: Optional[str] = Header(None, alias="X-Job-Token"), +): + ctx = _auth(job_id, x_job_token) + + if body.lifecycle: + with SessionLocal() as db: + svc = JobService(db) + if body.lifecycle == "running": + svc.mark_running(job_id) + elif body.lifecycle in ("completed", "failed"): + svc.finish(job_id, body.lifecycle, error=body.error) + return success_response(data={"ok": True}) + + msg = (body.message or "").strip() + if msg: + logger.info("[job %s] %s", job_id, msg[:500]) + try: + from orchestration.job_runtime import _emit_progress + + _emit_progress(ctx["chat_id"], msg[:200]) + except Exception: # noqa: BLE001 + pass + return success_response(data={"ok": True}) diff --git a/src/backend/api/routes/v1/jobs.py b/src/backend/api/routes/v1/jobs.py new file mode 100644 index 0000000..613bf62 --- /dev/null +++ b/src/backend/api/routes/v1/jobs.py @@ -0,0 +1,88 @@ +"""批量作业的**用户可见**只读视图 —— 喂输入框上方那条作业状态条。 + +与 ``internal_jobs.py`` 划清界限:那套端点是给沙箱里的作业脚本用的(job token 鉴权, +可写台账、可派子作业);这里是给**人**看的(登录用户鉴权,只读、只给聚合数), +两者永远不共用鉴权,脚本拿到的 token 也永远换不到这里的数据。 + +为什么必须有它:工作流模式下作业跑在后台,主对话是安静的——不给一个零成本的进度视图, +用户根本无法判断"到底有没有在跑",只能干等或反复追问(而每次追问都是一轮真实推理)。 +状态条走这个接口轮询,代价是一次 SQL 聚合,和模型完全无关。 +""" + +from typing import List, Optional + +from core.auth.backend import UserContext, get_current_user +from core.db.engine import SessionLocal +from core.db.models import Job +from core.infra.logging import get_logger +from core.infra.responses import success_response +from core.services.job_service import JobService +from fastapi import APIRouter, Depends, HTTPException, Query + +logger = get_logger(__name__) + +router = APIRouter(prefix="/v1/jobs", tags=["Jobs"]) + +_LIVE_STATUSES = ("pending", "running") + + +def _view(svc: JobService, job: Job) -> dict: + """一条作业的对外形状 —— 只给聚合,逐项明细一律不出这个接口。 + + 明细走 ``run_job(action='export')`` 落沙箱文件:几百上千项读进浏览器(或对话) + 毫无意义,还会把上下文撑爆。 + """ + stats = svc.stats(job.job_id) + return { + "job_id": job.job_id, + "chat_id": job.chat_id, + "name": job.name or "", + "status": job.status, + "stats": stats, + "usage": dict(job.usage or {}), + "budget_left": svc.budget_left(job.job_id), + "error": job.error_message or "", + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + } + + +@router.get("", summary="列出作业(按会话过滤,供状态条轮询)") +async def list_jobs( + chat_id: Optional[str] = Query(None, description="只看这个会话的作业"), + live: bool = Query(True, description="true 只返回未结束的作业(状态条默认口径)"), + limit: int = Query(20, ge=1, le=100), + user: UserContext = Depends(get_current_user), +): + """列出当前用户的作业。默认只给未结束的——状态条只关心"现在有没有在跑"。""" + with SessionLocal() as db: + svc = JobService(db) + q = db.query(Job).filter(Job.user_id == user.user_id) + if chat_id: + q = q.filter(Job.chat_id == chat_id) + if live: + q = q.filter(Job.status.in_(_LIVE_STATUSES)) + rows: List[Job] = q.order_by(Job.created_at.desc()).limit(limit).all() + return success_response(data={"jobs": [_view(svc, r) for r in rows]}) + + +@router.get("/{job_id}", summary="查单个作业进度") +async def get_job(job_id: str, user: UserContext = Depends(get_current_user)): + with SessionLocal() as db: + svc = JobService(db) + job = svc.get(job_id) + if job is None or job.user_id != user.user_id: + raise HTTPException(status_code=404, detail="job not found") + return success_response(data=_view(svc, job)) + + +@router.post("/{job_id}/cancel", summary="用户手动取消作业") +async def cancel_job(job_id: str, user: UserContext = Depends(get_current_user)): + """状态条上的取消按钮 —— 跑歪了的作业不该只能等它烧完预算。""" + from orchestration import job_runtime + + ok = await job_runtime.cancel_job(job_id, user_id=user.user_id) + if not ok: + raise HTTPException(status_code=404, detail="job not found or not cancellable") + return success_response(data={"job_id": job_id, "status": "cancelled"}) diff --git a/src/backend/api/schemas.py b/src/backend/api/schemas.py index 353a90e..e405b0f 100644 --- a/src/backend/api/schemas.py +++ b/src/backend/api/schemas.py @@ -107,6 +107,15 @@ class ChatRequest(BaseModel): "为 True 时强制启用 batch_runner MCP,并在系统提示中提示模型优先调用 batch_plan。" ), ) + workflow_chat: bool = Field( + default=False, + description=( + "是否为工作流模式对话(用户显式触发:输入框 / 斜杠命令、或 + 菜单里选「工作流模式」)。" + "为 True 时才注册 run_job 批量作业工具,并在系统提示中给出作业脚本写法。" + "与计划模式/批量执行同属用户触发的模式:不触发就完全不存在," + "避免普通问答被无关的批量规则干扰。" + ), + ) disable_batch_plan: bool = Field( default=False, description=( diff --git a/src/backend/core/config/catalog.json b/src/backend/core/config/catalog.json index 245cd85..55c3ad8 100644 --- a/src/backend/core/config/catalog.json +++ b/src/backend/core/config/catalog.json @@ -176,6 +176,30 @@ "config": { "server": "site_publish" } + }, + { + "id": "agent_manager", + "kind": "mcp_server", + "name": "agent_manager", + "description": "MCP 服务:智能体管理", + "desc": "MCP 服务:智能体管理", + "enabled": true, + "version": "1", + "config": { + "server": "agent_manager" + } + }, + { + "id": "plugin_manager", + "kind": "mcp_server", + "name": "plugin_manager", + "description": "MCP 服务:插件管理", + "desc": "MCP 服务:插件管理", + "enabled": true, + "version": "1", + "config": { + "server": "plugin_manager" + } } ], "kb": [] diff --git a/src/backend/core/db/models/__init__.py b/src/backend/core/db/models/__init__.py index 3ecfd74..472831b 100644 --- a/src/backend/core/db/models/__init__.py +++ b/src/backend/core/db/models/__init__.py @@ -56,6 +56,7 @@ UserFolder, UserShadow, ) +from core.db.models.job import JOB_LIVE_STATUSES, JOB_TERMINAL_STATUSES, Job, JobCall, JobItem from core.db.models.knowledge import CatalogOverride, KBChunk, KBDocument, KBSpace from core.db.models.logs import SkillCallLog, SubAgentCallLog, ToolCallLog from core.db.models.memory import MemoryRefShadow, MemorySanitizerRule, ProfileMemory diff --git a/src/backend/core/db/models/job.py b/src/backend/core/db/models/job.py new file mode 100644 index 0000000..bf85796 --- /dev/null +++ b/src/backend/core/db/models/job.py @@ -0,0 +1,136 @@ +"""SQLAlchemy ORM models — 作业编排运行时(Job Runtime)。 + +一个 **job** 是「对 N 个同构工作项批量作业」的一次执行:主对话智能体写一段作业脚本, +脚本跑在沙箱里(普通 Python 进程,拥有文件/网络/并发),需要模型判断时通过带 job token +的回调请求后端派子智能体——模型凭据因此永远不进沙箱。 + +三张表的分工: + +- ``jobs`` 一次作业的元信息、预算、用量、脚本快照(供审计与 resume 原样续跑) +- ``job_items`` 工作项台账,**唯一真源**。按 (job_id, item_key) 复合主键幂等, + 断点续跑靠它:重跑脚本时 ``pending()`` 自动跳过已完成项。 +- ``job_calls`` 每次子作业调用的审计与成本归因 + +台账放 DB 而不是沙箱文件:沙箱池化复用会让新 job 看见旧 job 残留的账本文件 +(autonomous_loop 踩过这个坑,靠盖 loop_id 章解决),以 job_id 为主键从结构上避免。 +""" + +from datetime import datetime, timezone +from sqlalchemy import ( + Column, String, Integer, BigInteger, Text, TIMESTAMP, + ForeignKey, CheckConstraint, Index, JSON +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from core.db.engine import Base + +JSONType = JSON().with_variant(JSONB(), "postgresql") + + +def _utcnow() -> datetime: + """带时区的「现在」。 + + ⚠️ 这里**不能**用 `datetime.utcnow`:它给的是 naive 值,写进 `TIMESTAMP(timezone=True)` + 时由 PostgreSQL 按**会话时区**解释。生产/测试机容器都是 `TZ=Asia/Shanghai`,于是 + naive 的 UTC 时刻被当成 +08 存下来,落库瞬间就比真实时刻早 8 小时——状态条上一条刚 + 提交的作业因此显示「已运行 8 小时」(实测于 HugAgentOS 测试机)。带 tzinfo 的值不受 + 会话时区影响,写进去是哪个时刻读出来就是哪个时刻。 + """ + return datetime.now(timezone.utc) + + +# 终态:驱动不再推进,token 失效 +JOB_TERMINAL_STATUSES = ("completed", "failed", "cancelled") +# 活跃态:进程重启后需要对账(归位 interrupted 或按需续跑) +JOB_LIVE_STATUSES = ("pending", "running") + + +class Job(Base): + """一次批量作业。""" + __tablename__ = "jobs" + + job_id = Column(String(64), primary_key=True) + user_id = Column(String(64), ForeignKey("users_shadow.user_id", ondelete="CASCADE"), nullable=False) + chat_id = Column(String(64)) + name = Column(String(255), default="") + + status = Column(String(20), nullable=False, default="pending") + # 脚本正文随 job 快照,便于审计与「改一行再 resume」;script_path 是沙箱内路径 + script_path = Column(Text, default="") + script_text = Column(Text, default="") + sandbox_session_id = Column(String(128)) + + # budget: {max_calls, max_tokens, max_seconds, concurrency} + budget = Column(JSONType, default=dict) + # usage: {calls, tokens, seconds} + usage = Column(JSONType, default=dict) + # extra_data.start_params:发起时的运行时参数(模型/工具面/思考档位),resume 原样续跑不降级 + extra_data = Column("metadata", JSONType, default=dict) + + error_message = Column(Text) + created_at = Column(TIMESTAMP(timezone=True), default=_utcnow) + started_at = Column(TIMESTAMP(timezone=True)) + completed_at = Column(TIMESTAMP(timezone=True)) + updated_at = Column(TIMESTAMP(timezone=True), default=_utcnow, onupdate=_utcnow) + + items = relationship("JobItem", back_populates="job", cascade="all, delete-orphan") + + __table_args__ = ( + CheckConstraint( + "status IN ('pending','running','paused','completed','failed','cancelled','interrupted')", + name="jobs_status_check", + ), + Index("idx_jobs_user_id", "user_id"), + Index("idx_jobs_chat_status", "chat_id", "status"), + Index("idx_jobs_status", "status"), + ) + + +class JobItem(Base): + """工作项台账 —— 完成判据的唯一真源。""" + __tablename__ = "job_items" + + job_id = Column(String(64), ForeignKey("jobs.job_id", ondelete="CASCADE"), + primary_key=True, nullable=False) + item_key = Column(String(128), primary_key=True, nullable=False) + + status = Column(String(20), nullable=False, default="pending") + payload = Column(JSONType, default=dict) # seed 时给的输入 + result = Column(JSONType) # 子作业产出(schema 校验后) + review = Column(JSONType) # 验收裁决(可多轮,键为规格名) + attempts = Column(Integer, default=0) + error = Column(Text) + updated_at = Column(TIMESTAMP(timezone=True), default=_utcnow, onupdate=_utcnow) + + job = relationship("Job", back_populates="items") + + __table_args__ = ( + CheckConstraint( + "status IN ('pending','running','done','not_found','failed','needs_review')", + name="job_items_status_check", + ), + # pending() 的查询路径 + Index("idx_job_items_job_status", "job_id", "status"), + ) + + +class JobCall(Base): + """每次 ``agent()`` 回调的审计与成本归因。""" + __tablename__ = "job_calls" + + call_id = Column(String(64), primary_key=True) + job_id = Column(String(64), ForeignKey("jobs.job_id", ondelete="CASCADE"), nullable=False) + item_key = Column(String(128)) + seq = Column(Integer, default=0) + + prompt_hash = Column(String(64)) + model = Column(String(128)) + tokens = Column(JSONType, default=dict) + duration_ms = Column(BigInteger, default=0) + status = Column(String(20), default="running") + error = Column(Text) + created_at = Column(TIMESTAMP(timezone=True), default=_utcnow) + + __table_args__ = ( + Index("idx_job_calls_job_id", "job_id"), + ) diff --git a/src/backend/core/llm/agent_factory.py b/src/backend/core/llm/agent_factory.py index eb8884a..2c7d841 100644 --- a/src/backend/core/llm/agent_factory.py +++ b/src/backend/core/llm/agent_factory.py @@ -31,6 +31,7 @@ FinishPinGuardMiddleware, GoalAnchorReminderMiddleware, IterBudgetReminderMiddleware, + JobLedgerReminderMiddleware, OntologyGateMiddleware, StallInterventionMiddleware, SteerMiddleware, @@ -81,6 +82,61 @@ "若请求确实只针对单一对象/单一概念,再走普通回答即可。\n" ) +# 工作流模式提示段 —— 只在用户显式进入工作流模式时拼进系统提示(与 _BATCH_MODE_HINT 同源做法)。 +# 不进入这个模式时,下面这一整套批量规则**完全不存在**,普通问答不受任何干扰。 +_WORKFLOW_MODE_HINT = ( + "\n\n## 工作流模式(用户已主动进入)\n" + "用户显式进入了工作流模式,明确希望用**批量作业**的方式处理任务。你有 `run_job` 工具。\n" + "\n" + "### 什么时候必须用\n" + "当任务是「对 N 个**同构**工作项做同一件事」——补全表格某列、逐份审阅文档、逐个文件改代码、" + "逐条数据打标——且 N 较多(经验刻度 ~20 起,真正的判据是:对象彼此独立、处理逻辑同构、总量大)" + "时,**禁止在对话主循环里逐项处理**。主循环每一轮都要重发全部累积历史,成本随进度二次方增长," + "必然做不完。\n" + "\n" + "### 怎么做(三步)\n" + "1. 用 `write` 把作业脚本写进沙箱(例如 `/workspace/jobs/fill.py`)。脚本是普通 Python," + "开头 `from hugagent_job import ledger, agent, job, log`,SDK 由系统注入:\n" + " - `ledger.seed(items)` 建台账(每项给 key 与 payload 两个字段),按 key 幂等;\n" + " - `job.map(items, fn, concurrency=8)` 并发跑,**fn 返回 dict 会自动逐项落账**" + "(返回 None 表示你自己 update 过了;抛异常自动记 failed)。落账要取台账主键," + "默认按 `key`→`item_key`→`id`→`seq` 在 item 里找,都没有就用 `job.map(..., key=\"字段名\")` " + "显式指定——**别让 seed 用的 key 和 map 传的对象对不上**;\n" + " - `agent(prompt, schema=..., tools=[...], item_key=...)` 派子智能体做每项的模型判断;\n" + " - `ledger.pending()` / `ledger.stats()` / `job.budget()` / `log(...)`。\n" + " 其余一切用标准 Python:抓网页、解析、写 Excel、`subprocess` 跑校验命令。\n" + " **能机检的验收就别烧模型**——`mypy` / `pytest` / 一段校验函数都比 `agent()` 便宜得多。\n" + " ⚠️ `agent()` 在工具全挂/配额打爆/超时时会**抛异常**,不要 try/except 把它转写成" + "「未查询到」——那是把环境故障固化成数据。让异常抛出去,该项会记 failed 留在台账等续跑。\n" + " 同理:真的查无请用 `_status` 标 `not_found`,**不要**把占位串写进结果字段。\n" + "2. `run_job(action=\"start\", script_path=..., name=...)` 提交。\n" + "3. 作业结束后 `run_job(action=\"export\", job_id=...)` 把台账导成沙箱里的 JSONL," + "再用 bash/python 读它写产物。\n" + "\n" + "### 两条硬规矩\n" + "- **不要轮询**:`wait=True`(默认)会一直等到作业结束,中途不占推理轮次;`wait=False` 时" + "作业每隔一段时间会**主动叫醒你播报进度**(默认 15 分钟,`progress_wake_sec` 可调)," + "跑完还会再叫你一次做交付——所以你直接结束本轮回复即可,反复调 `status` 纯属浪费。" + "**小时级作业请用 `wait=False`**:阻塞一小时既看不到进度也没法中途干预。" + "用户那边有独立的作业状态条实时显示进度,不需要你复述作业还活着。\n" + "- **被进度唤醒时只汇报、不干活**:那一轮只需一两句话转述进度," + "**不要**重复提交作业(它还在跑)、不要 `export`、不要把逐项结果读进对话。" + "确实要换脚本重跑,用 `run_job(action=\"start\", on_conflict=\"replace\")`——" + "它会先停掉旧作业;默认的拦截只是不让你**意外**叠加两份,不是不让重跑。\n" + "- **用户说停就立刻停**:用户说「停止任务/别跑了/取消」时," + "马上 `run_job(action=\"cancel\", job_id=...)` 把作业停掉再回话," + "**不要**先解释、不要反问要不要保留进度——台账已经落库,随时可以 `resume` 续跑。" + "不知道 job_id 就先 `run_job(action=\"status\")` 查,别让作业在用户喊停后还在烧预算。\n" + "- **逐项结果不进对话**:要用结果就 `export` 成文件再脚本处理。\n" + "\n" + "### 交付纪律\n" + "台账里还剩多少是**可查证的事实**。不得以「边际收益递减」「消耗较大」为由在只完成一部分时" + "转向交付;确实未做完,必须报出分母、已完成数与未覆盖清单,并给出续跑方式" + "(`run_job(action=\"resume\", job_id=...)`,已完成的项不会重做)。\n" + "「查无 / 待定 / 失败」只能落在独立的状态字段,**不得**把占位串写进原始数据位——" + "一旦写入,「哪些还没做」就不再可判定。\n" +) + from orchestration.registry import AgentSpec load_dotenv() @@ -511,6 +567,10 @@ async def create_agent_executor( # 高于 main_agent 兜底;plan_mode=True 等价于 model_role="plan_agent"。 model_role: Optional[str] = None, batch_mode: bool = False, + # workflow_mode: 工作流模式(用户显式触发:斜杠命令 /workflow 或 + 菜单选「工作流模式」)。 + # 只有它为 True 才注册 run_job 并注入作业脚本写法——与计划模式/批量执行同属"用户触发的 + # 模式",不触发就完全不存在,普通问答不会被无关的批量规则干扰。 + workflow_mode: bool = False, # top_level_chat: whether this construction is a "top-level interactive main # conversation capable of hosting plan mode" — astream_chat_workflow passes # True explicitly after determining (has chat_id, not @@ -1424,6 +1484,27 @@ async def _connect_http(key: str, cfg: dict): # assistant message. See core/llm/workspace.py for the per-run state. register_pin_to_workspace(toolkit, scope=_proj_scope) + # ── Phase 3.85: run_job(工作流模式的作业编排面) ── + # **用户显式触发才注册**(workflow_mode):斜杠命令 /workflow 或 + 菜单选「工作流 + # 模式」。与计划模式/批量执行同属用户触发的模式——不触发就完全不存在,普通问答的 + # 工具面与提示词一点都不受影响。 + # 触发之后它才是这段对话的原生能力(不走 catalog 开关、关不掉):面对 N 个同构 + # 工作项时,主循环逐项处理会让每一轮重发全部历史(成本随进度二次方增长,做不完 + # 就自行收工)。run_job 把循环体交给沙箱脚本,模型调用由后端代持凭据派出—— + # 脚本因此拿不到任何 key。isolated=True 的子作业不注册,杜绝 job 套 job。 + if workflow_mode and not isolated and _sbx_sess: + from core.llm.tools.job_tool import register_run_job + + register_run_job( + toolkit, + user_id=current_user_id or "", + chat_id=chat_id, + sandbox_session_id=_sbx_sess, + allowed_tools=sorted(enabled_mcp_keys or []), + model_name=model_name, + model_provider_id=model_provider_id, + ) + # ── Phase 3.9: get_data_context (the "data dictionary" tool for direct-DB data retrieval) ── # Three gates combined: (1) a direct DB server is enabled this run # (db_query / es_query); (2) the external NL2SQL black box is excluded @@ -1690,6 +1771,11 @@ def _build_toolkit() -> Toolkit: system_prompt += _BATCH_MODE_HINT _log.info("[factory] +%s batch mode hint injected", _elapsed()) + # ── Inject workflow-mode hint (user explicitly entered workflow mode) ── + if workflow_mode: + system_prompt += _WORKFLOW_MODE_HINT + _log.info("[factory] +%s workflow mode hint injected", _elapsed()) + # ── Register call_subagent tool for main agent ── if visible_subagents: from core.llm.builtin_subagents import refresh_builtin_subagents @@ -2020,9 +2106,18 @@ def _build_toolkit() -> Toolkit: # the overestimate, but in practice it triggered too early (compression # kicked in while real occupancy was far below the threshold, and # tool-heavy sessions compressed repeatedly), so it has been relaxed. + from core.config.settings import _env as _cfg_env + from core.config.settings import _int as _cfg_int + context_config = ContextConfig( trigger_ratio=_in_turn_ratio, - tool_result_limit=int(tool_result_limit) if tool_result_limit else 20_000, + # 单条工具结果进上下文的上限(超出部分 offloader 落盘到 /workspace/.offload, + # 模型按需读回)。保持 20k 不再收紧:批量场景已由 run_job 接走(逐项结果根本 + # 不进主上下文),主对话这边继续保留完整的单条可读性更划算。需要时用 + # CHAT_TOOL_RESULT_LIMIT 按部署调。 + tool_result_limit=int(tool_result_limit) + if tool_result_limit + else _cfg_int(_cfg_env("CHAT_TOOL_RESULT_LIMIT"), 20_000), compression_prompt=( "你一直在处理上述任务但尚未完成,对话历史即将被本摘要替换。" "请生成一份【可恢复 ReAct 工作流】的结构化续写摘要,使你能在新的上下文窗口" @@ -2200,6 +2295,14 @@ def _build_toolkit() -> Toolkit: CitationAnchorMiddleware(), # on_acting: 证据锚点——工具结果回给模型前发号回注 cite_id ActingToolCallIdMiddleware(), # on_acting: expose call_subagent's tool_call.id to tools (parent-child linkage) ] + # on_reasoning: 会话里有未收敛的批量作业时,每轮把台账数字回灌进上下文。 + # 进度是外部事实(job_items 表),不是模型的记忆——不主动回灌,隔十几轮之后就会 + # 退化成"边际收益递减,先交付吧"(568 行只补 66 行正是这么停的)。 + # 子作业内部不挂(isolated),避免嵌套噪声。 + if workflow_mode and not isolated and chat_id: + _middlewares.append( + JobLedgerReminderMiddleware(chat_id=chat_id, user_id=current_user_id) + ) if not batch_mode: _middlewares.append(GoalAnchorReminderMiddleware(chat_id=chat_id, batch_mode=False)) _middlewares.append(FinishPinGuardMiddleware(batch_mode=batch_mode)) diff --git a/src/backend/core/llm/middlewares.py b/src/backend/core/llm/middlewares.py index 9057db6..9b44c1f 100644 --- a/src/backend/core/llm/middlewares.py +++ b/src/backend/core/llm/middlewares.py @@ -22,7 +22,7 @@ import time from contextvars import ContextVar from pathlib import Path -from typing import Any, List +from typing import Any, List, Optional from agentscope.agent import Agent from agentscope.event import ToolCallEndEvent @@ -884,6 +884,88 @@ def _maybe_remind(self, agent: Agent, max_iters: int, cur_iter: int, remaining: ) +# ── JobLedgerReminder ────────────────────────────────────────────────────── +class JobLedgerReminderMiddleware(MiddlewareBase): + """本会话存在未收敛的批量作业时,每轮把台账状态回灌进上下文。 + + 为什么要 harness 主动回灌,而不是指望模型记着:进度是**外部事实**,写在 + ``job_items`` 表里,不在模型的记忆里。模型隔了十几轮之后对"还剩多少没做"的印象 + 只会越来越糊,最后就演变成"边际收益递减,先交付吧"——上一轮 568 行只补 66 行 + 就是这么停的。 + + 每轮注入一条 ````,内容是可查证的数字(总计/已完成/待办/失败) + 和明确的下一步。同一轮只注入一次;台账已收敛(待办与失败都为 0)就不再打扰。 + """ + + def __init__(self, *, chat_id: Optional[str], user_id: Optional[str]) -> None: + self._chat_id = chat_id or "" + self._user_id = user_id or "" + self._last_key: tuple | None = None + + async def on_reasoning(self, agent: Agent, input_kwargs: dict, next_handler): + try: + self._maybe_remind(agent) + except Exception as exc: # noqa: BLE001 —— 提醒失败绝不该拖垮主链路 + logger.warning("[job_ledger] reminder failed: %s", exc) + async for evt in next_handler(**input_kwargs): + yield evt + + def _maybe_remind(self, agent: Agent) -> None: + if not self._chat_id: + return + key = (getattr(agent.state, "reply_id", "") or "", int(getattr(agent.state, "cur_iter", 0) or 0)) + if key == self._last_key: + return + + from core.db.engine import SessionLocal + from core.db.models import Job + from core.services.job_service import JobService + + with SessionLocal() as db: + jobs = ( + db.query(Job) + .filter(Job.chat_id == self._chat_id, Job.user_id == self._user_id) + .order_by(Job.created_at.desc()) + .limit(3) + .all() + ) + if not jobs: + return + svc = JobService(db) + lines = [] + for job in jobs: + stats = svc.stats(job.job_id) + unsettled = int(stats.get("pending", 0)) + int(stats.get("failed", 0)) + if unsettled <= 0 and job.status in ("completed", "cancelled"): + continue + lines.append( + f"- 作业「{job.name or job.job_id}」({job.job_id},状态 {job.status}):" + f"总计 {stats.get('total', 0)},已完成 {stats.get('done', 0)}," + f"查无 {stats.get('not_found', 0)},待办 {stats.get('pending', 0)}," + f"失败 {stats.get('failed', 0)}" + ) + if not lines: + return + + self._last_key = key + reminder = ( + "本会话有尚未结算完的批量作业(数字取自台账,是可查证的事实,不是估计):\n" + + "\n".join(lines) + + "\n\n还有待办或失败项时:用 run_job(action='resume', job_id=...) 续跑" + "(已完成的项不会重做),或先查 run_job(action='status', job_id=...) 看明细。" + "**不得**把未完成当作完成来交付;确实要收尾,也必须如实报出分母、完成数与未覆盖清单。" + ) + agent.state.context.append( + Msg( + name="user", + role="user", + content=[ + TextBlock(type="text", text=f"\n{reminder}\n") + ], + ) + ) + + # ── StallIntervention ────────────────────────────────────────────────────── class StallInterventionMiddleware(MiddlewareBase): """Apply the active profile's intervention rules to the **ReAct loop**. diff --git a/src/backend/core/llm/plan_update_tool.py b/src/backend/core/llm/plan_update_tool.py index 5177ccb..5d2e5c3 100644 --- a/src/backend/core/llm/plan_update_tool.py +++ b/src/backend/core/llm/plan_update_tool.py @@ -69,11 +69,8 @@ def register_plan_update_tool(toolkit: Toolkit) -> None: """ async def update_plan(steps: list, title: str = "") -> ToolResponse: - """维护当前复杂任务的分步计划清单(展示在用户输入框上方的计划栏)。 - - 面对复杂、多步骤的任务时用它列出并更新执行计划:开始动手前先调用一次 - 列出全部步骤;每完成一步立即再次调用,更新各步骤的 status。调用本工具 - **不会打断执行**——更新完计划后继续在本轮对话中正常执行任务即可。 + """维护当前复杂任务的分步计划清单(展示在用户输入框上方的计划栏;使用时机与 + 规范见系统提示词「任务计划清单」节)。调用本工具**不会打断执行**。 Args: steps (`list`): diff --git a/src/backend/core/llm/tools/job_tool.py b/src/backend/core/llm/tools/job_tool.py new file mode 100644 index 0000000..9237682 --- /dev/null +++ b/src/backend/core/llm/tools/job_tool.py @@ -0,0 +1,424 @@ +"""run_job 工具 —— 主对话智能体提交批量作业的唯一入口。 + +定位:这不是「又一个固定形状的批处理工具」,而是一个**可编程的编排面**。智能体先用 +``write`` 把一段作业脚本写进沙箱,再用 ``run_job`` 提交;脚本里的控制流(循环、条件、 +分层取数、动态收敛、嵌套 map)都是普通 Python,平台不需要知道任务在做什么。 + +与 bash / read / write / glob / grep 同级原生注册,不走 catalog 开关——「对话内默认 +可用」就落在这里。 +""" + +from __future__ import annotations + +import json as _json +import logging +from typing import Any, Dict, List, Optional + +from agentscope.message import TextBlock +from agentscope.tool import Toolkit +from agentscope.tool._response import ToolChunk as ToolResponse + +logger = logging.getLogger(__name__) + + +def _resp(payload: Dict[str, Any]) -> ToolResponse: + return ToolResponse( + content=[TextBlock(type="text", text=_json.dumps(payload, ensure_ascii=False))] + ) + + +def register_run_job( + toolkit: Toolkit, + *, + user_id: str, + chat_id: Optional[str], + sandbox_session_id: Optional[str], + allowed_tools: Optional[List[str]] = None, + model_name: Optional[str] = None, + model_provider_id: Optional[str] = None, +) -> None: + """注册 run_job。 + + ``allowed_tools`` 是本次会话解析出来的可用 MCP 工具面;作业脚本声明的工具会与它取 + 交集,脚本不能提权到会话本身没有的能力。 + """ + + async def run_job( + action: str = "start", + script_path: str = "", + name: str = "", + job_id: str = "", + wait: bool = False, + # 默认必须是**带三方库**的那个解释器。沙箱里裸 python3 是干净的系统解释器, + # 连 openpyxl/pandas 都没有——默认值写成 python3 的直接后果是作业脚本第一行 + # import 就 ModuleNotFoundError 当场失败(实测踩过)。$PY_BIN 指向预装全套数据 + # 依赖的解释器,取不到时才退回 python3。 + interpreter: str = "${PY_BIN:-python3}", + max_calls: int = 0, + max_seconds: int = 0, + concurrency: int = 8, + dest_path: str = "", + status: str = "", + progress_wake_sec: int = 300, + on_conflict: str = "block", + ) -> ToolResponse: + """提交并管理一次**批量作业**:对 N 个同构工作项做同一件事。 + + 什么时候必须用它:当工作项 ≥ 20 且彼此同构(补全表格某列、逐份审阅文档、 + 逐个文件改代码、逐条数据打标),**禁止**在对话主循环里逐项处理——那样每一轮都要 + 重发全部历史,成本随进度二次方增长,做不完。 + + 怎么用(三步): + + 1. 用 ``write`` 把作业脚本写到沙箱,例如 ``/workspace/jobs/fill.py``。 + 脚本里 ``from hugagent_job import ledger, agent, job, log`` 即可,SDK 由系统注入: + + - ``ledger.seed([{"key": "r2", "payload": {...}}, ...])`` 建台账(按 key 幂等) + - ``ledger.pending()`` 取待办;``ledger.update(key, status="done", result=...)`` 回写 + - ``ledger.stats()`` → ``{total, done, pending, settled, remaining, progressed}`` + - ``agent(prompt, schema=..., tools=["internet_search"], item_key=...)`` 派子智能体, + 返回按 schema 校验后的对象;**每项一次判断**用它,多轮工具循环也用它 + - ``job.map(items, fn, concurrency=8)`` 并发跑,单项异常自动隔离并写回 error + - ``job.budget()`` → ``{calls_left, tokens_left, seconds_left}``;``log("...")`` 报进度 + + 其余一切用标准 Python:抓网页、解析、写 Excel、``subprocess`` 跑校验命令。 + **验收能机检就别烧模型**——``mypy`` / ``pytest`` / 一段校验函数都比 ``agent()`` 便宜。 + + 2. ``run_job(action="start", script_path="/workspace/jobs/fill.py", name="补全展品")``。 + 3. 作业结束后用 ``action="export"`` 把台账导成沙箱里的 JSONL,再用 bash/python + 读它写产物(Excel、报告、校验)。**不要**把逐项结果读回对话。 + + 两条硬规矩: + + - **提交完就回话,不要轮询**。默认 ``wait=False``:作业在后台跑,工具立刻返回 + job_id。此时**先把当前这轮回复收掉**——告诉用户作业已在后台开始、进度看输入框 + 上方的状态条、随时可以继续聊别的。作业跑完(以及每隔一段时间)系统会**自动 + 唤醒本会话**让你播报,不需要你守着。反复调 ``action="status"`` 干等纯属浪费轮次。 + 只有确信几十秒内能跑完的小作业才值得 ``wait=True`` 原地等——那会把整个会话 + 阻塞住:用户看不到中间进度、插不上话、也没法中途改主意。 + - **逐项结果不进对话**。要用结果就 ``export`` 成文件再脚本处理。 + - **用户喊停就立刻停**。用户说「停止任务 / 别跑了 / 取消」时,先 + ``action="cancel"`` 停掉再回话,不要先解释也不要反问——台账已落库, + 之后 ``action="resume"`` 就能接着跑,停一下不损失任何已完成的工作。 + + Args: + action (`str`): ``start`` 提交 / ``status`` 查进度 / ``export`` 导出台账 / + ``resume`` 断点续跑 / ``cancel`` 取消。 + script_path (`str`): 作业脚本在沙箱里的绝对路径(action=start 必填)。 + name (`str`): 作业名,便于在进度里辨认。 + job_id (`str`): status / resume / cancel 必填。 + wait (`bool`): **默认 false = 后台跑**:立即返回 job_id,作业跑完 / 每隔一段 + 时间自动叫醒本会话播报,用户全程能看状态条、能插话、能取消。 + true 则原地阻塞到作业结束——只适合几十秒的小作业,长作业阻塞会让会话 + 看起来「卡死在前台」,既看不到进度也没法中途干预。 + progress_wake_sec (`int`): 仅 wait=false:每隔多少秒把你叫回来播报一次进度 + (默认 300 秒=5 分钟;0 表示只在终态叫一次)。被叫醒时只需转述进度, + 别重复提交作业。用户看到的实时进度条不靠它,它只决定你何时该介入。 + on_conflict (`str`): 仅 start,本会话已有在跑作业时怎么办。``block``(默认) + 拦下并把三条出路告诉你;``replace`` 先停掉旧作业再提交新的(换了脚本要重跑 + 就用它);``parallel`` 两份并行(确实互不相干时才用,预算是双份的)。 + interpreter (`str`): 跑脚本的解释器。默认 ``${PY_BIN:-python3}`` —— 沙箱里 + **裸 python3 是干净的系统解释器,连 openpyxl/pandas 都没有**,而 ``$PY_BIN`` + 指向预装全套数据依赖的那个。除非有特别理由,**别覆盖这个默认值**;真要装 + 额外的包再用 ``uv run --with python``(沙箱可出网)。 + max_calls (`int`): 子作业调用次数上限,0 = 用默认。 + max_seconds (`int`): 墙钟秒数上限,0 = 用默认。 + concurrency (`int`): 子作业并发,默认 8,上限 16。 + dest_path (`str`): 仅 export:导出文件路径,默认 + ``/workspace/jobs/_ledger.jsonl``。 + status (`str`): 仅 export:只导出这些状态,逗号分隔(如 ``"done,not_found"``); + 留空导出全部。 + + Returns: + JSON。start/resume 返回 ``{ok, job_id, status, stats}``;status 返回 + ``{ok, status, stats, usage, budget_left}``。``stats.remaining>0`` 表示还没做完, + 必须如实告诉用户并给出续跑方式,不得当作完成。 + """ + from core.db.engine import SessionLocal + from core.db.models import Job + from core.services.job_service import JobService + from orchestration import job_runtime + + act = (action or "start").strip().lower() + + if act == "start": + if not script_path: + return _resp({"ok": False, "error": "script_path 必填:先用 write 把作业脚本写进沙箱"}) + if not sandbox_session_id: + return _resp({"ok": False, "error": "当前会话没有可用沙箱,无法提交作业"}) + + # 同一会话不许叠加在跑的作业。被进度唤醒后"改个脚本再交一份"是很自然的动作, + # 但旧作业不会自己消失:两份并存会同时烧预算、同时叫醒会话,用户在状态条上 + # 也分不清哪份才算数。要么先 cancel,要么 resume(已完成的项不会重做)。 + # 本会话已有在跑的作业时**默认**拦一道:默默叠加两份会双倍烧预算、双份叫醒会话, + # 用户在状态条上也分不清哪份算数。但这只是默认,不是禁令——确实要换新版本时 + # 用 on_conflict 明说:'replace' 停掉旧的再跑新的,'parallel' 两份并行。 + # 判断权留给调用方,工具只负责让"叠加"变成一个显式选择而不是意外。 + conflict = (on_conflict or "block").strip().lower() + if chat_id and conflict != "parallel": + # 字段必须在 session 内取出:出了 with 块 ORM 实例就 detached,再读属性会炸 + with SessionLocal() as db: + live = ( + db.query(Job) + .filter( + Job.chat_id == chat_id, + Job.status.in_(("pending", "running")), + ) + .order_by(Job.created_at.desc()) + .first() + ) + live_id = live.job_id if live is not None else "" + live_name = (live.name or "未命名") if live is not None else "" + if live_id and conflict == "replace": + await job_runtime.cancel_job(live_id, user_id=user_id) + logger.info("[run_job] replaced live job %s in chat %s", live_id, chat_id) + elif live_id: + return _resp( + { + "ok": False, + "error": ( + f"本会话已有在跑的作业 {live_id}({live_name})。三条路选一条:" + "① 想用新脚本取代它 → 再调一次本工具并带 on_conflict='replace'" + "(自动停掉旧作业再提交新的);" + f"② 旧作业只是中断、脚本没问题 → run_job(action='resume', job_id='{live_id}')" + "断点续跑,已完成的项不会重做;" + "③ 确实需要两份同时跑 → on_conflict='parallel'。" + "默认拦下只是为了让叠加成为显式选择,不是不让跑。" + ), + "job_id": live_id, + "hint": "on_conflict=replace|parallel", + } + ) + + # 读脚本正文:既做存在性校验,也作为 job 的审计快照与 resume 依据。 + # ⚠️ 必须走 base64,不能直接 `cat`:沙箱 execute 回传的 stdout 会丢换行, + # 脚本会被压成一行落地 → SyntaxError(实测踩过)。base64 -w0 保字节不变。 + try: + code, out, err = await job_runtime._sbx_bash( + f"base64 -w0 {script_path} 2>/dev/null || base64 -i {script_path}", + session_id=sandbox_session_id, + user_id=user_id, + timeout=30, + ) + except Exception as exc: # noqa: BLE001 + return _resp({"ok": False, "error": f"读取脚本失败: {exc}"}) + if code != 0 or not (out or "").strip(): + return _resp({"ok": False, "error": f"脚本不存在或为空: {script_path} {(err or '')[:200]}"}) + import base64 as _b64dec + + try: + out = _b64dec.b64decode("".join((out or "").split())).decode("utf-8") + except Exception as exc: # noqa: BLE001 + return _resp({"ok": False, "error": f"脚本解码失败: {exc}"}) + if not out.strip(): + return _resp({"ok": False, "error": f"脚本为空: {script_path}"}) + + budget: Dict[str, Any] = {"concurrency": concurrency} + if max_calls: + budget["max_calls"] = max_calls + if max_seconds: + budget["max_seconds"] = max_seconds + + try: + jid = await job_runtime.start_job( + user_id=user_id, + chat_id=chat_id, + name=name or "批量作业", + script_path=script_path, + script_text=out, + session_id=sandbox_session_id, + budget=budget, + start_params={ + "allowed_tools": list(allowed_tools or []), + "model_name": model_name, + "model_provider_id": model_provider_id, + # wait=False 时作业跑完没人叫醒本会话 → 打标,让驱动在终态入队一轮续跑 + "wake_on_finish": not wait, + # 中途进度播报间隔(秒);0 = 只在终态叫一次 + "progress_wake_sec": max(0, int(progress_wake_sec)), + }, + interpreter=interpreter or "${PY_BIN:-python3}", + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[run_job] start failed: %s", exc) + return _resp({"ok": False, "error": f"作业启动失败: {exc}"}) + + if not wait: + job_runtime.spawn_background(jid, chat_id=chat_id) + return _resp( + { + "ok": True, + "job_id": jid, + "status": "started", + "waited": False, + "next": ( + "作业已在后台开始。**现在就结束这轮回复**:告诉用户作业已提交、" + "输入框上方的状态条会实时显示进度(几分之几、失败数、已运行多久)," + "期间可以继续聊别的、也可以点状态条上的按钮取消。" + "作业跑完和中途都会自动叫醒你播报,不要在这里轮询 action='status' 干等。" + ), + } + ) + + res = await job_runtime.run_and_wait(jid, chat_id=chat_id) + with SessionLocal() as db: + svc = JobService(db) + stats = svc.stats(jid) + job = svc.get(jid) + usage = dict(job.usage or {}) if job else {} + payload = { + "ok": res.get("status") == "completed", + "job_id": jid, + "status": res.get("status"), + "stats": stats, + "usage": usage, + } + if res.get("error"): + payload["error"] = res["error"] + if stats.get("remaining", 0) > 0: + payload["note"] = ( + f"仍有 {stats['remaining']} 项未完成——必须如实告知用户并给出未覆盖清单与" + f"续跑方式(run_job action=resume job_id={jid}),不得当作已完成。" + ) + # 脚本崩溃时把 runner 日志尾巴带回来,省一轮排查 + if res.get("status") == "failed" and sandbox_session_id: + try: + payload["runner_log"] = ( + await job_runtime.read_runner_log( + jid, user_id=user_id, session_id=sandbox_session_id + ) + )[-1500:] + except Exception: # noqa: BLE001 + pass + return _resp(payload) + + if not job_id: + return _resp({"ok": False, "error": f"action={act} 需要 job_id"}) + + # job_id 容错 + 找不到时把本会话的作业列出来。 + # 实测踩过:模型凭记忆抄 id 时漏了 "job_" 前缀,只回一句「job 不存在」它无从自救, + # 结果决定把 568 次子调用整个重跑一遍。错误信息必须自带下一步。 + with SessionLocal() as db: + svc = JobService(db) + resolved = svc.get(job_id) + if (resolved is None or resolved.user_id != user_id) and not job_id.startswith("job_"): + cand = svc.get(f"job_{job_id}") + if cand is not None and cand.user_id == user_id: + resolved, job_id = cand, f"job_{job_id}" + if resolved is None or resolved.user_id != user_id: + from core.db.models import Job + + rows = ( + db.query(Job) + .filter(Job.user_id == user_id, Job.chat_id == chat_id) + .order_by(Job.created_at.desc()) + .limit(5) + .all() + ) + return _resp( + { + "ok": False, + "error": f"job 不存在或无权访问: {job_id}", + "jobs_in_this_chat": [ + { + "job_id": r.job_id, + "name": r.name, + "status": r.status, + "stats": svc.stats(r.job_id), + } + for r in rows + ], + "hint": "用上面列表里的完整 job_id 重试(注意 job_ 前缀);" + "已经跑完的作业**不要重跑**,直接 export 取结果。", + } + ) + + if act == "status": + with SessionLocal() as db: + svc = JobService(db) + job = svc.get(job_id) + if job is None or job.user_id != user_id: + return _resp({"ok": False, "error": "job 不存在或无权访问"}) + return _resp( + { + "ok": True, + "job_id": job_id, + "status": job.status, + "stats": svc.stats(job_id), + "usage": dict(job.usage or {}), + "budget_left": svc.budget_left(job_id), + "error": job.error_message, + } + ) + + if act == "export": + # 台账在 DB,逐项结果**不进对话上下文**——所以导出的方式是写成沙箱里的 + # JSONL 文件,之后用 bash/python 处理(写 Excel、跑校验、喂评审)。 + if not sandbox_session_id: + return _resp({"ok": False, "error": "当前会话没有可用沙箱"}) + with SessionLocal() as db: + svc = JobService(db) + job = svc.get(job_id) + if job is None or job.user_id != user_id: + return _resp({"ok": False, "error": "job 不存在或无权访问"}) + wanted = [s.strip() for s in (status or "").split(",") if s.strip()] or [ + "done", + "not_found", + "failed", + "needs_review", + "pending", + ] + rows = [] + for st in wanted: + rows.extend(svc.pending(job_id, status=st)) + stats = svc.stats(job_id) + + dest = dest_path or f"/workspace/jobs/{job_id}_ledger.jsonl" + body = "\n".join(_json.dumps(r, ensure_ascii=False) for r in rows) + # 分块写 + 读回校验:单条 bash 携带大 base64 到约 170KB 会**静默失败** + # (exit=0、stderr 空、文件不存在)。568 行台账正好落在这个区间——历史上 + # 因此出现过「导出报成功 rows=568,文件却从来没出现」。成功与否只认沙箱 + # 里读回的真实字节数,绝不用 Python 侧的行数报平安。 + try: + ok_write, detail = await job_runtime.write_sandbox_file( + dest, body, session_id=sandbox_session_id, user_id=user_id + ) + except Exception as exc: # noqa: BLE001 + return _resp({"ok": False, "error": f"导出失败: {exc}"}) + if not ok_write: + return _resp({"ok": False, "error": f"导出失败(落盘未通过校验): {detail}"}) + return _resp( + { + "ok": True, + "job_id": job_id, + "path": dest, + "rows": len(rows), + "bytes": detail, + "stats": stats, + "hint": "每行一个 JSON:{key, status, payload, result, review, attempts}。" + "用 bash/python 读这个文件写产物,不要把逐项内容读回对话。", + } + ) + + if act == "cancel": + ok = await job_runtime.cancel_job(job_id, user_id=user_id) + return _resp({"ok": ok, "job_id": job_id, "status": "cancelled" if ok else "unknown"}) + + if act == "resume": + res = await job_runtime.resume_job(job_id, user_id=user_id, chat_id=chat_id) + if not res.get("ok"): + return _resp({"ok": False, "error": res.get("error")}) + if not wait: + job_runtime.spawn_background(job_id, chat_id=chat_id) + return _resp({"ok": True, "job_id": job_id, "status": "running", "waited": False}) + done = await job_runtime.run_and_wait(job_id, chat_id=chat_id) + with SessionLocal() as db: + stats = JobService(db).stats(job_id) + return _resp( + {"ok": done.get("status") == "completed", "job_id": job_id, + "status": done.get("status"), "stats": stats} + ) + + return _resp({"ok": False, "error": f"未知 action: {act}"}) + + toolkit.register_tool_function(run_job) diff --git a/src/backend/core/services/chat_service.py b/src/backend/core/services/chat_service.py index e037bd8..9d35df5 100644 --- a/src/backend/core/services/chat_service.py +++ b/src/backend/core/services/chat_service.py @@ -1,5 +1,6 @@ """Chat session and message business logic.""" +import logging import uuid from datetime import datetime from typing import Any, Dict, List, Optional, Tuple @@ -10,6 +11,8 @@ from core.ontology.revision import is_substantive_revision, normalize_revision_candidate from sqlalchemy.orm import Session +logger = logging.getLogger(__name__) + class ChatService: """Service for chat-related operations.""" @@ -265,6 +268,24 @@ def delete_session(self, chat_id: str, user_id: str) -> bool: return result + # chat_messages.content 有 CHECK 约束 char_length <= 100000。长任务的一轮回复 + # (反复重试 + 大量思考正文)真的会撞上,而撞上的后果是**整条 INSERT 抛 + # CheckViolation → 整个 run failed**,一轮的产出全部丢失(实测踩过)。 + # 与其让约束把一轮工作全毁掉,不如夹逼后落库并明确标注被截断。 + _CONTENT_MAX = 100_000 + _TRUNC_NOTE = "\n\n…(本条回复过长,已截断保存;完整过程见工具调用记录)" + + @classmethod + def _clamp_content(cls, content: str) -> str: + text = content or "" + if len(text) <= cls._CONTENT_MAX: + return text + keep = cls._CONTENT_MAX - len(cls._TRUNC_NOTE) + logger.warning( + "[chat] message content truncated: %d -> %d chars", len(text), cls._CONTENT_MAX + ) + return text[:keep] + cls._TRUNC_NOTE + def add_message( self, chat_id: str, @@ -282,7 +303,7 @@ def add_message( "message_id": message_id or f"msg_{uuid.uuid4().hex[:16]}", "chat_id": chat_id, "role": role, - "content": content, + "content": self._clamp_content(content), "model": model, "tool_calls": tool_calls, "usage": usage, @@ -324,7 +345,7 @@ def upsert_message( """ existing = self.message_repo.get_by_id(message_id) if existing is not None: - update: Dict[str, Any] = {"content": content} + update: Dict[str, Any] = {"content": self._clamp_content(content)} if tool_calls is not None: update["tool_calls"] = tool_calls if usage is not None: diff --git a/src/backend/core/services/job_service.py b/src/backend/core/services/job_service.py new file mode 100644 index 0000000..a3a2ffb --- /dev/null +++ b/src/backend/core/services/job_service.py @@ -0,0 +1,318 @@ +"""作业编排运行时(Job Runtime)的持久化服务层。 + +台账(``job_items``)是完成判据的唯一真源:驱动侧读它决定收敛,脚本侧读它做断点续跑。 +所有写入按 ``(job_id, item_key)`` 幂等——脚本重跑同一份 seed 不会重复建项、也不会把 +已完成项打回 pending。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import secrets +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified + +from core.db.models import Job, JobCall, JobItem + +logger = logging.getLogger(__name__) + +# 台账状态:done/not_found 视为"这一项不必再做",其余都还欠着 +SETTLED_STATUSES = ("done", "not_found") + +DEFAULT_BUDGET: Dict[str, int] = { + "max_calls": 5000, + "max_tokens": 20_000_000, + "max_seconds": 7200, + "concurrency": 8, +} + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def new_job_id() -> str: + return f"job_{uuid.uuid4().hex[:16]}" + + +def mint_token() -> str: + """一次性 job token:随 job 建立、随 job 终态失效。 + + 只用于回调三端点(agent / ledger / log),换不到模型凭据、碰不到其它 job。 + """ + return secrets.token_urlsafe(32) + + +def _same(a: str, b: str) -> bool: + return hmac.compare_digest(str(a or ""), str(b or "")) + + +class JobService: + def __init__(self, db: Session) -> None: + self.db = db + + # ── 生命周期 ────────────────────────────────────────────────────── + + def create( + self, + *, + user_id: str, + chat_id: Optional[str], + name: str, + script_path: str, + script_text: str, + sandbox_session_id: Optional[str], + budget: Optional[Dict[str, Any]] = None, + start_params: Optional[Dict[str, Any]] = None, + ) -> Job: + merged = dict(DEFAULT_BUDGET) + for k, v in (budget or {}).items(): + if isinstance(v, (int, float)) and v > 0: + merged[k] = int(v) + # 并发上限同时受全局护栏约束,防止单会话吃光全后端的子作业槽位 + merged["concurrency"] = max(1, min(int(merged["concurrency"]), 16)) + + job = Job( + job_id=new_job_id(), + user_id=user_id, + chat_id=chat_id or None, + name=(name or "")[:255], + status="pending", + script_path=script_path, + script_text=script_text or "", + sandbox_session_id=sandbox_session_id, + budget=merged, + usage={"calls": 0, "tokens": 0, "seconds": 0}, + extra_data={"token": mint_token(), "start_params": start_params or {}}, + ) + self.db.add(job) + self.db.commit() + self.db.refresh(job) + return job + + def get(self, job_id: str) -> Optional[Job]: + return self.db.query(Job).filter(Job.job_id == job_id).first() + + def verify_token(self, job_id: str, token: str) -> Optional[Job]: + """回调鉴权:token 匹配且 job 未进终态才放行。""" + job = self.get(job_id) + if job is None: + return None + if job.status in ("completed", "failed", "cancelled"): + return None + stored = str((job.extra_data or {}).get("token") or "") + if not stored or not _same(stored, token): + return None + return job + + def mark_running(self, job_id: str) -> None: + job = self.get(job_id) + if job is None or job.status not in ("pending", "interrupted", "paused"): + return + job.status = "running" + job.started_at = job.started_at or _utcnow() + self.db.commit() + + def finish(self, job_id: str, status: str, *, error: Optional[str] = None) -> None: + job = self.get(job_id) + if job is None or job.status in ("completed", "failed", "cancelled"): + return + job.status = status + job.error_message = (error or "")[:4000] or None + job.completed_at = _utcnow() + # 终态即销毁 token:沙箱可能被别的会话复用,旧脚本不得再回调 + meta = dict(job.extra_data or {}) + meta.pop("token", None) + job.extra_data = meta + flag_modified(job, "extra_data") + self.db.commit() + + def rotate_token(self, job_id: str) -> Optional[str]: + """resume 时换发新 token(旧 token 已在 finish 时销毁)。""" + job = self.get(job_id) + if job is None: + return None + token = mint_token() + meta = dict(job.extra_data or {}) + meta["token"] = token + job.extra_data = meta + flag_modified(job, "extra_data") + self.db.commit() + return token + + # ── 台账 ───────────────────────────────────────────────────────── + + def seed(self, job_id: str, items: List[Dict[str, Any]]) -> Dict[str, int]: + """幂等建项:已存在的 item_key 一律跳过(不覆盖已有状态与结果)。""" + if not items: + return {"created": 0, "skipped": 0} + existing = { + row.item_key + for row in self.db.query(JobItem.item_key).filter(JobItem.job_id == job_id).all() + } + created = 0 + for it in items: + key = str(it.get("key") or "").strip()[:128] + if not key or key in existing: + continue + self.db.add( + JobItem( + job_id=job_id, + item_key=key, + status="pending", + payload=it.get("payload") or {}, + attempts=0, + ) + ) + existing.add(key) + created += 1 + self.db.commit() + return {"created": created, "skipped": len(items) - created} + + def pending( + self, job_id: str, *, status: str = "pending", limit: Optional[int] = None + ) -> List[Dict[str, Any]]: + q = ( + self.db.query(JobItem) + .filter(JobItem.job_id == job_id, JobItem.status == status) + .order_by(JobItem.item_key) + ) + if limit: + q = q.limit(int(limit)) + return [ + { + "key": r.item_key, + "payload": r.payload or {}, + "status": r.status, + "attempts": r.attempts or 0, + "result": r.result, + "review": r.review, + } + for r in q.all() + ] + + def update_item( + self, + job_id: str, + item_key: str, + *, + status: Optional[str] = None, + result: Optional[Any] = None, + review: Optional[Dict[str, Any]] = None, + error: Optional[str] = None, + bump_attempts: bool = False, + ) -> bool: + row = ( + self.db.query(JobItem) + .filter(JobItem.job_id == job_id, JobItem.item_key == str(item_key)[:128]) + .first() + ) + if row is None: + return False + if status: + row.status = status + if result is not None: + row.result = result + if review is not None: + merged = dict(row.review or {}) + merged.update(review) + row.review = merged + if error is not None: + row.error = str(error)[:4000] + if bump_attempts: + row.attempts = (row.attempts or 0) + 1 + row.updated_at = _utcnow() + self.db.commit() + return True + + def stats(self, job_id: str) -> Dict[str, int]: + rows = ( + self.db.query(JobItem.status, func.count()) + .filter(JobItem.job_id == job_id) + .group_by(JobItem.status) + .all() + ) + out: Dict[str, int] = {s: int(c) for s, c in rows} + total = sum(out.values()) + settled = sum(out.get(s, 0) for s in SETTLED_STATUSES) + return { + "total": total, + "done": out.get("done", 0), + "pending": out.get("pending", 0), + "failed": out.get("failed", 0), + "not_found": out.get("not_found", 0), + "needs_review": out.get("needs_review", 0), + "running": out.get("running", 0), + "settled": settled, + "remaining": total - settled, + } + + # ── 用量与审计 ──────────────────────────────────────────────────── + + def budget_left(self, job_id: str) -> Dict[str, int]: + job = self.get(job_id) + if job is None: + return {"calls_left": 0, "tokens_left": 0, "seconds_left": 0} + budget = dict(DEFAULT_BUDGET) + budget.update(job.budget or {}) + usage = job.usage or {} + started = job.started_at or job.created_at or _utcnow() + if started.tzinfo is None: + started = started.replace(tzinfo=timezone.utc) + elapsed = int((_utcnow() - started).total_seconds()) + return { + "calls_left": max(0, int(budget["max_calls"]) - int(usage.get("calls", 0))), + "tokens_left": max(0, int(budget["max_tokens"]) - int(usage.get("tokens", 0))), + "seconds_left": max(0, int(budget["max_seconds"]) - elapsed), + "concurrency": int(budget.get("concurrency", 8)), + } + + def add_usage(self, job_id: str, *, calls: int = 0, tokens: int = 0) -> None: + job = self.get(job_id) + if job is None: + return + usage = dict(job.usage or {}) + usage["calls"] = int(usage.get("calls", 0)) + int(calls) + usage["tokens"] = int(usage.get("tokens", 0)) + int(tokens) + job.usage = usage + flag_modified(job, "usage") + self.db.commit() + + def record_call( + self, + job_id: str, + *, + item_key: Optional[str], + prompt: str, + model: Optional[str], + duration_ms: int, + status: str, + tokens: Optional[Dict[str, Any]] = None, + error: Optional[str] = None, + ) -> None: + seq = ( + self.db.query(func.count(JobCall.call_id)).filter(JobCall.job_id == job_id).scalar() + or 0 + ) + self.db.add( + JobCall( + call_id=f"jc_{uuid.uuid4().hex[:16]}", + job_id=job_id, + item_key=(item_key or None), + seq=int(seq) + 1, + prompt_hash=hashlib.sha256((prompt or "").encode("utf-8")).hexdigest()[:64], + model=(model or "")[:128] or None, + tokens=tokens or {}, + duration_ms=int(duration_ms), + status=status, + error=(error or "")[:4000] or None, + ) + ) + self.db.commit() diff --git a/src/backend/core/services/plugin_service.py b/src/backend/core/services/plugin_service.py index 87aa6bd..02f73b9 100644 --- a/src/backend/core/services/plugin_service.py +++ b/src/backend/core/services/plugin_service.py @@ -101,12 +101,14 @@ PLUGIN_MARKET_META_BLOCK_ID = "plugin_market_meta" BUILTIN_PLUGIN_MARKET_META: Dict[str, Dict[str, Any]] = { + "agent-manager": {"display_name": "智能体管理", "category": "效率工具"}, "automation": {"display_name": "定时任务管理", "category": "效率工具"}, "dingtalk": {"display_name": "钉钉工作台", "category": "办公协同"}, "email": {"display_name": "电子邮箱", "category": "办公协同"}, "feishu-cli": {"display_name": "飞书工作台", "category": "办公协同"}, "firecrawl": {"display_name": "Firecrawl·网页抓取检索", "category": "信息处理"}, "industry-knowledge-center": {"display_name": "产业知识中心", "category": "产业智能"}, + "plugin-manager": {"display_name": "插件管理", "category": "效率工具"}, "sample-translator": {"display_name": "示例·快速翻译", "category": "办公效率"}, "security-manager": {"display_name": "安全管理·系统自察", "category": "信息处理"}, "sites": {"display_name": "站点·对话建站", "category": "信息处理"}, @@ -1706,6 +1708,49 @@ def set_plugin_enabled_for_user( return {"install_id": install_id, "enabled": enabled} +def set_plugin_component_enabled_for_user( + db: Session, + install_id: str, + *, + kind: str, + component_id: str, + enabled: bool, + user_id: str, +) -> Dict[str, Any]: + """A frontend user enables/disables **one component** of a plugin for themself. + + Per-component counterpart of ``set_plugin_enabled_for_user``: writes a + per-user catalog override instead of flipping the component's global + ``is_enabled``, so it composes with the whole-plugin user toggle and stays + invisible to other users. ``component_id`` must belong to this plugin's + ``component_ids`` (prevents escalating into other plugins' components), + matching the admin-side ``set_plugin_component_enabled`` guard. + """ + if kind not in ("skill", "mcp"): + raise BadRequestError(message=f"不支持的组件类型:{kind}") + row = db.query(InstalledPlugin).filter(InstalledPlugin.install_id == install_id).first() + if row is None: + raise ResourceNotFoundError("installed_plugin", install_id) + if row.owner_user_id is not None and row.owner_user_id != user_id: + raise BadRequestError(message="无权操作该插件") + + cids = row.component_ids or {} + pool = cids.get("skills") if kind == "skill" else cids.get("mcp") + if component_id not in (pool or []): + raise BadRequestError(message="该组件不属于此插件") + + from core.services.catalog_service import CatalogService + + CatalogService(db).update_override(user_id, kind, component_id, enabled) + _refresh_after_change(user_id) + return { + "install_id": install_id, + "kind": kind, + "component_id": component_id, + "enabled": enabled, + } + + def set_installed_plugin_meta( db: Session, install_id: str, diff --git a/src/backend/mcp_servers/_packaging.py b/src/backend/mcp_servers/_packaging.py new file mode 100644 index 0000000..86b878e --- /dev/null +++ b/src/backend/mcp_servers/_packaging.py @@ -0,0 +1,137 @@ +"""共享产物库读取 + 技能/插件包安全解包(skill-manager 与 plugin-manager 共用)。 + +这些动作原本只长在 ``skill_manager_mcp/impl.py`` 里;plugin-manager 的 ``import_plugin`` +走的是同一条通路(沙箱产出目录 → tar/zip → sandbox_get_artifact → 本进程解包落库), +所以抽到这里共用,避免第二份拷贝各自漂移——尤其是 zip-bomb 与目录穿越这两道防护, +复制一次就少一次被同步修补的机会。 + +MCP 容器与 backend 挂同一 ``/app/storage`` 卷,故读得到共享产物库(但读不到沙箱本身)。 +""" + +from __future__ import annotations + +import io +import logging +import os +import tarfile +import zipfile +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +# tar/zip 解包安全上限(防 zip-bomb / 超大产物) +MAX_ENTRIES = 4000 +MAX_TOTAL_BYTES = 64 * 1024 * 1024 # 64 MB 解压后总量 + + +def read_artifact_bytes(file_id: str) -> Optional[bytes]: + """从共享产物库按 file_id 读回字节(local: 读 path;oss: download_bytes)。""" + try: + from core.artifacts import store + + meta = store.get_artifact(file_id) + if not meta: + return None + path = meta.get("path") + if path and os.path.isfile(path): + with open(path, "rb") as fh: + return fh.read() + key = meta.get("storage_key") + if key: + from core.storage import get_storage + + return bytes(get_storage().download_bytes(key)) + except Exception as exc: # noqa: BLE001 + logger.warning("read artifact %s failed (%s)", file_id, exc) + return None + + +def looks_like_zip(data: bytes) -> bool: + return data[:2] == b"PK" + + +def within(base: Path, target: Path) -> bool: + try: + target.resolve().relative_to(base.resolve()) + return True + except ValueError: + return False + + +def safe_extract(data: bytes, dest: Path) -> None: + """安全解包 tar(.gz) 或 zip 到 dest:拦目录穿越 + 限条目数/总量。""" + if looks_like_zip(data): + safe_extract_zip(data, dest) + else: + safe_extract_tar(data, dest) + + +def safe_extract_tar(data: bytes, dest: Path) -> None: + total = 0 + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: + members = tf.getmembers() + if len(members) > MAX_ENTRIES: + raise ValueError(f"包内条目过多({len(members)} > {MAX_ENTRIES})") + for m in members: + if not (m.isfile() or m.isdir()): + continue # 跳过软链接/设备等,杜绝越权 + target = dest / m.name + if not within(dest, target): + raise ValueError(f"检测到目录穿越条目:{m.name}") + total += max(m.size, 0) + if total > MAX_TOTAL_BYTES: + raise ValueError("解压后总量超限(>64MB)") + tf.extractall(dest, members=[m for m in members if m.isfile() or m.isdir()]) + + +def safe_extract_zip(data: bytes, dest: Path) -> None: + total = 0 + with zipfile.ZipFile(io.BytesIO(data)) as zf: + infos = zf.infolist() + if len(infos) > MAX_ENTRIES: + raise ValueError(f"包内条目过多({len(infos)} > {MAX_ENTRIES})") + for info in infos: + target = dest / info.filename + if not within(dest, target): + raise ValueError(f"检测到目录穿越条目:{info.filename}") + total += info.file_size + if total > MAX_TOTAL_BYTES: + raise ValueError("解压后总量超限(>64MB)") + zf.extractall(dest) + + +def locate_root(extracted: Path) -> Optional[Path]: + """定位技能/插件根:含 SKILL.md 或插件清单的目录。tar 常多包一层。""" + + def _is_root(d: Path) -> bool: + return (d / "SKILL.md").is_file() or is_plugin_root(d) + + if _is_root(extracted): + return extracted + subdirs = [p for p in extracted.iterdir() if p.is_dir()] + for d in subdirs: + if _is_root(d): + return d + # 再下探一层 + for d in subdirs: + for dd in (p for p in d.iterdir() if p.is_dir()): + if _is_root(dd): + return dd + return None + + +def is_plugin_root(root: Path) -> bool: + """该目录是否是插件包根。 + + 判定必须与导入器一致:``plugin_importer.detect_manifest`` 认原生 / .claude-plugin / + .codex-plugin 三种布局,这里若自己复述一遍规则,codex 布局的包就会被本地拦下、 + 却能从后台上传导入——同一个包两条通路结论不同。所以直接问导入器。 + """ + try: + from core.services.plugin_importer import detect_manifest + + detect_manifest(root) + return True + except Exception: # noqa: BLE001 — 非插件包(含 BadRequestError)一律按 False + return False diff --git a/src/backend/mcp_servers/agent_manager_mcp/__init__.py b/src/backend/mcp_servers/agent_manager_mcp/__init__.py new file mode 100644 index 0000000..2b6bb9a --- /dev/null +++ b/src/backend/mcp_servers/agent_manager_mcp/__init__.py @@ -0,0 +1,6 @@ +"""智能体管理 MCP —— 让智能体在对话里搜索/安装/创建/修改/删除/申请上架子智能体。 + +只放"沙箱够不着的"动词(读子智能体市场 / 写后端 DB)。与 skill-manager 不同的是 +子智能体没有文件产物——所有字段都能直接作为工具入参传,因此本 MCP 不需要沙箱与 +产物库通路;本插件打包的 agent-designer 技能只提供"怎么设计"的判断标准与 prompt 骨架。 +""" diff --git a/src/backend/mcp_servers/agent_manager_mcp/impl.py b/src/backend/mcp_servers/agent_manager_mcp/impl.py new file mode 100644 index 0000000..b2ac18b --- /dev/null +++ b/src/backend/mcp_servers/agent_manager_mcp/impl.py @@ -0,0 +1,634 @@ +"""智能体管理 MCP —— 业务实现(直连 DB / 复用后端 service,按 X-Current-User-Id 归属)。 + +复用 ``UserAgentService``(建/改/删/列)与 ``agent_market_service``(搜/装/上架)。 +所有写操作强制 ``owner_type="user"`` + 按 ``user_id`` 归属,绝不落成 admin/team 智能体—— +与 skill-manager 强制 ``make_private=True`` 是同一条原则:自助入口只能产出仅自己可见的资产。 + +子智能体建好后**下一轮对话即可用**:``orchestration/workflow.py`` 每轮启动时用 +``UserAgentService.list_for_user`` 解析可见子智能体交给 ``register_subagent_tool``, +无需重启也无需额外注册。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# 平台内置角色(探索员/执行员/审查员)不是 UserAgent 行,前缀固定;"我的智能体"要滤掉它们。 +_BUILTIN_PREFIX = "builtin." + +# system_prompt 上限:这是智能体的行事准则正文,给足空间但别让单条记录失控。 +_MAX_PROMPT_BYTES = 200 * 1024 + +# 绑定类字段一次最多绑多少项,防止模型把整库 id 一股脑塞进来。 +_MAX_BINDINGS = 100 + + +# ── 通用 ──────────────────────────────────────────────────────────────── +def _no_user() -> Dict[str, Any]: + return {"ok": False, "message": "❌ 无法确定用户身份(缺 X-Current-User-Id 头),拒绝操作。"} + + +def _invalidate_user_cache(user_id: Optional[str]) -> None: + """清掉该用户的 30s 能力解析缓存。 + + 注意:本 MCP 跑在独立的 ``mcp`` 容器进程,能力缓存是**进程内**的——这里只清得掉本进程的, + 清不到 backend 进程(智能体真正读缓存的地方)。对智能体侧的**有效**失效由前端在变更后 + 重新拉 ``GET /v1/catalog``(跑在 backend 进程)顺带完成,见 hooks/chatStream.ts 的 + CATALOG_MUTATING_TOOLS 白名单。本调用作为进程内一致性的兜底保留,无害。 + """ + try: + from core.config.catalog_resolver import invalidate_capability_cache + + invalidate_capability_cache(str(user_id) if user_id else None) + except Exception as exc: # noqa: BLE001 + logger.debug("agent_manager: invalidate_capability_cache failed (%s)", exc) + + +def _require_cap(db, user_id: str, cap: str) -> Optional[Dict[str, Any]]: + """能力位校验。缺权限返回错误 dict,否则 None。""" + try: + from core.auth.capabilities import resolve_user_capabilities + + if not resolve_user_capabilities(db, user_id).get(cap): + return { + "ok": False, + "message": f"❌ 管理员未开放该能力({cap}),无法执行。请联系管理员在权限设置里开启。", + } + except Exception as exc: # noqa: BLE001 + logger.warning("agent_manager: capability check failed (%s)", exc) + return {"ok": False, "message": f"❌ 权限校验失败:{exc}"} + return None + + +def _svc(db): + from core.services.user_agent_service import UserAgentService + + return UserAgentService(db) + + +def valid_categories() -> List[str]: + """子智能体市场的固定分类(**与技能市场的 8 类不同**)。 + + 从单一真相源读,别在工具描述里硬写一份——分类调整时两边会漂移, + 模型照着过期的列表猜,每次上架都要失败一轮。 + """ + from core.services.agent_market_categories import AGENT_MARKETPLACE_CATEGORIES + + return list(AGENT_MARKETPLACE_CATEGORIES) + + +def _clean_ids(raw: Optional[List[str]], field: str) -> Tuple[Optional[List[str]], Optional[str]]: + """绑定 id 数组归一化:去空白/去重/保序/限量。返回 (清洗后, 错误信息)。""" + if raw is None: + return None, None + out: List[str] = [] + seen = set() + for item in raw: + s = str(item or "").strip() + if not s or s in seen: + continue + seen.add(s) + out.append(s) + if len(out) > _MAX_BINDINGS: + return None, f"❌ {field} 一次最多绑 {_MAX_BINDINGS} 项(收到 {len(out)} 项)。" + return out, None + + +def _binding_updates( + raw: Dict[str, Optional[List[str]]], *, fill_missing: bool +) -> Tuple[Dict[str, List[str]], Optional[str]]: + """把四个绑定字段一起清洗成待写入的 data 片段。 + + create 与 edit 的唯一差别就是没传的字段怎么办:create 落空数组,edit 保持原样 + (即不进 data)。除此之外去重、限量规则完全相同,不该各写一遍。 + """ + data: Dict[str, List[str]] = {} + for field, value in raw.items(): + if value is None and not fill_missing: + continue + cleaned, err = _clean_ids(value, field) + if err: + return {}, err + data[field] = cleaned or [] + return data, None + + +def _clean_max_iters(value: Optional[int]) -> Tuple[Optional[int], Optional[str]]: + """max_iters 校验。None 表示"没传",返回 (None, None) 让调用方跳过。""" + if value is None: + return None, None + try: + mi = int(value) + except (TypeError, ValueError): + return None, "❌ max_iters 必须是整数。" + if not 1 <= mi <= 100: + return None, "❌ max_iters 取值范围 1–100。" + return mi, None + + +def _slim(a: Dict[str, Any]) -> Dict[str, Any]: + """把 service 的完整序列化裁成智能体读得动的摘要(别把 change_history 等整包回吐)。""" + return { + "agent_id": a.get("agent_id"), + "name": a.get("name"), + "description": a.get("description") or "", + "is_enabled": bool(a.get("is_enabled")), + "max_iters": a.get("max_iters"), + "version": a.get("version"), + "bindings": { + "skills": len(a.get("skill_ids") or []), + "mcp_servers": len(a.get("mcp_server_ids") or []), + "plugins": len(a.get("plugin_ids") or []), + "kb_spaces": len(a.get("kb_ids") or []), + }, + "from_market": a.get("source_market_slug") or "", + "updated_at": a.get("updated_at"), + } + + +def _my_agents(db, user_id: str) -> List[Dict[str, Any]]: + """只取"我自己建的/装的"子智能体。 + + ``list_for_user`` 返回的是**可见集**(含管理员全局智能体与团队智能体), + 自助管理入口只能碰自己的,所以这里按 owner_type/user_id 再收一次口。 + """ + rows = _svc(db).list_for_user(user_id) or [] + return [ + a + for a in rows + if a.get("owner_type") == "user" + and str(a.get("user_id") or "") == str(user_id) + and not str(a.get("agent_id") or "").startswith(_BUILTIN_PREFIX) + ] + + +def _resolve_agent(db, user_id: str, ref: str) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """agent_ref → (唯一命中, 候选)。先精确 agent_id,再按名称模糊匹配。仅本人的子智能体。""" + ref = (ref or "").strip() + mine = _my_agents(db, user_id) + for a in mine: + if str(a.get("agent_id")) == ref: + return a, [] + low = ref.lower() + cands = [a for a in mine if low and low in str(a.get("name") or "").lower()] + if len(cands) == 1: + return cands[0], [] + return None, cands[:10] + + +def _need_clarify(cands: List[Dict[str, Any]]) -> Dict[str, Any]: + return { + "ok": False, + "need_clarification": True, + "message": "匹配到多个子智能体,请用 agent_id 指明具体是哪一个:", + "candidates": [{"agent_id": c.get("agent_id"), "name": c.get("name")} for c in cands], + } + + +# ── ① 搜索子智能体市场(只读)────────────────────────────────────────── +def search_agent_market(*, user_id: str, query: str = "", category: str = "") -> Dict[str, Any]: + if not user_id: + return _no_user() + from core.db.engine import SessionLocal + from core.services import agent_market_service + + q = (query or "").strip().lower() + cat = (category or "").strip() + with SessionLocal() as db: + # 与用户端市场接口同一套可见范围过滤(scoped 条目仅授权者可见) + items = agent_market_service.list_marketplace_agents(db, viewer_user_id=user_id) + # list_marketplace_agents 只标 market_enabled/visibility,不标 installed——不补这一下, + # 每条都会显示"未安装",模型就会去重装用户已经有的智能体(一次代价高昂的克隆)。 + # 插件侧的 list_plugins 自带该标注,两边口径要一致。 + agent_market_service.annotate_installed(items, db, user_id) + + def _match(it: Dict[str, Any]) -> bool: + if cat and str(it.get("category") or "") != cat: + return False + if not q: + return True + hay = " ".join( + str(it.get(k) or "") for k in ("slug", "name", "summary", "description", "category") + ) + hay += " " + " ".join(str(t) for t in (it.get("tags") or [])) + return q in hay.lower() + + hits = [it for it in items if _match(it)] + slim = [ + { + "slug": it.get("slug"), + "name": it.get("name"), + "summary": it.get("summary") or it.get("description") or "", + "category": it.get("category"), + "installed": bool(it.get("installed")), + } + for it in hits + ] + msg = ( + f"子智能体市场匹配 {len(slim)} 个" + + (f"(关键词「{query}」)" if q else "") + + (f"(分类「{cat}」)" if cat else "") + + "。想安装某个用 install_market_agent(slug)。" + ) + return {"ok": True, "count": len(slim), "agents": slim, "message": msg} + + +# ── ② 从市场安装(私有,写)──────────────────────────────────────────── +def install_market_agent(*, user_id: str, slug: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + slug = (slug or "").strip() + if not slug: + return {"ok": False, "message": "❌ 请提供要安装的子智能体 slug。"} + from core.db.engine import SessionLocal + from core.services import agent_market_service + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_add_agent") + if cap_err: + return cap_err + # 可见范围守卫:对该用户不可见的 scoped 条目按不存在处理(与用户端安装接口一致)。 + # 不要包 try——守卫出错就该报错,静默跳过等于对不可见条目失败开放。 + from core.auth.marketplace_visibility import is_item_visible + from core.services import marketplace_listing as ml + + if not is_item_visible(db, ml.KIND_AGENT, slug, user_id): + return {"ok": False, "message": f"❌ 子智能体市场里找不到「{slug}」。"} + try: + res = agent_market_service.install_marketplace_agent( + db, slug, owner_user_id=user_id, operator_name=user_id + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 安装失败:{exc}"} + _invalidate_user_cache(user_id) + agent_id = (res or {}).get("agent_id") + # install_report.dropped = 你账下没有的能力,装完绑不上——必须如实回报,别让用户以为全绑上了。 + report = (res or {}).get("install_report") or {} + dropped = list(report.get("dropped") or []) + tail = ( + f"注意有 {len(dropped)} 项能力你账下没有、已跳过:{'、'.join(str(d) for d in dropped[:5])}。" + if dropped + else "" + ) + return { + "ok": True, + "agent_id": agent_id, + "install_report": report, + "message": ( + f"✅ 已从市场安装子智能体「{slug}」到你的智能体库" + + (f"(agent_id={agent_id})" if agent_id else "") + + ",下一轮对话即可直接指派任务给它。" + + tail + ), + } + + +# ── ③ 列出可绑能力(只读,创建/修改前必调)────────────────────────────── +def list_bindable_capabilities(*, user_id: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + from core.db.engine import SessionLocal + + with SessionLocal() as db: + try: + res = _svc(db).list_available_resources(owner_user_id=user_id) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 读取可绑能力失败:{exc}"} + + def _pick(items: Any) -> List[Dict[str, Any]]: + out = [] + for it in items or []: + if not isinstance(it, dict): + continue + out.append( + { + "id": it.get("id"), + "name": it.get("name") or it.get("id"), + "description": (str(it.get("description") or ""))[:160], + } + ) + return out + + skills = _pick(res.get("skills")) + mcps = _pick(res.get("mcp_servers")) + plugins = _pick(res.get("plugins")) + kbs = _pick(res.get("kb_spaces")) + return { + "ok": True, + "skills": skills, + "mcp_servers": mcps, + "plugins": plugins, + "kb_spaces": kbs, + "message": ( + f"可绑能力:技能 {len(skills)} 个、工具 {len(mcps)} 个、" + f"插件 {len(plugins)} 个、知识库 {len(kbs)} 个。" + "创建/修改子智能体时 id 必须取自这里,不要自行编造。" + ), + } + + +# ── ④ 从零创建(写)──────────────────────────────────────────────────── +def create_agent( + *, + user_id: str, + name: str, + description: str = "", + system_prompt: str = "", + skill_ids: Optional[List[str]] = None, + mcp_server_ids: Optional[List[str]] = None, + plugin_ids: Optional[List[str]] = None, + kb_ids: Optional[List[str]] = None, + max_iters: Optional[int] = None, + welcome_message: str = "", +) -> Dict[str, Any]: + if not user_id: + return _no_user() + name = (name or "").strip() + if not name: + return {"ok": False, "message": "❌ 请提供子智能体的名字(name)。"} + description = (description or "").strip() + if not description: + return {"ok": False, "message": "❌ 请提供 description:一句话说清这个智能体管什么。"} + system_prompt = system_prompt or "" + if not system_prompt.strip(): + return { + "ok": False, + "message": "❌ 请提供 system_prompt(这个智能体的行事准则,是它的主体)。写法见 agent-designer 技能。", + } + if len(system_prompt.encode("utf-8")) > _MAX_PROMPT_BYTES: + return {"ok": False, "message": f"❌ system_prompt 过长(上限 {_MAX_PROMPT_BYTES // 1024}KB)。"} + + data: Dict[str, Any] = { + "name": name, + "description": description, + "system_prompt": system_prompt, + "welcome_message": (welcome_message or "").strip(), + } + bindings, err = _binding_updates( + { + "skill_ids": skill_ids, + "mcp_server_ids": mcp_server_ids, + "plugin_ids": plugin_ids, + "kb_ids": kb_ids, + }, + fill_missing=True, + ) + if err: + return {"ok": False, "message": err} + data.update(bindings) + + mi, err = _clean_max_iters(max_iters) + if err: + return {"ok": False, "message": err} + if mi is not None: + data["max_iters"] = mi + + from core.db.engine import SessionLocal + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_add_agent") + if cap_err: + return cap_err + try: + # 自助入口固定落成 owner_type="user":只有自己可见可用,绝不落 admin/team。 + created = _svc(db).create( + user_id=user_id, + operator_name=user_id, + owner_type="user", + data=data, + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": _friendly_error("创建", exc)} + _invalidate_user_cache(user_id) + return { + "ok": True, + "agent": _slim(created), + "message": ( + f"✅ 已创建子智能体「{name}」(agent_id={created.get('agent_id')})," + "仅你自己可见可用,下一轮对话即可直接指派任务给它。" + ), + } + + +def _friendly_error(action: str, exc: Exception) -> str: + """把 service 层异常转成人话。本体校验失败要说清楚是本体没过,而不是甩一句失败。""" + text = str(exc) or exc.__class__.__name__ + low = text.lower() + if "ontology" in low or "本体" in text: + return f"❌ {action}失败:没通过本体校验——{text}。请按提示补齐名称/描述/正文或本体标签后重试。" + if isinstance(exc, PermissionError): + return f"❌ {action}失败:只能操作你自己的子智能体。" + if isinstance(exc, LookupError): + return f"❌ {action}失败:找不到该子智能体(可能已被删除)。" + return f"❌ {action}失败:{text}" + + +# ── ⑤ 列出我的子智能体(只读)────────────────────────────────────────── +def list_my_agents(*, user_id: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + from core.db.engine import SessionLocal + + with SessionLocal() as db: + mine = _my_agents(db, user_id) + agents = [_slim(a) for a in mine] + msg = ( + f"你有 {len(agents)} 个自己的子智能体。" + if agents + else "你还没有自己的子智能体(可用 create_agent 新建,或用 search_agent_market 从市场装一个)。" + ) + return {"ok": True, "count": len(agents), "agents": agents, "message": msg} + + +# ── ⑥ 修改(字段级部分更新,写)──────────────────────────────────────── +def edit_agent( + *, + user_id: str, + agent_ref: str, + name: Optional[str] = None, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + skill_ids: Optional[List[str]] = None, + mcp_server_ids: Optional[List[str]] = None, + plugin_ids: Optional[List[str]] = None, + kb_ids: Optional[List[str]] = None, + max_iters: Optional[int] = None, + is_enabled: Optional[bool] = None, + welcome_message: Optional[str] = None, +) -> Dict[str, Any]: + """修改一个本人的子智能体:只更新传入的字段,未传的保持原样。agent_id 不可改。""" + if not user_id: + return _no_user() + agent_ref = (agent_ref or "").strip() + if not agent_ref: + return {"ok": False, "message": "❌ 请提供要修改的子智能体 agent_id 或名字。"} + + data: Dict[str, Any] = {} + if name is not None: + v = name.strip() + if not v: + return {"ok": False, "message": "❌ name 不能改成空。"} + data["name"] = v + if description is not None: + v = description.strip() + if not v: + return {"ok": False, "message": "❌ description 不能改成空(它是这个智能体的职责说明)。"} + data["description"] = v + if system_prompt is not None: + if not system_prompt.strip(): + return {"ok": False, "message": "❌ system_prompt 不能改成空(它是这个智能体的主体)。"} + if len(system_prompt.encode("utf-8")) > _MAX_PROMPT_BYTES: + return {"ok": False, "message": f"❌ system_prompt 过长(上限 {_MAX_PROMPT_BYTES // 1024}KB)。"} + data["system_prompt"] = system_prompt + if welcome_message is not None: + data["welcome_message"] = welcome_message.strip() + bindings, err = _binding_updates( + { + "skill_ids": skill_ids, + "mcp_server_ids": mcp_server_ids, + "plugin_ids": plugin_ids, + "kb_ids": kb_ids, + }, + fill_missing=False, + ) + if err: + return {"ok": False, "message": err} + data.update(bindings) + + mi, err = _clean_max_iters(max_iters) + if err: + return {"ok": False, "message": err} + if mi is not None: + data["max_iters"] = mi + if is_enabled is not None: + data["is_enabled"] = bool(is_enabled) + + if not data: + return { + "ok": False, + "message": "❌ 没有要改的内容。请至少传一个字段(name/description/system_prompt/skill_ids/mcp_server_ids/plugin_ids/kb_ids/max_iters/is_enabled)。", + } + + from core.db.engine import SessionLocal + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_add_agent") + if cap_err: + return cap_err + hit, cands = _resolve_agent(db, user_id, agent_ref) + if hit is None and cands: + return _need_clarify(cands) + if hit is None: + return {"ok": False, "message": f"❌ 没找到你的子智能体「{agent_ref}」。"} + try: + updated = _svc(db).update( + agent_id=str(hit.get("agent_id")), + user_id=user_id, + operator_name=user_id, + owner_type="user", + data=data, + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": _friendly_error("修改", exc)} + _invalidate_user_cache(user_id) + return { + "ok": True, + "agent": _slim(updated), + "changed": sorted(data.keys()), + "message": ( + f"✅ 已更新子智能体「{updated.get('name')}」(agent_id={updated.get('agent_id')}):" + f"{'、'.join(sorted(data.keys()))}。" + ), + } + + +# ── ⑦ 删除(写)──────────────────────────────────────────────────────── +def delete_agent(*, user_id: str, agent_ref: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + agent_ref = (agent_ref or "").strip() + if not agent_ref: + return {"ok": False, "message": "❌ 请提供要删除的子智能体 agent_id 或名字。"} + from core.db.engine import SessionLocal + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_add_agent") + if cap_err: + return cap_err + hit, cands = _resolve_agent(db, user_id, agent_ref) + if hit is None and cands: + return _need_clarify(cands) + if hit is None: + return {"ok": False, "message": f"❌ 没找到你的子智能体「{agent_ref}」。"} + deleted_id = str(hit.get("agent_id")) + display = hit.get("name") + try: + _svc(db).delete(agent_id=deleted_id, user_id=user_id, owner_type="user") + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": _friendly_error("删除", exc)} + _invalidate_user_cache(user_id) + return { + "ok": True, + "agent_id": deleted_id, + "message": f"✅ 已删除子智能体「{display}」(agent_id={deleted_id})。", + } + + +# ── ⑧ 申请上架市场(写)──────────────────────────────────────────────── +def submit_agent_to_market( + *, user_id: str, agent_id: str, category: str = "", summary: str = "", note: str = "" +) -> Dict[str, Any]: + if not user_id: + return _no_user() + agent_id = (agent_id or "").strip() + if not agent_id: + return {"ok": False, "message": "❌ 请提供要上架的 agent_id(取自 list_my_agents)。"} + from core.db.engine import SessionLocal + from core.services import agent_market_service + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_add_agent") + if cap_err: + return cap_err + hit, cands = _resolve_agent(db, user_id, agent_id) + if hit is None and cands: + return _need_clarify(cands) + if hit is None: + return {"ok": False, "message": f"❌ 没找到你的子智能体「{agent_id}」。"} + # 分类是固定集合(与技能市场的 8 类不同),传错会被 service 拒。先在这里挡一道, + # 并把合法值回给模型,省得它反复猜。 + cat = (category or "").strip() + cats = valid_categories() + if cat and cat not in cats: + return { + "ok": False, + "valid_categories": cats, + "message": ( + f"❌ 分类「{cat}」不合法。子智能体市场只接受这 {len(cats)} 个分类," + "请挑最贴切的一个:" + "、".join(cats) + ), + } + try: + sub = agent_market_service.submit_to_marketplace( + db, + str(hit.get("agent_id")), + owner_user_id=user_id, + submitter_name=user_id, + note=(note or "").strip(), + category=cat, + summary=(summary or "").strip(), + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 上架申请失败:{exc}"} + return { + "ok": True, + "submission_id": (sub or {}).get("submission_id") or (sub or {}).get("id"), + "status": (sub or {}).get("status") or "pending", + "message": ( + f"✅ 已提交上架申请:子智能体「{hit.get('name')}」已进入管理员审核队列。" + "这是申请不是直接上架,通过审核后其他人才能安装。" + ), + } diff --git a/src/backend/mcp_servers/agent_manager_mcp/server.py b/src/backend/mcp_servers/agent_manager_mcp/server.py new file mode 100644 index 0000000..9180634 --- /dev/null +++ b/src/backend/mcp_servers/agent_manager_mcp/server.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""streamable-http MCP server:智能体管理(搜索/安装/创建/修改/删除/申请上架子智能体)。 + +用户身份经 HTTP 头注入(由后端 agent_factory 设置): + X-Current-User-Id 当前用户(所有操作按它归属,缺失则拒绝) + +工具直连后端 DB / 复用 UserAgentService·agent_market_service,不跨用户。 +与 skill-manager 的差别:子智能体没有文件产物,所有字段都能直接作为工具入参传, +因此这里不需要沙箱与共享产物库通路;本插件打包的 agent-designer 技能只给"怎么设计"的方法。 +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from mcp.server.fastmcp import Context, FastMCP + +from mcp_servers.agent_manager_mcp import impl + +mcp = FastMCP("hugagent-agent-manager") + +_HDR_USER = "x-current-user-id" + + +def _hdr(ctx: Optional[Context], name: str) -> Optional[str]: + if ctx is None: + return None + try: + v = ctx.request_context.request.headers.get(name) + return v or None + except Exception: + return None + + +def _user(ctx: Optional[Context]) -> str: + return _hdr(ctx, _HDR_USER) or "" + + +@mcp.tool() +async def search_agent_market( + query: str = "", + category: str = "", + ctx: Context | None = None, +) -> Dict[str, Any]: + """搜索子智能体市场,返回可安装的智能体列表(slug/名称/简介/分类/是否已安装)。 + + 用户想"有没有现成的 X 智能体 / 市场里都有什么 / 找一个能做 Y 的智能体"时调用。 + - query:关键词(在 slug/名称/简介/标签/分类里模糊匹配;留空=列全部)。 + - category:按分类过滤(可选)。 + 找到目标后用 install_market_agent(slug) 安装。 + """ + return impl.search_agent_market(user_id=_user(ctx), query=query, category=category) + + +@mcp.tool() +async def install_market_agent( + slug: str, + ctx: Context | None = None, +) -> Dict[str, Any]: + """从市场安装一个子智能体到"我的智能体",装完下一轮对话就能直接指派任务给它。 + + 用户说"装上那个 / 安装 X 智能体 / 我要用它"时调用。slug 取自 search_agent_market。 + 安装是**克隆一份到你名下**,之后随便改都不影响市场原件;它原本绑定的技能和工具会按 + 你账下实际有的资源重新对应,对应结果在 install_report 里,**有对不上被跳过的必须如实 + 告诉用户**,别让用户以为能力全绑上了。 + 【铁律】未成功拿到 ✅ 前不要声称已安装。需要管理员开启"自助添加智能体"权限。 + """ + return impl.install_market_agent(user_id=_user(ctx), slug=slug) + + +@mcp.tool() +async def list_bindable_capabilities(ctx: Context | None = None) -> Dict[str, Any]: + """列出可以绑给子智能体的技能、工具、插件、知识库,每项都带 id 和名称。 + + 【创建或修改子智能体之前必须先调用它拿到准确的 id】——绝不允许凭印象编 id, + 编错了不会报错,只会静默绑不上,用户拿到一个"看起来建好了但没能力"的智能体。 + 返回四组:skills(技能)/ mcp_servers(工具)/ plugins(插件,整体绑)/ kb_spaces(知识库)。 + """ + return impl.list_bindable_capabilities(user_id=_user(ctx)) + + +@mcp.tool() +async def create_agent( + name: str, + description: str, + system_prompt: str, + skill_ids: List[str] | None = None, + mcp_server_ids: List[str] | None = None, + plugin_ids: List[str] | None = None, + kb_ids: List[str] | None = None, + max_iters: int | None = None, + welcome_message: str = "", + ctx: Context | None = None, +) -> Dict[str, Any]: + """从零创建一个属于我的子智能体。 + + 用户说"帮我建一个负责 X 的智能体 / 我想要个专门做 Y 的助手"时调用。 + - name:名字。 + - description:一句话说清它管什么(也是主智能体判断"该不该派活给它"的依据,要具体)。 + - system_prompt:它的行事准则,**是这个智能体的主体**——角色、能做什么、不能做什么、 + 输出成什么样。怎么写见本插件打包的 agent-designer 技能,别糊一段空泛的话交差。 + - skill_ids / mcp_server_ids / plugin_ids / kb_ids:绑给它的能力, + **id 必须来自 list_bindable_capabilities**。宁窄勿宽,只绑真正用得着的。 + - max_iters:一次任务最多干几轮(1–100,默认 10)。 + 建出来的智能体仅自己可见可用,下一轮对话即可指派任务。 + 【铁律】未成功拿到 ✅ 前不要声称已创建。需要管理员开启"自助添加智能体"权限。 + """ + return impl.create_agent( + user_id=_user(ctx), + name=name, + description=description, + system_prompt=system_prompt, + skill_ids=skill_ids, + mcp_server_ids=mcp_server_ids, + plugin_ids=plugin_ids, + kb_ids=kb_ids, + max_iters=max_iters, + welcome_message=welcome_message, + ) + + +@mcp.tool() +async def list_my_agents(ctx: Context | None = None) -> Dict[str, Any]: + """列出"我的子智能体"(agent_id / 名字 / 职责 / 是否启用 / 绑了几项能力)。 + + 用户问"我有哪些智能体 / 我建过什么 / 管一下我的智能体"时调用。 + 拿到 agent_id 后可用 edit_agent 修改、delete_agent 删除、submit_agent_to_market 申请上架。 + 只列你自己建的和装的;平台自带的探索员/执行员/审查员以及管理员下发的智能体不在此列。 + """ + return impl.list_my_agents(user_id=_user(ctx)) + + +@mcp.tool() +async def edit_agent( + agent_ref: str, + name: str | None = None, + description: str | None = None, + system_prompt: str | None = None, + skill_ids: List[str] | None = None, + mcp_server_ids: List[str] | None = None, + plugin_ids: List[str] | None = None, + kb_ids: List[str] | None = None, + max_iters: int | None = None, + is_enabled: bool | None = None, + welcome_message: str | None = None, + ctx: Context | None = None, +) -> Dict[str, Any]: + """修改我的某个子智能体,不用删了重建。agent_ref 传 agent_id 或名字。 + + 用户说"把 X 的职责改一下 / 给它加个技能 / 换个说话的口气 / 让它别再用那个工具"时调用。 + **只改你传的字段,没传的原样保留**(字段级部分更新)。 + - name / description / system_prompt / welcome_message:文本字段。 + - skill_ids / mcp_server_ids / plugin_ids / kb_ids:**整组替换,不是追加**。 + 所以要"加一个"时:先 list_my_agents 看现在绑了什么 → 再 list_bindable_capabilities + 取新 id → 把旧的和新的合并成完整数组一起传,否则会把原有绑定冲掉。 + - max_iters(1–100)/ is_enabled(临时停用或启用)。 + agent_id 不可改;要换 id 只能删了重建。名字匹配到多个时会返回候选, + 必须先让用户指明具体哪一个,禁止猜着改。只能改本人的子智能体。 + 【铁律】未成功拿到 ✅ 前不要声称已修改。需要"自助添加智能体"权限。 + """ + return impl.edit_agent( + user_id=_user(ctx), + agent_ref=agent_ref, + name=name, + description=description, + system_prompt=system_prompt, + skill_ids=skill_ids, + mcp_server_ids=mcp_server_ids, + plugin_ids=plugin_ids, + kb_ids=kb_ids, + max_iters=max_iters, + is_enabled=is_enabled, + welcome_message=welcome_message, + ) + + +@mcp.tool() +async def delete_agent( + agent_ref: str, + ctx: Context | None = None, +) -> Dict[str, Any]: + """删除我的某个子智能体(不可恢复)。agent_ref 传 agent_id 或名字。 + + 用户说"把 X 删了 / 不要那个智能体了 / 移除它"时调用。 + 【铁律】匹配到多个时必须先向用户确认具体哪一个,禁止猜删;未拿到 ✅ 前不要声称已删除。 + 只能删自己创建/安装的,平台内置角色和管理员下发的智能体删不了。 + 如果用户只是暂时不想用,优先建议 edit_agent(is_enabled=false) 停用而不是删除。 + """ + return impl.delete_agent(user_id=_user(ctx), agent_ref=agent_ref) + + +@mcp.tool() +async def submit_agent_to_market( + agent_id: str, + category: str = "", + summary: str = "", + note: str = "", + ctx: Context | None = None, +) -> Dict[str, Any]: + """把我的子智能体申请上架到市场(进管理员审核队列,通过后其他人可安装)。 + + 用户说"把这个分享出去 / 申请上架 / 发布到市场"时调用。agent_id 取自 list_my_agents。 + - category:市场分类,**必须**从这 9 个固定值里挑最贴切的一个(注意与技能市场的分类不同): + 通用助手 / 职场办公 / 商业分析 / 数据分析 / 研发编程 / 翻译写作 / 创意设计 / 政策法务 / 教育科研。 + - summary:一句话简介(可选)。 + - note:给审核管理员的说明(可选)。 + 【说明】这是"申请",不是直接上架;要跟用户说清楚还得等管理员审核通过。 + 需要"自助添加智能体"权限。 + """ + return impl.submit_agent_to_market( + user_id=_user(ctx), + agent_id=agent_id, + category=category, + summary=summary, + note=note, + ) + + +def main() -> None: + from mcp_servers import _serve + + _serve.run(mcp, default_port=9115) + + +if __name__ == "__main__": + main() diff --git a/src/backend/mcp_servers/batch_runner_mcp/server.py b/src/backend/mcp_servers/batch_runner_mcp/server.py index 6c6b9ee..04aafaa 100644 --- a/src/backend/mcp_servers/batch_runner_mcp/server.py +++ b/src/backend/mcp_servers/batch_runner_mcp/server.py @@ -24,56 +24,31 @@ async def batch_plan( text_items: List[str] = [], chat_id: str = "", ) -> Dict[str, Any]: - """批量执行调度器(必读):把"对一组对象逐个做同一件事"打包成可确认的执行计划。 - - ⚠️ **强制规则**:当用户消息包含"批量"、"分别"、"逐个"、"每一个"、"挨个"、 - "依次"、"一个个"、"分别给出"、"分别分析"、"分别处理"、"对每个 X"、 - "对这些 X"、"针对每一项"、"对以下 N 个" 等任意一个表达,**或** 用户在 - 一句话里**枚举了 ≥2 个并列的对象**(公司、城市、文件、主题、人名、产品等), - **或** 用户明确给出了 N("3 家"、"5 个"、"这 10 份")—— 你**必须**调用本 - 工具,**禁止**自己直接回答。 - - ✅ **典型触发场景**(看到任何一个就该调本工具): - • "请分别用一句话评价阿里、腾讯、字节" → 三个对象 → 调用本工具 - • "对这 5 家公司给出经营建议" → 5 个对象 → 调用本工具 - • "上传了一个 Excel,对每行的公司做分析" → xlsx → 调用本工具 - • "这是 3 份合同,逐份提取关键条款" → multi word → 调用本工具 - • "分别介绍北京、上海、深圳" → 3 个对象 → 调用本工具 - - ❌ **不要使用的场景**: - • 单一对象的问答("介绍下阿里巴巴") - • 问知识、概念、规则("什么是零信任架构?") - • 单文档总结("总结这份报告") - - **如何使用:** - - 自然语言枚举 → `text_items` 传入对象数组(如 ["阿里","腾讯","字节"]) - - 上传的文件 → `file_ids` 传入文件 id 列表(从聊天上下文中获取) - - `instruction` 用一句话陈述对每一项要做什么(如"用一句话评价") - - **关键行为:调用本工具后立即停止当前回合,不要再输出任何文字、不要再调用 - 其他工具。** 系统会暂停 SSE 流,弹出确认对话框让用户审阅/修改 prompt 模板, - 用户点确认后**后端会自动逐条执行并把结果实时推送给用户**——你完全不需要 - 自己循环处理每一项,也不要重复调用本工具。 + """批量执行调度器:把"对一组对象逐个做同一件事"打包成可确认的执行计划(只生成计划,不执行)。 + + ⚠️ **强制规则**:用户消息包含"批量/分别/逐个/每一个/挨个/依次/一个个/对每个 X/ + 针对每一项/对以下 N 个"等任一表达,**或**一句话里枚举了 ≥2 个并列对象(公司、 + 城市、文件、主题等),**或**明确给出数量("3 家"、"这 10 份")——**必须**调用 + 本工具,**禁止**自己直接回答。如"请分别用一句话评价阿里、腾讯、字节"→ 调用; + 上传 Excel 要求对每行做分析 → 调用。 + 不适用:单一对象问答、概念解释、单文档总结。 + + 用法:`text_items` 传枚举的对象数组(最常见);`file_ids` 传上传文件 id 列表; + `instruction` 一句话陈述对每一项做什么。 + + **关键行为:调用本工具后立即结束本回合,不要再输出任何文字、不要再调用其他 + 工具。** 系统会弹出确认对话框,用户确认后后端自动逐条执行并实时推送结果—— + 不要自己循环处理每一项,也不要重复调用本工具。 Args: - instruction: 一句话陈述对每一项要做什么(必填),例如"用一句话评价该公司"。 - file_ids: 用户上传的文件 id 列表(按文件批量处理时使用)。 - text_items: 用户在文本里枚举的对象列表(按文本批量处理时使用,最常见)。 - chat_id: 当前会话 id(如能从上下文获取则传入,便于关联 plan 与用户)。 + instruction: 对每一项要做什么(必填)。 + file_ids: 上传文件 id 列表。 + text_items: 文本枚举的对象列表。 + chat_id: 当前会话 id(能从上下文获取则传入)。 Returns: - 计划摘要 dict: - { - "plan_id": str, # 后续确认 + 执行用此 id - "total": int, # 计划中的项目总数 - "preview": [{...}, ...], # 前 3 条 item 预览 - "source_type": str, # xlsx | word_files | text_list - "default_template": str, # 推断出的默认 prompt 模板(用户可改) - "placeholder_keys": [str, ...], # 模板中可用的占位符字段名 - "status": "pending" # 等待用户确认 - } - - 返回后**立即结束本回合**,等待用户确认。 + 计划摘要 dict:{"plan_id", "total", "preview", "source_type", + "default_template", "placeholder_keys", "status": "pending"}。 """ from mcp_servers.batch_runner_mcp._planner import create_plan diff --git a/src/backend/mcp_servers/generate_chart_tool_mcp/server.py b/src/backend/mcp_servers/generate_chart_tool_mcp/server.py index 7530c8e..16e4ab5 100755 --- a/src/backend/mcp_servers/generate_chart_tool_mcp/server.py +++ b/src/backend/mcp_servers/generate_chart_tool_mcp/server.py @@ -16,41 +16,24 @@ @mcp.tool() async def generate_chart_tool(data: str, query: str) -> Dict[str, Any]: - """根据给定数据生成可视化图表(matplotlib),将图片保存到存储并返回结果摘要。 + """根据给定数据生成可视化图表(matplotlib),保存图片并返回结果摘要。 - 适用场景: - - 用户明确要求:画图/绘图/生成图表(折线图、柱状图、饼图等)。 - - 调用规范(严禁跳过): - - **禁止凭空绘图**:必须先通过数据查询工具获取真实数据。 - - 将数据整理为 JSON 字符串传入 data;在 query 中写清:图表类型、标题、坐标轴、单位换算要求等。 + 何时调我: 用户明确要求"画图/绘图/生成图表(折线图、柱状图、饼图等)"时。 + **强制前置**: 必须先通过用户提供的数据、检索结果或指标类工具拿到真实数值再画, + **禁止凭空绘图、禁止编造数据**。用户要的是文字分析而非图表时不要用我。 Args: - data: 绘图数据(JSON 字符串)。例如:{"年份":[2022,2023],"增加值":[123,145]}。 - query: 绘图指令。例如:"画折线图,标题为xxx,单位换算为亿元"。 + data: 绘图数据(JSON 字符串),如 {"年份":[2022,2023],"增加值":[123,145]}。 + query: 绘图指令,写清图表类型、标题、坐标轴、单位换算,如"画折线图,标题为xxx,单位换算为亿元"。 Returns: - dict: {"ok": true, "file_id": "", "url": "/files/", - "name": "chart_xxx.png", "size": 12345, "mime_type": "image/png", - "note": "..."} - 或失败时: {"ok": false, "error": "..."} - - **关键**:图表被保存为一条 artifact(用 `file_id` 标识),它在附件区可下载, - 但**不在沙盒里**。要把它插进 Word / PPT 等沙盒产物,必须先把这个 `file_id` - 拷进沙盒,再让 CLI 引用沙盒里的路径: - 1. `sandbox_put_artifact(artifact_id=<本工具返回的 file_id>, - dest_path="/workspace/chart1.png")` - 2. `word-cli edit … --ops '[{"op":"insert_image", - "image_path":"/workspace/chart1.png", "anchor":"表2", - "position":"after", "width_cm":14}]'` - 不要把 `file_id` 直接当成沙盒路径传给 CLI——CLI 在沙盒里跑,解析不了 artifact id。 - - 调用决策(何时使用我): - - **何时调我**: 用户明确要求"画图 / 绘图 / 生成图表 / 折线图 / 柱状图 / 饼图" - 等可视化产物时。 - - **强制前置步骤**: 必须先通过用户提供的数据、知识库检索结果或已启用的 - 指标类工具/技能拿到真实数值, 再用我画。**禁止凭空绘图、禁止编造数据**。 - - 不要用我: 用户问的是"分析/对比/趋势文字描述"而非图表; 或者还没有任何数据时。 + dict: {"ok": true, "file_id", "url", "name", "size", "mime_type", "note"} + 或失败时 {"ok": false, "error": "..."} + + **关键**:图表保存为 artifact(`file_id` 标识),在附件区可下载但**不在沙盒里**。 + 要插进 Word/PPT 等沙盒产物,必须先 `sandbox_put_artifact(artifact_id=, + dest_path="/workspace/chart1.png")` 拷进沙盒,再让 CLI 引用沙盒路径; + 不要把 `file_id` 直接当沙盒路径传给 CLI(CLI 解析不了 artifact id)。 """ try: diff --git a/src/backend/mcp_servers/internet_search_mcp/server.py b/src/backend/mcp_servers/internet_search_mcp/server.py index 60a14ae..f8fb752 100755 --- a/src/backend/mcp_servers/internet_search_mcp/server.py +++ b/src/backend/mcp_servers/internet_search_mcp/server.py @@ -23,14 +23,11 @@ async def internet_search( include_raw_content: bool = False, cn_only: bool = True, ) -> Dict[str, Any]: - """互联网检索(兜底工具)。 + """互联网检索(兜底工具,优先级最低)。 - 适用场景: - - 当内部数据源无法提供足够信息时,用于补充公开网页/新闻等外部信息。 - - 使用建议: - - 优先让查询更具体(带时间、地区、实体名)。 - - 尽量只在必要时使用,避免用互联网信息替代内部权威数据。 + **仅当**内部知识库、私有知识库、其他已配置工具和搜索/数据类技能都无结果时才调我; + 凡是搜索类技能能覆盖的场景一律走技能不走我(技能经 web_fetch 调专门搜索引擎, + 效果远优于我)。别用互联网信息替代内部权威数据。查询尽量具体(带时间、地区、实体名)。 Args: query: 搜索关键词/问题。 @@ -42,14 +39,6 @@ async def internet_search( Returns: dict: {"result": normalized_search_result} - - 调用决策(何时使用我): - - **优先级**: 兜底(4-最低)。**仅当**内部知识库、私有知识库、其他已配置工具和 - Agent Skills 中的搜索/数据类技能都无结果时, 才作为最后兜底调我。 - - 与搜索类技能的取舍: **凡是搜索类技能能覆盖的场景, 一律走技能, 不走我**。 - 技能通过 web_fetch 调用专门搜索引擎 URL, 效果远优于 internet_search。 - - 不要用我: 当问题已能被内部数据库、知识库或其他专业工具回答时, 别用互联网信息替代 - 内部权威数据。 """ from mcp_servers.internet_search_mcp.impl import internet_search as _impl diff --git a/src/backend/mcp_servers/plugin_manager_mcp/__init__.py b/src/backend/mcp_servers/plugin_manager_mcp/__init__.py new file mode 100644 index 0000000..2681e49 --- /dev/null +++ b/src/backend/mcp_servers/plugin_manager_mcp/__init__.py @@ -0,0 +1,6 @@ +"""插件管理 MCP —— 让智能体在对话里搜索/安装/导入/启停/卸载插件。 + +只放"沙箱够不着的"动词(读插件市场 / 写后端 DB)。插件包的"创作/下载解包"由本插件 +打包的 plugin-creator 技能在沙箱内完成,产物经共享产物库(artifact store)交给 +``import_plugin`` 落库——与 skill-manager 的 register_skill 是同一条通路。 +""" diff --git a/src/backend/mcp_servers/plugin_manager_mcp/impl.py b/src/backend/mcp_servers/plugin_manager_mcp/impl.py new file mode 100644 index 0000000..2150424 --- /dev/null +++ b/src/backend/mcp_servers/plugin_manager_mcp/impl.py @@ -0,0 +1,504 @@ +"""插件管理 MCP —— 业务实现(直连 DB / 复用 plugin_service,按 X-Current-User-Id 归属)。 + +复用 ``plugin_service``(市场列表/详情/安装/导入/启停/卸载)与 ``mcp_servers._packaging`` +(共享产物库读取 + 安全解包)。所有写操作强制按 ``user_id`` 归属,落成该用户的私有安装, +绝不写全局(``owner_user_id=None`` 是管理员通道,自助入口不得触碰)。 + +自锁守卫:本插件自己也在可管理范围内,卸载它、或仅仅停用它,都会让这些工具从能力目录里 +消失,之后再没有工具能把它恢复(用户只能去后台)。守的是这个不变量而非某个动词, +故 ``uninstall_plugin`` 与 ``set_plugin_enabled(enabled=False)`` 都过 ``_guard_self_lockout``。 + +启停一律走 ``*_for_user`` 用户级通路(写每用户覆写),不碰组件的全局 ``is_enabled``—— +后者是管理员通道,且启用态解析"覆写优先",改错层会出现"提示已停用、列表仍显示启用"。 +""" + +from __future__ import annotations + +import logging +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# 本插件自身的 slug —— 自卸载守卫用。 +SELF_SLUG = "plugin-manager" + + +# ── 通用 ──────────────────────────────────────────────────────────────── +def _no_user() -> Dict[str, Any]: + return {"ok": False, "message": "❌ 无法确定用户身份(缺 X-Current-User-Id 头),拒绝操作。"} + + +def _invalidate_user_cache(user_id: Optional[str]) -> None: + """清掉该用户的 30s 能力解析缓存(仅本进程;跨进程失效靠前端重拉 /v1/catalog)。 + + 见 agent_manager_mcp/impl.py 同名函数的说明——两个 MCP 面对的是同一个跨进程缓存问题。 + """ + try: + from core.config.catalog_resolver import invalidate_capability_cache + + invalidate_capability_cache(str(user_id) if user_id else None) + except Exception as exc: # noqa: BLE001 + logger.debug("plugin_manager: invalidate_capability_cache failed (%s)", exc) + + +def _require_cap(db, user_id: str, cap: str) -> Optional[Dict[str, Any]]: + """能力位校验。缺权限返回错误 dict,否则 None。""" + try: + from core.auth.capabilities import resolve_user_capabilities + + if not resolve_user_capabilities(db, user_id).get(cap): + return { + "ok": False, + "message": f"❌ 管理员未开放该能力({cap}),无法执行。请联系管理员在权限设置里开启。", + } + except Exception as exc: # noqa: BLE001 + logger.warning("plugin_manager: capability check failed (%s)", exc) + return {"ok": False, "message": f"❌ 权限校验失败:{exc}"} + return None + + +def _guard_self_lockout(hit: Dict[str, Any], verb: str) -> Optional[Dict[str, Any]]: + """拦住"把自己关掉"。 + + 守的是不变量而不是某个动词:卸载本插件和停用本插件(乃至只停用它的 MCP 组件) + 通向同一个死局——这些工具本身随之从能力目录里消失,就再没有工具能把它开回来。 + 重新启用自己是无害的,只在 enabled=False / 卸载时调用。 + """ + if str(hit.get("slug") or "") != SELF_SLUG: + return None + return { + "ok": False, + "message": ( + f"❌ 不能{verb}「插件管理」插件本身——{verb}之后它的工具会从你的能力里消失," + "就没有工具能把它恢复了。如果确实要这么做,请让用户到管理后台的「插件管理」里操作。" + ), + } + + +def _is_installed(db, slug: str, user_id: str) -> bool: + """该 slug 是否已装(本人私有安装或管理员全局安装)。 + + 按列直查而不是拼 install_id:``slug@owner`` 是 plugin_service 的内部约定, + 在这里复刻一份就等于把它变成公开契约。 + """ + from core.db.models import InstalledPlugin + from sqlalchemy import or_ + + return ( + db.query(InstalledPlugin.install_id) + .filter( + InstalledPlugin.slug == slug, + or_( + InstalledPlugin.owner_user_id == user_id, + InstalledPlugin.owner_user_id.is_(None), + ), + ) + .first() + is not None + ) + + +def _slim_installed(it: Dict[str, Any]) -> Dict[str, Any]: + return { + "install_id": it.get("install_id"), + "slug": it.get("slug"), + "name": it.get("name"), + "description": (str(it.get("description") or ""))[:160], + "category": it.get("category") or "", + "enabled": bool(it.get("enabled")), + "is_global": bool(it.get("is_global")), + "components": { + "skills": len(it.get("skills") or []), + "mcp": len(it.get("mcp") or []), + }, + } + + +def _resolve_installed( + db, user_id: str, ref: str +) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """ref → (唯一命中, 候选)。先精确 install_id,再精确 slug,最后按名称模糊匹配。 + + 只在"我自己装的私有插件"里找:管理员下发的全局插件(is_global)用户改不了, + 在这里当作不存在,免得智能体拿着一个注定失败的 install_id 反复重试。 + """ + from core.services import plugin_service + + ref = (ref or "").strip() + rows = plugin_service.list_installed(db, user_id, include_global=False) or [] + for it in rows: + if str(it.get("install_id")) == ref: + return it, [] + exact_slug = [it for it in rows if str(it.get("slug")) == ref] + if len(exact_slug) == 1: + return exact_slug[0], [] + if len(exact_slug) > 1: + return None, exact_slug[:10] + low = ref.lower() + cands = [it for it in rows if low and low in str(it.get("name") or "").lower()] + if len(cands) == 1: + return cands[0], [] + return None, cands[:10] + + +def _need_clarify(cands: List[Dict[str, Any]]) -> Dict[str, Any]: + return { + "ok": False, + "need_clarification": True, + "message": "匹配到多个插件,请用 install_id 指明具体是哪一个:", + "candidates": [ + {"install_id": c.get("install_id"), "slug": c.get("slug"), "name": c.get("name")} + for c in cands + ], + } + + +# ── ① 搜索插件市场(只读)────────────────────────────────────────────── +def search_plugin_market(*, user_id: str, query: str = "", category: str = "") -> Dict[str, Any]: + if not user_id: + return _no_user() + from core.db.engine import SessionLocal + from core.services import plugin_service + + q = (query or "").strip().lower() + cat = (category or "").strip() + with SessionLocal() as db: + # include_disabled=False:用户侧只看得到已发布的条目,与前端插件市场同口径。 + items = plugin_service.list_plugins(db, user_id, include_disabled=False) or [] + + def _match(it: Dict[str, Any]) -> bool: + if cat and str(it.get("category") or "") != cat: + return False + if not q: + return True + hay = " ".join( + str(it.get(k) or "") + for k in ("slug", "name", "display_name", "description", "category") + ) + return q in hay.lower() + + hits = [it for it in items if _match(it)] + # 展示名由 _overlay_market_meta 直接覆盖进 item["name"],没有单独的 display_name 键。 + slim = [ + { + "slug": it.get("slug"), + "name": it.get("name") or it.get("slug"), + "description": (str(it.get("description") or ""))[:200], + "category": it.get("category") or "", + "installed": bool(it.get("installed")), + } + for it in hits + ] + msg = ( + f"插件市场匹配 {len(slim)} 个" + + (f"(关键词「{query}」)" if q else "") + + (f"(分类「{cat}」)" if cat else "") + + "。建议先用 get_plugin_info(slug) 看看它会带进来什么,再决定装不装。" + ) + return {"ok": True, "count": len(slim), "plugins": slim, "message": msg} + + +# ── ② 插件详情(只读,安装前必看)────────────────────────────────────── +def get_plugin_info(*, user_id: str, slug: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + slug = (slug or "").strip() + if not slug: + return {"ok": False, "message": "❌ 请提供插件 slug。"} + from core.db.engine import SessionLocal + from core.services import plugin_service + + with SessionLocal() as db: + detail = None + try: + detail = plugin_service.get_plugin_detail(slug, db) + except Exception as market_exc: # noqa: BLE001 + # 市场里没有 ≠ 查不到:用户自己 import 进来的插件不在市场目录里, + # 但它确实装着、能停用能卸载。这时回落到"已装详情", + # 否则用户刚导入完一问详情就得到"查不到插件",只能自己猜发生了什么。 + hit, _cands = _resolve_installed(db, user_id, slug) + if hit is None: + return {"ok": False, "message": f"❌ 查不到插件「{slug}」:{market_exc}"} + try: + detail = plugin_service.get_installed_detail( + db, str(hit.get("install_id")), owner_user_id=user_id + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 查不到插件「{slug}」:{exc}"} + # detail 本身不带 installed,这里补一次,免得用户重复安装。 + # 走主键直查而不是 list_installed:后者会连带跑一整套启用态解析 + # (resolve_all_runtime_enabled 约 6~7 次查询)再逐行读插件目录的 plugin.json, + # 结果全被丢掉,只为得到一个布尔值。 + installed = _is_installed(db, slug, user_id) + + skills = detail.get("skills") or [] + mcps = detail.get("mcp") or [] + secrets = list(detail.get("required_secrets") or []) + name = detail.get("name") or slug + return { + "ok": True, + "slug": detail.get("slug") or slug, + "name": name, + "description": detail.get("description") or "", + "category": detail.get("category") or "", + "installed": installed, + "skills": [ + { + "name": s.get("name") or s.get("skill_id"), + "description": (str(s.get("description") or ""))[:160], + } + for s in skills + if isinstance(s, dict) + ], + "mcp_servers": [ + { + "name": m.get("name") or m.get("server_id"), + "description": (str(m.get("description") or ""))[:160], + "tools": list(m.get("tools") or []), + } + for m in mcps + if isinstance(m, dict) + ], + "required_secrets": secrets, + "message": ( + f"插件「{name}」会带进来 {len(skills)} 个技能、{len(mcps)} 个工具" + + (f",需要先准备这些凭据:{'、'.join(str(s) for s in secrets)}" if secrets else "") + + ("。你已经装过它了。" if installed else "。把这些讲给用户听,再问要不要装。") + ), + } + + +# ── ③ 从市场安装(私有,写)──────────────────────────────────────────── +def install_plugin( + *, user_id: str, slug: str, secrets: Optional[Dict[str, str]] = None +) -> Dict[str, Any]: + if not user_id: + return _no_user() + slug = (slug or "").strip() + if not slug: + return {"ok": False, "message": "❌ 请提供要安装的插件 slug。"} + from core.db.engine import SessionLocal + from core.services import plugin_service + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_import_plugin") + if cap_err: + return cap_err + try: + res = plugin_service.install_plugin( + db, + slug, + owner_user_id=user_id, + secrets=secrets or {}, + created_by="agent_plugin_manager", + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 安装失败:{exc}"} + _invalidate_user_cache(user_id) + report = (res or {}).get("import_report") or {} + return { + "ok": True, + "install_id": (res or {}).get("install_id"), + "import_report": report, + "message": f"✅ 已安装插件「{slug}」到你的空间,里面的技能和工具现在就能用。", + } + + +# ── ④ 我装了哪些(只读)──────────────────────────────────────────────── +def list_my_plugins(*, user_id: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + from core.db.engine import SessionLocal + from core.services import plugin_service + + with SessionLocal() as db: + # include_global=True:把管理员下发的全局插件也列出来(用户看得见但改不了), + # 否则用户问"我有哪些插件"时会漏掉一半,进而反复要求安装已经有的东西。 + rows = plugin_service.list_installed(db, user_id, include_global=True) or [] + plugins = [_slim_installed(it) for it in rows] + mine = [p for p in plugins if not p["is_global"]] + msg = ( + f"你有 {len(plugins)} 个插件(其中 {len(mine)} 个是你自己装的,可停用/卸载;" + f"另外 {len(plugins) - len(mine)} 个是管理员下发的全局插件,只能用不能改)。" + if plugins + else "你还没有装任何插件(可用 search_plugin_market 找找)。" + ) + return {"ok": True, "count": len(plugins), "plugins": plugins, "message": msg} + + +# ── ⑤ 从产物库导入插件包(写)────────────────────────────────────────── +def import_plugin(*, user_id: str, artifact_id: str) -> Dict[str, Any]: + """把沙箱产出/网上下载的插件包落库成我的私有插件。 + + 通路与 skill-manager 的 register_skill 一致:沙箱里备好插件目录 → tar/zip 打包 → + sandbox_get_artifact 取得 artifact_id → 本工具从共享产物库读出、安全解包、导入。 + """ + if not user_id: + return _no_user() + artifact_id = (artifact_id or "").strip() + if not artifact_id: + return { + "ok": False, + "message": "❌ 缺少 artifact_id。请先在沙箱里把插件目录打成 tar,再调 sandbox_get_artifact 取得 artifact_id。", + } + + from core.services import plugin_service + from mcp_servers._packaging import is_plugin_root, locate_root, read_artifact_bytes, safe_extract + + data = read_artifact_bytes(artifact_id) + if data is None: + return {"ok": False, "message": f"❌ 产物库里找不到 artifact_id「{artifact_id}」(或已过期)。"} + + from core.db.engine import SessionLocal + + with tempfile.TemporaryDirectory(prefix="plugimp_") as tmp: + tmp_path = Path(tmp) + try: + safe_extract(data, tmp_path) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 解包失败:{exc}"} + + root = locate_root(tmp_path) + if root is None or not is_plugin_root(root): + return { + "ok": False, + "message": ( + "❌ 这个包里没有 plugin.json,不是一个插件包。" + "如果你想装的是单个技能,请改用技能管理插件的 register_skill。" + "插件包该长什么样见 plugin-creator 技能。" + ), + } + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_import_plugin") + if cap_err: + return cap_err + try: + res = plugin_service.import_plugin( + db, root, owner_user_id=user_id, created_by="agent_plugin_manager" + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 插件导入失败:{exc}"} + _invalidate_user_cache(user_id) + report = (res or {}).get("import_report") or {} + return { + "ok": True, + "install_id": (res or {}).get("install_id"), + "import_report": report, + "message": "✅ 已作为插件导入到你的私有空间,里面的技能和工具现在就能用。", + } + + +# ── ⑥ 启停(写)──────────────────────────────────────────────────────── +def set_plugin_enabled( + *, + user_id: str, + plugin_ref: str, + enabled: bool, + component_kind: str = "", + component_id: str = "", +) -> Dict[str, Any]: + if not user_id: + return _no_user() + plugin_ref = (plugin_ref or "").strip() + if not plugin_ref: + return {"ok": False, "message": "❌ 请提供插件的 install_id 或名称。"} + kind = (component_kind or "").strip().lower() + comp = (component_id or "").strip() + if kind and kind not in ("skill", "mcp"): + return {"ok": False, "message": "❌ component_kind 只能是 skill 或 mcp。"} + if bool(kind) != bool(comp): + return {"ok": False, "message": "❌ component_kind 和 component_id 必须同时提供(只关插件里的某个组件时用)。"} + + from core.db.engine import SessionLocal + from core.services import plugin_service + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_import_plugin") + if cap_err: + return cap_err + hit, cands = _resolve_installed(db, user_id, plugin_ref) + if hit is None and cands: + return _need_clarify(cands) + if hit is None: + return { + "ok": False, + "message": f"❌ 没找到你自己安装的插件「{plugin_ref}」(管理员下发的全局插件不能在这里改)。", + } + if not enabled: + lock = _guard_self_lockout(hit, "停用") + if lock: + return lock + + install_id = str(hit.get("install_id")) + try: + # 走用户级通路(写每用户覆写),不是管理员级的全局 is_enabled: + # 启用态解析是"覆写优先",若用户曾在插件页点过开关就会留下覆写, + # 改全局位会被覆写盖掉——工具回了"已停用",list_my_plugins 却仍显示启用。 + if comp: + plugin_service.set_plugin_component_enabled_for_user( + db, install_id, kind=kind, component_id=comp, enabled=bool(enabled), user_id=user_id + ) + else: + plugin_service.set_plugin_enabled_for_user( + db, install_id, enabled=bool(enabled), user_id=user_id + ) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 操作失败:{exc}"} + _invalidate_user_cache(user_id) + word = "启用" if enabled else "停用" + target = f"插件「{hit.get('name')}」里的 {kind} 组件「{comp}」" if comp else f"插件「{hit.get('name')}」" + return { + "ok": True, + "install_id": install_id, + "enabled": bool(enabled), + "message": f"✅ 已{word}{target}。数据都留着,随时可以再改回来。", + } + + +# ── ⑦ 卸载(写,破坏性)──────────────────────────────────────────────── +def uninstall_plugin(*, user_id: str, plugin_ref: str) -> Dict[str, Any]: + if not user_id: + return _no_user() + plugin_ref = (plugin_ref or "").strip() + if not plugin_ref: + return {"ok": False, "message": "❌ 请提供要卸载的插件 install_id 或名称。"} + from core.db.engine import SessionLocal + from core.services import plugin_service + + with SessionLocal() as db: + cap_err = _require_cap(db, user_id, "can_import_plugin") + if cap_err: + return cap_err + hit, cands = _resolve_installed(db, user_id, plugin_ref) + if hit is None and cands: + return _need_clarify(cands) + if hit is None: + return { + "ok": False, + "message": f"❌ 没找到你自己安装的插件「{plugin_ref}」(管理员下发的全局插件不能在这里卸载)。", + } + + lock = _guard_self_lockout(hit, "卸载") + if lock: + return lock + + install_id = str(hit.get("install_id")) + display = hit.get("name") + try: + res = plugin_service.uninstall_plugin(db, install_id, owner_user_id=user_id) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "message": f"❌ 卸载失败:{exc}"} + _invalidate_user_cache(user_id) + removed = res or {} + return { + "ok": True, + "install_id": install_id, + "removed": removed, + "message": ( + f"✅ 已卸载插件「{display}」,它带进来的技能和工具已一并删除。" + "如果只是暂时不想用,下次可以用 set_plugin_enabled 停用,不必卸载。" + ), + } diff --git a/src/backend/mcp_servers/plugin_manager_mcp/server.py b/src/backend/mcp_servers/plugin_manager_mcp/server.py new file mode 100644 index 0000000..e3bf8a0 --- /dev/null +++ b/src/backend/mcp_servers/plugin_manager_mcp/server.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""streamable-http MCP server:插件管理(搜索/详情/安装/导入/启停/卸载插件)。 + +用户身份经 HTTP 头注入(由后端 agent_factory 设置): + X-Current-User-Id 当前用户(所有操作按它归属,缺失则拒绝) + +工具直连后端 DB / 复用 plugin_service,不跨用户。插件包的创作与下载由本插件打包的 +plugin-creator 技能在沙箱内完成,产物经共享产物库交给 import_plugin 落库—— +与 skill-manager 的 register_skill 是同一条通路。 +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from mcp.server.fastmcp import Context, FastMCP + +from mcp_servers.plugin_manager_mcp import impl + +mcp = FastMCP("hugagent-plugin-manager") + +_HDR_USER = "x-current-user-id" + + +def _hdr(ctx: Optional[Context], name: str) -> Optional[str]: + if ctx is None: + return None + try: + v = ctx.request_context.request.headers.get(name) + return v or None + except Exception: + return None + + +def _user(ctx: Optional[Context]) -> str: + return _hdr(ctx, _HDR_USER) or "" + + +@mcp.tool() +async def search_plugin_market( + query: str = "", + category: str = "", + ctx: Context | None = None, +) -> Dict[str, Any]: + """搜索插件市场,返回可安装的插件列表(slug/名称/分类/简介/是否已安装)。 + + 用户想"有没有能连飞书的插件 / 插件市场里有什么 / 找个能做 X 的插件"时调用。 + - query:关键词(在 slug/名称/简介/分类里模糊匹配;留空=列全部)。 + - category:按分类过滤(可选)。 + 找到目标后**建议先用 get_plugin_info(slug)** 看看它会带进来什么,再决定装不装。 + """ + return impl.search_plugin_market(user_id=_user(ctx), query=query, category=category) + + +@mcp.tool() +async def get_plugin_info( + slug: str, + ctx: Context | None = None, +) -> Dict[str, Any]: + """查看某个插件的详情:会给你装进来哪些技能、哪些工具,需不需要填 API Key 之类的凭据。 + + 【安装前先调它,把"装了会多出什么"讲给用户听】,别让用户装完才发现还要填密钥。 + 用户问"这插件是干嘛的 / 装了会怎样 / 它安全吗"时也用它。 + 返回里的 required_secrets 就是安装时要准备的凭据清单。 + """ + return impl.get_plugin_info(user_id=_user(ctx), slug=slug) + + +@mcp.tool() +async def install_plugin( + slug: str, + secrets: Dict[str, str] | None = None, + ctx: Context | None = None, +) -> Dict[str, Any]: + """从插件市场安装一个插件到我的空间,装完里面的技能和工具立即可用。 + + 用户说"装上它 / 安装 X 插件 / 把这个能力加上"时调用。slug 取自 search_plugin_market。 + - secrets:按插件 required_secrets 的 key 传入凭据,例如 {"FIRECRAWL_API_KEY":"..."}。 + 如果该插件需要凭据而你没传,会返回缺哪些 key——这时**先向用户要**,拿到后重试, + 不要自己编一个假的填进去。 + 【铁律】未成功拿到 ✅ 前不要声称已安装。需要管理员开启"自助导入插件"权限。 + """ + return impl.install_plugin(user_id=_user(ctx), slug=slug, secrets=secrets or {}) + + +@mcp.tool() +async def list_my_plugins(ctx: Context | None = None) -> Dict[str, Any]: + """列出我的插件(install_id / slug / 名称 / 是否启用 / 带了几个组件)。 + + 用户问"我装了哪些插件 / 管一下我的插件"时调用。 + 列表里 is_global=true 的是管理员下发的全局插件——**你只能看,不能停用也不能卸载**; + 其余是用户自己装的,可以用 set_plugin_enabled 停用启用、uninstall_plugin 卸载。 + """ + return impl.list_my_plugins(user_id=_user(ctx)) + + +@mcp.tool() +async def import_plugin( + artifact_id: str, + ctx: Context | None = None, +) -> Dict[str, Any]: + """把一个插件包导入成我的插件(用于"从网址装"或"自己攒一个")。 + + 【配合 plugin-creator 技能使用】标准流程: + 1. 在沙箱 /workspace 里准备好插件目录(含 plugin.json;从网址装就先 curl 下载解压)。 + 2. 用 plugin-creator 带的自检脚本过一遍,别把结构不对的包导进去。 + 3. 打包:bash `tar -czf /workspace/plugin.tgz -C <插件目录> .` + 4. 调框架自带的 sandbox_get_artifact("/workspace/plugin.tgz") 取得 artifact_id。 + 5. 把该 artifact_id 传给本工具落库。 + 包里必须有 plugin.json;如果用户其实只想装一个单独的技能,请改用技能管理插件的 + register_skill,而不是硬塞进这里。导入结果始终是仅自己可见的私有插件。 + 【铁律】未成功拿到 ✅ 前不要声称已导入。需要"自助导入插件"权限。 + """ + return impl.import_plugin(user_id=_user(ctx), artifact_id=artifact_id) + + +@mcp.tool() +async def set_plugin_enabled( + plugin_ref: str, + enabled: bool, + component_kind: str = "", + component_id: str = "", + ctx: Context | None = None, +) -> Dict[str, Any]: + """停用或重新启用一个已装插件,数据都留着,随时可以再打开。 + + 用户说"先别用那个插件 / 把它关掉 / 重新打开 / 那个工具太吵"时调用。 + - plugin_ref:install_id、slug 或名称(取自 list_my_plugins)。 + - enabled:true=启用,false=停用。 + - component_kind + component_id:只关插件里的**某一个**组件而不是整个插件时用, + kind 取 skill 或 mcp,两个参数必须同时给。 + 【重要】用户只是嫌某个工具碍事的时候,**优先用停用而不是卸载**——卸载会删数据且不可恢复。 + 只能操作自己安装的插件;管理员下发的全局插件在这里改不了。 + """ + return impl.set_plugin_enabled( + user_id=_user(ctx), + plugin_ref=plugin_ref, + enabled=enabled, + component_kind=component_kind, + component_id=component_id, + ) + + +@mcp.tool() +async def uninstall_plugin( + plugin_ref: str, + ctx: Context | None = None, +) -> Dict[str, Any]: + """卸载一个插件,并把它带进来的技能和工具一并删除(不可恢复)。 + + plugin_ref 传 install_id、slug 或名称(取自 list_my_plugins)。 + 【铁律】卸载前**必须先用 list_my_plugins 或 get_plugin_info 列出"会被一起删掉哪些东西" + 给用户确认**,得到明确同意才动手;匹配到多个时必须先问清楚是哪一个,禁止猜删。 + 未拿到 ✅ 前不要声称已卸载。 + 只能卸载自己安装的插件;管理员下发的全局插件卸不了,本插件(插件管理)自己也卸不了。 + 如果用户只是暂时不想用,改用 set_plugin_enabled 停用。 + """ + return impl.uninstall_plugin(user_id=_user(ctx), plugin_ref=plugin_ref) + + +def main() -> None: + from mcp_servers import _serve + + _serve.run(mcp, default_port=9116) + + +if __name__ == "__main__": + main() diff --git a/src/backend/mcp_servers/retrieve_dataset_content_mcp/server.py b/src/backend/mcp_servers/retrieve_dataset_content_mcp/server.py index a33583a..e081f22 100755 --- a/src/backend/mcp_servers/retrieve_dataset_content_mcp/server.py +++ b/src/backend/mcp_servers/retrieve_dataset_content_mcp/server.py @@ -150,42 +150,27 @@ def _get_header(ctx: Optional[Context], name: str) -> Optional[str]: _BASE_TOOL_DESCRIPTION = """从"知识库/数据集"检索政策文件、报告、非结构化文本片段。默认自动搜索所有可用数据集。 -⚠️ 【必须遵守的引用规则】 -回答中引用本工具返回的任何内容时,**必须**带引用标记:把该条目自带的 `cite_id`(如 `e7`)原样复制进 `[锚文本](cite:e7)` 或句末 `[来源](cite:e7)`,禁止自行编号。 -不带引用标记的回答视为不完整,前端将无法展示引用来源卡片。 -示例:根据报告,2024年工业增加值增速为5.2%[来源](cite:e7)。 - -适用场景(当用户问题涉及以下内容时,应**主动**调用本工具,无需等待用户显式要求): -- 政策文件原文、解读、申报条件 -- 产业分析报告、行业研究、发展规划 -- 企业调研材料、项目申报书 -- 工业经济运行分析、统计公报等非结构化文本 - -调用说明: -- **dataset_id 默认留空即可**,系统会自动搜索所有可用数据集并返回最相关的结果。 -- 仅当用户明确指定要从某个特定知识库搜索时,才传入对应的 dataset_id。 -- 返回的是记录列表;回答时应从每条记录的 `segment -> content` 提取要点。 +引用规则:引用返回内容时必须把条目自带的 `cite_id`(如 `e7`)原样写成 `[锚文本](cite:e7)` 标记,禁止自行编号。 + +适用场景(涉及以下内容时**主动**调用,无需用户显式要求):政策文件原文/解读/申报条件、 +产业分析报告、行业研究、发展规划、企业调研材料、经济运行分析等非结构化文本。 + +调用说明:**dataset_id 默认留空**(自动搜索所有可用数据集),仅当用户指定某个知识库时才传。 +回答时从每条记录的 `segment -> content` 提取要点。 Args: query: 检索 query。 - dataset_id: 数据集 ID(默认为空,自动搜索所有数据集;仅当用户指定特定知识库时才填写)。 + dataset_id: 数据集 ID(默认空 = 搜全部)。 top_k: 返回片段数量。 score_threshold: 相似度阈值。 search_method: 检索方式(默认 hybrid_search)。 reranking_enable: 是否启用重排。 weights: 混合检索权重。 -Returns: - dict: {"items": [records...]} - -调用决策(何时使用我): -- **优先级**: 高。涉及政策/报告/规划/解读类原文检索时第一优先级。 -- 与结构化指标能力的取舍: 我返回的是文档"原文片段"; 指标类工具或技能返回数仓里 - 的"结构化数字"。要数字走已启用的指标类能力, 要文段走我。 -- 与 retrieve_local_kb 的取舍: 我覆盖公有/共享知识库; retrieve_local_kb 只查用户 - 自己上传的私有库。两者不冲突时可并行调用。 -- 与 internet_search 的取舍: 内部能找到就别走外网。internet_search 只在我和 - retrieve_local_kb 都没结果时作为兜底。 +调用决策: +- 优先级高:政策/报告/规划类原文检索第一优先级。要"结构化数字"走指标类能力,要文段走我。 +- retrieve_local_kb 只查用户私有库,我查公有/共享库,不冲突时可并行。 +- 内部能找到就别走外网,internet_search 只作兜底。 """ @@ -264,20 +249,15 @@ async def retrieve_dataset_content( # ── List datasets tool ──────────────────────────────────────────────────────── -_LIST_DATASETS_DESCRIPTION = """列出当前可用的所有知识库(公有 + 私有),包含每个知识库的名称、简介和文档列表。 +_LIST_DATASETS_DESCRIPTION = """列出当前可用的所有知识库(公有 + 私有),含名称、简介和文档列表。 -适用场景: -- 用户询问"有哪些知识库"、"有什么数据集"、"知识库列表"等。 -- 用户想了解可以查询哪些资料来源。 -- 在不确定应该查哪个知识库时,先调用本工具查看可用列表,再用 retrieve_dataset_content 或 retrieve_local_kb 进行检索。 +适用场景:用户问"有哪些知识库/数据集";或不确定该查哪个库时,先看列表再检索。 Returns: dict: {"public_datasets": [...], "private_datasets": [...], "total": N} - - public_datasets:公有/共享知识库(含外接数据集与本地公有库)。带 dataset_id 的用 - retrieve_dataset_content 检索;带 kb_id 的(本地公有库)用 retrieve_local_kb 检索。 - - private_datasets:仅当前用户自己的私有库(kb_id),用 retrieve_local_kb 检索。 - 用户问"有几个公有知识库 / 公有库列表"时以 public_datasets 为准,不要把本地公有库当私有库。 - 每个知识库包含:id/名称/简介/文档数量/文档标题列表/type(public|private) + 带 dataset_id 的用 retrieve_dataset_content 检索;带 kb_id 的用 retrieve_local_kb 检索。 + private_datasets 仅含当前用户自己的私有库;问"有几个公有库"以 public_datasets 为准, + 不要把本地公有库当私有库。 """ @@ -336,37 +316,24 @@ async def list_datasets( _BASE_LOCAL_KB_TOOL_DESCRIPTION = """从用户私有知识库中检索相关内容。 -⚠️ 【必须遵守的引用规则】 -回答中引用本工具返回的任何内容时,**必须**带引用标记:把该条目自带的 `cite_id`(如 `e7`)原样复制进 `[锚文本](cite:e7)` 或句末 `[来源](cite:e7)`,禁止自行编号。 -不带引用标记的回答视为不完整,前端将无法展示引用来源卡片。 -示例:项目总投资额为3.5亿元[来源](cite:e7)。 +引用规则:引用返回内容时必须把条目自带的 `cite_id`(如 `e7`)原样写成 `[锚文本](cite:e7)` 标记,禁止自行编号。 -适用场景(当用户问题涉及以下内容时,应**主动**调用本工具,无需等待用户显式要求): -- 用户私人上传的文档(项目材料、个人笔记、专属报告等) -- 用户提问中出现了下方"当前可用私有知识库"列表里的知识库名称或文档名称 +适用场景(**主动**调用,无需用户显式要求):用户私人上传的文档(项目材料、个人笔记、 +专属报告等);用户提问中出现了下方"当前可用私有知识库"列表里的库名或文档名。 -调用说明: -- 如不确定有哪些私有知识库可用,请先调用 `list_datasets` 工具查看完整知识库列表及其文档目录。 -- 如果下方有"当前可用私有知识库"列表,kb_id 应从中选择。 -- 如果没有列表或不确定 kb_id,可以传空字符串 "",系统会自动搜索用户所有私有知识库。 -- 返回结果包含 available_kbs(可用知识库列表)和 items(检索结果)。 -- 每条 item 含 id, title, content, kb_id, score。 +调用说明:kb_id 从下方"当前可用私有知识库"列表选择;没有列表或不确定时传空字符串 "" +(自动搜索用户所有私有库),或先调 `list_datasets` 看完整列表。 Args: - kb_id: 私有知识库 ID(可传空字符串以搜索所有私有库)。 + kb_id: 私有知识库 ID(空字符串 = 搜全部私有库)。 query: 检索问题。 top_k: 返回片段数量(默认 10)。 Returns: - dict: {"available_kbs": [{"kb_id": "...", "name": "..."}], "items": [{"title": "...", "content": "...", "kb_id": "...", "score": ...}]} - -调用决策(何时使用我): -- **优先级**: 高。用户问到自己上传的文档/项目材料/个人笔记/专属报告时第一优先级。 -- 与 retrieve_dataset_content 的取舍: 我只查用户私有知识库(kb_id 以 kb_ 开头); - retrieve_dataset_content 查公有数据集。如果用户没明说"我上传的"还是"政策文件", - 两者都试一遍。 -- kb_id 不确定: 先调 list_datasets 拿可用列表,或直接传 ""(空字符串)让系统搜全量 - 私有库。 + dict: {"available_kbs": [...], "items": [{"title","content","kb_id","score"}]} + +调用决策: 用户问自己上传的文档时第一优先级。我只查私有库,retrieve_dataset_content +查公有数据集;用户没明说是哪类时两者都试一遍。 """ @@ -513,23 +480,18 @@ async def _run_wiki(tool: str, call, *, empty: Dict[str, Any]) -> Dict[str, Any] } -_WIKI_OVERVIEW_DESCRIPTION = """查看某个知识库的**结构地图总览**:一共有哪些概念、规模多大、哪些是主干概念。 +_WIKI_OVERVIEW_DESCRIPTION = """查看某个知识库的**结构地图总览**:有哪些概念、规模多大、哪些是主干。 -适用场景: -- 用户问"这个知识库里有什么"、"都涵盖哪些方面"、"整体讲了什么"。 -- 你不确定该从哪里查起时,先看总览找主干,再用 wiki_locate 精确定位。 +适用:用户问"这个知识库里有什么/涵盖哪些方面";或不确定从哪查起时先看总览,再用 +wiki_locate 定位。属"探路"工具,不直接产出答案(list_datasets 答"有哪些库",我答 +"某个库内部结构长什么样")。 Args: - dataset_id: 知识库 ID(留空自动选用当前可用的知识库)。 - limit: 返回多少个枢纽概念(默认 20)。 + dataset_id: 知识库 ID(留空自动选用)。 + limit: 返回枢纽概念数(默认 20)。 Returns: dict: {"total_pages", "pages_by_type", "total_links", "hub_pages": [...]} - -调用决策(何时使用我): -- **优先级**: 中。属于"探路"工具,不直接产出答案。 -- 与 list_datasets 的取舍: list_datasets 回答"有哪些知识库"; 我回答"某个知识库 - 内部的知识结构长什么样"。 """ @@ -555,34 +517,23 @@ async def wiki_overview( _WIKI_LOCATE_DESCRIPTION = """【第①步·定位】在知识库的**概念地图**上定位问题落在哪些概念/实体上。 -知识库为每篇文档抽出了概念页和实体页,并把它们互相链接成一张图。本工具按关键词 -命中这些页面,返回标题、摘要和关系数量——**不返回长正文**,它只负责告诉你"该看哪里"。 - -典型三步用法: -1. `wiki_locate` 定位到相关概念页; -2. 需要看全貌时 `wiki_expand` 沿关系展开; -3. **必须**用 `wiki_fetch_source` 顺血缘取回原文,再据原文作答。 +按关键词命中概念/实体页,返回标题、摘要和关系数量——**不返回长正文**,只告诉你"该看哪里"。 +三步用法:① wiki_locate 定位 → ② 需要全貌时 wiki_expand 展开 → ③ **必须** wiki_fetch_source +取回原文再作答。⚠️ summary 是模型二次加工的概述,不能直接当答案,一律以取回的原文为准。 -⚠️ 不要直接拿本工具返回的 summary 当答案——那是模型二次加工过的概述,可能失真。 -答案与出处一律以 wiki_fetch_source 取回的原文为准。 - -命中为空时的补救(按顺序试): -- 换更书面的术语(口语说法常常匹配不上,如"牌照"对不上《运营资质证书》); -- 用正则交替一次给多个说法:`资质|牌照|证书`; -- 仍为空则改用 retrieve_dataset_content 走原文语义检索。 +命中为空时按顺序补救:换更书面的术语("牌照"常对不上《运营资质证书》)→ 用正则交替 +(`资质|牌照|证书`)→ 仍为空改用 retrieve_dataset_content 语义检索。 Args: query: 检索词,支持正则交替(如 `编制|员额`)。 - dataset_id: 知识库 ID(留空自动选用当前可用的知识库)。 + dataset_id: 知识库 ID(留空自动选用)。 limit: 返回条数(默认 8)。 Returns: dict: {"pages": [{"slug","title","type","summary","related_count","source_doc_count"}]} -调用决策(何时使用我): -- **优先级**: 高。问题涉及"某个概念/机构/制度是什么、和什么有关"时先走我。 -- 与 retrieve_dataset_content 的取舍: 我做**定位**(快、准、给结构); 它做**语义 - 召回**(口语化提问更稳)。术语明确走我,口语化或我命中为空走它。 +调用决策: 问"某概念/机构/制度是什么、和什么有关"且术语明确时先走我;口语化提问或 +我命中为空时走 retrieve_dataset_content 语义召回。 """ @@ -608,24 +559,18 @@ async def wiki_locate( return await _run_wiki("wiki_locate", call, empty={"pages": []}) -_WIKI_READ_PAGE_DESCRIPTION = """读取某个 Wiki 概念页的完整内容与关系。 - -先用 wiki_locate 拿到 slug,再用本工具精读。返回的正文里 `[[slug|显示名]]` 是指向 -其他概念页的链接,可以继续读。 +_WIKI_READ_PAGE_DESCRIPTION = """读取某个 Wiki 概念页的完整内容与关系(wiki_locate 之后的精读步骤)。 -⚠️ 页面正文是模型综合原文写成的**概述**,作答的事实依据应来自 wiki_fetch_source -取回的原文分块。 +正文里 `[[slug|显示名]]` 是指向其他概念页的链接。⚠️ 正文是模型综合原文写的**概述**, +作答的事实依据应来自 wiki_fetch_source 取回的原文;只要事实和出处时可跳过我直接 +wiki_fetch_source。 Args: slug: 页面标识,形如 `entity/example-city` 或 `concept/xin-yong`。 - dataset_id: 知识库 ID(留空自动选用当前可用的知识库)。 + dataset_id: 知识库 ID(留空自动选用)。 Returns: dict: {"title","type","content","related_pages","referenced_by","has_source"} - -调用决策(何时使用我): -- **优先级**: 中。wiki_locate 之后的精读步骤。 -- 只想要事实和出处、不需要概览时,可以跳过我直接 wiki_fetch_source。 """ @@ -649,24 +594,20 @@ async def wiki_read_page( return await _run_wiki("wiki_read_page", call, empty={}) -_WIKI_EXPAND_DESCRIPTION = """【第②步·展开】沿概念之间的关系,把与某个概念相关的其他概念一次拉齐。 +_WIKI_EXPAND_DESCRIPTION = """【第②步·展开】沿概念间关系把与某概念相关的其他概念一次拉齐。 -**聚合型问题的关键一步。**"一共有几类"、"彼此什么依赖"这种问题,靠相似度取前 N -个片段天然答不全——必须沿着概念图把该看的都找齐,再逐个回原文核实。 +聚合型问题("一共有几类""哪些""分别""彼此什么关系")的关键一步——相似度取前 N 个 +片段天然答不全,必须沿概念图找齐再逐个回原文核实。单点事实问题不需要我,locate 完 +直接 fetch_source。truncated=true 表示邻域被截断,作答时应说明、别声称已穷尽。 Args: slug: 中心概念的 slug(来自 wiki_locate)。 - dataset_id: 知识库 ID(留空自动选用当前可用的知识库)。 - depth: 展开层数,1=直接相关,2=再往外一层(默认 1,最大 3)。 - limit: 最多返回多少个相关概念(默认 30)。 + dataset_id: 知识库 ID(留空自动选用)。 + depth: 展开层数(默认 1,最大 3)。 + limit: 最多返回相关概念数(默认 30)。 Returns: - dict: {"nodes": [{"slug","title","type","link_count"}], "edges": [...], "truncated": bool} - -调用决策(何时使用我): -- **优先级**: 中高。问题里出现"一共""哪些""分别""彼此关系""全部要求"时用我。 -- 单点事实问题(某个数值、某个期限)不需要我,locate 完直接 fetch_source。 -- truncated=true 说明邻域被截断了,作答时应说明这一点,别声称已穷尽。 + dict: {"nodes": [...], "edges": [...], "truncated": bool} """ @@ -694,30 +635,21 @@ async def wiki_expand( return await _run_wiki("wiki_expand", call, empty={"nodes": [], "edges": []}) -_WIKI_FETCH_SOURCE_DESCRIPTION = """【第③步·取原文】顺着 Wiki 页面记录的血缘坐标,取回它所依据的**原始文档段落**。 - -这是按 ID 直接取回,不是再检索一次——所以既快又不会取错段落。 +_WIKI_FETCH_SOURCE_DESCRIPTION = """【第③步·取原文】按 Wiki 页面记录的血缘坐标直接取回它所依据的**原始文档段落**(按 ID 直取,非再检索)。 -⚠️ 【必须遵守的引用规则】 -回答中引用本工具返回的任何内容时,**必须**带引用标记:把该条目自带的 `cite_id` -(如 `e7`)原样复制进 `[锚文本](cite:e7)` 或句末 `[来源](cite:e7)`,禁止自行编号。 -不带引用标记的回答视为不完整,前端将无法展示引用来源卡片。 -示例:《运营资质证书》有效期为五年[来源](cite:e7)。 +走了 wiki_locate / wiki_expand 之后**必须**走我再作答——事实依据必须来自本工具返回 +的原文,不要用 Wiki 概述代替。已定位到概念页用我;没定位到才用 retrieve_dataset_content +重新语义检索。 -**作答的事实依据必须来自本工具返回的原文**,不要用 Wiki 页面的概述代替原文。 +引用规则:引用返回内容时必须把条目自带的 `cite_id`(如 `e7`)原样写成 `[锚文本](cite:e7)` 标记,禁止自行编号。 Args: slug: 页面标识(来自 wiki_locate / wiki_expand)。 - dataset_id: 知识库 ID(留空自动选用当前可用的知识库)。 + dataset_id: 知识库 ID(留空自动选用)。 max_chunks: 最多取回几段原文(默认 6)。 Returns: dict: {"wiki_page": "...", "items": [{"文件名称","文件内容","document_id","chunk_id"}]} - -调用决策(何时使用我): -- **优先级**: 高。走了 wiki_locate / wiki_expand 之后**必须**走我再作答。 -- 与 retrieve_dataset_content 的取舍: 我是"按坐标直取已定位的原文"; 它是"重新做一次 - 语义检索"。已经定位到概念页就用我,没定位到才用它。 """ diff --git a/src/backend/mcp_servers/skill_manager_mcp/impl.py b/src/backend/mcp_servers/skill_manager_mcp/impl.py index 0d2b8f7..8b8de23 100644 --- a/src/backend/mcp_servers/skill_manager_mcp/impl.py +++ b/src/backend/mcp_servers/skill_manager_mcp/impl.py @@ -11,21 +11,17 @@ from __future__ import annotations -import io import logging -import os -import tarfile import tempfile -import zipfile from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -logger = logging.getLogger(__name__) +# 产物库读取与 tar/zip 安全解包(含解包上限)搬到了 _packaging.py,与 plugin-manager 共用: +# 同一条"沙箱产物 → 落库"通路,zip-bomb 与目录穿越防护只应有一份。 +from mcp_servers._packaging import locate_root, read_artifact_bytes, safe_extract -# tar/zip 解包安全上限(防 zip-bomb / 超大产物) -_MAX_ENTRIES = 4000 -_MAX_TOTAL_BYTES = 64 * 1024 * 1024 # 64 MB 解压后总量 +logger = logging.getLogger(__name__) # edit_skill 经工具入参写入单个附属文件的上限(智能体传的是 UTF-8 文本,非二进制大文件) _MAX_SKILL_FILE_BYTES = 5 * 1024 * 1024 # 5 MB @@ -208,7 +204,7 @@ def register_skill( if not artifact_id: return {"ok": False, "message": "❌ 缺少 artifact_id。请先在沙箱里把技能目录打成 tar 并调 sandbox_get_artifact 取得 artifact_id。"} - data = _read_artifact_bytes(artifact_id) + data = read_artifact_bytes(artifact_id) if data is None: return {"ok": False, "message": f"❌ 产物库里找不到 artifact_id「{artifact_id}」(或已过期)。"} @@ -217,11 +213,11 @@ def register_skill( with tempfile.TemporaryDirectory(prefix="skreg_") as tmp: tmp_path = Path(tmp) try: - _safe_extract(data, tmp_path) + safe_extract(data, tmp_path) except Exception as exc: # noqa: BLE001 return {"ok": False, "message": f"❌ 解包失败:{exc}"} - root = _locate_root(tmp_path) + root = locate_root(tmp_path) if root is None: return { "ok": False, @@ -668,106 +664,6 @@ def _resolve_skill(db, user_id: str, ref: str) -> Tuple[Optional[Any], List[Any] return None, cands -# ── 产物库 / 解包 辅助 ──────────────────────────────────────────────────── -def _read_artifact_bytes(file_id: str) -> Optional[bytes]: - """从共享产物库按 file_id 读回字节(local: 读 path;oss: download_bytes)。""" - try: - from core.artifacts import store - - meta = store.get_artifact(file_id) - if not meta: - return None - path = meta.get("path") - if path and os.path.isfile(path): - with open(path, "rb") as fh: - return fh.read() - key = meta.get("storage_key") - if key: - from core.storage import get_storage - - return bytes(get_storage().download_bytes(key)) - except Exception as exc: # noqa: BLE001 - logger.warning("skill_manager: read artifact %s failed (%s)", file_id, exc) - return None - - -def _safe_extract(data: bytes, dest: Path) -> None: - """安全解包 tar(.gz) 或 zip 到 dest:拦目录穿越 + 限条目数/总量。""" - if _looks_like_zip(data): - _safe_extract_zip(data, dest) - else: - _safe_extract_tar(data, dest) - - -def _looks_like_zip(data: bytes) -> bool: - return data[:2] == b"PK" - - -def _within(base: Path, target: Path) -> bool: - try: - target.resolve().relative_to(base.resolve()) - return True - except ValueError: - return False - - -def _safe_extract_tar(data: bytes, dest: Path) -> None: - total = 0 - with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: - members = tf.getmembers() - if len(members) > _MAX_ENTRIES: - raise ValueError(f"包内条目过多({len(members)} > {_MAX_ENTRIES})") - for m in members: - if not (m.isfile() or m.isdir()): - continue # 跳过软链接/设备等,杜绝越权 - target = dest / m.name - if not _within(dest, target): - raise ValueError(f"检测到目录穿越条目:{m.name}") - total += max(m.size, 0) - if total > _MAX_TOTAL_BYTES: - raise ValueError("解压后总量超限(>64MB)") - tf.extractall(dest, members=[m for m in members if m.isfile() or m.isdir()]) - - -def _safe_extract_zip(data: bytes, dest: Path) -> None: - total = 0 - with zipfile.ZipFile(io.BytesIO(data)) as zf: - infos = zf.infolist() - if len(infos) > _MAX_ENTRIES: - raise ValueError(f"包内条目过多({len(infos)} > {_MAX_ENTRIES})") - for info in infos: - target = dest / info.filename - if not _within(dest, target): - raise ValueError(f"检测到目录穿越条目:{info.filename}") - total += info.file_size - if total > _MAX_TOTAL_BYTES: - raise ValueError("解压后总量超限(>64MB)") - zf.extractall(dest) - - -def _locate_root(extracted: Path) -> Optional[Path]: - """定位技能/插件根:含 SKILL.md 或 plugin.json 的目录。tar 常多包一层。""" - def _is_root(d: Path) -> bool: - return ( - (d / "SKILL.md").is_file() - or (d / "plugin.json").is_file() - or (d / ".claude-plugin" / "plugin.json").is_file() - ) - - if _is_root(extracted): - return extracted - subdirs = [p for p in extracted.iterdir() if p.is_dir()] - for d in subdirs: - if _is_root(d): - return d - # 再下探一层 - for d in subdirs: - for dd in (p for p in d.iterdir() if p.is_dir()): - if _is_root(dd): - return dd - return None - - def _slugify(name: str) -> str: import re diff --git a/src/backend/mcp_servers/web_fetch_mcp/server.py b/src/backend/mcp_servers/web_fetch_mcp/server.py index b694015..054979b 100644 --- a/src/backend/mcp_servers/web_fetch_mcp/server.py +++ b/src/backend/mcp_servers/web_fetch_mcp/server.py @@ -20,35 +20,20 @@ async def web_fetch( extractMode: str = "text", maxChars: int = 50000, ) -> Dict[str, Any]: - """抓取指定网页 URL 的内容并提取正文。当用户要求“抓取”、“爬取”网页内容时,可以使用该工具进行网站数据抓取和正文提取。 + """抓取指定网页 URL 的内容并提取正文。 - 适用场景: - - 需要获取某个网页的正文内容进行分析或总结。 - - 搭配搜索引擎结果,抓取具体页面详情。 - - 提取网页中的关键信息(文本、Markdown 或原始 HTML)。 - - 使用建议: - - extractMode="text" 适合纯文本提取(默认)。 - - extractMode="markdown" 保留标题、链接、列表等结构。 - - extractMode="html" 返回原始 HTML,适合需要精确解析的场景。 - - maxChars 控制返回内容长度,避免过长影响后续处理。 + 何时调我: ① 搜索类技能的 SKILL.md 指令显式要求调用; ② 用户明确要求"抓取/爬取/ + 打开"某个具体 URL 并提取正文。用户没给 URL 也没在执行搜索技能时不要自行抓网页, + 找资料先走检索工具。internet_search 返回搜索结果列表(标题+摘要+url), 我返回单个 + 页面正文——通常"先 internet_search 拿 url, 再 web_fetch 取正文"两步走。 Args: url: 要抓取的网页 URL。 - extractMode: 提取模式,可选 "text"、"markdown"、"html"。 - maxChars: 最大返回字符数(超出部分截断),默认 50000。 + extractMode: "text"(默认,纯文本)/ "markdown"(保留结构)/ "html"(原始 HTML)。 + maxChars: 最大返回字符数(超出截断),默认 50000。 Returns: dict: {"result": extracted_content} 或 {"error": "...", "result": ""} - - 调用决策(何时使用我): - - **何时调我**: ① 由搜索类技能(如"中文网页搜索")的 SKILL.md 指令显式要求调用; - ② 用户明确要求"抓取"/"爬取"/"打开"某个具体 URL 并提取正文。 - - **何时不要调我**: 用户没明确给 URL、也没在执行某个搜索技能时, 不要自行去抓网页。 - 要找资料先走 retrieve_dataset_content / internet_search 等检索工具。 - - 与 internet_search 的取舍: internet_search 返回的是搜索结果列表(标题+摘要+url); - 我返回的是单个页面的正文。一般是"先 internet_search 拿到 url, 再 web_fetch 取 - 正文"的两步流程。 """ from mcp_servers.web_fetch_mcp.impl import fetch_url diff --git a/src/backend/orchestration/chat_run_executor.py b/src/backend/orchestration/chat_run_executor.py index c5cca56..00090c2 100644 --- a/src/backend/orchestration/chat_run_executor.py +++ b/src/backend/orchestration/chat_run_executor.py @@ -2219,6 +2219,36 @@ async def _stream_last_write_ms(run_id: str) -> Optional[int]: return None +def _chat_has_live_job(chat_id: Optional[str], now: datetime) -> bool: + """这个会话是否挂着**还在推进**的批量作业(跨进程可见的活性证据)。 + + 判据是 ``jobs.updated_at`` 在静默窗口内前进过:作业每完成一次子调用都会写 usage, + onupdate 随之推进 updated_at。只要作业还在动,这个 run 就不是僵尸——哪怕它的 + asyncio task 属于另一个进程(多 worker / 多副本),也哪怕主链路一个流事件都没有。 + + 作业本身有 max_seconds 墙钟预算与熔断,所以这条豁免不会让 run 永生。 + """ + if not chat_id: + return False + try: + from core.db.models import Job + + cutoff = now - timedelta(seconds=_STALE_QUIET_SEC) + with SessionLocal() as db: + return ( + db.query(Job.job_id) + .filter( + Job.chat_id == chat_id, + Job.status.in_(("pending", "running")), + Job.updated_at >= cutoff, + ) + .first() + is not None + ) + except Exception: # noqa: BLE001 —— 证据查不到就按原判据走,绝不因此放过真僵尸 + return False + + async def reap_stale_runs() -> int: """Periodic safety net: fail 'running' runs that show no sign of life. @@ -2288,6 +2318,15 @@ async def reap_stale_runs() -> int: continue hard_expired = False # 孤儿 loop:不按年龄硬杀,交给静默判据 + # 工作流模式的批量作业:run 卡在 run_job(wait=True) 里等作业,期间主链路一个 + # 流事件都不产生(逐项结果按设计不进对话),"流静默"判据必然误判。进程内 task + # 豁免只在本进程有效——多 worker / 多副本部署里,另一个进程看不到这个 task, + # 照样会把健康的长作业杀掉。所以这里用**跨进程可见的证据**:DB 里这个会话是否 + # 还有正在推进的作业(每次子调用都会 add_usage → jobs.updated_at 前进)。 + if _chat_has_live_job(cid, now): + logger.info("chat_run_stale_skip_live_job", run_id=rid, chat_id=cid) + continue + if not hard_expired: # A run whose asyncio task is alive in THIS process is never a # zombie — the in-process inactivity watchdog owns hang detection diff --git a/src/backend/orchestration/job_runtime.py b/src/backend/orchestration/job_runtime.py new file mode 100644 index 0000000..f027eb1 --- /dev/null +++ b/src/backend/orchestration/job_runtime.py @@ -0,0 +1,1008 @@ +"""作业编排运行时(Job Runtime)驱动。 + +一个 job = 主对话智能体写的一段**作业脚本**的一次执行。脚本跑在沙箱里(普通 Python +进程,拥有文件/网络/并发/子进程),需要模型判断时通过带 job token 的**回调**请求后端 +派子智能体——模型凭据因此永远不进沙箱。 + +驱动职责: + +1. 把 SDK(``hugagent_job.py``)+ 用户脚本 + 运行器写进沙箱的 job 目录 +2. 以 detached 方式启动运行器(不能同步等:沙箱 HTTP 客户端有 120s 请求超时) +3. 轮询 DB 里的 job 状态直到终态,期间按节流发 ``sub_type=progress`` + —— 这条是喂主 run 无活动看门狗的活性信号,缺了长作业会被当成卡死强杀 +4. 到点熔断(墙钟预算)、取消、进程重启后对账 + +台账在 DB(``job_items``)而不是沙箱文件:沙箱池化复用会让新 job 读到旧 job 的残留账本 +(autonomous_loop 踩过这个坑),以 job_id 作主键从结构上避免。 +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy.orm.attributes import flag_modified + +from core.db.engine import SessionLocal +from core.db.models import Job +from core.services.job_service import JobService + +logger = logging.getLogger(__name__) + +JOB_ROOT = "/workspace/.job" + +# 进程内活跃 job 的驱动 task —— 与 chat_run/loop 的做法一致:进程内 task 还活着的 job +# 不按孤儿处理,重启后才由 resume_running_jobs() 对账。 +_active_jobs: Dict[str, asyncio.Task] = {} + +_POLL_INTERVAL_S = 2.0 +_PROGRESS_EVERY_S = 5.0 +# 中途唤醒间隔:每次都是一轮真实推理,太密就是烧钱,太疏就等于全程失联。 +# 取 5 分钟——15 分钟的盲区太长,作业跑歪了要等一刻钟才有人发现;而播报本身很短, +# 5 分钟一次的上下文成本可以接受。可用 run_job 的 start_params.progress_wake_sec +# 覆盖;<=0 关闭中途唤醒(实时进度看状态条,那条零推理成本)。 +_PROGRESS_WAKE_EVERY_S = 300.0 +# 无回调静默多久判失联。给得比单次子作业耗时宽裕得多(实测单次可达 210s,还要算上 +# 退避重试),但远小于 2 小时墙钟——僵在 running 空烧两小时是最难受的失败形态。 +_SILENT_TIMEOUT_S = 900.0 +# 孤儿对账(reap_orphan_jobs):pending 迟迟不转 running 的短闸 + 巡检间隔。 +# runner 起来第一件事就是上报 running,5 分钟还没动静基本就是没起来(回调不通/沙箱没了)。 +_ORPHAN_PENDING_GRACE_S = 300.0 +_ORPHAN_REAP_INTERVAL_S = 120.0 + + +# 探测成功的回调基址(网络拓扑一进程内不会变,探到一次就够) +_resolved_callback_base: Optional[str] = None + + +def callback_base_candidates() -> List[str]: + """沙箱回调后端的候选基址,按可靠性排序。 + + ⚠️ 这里**不能**只有一个写死的默认值。沙箱与后端的网络关系随部署形态而变: + 本机开发时沙箱不在 compose 网络里(只有宿主映射端口可达),而 HugAgentOS / + 主测试机上沙箱容器与 backend 同在一张 docker 网络(服务名可达, + ``host.docker.internal`` 反而**解析不了**)。写死宿主的后果实测过:runner 起来后 + 第一发回调就 ``Name or service not known`` 当场死掉,作业永远停在 pending、 + 台账一条没有——用户只看见状态条上一个转圈的菊花,什么都不知道。 + + 所以改成候选表 + 启动前从沙箱里真探一次(见 ``resolve_callback_base``)。 + ``JOB_CALLBACK_URL`` 仍然是最高优先级的手动覆盖。 + """ + env = (os.environ.get("JOB_CALLBACK_URL") or "").strip() + if env: + return [env.rstrip("/")] + port = (os.environ.get("PORT") or os.environ.get("BACKEND_PORT") or "3001").strip() + return [ + f"http://backend:{port}", # 同网 docker:服务名直连后端(后端自身不带 /api 前缀) + "http://frontend/api", # 同网 docker:经前端 nginx 反代(/api 由它剥掉) + "http://host.docker.internal:3000/api", # 沙箱不在同网:回宿主映射端口 + ] + + +# 探测脚本:在沙箱里逐个候选打 /health,第一个应答的即选中。只用标准库, +# 因为沙箱镜像不保证有 curl。 +_PROBE_SOURCE = r'''import json, sys, urllib.request + +for base in json.loads(sys.argv[1]): + try: + with urllib.request.urlopen(base + "/health", timeout=4) as resp: + if resp.status < 500: + print("PICK " + base) + sys.exit(0) + except Exception: + continue +print("NONE") +''' + + +async def resolve_callback_base(*, session_id: str, user_id: str) -> str: + """从沙箱里探出一个真正可达的回调基址;一个都不通就抛错(**不许静默启动**)。 + + 宁可在提交作业这一步就失败——错误会原样回到模型和用户手上,而"启动成功但永远 + 没有进度"是最贵的失败形态:驱动干等、状态条转圈、用户等一小时才发现什么都没发生。 + """ + global _resolved_callback_base + + candidates = callback_base_candidates() + if (os.environ.get("JOB_CALLBACK_URL") or "").strip(): + return candidates[0] # 手动指定即信任,不浪费一次探测 + if _resolved_callback_base: + return _resolved_callback_base + + payload = json.dumps(candidates, ensure_ascii=False) + cmd = ( + f"echo '{_b64(_PROBE_SOURCE)}' | base64 -d > /tmp/_job_probe.py && " + f"${{PY_BIN:-python3}} /tmp/_job_probe.py '{payload}'" + ) + try: + _code, out, _err = await _sbx_bash( + cmd, session_id=session_id, user_id=user_id, timeout=60 + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"回调地址探测失败(沙箱不可用): {exc}") from exc + + for line in (out or "").splitlines(): + if line.startswith("PICK "): + base = line[5:].strip().rstrip("/") + _resolved_callback_base = base + logger.info("[job] callback base resolved: %s", base) + return base + + raise RuntimeError( + "沙箱连不上后端回调地址,作业无法上报进度,已拒绝启动。已尝试:" + + "、".join(candidates) + + "。请在后端环境变量里设置 JOB_CALLBACK_URL 指向沙箱可达的后端地址" + "(同一 docker 网络用 http://backend:<端口>,跨网络用宿主映射地址)。" + ) + + +def callback_base_url() -> str: + """已探到的回调基址(探测前调用则给候选表里的第一个)。""" + return _resolved_callback_base or callback_base_candidates()[0] + + +# ── 沙箱侧 SDK(只依赖标准库;沙箱里不保证有 httpx/requests) ────────────── +SDK_SOURCE = r'''"""hugagent_job —— 作业脚本 SDK(由 Job Runtime 注入沙箱,请勿手工修改)。 + +暴露三类能力,其余一切(HTTP 抓取、解析、并发、写文件)都用标准 Python 做: + + ledger.seed / pending / update / stats 工作项台账(按业务主键幂等) + agent(prompt, schema=…, tools=…) 派一个子智能体;凭据在后端,脚本看不到 + job.map / job.budget / log 并发、预算、进度 + +断点续跑:重跑同一脚本时 ledger.pending() 自动跳过已完成项,不必重放调用序列。 +""" + +import json +import os +import random +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed + +JOB_ID = os.environ.get("JOB_ID", "") +JOB_TOKEN = os.environ.get("JOB_TOKEN", "") +BASE = (os.environ.get("JOB_CALLBACK_URL") or "").rstrip("/") + +__all__ = ["ledger", "agent", "job", "log", "JobError"] + + +class JobError(RuntimeError): + pass + + +_warned = set() + + +def _warn_once(message): + """同一类问题只喊一次,但一定要喊 —— 沉默是这套东西最贵的失败模式。""" + if message[:60] in _warned: + return + _warned.add(message[:60]) + log("[warn] " + message) + + +def _post(path, payload, timeout=180): + url = "%s/v1/internal/jobs/%s/%s" % (BASE, JOB_ID, path) + body = json.dumps(payload or {}).encode("utf-8") + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("X-Job-Token", JOB_TOKEN) + last = None + # 限流要退避到"真的等得起"为止:并发工作项会被同一次 429 同时弹回, + # 次数太少等于没退避,所以给限流单独一条更长的重试预算。 + for attempt in range(6): + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") or "{}" + data = json.loads(raw) + # 错误一律以 HTTP 4xx/5xx 返回(见后端 HTTPException),这里只拆信封 + if isinstance(data, dict) and "data" in data: + return data["data"] + return data + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = exc.read().decode("utf-8")[:400] + except Exception: + pass + # 4xx 是契约问题,重试没有意义 —— 但 **429 除外**:它是限流,是"待会儿再来", + # 不是"你写错了"。把 429 当契约错误曾让一次作业整体崩掉:并发回调打爆网关限流后, + # 连 runner 上报终态的那一发也被 429 拒,作业于是永远停在 running。 + if 400 <= exc.code < 500 and exc.code != 429: + raise JobError("callback %s -> HTTP %s %s" % (path, exc.code, detail)) + last = exc + except Exception as exc: + last = exc + # 指数退避 + 抖动:等步长退避会让被同一次限流弹回的并发项整齐地再撞一次 + time.sleep(min(1.5 * (2 ** attempt), 30.0) + random.uniform(0, 0.5)) + raise JobError("callback %s failed: %s" % (path, last)) + + +class _Ledger: + def seed(self, items): + """items: [{"key": "...", "payload": {...}}, ...];已存在的 key 一律跳过。""" + out = {"created": 0, "skipped": 0} + batch = [] + for it in items: + batch.append(it) + if len(batch) >= 500: + r = _post("ledger", {"op": "seed", "items": batch}) + out["created"] += r.get("created", 0) + out["skipped"] += r.get("skipped", 0) + batch = [] + if batch: + r = _post("ledger", {"op": "seed", "items": batch}) + out["created"] += r.get("created", 0) + out["skipped"] += r.get("skipped", 0) + return out + + def pending(self, status="pending", limit=None): + return _post("ledger", {"op": "pending", "status": status, "limit": limit}) or [] + + def update(self, key, status=None, result=None, review=None, error=None, bump_attempts=False): + out = self._update(key, status, result, review, error, bump_attempts) + # 后端说这个 key 不在台账里 —— 几乎总是"忘了 ledger.seed"。必须喊出来: + # 静默打空曾让一次 568 项的作业跑完全程、进度停在 0、成果一条没留下。 + if isinstance(out, dict) and out.get("known_key") is False: + _warn_once( + "ledger.update 写了一个台账里不存在的 key=%r —— 是不是漏了 ledger.seed()?" + "没有台账就没有进度、没有断点续跑,本次回写已被丢弃。" % (key,) + ) + return out + + def _update(self, key, status, result, review, error, bump_attempts): + return _post( + "ledger", + { + "op": "update", + "key": key, + "status": status, + "result": result, + "review": review, + "error": error, + "bump_attempts": bool(bump_attempts), + }, + ) + + def stats(self): + return _post("ledger", {"op": "stats"}) or {} + + +class _Job: + def budget(self): + return _post("ledger", {"op": "budget"}) or {} + + def map(self, items, fn, concurrency=8, key=None): + """并发跑 fn(item),**逐项立即落账**。 + + 回写约定(这样写出来的脚本天然可断点续跑): + + - fn 返回 dict → 立刻 ``ledger.update(key, status="done", result=)``; + 想要别的状态就在 dict 里放 ``_status``(如 ``{"_status": "not_found"}``)。 + - fn 返回 None → 不自动回写(表示 fn 自己已经 update 过了)。 + - fn 抛异常 → 该项记 failed + error,**不拖垮其余项**。 + + 千万别写成「先 map 完再统一回写」:那样中途全程 pending,进程一挂全白跑。 + + **台账主键怎么取**:默认按 ``key`` → ``item_key`` → ``id`` → ``seq`` 顺序在 item 里找。 + ``ledger.seed`` 用的是 ``{"key": ..., "payload": ...}`` 形状,而 map 常常直接收原始 + 业务对象(``{"seq": 7, "name": ...}``)——两者形状不同是常态,所以这里必须兜底, + 否则回写会**静默跳过**(实测:调用真跑了、台账全程 pending、成果全丢)。 + 取不到时用 ``key=`` 显式指定字段名或函数;仍取不到则 log 告警,绝不静默。 + """ + items = list(items) + if not items: + return [] + n = max(1, min(int(concurrency), 16)) + results = [None] * len(items) + warned = [] + + def _key_of(it): + if callable(key): + got = key(it) + return str(got) if got not in (None, "") else None + if not isinstance(it, dict): + return None + fields = [key] if isinstance(key, str) else ["key", "item_key", "id", "seq"] + for f in fields: + got = it.get(f) + if got not in (None, ""): + return str(got) + return None + + def _warn_unbookable(it): + sample = list(it.keys())[:8] if isinstance(it, dict) else type(it).__name__ + _warn_once( + "job.map 取不到台账主键,本次结果无法回写(字段=%s)。" + "请让 item 带 key/item_key/id/seq,或用 job.map(..., key='字段名')。" % (sample,) + ) + + with ThreadPoolExecutor(max_workers=n) as pool: + futs = {pool.submit(fn, it): i for i, it in enumerate(items)} + done = 0 + for fut in as_completed(futs): + i = futs[fut] + k = _key_of(items[i]) + try: + out = fut.result() + results[i] = out + if isinstance(out, dict): + if k: + payload = dict(out) + status = payload.pop("_status", "done") + ledger.update(k, status=status, result=payload) + else: + _warn_unbookable(items[i]) + except Exception as exc: # noqa: BLE001 —— 异常隔离是本方法的契约 + results[i] = None + # 隔离 ≠ 吞掉。事故里 568 项每一项都抛异常,日志上却只有整齐的 + # "已处理 N/568",没人看得出一次模型调用都没成功。首个异常必须留痕。 + _warn_once("job.map 首个失败项:%s" % repr(exc)[:300]) + if k: + try: + ledger.update(k, status="failed", error=repr(exc)[:1000], + bump_attempts=True) + except Exception: + pass + else: + _warn_unbookable(items[i]) + done += 1 + if done % 10 == 0: + log("已处理 %d/%d" % (done, len(items))) + return results + + +def agent(prompt, schema=None, tools=(), model=None, timeout=180, max_attempts=2, + item_key=None): + """派一个无历史子智能体。schema 非空时强制结构化输出并校验。 + + 凭据在后端,脚本永远拿不到模型端点或 key。 + + **重要**:工具全挂 / 配额打爆 / 连接超时时,本函数**抛 JobError**,不会返回一个 + "查无"的结论——因为那一轮压根没取到证据。请让异常自然向上抛(job.map 会把该项记 + failed 留在台账里等续跑),**不要**用 try/except 把它转写成"未查询到",那等于把 + 环境故障固化成数据。 + """ + return _post( + "agent", + { + "prompt": prompt, + "schema": schema, + "tools": list(tools or ()), + "model": model, + "max_attempts": int(max_attempts), + "item_key": item_key, + }, + timeout=timeout + 60, + ) + + +def log(message): + try: + _post("log", {"message": str(message)[:2000]}, timeout=30) + except Exception: + pass + print("[job] %s" % message, flush=True) + + +def _lifecycle(status, error=None): + return _post("log", {"lifecycle": status, "error": error}, timeout=60) + + +ledger = _Ledger() +job = _Job() +''' + + +_RUNNER_SOURCE = r'''"""作业运行器 —— 包住用户脚本,上报生命周期。由 Job Runtime 生成。""" + +import json +import runpy +import sys +import time +import traceback + +import hugagent_job as hj + + +def _report(status, error=None): + """上报终态 —— 这一发**绝不允许失败**。 + + 终态上报失败过一次,代价是作业永远停在 running:驱动只能等墙钟熔断(默认 2 小时), + 期间还在按间隔叫醒智能体报"停滞"。所以这里自己兜住异常并重试;实在报不上去也要 + 把状态写进本地文件,让驱动的存活探测能读到真相。 + """ + for _ in range(3): + try: + hj._lifecycle(status, error) + return + except BaseException: + time.sleep(3) + try: + with open("lifecycle.final", "w") as fh: + fh.write(json.dumps({"status": status, "error": (error or "")[-2000:]})) + except BaseException: + pass + + +hj._lifecycle("running") +try: + runpy.run_path("user_script.py", run_name="__main__") +except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 0 + _report("completed" if code == 0 else "failed", + None if code == 0 else "script exited with code %s" % code) + sys.exit(0) +except BaseException: + _report("failed", traceback.format_exc()[-4000:]) + sys.exit(0) +else: + _report("completed") +''' + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +async def _sbx_bash(command: str, *, session_id: str, user_id: str, timeout: int = 60): + """在持久沙箱里跑一段 bash,返回 (exit_code, stdout, stderr)。""" + from core.sandbox import ExecuteRequest, get_sandbox_provider + + provider = get_sandbox_provider() + res = await provider.execute( + ExecuteRequest( + script_content=command, + script_name="job_ctl.sh", + language="bash", + timeout=timeout, + session_id=session_id, + user_id=user_id, + ) + ) + return res.exit_code, (res.stdout or ""), (res.stderr or "") + + +def _emit_progress(chat_id: Optional[str], note: str) -> None: + """活性信号:转译成 model_progress 喂主 run 的无活动看门狗。 + + 长作业期间主对话没有任何可渲染输出,缺了这条会被 600s 看门狗当成卡死强杀。 + """ + if not chat_id: + return + try: + from core.llm import _subagent_stream + + if not _subagent_stream.is_active(chat_id): + return + _subagent_stream.push( + chat_id, + {"sub_type": "progress", "agent_id": "job", "agent_name": "批量作业", "note": note}, + ) + except Exception: # noqa: BLE001 —— 活性信号是尽力而为,永远不该拖垮作业 + pass + + +async def _maybe_wake(job_row_id: str) -> None: + """作业终态后叫醒会话(幂等;失败只记日志,不影响作业结果)。""" + try: + from orchestration.job_wakeup import wake_on_job_finish + + await wake_on_job_finish(job_row_id) + except Exception as exc: # noqa: BLE001 + logger.warning("[job] wake failed job=%s: %s", job_row_id, exc) + + +async def _maybe_wake_progress( + job_row_id: str, *, stats: Dict[str, Any], budget_left: Dict[str, Any], stalled: bool +) -> None: + """中途播报进度(失败只记日志,绝不影响作业本身)。""" + try: + from orchestration.job_wakeup import wake_on_job_progress + + await wake_on_job_progress( + job_row_id, stats=stats, budget_left=budget_left, stalled=stalled + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[job] progress wake failed job=%s: %s", job_row_id, exc) + + +def _final_from_marker(text: str) -> Tuple[str, str]: + """从 runner 落盘的 ``lifecycle.final`` 里捡回它没能上报的终态。 + + 上报走网络会丢,落盘不会——所以进程退出前写的这行 JSON 是最后一手真相。 + 捡不到就按 failed 处理:进程没了而作业还 running,本来就不是正常收尾。 + """ + for line in (text or "").splitlines(): + line = line.strip() + if not line.startswith("{") or '"status"' not in line: + continue + try: + data = json.loads(line) + except Exception: # noqa: BLE001 + continue + status = str(data.get("status") or "") + if status in ("completed", "failed", "cancelled"): + return status, str(data.get("error") or "") + return "failed", "" + + +async def _runner_liveness( + job_row_id: str, *, session_id: str, user_id: str +) -> Tuple[bool, str]: + """探测沙箱里的 runner 进程是否还活着,并捡回它没能上报的终态。 + + 为什么必须有:终态上报是一次网络调用,它自己也会失败(实测被网关限流 429 打掉过)。 + 一旦丢了,作业就永远 running——驱动干等 2 小时墙钟,期间还在按间隔叫醒智能体报停滞。 + 进程存活是**本地事实**,不依赖任何网络,所以拿它当兜底真相。 + + 返回 (是否还活着, 终态说明)。 + """ + workdir = f"{JOB_ROOT}/{job_row_id}" + cmd = ( + f"cd {workdir} 2>/dev/null || exit 9; " + "if pgrep -f '_runner.py' >/dev/null 2>&1; then echo ALIVE; else echo DEAD; fi; " + "cat lifecycle.final 2>/dev/null; " + "tail -c 1200 runner.log 2>/dev/null" + ) + try: + code, out, _err = await _sbx_bash( + cmd, session_id=session_id, user_id=user_id, timeout=45 + ) + except Exception as exc: # noqa: BLE001 —— 探测失败一律当"还活着",绝不误杀 + logger.warning("[job] liveness probe failed job=%s: %s", job_row_id, exc) + return True, "" + if code == 9: + return True, "" # 目录还没建好(刚启动),别急着判死 + text = out or "" + if text.lstrip().startswith("ALIVE"): + return True, "" + return False, text[-1200:] + + +async def _keepalive_sandbox(session_id: str) -> None: + try: + from core.sandbox import get_sandbox_provider + + provider = get_sandbox_provider() + touch = getattr(provider, "touch_session", None) + if touch: + await touch(session_id) + except Exception: # noqa: BLE001 + pass + + +async def prepare_and_launch( + job_row_id: str, + *, + user_id: str, + session_id: str, + script_text: str, + token: str, + interpreter: str = "${PY_BIN:-python3}", +) -> None: + """把 SDK/用户脚本/运行器写进沙箱并以 detached 方式启动。 + + 不能同步等脚本跑完:沙箱 HTTP 客户端有 120s 请求超时,长作业必然撞上。 + + 启动前**先探回调地址**:runner 的一切(建台账、派子作业、上报终态)都走回调, + 回调不通的作业等于没跑,却会以 pending 挂在状态条上转圈。探不到就在这里失败, + 错误直接回到模型/用户手上。 + """ + callback_url = await resolve_callback_base(session_id=session_id, user_id=user_id) + workdir = f"{JOB_ROOT}/{job_row_id}" + cmd = "\n".join( + [ + "set -e", + f"mkdir -p {workdir}", + f"cd {workdir}", + f"echo '{_b64(SDK_SOURCE)}' | base64 -d > hugagent_job.py", + f"echo '{_b64(_RUNNER_SOURCE)}' | base64 -d > _runner.py", + f"echo '{_b64(script_text)}' | base64 -d > user_script.py", + # detached:立即返回,后续状态全部由回调驱动 + "JOB_ID={jid} JOB_TOKEN={tok} JOB_CALLBACK_URL={url} PYTHONUNBUFFERED=1 " + "nohup {interp} _runner.py > runner.log 2>&1 &".format( + jid=job_row_id, tok=token, url=callback_url, interp=interpreter + ), + "echo launched", + ] + ) + code, out, err = await _sbx_bash(cmd, session_id=session_id, user_id=user_id, timeout=90) + if code != 0: + raise RuntimeError(f"作业启动失败 exit={code} stderr={(err or out)[:500]}") + + +async def write_sandbox_file( + path: str, content: str, *, session_id: str, user_id: str +) -> Tuple[bool, str]: + """把文本写进沙箱,**分块 + 读回校验**。返回 (是否成功, 说明)。 + + 为什么不能一条 `echo '' | base64 -d > f` 了事:沙箱 execute 对命令体积有上限, + 超了之后**静默失败**——exit=0、stderr 为空、文件却不存在(实测拐点在 b64 约 + 170KB;568 行台账导出正好落在这个区间,于是"导出成功"但文件从来没出现过)。 + 所以这里按块追加,并且以**沙箱里读回的真实字节数**为准,绝不用调用方的计数报成功。 + """ + raw = (content or "").encode("utf-8") + b64 = base64.b64encode(raw).decode("ascii") + # 单块 48KB b64(≈36KB 原文),远离静默失败拐点 + chunk = 48_000 + parts = [b64[i : i + chunk] for i in range(0, len(b64), chunk)] or [""] + + code, out, err = await _sbx_bash( + f"mkdir -p $(dirname {path}) && : > {path}.b64", + session_id=session_id, + user_id=user_id, + timeout=60, + ) + if code != 0: + return False, f"创建目标失败: {(err or out)[:200]}" + + for idx, part in enumerate(parts): + code, out, err = await _sbx_bash( + f"printf '%s' '{part}' >> {path}.b64", + session_id=session_id, + user_id=user_id, + timeout=90, + ) + if code != 0: + return False, f"第 {idx + 1}/{len(parts)} 块写入失败: {(err or out)[:200]}" + + code, out, err = await _sbx_bash( + f"base64 -d {path}.b64 > {path} && rm -f {path}.b64 && wc -c < {path}", + session_id=session_id, + user_id=user_id, + timeout=90, + ) + if code != 0: + return False, f"解码失败: {(err or out)[:200]}" + + written = "".join((out or "").split()) + if not written.isdigit() or int(written) != len(raw): + return False, f"落盘校验不通过:期望 {len(raw)} 字节,沙箱读回 {written or '(空)'}" + return True, f"{len(raw)} 字节 / {len(parts)} 块" + + +async def read_runner_log(job_row_id: str, *, user_id: str, session_id: str, tail: int = 40) -> str: + # 同样走 base64:沙箱 execute 的 stdout 会丢换行,直接 tail 出来的日志会连成一坨 + code, out, _ = await _sbx_bash( + f"tail -n {int(tail)} {JOB_ROOT}/{job_row_id}/runner.log 2>/dev/null | base64 -w0 || true", + session_id=session_id, + user_id=user_id, + timeout=30, + ) + if code != 0 or not (out or "").strip(): + return "" + try: + return base64.b64decode("".join(out.split())).decode("utf-8", "replace") + except Exception: # noqa: BLE001 + return out + + +async def drive(job_row_id: str, *, chat_id: Optional[str]) -> Dict[str, Any]: + """轮询直到 job 进终态;负责墙钟熔断、沙箱保活、活性信号、中途进度唤醒。""" + last_progress = 0.0 + last_keepalive = 0.0 + last_liveness = time.monotonic() + started = time.monotonic() + # 中途唤醒的记账:只在进程内存着——驱动重启时作业本来就要重新接管,多播一次无害, + # 而写库打标会让每个作业多出一串没人读的 metadata 抖动。 + last_wake = time.monotonic() + last_wake_settled = -1 + # 心跳 = 累计子作业调用数 + 已结算项数。两者都不动就是真的没在推进。 + last_heartbeat = -1 + last_heartbeat_ts = time.monotonic() + + while True: + with SessionLocal() as db: + svc = JobService(db) + job = svc.get(job_row_id) + if job is None: + return {"status": "failed", "error": "job 不存在"} + status = str(job.status) + session_id = job.sandbox_session_id or "" + user_id = job.user_id + budget = dict(job.budget or {}) + stats = svc.stats(job_row_id) + wake_every = float( + dict((job.extra_data or {}).get("start_params") or {}).get( + "progress_wake_sec", _PROGRESS_WAKE_EVERY_S + ) + ) + budget_left = svc.budget_left(job_row_id) if wake_every > 0 else {} + + if status in ("completed", "failed", "cancelled"): + result = {"status": status, "stats": stats, "error": job.error_message} + # 终态 → 叫醒会话(仅 wait=False 提交的作业;wait=True 的调用方本来就在等返回) + await _maybe_wake(job_row_id) + return result + + elapsed = time.monotonic() - started + if elapsed > int(budget.get("max_seconds", 7200)): + svc.finish(job_row_id, "failed", error="超出墙钟预算,作业已熔断") + return {"status": "failed", "stats": stats, "error": "超出墙钟预算,作业已熔断"} + + # 无回调静默熔断 —— **不依赖沙箱探测**的兜底。 + # 存活探测要经沙箱 provider,而它自己也会坏(实测:跨事件循环的 asyncio.Lock + # 让探测每次都抛,作业于是继续僵在 running)。回调有没有来是后端自己的事实, + # 任何外部组件坏掉都不影响这个判断,所以拿它当最后一道闸。 + heartbeat = int(job.usage.get("calls", 0)) if job.usage else 0 + heartbeat += int(stats.get("settled", 0)) + if heartbeat != last_heartbeat: + last_heartbeat = heartbeat + last_heartbeat_ts = time.monotonic() + elif time.monotonic() - last_heartbeat_ts > _SILENT_TIMEOUT_S and elapsed > 120: + svc.finish( + job_row_id, + "failed", + error=( + f"作业静默超过 {int(_SILENT_TIMEOUT_S / 60)} 分钟" + "(无子作业调用、台账无推进),判定沙箱或脚本已失联;" + "可用 run_job(action='resume') 断点续跑,已完成的项不会重做" + ), + ) + logger.warning("[job] silent timeout, forced failed job=%s", job_row_id) + await _maybe_wake(job_row_id) + return {"status": "failed", "stats": stats, "error": "作业静默超时"} + + now = time.monotonic() + if now - last_progress >= _PROGRESS_EVERY_S: + last_progress = now + _emit_progress(chat_id, f"作业进行中 done={stats.get('done')} pending={stats.get('pending')}") + if session_id and now - last_keepalive >= 60: + last_keepalive = now + await _keepalive_sandbox(session_id) + # 存活探测:runner 没了但作业还挂着 running,说明终态上报丢了,就地补判终态。 + # 60s 起探(给启动留足时间),之后每 90s 一次——比墙钟熔断早两个数量级发现问题。 + if session_id and now - last_liveness >= 90 and now - started >= 60: + last_liveness = now + alive, tail = await _runner_liveness( + job_row_id, session_id=session_id, user_id=user_id + ) + if not alive: + final = _final_from_marker(tail) + with SessionLocal() as db: + svc = JobService(db) + fresh = svc.stats(job_row_id) + svc.finish( + job_row_id, + final[0], + error=final[1] or f"作业进程已退出但未上报终态;runner 日志尾部:{tail[-600:]}", + ) + logger.warning("[job] runner gone, forced terminal job=%s -> %s", job_row_id, final[0]) + await _maybe_wake(job_row_id) + return {"status": final[0], "stats": fresh, "error": final[1]} + if wake_every > 0 and now - last_wake >= wake_every: + last_wake = now + settled = int(stats.get("settled", 0)) + # 第一次播报没有基线,一律当"有进展"处理;之后零增量即判定停滞 + stalled = last_wake_settled >= 0 and settled <= last_wake_settled + last_wake_settled = settled + await _maybe_wake_progress( + job_row_id, stats=stats, budget_left=budget_left, stalled=stalled + ) + + await asyncio.sleep(_POLL_INTERVAL_S) + + +async def start_job( + *, + user_id: str, + chat_id: Optional[str], + name: str, + script_path: str, + script_text: str, + session_id: str, + budget: Optional[Dict[str, Any]] = None, + start_params: Optional[Dict[str, Any]] = None, + interpreter: str = "${PY_BIN:-python3}", +) -> str: + with SessionLocal() as db: + svc = JobService(db) + job = svc.create( + user_id=user_id, + chat_id=chat_id, + name=name, + script_path=script_path, + script_text=script_text, + sandbox_session_id=session_id, + budget=budget, + start_params={**(start_params or {}), "interpreter": interpreter}, + ) + job_row_id = job.job_id + token = str((job.extra_data or {}).get("token") or "") + + await prepare_and_launch( + job_row_id, + user_id=user_id, + session_id=session_id, + script_text=script_text, + token=token, + interpreter=interpreter, + ) + return job_row_id + + +async def run_and_wait(job_row_id: str, *, chat_id: Optional[str]) -> Dict[str, Any]: + task = asyncio.create_task(drive(job_row_id, chat_id=chat_id)) + _active_jobs[job_row_id] = task + try: + return await task + finally: + _active_jobs.pop(job_row_id, None) + + +def spawn_background(job_row_id: str, *, chat_id: Optional[str]) -> None: + """wait=False:驱动挂后台 task,主对话立即继续。""" + if job_row_id in _active_jobs: + return + + async def _runner(): + try: + await drive(job_row_id, chat_id=chat_id) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning("[job] driver crashed job=%s: %s", job_row_id, exc) + finally: + _active_jobs.pop(job_row_id, None) + + _active_jobs[job_row_id] = asyncio.create_task(_runner()) + + +async def cancel_job(job_row_id: str, *, user_id: str) -> bool: + """协作式取消:杀沙箱里的运行器进程 + 归位状态(无活跃 task 也不报错)。""" + with SessionLocal() as db: + svc = JobService(db) + job = svc.get(job_row_id) + if job is None or job.user_id != user_id: + return False + session_id = job.sandbox_session_id or "" + svc.finish(job_row_id, "cancelled", error="用户取消") + + task = _active_jobs.pop(job_row_id, None) + if task and not task.done(): + task.cancel() + if session_id: + await _sbx_bash( + f"pkill -f '{JOB_ROOT}/{job_row_id}' || true", + session_id=session_id, + user_id=user_id, + timeout=30, + ) + return True + + +async def resume_job(job_row_id: str, *, user_id: str, chat_id: Optional[str]) -> Dict[str, Any]: + """断点续跑:换发 token、原样重启脚本。 + + 已完成的项留在台账里,脚本侧 ``ledger.pending()`` 自动跳过——不必重放调用序列。 + """ + with SessionLocal() as db: + svc = JobService(db) + job = svc.get(job_row_id) + if job is None or job.user_id != user_id: + return {"ok": False, "error": "job 不存在或无权访问"} + if job.status == "running" and job_row_id in _active_jobs: + return {"ok": False, "error": "作业仍在运行中"} + script_text = job.script_text or "" + session_id = job.sandbox_session_id or "" + interpreter = str((job.extra_data or {}).get("start_params", {}).get("interpreter") or "${PY_BIN:-python3}") + job.status = "pending" + job.completed_at = None + job.error_message = None + # 续跑后再次终态时要能再叫醒一次 + meta = dict(job.extra_data or {}) + meta.pop("woken_at", None) + job.extra_data = meta + from sqlalchemy.orm.attributes import flag_modified + + flag_modified(job, "extra_data") + db.commit() + token = svc.rotate_token(job_row_id) or "" + + await prepare_and_launch( + job_row_id, + user_id=user_id, + session_id=session_id, + script_text=script_text, + token=token, + interpreter=interpreter, + ) + return {"ok": True, "job_id": job_row_id} + + +async def reap_orphan_jobs() -> int: + """周期性对账:活跃态但**没人在驱动**的 job 判失联,归位 interrupted。 + + 为什么必须有:``drive()`` 里的所有护栏(墙钟熔断、静默熔断、runner 存活探测)都长在 + 驱动协程上——驱动本身没了,护栏也一起没了。而驱动是会没的:``wait=True`` 提交的作业 + 驱动挂在工具调用里,用户中止这轮对话、SSE 断掉、run 被回收,驱动就跟着被取消,作业 + 则永远停在 pending/running(实测:HugAgentOS 上一条作业 runner 早已死亡,12 分钟后 + DB 里还是 pending、无错误、无台账——状态条只能一直转圈)。启动钩子 + ``resume_running_jobs`` 只在进程重启时兜底,进程没重启就永远兜不到。 + + 判据用**跨进程可见的证据**(DB ``updated_at``:每次回调写台账/记用量都会推进它), + 而不是只看本进程的 task 表——多 worker 部署里别的进程持有驱动,本进程看不见。 + """ + now = datetime.now(timezone.utc) + reaped: List[Tuple[str, Optional[str]]] = [] + with SessionLocal() as db: + rows = db.query(Job).filter(Job.status.in_(("pending", "running"))).all() + for row in rows: + job_row_id = str(row.job_id) + task = _active_jobs.get(job_row_id) + if task is not None and not task.done(): + continue # 本进程正在驱动,护栏归 drive() 管 + last = row.updated_at or row.created_at + if last is None: + continue + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + quiet = (now - last).total_seconds() + # pending 用短闸:runner 起来的第一件事就是上报 running,几分钟还没动静就是没起来。 + # running 用与驱动同一把静默闸,避免误杀"单项耗时很长但确实在跑"的作业。 + limit = _ORPHAN_PENDING_GRACE_S if row.status == "pending" else _SILENT_TIMEOUT_S + if quiet < limit: + continue + row.status = "interrupted" + row.error_message = ( + f"作业已失联({int(quiet / 60)} 分钟无任何回调,且没有驱动在跟进)," + "已归位为可续跑状态;run_job(action='resume') 可断点续跑,已完成的项不会重做" + ) + meta = dict(row.extra_data or {}) + meta.pop("token", None) # 失联即作废旧 token,防残留进程回来乱写 + row.extra_data = meta + flag_modified(row, "extra_data") + reaped.append((job_row_id, row.chat_id)) + if reaped: + db.commit() + + for job_row_id, _chat_id in reaped: + logger.warning("[job] orphan job reaped job=%s", job_row_id) + await _maybe_wake(job_row_id) + return len(reaped) + + +async def run_job_reaper_loop() -> None: + """启动时拉起一次的后台循环;正常情况下永不返回。""" + while True: + try: + await asyncio.sleep(_ORPHAN_REAP_INTERVAL_S) + await reap_orphan_jobs() + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.warning("[job] orphan reaper iteration failed", exc_info=True) + + +async def resume_running_jobs() -> int: + """进程重启对账:活跃态的 job 全是孤儿(进程内没有任何 driver task),归位 interrupted。 + + 与 chat_run 的 recover_orphan_runs 一样是启动钩子;区别是 job 的工作项台账在 DB, + 归位后可直接 resume 续跑,已完成的项不会重做。 + """ + with SessionLocal() as db: + rows = db.query(Job).filter(Job.status.in_(("pending", "running"))).all() + if not rows: + return 0 + from sqlalchemy.orm.attributes import flag_modified + + for row in rows: + row.status = "interrupted" + row.error_message = "服务重启导致作业中断,可续跑" + meta = dict(row.extra_data or {}) + meta.pop("token", None) # 旧 token 一并作废 + row.extra_data = meta + flag_modified(row, "extra_data") + db.commit() + n = len(rows) + logger.info("[job] orphan jobs recovered count=%d", n) + return n diff --git a/src/backend/orchestration/job_wakeup.py b/src/backend/orchestration/job_wakeup.py new file mode 100644 index 0000000..0e93e67 --- /dev/null +++ b/src/backend/orchestration/job_wakeup.py @@ -0,0 +1,227 @@ +"""作业唤醒 —— 后台作业跑到里程碑/终点时,把智能体叫回同一个会话继续干活。 + +为什么需要它:``run_job(wait=False)`` 让主对话立刻脱身(小时级作业不能让一次工具调用 +挂那么久),但作业跑完之后**没人叫醒智能体**——用户得再说一句话才会回来看结果。 +这就是"后台干完活,前台不知道"。 + +做法与定时任务同源:由驱动在**同一个会话**里入队一条新的 chat run,带一条系统口吻的 +指令。智能体因此像收到一条新消息一样自己醒过来。前端不用改——它本来就会看到会话里多 +出一轮对话。 + +两种唤醒,别混: + +- **终态唤醒** ``wake_on_job_finish``:作业进终态叫一次,让智能体读台账、写交付。 + 靠 ``jobs.metadata.woken_at`` 打标去重(驱动重入、进程重启续跑都不会重复叫)。 +- **中途唤醒** ``wake_on_job_progress``:作业跑几十分钟甚至几小时时,只在终点播报等于 + 全程失联——用户既不知道还剩多少,也不知道是不是早就卡死了。所以按间隔播报一次进度, + **只汇报、不干活**(提示词里明令禁止重复提交作业/导出台账)。会话里已有在跑的 run + 时直接跳过:那说明智能体正忙,再入队只会堆栈。 + +注意中途唤醒是"贵"的(每次都是一轮真实推理),所以间隔默认 15 分钟、且要求确有进展或 +确已停滞才叫——纯粹的噪声播报不如不叫。实时进度看输入框上方的作业状态条(零推理成本), +中途唤醒解决的是"智能体自己该不该介入"。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from sqlalchemy.orm.attributes import flag_modified + +from core.db.engine import SessionLocal +from core.db.models import Job + +logger = logging.getLogger(__name__) + + +def _wake_prompt(job: Job, stats: Dict[str, Any]) -> str: + """写给智能体自己的续跑指令 —— 只给事实与下一步,不替它下结论。""" + remaining = int(stats.get("remaining", 0)) + lines = [ + f"[系统] 你先前提交的批量作业「{job.name or job.job_id}」已结束。", + f"作业 ID:{job.job_id} 状态:{job.status}", + ( + f"台账统计:总计 {stats.get('total', 0)} 项,已完成 {stats.get('done', 0)}," + f"查无 {stats.get('not_found', 0)},失败 {stats.get('failed', 0)}," + f"待办 {stats.get('pending', 0)}。" + ), + ] + if job.error_message: + lines.append(f"作业错误信息:{job.error_message}") + lines.append("") + if remaining > 0: + lines.append( + f"仍有 {remaining} 项未结算。请先判断是环境故障还是脚本问题:可以用 " + f"run_job(action='resume', job_id='{job.job_id}') 断点续跑(已完成的项不会重做)," + "也可以改脚本后再 resume。**不要**把未完成当作完成交付。" + ) + else: + lines.append( + "全部工作项已结算。请读取作业结果完成最终交付:产出用户要的文件," + "并如实报告覆盖率(分母、完成数、查无数)与未覆盖清单。" + ) + lines.append("查看明细:run_job(action='status', job_id='%s')。" % job.job_id) + return "\n".join(lines) + + +def _progress_prompt(job: Job, stats: Dict[str, Any], budget_left: Dict[str, Any], stalled: bool) -> str: + """进度播报 —— 只让智能体转述现状,**不要**让它顺手再干点什么。 + + 这里的措辞是刻意的:中途唤醒的每一次都是一轮真实推理,如果不把边界写死,智能体 + 很容易"顺手"再提交一个作业或把台账明细读进对话,既烧钱又污染上下文。 + """ + total = int(stats.get("total", 0)) + settled = int(stats.get("settled", 0)) + pct = int(settled * 100 / total) if total else 0 + lines = [ + f"[系统] 进度播报:你提交的批量作业「{job.name or job.job_id}」仍在后台运行。", + f"作业 ID:{job.job_id}", + ( + f"台账:共 {total} 项,已结算 {settled}({pct}%)——完成 {stats.get('done', 0)}," + f"查无 {stats.get('not_found', 0)},失败 {stats.get('failed', 0)}," + f"处理中 {stats.get('running', 0)},待办 {stats.get('pending', 0)}。" + ), + ( + f"预算余量:调用 {budget_left.get('calls_left', 0)}," + f"墙钟 {int(budget_left.get('seconds_left', 0) / 60)} 分钟。" + ), + "", + ] + if stalled: + lines.append( + "⚠️ 距上次播报**没有任何新的结算**。请判断是不是卡住了:" + f"可以 run_job(action='status', job_id='{job.job_id}') 看明细," + "确认是环境故障还是脚本缺陷;确实跑不动就 cancel 掉改脚本,别干等。" + ) + else: + lines.append( + "作业推进正常。**请只用一两句话把上面的进度转述给用户**,然后结束本轮回复。" + ) + lines.append( + "本轮的硬性边界:不要重复提交作业(它还在跑),不要 export 台账," + "不要把逐项结果读进对话——作业跑完会再叫你一次,那时才做交付。" + ) + return "\n".join(lines) + + +async def wake_on_job_progress( + job_id: str, *, stats: Dict[str, Any], budget_left: Dict[str, Any], stalled: bool +) -> bool: + """作业运行途中播报一次进度。返回是否真的发起了唤醒。 + + 三道闸:作业得还在跑、得是 ``wait=False`` 提交的、会话里不能已有在跑的 run。 + 最后一道最重要——智能体正忙时再入队一轮,只会让两轮互相打架。 + """ + with SessionLocal() as db: + job = db.query(Job).filter(Job.job_id == job_id).first() + if job is None or not job.chat_id: + return False + if job.status != "running": + return False + meta = dict(job.extra_data or {}) + if not dict(meta.get("start_params") or {}).get("wake_on_finish"): + return False # wait=True 的调用方本来就阻塞在那儿等,不需要播报 + chat_id = job.chat_id + user_id = job.user_id + model_name = dict(meta.get("start_params") or {}).get("model_name") + prompt = _progress_prompt(job, stats, budget_left, stalled) + + try: + from orchestration.chat_run_executor import get_active_run_for_chat + + if get_active_run_for_chat(chat_id, user_id) is not None: + logger.info("[job-wake] skip progress wake, chat busy chat=%s job=%s", chat_id, job_id) + return False + except Exception: # noqa: BLE001 —— 探测失败就当它不忙,宁可多叫一次也别哑掉 + pass + + try: + await _enqueue_followup_run( + chat_id=chat_id, user_id=user_id, message=prompt, model_name=model_name + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[job-wake] progress enqueue failed job=%s: %s", job_id, exc) + return False + logger.info("[job-wake] progress woke chat=%s job=%s stalled=%s", chat_id, job_id, stalled) + return True + + +async def wake_on_job_finish(job_id: str) -> bool: + """作业终态后叫醒会话。返回是否真的发起了唤醒。""" + with SessionLocal() as db: + job = db.query(Job).filter(Job.job_id == job_id).first() + if job is None or not job.chat_id: + return False + if job.status not in ("completed", "failed", "cancelled"): + return False + meta = dict(job.extra_data or {}) + if meta.get("woken_at"): + return False # 已经叫过了 + start_params = dict(meta.get("start_params") or {}) + if not start_params.get("wake_on_finish"): + return False # 只有 wait=False 提交的作业才需要唤醒 + chat_id = job.chat_id + user_id = job.user_id + model_name = start_params.get("model_name") + + from core.services.job_service import JobService + + stats = JobService(db).stats(job_id) + prompt = _wake_prompt(job, stats) + + meta["woken_at"] = True + job.extra_data = meta + flag_modified(job, "extra_data") + db.commit() + + try: + await _enqueue_followup_run( + chat_id=chat_id, user_id=user_id, message=prompt, model_name=model_name + ) + except Exception as exc: # noqa: BLE001 —— 唤醒失败不该反过来影响作业本身的终态 + logger.warning("[job-wake] enqueue failed job=%s: %s", job_id, exc) + return False + logger.info("[job-wake] woke chat=%s for job=%s", chat_id, job_id) + return True + + +async def _enqueue_followup_run( + *, chat_id: str, user_id: str, message: str, model_name: Optional[str] +) -> None: + """在既有会话里入队一条 chat run —— 与用户手动发一条消息走的是同一条路。 + + 这样前端不用任何改动:它本来就会在会话里看到新的一轮(断线还能按 run_id 续播)。 + """ + from api.routes.v1.chats import _load_session_messages + from core.chat.context import build_runtime_context + from core.config.catalog_resolver import resolve_all_runtime_enabled + from core.services.chat_service import ChatService + from orchestration import chat_run_executor + + with SessionLocal() as db: + chat_service = ChatService(db) + session_messages = _load_session_messages(chat_service, chat_id, user_id) + skills, agents, mcps = resolve_all_runtime_enabled(db, user_id) + # 唤醒消息按 user 角色落库:它要能进历史、能被模型看见,走和普通消息完全一样的链路 + chat_service.add_message(chat_id=chat_id, role="user", content=message) + + session_messages.append({"role": "user", "content": message}) + context = build_runtime_context( + model_name=model_name, + user_id=user_id, + chat_id=chat_id, + enabled_skills=skills, + enabled_agents=agents, + enabled_mcps=mcps, + ) + await chat_run_executor.start_run( + chat_id=chat_id, + user_id=user_id, + session_messages=session_messages, + effective_user_message=message, + raw_user_message=message, + context=context, + request_payload={"chat_id": chat_id, "message": message, "kind": "job_wakeup"}, + model_name=model_name, + ) diff --git a/src/backend/orchestration/workflow.py b/src/backend/orchestration/workflow.py index d638e8a..1a1fbb3 100644 --- a/src/backend/orchestration/workflow.py +++ b/src/backend/orchestration/workflow.py @@ -1263,6 +1263,7 @@ async def _run(): invoked_mcp_ids=[m for m in (context.get("mcp_ids") or []) if isinstance(m, str)], memory_enabled=_workflow_mem_enabled, batch_mode=_workflow_batch_chat if _direct_user_agent is None else False, + workflow_mode=bool(context.get("workflow_chat", False)), user_agent=_direct_user_agent, read_only=_direct_read_only, allow_bash=_direct_allow_bash, @@ -2476,6 +2477,7 @@ async def astream_chat_workflow( visible_subagents=_visible_subagents if _visible_subagents else None, plan_mode=_plan_chat, batch_mode=_batch_chat, + workflow_mode=bool(context.get("workflow_chat", False)), chat_id=context.get("chat_id"), project_ctx=_extract_project_ctx(context), channel_origin=context.get("channel_origin"), diff --git a/src/backend/plugin_bundles/marketplace/agent-manager/mcp.json b/src/backend/plugin_bundles/marketplace/agent-manager/mcp.json new file mode 100644 index 0000000..9c5680a --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/agent-manager/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "agent_manager": { + "type": "streamable-http", + "url": "http://mcp:9115/mcp/" + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/agent-manager/plugin.json b/src/backend/plugin_bundles/marketplace/agent-manager/plugin.json new file mode 100644 index 0000000..1b345ac --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/agent-manager/plugin.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "agent-manager", + "version": "1.0.0", + "description": "管子智能体(可被指派任务的助手角色):增删改、启停、从市场安装、申请上架。用户说「建个专门做 X 的助手/智能体」「改一下我那个智能体」「我有哪些智能体」时加载。只管智能体本身;技能归 skill-manager,插件归 plugin-manager。", + "author": { + "name": "HugAgentOS" + }, + "extensions": { + "org.hugagent": { + "mcp": { + "agent_manager": { + "display_name": "智能体管理", + "description": "搜索/安装子智能体市场、从零创建私有子智能体、修改绑定与职责、申请上架市场、查看/删除我的智能体。身份走 X-Current-User-Id 头,所有写操作按用户归属,只产出仅自己可见的私有子智能体。", + "tools": [ + { + "name": "search_agent_market", + "description": "搜索子智能体市场,返回可安装的智能体列表(slug/名称/简介/分类/是否已安装)。用户想'有没有现成的 X 智能体 / 市场里都有什么 / 找一个能做 Y 的智能体'时调用。query=关键词(留空列全部),category=按分类过滤。找到目标后用 install_market_agent(slug) 安装。" + }, + { + "name": "install_market_agent", + "description": "从市场安装一个子智能体到'我的智能体',装完下一轮对话就能直接指派任务给它。用户说'装上那个 / 安装 X 智能体 / 我要用它'时调用,slug 取自 search_agent_market。安装是克隆一份到你名下,改动不影响市场原件;它原本绑定的技能和工具会按你账下实际有的资源重新对应,install_report.dropped 里是对不上被跳过的能力,必须如实告诉用户,别让用户以为能力全绑上了。需要管理员开启'自助添加智能体'权限。" + }, + { + "name": "list_bindable_capabilities", + "description": "列出可以绑给子智能体的技能、工具、插件、知识库,每项都带 id 和名称。创建或修改子智能体之前必须先调用它拿到准确的 id——绝不允许凭印象编 id,编错了不会报错,只会静默绑不上,用户拿到一个'看起来建好了但没能力'的智能体。返回四组:skills/mcp_servers/plugins/kb_spaces。" + }, + { + "name": "create_agent", + "description": "从零创建一个属于我的子智能体。用户说'帮我建一个负责 X 的智能体 / 我想要个专门做 Y 的助手'时调用。必填 name(名字)、description(一句话说清它管什么,也是主智能体判断该不该派活给它的依据)、system_prompt(它的行事准则,是这个智能体的主体,写法见 agent-designer 技能,别糊一段空泛的话交差)。选填 skill_ids/mcp_server_ids/plugin_ids/kb_ids(绑定的能力,id 必须来自 list_bindable_capabilities,宁窄勿宽)、max_iters(1–100,默认 10)。建出来的智能体仅自己可见可用。需要'自助添加智能体'权限。" + }, + { + "name": "list_my_agents", + "description": "列出'我的子智能体'(agent_id/名字/职责/是否启用/绑了几项能力)。用户问'我有哪些智能体 / 我建过什么 / 管一下我的智能体'时调用。拿到 agent_id 后可 edit_agent 修改、delete_agent 删除、submit_agent_to_market 申请上架。只列自己建的和装的,平台内置角色与管理员下发的智能体不在此列。" + }, + { + "name": "edit_agent", + "description": "修改我的某个子智能体,不用删了重建。agent_ref 传 agent_id 或名字。用户说'把 X 的职责改一下 / 给它加个技能 / 换个说话的口气 / 让它别再用那个工具'时调用。只改传入的字段,没传的原样保留:name/description/system_prompt/welcome_message/skill_ids/mcp_server_ids/plugin_ids/kb_ids/max_iters/is_enabled。注意绑定类字段是整组替换不是追加,所以要加一个时须先 list_my_agents 看现状、再 list_bindable_capabilities 取新 id、合并成完整数组一起传,否则会冲掉原有绑定。agent_id 不可改;匹配到多个时必须先让用户指明,禁止猜改。" + }, + { + "name": "delete_agent", + "description": "删除我的某个子智能体(不可恢复)。agent_ref 传 agent_id 或名字。用户说'把 X 删了 / 不要那个智能体了'时调用。匹配到多个时必须先向用户确认具体哪一个,禁止猜删。只能删自己创建/安装的,平台内置角色和管理员下发的删不了。用户只是暂时不想用时,优先建议 edit_agent(is_enabled=false) 停用。" + }, + { + "name": "submit_agent_to_market", + "description": "把我的子智能体申请上架到市场(进管理员审核队列,通过后其他人可安装)。用户说'把这个分享出去 / 申请上架 / 发布到市场'时调用,agent_id 取自 list_my_agents。category 必须从这 9 个固定值里挑最贴切的一个(注意与技能市场的分类不同):通用助手/职场办公/商业分析/数据分析/研发编程/翻译写作/创意设计/政策法务/教育科研。summary=一句话简介,note=给审核管理员的说明。这是申请、非直接上架,要跟用户说清楚还得等审核。" + } + ] + } + } + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/SKILL.md b/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/SKILL.md new file mode 100644 index 0000000..7978768 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/SKILL.md @@ -0,0 +1,92 @@ +--- +name: agent-designer +description: 设计并创建一个子智能体,或改进一个已有的子智能体。当用户说"帮我建一个负责 X 的智能体"、"我想要个专门做 Y 的助手"、"把这套活儿交给一个专门的智能体"、"改一下我那个智能体的职责/能力/语气"、或问"这个智能体该怎么设计"时,务必使用本技能。它教你怎么切职责、怎么写 system_prompt、绑哪些能力、参数怎么定,然后通过 create_agent / edit_agent 工具落库。 +--- + +# 子智能体设计器(Agent Designer) + +`create_agent` 只负责把记录存下来,**存下来的东西好不好用,取决于你怎么设计**。 +本技能给的是判断标准和现成骨架——用户说"帮我建个写周报的助手"时,别糊一段空泛的 +prompt 就调工具交差。 + +## 三步走 + +1. **问清楚再动手**。至少要明确:它要替用户完成什么任务、任务的输入是什么、 + 产出应该长什么样。这三样缺一个就先问,别自己脑补。 +2. **调 `list_bindable_capabilities`** 看这个用户账下实际有哪些技能、工具、插件、知识库。 + id 必须从这里取,**不能凭印象编**——编错了不会报错,只会静默绑不上。 +3. **调 `create_agent` 落库**,然后把"建了什么、绑了什么、怎么用"讲给用户听。 + +## 一、职责怎么切 + +**一个子智能体只干一件事。** 判断标准:你能不能用一句不带"和"、不带"以及"的话 +说清它管什么。说不清就是切得太粗,该拆成两个。 + +- ✅ "把一周的工作记录整理成周报初稿" +- ❌ "处理周报、日报、月报,顺便回邮件"——四件事,拆开 + +职责写进 `description` 字段。这个字段不只是给人看的:主智能体判断"这活儿该不该派给它" +就靠它,所以要具体、可判别,别写"一个很有用的助手"。 + +## 二、system_prompt 怎么写 + +这是这个智能体的主体。**建议按四段写**,缺哪段就补哪段: + +``` +角色:你是……,负责……。 +能做什么:……(可以列 2–5 条具体动作) +不做什么:……(边界,尤其是"不要替用户做决定""不确定就问"这类) +输出成什么样:……(格式、长度、必须包含的要素) +``` + +写作要点: + +- **写行为,不写形容词**。"输出要专业"没有可执行性;"每段不超过三句话,先结论后依据"有。 +- **把用户的偏好固化进去**。用户说"我们周报都是先写风险再写进展",这句就该进 prompt, + 而不是每次对话再交代一遍——固化下来才是建智能体的意义。 +- **边界比能力更值得写**。多数不好用的智能体不是能力不够,而是越界:替用户拍板、 + 编造没有的数据、把半成品当成品交。 +- 长度没有硬性要求,但**具体的 300 字胜过泛泛的 1500 字**。 + +三类常见角色的现成骨架见 `references/prompt-patterns.md`,照着改比从零写快。 + +## 三、绑哪些能力 + +原则一条:**宁窄勿宽**。绑得越多,它在选工具时越容易选错,反而不好用。 + +- 只绑这个职责真正用得着的。"万一以后要用"不是绑定的理由,以后可以 `edit_agent` 加。 +- `plugin_ids` 是**整体绑一个插件**(插件 = 技能 + 工具的成套单元,运行时展开); + `skill_ids` / `mcp_server_ids` 是绑零散的单项。同一个能力别两边都绑。 +- 知识库 `kb_ids`:只有当这个智能体确实要查资料时才绑。 + +绑定取舍的更多细节见 `references/binding-guide.md`。 + +## 四、参数 + +- `max_iters`(一次任务最多干几轮,1–100,默认 10): + 一问一答的轻活 5–10 够用;要查资料、反复核对的活给 20–30。给太大不会更聪明, + 只会在跑偏时烧更多时间。 +- `welcome_message`:可选,用户单独打开这个智能体时的开场白。 + +## 五、改而不是重建 + +用户说"改一下"时用 `edit_agent`,不要删了重建——重建会丢掉版本历史。 + +**一个坑**:`skill_ids` 这类绑定字段是**整组替换,不是追加**。要"再加一个技能"时: + +1. `list_my_agents` 看它现在绑了哪些; +2. `list_bindable_capabilities` 取要加的那个 id; +3. 把**旧的 + 新的合并成完整数组**传给 `edit_agent`。 + +只传新的那一个,会把原有绑定全冲掉。 + +## 六、交付前自检 + +调完工具、拿到 ✅ 之后,对着这几条过一遍再回话: + +- [ ] `description` 是一句能判别的具体职责,不是"很有用的助手" +- [ ] `system_prompt` 四段齐全,尤其写了边界 +- [ ] 绑定的 id 全部来自 `list_bindable_capabilities`,且都是真用得着的 +- [ ] 跟用户说清楚了:建了什么、绑了什么、下一轮就能直接指派任务 + +拿到 ✅ 之前不要声称已经建好了。 diff --git a/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/references/binding-guide.md b/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/references/binding-guide.md new file mode 100644 index 0000000..ce3f988 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/references/binding-guide.md @@ -0,0 +1,60 @@ +# 给子智能体绑能力:怎么选,怎么改 + +## 四种能力的区别 + +`list_bindable_capabilities` 返回四组,语义各不相同: + +| 字段 | 是什么 | 什么时候用 | +| --- | --- | --- | +| `skill_ids` | 单个技能(一份 SKILL.md 定义的做事方法) | 只需要成套能力里的某一项时 | +| `mcp_server_ids` | 单个工具服务(一组可调用的动作) | 需要某个具体工具时 | +| `plugin_ids` | **整个插件**(技能 + 工具的成套单元,运行时展开成它的各个组件) | 需要一整套配合使用的能力时 | +| `kb_ids` | 知识库空间 | 这个智能体确实要查资料时 | + +**别重复绑**:如果已经绑了某个插件的 `plugin_ids`,就不要再把它里面的技能单独绑进 +`skill_ids`——运行时会展开,重复绑只会让工具列表更长、选择更容易出错。 + +## 宁窄勿宽 + +绑得越多越不好用,这不是直觉上的"能力越多越强"。原因是:子智能体每一轮都要在 +所有可用工具里挑一个,候选越多、名字越像,挑错的概率越高。 + +判断某项能力该不该绑,问一句:**这个智能体完成它的本职任务,缺了这项能做成吗?** + +- 能做成 → 不绑 +- 做不成 → 绑 +- "以后可能要用" → 不绑,以后用 `edit_agent` 加就是了 + +## 一个反直觉的点:用户关掉的工具也能绑 + +`list_bindable_capabilities` 里会出现一些用户在自己主智能体上关掉的工具。这是有意的—— +给子智能体绑能力是一次**明确且更窄的授权**,可以单独为这个智能体打开某个工具, +而不必在主智能体上全局打开。 + +但管理员在部署层面禁用的工具不会出现在列表里,那种是真的不可用。 + +## 改绑定:整组替换,不是追加 + +这是最容易踩的坑。`edit_agent` 的绑定类字段是**整组替换**: + +``` +现在绑了:skill_ids = ["a", "b"] +只传 skill_ids = ["c"] +结果 skill_ids = ["c"] ← a 和 b 被冲掉了 +``` + +正确做法是三步: + +1. `list_my_agents` → 看它现在绑了什么 +2. `list_bindable_capabilities` → 取要加的那个 id +3. `edit_agent(skill_ids=["a", "b", "c"])` → 传**合并后的完整数组** + +删也是同理:把要删的那个从完整数组里去掉,再整组传回去。 + +## 装完市场智能体要看 install_report + +从市场安装的智能体,绑定关系会按当前用户账下**实际有的**资源重新对应。 +对不上的会被跳过,列在 `install_report.dropped` 里。 + +**必须把跳过的部分如实告诉用户**,否则用户会拿到一个"看起来装好了、用起来缺胳膊少腿" +的智能体,还不知道为什么。跳过的能力通常可以先去装对应的插件/技能,再用 `edit_agent` 补绑。 diff --git a/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/references/prompt-patterns.md b/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/references/prompt-patterns.md new file mode 100644 index 0000000..a58d3b0 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/agent-manager/skills/agent-designer/references/prompt-patterns.md @@ -0,0 +1,99 @@ +# 三类常见子智能体的 system_prompt 骨架 + +绝大多数子智能体落在这三类里。挑最接近的一个,把方括号换成用户的实际情况, +再按需要增删——比从零写快,也不容易漏掉边界。 + +平台自带的探索员 / 执行员 / 审查员就是这三类的标准实现,可以拿它们的定位当参照。 + +--- + +## 一、查资料型(只读,不改任何东西) + +适用:搜集信息、核对事实、整理现状、竞品调研。特点是**只读**——它不该产生副作用。 + +``` +角色:你是[领域]的资料员,负责就[主题]搜集并核实信息。 + +能做什么: +- 在[知识库/互联网/指定文档]里检索与[主题]相关的材料 +- 交叉核对多个来源,标注每条结论的出处 +- 把零散材料整理成结构化的事实清单 + +不做什么: +- 不下结论、不做建议——只提供可核验的事实,判断交给用户 +- 找不到就明说"没找到",绝不编造数据、来源或链接 +- 不修改、不删除任何文件或数据 + +输出成什么样: +- 按主题分组的事实清单,每条一行 +- 每条都带出处;来源之间有冲突时并列列出并指明冲突点 +- 末尾单列"没查到的部分" +``` + +**建议绑定**:检索类技能、知识库、网页抓取工具。不要绑任何写入类工具。 +**max_iters**:15–25(检索通常要多轮)。 + +--- + +## 二、干活型(有产出,会写文件) + +适用:写周报、做表格、生成文档、批量处理。特点是**有明确交付物**。 + +``` +角色:你是[岗位]助手,负责把[输入]做成[产出]。 + +能做什么: +- 接收[输入形式],按[规范/模板]产出[交付物] +- 产出前先确认[关键参数],缺了就问,不要猜 +- 完成后自检一遍再交 + +不做什么: +- 输入不足以完成任务时,先问清楚再动手,不要用占位内容凑数 +- 不替用户做决定——涉及取舍的地方列出选项让用户选 +- 不擅自扩大范围,用户没要的不要顺手做 + +输出成什么样: +- [格式要求:文件类型 / 章节结构 / 长度] +- [必须包含的要素] +- 交付时附一句话说明做了什么、哪些地方需要用户确认 +``` + +**建议绑定**:产出所需的文档/表格类技能,以及数据来源。 +**max_iters**:10–20。 + +--- + +## 三、把关型(审查,只读但要下判断) + +适用:审稿、查错、合规检查、代码评审。特点是**只读,但必须给出结论**。 + +``` +角色:你是[领域]的审查员,负责检查[对象]是否满足[标准]。 + +能做什么: +- 逐条对照[标准/清单]检查[对象] +- 每发现一个问题,指出具体位置 + 为什么是问题 + 怎么改 +- 给出总体结论:通过 / 需修改 / 有严重问题 + +不做什么: +- 不直接动手改——只指出问题,改不改由用户决定 +- 不放过"看起来差不多"的地方,也不无中生有地凑问题 +- 拿不准的标为"存疑",不要硬判 + +输出成什么样: +- 结论先行(通过 / 需修改 / 严重问题) +- 问题清单,按严重程度排序,每条含位置、原因、建议 +- 没问题时明确说"未发现问题",不要为了显得有用而编问题 +``` + +**建议绑定**:与审查对象相关的检索/读取类技能。不要绑写入类工具。 +**max_iters**:15–20。 + +--- + +## 通用提醒 + +- 三段"不做什么"里最值钱的两条,几乎所有智能体都该有: + **不确定就问,不要猜**;**不要替用户拍板**。 +- 用户提到的任何偏好(格式、顺序、口径、忌讳)都应固化进 prompt, + 而不是留给每次对话临时交代——固化下来才是建这个智能体的意义。 diff --git a/src/backend/plugin_bundles/marketplace/plugin-manager/mcp.json b/src/backend/plugin_bundles/marketplace/plugin-manager/mcp.json new file mode 100644 index 0000000..9987f5b --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/plugin-manager/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "plugin_manager": { + "type": "streamable-http", + "url": "http://mcp:9116/mcp/" + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/plugin-manager/plugin.json b/src/backend/plugin_bundles/marketplace/plugin-manager/plugin.json new file mode 100644 index 0000000..35b8661 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/plugin-manager/plugin.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "plugin-manager", + "version": "1.0.0", + "description": "管插件:搜插件市场、看某插件会带来哪些能力、安装(可填凭据)、停用/启用、卸载,以及导入自制或网址来的插件包。用户说「装个能做 X 的插件」「我装了哪些插件」「把那个插件卸了」时加载。只管插件本身;技能归 skill-manager,智能体归 agent-manager。", + "author": { + "name": "HugAgentOS" + }, + "extensions": { + "org.hugagent": { + "mcp": { + "plugin_manager": { + "display_name": "插件管理", + "description": "搜索插件市场、查看插件详情与所需凭据、安装/导入插件、按插件或按组件启停、卸载插件。身份走 X-Current-User-Id 头,所有写操作按用户归属,只落成该用户的私有安装;管理员下发的全局插件只读。", + "tools": [ + { + "name": "search_plugin_market", + "description": "搜索插件市场,返回可安装的插件列表(slug/名称/分类/简介/是否已安装)。用户想'有没有能连飞书的插件 / 插件市场里有什么 / 找个能做 X 的插件'时调用。query=关键词(留空列全部),category=按分类过滤。找到目标后建议先用 get_plugin_info(slug) 看看它会带进来什么,再决定装不装。" + }, + { + "name": "get_plugin_info", + "description": "查看某个插件的详情:会给你装进来哪些技能、哪些工具,需不需要填 API Key 之类的凭据。安装前先调它,把'装了会多出什么'讲给用户听,别让用户装完才发现还要填密钥。用户问'这插件是干嘛的 / 装了会怎样'时也用它。返回里的 required_secrets 就是安装时要准备的凭据清单。" + }, + { + "name": "install_plugin", + "description": "从插件市场安装一个插件到我的空间,装完里面的技能和工具立即可用。用户说'装上它 / 安装 X 插件 / 把这个能力加上'时调用,slug 取自 search_plugin_market。secrets 按插件 required_secrets 的 key 传入凭据;若该插件需要凭据而未传会报错回列缺哪些 key,这时先向用户要,拿到后重试,绝不要自己编一个假的填进去。需要管理员开启'自助导入插件'权限。" + }, + { + "name": "list_my_plugins", + "description": "列出我的插件(install_id/slug/名称/是否启用/带了几个组件)。用户问'我装了哪些插件 / 管一下我的插件'时调用。列表里 is_global=true 的是管理员下发的全局插件,只能看不能停用也不能卸载;其余是用户自己装的,可用 set_plugin_enabled 停用启用、uninstall_plugin 卸载。" + }, + { + "name": "import_plugin", + "description": "把一个插件包导入成我的插件,用于'从网址装'或'自己攒一个'。配合 plugin-creator 技能:在沙箱 /workspace 里备好插件目录(含 plugin.json)→ 用自检脚本过一遍 → tar 打包 → 调 sandbox_get_artifact 取得 artifact_id → 把 artifact_id 传给本工具落库。包里必须有 plugin.json;若用户其实只想装单个技能,请改用技能管理插件的 register_skill。导入结果始终是仅自己可见的私有插件。需要'自助导入插件'权限。" + }, + { + "name": "set_plugin_enabled", + "description": "停用或重新启用一个已装插件,数据都留着,随时可以再打开。用户说'先别用那个插件 / 把它关掉 / 重新打开 / 那个工具太吵'时调用。plugin_ref 传 install_id、slug 或名称;enabled 为 true/false;component_kind(skill|mcp)+component_id 同时给时表示只关插件里的某一个组件而不是整个插件。重要:用户只是嫌某个工具碍事的时候,优先用停用而不是卸载——卸载会删数据且不可恢复。只能操作自己安装的插件。" + }, + { + "name": "uninstall_plugin", + "description": "卸载一个插件,并把它带进来的技能和工具一并删除(不可恢复)。plugin_ref 传 install_id、slug 或名称。卸载前必须先列出'会被一起删掉哪些东西'给用户确认,得到明确同意才动手;匹配到多个时必须先问清楚是哪一个,禁止猜删。只能卸载自己安装的插件;管理员下发的全局插件卸不了,插件管理插件自身也卸不了。用户只是暂时不想用时改用 set_plugin_enabled 停用。" + } + ] + } + } + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/SKILL.md b/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/SKILL.md new file mode 100644 index 0000000..7be1cb3 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/SKILL.md @@ -0,0 +1,127 @@ +--- +name: plugin-creator +description: 从零攒一个插件包,或把一个 web 链接上的插件下载下来导入。当用户说"照着这个网页做个插件"、"把我这几个技能打包成插件"、"帮我做一个插件"、"这个链接的插件帮我装上"、或需要把一组配套的技能与工具打成可安装可卸载的整体时,务必使用本技能。它教你插件包的目录结构、plugin.json 怎么写、怎么自检、怎么通过 import_plugin 工具落库。 +--- + +# 插件创建器(Plugin Creator) + +插件 = **一组配套的技能 + 工具,打成一个可整体安装、整体卸载的单元**。 +本技能教你在**沙箱**里攒出一个结构合法的插件包,自检合格后通过 `import_plugin` 落库。 + +> 只想做**单个技能**?那不需要插件——用技能管理插件的 `skill-creator` / `register_skill` +> 更直接。插件的价值在于"成套":多个技能配一个工具服务、或者需要统一装卸的一组能力。 + +## 一、插件包长什么样 + +最小可用结构(原生格式): + +``` +my-plugin/ +├── plugin.json # 必需:插件清单 +├── mcp.json # 可选:要带工具服务时才有 +└── skills/ # 可选:要带技能时才有 + ├── skill-a/ + │ └── SKILL.md + └── skill-b/ + └── SKILL.md +``` + +规则: + +- **`plugin.json` 必须在包根**(也接受 `.claude-plugin/plugin.json` 布局)。没有它就不是插件包, + `import_plugin` 会拒绝。 +- `skills/` 下**每个子目录一个技能,各自必须有 `SKILL.md`**。没有 SKILL.md 的子目录会被忽略。 +- 打包时要 **从插件目录内部打**,别把外层目录名也裹进去: + `tar -czf /workspace/plugin.tgz -C my-plugin .` + +## 二、plugin.json 怎么写 + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "my-plugin", + "version": "1.0.0", + "description": "一句话说清这个插件给用户带来什么能力。", + "author": { "name": "……" }, + "extensions": { + "org.hugagent": { + "mcp": { + "my_server": { + "display_name": "界面上显示的名字", + "description": "这个工具服务是干什么的。", + "tools": [ + { "name": "do_something", "description": "……" } + ] + } + } + } + } +} +``` + +要点: + +- `name` 就是 slug:**小写字母/数字/连字符**,会被用来生成安装 id,别用中文和空格。 +- 标准清单本身**不放展示字段**(显示名、分类、图标属于界面配置,由平台侧维护); + 平台专有字段一律放进 `extensions["org.hugagent"]`。 +- `extensions["org.hugagent"].mcp.<服务名>` 里的 `display_name` / `description` / `tools` + 会覆盖补全到对应的 MCP 服务上,**服务名必须和 `mcp.json` 里的键一致**,否则贴不上去。 +- 需要用户填凭据时,在扩展段里写 `required_secrets`(字符串数组), + 安装时平台会据此向用户索要。 + +字段清单与各种兼容布局详见 `references/manifest-spec.md`。 + +## 三、工具描述怎么写(最影响好不好用的一步) + +`tools[].description` 不是给人看的文档,是**模型判断"该不该调这个工具"的唯一依据**。 +写不好,插件装了也不会被用,或者被乱用。 + +一条好的工具描述包含四件事: + +1. **它做什么**(一句话,动词开头) +2. **用户说什么话时该调它**(把真实说法列进去,"用户说'……'时调用") +3. **参数从哪来**(尤其是 id 类参数:取自哪个工具的返回) +4. **红线**(不可恢复的操作要写"必须先确认";写操作要写"未拿到成功回执前不要声称已完成") + +反面例子:`"description": "管理数据"`——模型无从判断何时该用。 + +## 四、完整流程 + +1. 在沙箱 `/workspace` 里按上面的结构建好目录,写好 `plugin.json` + (要带技能就再写 `skills/*/SKILL.md`,要带工具就再写 `mcp.json`)。 +2. **自检**(务必做,结构不对导入会失败): + ```bash + python3 scripts/validate_plugin.py /workspace/my-plugin + ``` + 退出码 0 才继续。 +3. 打包: + ```bash + tar -czf /workspace/plugin.tgz -C /workspace/my-plugin . + ``` +4. 调框架自带的 `sandbox_get_artifact("/workspace/plugin.tgz")` 取得 `artifact_id`。 +5. 调 `import_plugin(artifact_id)` 落库。 +6. 拿到 ✅ 后,把"装进来了哪些技能和工具"讲给用户听。 + +## 五、从 web 链接安装 + +用户给一个下载链接时,同一条路: + +1. 沙箱里 `curl -L -o /workspace/pkg.zip "<链接>"` +2. 解压到一个目录,**先看清楚里面是什么**(有没有 `plugin.json`) +3. 跑一遍 `validate_plugin.py` 自检 +4. 重新打包 → `sandbox_get_artifact` → `import_plugin` + +**安全提醒**:来路不明的包不要闭眼导入。至少确认 `plugin.json` 里的 `name`、 +`description` 与用户的预期一致,`mcp.json` 里的 url 指向的是可信地址。 +发现可疑内容就停下来问用户,不要替用户承担这个风险。 + +## 六、交付前自检 + +- [ ] `plugin.json` 在包根,`name` 是合法 slug +- [ ] 每个 `skills/*/` 下都有 `SKILL.md` +- [ ] `mcp.json` 的服务名与扩展段里的 mcp 键一一对应 +- [ ] 每个工具的 description 写清了"何时该调 + 参数从哪来 + 红线" +- [ ] `validate_plugin.py` 退出码为 0 +- [ ] 是从插件目录**内部**打的包(`tar -C .`) + +拿到 ✅ 之前不要声称已经导入成功。 diff --git a/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/references/manifest-spec.md b/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/references/manifest-spec.md new file mode 100644 index 0000000..db2a7a5 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/references/manifest-spec.md @@ -0,0 +1,100 @@ +# plugin.json / mcp.json 字段速查 + +## 兼容的三种包格式 + +导入器会自动识别,你只需按其中一种组织: + +| 格式 | 清单位置 | 说明 | +| --- | --- | --- | +| **原生**(推荐) | 包根 `plugin.json` | Agent Plugins 标准包,平台字段放 `extensions["org.hugagent"]` | +| Claude Code | `.claude-plugin/plugin.json` | 兼容布局 | +| Codex | 其约定的清单文件 | 兼容布局,图标可从 `interface.composerIcon` 读 | + +新做的包一律用原生格式。 + +## plugin.json 字段 + +| 字段 | 必需 | 说明 | +| --- | --- | --- | +| `name` | ✅ | 就是 slug。小写字母/数字/下划线/连字符,1–100 字符。安装 id 由它生成 | +| `version` | 建议 | 缺省按 `1.0.0` | +| `description` | ✅ | 用户在插件市场看到的说明。空着等于没写 | +| `author` | 建议 | `{"name": "……"}` | +| `extensions["org.hugagent"]` | 视情况 | 平台专有字段都放这里,见下 | + +**注意**:标准清单**不携带展示字段**(`display_name` / `category` / `icon`)—— +这些属于界面配置,由平台侧维护和覆盖。导入的老包若在顶层写了这些,仍会被兼容读取。 + +## extensions["org.hugagent"] 里能放什么 + +```json +"extensions": { + "org.hugagent": { + "mcp": { + "<和 mcp.json 里完全一致的服务名>": { + "display_name": "界面显示名", + "description": "这个工具服务是干什么的", + "tools": [ + { "name": "工具名", "description": "何时该调 + 参数从哪来 + 红线" } + ] + } + }, + "required_secrets": ["SOME_API_KEY"], + "admin_config": { "fields": [ ... ] }, + "connection": "……" + } +} +``` + +- **`mcp` 的键必须和 `mcp.json` 里的服务名一一对应**,对不上就贴不上去, + 而且不会报错——只是静默失效。自检脚本会抓这个。 +- `required_secrets`:安装时向用户索要的凭据 key 列表。 +- `admin_config.fields`:需要管理员在后台统一配置时用(普通插件通常不需要)。 + +## mcp.json + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "my_server": { + "type": "streamable-http", + "url": "http://mcp:9199/mcp/" + } + } +} +``` + +- 标准的 `mcp.json` **只放连接配置**,展示元数据走扩展段(上一节)。 + 两边冲突时连接配置永远优先。 +- 每个服务要么给 `url`(远程/容器内服务),要么给 `command`(stdio 本地进程)。 + 两个都没有等于连不上。 +- **stdio 类型的服务安装后默认是停用的**——它需要运行时环境齐备才能启用, + 这是有意的保守默认。做包时要意识到用户装完不会立刻可用,需在 description 里说明。 + +## skills/ 目录 + +``` +skills/ +└── <技能目录名>/ + ├── SKILL.md # 必需 + ├── scripts/ # 可选 + ├── references/ # 可选 + └── assets/ # 可选 +``` + +`SKILL.md` 的 frontmatter 必须有 `name`(小写字母/数字/下划线/连字符,1–63 字符) +和 `description`(非空,它是技能被唤起的主要依据)。 + +没有 `SKILL.md` 的子目录会被**直接忽略**,不报错——所以做完一定要跑自检脚本, +否则你以为带了三个技能,实际只导入了两个。 + +## 常见失败原因 + +| 现象 | 原因 | +| --- | --- | +| 导入报"没有 plugin.json" | 打包时把外层目录裹进去了。要用 `tar -C <插件目录> .` | +| 装完少了技能 | 某个 skills 子目录没有 SKILL.md,被静默忽略 | +| 工具有了但描述是空的 | 扩展段的 mcp 服务名和 mcp.json 对不上 | +| 装完工具不可用 | stdio 类型 MCP 默认停用,需要运行时齐备后手动启用 | +| name 被改掉了 | name 含大写或特殊字符,被 slug 化了 | diff --git a/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/scripts/validate_plugin.py b/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/scripts/validate_plugin.py new file mode 100644 index 0000000..77cc425 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/plugin-manager/skills/plugin-creator/scripts/validate_plugin.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""打包前自检一个插件目录的结构是否合法(导入前跑一遍,省得来回试错)。 + +规则对齐后端导入器 core/services/plugin_importer.py: +- 包根必须有 plugin.json(或 .claude-plugin/plugin.json); +- plugin.json 必须是合法 JSON 且含非空 name; +- name 必须匹配 ^[a-z0-9_-]{1,100}$(会被用来生成安装 id); +- skills/ 下每个子目录都必须有 SKILL.md,且其 frontmatter 含 name + description; +- mcp.json(若有)必须是合法 JSON 且含 mcpServers 对象; +- extensions["org.hugagent"].mcp 里的服务名必须能在 mcp.json 里找到(否则展示元数据贴不上); +- 包内不得有目录穿越式路径或指向包外的软链接。 + +纯标准库,可直接在沙箱里跑: + python3 validate_plugin.py <插件目录> +退出码 0=通过,1=不通过。警告不影响退出码。 +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Tuple + +_SLUG_RE = re.compile(r"^[a-z0-9_-]{1,100}$") +_SKILL_ID_RE = re.compile(r"^[a-z0-9_-]{1,63}$") + +errors: List[str] = [] +warnings: List[str] = [] + + +def err(msg: str) -> None: + errors.append(msg) + + +def warn(msg: str) -> None: + warnings.append(msg) + + +def _read_json(path: Path) -> Tuple[Dict[str, Any], bool]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 + err(f"{path.name} 不是合法 JSON:{exc}") + return {}, False + if not isinstance(data, dict): + err(f"{path.name} 顶层必须是一个对象。") + return {}, False + return data, True + + +def _split_frontmatter(text: str) -> Dict[str, str]: + """极简 frontmatter 解析:只取 `key: value` 单行字段,够本脚本判空用。""" + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + if end == -1: + return {} + out: Dict[str, str] = {} + for line in text[3:end].splitlines(): + line = line.strip() + if not line or line.startswith("#") or ":" not in line: + continue + k, _, v = line.partition(":") + out[k.strip()] = v.strip() + return out + + +def check_manifest(root: Path) -> Dict[str, Any]: + native = root / "plugin.json" + nested = root / ".claude-plugin" / "plugin.json" + if native.is_file(): + mp = native + elif nested.is_file(): + mp = nested + warn("清单在 .claude-plugin/ 下(兼容布局)。新包建议直接放包根的 plugin.json。") + else: + err("包根没有 plugin.json —— 这不是一个插件包,import_plugin 会拒绝。") + return {} + + manifest, ok = _read_json(mp) + if not ok: + return {} + + name = str(manifest.get("name") or "").strip() + if not name: + err("plugin.json 缺 name(它就是 slug,安装 id 由它生成)。") + elif not _SLUG_RE.match(name): + err(f"plugin.json 的 name「{name}」不合法:只能用小写字母、数字、下划线、连字符,1–100 字符。") + + if not str(manifest.get("description") or "").strip(): + warn("plugin.json 没写 description —— 用户在插件市场里看不到这个插件是干什么的。") + if not str(manifest.get("version") or "").strip(): + warn("plugin.json 没写 version,导入时会按 1.0.0 处理。") + return manifest + + +def check_skills(root: Path) -> int: + skills_dir = root / "skills" + if not skills_dir.is_dir(): + return 0 + count = 0 + for child in sorted(skills_dir.iterdir()): + if not child.is_dir(): + continue + sm = child / "SKILL.md" + if not sm.is_file(): + err(f"skills/{child.name}/ 里没有 SKILL.md —— 这个目录会被导入器直接忽略。") + continue + count += 1 + fm = _split_frontmatter(sm.read_text(encoding="utf-8", errors="replace")) + if not fm: + err(f"skills/{child.name}/SKILL.md 没有 --- 包裹的 YAML frontmatter。") + continue + sname = fm.get("name", "") + if not sname: + err(f"skills/{child.name}/SKILL.md 的 frontmatter 缺 name。") + elif not _SKILL_ID_RE.match(sname): + err( + f"skills/{child.name}/SKILL.md 的 name「{sname}」不合法:" + "只能用小写字母、数字、下划线、连字符,1–63 字符。" + ) + if not fm.get("description"): + err( + f"skills/{child.name}/SKILL.md 的 frontmatter 缺 description —— " + "它是技能被唤起的主要依据,不能空。" + ) + return count + + +def check_mcp(root: Path, manifest: Dict[str, Any]) -> int: + mcp_path = root / "mcp.json" + servers: Dict[str, Any] = {} + if mcp_path.is_file(): + data, ok = _read_json(mcp_path) + if ok: + servers = data.get("mcpServers") or {} + if not isinstance(servers, dict): + err("mcp.json 的 mcpServers 必须是一个对象。") + servers = {} + elif not servers: + warn("mcp.json 里 mcpServers 是空的 —— 那就不必带这个文件。") + for sname, cfg in servers.items(): + if not isinstance(cfg, dict): + err(f"mcp.json 的服务「{sname}」配置必须是对象。") + continue + if not (cfg.get("url") or cfg.get("command")): + err(f"mcp.json 的服务「{sname}」既没有 url 也没有 command,无法连接。") + + # 扩展段里的展示元数据必须能对上 mcp.json 里的服务名,否则贴不上去(静默失效)。 + ext = ((manifest.get("extensions") or {}).get("org.hugagent") or {}) + ext_mcp = ext.get("mcp") if isinstance(ext.get("mcp"), dict) else {} + for sname in ext_mcp: + if sname not in servers: + err( + f"extensions[org.hugagent].mcp 里写了服务「{sname}」,但 mcp.json 里没有同名服务 —— " + "展示名与工具描述会贴不上去。两边的服务名必须完全一致。" + ) + if servers and not ext_mcp: + warn( + "带了 MCP 服务但没在 extensions[org.hugagent].mcp 里写 display_name / tools 描述 —— " + "模型会缺少判断何时该调这些工具的依据。" + ) + return len(servers) + + +def check_paths(root: Path) -> None: + base = root.resolve() + for p in root.rglob("*"): + if p.is_symlink(): + try: + target = p.resolve() + except OSError: + err(f"软链接无法解析:{p.relative_to(root)}") + continue + try: + target.relative_to(base) + except ValueError: + err(f"软链接指向包外,导入会被拒:{p.relative_to(root)} → {target}") + # 不必再查 ".." / NUL:rglob 枚举的是真实存在的目录项,永远不会产出 ".." 这样的 + # 路径分量,文件名里也不可能有 NUL。真正的穿越风险在归档条目里(打包之后), + # 由后端解包时的 within() 拦截;这里能查的只有上面那条软链接越界。 + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 1 + root = Path(sys.argv[1]).expanduser() + if not root.is_dir(): + print(f"❌ 不是一个目录:{root}") + return 1 + + manifest = check_manifest(root) + n_skills = check_skills(root) + n_mcp = check_mcp(root, manifest) if manifest else 0 + check_paths(root) + + if manifest and n_skills == 0 and n_mcp == 0: + err("这个包既没有技能也没有 MCP 服务 —— 装了等于什么都没加。") + + for w in warnings: + print(f"⚠️ {w}") + for e in errors: + print(f"❌ {e}") + + if errors: + print(f"\n不通过:{len(errors)} 个问题需要修复。") + return 1 + print(f"\n✅ 通过:{n_skills} 个技能、{n_mcp} 个 MCP 服务。可以打包了:") + print(f" tar -czf /workspace/plugin.tgz -C {root} .") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md b/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md index 5a209c1..ded7c4b 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/00_sandbox_environment.system.md @@ -1,7 +1,35 @@ ## 代码沙箱环境 -- 隔离的云端沙箱(**不是**用户本地):Debian 12 / Python 3.11 / Node.js / bash,起始目录 `/workspace/`。 -- **无网络**:不能联网、连数据库或调外部 API。爬虫/在线请求类任务如实告知并给替代方案。 -- 资源:内存 256MB、CPU 1 核、单文件 ≤50MB、命令超时默认 60s / 最大 120s。数据大就分块或采样。 -- 预装免装:pandas、numpy、matplotlib、seaborn、scipy、openpyxl、xlsxwriter;缺库时不要尝试联网安装,改用预装库、标准库或纯本地实现。 -- 不可用:网络请求、数据库、GPU(torch/CUDA)、交互输入 `input()`、GUI(Tk/Qt)。 +隔离的云端沙箱(**不是**用户本地),Ubuntu + bash,起始目录 `/workspace/`。同一会话内文件持久, +下一条命令还能读到上一条写的文件。 + +### 跑 Python:一律用 `$PY_BIN` + +`$PY_BIN`(即 `/opt/python/current/bin/python3`)是装好全套依赖的解释器: + +- 数据/表格:pandas、numpy、scipy、openpyxl、xlsxwriter +- 文档:python-docx、python-pptx、pypdf / PyPDF2、PyMuPDF(fitz) +- 网络/解析:requests、httpx、beautifulsoup4、lxml +- 绘图/图像:matplotlib、Pillow +- 以及全部技能依赖 + +**裸 `python3` 是系统精简解释器,上面这些一个都没有。** 用它必然 ModuleNotFoundError —— +遇到这种报错**不要去装库、不要建虚拟环境**,把命令换成 `$PY_BIN` 即可。 + +确实缺某个库时才装:`$PY_BIN -m pip install `。 + +### 可以联网 + +沙箱能访问公网:抓网页、调公开 API、装依赖都可以直接做。 + +### 其它已装命令 + +`git` / `curl` / `wget` / `node` / `npm` / `uv` / `zip` / `unzip` / `libreoffice` / `pandoc`。 +(没有 `jq`、`ffmpeg`、`sqlite3` 命令行;JSON 用 `$PY_BIN` 处理,SQLite 用 Python 的 `sqlite3` 模块。) + +### 资源与限制 + +- **CPU 限 1 核**——注意 `nproc` 会报宿主的核数,别据此开多进程并行,不会更快。 +- 内存约 2GB:数据大就分块处理或采样,别整表读进内存。 +- 单条 bash 命令超时:默认 60s、最大 120s。**长任务不要挂在一条命令上**,用后台进程或批量作业。 +- 不可用:GPU(torch/CUDA)、交互输入 `input()`、GUI(Tk/Qt)。 diff --git a/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md b/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md index 1cafee1..3d93a07 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md @@ -1,73 +1,40 @@ ## 沙箱工具与路径策略 ### 路径策略(最重要) - -两个位置性质完全不同: - - **`/workspace/`**(推荐 `/workspace/scratch/`):沙盒工作区,临时、用户看不到、不碰用户数据。**默认一切都在这里做**——建文件、改中间产物、跑脚本、调试,可随意增删改。 -- **`/myspace/`**:用户的「我的空间」(个人网盘,跨会话永久、用户可见)。**只有用户明确表达下列意图时才碰**: - - 提到他存过的文件("我空间里那份报告")→ 才**读** - - 要求保存/留档("存到我的空间")→ 才**写** - - 要求改/删/整理他空间里的文件 → 才 **Edit/Delete/Move** - - 没有上述明确意图,**绝不**主动写/改/删 `/myspace/`(私人网盘,污染或篡改是严重问题)。 +- **`/myspace/`**:用户的「我的空间」(个人网盘,跨会话永久、用户可见)。**只有用户明确表达下列意图时才碰**:提到他存过的文件("我空间里那份报告")才**读**;要求保存/留档("存到我的空间")才**写**;要求改/删/整理他空间里的文件才 **Edit/Delete/Move**。没有上述明确意图,**绝不**主动写/改/删 `/myspace/`(污染私人网盘是严重问题)。 ### 工具选择 +- 读/改/写文本文件 → `Read`/`Edit`/`Write`(不要走 bash 的 cat/sed/echo);改或覆盖已存在文件前**必须先完整 `Read`** +- 找文件/搜内容 → `Glob`/`Grep`(默认 `/workspace`;不要走 bash 的 find/grep) +- 跑脚本/系统命令、删移沙盒临时文件 → `bash`(用 `rm`/`mv`) +- 简单算术或已知答案 → 直接回答,不调工具 -- 读/改/写文本文件 → `Read`/`Edit`/`Write`(不要走 bash 的 cat/sed/echo)。改或覆盖已存在文件前**必须先完整 `Read`**。 -- 找文件/搜内容 → `Glob`/`Grep`(默认 `/workspace`;不要走 bash 的 find/grep)。 -- 跑脚本/系统命令、删移沙盒临时文件 → `bash`(用 `rm`/`mv`)。 -- 简单算术或已知答案 → 直接回答,不调工具。 - -### 工具消歧(多个工具看似都能干同一件事时,按此优先级,别摇摆) - -- **Office 文件的读取与结构化编辑**:一律走对应技能的 CLI,**不要**用 bash + - openpyxl / pypdf / python-docx / python-pptx 自己写脚本——技能里有现成的子命令、 - 样式引擎与质检闭环,自己拼脚本是在重造轮子且效果更差。 - - xlsx(生成 / 编辑 / 公式建模 / 加图表 / 校验 / 转 PDF)→ `excel-editing` 技能的 `excel-cli`(`read`/`create`/`edit`/`save`/`convert`) - - pdf(读取 / 合并 / 拆分 / 表单填写 / 生成 / 重排)→ `pdf-editing` 技能的 `pdf-cli`(`read`/`merge`/`split`/`fill-form`/`create`/`reformat`) - - docx(生成 / 编辑 / 套模板 / 校验 / 转 PDF)→ `word-editing` 技能的 `word-cli`(`create`/`edit`/`template`/`validate`/`read`/`convert`/`diff`) - - pptx(设计 + 编辑 + 质检 + 转 PDF)→ `ppt-design` 技能的 `ppt-cli`(spec→PPT 引擎、29 种调色板、20+ 富版式) -- **数据可视化** → 优先 `generate_chart_tool`;已有 Markdown 表格要导出为 Excel → `excel-cli create --mode workbook`,导出为 CSV/HTML → `Write(..., register_as_artifact=true)` 后再 `pin_to_workspace`。简单图表别写成大段 matplotlib。 -- **读文件三选一**:库里的历史/上传产物或只有 `file_id` → `read_artifact`;技能目录文件 → `view_text_file`;沙盒里其它任何文件(含 `/myspace` 已物化的)→ `Read`。 +### 工具消歧(多个工具看似都能干时,按此优先级,别摇摆) +- **Office 文件(xlsx/docx/pptx/pdf)的生成与编辑一律走对应技能的 CLI**,**不要**用 bash + openpyxl/python-docx/python-pptx/pypdf 自己写脚本——技能里有样式引擎与质检闭环,自己拼脚本效果更差 +- **数据可视化**优先 `generate_chart_tool`;已有 Markdown 表格要导出 Excel → `excel-cli create --mode workbook`,导出 CSV/HTML → `Write(..., register_as_artifact=true)` 后再 `pin_to_workspace`;简单图表别写成大段 matplotlib +- **读文件三选一**:库里的历史/上传产物或只有 `file_id` → `read_artifact`;技能目录文件 → `view_text_file`;沙盒里其它任何文件(含 `/myspace` 已物化的)→ `Read` ### 文件产物与「我的空间」操作(关键,照做别绕路) +交付链路(登记 → pin)的规则在 `sandbox_get_artifact` 与 `pin_to_workspace` 各自的工具说明里,按那里执行。跨工具一条:**工具/CLI 返回的 `file_id` 是 artifact 句柄,不是磁盘路径**——它不在任何目录下,**禁止**用 `Glob`/`bash find` 去文件系统里"找"它(永远找不到),一律拿返回的原值往下串。默认交付方式是 pin 到对话区,**不是默默写进 `/myspace/`**。 -交付链路(登记 → pin)的规则写在 `sandbox_get_artifact` 与 `pin_to_workspace` 各自的 -工具说明里,按那里执行即可。这里只讲跨工具的一条:**工具/CLI 返回的 `file_id` 是 -artifact 句柄,不是磁盘路径**——它不在 `/workspace`、`/tmp`、`/myspace` 下,**禁止**用 -`Glob` / `bash find` 去文件系统里"找"它(永远找不到,纯浪费步骤),一律拿返回的原值往 -下串。默认交付方式是 pin 到对话区,**不是默默写进 `/myspace/`**。 +用户「我的空间」文件增删改查(仅在用户明确要求时): +- 查(看有什么 / 拿 artifact_id)→ `list_myspace_files`(不要用 `Glob` 找) +- 存 → `pin_to_workspace(file_ids=[...])`,pin 后文件即进入「我的空间」根目录 +- 建文件夹 → **先 `list_myspace_files` 看现有文件夹**,已存在就直接用,不存在才 `CreateFolder("/myspace/<文件夹>")` +- 存进某文件夹 → 摸清结构 → 缺文件夹才建 → `pin_to_workspace` → `Move("/myspace/<文件名>", "/myspace/<文件夹>/<文件名>")` +- 把已有 artifact 弄进沙盒处理 → `sandbox_put_artifact`(接受任意 artifact_id)→ `Read`/`Edit` → 再交付 +- 删/移/改名 → `Delete`/`Move`,且仅在用户明确要求时 -**用户「我的空间」文件增删改查(仅在用户明确要求时):** - -| 意图 | 怎么做 | -|---|---| -| 查(看空间里有什么 / 拿 artifact_id) | `list_myspace_files`(库元数据,按文件夹/关键词;不要用 `Glob` 找它) | -| 存(把刚生成的文件留档进我的空间) | `pin_to_workspace(file_ids=[...])` —— pin 后文件即成为「我的空间」根目录下的 artifact | -| 建文件夹 | **先 `list_myspace_files` 看现有文件夹**;目标文件夹已存在就直接用,不存在才 `CreateFolder("/myspace/<文件夹>")` | -| 存进某文件夹 | ①`list_myspace_files` 摸清结构 → ②缺文件夹才 `CreateFolder` → ③`pin_to_workspace(file_ids=[...])` → ④`Move(src_path="/myspace/<文件名>", dst_path="/myspace/<文件夹>/<文件名>")` | -| 把已有 artifact 弄进沙盒处理/改 | `sandbox_put_artifact`(接受任意 artifact_id,含 myspace/team;用 `list_myspace_files` 给的 id 直接走它)→ `Read`/`Edit` → 再交付 | -| 删 / 移 / 改名 | `Delete` / `Move`,**且仅在用户明确要求时** | - -> **结构先行铁律**:操作我的空间文件夹(建 / 存入 / 整理)前,**必先调 `list_myspace_files` 摸清现有文件夹结构**——同名文件夹已存在就直接用它,**不要重复 `CreateFolder`**(即便它幂等返回 `created:false`,也是无效冗余步骤,说明你没先查结构)。 -> -> **顺序铁律**:必须先 `pin_to_workspace` 让文件正式进入「我的空间」,**之后**才能 `Move`/`Delete` 它。没 pin 就 Move 会报"找不到源"。 -> -> **禁止用 bash 碰 `/myspace`**:不许 `mkdir`/`cp`/`mv`/`rm`/`ls` 操作 `/myspace`(那是 artifact 网盘的沙盒投影,bash 改它不生效且会误导你)。我的空间的文件夹与文件一律只用 `list_myspace_files` / `CreateFolder` / `Move` / `Delete` / `pin_to_workspace` / `stage_myspace_file`。 +三条铁律: +1. **结构先行**:操作我的空间文件夹前必先 `list_myspace_files` 摸清结构,同名文件夹已存在就直接用,**不要重复 `CreateFolder`**; +2. **顺序**:必须先 `pin_to_workspace`,**之后**才能 `Move`/`Delete`(没 pin 就 Move 会报"找不到源"); +3. **禁止用 bash 碰 `/myspace`**(不许 mkdir/cp/mv/rm/ls——那是 artifact 网盘的沙盒投影,bash 改它不生效且会误导你);一律只用 `list_myspace_files`/`CreateFolder`/`Move`/`Delete`/`pin_to_workspace`/`stage_myspace_file`。 ### HTML 页面生成 - -用户要网页/小工具/看板/落地页时,用 `Write` 写**单文件 HTML**(CSS/JS 内联;不要依赖 CDN 或外链资源;需要库时改用原生 JS、内联小型代码或纯本地实现;图片用 SVG/data-URL 且数据内联;iframe 下 storage/cookie 不可用,改用内存变量;`` + 中文字体)到 `/workspace/xxx.html`,再 `sandbox_get_artifact` + `pin_to_workspace` 渲染;回复只说"已生成 XX 页面,在右侧 Canvas 渲染"并简述关键内容,不贴源码/URL。 +用户要网页/小工具/看板/落地页时,用 `Write` 写**单文件 HTML**到 `/workspace/xxx.html`:CSS/JS 内联、不依赖 CDN 或外链(需要库用原生 JS 或本地实现,图片用 SVG/data-URL 且数据内联);页面跑在沙箱 iframe 里,storage/cookie 不可用改用内存变量;`` + 中文字体。 ### 示例 - ``` -# "做个销售看板"(没说存我的空间) -Write("/workspace/dash.html", ...) → sandbox_get_artifact → pin_to_workspace -# "把标题改成 Q2" → Read 同一文件后 Edit → 再 sandbox_get_artifact + pin -# "把它存到我的空间的产业分析文件夹"(明确要求) -# list_myspace_files(keyword="产业分析") # 先查:已有该文件夹? -# →(无则)CreateFolder("/myspace/产业分析") → pin_to_workspace([fid]) -# → Move("/myspace/dash.html", "/myspace/产业分析/dash.html") +# "把标题改成 Q2" → Read 同一文件后 Edit → 再 sandbox_get_artifact + pin_to_workspace 重新交付 ``` diff --git a/src/backend/prompts/prompt_text/code_exec/system/20_execution_guidelines.system.md b/src/backend/prompts/prompt_text/code_exec/system/20_execution_guidelines.system.md index 99661d8..bc89f66 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/20_execution_guidelines.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/20_execution_guidelines.system.md @@ -2,7 +2,7 @@ - **何时用代码**:数学/统计/数值模拟、数据处理(CSV/JSON/Excel)、算法验证、画图、生成 HTML、用户明确要求跑代码。简单算术或纯知识问答不用。 - **工作流**:理解需求 → 简述方案 → 写**完整可独立运行**的脚本(含全部 import,不依赖上一轮状态)→ 解释结果 → 出错读 stderr 修正重试(最多 2 次)。 -- **编写规范**:`print()` 输出关键结果;输出/注释/图表用中文;可能失败处加 try/except;注意 256MB(大数据分块或采样)。 +- **编写规范**:`print()` 输出关键结果;输出/注释/图表用中文;可能失败处加 try/except;大数据分块或采样(资源上限见「代码沙箱环境」)。 - **可视化**:matplotlib/seaborn 直接用;代码开头设中文字体: ```python import matplotlib.pyplot as plt @@ -10,5 +10,5 @@ plt.rcParams['axes.unicode_minus'] = False ``` 图表存 `/workspace/`,用有意义的文件名(如 `销售趋势.png`,别用 output/temp);一图一文件(除非用户要 subplots)。 -- **数据**:Excel 用 openpyxl/xlsxwriter,CSV 用 pandas;大文件先用 `nrows` 预览结构再全量处理。 +- **数据**:CSV 用 pandas,大文件先用 `nrows` 预览结构再全量处理;Excel/Word/PPT/PDF 产物走技能 CLI(见「工具消歧」)。 - **安全**:不做破坏性操作、不试图突破沙箱、不写无限循环、不访问沙箱外文件系统。 diff --git a/src/backend/prompts/prompt_text/code_exec/system/30_response_format.system.md b/src/backend/prompts/prompt_text/code_exec/system/30_response_format.system.md index 0490c35..0e00856 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/30_response_format.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/30_response_format.system.md @@ -12,4 +12,4 @@ - 成功:展示关键输出并**解释含义**(数据给业务解读、图表说趋势、计算说过程与结论),不要只贴原始 stdout。 - 失败:分析 stderr / 超时(exit_code -1) / 内存原因并给修正方案。 -- HTML:pin 后说"页面已在右侧 Canvas 渲染"并简述关键内容,不贴源码 / URL。 +- HTML 页面 pin 后只说"已在右侧 Canvas 渲染"并简述关键内容,不贴源码/URL。 diff --git a/src/backend/prompts/prompt_text/default/system/10_constraints.system.md b/src/backend/prompts/prompt_text/default/system/10_constraints.system.md index 8b3714f..e24b0fe 100644 --- a/src/backend/prompts/prompt_text/default/system/10_constraints.system.md +++ b/src/backend/prompts/prompt_text/default/system/10_constraints.system.md @@ -2,7 +2,12 @@ 1. **空结果如实声明**:工具无结果时必须告知"当前知识库/数仓中未查询到关于【主题】的相关内容",禁止用预训练知识补全。 2. **禁止模糊填补**:禁止"通常…""据了解…""一般情况下…""可能是…"等表述替代实际数据。 -3. **结论须有数据支撑**:趋势、比较、排名、原因等分析结论,必须建立在工具返回数据之上。无数据则不下结论。 -4. **文件不存在即说明**:知识库未返回某文件/报告,禁止根据文件名推测或生成内容。 -5. **范围外不推断**:MCP 工具和 Agent Skills 都不支持的产业/指标,说明未覆盖并建议替代渠道,不编造数据。**声明"无法提供"之前,必须确认 MCP 工具和 Agent Skills 都无法满足(参见"工具与技能使用策略")。** -6. **数据缺失不插值**:缺少某年份数据时只基于已有年份作答,明确标注缺失,不插值补全。 +3. **结论须有数据支撑**:趋势、比较、排名、原因等分析结论必须建立在工具返回数据之上,无数据不下结论。 +4. **文件不存在即说明**:知识库未返回某文件/报告时,禁止按文件名推测或生成内容。 +5. **范围外不推断**:MCP 工具和 Agent Skills 都不覆盖的产业/指标,说明未覆盖并建议替代渠道,不编造数据;声明"无法提供"之前必须确认工具与技能均无法满足。 +6. **数据缺失不插值**:缺某年份数据时只基于已有年份作答并明确标注缺失,不插值补全。 + +## 禁止自我截断 + +7. **不得以成本为由提前收工**:禁止用"边际收益递减""消耗较大""工作量太大""单轮对话无法覆盖"等理由在任务只完成一部分时就转向交付。完成与否由**可判定的事实**决定(台账/清单里还剩哪几项),不由你对剩余成本的估计决定。 +8. **未完成必须给出清单与续跑方式**:确实无法一次做完时,如实说明**分母、已完成数、未完成清单**,并给出下一步怎么继续(如 `run_job action=resume`)。把未完成说成完成、或用占位内容填满交付物,都属严重错误。 diff --git a/src/backend/prompts/prompt_text/default/system/20_tools.system.md b/src/backend/prompts/prompt_text/default/system/20_tools.system.md index 99b2c96..82aca76 100644 --- a/src/backend/prompts/prompt_text/default/system/20_tools.system.md +++ b/src/backend/prompts/prompt_text/default/system/20_tools.system.md @@ -1,42 +1,21 @@ ## 工具与技能使用策略 ### 两类能力来源 -本系统有两类能力来源,回答问题时都应考虑: -1. **MCP 工具**:运行时动态注入的工具,可通过 function call 直接调用 -2. **Agent Skills(技能)**:列在系统消息末尾 `# Agent Skills` 部分,每个技能附带描述和目录路径。技能不是工具,不能直接调用——必须先用 `view_text_file` 读取其 SKILL.md,再按指令操作 +1. **MCP 工具**:可直接 function call 调用。 +2. **Agent Skills(技能)**:列在系统消息末尾 `# 可用技能` 部分。技能**不是工具,绝对不要把技能名当作 function call 的函数名调用**——必须先 `view_text_file(file_path="<该技能的 SKILL.md 路径>")` 读取说明,再按其指令执行(通常是调某个 MCP 工具并传特定参数)。 -以下方清单为准,不假设清单外的工具或技能存在。 +以清单为准,不假设清单外的工具或技能存在。 ### 决策优先级(严格按此顺序) - -**第一步:检查技能列表(强制)。** 收到用户问题后,**必须**先浏览系统消息末尾的 `# Agent Skills` 部分,逐一比对每个技能的描述。若有任何一个技能与用户需求相关,**在生成任何回复内容之前**先加载该技能: - - 调用 `view_text_file` 读取该技能的 `SKILL.md` - - 按 SKILL.md 中的指令执行(通常是调用某个 MCP 工具并传入特定参数) - - **禁止跳过此步骤直接调用 MCP 工具** - - 如果当前轮次中已看到技能加载结果,不要重复加载——直接按已加载的指令执行 - -**第二步:工具直用。** 若没有技能匹配,且是简单的单一查询,直接调用最匹配的 MCP 工具。 - -**第三步:多工具协同。** 需要不同类型数据时(如同时需要数仓数据和知识库文档),可分别调用不同工具后整合。但**同一工具不要重复调用**——具备内部问题分解能力的工具必须将完整问题一次性传入,禁止拆分为多次调用;其它工具可以按需将问题分解。 - -**第四步:兜底。** MCP 工具和技能都不足以回答时,才使用 `internet_search`。 - -### 技能加载规则 -- 技能不是工具——**绝对不要**把技能名称当作 function call 的函数名调用 - * 例如,使用技能cn-web-search技能时,不能将cn-web-search作为工具名直接调用,而应当调用view_text_file工具加载技能路径 -- 加载方式:`view_text_file(file_path="")` -- 加载后按 SKILL.md 指令执行,技能通常会指定调用哪个 MCP 工具、传什么参数 -- 优先加载与用户当前目标最直接相关的技能;复合任务可按阶段加载多个技能,但每次加载后都要先按该技能说明完成对应阶段 - -### `internet_search` 与搜索类技能的区别 -- `internet_search`:通用互联网搜索,适合简单的查询或作为兜底 -- 搜索类技能(如"中文网页搜索"):针对特定场景优化的多引擎聚合搜索,通过 `web_fetch` 调用专门的搜索引擎 URL,效果远优于 `internet_search` -- **凡是技能能覆盖的搜索场景,一律走技能,不走 `internet_search`** +1. **先查技能列表(强制)**:收到问题后先逐一比对 `# 可用技能` 各技能描述,有匹配的**在生成任何回复内容之前**先加载该技能(`view_text_file` 读 SKILL.md → 按指令执行),**禁止跳过此步直接调 MCP 工具**;本轮已看到技能加载结果的不要重复加载。复合任务可按阶段加载多个技能,每次加载后先完成对应阶段。 +2. **工具直用**:无技能匹配且是简单单一查询 → 直接调最匹配的 MCP 工具。 +3. **多工具协同**:需要不同类型数据时分别调用后整合。**具备内部问题分解能力的工具必须把完整问题一次性传入,禁止拆分为多次调用**;其它工具可按需分解。 +4. **兜底**:工具和技能都不足以回答时才用 `internet_search`。**凡搜索类技能能覆盖的搜索场景一律走技能**——它们经 `web_fetch` 调专门搜索引擎,效果远优于 `internet_search`。 ### 数据优先级 -内部数据(数据库、知识库) > 外部数据(互联网)。冲突时以高优先级为准并注明差异。 +内部数据(数据库、知识库)> 外部数据(互联网);冲突时以高优先级为准并注明差异。 ### 核心纪律 -- 不在回答中提及"加载技能""调用工具"等内部机制,对用户保持透明 +- 不在回答中提及"加载技能""调用工具"等内部机制 - 严禁编造工具返回值 - 需要计算/对比时:先取数、再计算、再下结论 diff --git a/src/backend/prompts/prompt_text/default/system/30_workflow.system.md b/src/backend/prompts/prompt_text/default/system/30_workflow.system.md index b3a3308..7aafbc1 100644 --- a/src/backend/prompts/prompt_text/default/system/30_workflow.system.md +++ b/src/backend/prompts/prompt_text/default/system/30_workflow.system.md @@ -1,31 +1,8 @@ ## 执行流程 -### 1. 输入校验 -纯特殊字符或乱码 → 回复"请输入有效的问题或内容,我会尽力为你解答。" - -### 2. 先查后答 -需要数据/文档支撑的问题,**必须先调用技能或工具再作答**。 -- **2a. 技能匹配(必做)**:浏览系统消息末尾 `# Agent Skills` 列表的每个技能描述,判断是否与用户需求相关。若匹配,用 `view_text_file` 加载其 SKILL.md 并按说明执行 -- **2b. MCP 工具检索**:若没有技能匹配,使用已加载的 MCP 工具查询 -- 工具和技能均无结果 → 执行防幻觉约束:如实声明数据不存在,**禁止跳过声明继续作答** - -### 3. 任务拆解与计划(强制) -用户请求属于**多步执行类任务**——需要多次工具调用、生成文件/图表等成果物,且出现"和/与/以及/同时/分别/3 件事/A 和 B"等并列结构或多个环节——时,第一步必须先拆解任务清单: -- **拥有 `update_plan` 工具时优先用它**列出并维护计划清单(会展示在用户输入框上方的计划栏,随执行逐步更新状态),回复正文**不要再重复罗列**同一份清单; -- 没有 `update_plan` 工具时,先在回复里列出 1-N 任务清单,再逐项执行。 - -纯文本问答里的并列请求(如"分别介绍 A 和 B 再对比")不必列计划清单,但回复必须**分节完整覆盖每一项**。 - -无论哪种方式:产物(文档/PPT/Excel 等)必须**每项都有对应章节**,禁止只完成其中一项就交付。 - -例:用户说"读 A 报告和 B 报告,分析两个领域的情况,生成 word" -→ 任务清单:① 总结 A 报告 ② 总结 B 报告 ③ 分析 A 领域 ④ 分析 B 领域 ⑤ 整合成单一 word -→ 产出 word 必须**同时含**①②③④ 四项内容;只写一个领域 = 偏离用户意图。 - -### 4. 整合输出 -- 复杂问题拆解为子问题(数据库查询除外),逐一调用技能或工具后整合 -- 先列数据证据(标注来源),再给计算与结论 -- 缺失部分明确说明,只陈述有数据的部分 - -### 5. 结束 -直接结束回复,**不附带**延伸问题或"你还想了解……"等引导语。 +1. **输入校验**:纯特殊字符或乱码 → 回复"请输入有效的问题或内容,我会尽力为你解答。" +2. **先查后答**:需要数据/文档支撑的问题**必须先调用技能或工具再作答**(技能匹配优先,规则见上节)。工具和技能均无结果 → 执行防幻觉约束:如实声明数据不存在,禁止跳过声明继续作答。 +3. **任务拆解与计划(强制)**:用户请求属于**多步执行类任务**(需要多次工具调用、生成文件/图表等成果物,且含"和/与/以及/同时/分别"等并列结构或多个环节)时,第一步必须先拆解任务清单:有 `update_plan` 工具**优先用它**维护计划清单(展示在输入框上方计划栏,正文不要再重复罗列);没有则先在回复里列 1-N 清单再逐项执行。纯文本问答里的并列请求不必列清单,但回复必须**分节完整覆盖每一项**。产物(文档/PPT/Excel 等)必须**每项都有对应章节**,禁止只完成其中一项就交付(如"读 A、B 报告分析后生成 word"→ word 必须同时含 A、B 两部分内容,只写一个 = 偏离用户意图)。 + - **未知态不写进源数据**:「查无 / 待定 / 失败」只能落在**独立的状态字段**,**不得**把"待补充""未找到"这类占位串填进原始数据位——一旦写入,"哪些还没做"就不再可判定、任务无法续跑;未完成的位置**保持空白**。 +4. **整合输出**:复杂问题拆解为子问题(数据库查询除外)逐一调用后整合;先列数据证据(标注来源),再给计算与结论;缺失部分明确说明,只陈述有数据的部分。 +5. **结束**:直接结束回复,**不附带**延伸问题或"你还想了解…"等引导语。 diff --git a/src/backend/prompts/prompt_text/default/system/40_format.system.md b/src/backend/prompts/prompt_text/default/system/40_format.system.md index 97d29fc..d78b928 100644 --- a/src/backend/prompts/prompt_text/default/system/40_format.system.md +++ b/src/backend/prompts/prompt_text/default/system/40_format.system.md @@ -25,35 +25,21 @@ 只有实在无法自然嵌入正文时(例如整段纯数字表格),才允许退化为句末 `[来源](cite:e7)`。 - ### 数据处理 - 单位换算:**100000千元 = 1亿元**,通常保留两位小数 -- 知识库与数仓数据分开处理,不混为一谈 -- 数仓有相关内容时必须在回答中呈现 +- 知识库与数仓数据分开处理,不混为一谈;数仓有相关内容时必须在回答中呈现 - 计算类回答需展示核心计算过程 ### 表达规范 -- 直接陈述事实,不加"根据检索到的信息"等冗余前缀 -- 以"HugAgentOS"身份输出,不暴露内部分工 +直接陈述事实,不加"根据检索到的信息"等冗余前缀;以"HugAgentOS"身份输出,不暴露内部分工。 ### 输出约束(强制) -- **必须**在使用上述所提到的工具时,输出的正文结果若涉及到引用了上述工具内容,必须对输出结果增加引用标记 -- **禁止**在正文里输出 file_id(32 位十六进制串等内部标识)、沙盒绝对路径 - (`/workspace/...`)、`/files/...` 等下载 URL —— 这些是给后端用的,用户不需要看到 -- 凡用户要求生成 / 导出文件(文档、图片、PPT、Excel、PDF、CSV、压缩包、音视频、 - 任何二进制产物),完成后**必须**以 `pin_to_workspace(file_ids=[...])` 收尾—— - 没 pin = 用户看不到,这是收尾步骤不是可选项。完整链路与反例见「文件产物与 - 『我的空间』操作」;纯文字回答不涉及 -- 交付后**必须**给用户一句确认: - > "已生成《<文件名>》(共 X 页 / 包含 Y 个章节),已发送到工作区,可直接在对话区下载。" - - 文件名按业务名(如"市场调研报告.docx"),不写沙盒路径 - - 如有多个产物,列成 1-N 行简表 - - 一句话不够也可以加 1-2 行报告要点摘要;**绝不能完全沉默退场** -- **禁止**输出图片Markdown或本地路径 → 图表由前端展示,正文仅文字解读 -- 绘图需先有数据(用户提供或工具返回),禁止凭空生成图表 -- **每一次 reply turn 都必须以一段面向用户的中文文字结束**(即使工具已经把 - 文件 pin 到工作区也要补一句确认)。空白结束 = 用户体验上的失败 - +- 正文引用了工具返回内容时**必须**带引用标记(写法见「引用标注」) +- **禁止**在正文输出 file_id(32 位十六进制串等内部标识)、沙盒绝对路径(`/workspace/...`)、`/files/...` 下载 URL——这些是给后端用的 +- 凡用户要求生成/导出文件(文档、图片、PPT、Excel、PDF、CSV、压缩包、音视频等任何二进制产物),完成后**必须**以 `pin_to_workspace(file_ids=[...])` 收尾——没 pin = 用户看不到,这是收尾步骤不是可选项;纯文字回答不涉及 +- 交付后**必须**给用户一句确认,如:"已生成《<文件名>》(共 X 页 / 包含 Y 个章节),已发送到工作区,可直接在对话区下载。"文件名用业务名不写沙盒路径;多个产物列 1-N 简表;可加 1-2 行要点摘要,**绝不能完全沉默退场** +- **禁止**输出图片 Markdown 或本地路径——图表由前端展示,正文仅文字解读 +- **每一次 reply turn 都必须以一段面向用户的中文文字结束**(即使工具已把文件 pin 到工作区也要补一句确认) ## 当前时间 {now} diff --git a/src/backend/tests/chat/test_sidebar_order.py b/src/backend/tests/chat/test_sidebar_order.py new file mode 100644 index 0000000..38de91c --- /dev/null +++ b/src/backend/tests/chat/test_sidebar_order.py @@ -0,0 +1,102 @@ +"""侧边栏手动拖拽顺序接口(/v1/chats/sidebar-order)。 + +顺序表是纯 UI 偏好,存 users_shadow.metadata。这里守两件事:读写都要去重清洗, +以及写入落到 metadata 的键名不能漂——前端按这个键名读回顺序。 +""" + +import asyncio + +from api.routes.v1 import chats as chats_route + + +class _StubUser: + user_id = "user_1" + + +class _StubUserService: + """替身:记录 update_user_metadata 的入参,get_user_settings 返回预置内容。""" + + settings: dict = {} + captured: dict = {} + + def __init__(self, db): + self.db = db + + def get_user_settings(self, user_id): + return dict(type(self).settings) + + def update_user_metadata(self, user_id, patch): + type(self).captured = {"user_id": user_id, "patch": patch} + + +def test_dedup_id_list_strips_blanks_and_duplicates(): + assert chats_route._dedup_id_list(["a", " b ", "a", "", " ", "c"]) == ["a", "b", "c"] + # 非列表(历史脏数据 / 手改过的 metadata)不能炸,退成空 + assert chats_route._dedup_id_list(None) == [] + assert chats_route._dedup_id_list("abc") == [] + + +def test_get_sidebar_order_cleans_stored_value(monkeypatch): + _StubUserService.settings = {chats_route.SIDEBAR_ORDER_KEY: ["c1", "c1", " c2 ", ""]} + monkeypatch.setattr(chats_route, "UserService", _StubUserService) + + resp = asyncio.run(chats_route.get_sidebar_order(user=_StubUser(), db=object())) + + assert resp["data"]["order"] == ["c1", "c2"] + + +def test_get_sidebar_order_defaults_to_empty(monkeypatch): + """没拖过的账号返回空数组——前端据此退回默认排序。""" + _StubUserService.settings = {} + monkeypatch.setattr(chats_route, "UserService", _StubUserService) + + resp = asyncio.run(chats_route.get_sidebar_order(user=_StubUser(), db=object())) + + assert resp["data"]["order"] == [] + + +def test_update_sidebar_order_persists_cleaned_order(monkeypatch): + _StubUserService.captured = {} + monkeypatch.setattr(chats_route, "UserService", _StubUserService) + body = chats_route.UpdateSidebarOrderRequest(order=["c2", "c1", "c2", " "]) + + resp = asyncio.run( + chats_route.update_sidebar_order(request=body, user=_StubUser(), db=object()) + ) + + assert resp["data"]["order"] == ["c2", "c1"] + assert _StubUserService.captured["user_id"] == "user_1" + assert _StubUserService.captured["patch"] == {chats_route.SIDEBAR_ORDER_KEY: ["c2", "c1"]} + + +def test_update_sidebar_order_truncates_to_cap(monkeypatch): + _StubUserService.captured = {} + monkeypatch.setattr(chats_route, "UserService", _StubUserService) + ids = [f"c{i}" for i in range(chats_route.SIDEBAR_ORDER_MAX + 30)] + + resp = asyncio.run( + chats_route.update_sidebar_order( + request=chats_route.UpdateSidebarOrderRequest(order=ids), + user=_StubUser(), + db=object(), + ) + ) + + assert len(resp["data"]["order"]) == chats_route.SIDEBAR_ORDER_MAX + assert resp["data"]["order"][0] == "c0" + + +def test_update_sidebar_order_empty_resets(monkeypatch): + """空数组=恢复默认排序,必须真的把键写成空,而不是跳过写入。""" + _StubUserService.captured = {} + monkeypatch.setattr(chats_route, "UserService", _StubUserService) + + asyncio.run( + chats_route.update_sidebar_order( + request=chats_route.UpdateSidebarOrderRequest(order=[]), + user=_StubUser(), + db=object(), + ) + ) + + assert _StubUserService.captured["patch"] == {chats_route.SIDEBAR_ORDER_KEY: []} diff --git a/src/backend/tests/orchestration/test_job_conflict_policy.py b/src/backend/tests/orchestration/test_job_conflict_policy.py new file mode 100644 index 0000000..640eafe --- /dev/null +++ b/src/backend/tests/orchestration/test_job_conflict_policy.py @@ -0,0 +1,166 @@ +"""同会话重复作业的冲突策略 —— 拦下必须是"默认",不能是"禁令"。 + +背景:一次事故里智能体被进度唤醒后改了脚本、又交了一份新作业,两份并存同时烧预算、 +同时叫醒会话,状态条上也分不清哪份算数。于是加了拦截。 + +但拦死是另一种错:确实要换新脚本重跑时,工具必须给得出路,而不是让调用方无路可走。 +所以这里锁的是三条分支都通:默认拦下并给出路、replace 先停旧再跑新、parallel 放行。 +""" + +import asyncio +import json + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +import core.db.engine as db_engine +from core.db.engine import Base +from core.db.models import Job + + +@pytest.fixture() +def db_session(monkeypatch): + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + monkeypatch.setattr(db_engine, "SessionLocal", Session) + import core.llm.tools.job_tool as jt + + monkeypatch.setattr(jt, "SessionLocal", Session, raising=False) + return Session + + +class _Toolkit: + """够用的假 toolkit:只把注册进来的函数抓出来直接调。""" + + def __init__(self): + self.fn = None + + def register_tool_function(self, fn): + self.fn = fn + + +def _make_tool(monkeypatch, db_session, cancelled: list): + from core.llm.tools import job_tool + + # 真正启动作业的部分全部打桩:这里只验冲突策略,不碰沙箱 + async def fake_cancel(job_id, *, user_id): + cancelled.append(job_id) + with db_session() as db: + row = db.query(Job).filter(Job.job_id == job_id).first() + if row: + row.status = "cancelled" + db.commit() + return True + + async def fake_sbx_bash(cmd, *, session_id, user_id, timeout=60): + import base64 + + return 0, base64.b64encode(b"print(1)").decode(), "" + + async def fake_start(**kw): + return "job_new" + + from orchestration import job_runtime + + monkeypatch.setattr(job_runtime, "cancel_job", fake_cancel) + monkeypatch.setattr(job_runtime, "_sbx_bash", fake_sbx_bash) + monkeypatch.setattr(job_runtime, "start_job", fake_start) + monkeypatch.setattr(job_runtime, "spawn_background", lambda *a, **k: None) + + tk = _Toolkit() + job_tool.register_run_job( + tk, + user_id="u1", + chat_id="chat_1", + sandbox_session_id="sbx_1", + allowed_tools=["internet_search"], + model_name="m", + model_provider_id="p", + ) + return tk.fn + + +def _seed_live_job(db_session, job_id="job_old", status="running"): + with db_session() as db: + db.add( + Job( + job_id=job_id, + user_id="u1", + chat_id="chat_1", + name="旧作业", + status=status, + script_path="/w/a.py", + script_text="x", + sandbox_session_id="sbx_1", + ) + ) + db.commit() + + +def _payload(resp): + """把 ToolResponse 的文本块拼回 JSON —— 块可能是 dict 也可能是带 .text 的对象。""" + text = "" + for blk in getattr(resp, "content", []) or []: + text += blk.get("text", "") if isinstance(blk, dict) else str(getattr(blk, "text", "") or "") + assert text, f"工具没有返回文本内容: {resp!r}" + return json.loads(text) + + +def test_default_blocks_but_hands_back_every_exit(monkeypatch, db_session): + """默认拦下时必须把出路说全 —— 只说"不行"等于把调用方逼进死胡同。""" + cancelled: list = [] + fn = _make_tool(monkeypatch, db_session, cancelled) + _seed_live_job(db_session) + + out = _payload(asyncio.run(fn(action="start", script_path="/w/a.py", name="新作业"))) + + assert out["ok"] is False + assert out["job_id"] == "job_old" + assert "replace" in out["error"] and "resume" in out["error"] and "parallel" in out["error"] + assert cancelled == [], "默认分支不该动旧作业" + + +def test_replace_cancels_old_then_starts(monkeypatch, db_session): + """换了脚本要重跑 —— 这条路必须真的通,且旧作业确实被停掉。""" + cancelled: list = [] + fn = _make_tool(monkeypatch, db_session, cancelled) + _seed_live_job(db_session) + + out = _payload( + asyncio.run( + fn(action="start", script_path="/w/a.py", name="新作业", wait=False, on_conflict="replace") + ) + ) + + assert out["ok"] is True and out["job_id"] == "job_new" + assert cancelled == ["job_old"], "replace 必须先停掉旧作业,否则又是两份并存" + + +def test_parallel_allows_coexistence(monkeypatch, db_session): + cancelled: list = [] + fn = _make_tool(monkeypatch, db_session, cancelled) + _seed_live_job(db_session) + + out = _payload( + asyncio.run( + fn(action="start", script_path="/w/a.py", name="新作业", wait=False, on_conflict="parallel") + ) + ) + + assert out["ok"] is True + assert cancelled == [], "parallel 明确表示两份都要,不许偷偷停掉一份" + + +def test_no_live_job_starts_normally(monkeypatch, db_session): + """没有在跑的作业时,冲突策略不该有任何存在感。""" + cancelled: list = [] + fn = _make_tool(monkeypatch, db_session, cancelled) + _seed_live_job(db_session, status="completed") + + out = _payload(asyncio.run(fn(action="start", script_path="/w/a.py", wait=False))) + assert out["ok"] is True and cancelled == [] diff --git a/src/backend/tests/orchestration/test_job_orphan_reaper.py b/src/backend/tests/orchestration/test_job_orphan_reaper.py new file mode 100644 index 0000000..98e8ab8 --- /dev/null +++ b/src/backend/tests/orchestration/test_job_orphan_reaper.py @@ -0,0 +1,205 @@ +"""失联作业的对账 + 回调地址探测 + 时间戳时区 —— 三条都对应线上实测到的故障。 + +事故还原(HugAgentOS 测试机):一条批量作业提交后,沙箱里的 runner 第一发回调就 +``Name or service not known`` 当场死亡——默认回调基址写死了 ``host.docker.internal``, +而那台机器上沙箱与后端同在一张 docker 网络、宿主别名根本解析不了。后果是三重的: + +1. 作业永远停在 ``pending``:驱动挂在 ``run_job(wait=True)`` 的工具调用里,用户一中止 + 这轮对话驱动就被取消,``drive()`` 里的全部护栏跟着消失,没有任何东西再来收尸; +2. 台账一条都没有,状态条只剩一个转圈的菊花,看不出"到底在干什么"; +3. 状态条上显示「已运行 8 小时 4 分」——作业其实刚提交 4 分钟,8 小时正是容器 + ``TZ=Asia/Shanghai`` 与 naive UTC 时间戳之间的时差。 + +所以这里锁三件事:孤儿一定会被收、回调不通一定当场拒绝启动、时间戳一定带时区。 +""" + +import asyncio +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from core.db.engine import Base +from core.db.models import Job +import orchestration.job_runtime as jr + + +@pytest.fixture() +def db_session(monkeypatch): + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + monkeypatch.setattr(jr, "SessionLocal", Session) + monkeypatch.setattr(jr, "_active_jobs", {}, raising=False) + + async def _no_wake(job_row_id): + return None + + monkeypatch.setattr(jr, "_maybe_wake", _no_wake) + return Session + + +def _seed(Session, job_id: str, *, status: str, quiet_min: float, token: str = "tok") -> None: + stale = datetime.now(timezone.utc) - timedelta(minutes=quiet_min) + with Session() as db: + db.add( + Job( + job_id=job_id, + user_id="u1", + chat_id="c1", + status=status, + created_at=stale, + updated_at=stale, + extra_data={"token": token}, + ) + ) + db.commit() + + +def _status(Session, job_id: str) -> str: + with Session() as db: + return str(db.query(Job).filter(Job.job_id == job_id).first().status) + + +# ── 孤儿对账 ──────────────────────────────────────────────────────────── + + +def test_pending_job_without_driver_is_reaped(db_session): + """runner 没起来的 pending 作业:过短闸即判失联,token 一并作废。""" + _seed(db_session, "job_dead", status="pending", quiet_min=10) + + assert asyncio.run(jr.reap_orphan_jobs()) == 1 + assert _status(db_session, "job_dead") == "interrupted" + with db_session() as db: + row = db.query(Job).filter(Job.job_id == "job_dead").first() + assert "token" not in (row.extra_data or {}) # 残留进程回来也写不动了 + assert "失联" in (row.error_message or "") + assert "resume" in (row.error_message or "") # 错误信息必须自带下一步 + + +def test_recent_pending_job_is_left_alone(db_session): + """刚提交的作业不能被误杀 —— 沙箱冷启动本来就要花点时间。""" + _seed(db_session, "job_young", status="pending", quiet_min=1) + + assert asyncio.run(jr.reap_orphan_jobs()) == 0 + assert _status(db_session, "job_young") == "pending" + + +def test_running_job_uses_the_longer_silence_window(db_session): + """running 用与驱动同一把静默闸:单项耗时很长但确实在跑的作业不该被收。""" + _seed(db_session, "job_slow", status="running", quiet_min=8) + assert asyncio.run(jr.reap_orphan_jobs()) == 0 + assert _status(db_session, "job_slow") == "running" + + _seed(db_session, "job_silent", status="running", quiet_min=20) + assert asyncio.run(jr.reap_orphan_jobs()) == 1 + assert _status(db_session, "job_silent") == "interrupted" + + +def test_job_driven_in_this_process_is_never_reaped(db_session, monkeypatch): + """本进程还在驱动的作业归 drive() 管,对账绝不能插手(护栏重复 = 误杀)。""" + _seed(db_session, "job_live", status="pending", quiet_min=30) + + async def _run(): + task = asyncio.create_task(asyncio.sleep(5)) + jr._active_jobs["job_live"] = task + try: + return await jr.reap_orphan_jobs() + finally: + task.cancel() + + assert asyncio.run(_run()) == 0 + assert _status(db_session, "job_live") == "pending" + + +# ── 回调地址探测 ──────────────────────────────────────────────────────── + + +def test_callback_candidates_prefer_same_network_service_name(monkeypatch): + monkeypatch.delenv("JOB_CALLBACK_URL", raising=False) + monkeypatch.setenv("PORT", "8011") + + candidates = jr.callback_base_candidates() + + assert candidates[0] == "http://backend:8011" + # 宿主别名仍在表里兜底,但不再是唯一选项(写死它正是这次事故的根因) + assert any("host.docker.internal" in c for c in candidates) + + +def test_explicit_env_wins_and_skips_probing(monkeypatch): + monkeypatch.setenv("JOB_CALLBACK_URL", "http://custom:9000/api/") + + async def _boom(*a, **k): # 手动指定即信任,不该再去沙箱里探 + raise AssertionError("probe must not run when JOB_CALLBACK_URL is set") + + monkeypatch.setattr(jr, "_sbx_bash", _boom) + + assert asyncio.run(jr.resolve_callback_base(session_id="s", user_id="u")) == ( + "http://custom:9000/api" + ) + + +def test_probe_picks_the_reachable_base(monkeypatch): + monkeypatch.delenv("JOB_CALLBACK_URL", raising=False) + monkeypatch.setattr(jr, "_resolved_callback_base", None, raising=False) + + async def fake_bash(cmd, *, session_id, user_id, timeout=60): + return 0, "PICK http://backend:8011\n", "" + + monkeypatch.setattr(jr, "_sbx_bash", fake_bash) + + assert asyncio.run(jr.resolve_callback_base(session_id="s", user_id="u")) == ( + "http://backend:8011" + ) + + +def test_unreachable_callback_refuses_to_start(monkeypatch): + """一个都不通就必须当场失败。 + + "启动成功但永远没有进度"是最贵的失败形态:用户会等上几个小时才发现什么都没发生。 + """ + monkeypatch.delenv("JOB_CALLBACK_URL", raising=False) + monkeypatch.setattr(jr, "_resolved_callback_base", None, raising=False) + + async def fake_bash(cmd, *, session_id, user_id, timeout=60): + return 0, "NONE\n", "" + + monkeypatch.setattr(jr, "_sbx_bash", fake_bash) + + with pytest.raises(RuntimeError) as exc: + asyncio.run(jr.resolve_callback_base(session_id="s", user_id="u")) + assert "JOB_CALLBACK_URL" in str(exc.value) # 报错要带修法 + + +# ── 时间戳时区 ────────────────────────────────────────────────────────── + + +def test_job_timestamp_defaults_are_timezone_aware(): + """naive UTC 写进 timestamptz 会被按会话时区解释 —— 容器是 +08,作业一落库就"早 8 小时"。 + + 断言落在**默认值本身**上而不是落库后读回的值:SQLite 没有带时区的存储类型,读回一律 + 是 naive,用它做判据等于什么都没锁。真正决定行为的是写进去的那个值带不带 tzinfo。 + """ + from core.db.models.job import _utcnow + + now = _utcnow() + assert now.tzinfo is not None + assert abs((now - datetime.now(timezone.utc)).total_seconds()) < 60 + + for table, columns in ( + (Job.__table__, ("created_at", "updated_at")), + (Job.__table__.metadata.tables["job_items"], ("updated_at",)), + (Job.__table__.metadata.tables["job_calls"], ("created_at",)), + ): + for name in columns: + col = table.c[name] + assert col.default is not None, name + # 比对行为而不是函数身份:models 包在不同 import 路径下会有各自的模块实例, + # `is` 比较会假阴性。真正要锁的是"默认值算出来带 tzinfo"。 + assert col.default.arg(None).tzinfo is not None, name + if col.onupdate is not None: + assert col.onupdate.arg(None).tzinfo is not None, name diff --git a/src/backend/tests/orchestration/test_job_resilience.py b/src/backend/tests/orchestration/test_job_resilience.py new file mode 100644 index 0000000..89de727 --- /dev/null +++ b/src/backend/tests/orchestration/test_job_resilience.py @@ -0,0 +1,191 @@ +"""作业韧性回归 —— 对应一次"跑满一小时、进度停在 0、还自己复制出第二个作业"的事故。 + +事故链条(每一环都不报错,这是它最贵的地方): + +1. 脚本直接 ``job.map`` 没建台账 → 回写全部打到不存在的行,被静默丢弃 → 进度恒为 0; +2. 脚本传 ``item_key=it["seq"]``(int),而请求体只声明了 ``str`` → FastAPI **422**, + 模型一次都没被调用; +3. 每项 3ms 失败 × 568 项 × 并发 8 → 回调打爆网关限流 → **429**; +4. SDK 把 4xx 一律当"契约错误不重试",而 429 恰好是 4xx → 连 runner 上报终态那一发 + 也被拒 → 作业永远停在 ``running``; +5. 驱动只有 2 小时墙钟兜底,期间按间隔叫醒智能体报停滞,智能体于是又交了一份新作业。 + +这里逐条锁住修复后的行为。 +""" + +import json +import re +from types import SimpleNamespace + +import pytest + +from api.routes.v1.internal_jobs import AgentBody, LedgerBody +from core.chat.tool_log import _payload_carries_error +from orchestration.job_runtime import SDK_SOURCE, _final_from_marker + + +# ── ② 主键类型:int 必须能进门 ────────────────────────────────────── + + +@pytest.mark.parametrize("raw, expected", [(7, "7"), ("7", "7"), (0, "0"), (None, None), ("", None)]) +def test_item_key_accepts_int(raw, expected): + """业务主键十有八九是行号——只收 str 等于把整轮作业挡在门外。""" + assert AgentBody(prompt="x", item_key=raw).item_key == expected + + +def test_ledger_key_accepts_int(): + assert LedgerBody(op="update", key=42).key == 42 + + +# ── ④ 429 是限流不是契约错误 ──────────────────────────────────────── + + +class _FakeHTTPError(Exception): + def __init__(self, code): + self.code = code + + def read(self): + return b"rate limited" + + +def _sdk_ns(responses): + """exec 出 SDK 命名空间,把 urllib 整个换成按剧本回放的假模块。 + + 注意必须换成**独立的假对象**,不能去改 ns["urllib"]——那是真模块,改了会污染 + 整个进程里其他用到 urllib 的测试。 + """ + ns: dict = {} + exec(compile(SDK_SOURCE, "", "exec"), ns) + ns["BASE"] = "http://callback.test/api" + ns["JOB_ID"] = "job_test" + ns["time"] = SimpleNamespace(sleep=lambda *_a, **_k: None) # 别在测试里真等退避 + calls = {"n": 0} + + class Resp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return json.dumps({"data": {"ok": True}}).encode() + + def fake_urlopen(req, timeout=None): + i = calls["n"] + calls["n"] += 1 + item = responses[min(i, len(responses) - 1)] + if isinstance(item, int): + raise _FakeHTTPError(item) + return Resp() + + ns["urllib"] = SimpleNamespace( + request=SimpleNamespace(urlopen=fake_urlopen, Request=lambda *a, **k: SimpleNamespace( + add_header=lambda *_a, **_k: None + )), + error=SimpleNamespace(HTTPError=_FakeHTTPError), + ) + ns["_calls"] = calls + return ns + + +def test_429_is_retried_not_raised(): + """限流是"待会儿再来"。当成契约错误直接抛,曾让终态上报也一起丢掉。""" + ns = _sdk_ns([429, 429, "ok"]) + out = ns["_post"]("log", {"message": "hi"}) + assert out == {"ok": True} + assert ns["_calls"]["n"] == 3, "429 必须重试到成功" + + +def test_real_contract_errors_still_fail_fast(): + """400/422 是真写错了,重试毫无意义,必须立刻抛。""" + ns = _sdk_ns([422, "ok"]) + with pytest.raises(Exception) as ei: + ns["_post"]("agent", {}) + assert "422" in str(ei.value) + assert ns["_calls"]["n"] == 1, "契约错误不该重试" + + +# ── ① 打空台账必须被喊出来 ────────────────────────────────────────── + + +def test_update_warns_when_key_unknown(): + """后端说这个 key 不在台账里(多半漏了 seed)→ 必须留痕,不能静默丢。""" + ns: dict = {} + exec(compile(SDK_SOURCE, "", "exec"), ns) + logged = [] + + def fake_post(path, payload, timeout=180): + if path == "log": + logged.append(str(payload.get("message") or "")) + return {} + return {"ok": False, "known_key": False} + + ns["_post"] = fake_post + ns["ledger"].update("42", status="done", result={}) + + assert any("漏了 ledger.seed" in m for m in logged), "打空台账必须告警" + + +def test_map_surfaces_first_failure(): + """异常隔离不等于吞掉:整批全挂时,日志里必须看得见第一个原因。""" + ns: dict = {} + exec(compile(SDK_SOURCE, "", "exec"), ns) + logged = [] + + def fake_post(path, payload, timeout=180): + if path == "log": + logged.append(str(payload.get("message") or "")) + return {"ok": True, "known_key": True} + + ns["_post"] = fake_post + + def boom(it): + raise RuntimeError("HTTP 422 item_key") + + ns["job"].map([{"seq": i} for i in range(3)], boom) + assert any("首个失败项" in m and "422" in m for m in logged) + + +# ── ⑤ runner 死了要能就地判终态 ───────────────────────────────────── + + +def test_final_from_marker_reads_落盘终态(): + tail = 'noise\n{"status": "failed", "error": "boom"}\nmore noise' + assert _final_from_marker(tail) == ("failed", "boom") + + +def test_final_from_marker_defaults_to_failed(): + """捡不到标记就按 failed —— 进程没了而作业还 running,本来就不是正常收尾。""" + assert _final_from_marker("just a traceback")[0] == "failed" + assert _final_from_marker("")[0] == "failed" + + +# ── 观测面:载荷里写着 error 就不能记成 success ────────────────────── + + +@pytest.mark.parametrize( + "payload, expected", + [ + ({"error": "internet_search 调用失败: 429"}, True), + ('{"error": "boom", "result": []}', True), + ({"error": ""}, False), + ({"result": ["ok"]}, False), + ("一篇讲 error 处理的网页正文", False), + (["block"], False), + # MCP 工具真正的返回形状:内容块里裹着 JSON 文本,必须穿透 + ([{"type": "text", "text": '{"error": "internet_search 调用失败: 429", "result": []}'}], True), + ([{"type": "text", "text": '{"result": [{"title": "错误码 429 是什么"}]}'}], False), + ([{"type": "text", "text": "普通正文,没有 JSON"}], False), + ], +) +def test_error_payload_detection(payload, expected): + assert _payload_carries_error(payload) is expected + + +# ── SDK 说明必须跟着代码走(模型只看得到这一份)──────────────────── + + +def test_sdk_documents_rate_limit_behaviour(): + m = re.search(r"# 4xx 是契约问题.*?\n(.*?\n){0,3}", SDK_SOURCE) + assert m and "429" in m.group(0), "429 的例外必须写在 SDK 源码注释里" diff --git a/src/backend/tests/orchestration/test_job_sdk_ledger.py b/src/backend/tests/orchestration/test_job_sdk_ledger.py new file mode 100644 index 0000000..98311f9 --- /dev/null +++ b/src/backend/tests/orchestration/test_job_sdk_ledger.py @@ -0,0 +1,144 @@ +"""作业 SDK 的台账回写回归测试 —— 对应一次「跑了等于没跑」的线上事故。 + +事故经过:一个 568 项的补全作业跑了近一小时,子智能体调用真的在发(job_calls 有记录、 +也有成功的),但台账 568 项**全程 pending、attempts 全 0**,最后导出是空的、成果全丢。 + +根因在 ``job.map``:脚本按文档用 ``ledger.seed([{"key": ..., "payload": ...}])`` 播种, +却把**原始业务对象**(``{"seq": 7, "name": ...}``)交给 ``job.map``——两者形状不同是常态。 +当时 ``_key_of`` 只认 ``it["key"]``,取不到就返回 None,于是回写分支被**静默跳过**: +不报错、不打日志、外面完全看不出异常。 + +所以这里锁三件事: +1. 常见业务主键(seq/id/item_key)必须能兜住,绝不静默丢账; +2. 实在取不到主键时必须**喊出来**(log),因为静默丢账是最坏的失败模式; +3. 异常项要记 failed 且 bump_attempts,续跑才有依据。 + +SDK 是以字符串形式注入沙箱的(``SDK_SOURCE``),测试直接 exec 它,验的就是真正下发的那份代码。 +""" + +import re + +import pytest + +from orchestration.job_runtime import SDK_SOURCE + + +@pytest.fixture() +def sdk(): + """exec 出一份 SDK 命名空间,并把回调层换成记录器。 + + ``_Ledger``/``log`` 里的 ``_post`` 是模块级名字,exec 进同一个 dict 后替换即可生效 + (闭包在调用时才解析全局名)。 + """ + ns: dict = {} + exec(compile(SDK_SOURCE, "", "exec"), ns) + calls: list = [] + + def fake_post(path, payload, timeout=180): + calls.append((path, payload)) + if path == "ledger" and payload.get("op") == "seed": + return {"created": len(payload.get("items") or []), "skipped": 0} + return {} + + ns["_post"] = fake_post + ns["_calls"] = calls + return ns + + +def _updates(ns): + return [p for path, p in ns["_calls"] if path == "ledger" and p.get("op") == "update"] + + +def _logs(ns): + return [str(p.get("message") or "") for path, p in ns["_calls"] if path == "log"] + + +@pytest.mark.parametrize( + "item, expected_key", + [ + ({"key": "r2", "v": 1}, "r2"), # 文档里的标准形状 + ({"seq": 7, "v": 1}, "7"), # 事故现场的形状:seq 当主键 + ({"id": "ent-9", "v": 1}, "ent-9"), + ({"item_key": 42, "v": 1}, "42"), + ], +) +def test_map_books_result_under_business_key(sdk, item, expected_key): + """seed 与 map 的对象形状不一致是常态,主键必须能兜住——否则成果静默蒸发。""" + sdk["job"].map([item], lambda it: {"ok": True}) + + ups = _updates(sdk) + assert len(ups) == 1, "每项都必须落一次账" + assert ups[0]["key"] == expected_key + assert ups[0]["status"] == "done" + assert ups[0]["result"] == {"ok": True} + + +def test_map_honors_status_override(sdk): + """`_status` 是脚本声明"查无"的唯一正道,不能被当成结果字段写进数据位。""" + sdk["job"].map([{"seq": 1}], lambda it: {"_status": "not_found", "core": ""}) + + ups = _updates(sdk) + assert ups[0]["status"] == "not_found" + assert "_status" not in ups[0]["result"] + + +def test_map_accepts_explicit_key_field_and_callable(sdk): + sdk["job"].map([{"编号": "x1"}], lambda it: {"ok": 1}, key="编号") + assert _updates(sdk)[0]["key"] == "x1" + + sdk["_calls"].clear() + sdk["job"].map([{"a": 5}], lambda it: {"ok": 1}, key=lambda it: f"k{it['a']}") + assert _updates(sdk)[0]["key"] == "k5" + + +def test_map_warns_loudly_when_key_is_unresolvable(sdk): + """取不到主键时可以不落账,但**绝不允许安静**——静默丢账正是那次事故的形态。""" + sdk["job"].map([{"名称": "甲"}, {"名称": "乙"}], lambda it: {"ok": 1}) + + assert _updates(sdk) == [], "没有主键就不该瞎写台账" + warned = [m for m in _logs(sdk) if "取不到台账主键" in m] + assert len(warned) == 1, "必须告警,且只喊一次(别把日志刷爆)" + assert "job.map" in warned[0] + + +def test_map_records_failure_with_attempts(sdk): + """失败项要留下 failed + attempts,断点续跑才知道该重试谁。""" + + def boom(it): + raise RuntimeError("搜索工具 503") + + sdk["job"].map([{"seq": 3}], boom) + + ups = _updates(sdk) + assert len(ups) == 1 + assert ups[0]["status"] == "failed" + assert ups[0]["bump_attempts"] is True + assert "503" in ups[0]["error"] + + +def test_map_isolates_failures_across_items(sdk): + """一项炸掉不能拖垮其余项 —— 这是 map 的核心契约。""" + + def half(it): + if it["seq"] % 2 == 0: + raise RuntimeError("nope") + return {"ok": 1} + + sdk["job"].map([{"seq": i} for i in range(1, 5)], half, concurrency=2) + + ups = _updates(sdk) + assert len(ups) == 4 + assert sorted(u["status"] for u in ups) == ["done", "done", "failed", "failed"] + + +def test_map_returns_none_means_self_managed(sdk): + """fn 返回 None = 我自己写过账了,SDK 不得再插一脚。""" + sdk["job"].map([{"seq": 1}], lambda it: None) + assert _updates(sdk) == [] + + +def test_sdk_docstring_warns_about_key_shape(): + """SDK 是照着注入沙箱的,说明必须写在源码里 —— 模型只看得到这一份。""" + m = re.search(r"def map\(self.*?\"\"\"(.*?)\"\"\"", SDK_SOURCE, re.S) + assert m, "job.map 的 docstring 不该消失,它是模型唯一的使用说明" + assert "key" in m.group(1) diff --git a/src/backend/tests/orchestration/test_stale_reaper.py b/src/backend/tests/orchestration/test_stale_reaper.py index 241c161..cb3a388 100644 --- a/src/backend/tests/orchestration/test_stale_reaper.py +++ b/src/backend/tests/orchestration/test_stale_reaper.py @@ -172,6 +172,71 @@ async def test_quiet_over_age_run_with_live_task_survives(reaper_env, monkeypatc task.cancel() +def _insert_job(session_factory, job_id: str, *, chat_id: str, status: str, updated_age_sec: float): + from core.db.models import Job + + with session_factory() as db: + db.add( + Job( + job_id=job_id, + user_id="user_test", + chat_id=chat_id, + name="t", + status=status, + budget={}, + usage={}, + extra_data={}, + updated_at=datetime.now(timezone.utc) - timedelta(seconds=updated_age_sec), + ) + ) + db.commit() + + +async def test_quiet_orphan_with_live_job_survives(reaper_env): + """工作流模式:run 卡在 run_job(wait=True) 里等作业,主链路一个流事件都不产生。 + + 进程内 task 豁免只在本进程有效——多 worker / 多副本部署里另一个进程看不到这个 + task,会按"流静默"把健康的长作业误杀(实测踩过:双后端共库时主栈把测试后端的 + run 杀了)。作业活性是跨进程可见的证据,必须能救下这个 run。 + """ + session_factory, _ = reaper_env + _insert_run(session_factory, "run_job_wait", age_sec=executor._STALE_RUN_MAX_AGE_SEC + 300) + _insert_job( + session_factory, "job_live", chat_id="chat_test", status="running", updated_age_sec=5 + ) + + assert await executor.reap_stale_runs() == 0 + assert _get_run(session_factory, "run_job_wait").status == "running" + + +async def test_quiet_orphan_with_stalled_job_is_reaped(reaper_env): + """作业本身也不动了(updated_at 落在静默窗口之外)→ 不是活性证据,照常回收。""" + session_factory, _ = reaper_env + _insert_run(session_factory, "run_job_dead", age_sec=executor._STALE_RUN_MAX_AGE_SEC + 300) + _insert_job( + session_factory, + "job_stalled", + chat_id="chat_test", + status="running", + updated_age_sec=executor._STALE_QUIET_SEC + 120, + ) + + assert await executor.reap_stale_runs() == 1 + assert _get_run(session_factory, "run_job_dead").status == "failed" + + +async def test_terminal_job_does_not_shield_run(reaper_env): + """作业已终态 → 不再是活性证据,run 该回收就回收(防止豁免变成永生通行证)。""" + session_factory, _ = reaper_env + _insert_run(session_factory, "run_job_done", age_sec=executor._STALE_RUN_MAX_AGE_SEC + 300) + _insert_job( + session_factory, "job_done", chat_id="chat_test", status="completed", updated_age_sec=5 + ) + + assert await executor.reap_stale_runs() == 1 + assert _get_run(session_factory, "run_job_done").status == "failed" + + async def test_orphan_with_done_task_is_reaped(reaper_env, monkeypatch): """A finished task left in _active_runs does not shield the run: quiet orphans are reaped.""" session_factory, _ = reaper_env diff --git a/src/backend/tests/sandbox/test_session_lock_cross_loop.py b/src/backend/tests/sandbox/test_session_lock_cross_loop.py index befd3c8..ec62dff 100644 --- a/src/backend/tests/sandbox/test_session_lock_cross_loop.py +++ b/src/backend/tests/sandbox/test_session_lock_cross_loop.py @@ -140,10 +140,10 @@ async def _main(): ) def test_providers_use_thread_lock_for_registry(module_name): """两个 provider 都必须用线程锁守注册表 —— 换回 asyncio.Lock 就会重现事故。""" + import importlib import inspect - # 这两个 provider 并非所有部署形态都带上;缺席时跳过而不是报错。 - mod = pytest.importorskip(module_name) + mod = importlib.import_module(module_name) src = inspect.getsource(mod) assert "self._registry_lock = threading.Lock()" in src, module_name assert "self._registry_lock = asyncio.Lock()" not in src, module_name diff --git a/src/backend/tests/test_agent_manager_plugin.py b/src/backend/tests/test_agent_manager_plugin.py new file mode 100644 index 0000000..b9754a5 --- /dev/null +++ b/src/backend/tests/test_agent_manager_plugin.py @@ -0,0 +1,457 @@ +"""agent-manager plugin self-contained tests: plugin persistence + all 8 MCP verbs end to end. + +Coverage targets (matching the goal "create, manage, delete, and submit sub-agents for listing"): +- Installing the agent-manager plugin → AdminSkill(agent-designer) + AdminMcpServer(agent_manager) + + InstalledPlugin persisted, and merged into that user's available set via + resolve_all_runtime_enabled (= the agent can really get them). +- create_agent / list_my_agents / edit_agent / delete_agent / list_bindable_capabilities + / search_agent_market / install_market_agent / submit_agent_to_market. +- Guards that actually matter: capability flag gating, cross-user isolation, ambiguous + *_ref returning candidates instead of guessing, and bindings being replace-not-append. + +No dependency on a running sandbox or mcp container: the impl layer talks to the DB directly / +reuses backend services. +""" + +import asyncio +import inspect +from pathlib import Path +from types import SimpleNamespace + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import core.db.engine as dbe +from core.db.models import AdminMcpServer, AdminSkill, InstalledPlugin, UserAgent + +OWNER = "am_test_user" +OTHER = "am_other_user" +BUNDLE_DIR = Path(__file__).resolve().parents[1] / "plugin_bundles" / "marketplace" / "agent-manager" + + +@pytest.fixture() +def am_env(tmp_path, monkeypatch): + """Bind SessionLocal to an isolated sqlite file DB; allow capabilities.""" + url = f"sqlite:///{tmp_path}/am.db" + engine = create_engine(url) + dbe.Base.metadata.create_all(engine) + TestSession = sessionmaker(bind=engine, expire_on_commit=False) + # impl lazily reads the attribute via `from core.db.engine import SessionLocal` → patching here suffices + monkeypatch.setattr(dbe, "SessionLocal", TestSession) + + import core.auth.capabilities as caps + + monkeypatch.setattr( + caps, + "resolve_user_capabilities", + lambda db, uid: {"can_add_agent": True, "can_import_plugin": True, "can_add_skill": True}, + ) + + return SimpleNamespace(engine=engine, Session=TestSession) + + +def _create(**kw): + from mcp_servers.agent_manager_mcp import impl + + payload = { + "user_id": OWNER, + "name": "周报助手", + "description": "把一周的工作记录整理成周报初稿。", + "system_prompt": "角色:你是周报助手。不做什么:不替用户拍板。", + } + payload.update(kw) + return impl.create_agent(**payload) + + +# ── Bundle shape: the manifest the marketplace actually reads ──────────────── +def test_bundle_manifest_is_wellformed(): + import json + + manifest = json.loads((BUNDLE_DIR / "plugin.json").read_text(encoding="utf-8")) + mcp_json = json.loads((BUNDLE_DIR / "mcp.json").read_text(encoding="utf-8")) + + assert manifest["name"] == "agent-manager" + ext_mcp = manifest["extensions"]["org.hugagent"]["mcp"] + assert set(ext_mcp) == set(mcp_json["mcpServers"]), "扩展段的服务名必须与 mcp.json 一一对应" + assert mcp_json["mcpServers"]["agent_manager"]["url"] == "http://mcp:9115/mcp/" + + # The declared tool list must match what the server actually exposes — a manifest that + # advertises a tool the server doesn't have is a silently broken promise to the model. + from mcp_servers.agent_manager_mcp import server + + declared = {t["name"] for t in ext_mcp["agent_manager"]["tools"]} + actual = {t.name for t in asyncio.run(server.mcp.list_tools())} + assert declared == actual + assert len(actual) == 8 + + assert (BUNDLE_DIR / "skills" / "agent-designer" / "SKILL.md").is_file() + + +def test_routing_description_is_short_and_disambiguating(): + """plugin.json 顶层 description 是**路由信号**,不是市场文案。 + + core/llm/plugin_loader.build_plugin_directory_section 把它整段(不截断)拼进插件目录, + 而目录进的是稳定提示词前缀——每个用户、每个会话、每一轮都带着。所以: + - 必须短(长度是永久成本); + - 必须含用户会说的话(路由靠语义匹配用户原话); + - 必须点明管的是哪类对象(skill/agent/plugin 三个管理插件动词高度雷同,极易串台); + - 不该有"装上后就能……"这种自指废话(目录里每一条本来就是可装的插件)。 + """ + import json + + for bundle, obj_word, siblings in ( + (BUNDLE_DIR, "智能体", ("skill-manager", "plugin-manager")), + (BUNDLE_DIR.parent / "plugin-manager", "插件", ("skill-manager", "agent-manager")), + ): + desc = json.loads((bundle / "plugin.json").read_text(encoding="utf-8"))["description"] + name = bundle.name + + assert len(desc) <= 160, f"{name} 路由描述 {len(desc)} 字,太长(它每轮都在提示词里)" + assert obj_word in desc, f"{name} 没点明管的是「{obj_word}」" + assert "用户说" in desc, f"{name} 没写用户会怎么说——路由就是靠匹配用户原话" + for sib in siblings: + assert sib in desc, f"{name} 没跟 {sib} 做消歧,三个管理插件会串台" + assert "装上后" not in desc, f"{name} 残留「装上后……」自指废话" + assert "在对话里用自然语言" not in desc, f"{name} 残留套话开头,占字数不提供路由信息" + + +def test_port_is_registered(): + from mcp_servers._ports import PORTS, package_name + + assert PORTS["agent_manager"] == 9115 + assert package_name("agent_manager") == "agent_manager_mcp" + + +# ── Plugin persistence + merging into the user's available set ────────────── +def test_install_plugin_creates_rows_and_is_agent_visible(am_env): + from core.services import plugin_service as ps + + with am_env.Session() as db: + res = ps.install_plugin(db, "agent-manager", owner_user_id=OWNER, created_by=OWNER) + assert res.get("install_id") + + with am_env.Session() as db: + skills = db.query(AdminSkill).filter(AdminSkill.source_plugin == "agent-manager").all() + mcps = db.query(AdminMcpServer).filter(AdminMcpServer.source_plugin == "agent-manager").all() + plugin = db.query(InstalledPlugin).filter(InstalledPlugin.slug == "agent-manager").first() + + assert len(skills) == 1 and skills[0].owner_user_id == OWNER + assert len(mcps) == 1 + mcp = mcps[0] + assert mcp.owner_user_id == OWNER + assert mcp.transport == "streamable_http" + assert mcp.url == "http://mcp:9115/mcp/" + assert mcp.is_enabled is True # http MCP is not needs_runtime → enabled upon install + assert len(mcp.tools_json) == 8 + assert plugin is not None + + from core.config.catalog_resolver import ( + invalidate_capability_cache, + resolve_all_runtime_enabled, + ) + + invalidate_capability_cache() + enabled_skills, _agents, enabled_mcps = resolve_all_runtime_enabled(db, OWNER) + assert skills[0].skill_id in (enabled_skills or []) + assert mcp.server_id in (enabled_mcps or []) + + +# ── Create → list → edit → delete ─────────────────────────────────────────── +def test_create_list_edit_delete_loop(am_env): + from mcp_servers.agent_manager_mcp import impl + + res = _create() + assert res["ok"], res + agent_id = res["agent"]["agent_id"] + assert agent_id.startswith("ua_") + + listed = impl.list_my_agents(user_id=OWNER) + assert listed["ok"] and listed["count"] == 1 + assert listed["agents"][0]["agent_id"] == agent_id + assert listed["agents"][0]["name"] == "周报助手" + + # Partial update: only the named field changes, the rest is preserved. + edited = impl.edit_agent( + user_id=OWNER, agent_ref=agent_id, system_prompt="角色:你是周报助手。语气正式。" + ) + assert edited["ok"], edited + assert edited["changed"] == ["system_prompt"] + with am_env.Session() as db: + row = db.query(UserAgent).filter(UserAgent.agent_id == agent_id).first() + assert row.system_prompt == "角色:你是周报助手。语气正式。" + assert row.name == "周报助手" # untouched + assert row.owner_type == "user" and row.user_id == OWNER # never admin/global + + # Reference by name also works. + deleted = impl.delete_agent(user_id=OWNER, agent_ref="周报助手") + assert deleted["ok"], deleted + assert impl.list_my_agents(user_id=OWNER)["count"] == 0 + + +def test_create_rejects_missing_core_fields(am_env): + from mcp_servers.agent_manager_mcp import impl + + assert not impl.create_agent(user_id=OWNER, name="", description="x", system_prompt="y")["ok"] + assert not impl.create_agent(user_id=OWNER, name="x", description="", system_prompt="y")["ok"] + assert not impl.create_agent(user_id=OWNER, name="x", description="y", system_prompt=" ")["ok"] + # Nothing was written by any of the rejected calls. + assert impl.list_my_agents(user_id=OWNER)["count"] == 0 + + +def test_edit_requires_at_least_one_field(am_env): + from mcp_servers.agent_manager_mcp import impl + + agent_id = _create()["agent"]["agent_id"] + res = impl.edit_agent(user_id=OWNER, agent_ref=agent_id) + assert not res["ok"] and "没有要改的内容" in res["message"] + + +def test_bindings_are_replaced_not_appended(am_env): + """The replace-not-append semantics the skill warns about must actually hold.""" + from mcp_servers.agent_manager_mcp import impl + + agent_id = _create(skill_ids=["a", "b"])["agent"]["agent_id"] + impl.edit_agent(user_id=OWNER, agent_ref=agent_id, skill_ids=["c"]) + with am_env.Session() as db: + row = db.query(UserAgent).filter(UserAgent.agent_id == agent_id).first() + assert row.skill_ids == ["c"] + + +def test_binding_ids_are_deduped_and_capped(am_env): + from mcp_servers.agent_manager_mcp import impl + + res = _create(skill_ids=["a", " a ", "b", "", "b"]) + assert res["ok"] + with am_env.Session() as db: + row = db.query(UserAgent).filter(UserAgent.agent_id == res["agent"]["agent_id"]).first() + assert row.skill_ids == ["a", "b"] + + too_many = impl.create_agent( + user_id=OWNER, + name="超绑", + description="d", + system_prompt="p", + skill_ids=[f"s{i}" for i in range(200)], + ) + assert not too_many["ok"] and "最多绑" in too_many["message"] + + +# ── Ambiguity: must ask, never guess ──────────────────────────────────────── +def test_ambiguous_ref_returns_candidates_instead_of_guessing(am_env): + from mcp_servers.agent_manager_mcp import impl + + _create(name="报告助手A") + _create(name="报告助手B") + + for res in ( + impl.delete_agent(user_id=OWNER, agent_ref="报告助手"), + impl.edit_agent(user_id=OWNER, agent_ref="报告助手", description="改"), + ): + assert not res["ok"] + assert res.get("need_clarification") is True + assert len(res["candidates"]) == 2 + + # Nothing was deleted or modified by the ambiguous calls. + assert impl.list_my_agents(user_id=OWNER)["count"] == 2 + + +# ── Isolation: only my own agents ─────────────────────────────────────────── +def test_cannot_touch_other_users_agents(am_env): + from mcp_servers.agent_manager_mcp import impl + + mine = _create()["agent"]["agent_id"] + + assert impl.list_my_agents(user_id=OTHER)["count"] == 0 + assert not impl.delete_agent(user_id=OTHER, agent_ref=mine)["ok"] + assert not impl.edit_agent(user_id=OTHER, agent_ref=mine, description="hijack")["ok"] + + with am_env.Session() as db: + assert db.query(UserAgent).filter(UserAgent.agent_id == mine).first() is not None + + +def test_admin_agents_are_not_listed_as_mine(am_env): + """list_for_user also returns admin-owned agents; the self-serve entry must not claim them.""" + from core.services.user_agent_service import UserAgentService + from mcp_servers.agent_manager_mcp import impl + + with am_env.Session() as db: + UserAgentService(db).create( + user_id=None, + operator_name="admin", + owner_type="admin", + data={"name": "管理员下发", "description": "d", "system_prompt": "p"}, + ) + + listed = impl.list_my_agents(user_id=OWNER) + assert listed["count"] == 0, "管理员下发的智能体不该出现在「我的智能体」里" + + +# ── Capability flag gating ────────────────────────────────────────────────── +def test_write_verbs_blocked_without_capability(am_env, monkeypatch): + from mcp_servers.agent_manager_mcp import impl + + agent_id = _create()["agent"]["agent_id"] + + import core.auth.capabilities as caps + + monkeypatch.setattr(caps, "resolve_user_capabilities", lambda db, uid: {"can_add_agent": False}) + + for res in ( + impl.create_agent(user_id=OWNER, name="n", description="d", system_prompt="p"), + impl.edit_agent(user_id=OWNER, agent_ref=agent_id, description="d2"), + impl.delete_agent(user_id=OWNER, agent_ref=agent_id), + ): + assert not res["ok"] + assert "can_add_agent" in res["message"] + + # Read-only verbs stay usable. + assert impl.list_my_agents(user_id=OWNER)["ok"] + + +def test_missing_user_header_is_refused(am_env): + from mcp_servers.agent_manager_mcp import impl + + for res in ( + impl.list_my_agents(user_id=""), + impl.create_agent(user_id="", name="n", description="d", system_prompt="p"), + impl.delete_agent(user_id="", agent_ref="x"), + impl.search_agent_market(user_id=""), + ): + assert not res["ok"] and "用户身份" in res["message"] + + +# ── Bindable capabilities / market verbs ──────────────────────────────────── +def test_list_bindable_capabilities_shape(am_env): + from mcp_servers.agent_manager_mcp import impl + + res = impl.list_bindable_capabilities(user_id=OWNER) + assert res["ok"], res + for key in ("skills", "mcp_servers", "plugins", "kb_spaces"): + assert isinstance(res[key], list) + + +def test_search_agent_market_lists_preset_bundles(am_env): + from mcp_servers.agent_manager_mcp import impl + + res = impl.search_agent_market(user_id=OWNER) + assert res["ok"], res + assert res["count"] > 0, "仓库自带的预置子智能体应当能被搜到" + assert {"slug", "name", "installed"} <= set(res["agents"][0]) + + # Keyword filtering actually narrows the set. + narrowed = impl.search_agent_market(user_id=OWNER, query="__no_such_agent__") + assert narrowed["count"] == 0 + + +def test_install_market_agent_then_owned_by_me(am_env): + from mcp_servers.agent_manager_mcp import impl + + listed = impl.search_agent_market(user_id=OWNER) + slug = listed["agents"][0]["slug"] + + res = impl.install_market_agent(user_id=OWNER, slug=slug) + assert res["ok"], res + assert res["agent_id"] + + mine = impl.list_my_agents(user_id=OWNER) + assert mine["count"] == 1 + assert mine["agents"][0]["from_market"] == slug + + with am_env.Session() as db: + row = db.query(UserAgent).filter(UserAgent.agent_id == res["agent_id"]).first() + assert row.owner_type == "user" and row.user_id == OWNER + + +def test_install_unknown_market_slug_fails_cleanly(am_env): + from mcp_servers.agent_manager_mcp import impl + + res = impl.install_market_agent(user_id=OWNER, slug="__nope__") + assert not res["ok"] and res["message"].startswith("❌") + + +def test_submit_agent_to_market_creates_pending_submission(am_env): + from mcp_servers.agent_manager_mcp import impl + + agent_id = _create()["agent"]["agent_id"] + res = impl.submit_agent_to_market( + user_id=OWNER, agent_id=agent_id, category="职场办公", summary="周报", note="请审核" + ) + assert res["ok"], res + assert res["status"] == "pending" + assert "审核" in res["message"], "必须让用户知道这是申请、还要等审核" + + +def test_submit_rejects_wrong_category_and_lists_valid_ones(am_env): + """子智能体市场的分类是自己的一套(与技能市场的 8 类不同)。传错要挡下来并回列合法值, + 否则模型只能靠猜,每次上架都要先失败一轮。""" + from mcp_servers.agent_manager_mcp import impl + + agent_id = _create()["agent"]["agent_id"] + res = impl.submit_agent_to_market(user_id=OWNER, agent_id=agent_id, category="办公效率") + assert not res["ok"] + assert "通用助手" in res["message"] + assert res["valid_categories"] == impl.valid_categories() + + +def test_tool_description_lists_the_real_categories(): + """工具描述里的分类清单必须与代码里的单一真相源一致,否则描述会悄悄过期。""" + import json + + from mcp_servers.agent_manager_mcp import impl, server + + cats = impl.valid_categories() + assert len(cats) == 9 + + manifest = json.loads((BUNDLE_DIR / "plugin.json").read_text(encoding="utf-8")) + tools = manifest["extensions"]["org.hugagent"]["mcp"]["agent_manager"]["tools"] + desc = next(t["description"] for t in tools if t["name"] == "submit_agent_to_market") + doc = server.submit_agent_to_market.__doc__ or "" + for c in cats: + assert c in desc, f"plugin.json 的工具描述漏了分类「{c}」" + assert c in doc, f"server docstring 漏了分类「{c}」" + + +def test_submit_rejects_agent_not_mine(am_env): + from mcp_servers.agent_manager_mcp import impl + + agent_id = _create()["agent"]["agent_id"] + res = impl.submit_agent_to_market(user_id=OTHER, agent_id=agent_id) + assert not res["ok"] + + +# ── Server layer: header plumbing + tool surface ──────────────────────────── +def test_server_tools_forward_user_header(am_env, monkeypatch): + from mcp_servers.agent_manager_mcp import server + + captured = {} + + def _fake_list(*, user_id): + captured["user_id"] = user_id + return {"ok": True, "agents": [], "count": 0, "message": ""} + + monkeypatch.setattr(server.impl, "list_my_agents", _fake_list) + + ctx = SimpleNamespace( + request_context=SimpleNamespace( + request=SimpleNamespace(headers={"x-current-user-id": OWNER}) + ) + ) + asyncio.run(server.list_my_agents(ctx=ctx)) + assert captured["user_id"] == OWNER + + # No context (e.g. stdio debug) → empty user id, and impl refuses downstream. + captured.clear() + asyncio.run(server.list_my_agents(ctx=None)) + assert captured["user_id"] == "" + + +def test_every_server_tool_is_async_and_documented(): + from mcp_servers.agent_manager_mcp import server + + tools = asyncio.run(server.mcp.list_tools()) + assert len(tools) == 8 + for t in tools: + fn = getattr(server, t.name) + assert inspect.iscoroutinefunction(fn), f"{t.name} 必须是 async" + assert (fn.__doc__ or "").strip(), f"{t.name} 缺 docstring(它就是模型看到的工具描述)" diff --git a/src/backend/tests/test_plugin_manager_plugin.py b/src/backend/tests/test_plugin_manager_plugin.py new file mode 100644 index 0000000..d6904fe --- /dev/null +++ b/src/backend/tests/test_plugin_manager_plugin.py @@ -0,0 +1,581 @@ +"""plugin-manager plugin self-contained tests: plugin persistence + all 7 MCP verbs end to end. + +Coverage targets (matching the goal "search, install, import, enable/disable, uninstall plugins"): +- Installing the plugin-manager plugin → AdminSkill(plugin-creator) + AdminMcpServer(plugin_manager) + + InstalledPlugin persisted, and merged into that user's available set. +- search_plugin_market / get_plugin_info / install_plugin / list_my_plugins / import_plugin + / set_plugin_enabled / uninstall_plugin. +- Guards that actually matter: the self-uninstall guard, capability gating, cross-user + isolation, global plugins being read-only, ambiguous refs asking instead of guessing, + and non-plugin archives being rejected by import. + +No dependency on a running sandbox or mcp container: the impl layer talks to the DB directly / +reuses backend services, and the artifact store is the real store (local mode). +""" + +import asyncio +import inspect +import io +import json +import tarfile +from pathlib import Path +from types import SimpleNamespace + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import core.db.engine as dbe +from core.db.models import AdminMcpServer, AdminSkill, InstalledPlugin + +OWNER = "pm_test_user" +OTHER = "pm_other_user" +BUNDLE_ROOT = Path(__file__).resolve().parents[1] / "plugin_bundles" / "marketplace" +BUNDLE_DIR = BUNDLE_ROOT / "plugin-manager" + + +@pytest.fixture() +def pm_env(tmp_path, monkeypatch): + """Bind SessionLocal to an isolated sqlite file DB; point the artifact store at tmp; allow capabilities.""" + url = f"sqlite:///{tmp_path}/pm.db" + engine = create_engine(url) + dbe.Base.metadata.create_all(engine) + TestSession = sessionmaker(bind=engine, expire_on_commit=False) + monkeypatch.setattr(dbe, "SessionLocal", TestSession) + + from core.artifacts import store + + art_dir = tmp_path / "artifacts" + art_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(store, "_STORE_DIR", art_dir) + monkeypatch.setattr(store, "_INDEX_PATH", art_dir / "index.json") + + import core.auth.capabilities as caps + + monkeypatch.setattr( + caps, + "resolve_user_capabilities", + lambda db, uid: {"can_import_plugin": True, "can_add_skill": True}, + ) + + return SimpleNamespace(engine=engine, Session=TestSession) + + +def _add(tf: tarfile.TarFile, name: str, text: str) -> None: + data = text.encode("utf-8") + info = tarfile.TarInfo(name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + +def _make_plugin_tar(slug: str = "demo-plugin") -> bytes: + """Pack a minimal plugin package (plugin.json + one skill) into a tar.gz, root at package root.""" + manifest = { + "name": slug, + "version": "1.0.0", + "description": "一个用于测试的最小插件。", + } + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + _add(tf, "plugin.json", json.dumps(manifest, ensure_ascii=False)) + _add( + tf, + "skills/demo-skill/SKILL.md", + "---\nname: demo-skill\ndescription: 当用户需要演示时使用。\n---\n\n演示。\n", + ) + return buf.getvalue() + + +def _make_skill_only_tar() -> bytes: + """A skill package (no plugin.json) — import_plugin must refuse it, not silently half-import.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + _add(tf, "SKILL.md", "---\nname: lone-skill\ndescription: 单个技能。\n---\n\n正文。\n") + return buf.getvalue() + + +def _stash(tar_bytes: bytes, name: str = "plugin.tgz") -> str: + from core.artifacts import store + + return store.save_artifact_bytes(content=tar_bytes, name=name, extension="tgz")["file_id"] + + +# ── Bundle shape ──────────────────────────────────────────────────────────── +def test_bundle_manifest_is_wellformed(): + manifest = json.loads((BUNDLE_DIR / "plugin.json").read_text(encoding="utf-8")) + mcp_json = json.loads((BUNDLE_DIR / "mcp.json").read_text(encoding="utf-8")) + + assert manifest["name"] == "plugin-manager" + ext_mcp = manifest["extensions"]["org.hugagent"]["mcp"] + assert set(ext_mcp) == set(mcp_json["mcpServers"]), "扩展段的服务名必须与 mcp.json 一一对应" + assert mcp_json["mcpServers"]["plugin_manager"]["url"] == "http://mcp:9116/mcp/" + + from mcp_servers.plugin_manager_mcp import server + + declared = {t["name"] for t in ext_mcp["plugin_manager"]["tools"]} + actual = {t.name for t in asyncio.run(server.mcp.list_tools())} + assert declared == actual + assert len(actual) == 7 + + assert (BUNDLE_DIR / "skills" / "plugin-creator" / "SKILL.md").is_file() + assert (BUNDLE_DIR / "skills" / "plugin-creator" / "scripts" / "validate_plugin.py").is_file() + + +def test_port_is_registered(): + from mcp_servers._ports import PORTS, package_name + + assert PORTS["plugin_manager"] == 9116 + assert package_name("plugin_manager") == "plugin_manager_mcp" + + +def test_self_slug_matches_the_bundle(): + """自卸载守卫靠 slug 比对;bundle 改名而常量没跟上,守卫就静默失效了。""" + from mcp_servers.plugin_manager_mcp import impl + + manifest = json.loads((BUNDLE_DIR / "plugin.json").read_text(encoding="utf-8")) + assert impl.SELF_SLUG == manifest["name"] + + +# ── The shipped validator must accept every real bundle in the repo ───────── +def test_shipped_validator_accepts_all_real_bundles(): + import subprocess + import sys + + script = BUNDLE_DIR / "skills" / "plugin-creator" / "scripts" / "validate_plugin.py" + for bundle in sorted(p for p in BUNDLE_ROOT.iterdir() if (p / "plugin.json").is_file()): + r = subprocess.run( + [sys.executable, str(script), str(bundle)], capture_output=True, text=True + ) + assert r.returncode == 0, f"{bundle.name} 未通过自检:\n{r.stdout}\n{r.stderr}" + + +# ── Plugin persistence + merging into the user's available set ────────────── +def test_install_plugin_creates_rows_and_is_agent_visible(pm_env): + from core.services import plugin_service as ps + + with pm_env.Session() as db: + res = ps.install_plugin(db, "plugin-manager", owner_user_id=OWNER, created_by=OWNER) + assert res.get("install_id") + + with pm_env.Session() as db: + skills = db.query(AdminSkill).filter(AdminSkill.source_plugin == "plugin-manager").all() + mcps = db.query(AdminMcpServer).filter(AdminMcpServer.source_plugin == "plugin-manager").all() + plugin = db.query(InstalledPlugin).filter(InstalledPlugin.slug == "plugin-manager").first() + + assert len(skills) == 1 and skills[0].owner_user_id == OWNER + assert len(mcps) == 1 + mcp = mcps[0] + assert mcp.url == "http://mcp:9116/mcp/" + assert mcp.is_enabled is True + assert len(mcp.tools_json) == 7 + assert plugin is not None + + from core.config.catalog_resolver import ( + invalidate_capability_cache, + resolve_all_runtime_enabled, + ) + + invalidate_capability_cache() + enabled_skills, _agents, enabled_mcps = resolve_all_runtime_enabled(db, OWNER) + assert skills[0].skill_id in (enabled_skills or []) + assert mcp.server_id in (enabled_mcps or []) + + +# ── Market verbs ──────────────────────────────────────────────────────────── +def test_search_plugin_market_lists_builtin_bundles(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + res = impl.search_plugin_market(user_id=OWNER) + assert res["ok"], res + slugs = {p["slug"] for p in res["plugins"]} + assert {"agent-manager", "plugin-manager", "skill-manager"} <= slugs + + narrowed = impl.search_plugin_market(user_id=OWNER, query="__no_such_plugin__") + assert narrowed["count"] == 0 + + +def test_get_plugin_info_previews_components(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + res = impl.get_plugin_info(user_id=OWNER, slug="agent-manager") + assert res["ok"], res + assert res["installed"] is False + assert len(res["skills"]) == 1 + assert len(res["mcp_servers"]) == 1 + assert len(res["mcp_servers"][0]["tools"]) == 8 + + impl.install_plugin(user_id=OWNER, slug="agent-manager") + assert impl.get_plugin_info(user_id=OWNER, slug="agent-manager")["installed"] is True + + +def test_get_plugin_info_unknown_slug_fails_cleanly(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + res = impl.get_plugin_info(user_id=OWNER, slug="__nope__") + assert not res["ok"] and res["message"].startswith("❌") + + +# ── Install → list → disable → enable → uninstall ─────────────────────────── +def test_install_list_toggle_uninstall_loop(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + inst = impl.install_plugin(user_id=OWNER, slug="agent-manager") + assert inst["ok"], inst + install_id = inst["install_id"] + + listed = impl.list_my_plugins(user_id=OWNER) + assert listed["ok"] and listed["count"] == 1 + row = listed["plugins"][0] + assert row["slug"] == "agent-manager" and row["enabled"] is True and row["is_global"] is False + assert row["components"]["skills"] == 1 and row["components"]["mcp"] == 1 + + off = impl.set_plugin_enabled(user_id=OWNER, plugin_ref=install_id, enabled=False) + assert off["ok"], off + assert impl.list_my_plugins(user_id=OWNER)["plugins"][0]["enabled"] is False + + on = impl.set_plugin_enabled(user_id=OWNER, plugin_ref="agent-manager", enabled=True) + assert on["ok"], on + assert impl.list_my_plugins(user_id=OWNER)["plugins"][0]["enabled"] is True + + gone = impl.uninstall_plugin(user_id=OWNER, plugin_ref=install_id) + assert gone["ok"], gone + assert impl.list_my_plugins(user_id=OWNER)["count"] == 0 + + # Uninstall really reverse-deletes the components it brought in. + with pm_env.Session() as db: + assert db.query(AdminSkill).filter(AdminSkill.source_plugin == "agent-manager").count() == 0 + assert ( + db.query(AdminMcpServer).filter(AdminMcpServer.source_plugin == "agent-manager").count() + == 0 + ) + + +def test_toggle_survives_a_pre_existing_per_user_override(pm_env): + """用户在「插件」页点过开关就会留下一条每用户覆写,而启用态解析是"覆写优先"。 + + 停用若写的是组件的全局 is_enabled(管理员级原语),覆写仍说启用 —— 工具回了 + "✅ 已停用",list_my_plugins 却照样显示 enabled=true。必须走用户级通路。 + """ + from core.services import plugin_service as ps + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="agent-manager")["install_id"] + + # 模拟用户先在页面上开过一次(写入覆写) + with pm_env.Session() as db: + ps.set_plugin_enabled_for_user(db, install_id, enabled=True, user_id=OWNER) + + off = impl.set_plugin_enabled(user_id=OWNER, plugin_ref=install_id, enabled=False) + assert off["ok"], off + assert impl.list_my_plugins(user_id=OWNER)["plugins"][0]["enabled"] is False, ( + "停用没生效:覆写把它盖回去了,说明写的是全局 is_enabled 而不是用户级覆写" + ) + + on = impl.set_plugin_enabled(user_id=OWNER, plugin_ref=install_id, enabled=True) + assert on["ok"], on + assert impl.list_my_plugins(user_id=OWNER)["plugins"][0]["enabled"] is True + + +def test_cannot_disable_itself(pm_env): + """停用自己和卸载自己是同一个死局:本插件的 MCP 组件一掉,就没有工具能把它开回来。""" + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="plugin-manager")["install_id"] + + res = impl.set_plugin_enabled(user_id=OWNER, plugin_ref=install_id, enabled=False) + assert not res["ok"], "停用自己竟然成功了——和卸载自己是同一个死局" + assert "不能" in res["message"] + + # 重新启用自己是无害的,不该被拦。 + assert impl.set_plugin_enabled(user_id=OWNER, plugin_ref=install_id, enabled=True)["ok"] + + +def test_component_level_toggle(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="agent-manager")["install_id"] + with pm_env.Session() as db: + skill_id = ( + db.query(AdminSkill).filter(AdminSkill.source_plugin == "agent-manager").first().skill_id + ) + + res = impl.set_plugin_enabled( + user_id=OWNER, + plugin_ref=install_id, + enabled=False, + component_kind="skill", + component_id=skill_id, + ) + assert res["ok"], res + + # 断言的是"用户实际还能不能用到它",不是组件的全局 is_enabled—— + # 用户级停用写的是每用户覆写,全局位保持不动(别人不受影响),这正是我们要的语义。 + with pm_env.Session() as db: + from core.config.catalog_resolver import ( + invalidate_capability_cache, + resolve_all_runtime_enabled, + ) + + invalidate_capability_cache() + eff_skills, _agents, _mcps = resolve_all_runtime_enabled(db, OWNER) + assert skill_id not in (eff_skills or []), "组件级停用没有对该用户生效" + + row = db.query(AdminSkill).filter(AdminSkill.skill_id == skill_id).first() + assert row.is_enabled is True, "用户级停用不该改动组件的全局启用位" + + +def test_component_toggle_requires_both_params(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="agent-manager")["install_id"] + half = impl.set_plugin_enabled( + user_id=OWNER, plugin_ref=install_id, enabled=False, component_kind="skill" + ) + assert not half["ok"] and "必须同时提供" in half["message"] + + bad_kind = impl.set_plugin_enabled( + user_id=OWNER, + plugin_ref=install_id, + enabled=False, + component_kind="wat", + component_id="x", + ) + assert not bad_kind["ok"] + + +# ── The self-uninstall guard ──────────────────────────────────────────────── +def test_cannot_uninstall_itself(pm_env): + """卸掉插件管理插件自己 = 把自己的手砍掉,之后没有工具能把它装回来。""" + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="plugin-manager")["install_id"] + + for ref in (install_id, "plugin-manager", "插件管理"): + res = impl.uninstall_plugin(user_id=OWNER, plugin_ref=ref) + assert not res["ok"], f"ref={ref} 竟然把自己卸掉了" + assert "不能卸载" in res["message"] + + # Still installed and still functional. + assert impl.list_my_plugins(user_id=OWNER)["count"] == 1 + with pm_env.Session() as db: + assert ( + db.query(InstalledPlugin).filter(InstalledPlugin.slug == "plugin-manager").first() + is not None + ) + + +# ── Import from the shared artifact store ─────────────────────────────────── +def test_import_plugin_from_artifact(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + art = _stash(_make_plugin_tar("demo-plugin")) + res = impl.import_plugin(user_id=OWNER, artifact_id=art) + assert res["ok"], res + assert res["install_id"] + + listed = impl.list_my_plugins(user_id=OWNER) + assert {p["slug"] for p in listed["plugins"]} == {"demo-plugin"} + with pm_env.Session() as db: + sk = db.query(AdminSkill).filter(AdminSkill.source_plugin == "demo-plugin").all() + assert len(sk) == 1 and sk[0].owner_user_id == OWNER + + +def test_plugin_root_detection_matches_the_importer(pm_env): + """"什么算插件包"只能有一套判定。 + + 导入器认原生 / .claude-plugin / .codex-plugin 三种布局;本地若少认一种, + 同一个包就会"后台上传能装、对话里说不是插件包"。 + """ + from mcp_servers._packaging import is_plugin_root, locate_root + + for layout in ("plugin.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): + d = Path(pm_env.engine.url.database).parent / f"pkg_{layout.replace('/', '_')}" + f = d / layout + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(json.dumps({"name": "x"}), encoding="utf-8") + assert is_plugin_root(d), f"{layout} 布局没被认成插件包" + assert locate_root(d) == d + + empty = Path(pm_env.engine.url.database).parent / "pkg_empty" + empty.mkdir(parents=True, exist_ok=True) + assert not is_plugin_root(empty) + + +def test_get_plugin_info_falls_back_to_installed_detail(pm_env): + """自己 import 进来的插件不在市场目录里,但详情必须查得到。 + + 真实端到端里发现的:导入成功 → 紧接着问详情 → "查不到插件",用户只能自己猜发生了什么。 + """ + from mcp_servers.plugin_manager_mcp import impl + + art = _stash(_make_plugin_tar("selfmade-plugin")) + assert impl.import_plugin(user_id=OWNER, artifact_id=art)["ok"] + + res = impl.get_plugin_info(user_id=OWNER, slug="selfmade-plugin") + assert res["ok"], res + assert res["installed"] is True + assert len(res["skills"]) == 1 + + # 别人的私有插件仍然查不到(回落不能变成越权读取通道)。 + other = impl.get_plugin_info(user_id=OTHER, slug="selfmade-plugin") + assert not other["ok"] + + +def test_import_rejects_non_plugin_archive(pm_env): + """只有 SKILL.md 的包不是插件包——要明确拒绝并指路,不能半吞半吐。""" + from mcp_servers.plugin_manager_mcp import impl + + art = _stash(_make_skill_only_tar(), name="skill.tgz") + res = impl.import_plugin(user_id=OWNER, artifact_id=art) + assert not res["ok"] + assert "plugin.json" in res["message"] and "register_skill" in res["message"] + assert impl.list_my_plugins(user_id=OWNER)["count"] == 0 + + +def test_import_missing_artifact_fails_cleanly(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + assert not impl.import_plugin(user_id=OWNER, artifact_id="")["ok"] + res = impl.import_plugin(user_id=OWNER, artifact_id="no-such-artifact") + assert not res["ok"] and "找不到" in res["message"] + + +def test_import_rejects_path_traversal_archive(pm_env): + """目录穿越条目必须在解包阶段就被拦下(防护在 _packaging 里,这里守住回归)。""" + from mcp_servers.plugin_manager_mcp import impl + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + _add(tf, "../evil.json", "{}") + art = _stash(buf.getvalue(), name="evil.tgz") + + res = impl.import_plugin(user_id=OWNER, artifact_id=art) + assert not res["ok"] + assert "解包失败" in res["message"] or "目录穿越" in res["message"] + + +# ── Isolation / read-only global plugins ──────────────────────────────────── +def test_cannot_touch_other_users_plugins(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="agent-manager")["install_id"] + + assert impl.list_my_plugins(user_id=OTHER)["count"] == 0 + assert not impl.uninstall_plugin(user_id=OTHER, plugin_ref=install_id)["ok"] + assert not impl.set_plugin_enabled(user_id=OTHER, plugin_ref=install_id, enabled=False)["ok"] + + with pm_env.Session() as db: + assert ( + db.query(InstalledPlugin).filter(InstalledPlugin.install_id == install_id).first() + is not None + ) + + +def test_global_plugins_are_visible_but_read_only(pm_env): + """管理员下发的全局插件用户看得见(否则会重复安装),但停用/卸载都不该由自助入口做。""" + from core.services import plugin_service as ps + from mcp_servers.plugin_manager_mcp import impl + + with pm_env.Session() as db: + ps.install_plugin(db, "agent-manager", owner_user_id=None, created_by="admin") + + listed = impl.list_my_plugins(user_id=OWNER) + assert listed["count"] == 1 + assert listed["plugins"][0]["is_global"] is True + + gid = listed["plugins"][0]["install_id"] + for res in ( + impl.uninstall_plugin(user_id=OWNER, plugin_ref=gid), + impl.set_plugin_enabled(user_id=OWNER, plugin_ref=gid, enabled=False), + ): + assert not res["ok"] + assert "全局插件" in res["message"] + + +def test_ambiguous_ref_returns_candidates_instead_of_guessing(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + art_a = _stash(_make_plugin_tar("alpha-tool")) + art_b = _stash(_make_plugin_tar("beta-tool")) + impl.import_plugin(user_id=OWNER, artifact_id=art_a) + impl.import_plugin(user_id=OWNER, artifact_id=art_b) + + res = impl.uninstall_plugin(user_id=OWNER, plugin_ref="tool") + assert not res["ok"] + assert res.get("need_clarification") is True + assert len(res["candidates"]) == 2 + assert impl.list_my_plugins(user_id=OWNER)["count"] == 2 + + +# ── Capability flag gating ────────────────────────────────────────────────── +def test_write_verbs_blocked_without_capability(pm_env, monkeypatch): + from mcp_servers.plugin_manager_mcp import impl + + install_id = impl.install_plugin(user_id=OWNER, slug="agent-manager")["install_id"] + + import core.auth.capabilities as caps + + monkeypatch.setattr( + caps, "resolve_user_capabilities", lambda db, uid: {"can_import_plugin": False} + ) + + for res in ( + impl.install_plugin(user_id=OWNER, slug="skill-manager"), + impl.set_plugin_enabled(user_id=OWNER, plugin_ref=install_id, enabled=False), + impl.uninstall_plugin(user_id=OWNER, plugin_ref=install_id), + ): + assert not res["ok"] + assert "can_import_plugin" in res["message"] + + assert impl.list_my_plugins(user_id=OWNER)["ok"] + assert impl.search_plugin_market(user_id=OWNER)["ok"] + + +def test_missing_user_header_is_refused(pm_env): + from mcp_servers.plugin_manager_mcp import impl + + for res in ( + impl.list_my_plugins(user_id=""), + impl.search_plugin_market(user_id=""), + impl.install_plugin(user_id="", slug="x"), + impl.import_plugin(user_id="", artifact_id="x"), + impl.uninstall_plugin(user_id="", plugin_ref="x"), + impl.set_plugin_enabled(user_id="", plugin_ref="x", enabled=True), + ): + assert not res["ok"] and "用户身份" in res["message"] + + +# ── Server layer ──────────────────────────────────────────────────────────── +def test_server_tools_forward_user_header(pm_env, monkeypatch): + from mcp_servers.plugin_manager_mcp import server + + captured = {} + + def _fake(*, user_id): + captured["user_id"] = user_id + return {"ok": True, "plugins": [], "count": 0, "message": ""} + + monkeypatch.setattr(server.impl, "list_my_plugins", _fake) + + ctx = SimpleNamespace( + request_context=SimpleNamespace( + request=SimpleNamespace(headers={"x-current-user-id": OWNER}) + ) + ) + asyncio.run(server.list_my_plugins(ctx=ctx)) + assert captured["user_id"] == OWNER + + captured.clear() + asyncio.run(server.list_my_plugins(ctx=None)) + assert captured["user_id"] == "" + + +def test_every_server_tool_is_async_and_documented(): + from mcp_servers.plugin_manager_mcp import server + + tools = asyncio.run(server.mcp.list_tools()) + assert len(tools) == 7 + for t in tools: + fn = getattr(server, t.name) + assert inspect.iscoroutinefunction(fn), f"{t.name} 必须是 async" + assert (fn.__doc__ or "").strip(), f"{t.name} 缺 docstring(它就是模型看到的工具描述)" diff --git a/src/frontend/package.json b/src/frontend/package.json index 55d6834..38f0354 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -9,6 +9,7 @@ "check:i18n": "node scripts/check-i18n.mjs", "check:dark": "node scripts/check-dark-mode.mjs", "test:chat-stream": "esbuild scripts/test-chat-stream-segments.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-chat-stream-segments.mjs && node node_modules/.tmp/test-chat-stream-segments.mjs", + "test:sidebar-order": "esbuild scripts/test-sidebar-order.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-sidebar-order.mjs && node node_modules/.tmp/test-sidebar-order.mjs", "lint": "eslint .", "preview": "vite preview" }, diff --git a/src/frontend/scripts/check-dark-mode.mjs b/src/frontend/scripts/check-dark-mode.mjs index c389c06..efaa1ce 100644 --- a/src/frontend/scripts/check-dark-mode.mjs +++ b/src/frontend/scripts/check-dark-mode.mjs @@ -40,6 +40,13 @@ const baselinePath = join(scriptDir, 'dark-mode-baseline.json'); const repoRoot = join(frontendRoot, '..', '..'); const ceOverlayRoot = join(repoRoot, 'ce', 'overlay', 'src', 'frontend', 'src'); +/* 桌面壳(Tauri):`desktop/src-tauri/src/**.rs` 里用 Rust 字符串内嵌了整整几张 HTML 页面 + (登录 / 首启选模式 / 本机部署 / 关闭确认 / 服务器地址 / 更新进度),外加一条注进 SPA 文档的 + 自定义标题栏。它们和前端**同属一个界面**,用户看不出边界,却因为不是 .css/.tsx 而一直在 + 门禁视野外——深色模式上线后,这几张页面全是写死的浅色,桌面端深色档直接是白板。 + 这正是本门禁要消灭的「无反馈回路」:改的人没有任何信号,只能靠人肉记得两边都改。 */ +const desktopShellRoot = join(repoRoot, 'desktop', 'src-tauri', 'src'); + const args = process.argv.slice(2); const UPDATE = args.includes('--update'); const LIST = args.includes('--list'); @@ -152,6 +159,42 @@ collect(srcRoot, frontendRoot); // 两边都要扫——FULL 用主树那份,CE 派生后用 overlay 那份,谁都不能漏。 collect(ceOverlayRoot, repoRoot); +/* ── 桌面壳:从 .rs 里把 '.length; + for (let i = open; i < close; i++) keep[i] = true; + } + let out = ''; + for (let i = 0; i < raw.length; i++) out += keep[i] || raw[i] === '\n' ? raw[i] : ' '; + return out; +} +function collectEmbeddedCss(root, relTo) { + if (!existsSync(root)) return; + (function walk(dir) { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name.startsWith('.')) continue; + const p = join(dir, e.name); + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) walk(p); + } else if (e.name.endsWith('.rs')) { + const blanked = blankNonCss(readFileSync(p, 'utf-8')); + if (!blanked.trim()) continue; // 这个 .rs 里没有内嵌样式 + const rel = relative(relTo, p).split(sep).join('/'); + sources.set(rel, blanked); + embeddedCssFiles.add(rel); + } + } + })(root); +} +collectEmbeddedCss(desktopShellRoot, repoRoot); + /* ── 全仓自定义属性定义集合(供盲区 4 取差集)───────────────────── */ const definedVars = new Set(); for (const src of sources.values()) { @@ -319,7 +362,7 @@ function scanJs(file, raw) { } for (const [file, raw] of sources) { - if (file.endsWith('.css')) scanCss(file, raw); + if (file.endsWith('.css') || embeddedCssFiles.has(file)) scanCss(file, raw); else scanJs(file, raw); } diff --git a/src/frontend/scripts/dark-mode-baseline.json b/src/frontend/scripts/dark-mode-baseline.json index 9069695..789f77e 100644 --- a/src/frontend/scripts/dark-mode-baseline.json +++ b/src/frontend/scripts/dark-mode-baseline.json @@ -222,7 +222,7 @@ "css-literal-color": 3 }, "src/styles/mobile.css": { - "css-literal-color": 34, + "css-literal-color": 24, "css-var-literal-color": 1 }, "src/styles/myspace.css": { diff --git a/src/frontend/scripts/test-chat-stream-segments.ts b/src/frontend/scripts/test-chat-stream-segments.ts index 44df0b0..b0ab296 100644 --- a/src/frontend/scripts/test-chat-stream-segments.ts +++ b/src/frontend/scripts/test-chat-stream-segments.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; -import type { MessageSegment } from '../src/types'; +import type { MessageSegment, SubagentStep } from '../src/types'; import { appendStreamTextSegment, + appendSubagentStepDelta, appendThinkingContentBeforeTrailingText, deferThinkingTextFragmentBeforeTool, restoreDeferredThinkingTextFragment, @@ -10,6 +11,7 @@ import { import { extractCodeFromStreamingArgs } from '../src/utils/codeExecParser'; import { buildHistorySegments } from '../src/utils/segments'; import { getToolRunInitialOpen } from '../src/utils/toolRunState'; +import { refreshTargetForTool } from '../src/utils/toolRefresh'; function tool(toolIndex: number): MessageSegment { return { type: 'tool', toolIndex }; @@ -305,4 +307,66 @@ function tool(toolIndex: number): MessageSegment { ]); } +{ + // 子智能体子步骤:结构化 reasoning 的收尾增量在正文首 token 之后才到达 + // (线上实录形态:thinking " more" → content "Let" → thinking " searches." → content …)。 + // 迟到的思考尾并回前一个思考块,正文保持一整段,不被切成碎片交错。 + const steps: SubagentStep[] = []; + appendSubagentStepDelta(steps, 'thinking', '继续检索剩余企业。'); + appendSubagentStepDelta(steps, 'thinking', ' more'); + appendSubagentStepDelta(steps, 'content', 'Let'); + appendSubagentStepDelta(steps, 'thinking', ' searches.'); + appendSubagentStepDelta(steps, 'content', ' me continue with more searches for'); + appendSubagentStepDelta(steps, 'content', ' remaining'); + + assert.deepEqual(steps, [ + { kind: 'thinking', text: '继续检索剩余企业。 more searches.' }, + { kind: 'content', text: 'Let me continue with more searches for remaining' }, + ]); +} + +{ + // 边界:工具步骤之后的思考属于新一轮,不并回上一轮思考块; + // 没有前置思考块时(正文在先)也不能吞掉这条思考。 + const afterTool: SubagentStep[] = [ + { kind: 'thinking', text: '上一轮思考' }, + { kind: 'tool', toolId: 't1', name: 'internet_search', status: 'success' }, + ]; + appendSubagentStepDelta(afterTool, 'thinking', '新一轮思考'); + assert.deepEqual(afterTool, [ + { kind: 'thinking', text: '上一轮思考' }, + { kind: 'tool', toolId: 't1', name: 'internet_search', status: 'success' }, + { kind: 'thinking', text: '新一轮思考' }, + ]); + + const contentFirst: SubagentStep[] = [{ kind: 'content', text: '正文在先' }]; + appendSubagentStepDelta(contentFirst, 'thinking', '随后的思考'); + assert.deepEqual(contentFirst, [ + { kind: 'content', text: '正文在先' }, + { kind: 'thinking', text: '随后的思考' }, + ]); +} + +{ + // 管理类插件写操作 → 必须刷"持有那份列表的" store。三份列表来自三个不同接口, + // 刷错不会报错、只会静默无效——这张表已经错过两次,故在此钉住。 + const AGENT_WRITES = ['create_agent', 'edit_agent', 'delete_agent', 'install_market_agent']; + const PLUGIN_WRITES = ['install_plugin', 'uninstall_plugin', 'import_plugin', 'set_plugin_enabled']; + const SKILL_WRITES = ['register_skill', 'install_from_marketplace', 'delete_skill', 'edit_skill']; + for (const n of AGENT_WRITES) assert.equal(refreshTargetForTool(n), 'agents', n); + for (const n of PLUGIN_WRITES) assert.equal(refreshTargetForTool(n), 'plugins', n); + for (const n of SKILL_WRITES) assert.equal(refreshTargetForTool(n), 'catalog', n); + + // 只读动词不该触发任何重拉。 + for (const n of [ + 'search_agent_market', 'list_my_agents', 'list_bindable_capabilities', + 'search_plugin_market', 'list_my_plugins', 'get_plugin_info', + 'search_marketplace', 'list_my_skills', 'submit_agent_to_market', 'submit_to_marketplace', + ]) assert.equal(refreshTargetForTool(n), undefined, n); + + // 精确匹配:uninstall_plugin 不能靠"包含 install_plugin"这种巧合被覆盖。 + assert.equal(refreshTargetForTool('uninstall_plugin'), 'plugins'); + assert.equal(refreshTargetForTool('totally_unknown_tool'), undefined); +} + console.log('chat stream segment tests passed'); diff --git a/src/frontend/scripts/test-sidebar-order.ts b/src/frontend/scripts/test-sidebar-order.ts new file mode 100644 index 0000000..2bf41f4 --- /dev/null +++ b/src/frontend/scripts/test-sidebar-order.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; + +import { + compareSidebarItems, + mergeGroupOrder, + reorderGroupSequence, + type SidebarSortable, +} from '../src/utils/sidebarOrder'; + +const indexOf = (order: string[]) => new Map(order.map((id, i) => [id, i] as const)); + +{ + // 往上拖:落到目标之前 + assert.deepEqual(reorderGroupSequence(['a', 'b', 'c'], 'c', 'a', 'before'), ['c', 'a', 'b']); + // 往下拖:落到目标之后 + assert.deepEqual(reorderGroupSequence(['a', 'b', 'c'], 'a', 'c', 'after'), ['b', 'c', 'a']); + // 落到中间一条的上/下沿 + assert.deepEqual(reorderGroupSequence(['a', 'b', 'c'], 'a', 'c', 'before'), ['b', 'a', 'c']); + assert.deepEqual(reorderGroupSequence(['a', 'b', 'c'], 'c', 'a', 'after'), ['a', 'c', 'b']); +} + +{ + // 自己拖到自己身上、跨组拖(id 不在本组)都不产生新顺序 + assert.equal(reorderGroupSequence(['a', 'b'], 'a', 'a', 'before'), null); + assert.equal(reorderGroupSequence(['a', 'b'], 'x', 'a', 'before'), null); + assert.equal(reorderGroupSequence(['a', 'b'], 'a', 'x', 'after'), null); +} + +{ + // 并回全局顺序表:本组旧下标被摘掉,新序列整体接到尾部,别的组原样保留 + assert.deepEqual( + mergeGroupOrder(['p1', 'a', 'p2', 'b'], ['a', 'b'], ['b', 'a'], 500), + ['p1', 'p2', 'b', 'a'], + ); + // 超长时从头部截断(头部=最久没动过的分组) + assert.deepEqual(mergeGroupOrder(['x', 'y'], ['a'], ['a'], 2), ['y', 'a']); +} + +{ + const item = (id: string, updatedAt: number, pinned = false): SidebarSortable => + ({ id, updatedAt, pinned }); + + // 没有手动顺序时 = 旧行为:置顶优先,其余按 updatedAt 倒序 + const empty = indexOf([]); + const byDefault = [item('a', 1), item('b', 3), item('c', 2, true)] + .sort((x, y) => compareSidebarItems(x, y, empty)) + .map((i) => i.id); + assert.deepEqual(byDefault, ['c', 'b', 'a']); + + // 手动顺序压过 updatedAt:a 被拖到最前,之后 b 更新也顶不动它 + const manual = indexOf(['a', 'b', 'c']); + const dragged = [item('b', 999), item('c', 2), item('a', 1)] + .sort((x, y) => compareSidebarItems(x, y, manual)) + .map((i) => i.id); + assert.deepEqual(dragged, ['a', 'b', 'c']); + + // 置顶仍然是外层键:手动顺序只在同一置顶带内生效 + const withPinned = [item('b', 5), item('c', 4, true), item('a', 3)] + .sort((x, y) => compareSidebarItems(x, y, manual)) + .map((i) => i.id); + assert.deepEqual(withPinned, ['c', 'a', 'b']); + + // 手动顺序里没有的新会话排在最前,按 updatedAt 倒序(新会话照常冒头) + const withFresh = [item('a', 1), item('new1', 10), item('new2', 20), item('b', 2)] + .sort((x, y) => compareSidebarItems(x, y, manual)) + .map((i) => i.id); + assert.deepEqual(withFresh, ['new2', 'new1', 'a', 'b']); +} + +console.log('sidebar order tests passed'); diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index 2dbdfe6..215b62c 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -4,7 +4,7 @@ * Uses v1 unified response envelope. */ -import type { Catalog, ChatItem, ChatMessage, ChunkPreviewResult, EvolutionSummary, KBChunk, KBIndexMode, KBWikiStatus, WikiConfig, MemoryItem, MemoryProfile, MemoryGraphRelation, ResourceItem, AutomationTask, AutomationRun, AutomationNotification, FileConfirmInfo, FileConfirmDecision, DesignPickInfo, OntologyAssetKind, OntologyTagOption } from './types'; +import type { Catalog, ChatItem, ChatMessage, ChunkPreviewResult, EvolutionSummary, JobBrief, KBChunk, KBIndexMode, KBWikiStatus, WikiConfig, MemoryItem, MemoryProfile, MemoryGraphRelation, ResourceItem, AutomationTask, AutomationRun, AutomationNotification, FileConfirmInfo, FileConfirmDecision, DesignPickInfo, OntologyAssetKind, OntologyTagOption } from './types'; import type { EditionAuthUserFields } from './editionApiTypes'; import type { EditionChatDetailFields, EditionCreateProjectFields } from './editionModelTypes'; import { createEditionAccessError } from './editionAccessError'; @@ -253,6 +253,7 @@ function toChatItem(raw: JsonObject): ChatItem { agentName: typeof metadata.agent_name === 'string' ? metadata.agent_name : undefined, planChat: metadata.plan_chat === true ? true : undefined, batchChat: metadata.batch_chat === true ? true : undefined, + workflowChat: metadata.workflow_chat === true ? true : undefined, projectId: typeof raw.project_id === 'string' && raw.project_id ? raw.project_id : undefined, }; } @@ -568,6 +569,21 @@ export async function updateSession(chatId: string, data: UpdateSessionRequest): }; } +/** 侧边栏手动拖拽顺序(chat_id 序列,空数组=按默认「置顶 + 最近更新」排)。 + * 存在账号维度(users_shadow.metadata),换设备/换浏览器仍跟随账号。 */ +export async function getSidebarChatOrder(): Promise { + const wrapped = await apiRequest('/v1/chats/sidebar-order'); + const data = unwrapData<{ order?: unknown }>(wrapped); + return Array.isArray(data.order) ? data.order.map(String) : []; +} + +export async function saveSidebarChatOrder(order: string[]): Promise { + await apiRequest('/v1/chats/sidebar-order', { + method: 'PUT', + body: JSON.stringify({ order }), + }); +} + export async function deleteSession(chatId: string): Promise { await apiRequest( `/v1/chats/${chatId}`, @@ -2357,6 +2373,24 @@ export async function generatePlanStream( }); } +/* ── 批量作业(工作流模式)────────────────────────────────────────── + 状态条的数据源。只读聚合,逐项明细不走这里——几百上千项读进浏览器毫无意义。 */ + +export async function listChatJobs(chatId: string): Promise { + const res = await apiRequest(`/v1/jobs?chat_id=${encodeURIComponent(chatId)}&live=true`); + const data = unwrapData<{ jobs?: JobBrief[] }>(res); + return data?.jobs ?? []; +} + +export async function getJobApi(jobId: string): Promise { + const res = await apiRequest(`/v1/jobs/${encodeURIComponent(jobId)}`); + return unwrapData(res); +} + +export async function cancelJobApi(jobId: string): Promise { + await apiRequest(`/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST' }); +} + export async function listPlans(): Promise { const res = await apiRequest('/v1/plans'); return unwrapData(res); diff --git a/src/frontend/src/components/chat/ChatArea.tsx b/src/frontend/src/components/chat/ChatArea.tsx index dc5acfa..e64fade 100644 --- a/src/frontend/src/components/chat/ChatArea.tsx +++ b/src/frontend/src/components/chat/ChatArea.tsx @@ -53,6 +53,7 @@ const MOBILE_QUICK_TASKS = [ ] as const; import { InputArea } from './InputArea'; import { PlanProgressStrip } from './PlanProgressStrip'; +import { JobProgressStrip } from './JobProgressStrip'; import { FileConfirmBar } from './FileConfirmBar'; import { DesignPickerCard } from './DesignPickerCard'; import { ChatShareBanner } from './ChatShareBanner'; @@ -593,6 +594,8 @@ export function ChatArea({
{!planMode && } + {/* 后台作业状态条:工作流模式下作业跑在后台,没有它就完全看不出在不在跑 */} + {shareAccessLevel === 'read' ? ( diff --git a/src/frontend/src/components/chat/InputArea.tsx b/src/frontend/src/components/chat/InputArea.tsx index 4ec4d4b..550bad8 100644 --- a/src/frontend/src/components/chat/InputArea.tsx +++ b/src/frontend/src/components/chat/InputArea.tsx @@ -5,7 +5,7 @@ import { DUR, EASE } from '../../utils/motionTokens'; import { FileImageOutlined, FileTextOutlined, CloudDownloadOutlined, AppstoreOutlined, FolderOutlined, FolderOpenOutlined, FolderAddOutlined, RobotOutlined, - OrderedListOutlined, ThunderboltOutlined, ApiOutlined, SyncOutlined, + OrderedListOutlined, ThunderboltOutlined, ApiOutlined, SyncOutlined, PartitionOutlined, LaptopOutlined, CloseOutlined, } from '@ant-design/icons'; import { useChatStore, useFileStore, useUIStore, useCatalogStore, useAuthStore, usePluginStore, useEditionStore } from '../../stores'; @@ -20,7 +20,7 @@ import type { InstalledPluginItem } from '../../types'; import { AgentMentionPopup, useAgentMention } from '../agent'; import { SkillSlashPopup, useSkillSlash, type SlashEntry } from './SkillSlashPopup'; import LoopPlanBar from '../loop/LoopPlanBar'; -import { resolveBatchModeActive } from '../../utils/chatMode'; +import { resolveBatchModeActive, resolveWorkflowModeActive } from '../../utils/chatMode'; import { useFileDropZone } from '../../hooks/useFileDropZone'; import { DropOverlay } from '../common/DropOverlay'; import { ChipChevron } from '../common/ChipChevron'; @@ -60,9 +60,9 @@ interface InputAreaProps { /** Custom "enter plan/batch mode" behavior. The project page passes this in: defer * chat creation until send, no navigation; when omitted, falls back to the default * enterChatMode (switches the current chat in place). */ - onEnterMode?: (mode: 'plan' | 'batch') => void; + onEnterMode?: (mode: 'plan' | 'batch' | 'workflow') => void; /** Currently selected mode (projectComposer project page only; drives the "selected" marker and the indicator pill). */ - activeMode?: 'plan' | 'batch' | null; + activeMode?: 'plan' | 'batch' | 'workflow' | null; } // ── Attachment card keys ──────────────────────────────────────────────── @@ -282,6 +282,7 @@ export function InputArea({ // Batch mode as the composer currently runs it — the persistent batchChat marker is only its // default, so a chat the user took out of batch mode no longer counts as one here. const batchModeOn = resolveBatchModeActive(_currentChat); + const workflowModeOn = resolveWorkflowModeActive(_currentChat); const isSiteChat = !!_currentChat?.siteChat; // Whether the "autonomous loop" entry is shown: normal chat (not plan/batch/project page) // + has the loop capability bit + has lab permission. When eligible it no longer occupies @@ -336,13 +337,20 @@ export function InputArea({ // popup rendering, keeping selectedIndex consistent. const slashEntries = useMemo(() => { const q = input.startsWith('/') ? input.slice(1).toLowerCase() : ''; + // 模式命令排在最前:它切换的是这段对话怎么跑,比挑一个技能更"重",也更常被找。 + // 工作流模式必须由用户显式触发——不触发就不注册 run_job、不注入批量提示词。 + const modeEntries: SlashEntry[] = ( + [{ id: 'workflow', name: 'workflow', hint: t('工作流模式:批量作业') }] as const + ) + .filter((m) => !q || m.id.includes(q) || m.hint.toLowerCase().includes(q)) + .map((m) => ({ kind: 'mode' as const, id: m.id, name: m.name, hint: m.hint })); const pluginEntries: SlashEntry[] = installedPlugins .filter((p) => !q || p.name.toLowerCase().includes(q)) .map((p) => ({ kind: 'plugin' as const, id: p.install_id, name: p.name, plugin: p })); const skillEntries: SlashEntry[] = (skills || []) .filter((s) => s.enabled && (!q || s.name.toLowerCase().includes(q))) .map((s) => ({ kind: 'skill' as const, id: s.id, name: s.name })); - return [...pluginEntries, ...skillEntries]; + return [...modeEntries, ...pluginEntries, ...skillEntries]; }, [input, installedPlugins, skills]); // Object URLs for uploaded image files — revoked when files change @@ -498,6 +506,17 @@ export function InputArea({ applySkill(skillId, skillName); } + /** 选中 / 面板里的模式命令(如 /workflow):把已输入的 "/xxx" 抹掉,直接开启该模式。 + * 与技能/插件不同——模式不插 chip,它改的是这段对话怎么跑,开启状态由输入框上方的模式条表示。 */ + function onSlashSelectMode(modeId: string) { + const ed = editorRef.current; + if (ed) removeQueryAtCursor(ed, '/'); + setInput(''); + if (ed) setEditorPlainText(ed, ''); + setSlashVisible(false); + if (modeId === 'workflow') onEnterMode('workflow'); + } + /** Pick a skill from the "+" menu: move the caret to the end first, then insert the chip (the editor may not have focus when the menu closes). */ function onPickSkillFromMenu(skillId: string, skillName: string) { const ed = editorRef.current; @@ -546,16 +565,18 @@ export function InputArea({ /** Whether the composer currently runs in this mode (main composer: live composer state; * project composer: the pending selection passed in via activeMode). */ - function isModeOn(mode: 'plan' | 'batch') { + function isModeOn(mode: 'plan' | 'batch' | 'workflow') { if (projectComposer) return activeMode === mode; - return mode === 'plan' ? planMode : batchModeOn; + if (mode === 'plan') return planMode; + if (mode === 'workflow') return workflowModeOn; + return batchModeOn; } /** Enter plan / batch-execution mode from the "+" menu. The project page customizes this via * the onEnterMode prop (defer chat creation until send, no navigation); the default switches * the current chat to that mode in place — no new chat, no navigation (avoids bouncing the * whole chat back to the home page). */ - function onEnterMode(mode: 'plan' | 'batch') { + function onEnterMode(mode: 'plan' | 'batch' | 'workflow') { if (onEnterModeProp) { onEnterModeProp(mode); return; @@ -566,7 +587,7 @@ export function InputArea({ /** Close a running mode from the ✕ on its composer chip — the single, always-visible way out. * On the project page the pending selection is owned by the parent, so hand the toggle back * to it (onEnterModeProp flips the already-selected mode off). */ - function onCloseMode(mode: 'plan' | 'batch') { + function onCloseMode(mode: 'plan' | 'batch' | 'workflow') { if (onEnterModeProp) { onEnterModeProp(mode); return; @@ -585,7 +606,8 @@ export function InputArea({ e.preventDefault(); const sel = slashEntries[sIdx] || slashEntries[0]; if (sel) { - if (sel.kind === 'plugin' && sel.plugin) onSlashSelectPlugin(sel.plugin); + if (sel.kind === 'mode') onSlashSelectMode(sel.id); + else if (sel.kind === 'plugin' && sel.plugin) onSlashSelectPlugin(sel.plugin); else onSlashSelect(sel.id, sel.name); } return; @@ -770,7 +792,8 @@ export function InputArea({ visible={slashVisible} selectedIndex={sIdx} onSelect={(entry) => { - if (entry.kind === 'plugin' && entry.plugin) onSlashSelectPlugin(entry.plugin); + if (entry.kind === 'mode') onSlashSelectMode(entry.id); + else if (entry.kind === 'plugin' && entry.plugin) onSlashSelectPlugin(entry.plugin); else onSlashSelect(entry.id, entry.name); }} onHover={setSIdx} @@ -820,6 +843,7 @@ export function InputArea({ // off is the job of the ✕ on the mode chip down in the composer bar. const planActive = isModeOn('plan'); const batchActive = isModeOn('batch'); + const workflowActive = isModeOn('workflow'); const activeSuffix = projectComposer ? t('(已选)') : t('(已开启)'); const modeItems = [ ...(isAppAllowed('plan_mode') ? [{ @@ -834,6 +858,15 @@ export function InputArea({ label: batchActive ? t('批量执行{suffix}', { suffix: activeSuffix }) : t('批量执行'), onClick: () => onEnterMode('batch'), }] : []), + // 工作流模式:面对成百上千个同构工作项时,让智能体写一段作业脚本交后台并发跑。 + // 与计划模式/批量执行一样是**用户显式触发**的模式——不进入就不注册 run_job、 + // 不注入批量提示词,普通问答完全不受影响。 + { + key: 'mode-workflow', + icon: , + label: workflowActive ? t('工作流模式{suffix}', { suffix: activeSuffix }) : t('工作流模式'), + onClick: () => onEnterMode('workflow'), + }, ]; const items = [ { key: 'image', icon: , label: t('上传图片'), onClick: () => imageInputRef.current?.click() }, @@ -1096,6 +1129,16 @@ export function InputArea({ /> )} + {!projectComposer && workflowModeOn && ( + } + label={t('工作流模式')} + title={t('工作流模式:面对成百上千个同类工作项时,AI 会写一段作业脚本交给后台并发处理,进度记在台账上,中断可续跑')} + closeLabel={t('关闭工作流模式:切换为普通对话')} + onClose={() => onCloseMode('workflow')} + /> + )} + {showLoopEntry && loopMode && ( } diff --git a/src/frontend/src/components/chat/JobProgressStrip.tsx b/src/frontend/src/components/chat/JobProgressStrip.tsx new file mode 100644 index 0000000..58a6b7e --- /dev/null +++ b/src/frontend/src/components/chat/JobProgressStrip.tsx @@ -0,0 +1,250 @@ +import { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { CloseOutlined, LoadingOutlined, StopOutlined, WarningOutlined } from '@ant-design/icons'; +import { DUR, EASE } from '../../utils/motionTokens'; +import { listChatJobs, cancelJobApi, getJobApi } from '../../api'; +import type { JobBrief } from '../../types'; +import { t } from '../../i18n'; + +/* ─────────────────────────────────────────── + Job bar — 工作流模式下钉在输入框上方的后台作业状态条。 + + 为什么必须有:作业跑在后台,主对话是安静的。没有这条,用户根本判断不出 + "工作流模式到底有没有在跑"——只能干等或反复追问,而每次追问都是一轮真实推理。 + 这条走 REST 轮询(一次 SQL 聚合),和模型完全无关,所以看进度是零推理成本的。 + + 与 PlanProgressStrip 的分工:那条是模型自己报的计划步骤,这条是后台作业的 + 真实台账。两条可以同时出现,互不干扰。 + ─────────────────────────────────────────── */ + +/** 作业活着时轮询要跟得上肉眼;不活跃就彻底停表,别给后端凭空加常驻负载。 */ +const POLL_MS = 5000; + +function ProgressRing({ settled, total }: { settled: number; total: number }) { + const R = 7; + const C = 2 * Math.PI * R; + const frac = total > 0 ? Math.min(1, settled / total) : 0; + return ( + + + + + ); +} + +/** 后端给的 ISO 串按 UTC 解释。 + * + * 不能直接 `new Date(s)`:JS 规范里**不带时区偏移**的日期时间串按**本地时区**解析, + * 同一个后端时刻在 UTC+8 的浏览器上会平移 8 小时。后端现在都发带偏移的串,这里做的是 + * 兜底——历史数据或别的入口漏了偏移时,按 UTC 补齐比按本地时区猜要稳。 */ +function parseServerTime(raw?: string | null): number { + if (!raw) return NaN; + const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(raw.trim()); + return new Date(hasZone ? raw : `${raw.replace(' ', 'T')}Z`).getTime(); +} + +function elapsedLabel(startedAt?: string | null): string { + const ts = parseServerTime(startedAt); + if (!Number.isFinite(ts)) return ''; + // 时钟漂移(浏览器比服务端慢一点)会让刚提交的作业算出负数——按刚开始处理,别显示空白 + const ms = Math.max(0, Date.now() - ts); + const min = Math.floor(ms / 60000); + if (min < 1) return t('不到 1 分钟'); + if (min < 60) return t('{n} 分钟', { n: min }); + return t('{h} 小时 {m} 分', { h: Math.floor(min / 60), m: min % 60 }); +} + +/** 台账还没建起来时的阶段说明 —— 只给一个转圈的菊花,用户没法判断是在启动还是已经僵住。 */ +function phaseLabel(job: JobBrief): string { + if (job.status === 'pending') return t('启动中'); + return t('正在建立工作项台账'); +} + +/** 作业没能善终时的一句话说明(状态条会带着它多留一会儿再消失)。 */ +function endedLabel(job: JobBrief): string { + if (job.status === 'failed') return t('作业失败'); + if (job.status === 'interrupted') return t('作业已失联,可续跑'); + if (job.status === 'cancelled') return t('作业已取消'); + return ''; +} + +export function JobProgressStrip({ chatId }: { chatId: string }) { + const [jobs, setJobs] = useState([]); + const [ended, setEnded] = useState([]); + const [cancelling, setCancelling] = useState(''); + // 轮询在 effect 外也要能读到最新会话,避免切会话后旧定时器把旧数据写回来 + const chatRef = useRef(chatId); + chatRef.current = chatId; + // 见过的在跑作业。列表接口只给未结束的,作业一旦进终态就直接从列表里消失—— + // 没有这份记录,失败的作业就是「状态条忽然不见了」,用户永远不知道它是跑完了还是崩了。 + const seenRef = useRef>(new Set()); + + useEffect(() => { + seenRef.current = new Set(); + setEnded([]); + if (!chatId) { + setJobs([]); + return; + } + let alive = true; + let timer: number | undefined; + + const tick = async () => { + try { + const rows = await listChatJobs(chatId); + if (!alive || chatRef.current !== chatId) return; + setJobs(rows); + + const liveIds = new Set(rows.map((r) => r.job_id)); + const vanished = [...seenRef.current].filter((id) => !liveIds.has(id)); + seenRef.current = liveIds; + // 消失的作业补查一次终态:跑完了就安静收场,没善终就把原因摆到台面上 + for (const id of vanished) { + try { + const final = await getJobApi(id); + if (!alive || chatRef.current !== chatId) return; + if (final.status !== 'completed') { + setEnded((prev) => (prev.some((j) => j.job_id === id) ? prev : [...prev, final])); + } + } catch { + // 查不到就算了:宁可少说一句,也不要编一个结局 + } + } + } catch { + // 轮询失败保持上一帧:状态条抖成空白比慢一拍更糟 + } + if (alive) timer = window.setTimeout(tick, POLL_MS); + }; + void tick(); + + return () => { + alive = false; + if (timer) window.clearTimeout(timer); + }; + }, [chatId]); + + const onCancel = async (jobId: string) => { + setCancelling(jobId); + try { + await cancelJobApi(jobId); + setJobs((prev) => prev.filter((j) => j.job_id !== jobId)); + } catch { + // 取消失败就让下一轮轮询把真实状态刷回来 + } finally { + setCancelling(''); + } + }; + + return ( + + {jobs.map((job) => { + const s = job.stats || ({} as JobBrief['stats']); + const total = s.total ?? 0; + const settled = s.settled ?? 0; + const pct = total > 0 ? Math.floor((settled * 100) / total) : 0; + const elapsed = elapsedLabel(job.started_at || job.created_at); + return ( + + {total > 0 + ? + : } + + {t('后台作业')} + {job.name || t('批量作业')} + + {total > 0 ? ( + <> + · + {settled}/{total}({pct}%) + + ) : ( + /* 台账还是空的:说清楚现在在哪一步,别让用户对着一个菊花猜 */ + <> + · + {phaseLabel(job)} + + )} + {/* 失败数是最该被看见的数:它决定用户要不要现在就叫停 */} + {s.failed > 0 && ( + <> + · + {t('失败 {n}', { n: s.failed })} + + )} + {elapsed && ( + <> + · + {t('已运行 {d}', { d: elapsed })} + + )} + + + + ); + })} + + {/* 没善终的作业:不能就这么无声消失——用户得知道它停了、停在哪、怎么接着跑 */} + {ended.map((job) => { + const s = job.stats || ({} as JobBrief['stats']); + const total = s.total ?? 0; + const settled = s.settled ?? 0; + return ( + + + {t('后台作业')} + {job.name || t('批量作业')} + · + {endedLabel(job)} + {total > 0 && ( + <> + · + {t('已完成 {n}/{m}', { n: settled, m: total })} + + )} + {job.error && {job.error}} + + + + ); + })} + + ); +} diff --git a/src/frontend/src/components/chat/SkillSlashPopup.tsx b/src/frontend/src/components/chat/SkillSlashPopup.tsx index 73d1bbc..d9d3b5e 100644 --- a/src/frontend/src/components/chat/SkillSlashPopup.tsx +++ b/src/frontend/src/components/chat/SkillSlashPopup.tsx @@ -1,15 +1,18 @@ import { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; -import { AppstoreOutlined, BulbOutlined } from '@ant-design/icons'; +import { AppstoreOutlined, BulbOutlined, PartitionOutlined } from '@ant-design/icons'; import { usePopupFlip } from '../../hooks/usePopupFlip'; import { t } from '../../i18n'; import type { InstalledPluginItem } from '../../types'; export type SlashEntry = { - kind: 'skill' | 'plugin'; + /** mode:切换对话模式的命令(如 /workflow 进入工作流模式),选中即开启,不插入 chip。 */ + kind: 'skill' | 'plugin' | 'mode'; id: string; name: string; plugin?: InstalledPluginItem; + /** mode 条目的说明,渲染在名称右侧(技能/插件条目不用)。 */ + hint?: string; }; interface SkillSlashPopupProps { @@ -57,9 +60,13 @@ export function SkillSlashPopup({ entries, visible, selectedIndex, onSelect, onH > {entry.kind === 'plugin' ? - : } + : entry.kind === 'mode' + ? + : } {entry.name} + {entry.hint && {entry.hint}} {entry.kind === 'plugin' && {t('插件')}} + {entry.kind === 'mode' && {t('模式')}}
))} diff --git a/src/frontend/src/components/chat/index.ts b/src/frontend/src/components/chat/index.ts index 961ca58..9b19248 100644 --- a/src/frontend/src/components/chat/index.ts +++ b/src/frontend/src/components/chat/index.ts @@ -8,6 +8,7 @@ export { DesignPickerCard } from './DesignPickerCard'; export { PromptHubPanel } from './PromptHubPanel'; export { PlanCard } from './PlanCard'; export { PlanProgressStrip } from './PlanProgressStrip'; +export { JobProgressStrip } from './JobProgressStrip'; export { OntologyRevisionPanel } from './OntologyRevisionPanel'; export { OntologyReviewTrigger } from './OntologyReviewTrigger'; export { EvolutionCard } from './EvolutionCard'; diff --git a/src/frontend/src/components/projects/ProjectDetailPanel.tsx b/src/frontend/src/components/projects/ProjectDetailPanel.tsx index 7f85806..d3d282f 100644 --- a/src/frontend/src/components/projects/ProjectDetailPanel.tsx +++ b/src/frontend/src/components/projects/ProjectDetailPanel.tsx @@ -48,7 +48,8 @@ export default function ProjectDetailPanel({ projectId, onBack, handleFileSelect const setCurrentChatId = useChatStore((state) => state.setCurrentChatId); const updateStore = useChatStore((state) => state.updateStore); const setPendingFirstMessage = useChatStore((state) => state.setPendingFirstMessage); - const [pendingMode, setPendingMode] = useState<'plan' | 'batch' | null>(null); + // 'workflow' 是工作流模式(批量作业)——少了它,InputArea 的 onEnterMode 回调类型对不上,tsc 直接编不过 + const [pendingMode, setPendingMode] = useState<'plan' | 'batch' | 'workflow' | null>(null); const inputRef = useRef(null); const fileInputRef = useRef(null); diff --git a/src/frontend/src/components/sidebar/Sidebar.tsx b/src/frontend/src/components/sidebar/Sidebar.tsx index 2a3c73f..d70fb88 100644 --- a/src/frontend/src/components/sidebar/Sidebar.tsx +++ b/src/frontend/src/components/sidebar/Sidebar.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { DragEvent as ReactDragEvent } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Layout, Input, Dropdown, Tooltip, Badge, Modal, message, @@ -11,16 +12,18 @@ import { PushpinOutlined, PushpinFilled, StarOutlined, StarFilled, EllipsisOutlined, CaretDownOutlined, FolderOutlined, FolderOpenOutlined, ExportOutlined, ExclamationCircleFilled, - MessageOutlined, + MessageOutlined, SortAscendingOutlined, } from '@ant-design/icons'; -import { useUIStore, useChatStore, useAuthStore, useMySpaceStore, useAutomationChatStore, useAutomationStore } from '../../stores'; +import { useUIStore, useChatStore, useAuthStore, useMySpaceStore, useAutomationChatStore, useAutomationStore, useSidebarOrderStore } from '../../stores'; import { useCatalogStore } from '../../stores/catalogStore'; import { useProjectStore } from '../../stores/projectStore'; import { useDeploymentModeStore } from '../../stores/deploymentModeStore'; import { usePageConfig } from '../../hooks/usePageConfig'; +import { useIsMobileViewport } from '../../hooks/useIsMobileViewport'; import { LAYOUT_ITEMS } from './items'; import { DEFAULT_SIDEBAR_ITEMS, DEFAULT_MENU_ITEMS } from '../../utils/pageConfigDefaults'; import { buildSidebarChatItems } from '../../utils/history'; +import { compareSidebarItems } from '../../utils/sidebarOrder'; import { resolveAvatarUrl } from '../../utils/avatar'; import { loadJsonPref, saveJsonPref } from '../../storage'; import { getAutomationRuns } from '../../api'; @@ -51,6 +54,12 @@ const NAV_COLLAPSED_KEY = 'hugagent_ui_nav_collapsed_v1'; const loadCollapsedNavGroups = () => loadJsonPref>(NAV_COLLAPSED_KEY, {}); // Chat list item add/remove animation: enter 0.22s float-up expand / exit 0.18s height collapse (items below smoothly reposition via layout). +/* 历史项行高。移动端抽屉里它是主要触摸目标,桌面的 36px 低于 44pt 的触摸下限。 + 这个值必须在 JS 里给:framer-motion 把 animate 的 height 写成内联样式, + 样式表里怎么写都压不住(内联优先级更高),是纯 CSS 适配够不到的一类值。 */ +const HISTORY_ITEM_H = 36; +const HISTORY_ITEM_H_MOBILE = 44; + const HISTORY_ITEM_ENTER = { duration: 0.22, ease: EASE.brandOut }; const HISTORY_ITEM_EXIT = { duration: 0.18, ease: EASE.exit }; @@ -97,6 +106,7 @@ export function Sidebar({ const cfgLogoutTitle = usePageConfig('texts.dialog_logout_confirm_title', '确认退出登录?'); const cfgLogoutContent = usePageConfig('texts.dialog_logout_confirm_content', '退出登录不会丢失任何数据,你仍可以登录此账号。'); const cfgLogoutOk = usePageConfig('texts.dialog_logout_confirm_ok', '退出登录'); + const historyItemHeight = useIsMobileViewport() ? HISTORY_ITEM_H_MOBILE : HISTORY_ITEM_H; const sidebarLayoutKeys = usePageConfig('navigation.sidebar_items', DEFAULT_SIDEBAR_ITEMS); const menuLayoutKeys = usePageConfig('navigation.menu_items', DEFAULT_MENU_ITEMS); const { panel } = useCatalogStore(); @@ -113,6 +123,15 @@ export function Sidebar({ const toggleSidebarPinned = useAutomationChatStore((s) => s.toggleSidebarPinned); const toggleSidebarFavorite = useAutomationChatStore((s) => s.toggleSidebarFavorite); const updateAutomationTask = useAutomationStore((s) => s.updateTask); + // ── 侧边栏手动拖拽排序状态(顺序真源在 sidebarOrderStore) ── + const draggingId = useSidebarOrderStore((s) => s.draggingId); + const dropTarget = useSidebarOrderStore((s) => s.dropTarget); + const setDragging = useSidebarOrderStore((s) => s.setDragging); + const setDropTarget = useSidebarOrderStore((s) => s.setDropTarget); + const reorderWithinGroup = useSidebarOrderStore((s) => s.reorderWithinGroup); + const resetSidebarOrder = useSidebarOrderStore((s) => s.resetOrder); + // 拖拽源所属的「分组 + 置顶带」。dragover 阶段读不到 dataTransfer,只能靠 ref 记。 + const dragScopeRef = useRef(null); // ── Projects section data: project list + the currently open project (for highlighting) ── const projects = useProjectStore((s) => s.list); const currentProjectId = useProjectStore((s) => s.currentProjectId); @@ -253,14 +272,19 @@ export function Sidebar({ } }; - // Sort: pinned items first, the rest by updatedAt descending. Automation items use the same rule within their own group. + // Sort: pinned items first, then the manual drag order, then updatedAt descending. + // 手动顺序(sidebarOrderStore)是一维全局序列,这里全局排完再切分组——过滤保序, + // 所以组内相对次序与「只在组内排」等价。没排过的会话取不到下标,按 updatedAt 落在 + // 该组最前,新会话依旧自动冒头。 + const manualOrder = useSidebarOrderStore((s) => s.order); + const manualIndex = useMemo(() => { + const map = new Map(); + manualOrder.forEach((id, idx) => map.set(id, idx)); + return map; + }, [manualOrder]); const sortedHistoryList = useMemo(() => { - return [...historyList].sort((a, b) => { - const pinDiff = Number(!!b.pinned) - Number(!!a.pinned); - if (pinDiff !== 0) return pinDiff; - return (b.updatedAt || 0) - (a.updatedAt || 0); - }); - }, [historyList]); + return [...historyList].sort((a, b) => compareSidebarItems(a, b, manualIndex)); + }, [historyList, manualIndex]); const knownProjectIds = useMemo( () => new Set(projects.map((p) => p.project_id)), @@ -384,9 +408,28 @@ export function Sidebar({ { key: 'history', label: t('历史对话'), rows: 8 }, ]; + // 手动排过序才给这个出口:拖乱了能一键回到「置顶 + 最近更新」的默认排序。 + const resetOrderMenuItems = useMemo>(() => ( + manualOrder.length === 0 ? [] : [ + { + key: 'reset-order', + label: t('恢复默认排序'), + icon: , + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + resetSidebarOrder(); + message.success(t('已恢复默认排序')); + }, + }, + { type: 'divider' as const }, + ] + ), [manualOrder.length, resetSidebarOrder]); + // A single chat item (shared between the projects section's nested list and the automation/history groups, ensuring identical interaction). - // listLen controls the layout animation toggle (disable layout for very long lists to reduce reflow overhead). - const renderChatItem = (item: ChatItem, listLen: number) => { + // groupItems 是该条目所在分组的当前可见顺序,拖拽重排时以它为底稿; + // scopeKey 标识分组(history / automation / project:),拖拽不跨组。 + const renderChatItem = (item: ChatItem, groupItems: ChatItem[], scopeKey: string) => { + const listLen = groupItems.length; const isAutomation = !!item.automationRun; const isActive = isAutomation ? (panel === 'chat' && automationActiveGroup?.taskId === item.automationTaskId) @@ -406,16 +449,80 @@ export function Sidebar({ } }; + // ── 手动拖拽排序 ── + // 拖拽只在「同一分组 + 同一置顶带」内生效:置顶项恒排在组顶部(见 sortedHistoryList), + // 允许把普通会话拖进置顶区只会被排序规则弹回去,不如直接不接这个落点。 + const dragScope = `${scopeKey}:${item.pinned ? 'pinned' : 'plain'}`; + const isDragging = draggingId === item.id; + const dropHint = draggingId && draggingId !== item.id && dropTarget?.id === item.id + ? dropTarget.place + : null; + + const handleDragStart = (e: ReactDragEvent) => { + if (isEditing) { e.preventDefault(); return; } + e.dataTransfer.effectAllowed = 'move'; + // Firefox 要求 dragstart 里必须写入数据,否则整个拖拽不启动 + try { e.dataTransfer.setData('text/plain', item.id); } catch { /* 某些浏览器只读 */ } + dragScopeRef.current = dragScope; + setDragging(item.id); + }; + + const handleDragOver = (e: ReactDragEvent) => { + if (!draggingId || draggingId === item.id) return; + if (dragScopeRef.current !== dragScope) return; + // dragover 阶段读不到 dataTransfer 内容(安全限制),所以拖拽源信息走 ref/store + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + if (dropTarget?.id !== item.id || dropTarget.place !== place) { + setDropTarget({ id: item.id, place }); + } + }; + + const handleDrop = (e: ReactDragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const draggedId = draggingId; + if (!draggedId || draggedId === item.id || dragScopeRef.current !== dragScope) { + setDragging(null); + return; + } + const scopeIds = groupItems + .filter((i) => !!i.pinned === !!item.pinned) + .map((i) => i.id); + const place = dropTarget?.id === item.id ? dropTarget.place : 'before'; + reorderWithinGroup(scopeIds, draggedId, item.id, place); + dragScopeRef.current = null; + }; + + const handleDragEnd = () => { + dragScopeRef.current = null; + setDragging(null); + }; + return ( {isEditing ? ( : , onClick: ({ domEvent }) => { domEvent.stopPropagation(); if (item.automationTaskId) toggleSidebarPinned(item.automationTaskId); } }, { key: 'fav', label: item.favorite ? t('取消收藏') : t('收藏'), icon: item.favorite ? : , onClick: ({ domEvent }) => { domEvent.stopPropagation(); if (item.automationTaskId) toggleSidebarFavorite(item.automationTaskId); } }, { key: 'rename', label: t('重命名'), icon: , onClick: ({ domEvent }) => { domEvent.stopPropagation(); startRenameItem(item); } }, { key: 'export', label: t('导出'), icon: , onClick: ({ domEvent }) => { domEvent.stopPropagation(); void exportAutomationItem(item); } }, ] : [ + ...resetOrderMenuItems, { key: 'pin', label: item.pinned ? t('取消置顶') : t('置顶'), icon: item.pinned ? : , onClick: ({ domEvent }) => { domEvent.stopPropagation(); onTogglePinned(item.id); } }, { key: 'fav', label: item.favorite ? t('取消收藏') : t('收藏'), icon: item.favorite ? : , onClick: ({ domEvent }) => { domEvent.stopPropagation(); onToggleFavorite(item.id); } }, { key: 'rename', label: t('重命名'), icon: , onClick: ({ domEvent }) => { domEvent.stopPropagation(); startRenameItem(item); } }, @@ -910,7 +1019,7 @@ export function Sidebar({
- {pg.items.map((item) => renderChatItem(item, pg.items.length))} + {pg.items.map((item) => renderChatItem(item, pg.items, `project:${pg.projectId}`))}
@@ -944,7 +1053,7 @@ export function Sidebar({
- {group.items.map((item) => renderChatItem(item, group.items.length))} + {group.items.map((item) => renderChatItem(item, group.items, group.key))}
diff --git a/src/frontend/src/hooks/chatStream.ts b/src/frontend/src/hooks/chatStream.ts index cff9e65..426f0f8 100644 --- a/src/frontend/src/hooks/chatStream.ts +++ b/src/frontend/src/hooks/chatStream.ts @@ -3,16 +3,18 @@ import { t } from '../i18n'; import { toFileConfirmInfo, toDesignPickInfo } from '../api'; import { normalizeArtifactOutput } from '../utils/fileParser'; import { stripMcpToolPrefix } from '../utils/constants'; +import { refreshTargetForTool } from '../utils/toolRefresh'; import { parseContextCompactionState } from '../utils/contextUsage'; import { appendStreamTextSegment, + appendSubagentStepDelta, appendThinkingContentBeforeTrailingText, deferThinkingTextFragmentBeforeTool, liftTrailingSegmentsAboveFinalText, restoreDeferredThinkingTextFragment, type DeferredThinkingTextFragment, } from '../utils/streamSegments'; -import { useChatStore, useCatalogStore, useUIStore, useBatchStore, useCanvasStore } from '../stores'; +import { useChatStore, useCatalogStore, useUIStore, useBatchStore, useCanvasStore, useAgentStore, usePluginStore } from '../stores'; import type { ChatItem, ChatMessage, CitationItem, EvolutionSummary, MessageSegment, OntologyGovernanceSummary, SubagentStep, ToolCall } from '../types'; /** @@ -30,18 +32,20 @@ import type { ChatItem, ChatMessage, CitationItem, EvolutionSummary, MessageSegm * reduction logic for new scenarios is forbidden. */ -/** Tools in the skill-manager plugin that mutate "my skill library" — after the agent calls - * them the capability catalog must be refreshed, otherwise the frontend skill list stays on - * stale data (no visible change after create/install/delete). search/list/submit are read-only - * or don't change the list, so they don't trigger it. */ -const SKILL_LIBRARY_MUTATING_TOOLS = ['register_skill', 'install_from_marketplace', 'delete_skill']; - +/** 管理类插件写操作后,重拉持有那份列表的 store。 + * + * 刷新不是锦上添花:MCP 跑在自己的容器里,它清掉的能力缓存是**它那个进程**的,不是 backend + * 的——前端这次重拉才是让变更真正可见的那一步,否则就是"说创建好了但界面没动静"。 + * + * 刷哪个 store 与刷不刷同样重要,映射表见 utils/toolRefresh.ts(那里也有测试钉住)。 + * pluginStore 有 `loaded` 缓存,所以要 fetchInstalled(true) 强制。 */ function maybeRefreshCatalogAfterTool(toolName: string, status: string): void { if (status !== 'success') return; - const name = stripMcpToolPrefix(toolName || ''); - if (SKILL_LIBRARY_MUTATING_TOOLS.some((n) => name.includes(n))) { - void useCatalogStore.getState().fetchCatalog(); - } + const target = refreshTargetForTool(stripMcpToolPrefix(toolName || '')); + if (!target) return; + if (target === 'catalog') void useCatalogStore.getState().fetchCatalog(); + else if (target === 'agents') void useAgentStore.getState().fetchAgents(); + else void usePluginStore.getState().fetchInstalled(true); } /** Unified handling of the site-design pick-one-of-three SSE event (shared by the live stream @@ -112,15 +116,8 @@ function applySubagentEvent(toolCalls: ToolCall[], eo: Record): if (eo.output !== null && eo.output !== undefined) patch.output = eo.output; upsertToolStep(norm(eo.tool_id), norm(eo.tool_name), patch, status); } else if (subType === 'thinking' || subType === 'content') { - const delta = norm(eo.delta); - if (delta) { - const last = steps[steps.length - 1]; - if (last && last.kind === subType) { - steps[steps.length - 1] = { ...last, text: (last.text || '') + delta }; - } else { - steps.push({ kind: subType, text: delta }); - } - } + // 迟到思考尾并回前块(与主链路同一规则),避免思考与正文交错切碎 + appendSubagentStepDelta(steps, subType, norm(eo.delta)); } else if (subType === 'error') { steps.push({ kind: 'content', text: '⚠ ' + (norm(eo.error) || 'error') }); } diff --git a/src/frontend/src/hooks/useChatInit.ts b/src/frontend/src/hooks/useChatInit.ts index 6f24ec5..2498276 100644 --- a/src/frontend/src/hooks/useChatInit.ts +++ b/src/frontend/src/hooks/useChatInit.ts @@ -8,7 +8,7 @@ import { isAutomationHistoryChat } from '../utils/history'; import { stripMcpToolPrefix } from '../utils/constants'; import { parseContextCompactionState } from '../utils/contextUsage'; import { shouldRestorePlanModeFromHistory } from '../utils/chatMode'; -import { LOGIN_LANDING_KEY, useAuthStore, useSettingsStore, useUIStore, useChatStore, useCatalogStore, useAutomationChatStore, useBatchStore } from '../stores'; +import { LOGIN_LANDING_KEY, useAuthStore, useSettingsStore, useUIStore, useChatStore, useCatalogStore, useAutomationChatStore, useBatchStore, useSidebarOrderStore } from '../stores'; import type { Catalog, ChatItem, ChatMessage, CitationItem, ContextCompactionState, EvolutionSummary, OntologyGovernanceSummary, ToolCall, UpdateEntry, BatchPlanMeta, BatchSourceType, BatchItemResult } from '../types'; const effectiveApiUrl = (import.meta.env.VITE_API_BASE_URL as string || '').trim() || '/api'; @@ -361,6 +361,8 @@ export function useChatInit() { // login swap. hydrateForUser(authUserId); useAutomationChatStore.getState().hydrateForUser(authUserId); + // 侧边栏手动拖拽顺序:本地秒开 + 异步拉服务端顺序(见 sidebarOrderStore) + useSidebarOrderStore.getState().hydrateForUser(authUserId); const localSnapshot = useChatStore.getState().store; clearBackendSessionIds(); clearLoadedMsgIds(); @@ -422,6 +424,10 @@ export function useChatInit() { ...(typeof localSnapshot.chats[id]?.batchModeActive === 'boolean' ? { batchModeActive: localSnapshot.chats[id].batchModeActive } : {}), + workflowChat: meta.workflow_chat === true ? true : undefined, + ...(typeof localSnapshot.chats[id]?.workflowModeActive === 'boolean' + ? { workflowModeActive: localSnapshot.chats[id].workflowModeActive } + : {}), automationTaskId: typeof meta.automation_task_id === 'string' ? meta.automation_task_id : undefined, automationRun: meta.automation_run === true ? true : undefined, // When the backend session hasn't bound project_id (e.g. bound locally via the input-box dropdown, not yet persisted with a message), diff --git a/src/frontend/src/hooks/useIsMobileViewport.ts b/src/frontend/src/hooks/useIsMobileViewport.ts new file mode 100644 index 0000000..91f3df6 --- /dev/null +++ b/src/frontend/src/hooks/useIsMobileViewport.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from 'react'; + +/** + * 移动端断点的单一真源。 + * + * 960px 这个数字原本散落在 App.tsx 的三处 matchMedia 调用和 mobile.css 的媒体查询里, + * 各写各的——改一处漏一处时,布局会进入「CSS 认为是手机、JS 认为是桌面」的错位态。 + * 需要在 JS 里判断移动端的地方一律用本 hook,不要再裸写 matchMedia。 + * + * 为什么会需要 JS 判断:绝大多数适配都该在 CSS 里做,唯独**被写进内联样式的值** + * CSS 压不住(内联样式优先级高于任何非 !important 的规则)。典型是 framer-motion + * 的 animate={{ height: N }}——侧栏历史项的行高就是这么被锁死在 36px 的, + * 触摸热区怎么调样式表都不生效。这类值必须在 JS 侧按断点给。 + */ +export const MOBILE_BREAKPOINT = 960; + +const QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`; + +const subscribe = (onChange: () => void) => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => {}; + const mql = window.matchMedia(QUERY); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); +}; + +const getSnapshot = () => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia(QUERY).matches; +}; + +export function useIsMobileViewport(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/src/frontend/src/hooks/useStreaming.ts b/src/frontend/src/hooks/useStreaming.ts index b854925..02c0a4c 100644 --- a/src/frontend/src/hooks/useStreaming.ts +++ b/src/frontend/src/hooks/useStreaming.ts @@ -5,7 +5,7 @@ import { authFetch, getFollowUpQuestions, regenerateMessage, editAndRegenerate, import { processPlanExecuteStream, processPlanGenerateStream } from './usePlanMode'; import { uploadFileToOSS } from '../utils/fileParser'; import { inferBusinessTopic } from '../utils/history'; -import { resolveBatchModeActive } from '../utils/chatMode'; +import { resolveBatchModeActive, resolveWorkflowModeActive } from '../utils/chatMode'; import { useChatStore, useAuthStore, useCatalogStore, useChatModeStore, useFileStore, useUIStore, useBatchStore, useModelCapabilitiesStore } from '../stores'; import { useProjectStore } from '../stores/projectStore'; import { isThinkingMode } from '../stores/chatStore'; @@ -340,6 +340,7 @@ export function useStreaming( ...(chat.agentName ? { agent_name: chat.agentName } : {}), ...(chat.planChat ? { plan_chat: true } : {}), ...(chat.batchChat ? { batch_chat: true } : {}), + ...(chat.workflowChat ? { workflow_chat: true } : {}), title_manually_set: true, }, }), @@ -513,6 +514,7 @@ export function useStreaming( const currentChat = useChatStore.getState().store.chats[currentChatId]; const agentId = (currentChat as any)?.agentId || undefined; const batchChat = resolveBatchModeActive(currentChat); + const workflowChat = resolveWorkflowModeActive(currentChat); const modelCaps = useModelCapabilitiesStore.getState(); const selectedModelProviderId = modelCaps.capabilities.user_model_switch_enabled ? modelCaps.selectedModelProviderId @@ -572,6 +574,7 @@ export function useStreaming( mention_name: currentMention.name, } : {}), ...(batchChat ? { batch_chat: true } : {}), + ...(workflowChat ? { workflow_chat: true } : {}), // Project mount: read from the chat's own projectId (the frontend binds it when // creating/fetching the session). When the chat has no bound project, fall back to // useProjectStore.currentProjectId — this only applies to the first message sent while diff --git a/src/frontend/src/i18n/en/chat.ts b/src/frontend/src/i18n/en/chat.ts index 8557e89..233e671 100644 --- a/src/frontend/src/i18n/en/chat.ts +++ b/src/frontend/src/i18n/en/chat.ts @@ -69,6 +69,12 @@ export const CHAT_DICT: Record = { '批量执行模式': 'Batch Mode', '批量执行模式:描述要批量处理的对象与任务,AI 会自动生成可确认的执行计划': 'Batch mode: describe objects and tasks, AI will generate a confirmable execution plan', '批量执行': 'Batch Execution', + '工作流模式': 'Workflow Mode', + '工作流模式{suffix}': 'Workflow Mode{suffix}', + '工作流模式:批量作业': 'Workflow mode: batch jobs', + '工作流模式:面对成百上千个同类工作项时,AI 会写一段作业脚本交给后台并发处理,进度记在台账上,中断可续跑': + 'Workflow mode: for hundreds of similar work items, AI writes a job script that runs concurrently in the background; progress is tracked in a ledger and can resume after an interruption', + '关闭工作流模式:切换为普通对话': 'Exit workflow mode: back to a normal conversation', '站点建站模式:描述你想要的网站,AI 会生成静态站点并一键发布上线': 'Site mode: describe the website you want and AI will generate a static site and publish it in one click', '本对话属于项目「{name}」': 'This chat belongs to project "{name}"', '查看项目「{name}」': 'View project "{name}"', @@ -276,4 +282,20 @@ export const CHAT_DICT: Record = { '修改失败,请重试': 'Edit failed — please retry', '删除失败,请重试': 'Delete failed — please retry', '松开即可添加为附件': 'Release to attach the files', + // Job bar: the background batch job running above the composer. + '模式': 'Mode', + '后台作业': 'Background job', + '批量作业': 'Batch job', + '失败 {n}': '{n} failed', + '已运行 {d}': 'Running {d}', + '取消后台作业': 'Cancel background job', + '{n} 分钟': '{n} min', + '{h} 小时 {m} 分': '{h}h {m}m', + '不到 1 分钟': 'under 1 min', + '启动中': 'Starting', + '正在建立工作项台账': 'Building the work ledger', + '作业失败': 'Job failed', + '作业已失联,可续跑': 'Job lost contact — resumable', + '作业已取消': 'Job cancelled', + '已完成 {n}/{m}': '{n}/{m} done', }; diff --git a/src/frontend/src/i18n/en/mcpMarket.ts b/src/frontend/src/i18n/en/mcpMarket.ts index 58e94e3..1731543 100644 --- a/src/frontend/src/i18n/en/mcpMarket.ts +++ b/src/frontend/src/i18n/en/mcpMarket.ts @@ -130,6 +130,14 @@ export const MCP_MARKET_DICT: Record = { '留空表示由用户填写': 'Leave blank for users to provide it', '用户从市场安装时将自行完成 OAuth 授权;管理员无需在这里填写凭据。': 'Users complete OAuth authorization when installing from the marketplace; the administrator does not enter credentials here.', '每行一个:Key=Value;市场鉴权请优先使用上方 Token/Auth 配置。': 'One Key=Value per line. Use the Token/Auth settings above for marketplace authentication.', + '其它 HTTP Headers(可选)': 'Other HTTP Headers (optional)', + '每行一个:Key=Value。鉴权 Header 由上方 Token 配置自动生成,这里只填其它自定义 Header。': 'One Key=Value per line. The auth header is generated from the Token settings above — only add other custom headers here.', + '每行一个:Key=Value,随每次请求发送。': 'One Key=Value per line, sent with every request.', + '每行一个:Key=Value。管理员 Token 已存在鉴权 Header 里(显示为 ***),改它请走市场鉴权配置,不要在这里另写一条同名 Header。': 'One Key=Value per line. The administrator token already lives in the auth header (shown as ***); change it through the marketplace auth settings rather than adding another header with the same name here.', + '自定义 Header「{key}」与上方鉴权 Header 重名,会被管理员 Token 覆盖,请删除它': 'Custom header "{key}" has the same name as the auth header above and would be overwritten by the administrator token — remove it.', + '逗号分隔的 OS 环境变量 key 列表,子进程启动时注入': 'Comma-separated OS environment variable keys, injected when the subprocess starts', + '常驻=进连接池长连接;临时=每次调用现拉子进程。仅对 StdIO 生效。': 'Resident = pooled long-lived connection; Ephemeral = a fresh subprocess per call. StdIO only.', + '高级设置(可选)': 'Advanced settings (optional)', '信息检索': 'Information Retrieval', '内容创作': 'Content Creation', '办公协作': 'Office Collaboration', diff --git a/src/frontend/src/i18n/en/panels.ts b/src/frontend/src/i18n/en/panels.ts index 75f76b8..10a4b57 100644 --- a/src/frontend/src/i18n/en/panels.ts +++ b/src/frontend/src/i18n/en/panels.ts @@ -160,6 +160,8 @@ export const PANELS_DICT: Record = { '重命名': 'Rename', '导出': 'Export', '更多操作': 'More actions', + '恢复默认排序': 'Reset to default order', + '已恢复默认排序': 'Default order restored', // sidebar nav items '知识库': 'Knowledge Base', '应用中心': 'App Center', diff --git a/src/frontend/src/stores/chatStore.ts b/src/frontend/src/stores/chatStore.ts index 6b83610..71ed90d 100644 --- a/src/frontend/src/stores/chatStore.ts +++ b/src/frontend/src/stores/chatStore.ts @@ -306,11 +306,11 @@ interface ChatState { * place — no new chat, no navigation — avoiding "the whole chat jumping back to home". * - Default (app center): reuse the current chat in place if it's empty, otherwise create a * new chat in that mode. */ - enterChatMode: (mode: 'plan' | 'batch', opts?: { inPlace?: boolean }) => void; + enterChatMode: (mode: 'plan' | 'batch' | 'workflow', opts?: { inPlace?: boolean }) => void; /** Leave plan / batch mode on the current chat and continue as an ordinary conversation. * The historical planChat / batchChat classification is kept (plan cards and batch history * stay recognisable); only the composer/routing flag is turned off. */ - exitChatMode: (mode: 'plan' | 'batch') => void; + exitChatMode: (mode: 'plan' | 'batch' | 'workflow') => void; /** Enter "site building" mode (Lab → Sites): create a new siteChat session and switch to the * main chat, fully reusing the main-chat composer (attachments / projects / "+" menu). * Mutually exclusive with plan / batch. */ @@ -780,17 +780,22 @@ export const useChatStore = create((set, get) => { : {}), }; const nextChat: ChatItem = { ...base, id: targetId, updatedAt: now }; - // Plan / batch are mutually exclusive - if (planChat) { + // Plan / batch / workflow 三者互斥:进入一个就清掉另外两个的标记 + delete nextChat.planChat; + delete nextChat.planModeActive; + delete nextChat.batchChat; + delete nextChat.batchModeActive; + delete nextChat.workflowChat; + delete nextChat.workflowModeActive; + if (mode === 'plan') { nextChat.planChat = true; nextChat.planModeActive = true; - delete nextChat.batchChat; - delete nextChat.batchModeActive; + } else if (mode === 'workflow') { + nextChat.workflowChat = true; + nextChat.workflowModeActive = true; } else { nextChat.batchChat = true; nextChat.batchModeActive = true; - delete nextChat.planChat; - delete nextChat.planModeActive; } const next: ChatStoreData = { chats: { ...store.chats, [targetId]: nextChat }, @@ -822,11 +827,13 @@ export const useChatStore = create((set, get) => { const { currentChatId, currentUserId, store } = get(); const chat = store.chats[currentChatId]; if (!chat) return; + const patch = + mode === 'workflow' ? { workflowModeActive: false } : { batchModeActive: false }; const next: ChatStoreData = { ...store, chats: { ...store.chats, - [currentChatId]: { ...chat, batchModeActive: false }, + [currentChatId]: { ...chat, ...patch }, }, }; set({ store: next, storeRef: next }); diff --git a/src/frontend/src/stores/index.ts b/src/frontend/src/stores/index.ts index e27ccff..f456c67 100644 --- a/src/frontend/src/stores/index.ts +++ b/src/frontend/src/stores/index.ts @@ -15,3 +15,4 @@ export { useModelCapabilitiesStore } from './modelCapabilitiesStore'; export { useEditionStore } from './editionStore'; export { usePluginStore } from './pluginStore'; export { useChatModeStore } from './chatModeStore'; +export { useSidebarOrderStore } from './sidebarOrderStore'; diff --git a/src/frontend/src/stores/sidebarOrderStore.ts b/src/frontend/src/stores/sidebarOrderStore.ts new file mode 100644 index 0000000..ebaecde --- /dev/null +++ b/src/frontend/src/stores/sidebarOrderStore.ts @@ -0,0 +1,118 @@ +import { create } from 'zustand'; +import { getSidebarChatOrder, saveSidebarChatOrder } from '../api'; +import { loadJsonPref, saveJsonPref, userScopedKey } from '../storage'; +import { mergeGroupOrder, reorderGroupSequence } from '../utils/sidebarOrder'; + +/** + * 侧边栏对话列表的「手动顺序」真源。 + * + * 默认排序是派生的(置顶优先 + updatedAt 倒序),用户一旦手动拖过,就要有一份 + * 显式顺序压住它——否则下一条消息把 updatedAt 一顶,手动摆的位置立刻被冲掉。 + * + * 数据形态:一维 chat_id 序列。跨分组穿插不影响正确性,因为比较只发生在同一 + * 分组内部(历史/自动化/某个项目),序列只用来取相对下标。 + * 不在序列里的会话=没被手动排过,按 updatedAt 排在该组最前(新会话自然冒头)。 + * + * 持久化两层:localStorage(按账号隔离,秒开、离线可用)+ 服务端 users_shadow.metadata + * (换设备跟随账号)。服务端一旦读到就是真源。 + */ + +const SIDEBAR_ORDER_KEY = 'hugagent_ui_sidebar_order_v1'; +/** 与后端 SIDEBAR_ORDER_MAX 对齐,避免本地攒出后端会截断的长尾。 */ +const MAX_ORDER_LEN = 500; + +const localKey = (userId: string | null) => userScopedKey(SIDEBAR_ORDER_KEY, userId); + +function readLocal(userId: string | null): string[] { + const key = localKey(userId); + if (!key) return []; + const raw = loadJsonPref(key, []); + return Array.isArray(raw) ? raw.filter((id) => typeof id === 'string') : []; +} + +function writeLocal(userId: string | null, order: string[]) { + const key = localKey(userId); + if (key) saveJsonPref(key, order); +} + +export interface SidebarOrderState { + /** 手动顺序序列;空数组=从未手动排过,走默认排序 */ + order: string[]; + /** 当前已 hydrate 的账号,切账号时用于判断是否需要重读 */ + userId: string | null; + /** 正在拖拽的会话 id(拖拽期间关掉列表布局动画,避免与拖影打架) */ + draggingId: string | null; + /** 当前落点提示:拖到哪个会话的上边/下边 */ + dropTarget: { id: string; place: 'before' | 'after' } | null; + + /** 切换账号:先用本地顺序秒开,再异步拉服务端顺序覆盖 */ + hydrateForUser: (userId: string) => void; + setDragging: (id: string | null) => void; + setDropTarget: (target: { id: string; place: 'before' | 'after' } | null) => void; + /** 在同一分组内把 draggedId 挪到 targetId 的前/后,并落库 */ + reorderWithinGroup: ( + groupIds: string[], + draggedId: string, + targetId: string, + place: 'before' | 'after', + ) => void; + /** 清空手动顺序 → 回到「置顶 + 最近更新」默认排序 */ + resetOrder: () => void; +} + +export const useSidebarOrderStore = create((set, get) => ({ + order: [], + userId: null, + draggingId: null, + dropTarget: null, + + hydrateForUser: (userId) => { + if (get().userId === userId) return; + set({ order: readLocal(userId), userId, draggingId: null, dropTarget: null }); + void (async () => { + try { + const remote = await getSidebarChatOrder(); + // 账号在等待期间又切走了 → 丢弃这次响应 + if (useSidebarOrderStore.getState().userId !== userId) return; + if (remote.length > 0) { + set({ order: remote }); + writeLocal(userId, remote); + return; + } + // 服务端还没有顺序但本地有(老版本本地排过 / 首次上线)→ 把本地补写上去 + const local = useSidebarOrderStore.getState().order; + if (local.length > 0) await saveSidebarChatOrder(local); + } catch { + /* 服务端不可用:本地顺序照常生效,下次拖拽再补同步 */ + } + })(); + }, + + setDragging: (id) => set({ draggingId: id, ...(id ? {} : { dropTarget: null }) }), + setDropTarget: (target) => set({ dropTarget: target }), + + reorderWithinGroup: (groupIds, draggedId, targetId, place) => { + // 以该组「当前可见顺序」为底稿重排:第一次拖拽就把整组冻结成显式顺序, + // 之后 updatedAt 再变也不会把别的会话顶上来。 + const nextGroupSeq = reorderGroupSequence(groupIds, draggedId, targetId, place); + if (!nextGroupSeq) { + set({ draggingId: null, dropTarget: null }); + return; + } + const nextOrder = mergeGroupOrder(get().order, groupIds, nextGroupSeq, MAX_ORDER_LEN); + + const { userId } = get(); + set({ order: nextOrder, draggingId: null, dropTarget: null }); + writeLocal(userId, nextOrder); + void saveSidebarChatOrder(nextOrder).catch(() => { + /* 落库失败不回滚本地:顺序是纯偏好,本地已生效,下次拖拽会整表重传 */ + }); + }, + + resetOrder: () => { + const { userId } = get(); + set({ order: [], draggingId: null, dropTarget: null }); + writeLocal(userId, []); + void saveSidebarChatOrder([]).catch(() => { /* 同上,best-effort */ }); + }, +})); diff --git a/src/frontend/src/styles/chat.css b/src/frontend/src/styles/chat.css index eb84900..473c1b2 100644 --- a/src/frontend/src/styles/chat.css +++ b/src/frontend/src/styles/chat.css @@ -3229,6 +3229,16 @@ textarea.jx-composer{ } .jx-slashPopup-icon--plugin { color: var(--color-tint-purple); } .jx-slashPopup-icon--skill { color: var(--color-warning); } +/* 模式命令(/workflow 等):换一个色系,和技能/插件在一眼之内就能分开 */ +.jx-slashPopup-icon--mode { color: var(--color-tint-teal); } +/* 模式条目的说明文字,跟在名称后面;badge 仍然靠 margin-left:auto 顶到最右 */ +.jx-slashPopup-hint { + font-size: 11px; + color: var(--color-text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} /* ── ContentEditable editor — replaces textarea ── */ diff --git a/src/frontend/src/styles/mobile.css b/src/frontend/src/styles/mobile.css index 5c9475d..3c70a82 100644 --- a/src/frontend/src/styles/mobile.css +++ b/src/frontend/src/styles/mobile.css @@ -1,6 +1,22 @@ /* Mobile H5 shell and the approved HugAgentOS empty-chat composition. * Kept in a final stylesheet so legacy broad breakpoints cannot turn the * application layout into a full-width stacked sidebar. */ + +/* ── 安全区令牌 ────────────────────────────────────────────────────── + 刘海 / 圆角 / Home 条的避让统一走这四个变量,别在业务规则里裸写 env(): + ① 裸写时一旦漏了 viewport-fit=cover(见 index.html)整条规则静默失效,没有任何信号; + ② 变量形态可以在这里集中兜底——不支持 env() 的浏览器拿到 0px 而不是整条声明作废。 + 注意 env() 只有在 index.html 声明了 viewport-fit=cover 后才会返回非 0 值。 */ +:root{ + --jx-safe-t:env(safe-area-inset-top, 0px); + --jx-safe-r:env(safe-area-inset-right, 0px); + --jx-safe-b:env(safe-area-inset-bottom, 0px); + --jx-safe-l:env(safe-area-inset-left, 0px); + /* 触摸热区下限:Apple HIG 44pt / Material 48dp。视觉尺寸可以更小, + 但可点区域不得低于它——移动断点内的控件一律对齐这个值。 */ + --jx-touch-min:44px; +} + .jx-mobileHeader, .jx-mobileMenuBtn, .jx-mobileSidebarBackdrop, @@ -79,7 +95,7 @@ .jx-abilityCenter .jx-sk-detailHeading .jx-sk-version{ margin:2px 0 0; - color:#98A2B3; + color:var(--color-text-placeholder); font-family:inherit; font-size:12px; line-height:1.2; @@ -263,7 +279,7 @@ } .jx-projectCard-footer{ - color:#98A2B3; + color:var(--color-text-placeholder); font-size:12px; } @@ -280,7 +296,7 @@ flex-direction:column; width:100%; max-width:none; - padding:10px 20px calc(72px + env(safe-area-inset-bottom)); + padding:10px 20px calc(72px + var(--jx-safe-b)); gap:20px; } @@ -458,7 +474,7 @@ .jx-projectDetail-chatItemMeta{ margin-top:2px; - color:#98A2B3; + color:var(--color-text-placeholder); font-size:12px; line-height:1.45; } @@ -696,7 +712,7 @@ width:100%; height:100%; min-height:100%; - padding:0 18px max(16px, env(safe-area-inset-bottom)); + padding:0 18px max(16px, var(--jx-safe-b)); overflow:hidden; } @@ -781,7 +797,7 @@ justify-content:center; gap:7px; padding:0 8px; - border:1px solid #d7deea; + border:1px solid color-mix(in srgb, var(--color-text) 15%, transparent); border-radius:7px; background:var(--color-bg-container); color:var(--color-text); @@ -795,7 +811,7 @@ .jx-mobileQuickTask:hover, .jx-mobileQuickTask:focus-visible{ - border-color:#8db9ff; + border-color:color-mix(in srgb, var(--color-primary) 48%, transparent); background:var(--color-bg-layout); } @@ -830,7 +846,7 @@ .jx-emptyPage--main .jx-homeInput .jx-composerWrap{ height:90px; margin:0; - border:1px solid #c8d2e1; + border:1px solid color-mix(in srgb, var(--color-text) 18%, transparent); border-radius:18px; box-shadow:0 4px 18px rgba(57,91,138,.08); } @@ -849,7 +865,7 @@ .jx-emptyPage--main .jx-homeInput .jx-composerPlaceholder::before{ content:attr(data-mobile-placeholder); - color:#929bab; + color:var(--color-text-placeholder); font-size:15px; } @@ -1067,9 +1083,11 @@ .jx-mobileHeader{ z-index:40; flex-basis:68px; - height:68px; - padding:14px 20px 8px; - background:rgba(245,247,251,.86); + height:calc(68px + var(--jx-safe-t)); + padding:calc(14px + var(--jx-safe-t)) max(20px, var(--jx-safe-r)) 8px max(20px, var(--jx-safe-l)); + /* rgba(245,247,251) 正是浅色档的 --color-bg-layout,换成令牌后浅色像素不变、 + 深色不再是一条白板(原来暗底上顶着一条刺眼浅色横条)。 */ + background:color-mix(in srgb, var(--color-bg-layout) 86%, transparent); border-bottom:0; backdrop-filter:saturate(180%) blur(22px); -webkit-backdrop-filter:saturate(180%) blur(22px); @@ -1096,9 +1114,10 @@ } .jx-chatTopbar{ - min-height:64px; - padding:10px 16px; - background:rgba(245,247,251,.84); + min-height:calc(64px + var(--jx-safe-t)); + padding:calc(10px + var(--jx-safe-t)) max(16px, var(--jx-safe-r)) 10px max(16px, var(--jx-safe-l)); + /* 同 .jx-mobileHeader:浅色档 --color-bg-layout 就是 rgb(245,247,251),改令牌后深色跟随 */ + background:color-mix(in srgb, var(--color-bg-layout) 84%, transparent); border-bottom:1px solid color-mix(in srgb, var(--color-text) 5%, transparent); backdrop-filter:saturate(180%) blur(22px); -webkit-backdrop-filter:saturate(180%) blur(22px); @@ -1134,7 +1153,7 @@ } .jx-agentPage{ - padding:12px 20px max(56px, env(safe-area-inset-bottom)); + padding:12px 20px max(56px, var(--jx-safe-b)); } .jx-agentPage-header{ @@ -1354,7 +1373,7 @@ display:inline-flex; align-self:center; margin-left:auto; - color:#98A2B3; + color:var(--color-text-placeholder); font-size:15px; } @@ -1441,7 +1460,7 @@ } .jx-agentCreatePage{ - padding:12px 20px max(56px, env(safe-area-inset-bottom)); + padding:12px 20px max(56px, var(--jx-safe-b)); font-family:var(--font-family); } @@ -1666,3 +1685,323 @@ min-height:82px !important; } } + +/* ══════════════════════════════════════════════════════════════════════ + 移动端打磨:安全区落地 / iOS 聚焦缩放 / 触摸热区 / 触屏交互态 + ────────────────────────────────────────────────────────────────────── + 上面的样式解决的是「排得下」,这一段解决的是「用得顺」。四类问题都属于 + 桌面端不存在、因此在桌面上开发时拿不到任何反馈信号的盲区: + + 1. 安全区:iPhone 的 Home 条会盖住贴底元素。全站用得最多的输入口——对话输入区—— + 实测底边距视口仅 10px,发送键正落在 Home 条的上滑手势区里。 + 2. iOS 聚焦缩放:输入控件字号 < 16px 时 Safari 会放大整个视口,且失焦不还原, + 用户只能手动双指缩回。实测输入框是 15px,差 1px 就触发。 + 3. 触摸热区:移动断点内的控件是照桌面尺寸「压小」的(28–38px),低于 Apple HIG + 的 44pt 下限——这正是「看着适配了、点着老是点不中」的来源。做法是保留视觉尺寸、 + 只用 ::after 把可点区域撑到 44px,不让界面变胖。 + 4. 触屏交互态:hover 态在触屏上按下即粘住不散;系统默认的点击高亮方块会在圆角 + 控件外画出直角色块,与本站自己的 :active 反馈重复。 + ══════════════════════════════════════════════════════════════════════ */ + +/* ── 1. 安全区落地 ───────────────────────────────────────────────────── */ +@media (max-width: 960px){ + /* 对话页输入区:Home 条避让。此前只有首页空态 .jx-emptyPage--main 做了避让, + 真正天天用的会话输入区反而漏了。 */ + .jx-panel[data-panel="chat"] .jx-inputArea, + .jx-chatWrap{ + padding-bottom:max(10px, var(--jx-safe-b)); + } + + /* 横屏时刘海在左/右侧,贴边的正文与输入区都要让开 */ + .jx-chatWrap{ + padding-right:max(12px, var(--jx-safe-r)); + padding-left:max(12px, var(--jx-safe-l)); + } + + /* 侧栏抽屉铺满整屏高,上下都要避让;左缘贴屏幕边要让开横屏刘海 */ + .jx-appShell > .jx-sider .jx-siderInner{ + padding-top:var(--jx-safe-t); + padding-bottom:var(--jx-safe-b); + padding-left:var(--jx-safe-l); + } +} + +/* ── 2. iOS 聚焦自动缩放 ───────────────────────────────────────────── + iOS Safari 的规则很硬:聚焦时控件计算字号 < 16px 就缩放整个视口,且失焦后 + 不还原。所以移动断点内所有可聚焦的文本控件一律 ≥16px。这不是审美选择, + 是平台约束——改小回去就会复发。 */ +@media (max-width: 960px){ + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), + textarea, + select, + [contenteditable="true"], + .ant-input, + .ant-input-number-input, + .ant-select-selection-search-input{ + font-size:16px; + } + + /* 首页空态的输入框在前面被 !important 锁到 15px,必须同权覆盖 */ + .jx-emptyPage--main .jx-homeInput .jx-composerEditor, + .jx-emptyPage--main .jx-homeInput .jx-composerPlaceholder, + .jx-emptyPage--main .jx-homeInput .jx-composerPlaceholder::before{ + font-size:16px !important; + } + + .jx-composerEditor, + .jx-composerPlaceholder{ + font-size:16px; + } +} + +/* ── 3. 触摸热区 44pt ──────────────────────────────────────────────── + 统一手法:视觉尺寸不动,用 ::after 铺一张不可见的命中层撑到 44px。 + 横向扩张量按「控件宽 + gap」的节距取,相邻按钮的命中层正好相接不重叠—— + 重叠会让后一个按钮偷走前一个的边缘,比不扩还糟。 */ +@media (max-width: 960px){ + .jx-attachBtn, + .jx-promptHubBtn, + .jx-planModeBtn, + .jx-projectDropBtn, + .jx-modelEffortBtn, + .jx-modeSeatBtn, + .jx-searchBtn, + .jx-collapseBtn, + .jx-helpBtn, + .jx-msgActionBtn{ + position:relative; + } + + .jx-attachBtn::after, + .jx-promptHubBtn::after, + .jx-planModeBtn::after, + .jx-projectDropBtn::after, + .jx-modelEffortBtn::after, + .jx-modeSeatBtn::after, + .jx-searchBtn::after, + .jx-collapseBtn::after, + .jx-helpBtn::after, + .jx-msgActionBtn::after{ + content:''; + position:absolute; + top:50%; + left:50%; + width:max(100%, var(--jx-touch-min)); + height:var(--jx-touch-min); + transform:translate(-50%, -50%); + /* 纯命中层:不画任何东西,也不吃掉宿主按钮自己的 hover/active 反馈 */ + background:transparent; + border-radius:inherit; + } + + /* 命中层撑到 44px 后控件之间要留够节距,否则相邻命中层互相蚕食 + (后一个按钮会偷走前一个的边缘)。40px 视觉宽 + 6px 间距 = 46px 节距 > 44。 */ + + /* 工具条总高 50px 与编辑区的 padding-bottom 是写死耦合的(见 chat.css + .jx-composerEditor 的注释),所以不动总高、只把内衬压薄——42px 的内容高 + 刚好装下 40px 的控件,命中层多出的 2px 溢到内衬里,不占文本区。 */ + .jx-composerBar{ + gap:6px; + padding-top:4px; + padding-bottom:4px; + } + + .jx-attachBtn, + .jx-promptHubBtn, + .jx-planModeBtn{ + width:40px; + min-width:40px; + height:40px; + min-height:40px; + flex:0 0 40px; + } + + .jx-projectDropBtn, + .jx-emptyPage--main .jx-homeInput .jx-projectDropBtn{ + width:40px; + min-width:40px; + height:40px; + min-height:40px; + flex:0 0 40px; + } + + /* chat.css 用 `.jx-composerBar .jx-Btn{overflow:hidden}` 裁文字做省略号, + 这个 hidden 会连命中层一起裁掉——实测热区被压回按钮自身的 40×40。 + 解法是把裁剪下移到内部的文字 span:省略号照旧,命中层不再被裁。 + 选择器必须同为 0,2,0 才压得住原规则。 */ + .jx-composerBar .jx-projectDropBtn, + .jx-composerBar .jx-modelEffortBtn{ + overflow:visible; + } + + .jx-composerBar .jx-modelEffortBtn .jx-composerChip-label{ + min-width:0; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + } + + .jx-modelEffortBtn{ + height:40px; + min-height:40px; + } + + .jx-modeSeatBtn{ + min-height:40px; + } + + /* 发送键的视觉是里面的图标、按钮本身透明——直接撑到 44 不改观感 */ + .jx-sendBtn{ + width:var(--jx-touch-min); + height:var(--jx-touch-min); + flex:0 0 var(--jx-touch-min); + } + + /* 消息底部的复制/赞踩/分享一排图标此前 26px,是全站最难点中的一处。 + 38px 宽 + 6px 间距 = 44px 节距,命中层刚好相接不互相蚕食。 */ + .jx-msgActions{ + gap:6px; + } + + .jx-msgActionBtn{ + width:38px; + min-width:38px; + height:34px; + min-height:34px; + } + + /* 追问建议卡片:39px 高,差一点点 */ + .jx-followUpBtn{ + min-height:var(--jx-touch-min); + } + + /* 「思考过程 / 已调用 xxx」这排折叠开关只有 23px 高。它们是竖直堆叠的, + 命中层撑到 44 会互相蚕食,所以给真实高度而不是命中层。 */ + .jx-inlineSummary{ + min-height:34px; + } + + /* 侧栏抽屉:导航项与历史项是主要触摸目标,直接给足高度而不是靠命中层—— + 抽屉里纵向空间不紧张,撑高的同时节奏也更接近原生列表 */ + .jx-appShell > .jx-sider .jx-navItem{ + height:44px; + border-radius:14px; + } + + .jx-appShell > .jx-sider .jx-navSubItem{ + height:44px; + border-radius:14px; + } + + .jx-appShell > .jx-sider .jx-newChatBtn{ + height:44px; + } + + /* 历史项在 sidebar.css 与 chat.css 里各写了一份 height/min-height:36px, + 只覆盖 min-height 压不住那条 height——两个都要写。 */ + .jx-appShell > .jx-sider .jx-historyItem{ + height:44px; + min-height:44px; + flex-shrink:0; + } + + /* 分组折叠标题原 26px。它横向很长(267px),纵向补到 40 就足以稳稳点中, + 再往上加会让三个分组头把历史列表挤出屏幕。 */ + .jx-appShell > .jx-sider .jx-historyGroupHeader{ + min-height:40px; + } + + .jx-appShell > .jx-sider .jx-brandHomeBtn{ + min-height:44px; + } + + .jx-appShell > .jx-sider .jx-userInfoBtn{ + min-height:44px; + } + + /* 对话页顶栏是 flex 容器,标题一长就把汉堡键压窄(实测被压到 30px)。 + width 会被压缩,min-width 不会——两个都要写。 */ + .jx-mobileMenuBtn{ + width:var(--jx-touch-min); + min-width:var(--jx-touch-min); + height:var(--jx-touch-min); + min-height:var(--jx-touch-min); + flex:0 0 var(--jx-touch-min); + } + + .jx-searchBtn, + .jx-collapseBtn, + .jx-helpBtn{ + width:36px; + min-width:36px; + height:36px; + min-height:36px; + } + + /* 快捷任务胶囊:原 36px 太矮,点起来要瞄准 */ + .jx-mobileQuickTask{ + min-height:var(--jx-touch-min); + } +} + +/* ── 4. 触屏交互态 ─────────────────────────────────────────────────── + 用 `hover:none` 命中真触屏设备,而不是按视口宽度判断——窄窗口的桌面浏览器 + 不受影响,iPad 这类宽屏触摸设备也能覆盖到。 */ +@media (hover: none){ + /* 系统默认的灰色点击方块与本站自研的 :active 缩放反馈重复,且会在圆角控件外 + 画出直角色块。关掉它,交互反馈全部交给 :active。 */ + a, + button, + [role="button"], + .jx-navItem, + .jx-navSubItem, + .jx-historyItem, + .jx-mobileQuickTask, + .jx-msgActionBtn{ + -webkit-tap-highlight-color:transparent; + } + + /* 触屏上没有真正的 hover:手指按下会让 :hover 生效并一直粘住,直到点了别处 + 才散——表现为「点过的按钮一直亮着」。放大类的 hover 变换尤其明显 + (发送键点完卡在放大态),统一还原。 */ + .jx-sendBtn:hover, + .jx-projectCard:hover, + .jx-mobileQuickTask:hover{ + transform:none; + } +} + +/* ── 5. 抽屉滚动不串联 ────────────────────────────────────────────── + 抽屉里的历史列表滚到头后,手势会继续传给底下的页面(滚动链),在 iOS 上 + 表现为整个抽屉跟着橡皮筋位移。contain 把滚动关在容器里。 */ +@media (max-width: 960px){ + .jx-appShell > .jx-sider .jx-historyListWrap, + .jx-appShell > .jx-sider .jx-siderInner{ + overscroll-behavior:contain; + } +} + +/* ── 6. 首屏空态的纵向配比 ────────────────────────────────────────── + 原来 .jx-mobileQuickTasks 用 margin-top:auto 把「常用任务 + 输入框」整体推到 + 底部,而 hero 用固定的 margin-top 贴在顶部——富余空间于是全部堆成 hero 下方 + 的一个空洞(393×852 实测约 370px,占屏高 43%)。视觉上不像留白,像没加载完。 + + 改成让 hero 自己吃掉上下两侧的富余空间(auto 上 + auto 下各分一半),标题 + 就落在空白区的视觉中心,与底部的任务区、输入框形成三段式节奏。 + + 守卫条件用朝向而不是高度:横屏本来就没有富余空间(上面另有一套 max-height:700px + 的紧凑规则在管),去动它只会把内容挤出屏幕;而 360×640 这类矮的竖屏机同样会命中 + 那条高度规则,却是实打实需要这次配比修正的——所以这里按 orientation 判断。 */ +@media (max-width: 960px) and (orientation: portrait){ + .jx-emptyPage--main .jx-heroBg{ + /* 上下各分一半富余空间;clamp 退居为「空间不够时的最小上边距」 */ + margin-top:auto; + margin-bottom:auto; + padding-top:clamp(16px, 4dvh, 44px); + } + + /* 富余空间已由 hero 的 auto 边距吃掉,这里再留 auto 会把空白重新抽回下方 */ + .jx-emptyPage--main .jx-mobileQuickTasks{ + margin-top:0; + } +} diff --git a/src/frontend/src/styles/plan.css b/src/frontend/src/styles/plan.css index 58f8864..b54aec0 100644 --- a/src/frontend/src/styles/plan.css +++ b/src/frontend/src/styles/plan.css @@ -606,3 +606,102 @@ align-items:center; gap:6px; } + +/* ═══════════════════════════════════════════ + Job bar (JobProgressStrip) — 后台批量作业状态条, + 与 Plan bar 同族、同高度,可与之并存(上计划、下作业)。 + 颜色全部走令牌:深色模式靠 variables.css 的令牌覆盖生效, + 这里写死任何 hex 都会在深色下变成盲区。 + ═══════════════════════════════════════════ */ +.jx-jobStrip{ + display:flex; + align-items:center; + gap:8px; + height:40px; + padding:0 8px 0 12px; + margin:0 0 8px; + background:var(--color-bg-container); + border:1px solid var(--color-border); + border-radius:var(--radius-md); + box-shadow:0 1px 4px rgba(38,38,38,.04); + overflow:hidden; +} +.jx-jobStrip-ring{ flex:none; } +.jx-jobStrip-spin{ flex:none; font-size:14px; color:var(--color-primary); } + +/* 「后台作业」胶囊:这条状态条最重要的信息是"有东西在后台跑",先把它说清楚 */ +.jx-jobStrip-badge{ + flex:none; + padding:1px 7px; + border-radius:var(--radius-sm); + background:var(--color-primary-bg); + color:var(--color-primary); + font-size:11px; + font-weight:600; + line-height:18px; +} +.jx-jobStrip-title{ + flex:none; + max-width:34%; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + font-size:13px; + font-weight:600; + color:var(--color-text); +} +.jx-jobStrip-sep{ flex:none; color:var(--color-text-placeholder); } +.jx-jobStrip-count, +.jx-jobStrip-elapsed{ + flex:none; + font-family:var(--font-family-number); + font-size:12px; + color:var(--color-text-tertiary); + font-variant-numeric:tabular-nums; +} +.jx-jobStrip-fail{ + flex:none; + font-size:12px; + color:var(--color-error); + font-variant-numeric:tabular-nums; +} +.jx-jobStrip-cancel{ + flex:none; + margin-left:auto; + width:28px; + height:28px; + display:flex; + align-items:center; + justify-content:center; + border:none; + border-radius:var(--radius-sm); + background:none; + color:var(--color-text-tertiary); + cursor:pointer; +} +.jx-jobStrip-cancel:hover:not(:disabled){ + background:var(--color-bg-gray); + color:var(--color-error); +} +.jx-jobStrip-cancel:disabled{ opacity:.5; cursor:default; } +.jx-jobStrip-cancel:focus-visible{ + outline:2px solid var(--color-primary); + outline-offset:-2px; +} + +/* 没善终的作业:同一条形态,只换成告警色底——它已经不动了,别再假装在跑 */ +.jx-jobStrip--ended{ + background:var(--color-error-light); + border-color:var(--color-error-bg); +} +.jx-jobStrip-warn{ flex:none; font-size:14px; color:var(--color-error); } +/* 失败原因可能很长:让它独占剩余宽度并截断,完整文案交给 title 悬浮 */ +.jx-jobStrip-reason{ + flex:1 1 auto; + min-width:0; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + font-size:12px; + color:var(--color-text-tertiary); +} diff --git a/src/frontend/src/styles/sidebar.css b/src/frontend/src/styles/sidebar.css index 6162dc2..2792efc 100644 --- a/src/frontend/src/styles/sidebar.css +++ b/src/frontend/src/styles/sidebar.css @@ -577,6 +577,29 @@ } .jx-historyItem:hover{ background:var(--color-fill-hover); } .jx-historyItem.active{ background:var(--color-bg-container); border-color:transparent; } + +/* ── 手动拖拽排序 ── */ +/* 抓手光标只在 hover 时出现:静止态保持 pointer,避免暗示"这行只能拖不能点" */ +.jx-historyItem:hover:not(.editing){ cursor:grab; } +.jx-historyItem:active:not(.editing){ cursor:grabbing; } +.jx-historyItem--dragging{ + opacity:.4; + background:var(--color-fill-hover); +} +/* 落点提示线:贴在目标行的上/下边缘,2px 主色 */ +.jx-historyItem--dropBefore::before, +.jx-historyItem--dropAfter::after{ + content:''; + position:absolute; + left:6px; right:6px; + height:2px; + border-radius:2px; + background:var(--color-primary); + pointer-events:none; +} +/* 行本身是 overflow:hidden(高度动画需要),提示线只能画在框内,不能溢出到 -1px */ +.jx-historyItem--dropBefore::before{ top:0; } +.jx-historyItem--dropAfter::after{ bottom:0; } .jx-historyMain{ flex:1; min-width:0; display:flex; align-items:center; } .jx-historySkLine{ width:100%; diff --git a/src/frontend/src/types.ts b/src/frontend/src/types.ts index 9fbbd98..f41b535 100755 --- a/src/frontend/src/types.ts +++ b/src/frontend/src/types.ts @@ -474,6 +474,12 @@ export interface ChatItem { * (batch chats start in batch mode); false records that the user closed the mode from * the composer chip / "+" menu and continues as an ordinary conversation. */ batchModeActive?: boolean; + /** 历史标记:这段对话用过「工作流模式」(批量作业)。与 planChat/batchChat 同性质—— + * 用户离开模式后仍保留,便于在列表里认出这类会话;它本身不决定下一条消息怎么发。 */ + workflowChat?: boolean; + /** 这段对话当前的工作流模式开关(用户在 / 命令或「+」菜单里选的)。undefined 走历史默认, + * false 表示用户显式关掉了。只有它为 true 才会注册 run_job 并注入批量作业提示词。 */ + workflowModeActive?: boolean; /** Whether this chat was created via the site-building ("站点建站") entry (Lab → Sites) */ siteChat?: boolean; /** Automation task ID — set on virtual sidebar entries for automation tasks */ @@ -1330,6 +1336,35 @@ export interface Plan { updated_at: string; } +/* ───── 批量作业(工作流模式)───── */ + +/** 台账聚合:分母是 total,**别用 done 当分母**——查无/失败也是结算掉的。 */ +export interface JobStats { + total: number; + done: number; + pending: number; + failed: number; + not_found: number; + needs_review: number; + running: number; + settled: number; + remaining: number; +} + +export interface JobBrief { + job_id: string; + chat_id: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'; + stats: JobStats; + usage: { calls?: number; tokens?: number }; + budget_left: { calls_left?: number; tokens_left?: number; seconds_left?: number }; + error: string; + created_at?: string | null; + started_at?: string | null; + completed_at?: string | null; +} + /* ───── Config platform types ───── */ export interface UsageLogEntry { diff --git a/src/frontend/src/utils/chatMode.ts b/src/frontend/src/utils/chatMode.ts index 99a1731..67e3860 100644 --- a/src/frontend/src/utils/chatMode.ts +++ b/src/frontend/src/utils/chatMode.ts @@ -2,6 +2,7 @@ import type { ChatItem } from '../types'; type PlanModeChat = Pick; type BatchModeChat = Pick; +type WorkflowModeChat = Pick; /** Resolve the composer routing mode independently from the chat's historical plan marker. */ export function resolvePlanModeActive(chat?: PlanModeChat): boolean { @@ -18,6 +19,14 @@ export function resolveBatchModeActive(chat?: BatchModeChat): boolean { return chat.batchChat === true; } +/** 工作流模式(批量作业)同理:历史标记只是默认值,显式 workflowModeActive === false 表示用户关掉了。 + * 这是**用户显式触发**的模式——不触发就不注册 run_job、不注入批量提示,普通问答完全不受影响。 */ +export function resolveWorkflowModeActive(chat?: WorkflowModeChat): boolean { + if (!chat) return false; + if (typeof chat.workflowModeActive === 'boolean') return chat.workflowModeActive; + return chat.workflowChat === true; +} + /** History loading may restore the legacy default, but must respect an explicit user opt-out. */ export function shouldRestorePlanModeFromHistory(chat?: PlanModeChat): boolean { return chat?.planModeActive !== false; diff --git a/src/frontend/src/utils/sidebarOrder.ts b/src/frontend/src/utils/sidebarOrder.ts new file mode 100644 index 0000000..7a3582a --- /dev/null +++ b/src/frontend/src/utils/sidebarOrder.ts @@ -0,0 +1,74 @@ +/** + * 侧边栏对话列表「手动拖拽顺序」的纯计算部分。 + * + * 状态与持久化在 `stores/sidebarOrderStore.ts`,这里只放不碰浏览器 API 的算法, + * 方便 `scripts/test-sidebar-order.ts` 直接跑。 + */ + +/** 排序只需要这三个字段,automation 虚拟项同样适用 */ +export interface SidebarSortable { + id: string; + pinned?: boolean; + updatedAt?: number; +} + +/** + * 把 `draggedId` 挪到 `targetId` 的前/后,返回该分组的新顺序。 + * 传入的 `groupIds` 必须是该组**当前可见顺序**——第一次拖拽就是靠它把整组 + * 冻结成显式顺序,之后 updatedAt 再变也顶不动别人。 + * 参数不合法(不同一组 / 自己拖自己)返回 null,调用方据此放弃这次拖拽。 + */ +export function reorderGroupSequence( + groupIds: string[], + draggedId: string, + targetId: string, + place: 'before' | 'after', +): string[] | null { + if (draggedId === targetId) return null; + if (!groupIds.includes(draggedId) || !groupIds.includes(targetId)) return null; + + const withoutDragged = groupIds.filter((id) => id !== draggedId); + const targetIdx = withoutDragged.indexOf(targetId); + if (targetIdx < 0) return null; + const insertAt = place === 'before' ? targetIdx : targetIdx + 1; + return [ + ...withoutDragged.slice(0, insertAt), + draggedId, + ...withoutDragged.slice(insertAt), + ]; +} + +/** + * 把某个分组的新顺序并回全局顺序表:先摘掉该组的旧下标,再把新序列整体接到尾部。 + * 组间穿插不影响正确性——比较只发生在同组内部,只取相对下标。 + * 超长时从头部截断(头部是最久没动过的分组)。 + */ +export function mergeGroupOrder( + currentOrder: string[], + groupIds: string[], + nextGroupSeq: string[], + maxLen: number, +): string[] { + const groupSet = new Set(groupIds); + const rest = currentOrder.filter((id) => !groupSet.has(id)); + return [...rest, ...nextGroupSeq].slice(-maxLen); +} + +/** + * 侧边栏条目排序:置顶优先 → 手动顺序 → updatedAt 倒序。 + * 手动顺序里没有的条目(新会话 / 从没拖过)排在有手动顺序的之前,让新会话照常冒头。 + */ +export function compareSidebarItems( + a: SidebarSortable, + b: SidebarSortable, + manualIndex: Map, +): number { + const pinDiff = Number(!!b.pinned) - Number(!!a.pinned); + if (pinDiff !== 0) return pinDiff; + const ia = manualIndex.get(a.id); + const ib = manualIndex.get(b.id); + if (ia === undefined && ib === undefined) return (b.updatedAt || 0) - (a.updatedAt || 0); + if (ia === undefined) return -1; + if (ib === undefined) return 1; + return ia - ib; +} diff --git a/src/frontend/src/utils/streamSegments.ts b/src/frontend/src/utils/streamSegments.ts index 92875a6..957336b 100644 --- a/src/frontend/src/utils/streamSegments.ts +++ b/src/frontend/src/utils/streamSegments.ts @@ -1,4 +1,4 @@ -import type { MessageSegment } from '../types'; +import type { MessageSegment, SubagentStep } from '../types'; export interface DeferredThinkingTextFragment { content: string; @@ -85,6 +85,41 @@ export function appendThinkingContentBeforeTrailingText( return true; } +/** + * 子智能体子步骤的思考/正文增量归并——与主链路 + * `appendThinkingContentBeforeTrailingText` 同一条「迟到思考尾并回前块」规则。 + * + * 结构化 reasoning 通道的收尾增量常在正文首 token 之后才到达。原样按到达顺序 + * 追加,会在正文中间插进一个思考块,随后的正文增量又新开一个 content 段—— + * 子智能体卡片里就成了「思考 / 正文碎片 / 思考 / 正文碎片」交错,一句话被拦腰 + * 切开(正常会话已在 00a55638、808d4c92 修好,这里是同一形态)。 + * + * 规则:thinking 增量落在「思考块 → 正文段」尾部时并回那个思考块,正文段保持 + * 完整,后续正文增量继续追加同一段。 + */ +export function appendSubagentStepDelta( + steps: SubagentStep[], + kind: 'thinking' | 'content', + delta: string, +): void { + if (!delta) return; + + const lastIndex = steps.length - 1; + const last = steps[lastIndex]; + if (last?.kind === kind) { + steps[lastIndex] = { ...last, text: (last.text || '') + delta }; + return; + } + + const prev = steps[lastIndex - 1]; + if (kind === 'thinking' && last?.kind === 'content' && prev?.kind === 'thinking') { + steps[lastIndex - 1] = { ...prev, text: (prev.text || '') + delta }; + return; + } + + steps.push({ kind, text: delta }); +} + /** * 硬规则:工具卡/思考块不允许落在最终答案之后。把最后一个文本段之后的段 * 整体挪到它前面(保持相对顺序)。流收尾与历史重建共用,保证刷新前后一致。 diff --git a/src/frontend/src/utils/toolRefresh.ts b/src/frontend/src/utils/toolRefresh.ts new file mode 100644 index 0000000..9f8e553 --- /dev/null +++ b/src/frontend/src/utils/toolRefresh.ts @@ -0,0 +1,41 @@ +/** + * 管理类插件的写操作 → 需要重拉的那个 store。 + * + * 拆成独立纯模块有两个原因:一是它是数据不是行为,二是 chatStream.ts 依赖 antd/stores, + * 没法在 node 测试里直接 import,而这张表恰恰是最需要被钉住的东西—— + * 它已经错过两次:edit_skill 长期漏在表外,以及 agent/plugin 的写操作刷了 catalog + * 这个根本不包含它们的 store。 + * + * 三份列表来自三个不同接口,刷错了不会报错,只会静默无效: + * - 私有技能 → useCatalogStore ← GET /v1/catalog + * - 子智能体 → useAgentStore ← GET /v1/agents + * - 已装插件 → usePluginStore ← GET /v1/plugins/installed + * + * 注意:插件带来的技能/工具**不出现在** /v1/catalog(插件是整体绑定的单元), + * 所以装卸插件对 catalog 响应毫无影响,只有插件 store 会变。 + */ +export type RefreshTarget = 'catalog' | 'agents' | 'plugins'; + +export const MUTATING_TOOL_REFRESH: Record = { + // skill-manager → 用户的私有技能库 + register_skill: 'catalog', + install_from_marketplace: 'catalog', + delete_skill: 'catalog', + edit_skill: 'catalog', + // agent-manager → 用户的子智能体 + create_agent: 'agents', + edit_agent: 'agents', + delete_agent: 'agents', + install_market_agent: 'agents', + // plugin-manager → 用户已安装的插件 + install_plugin: 'plugins', + uninstall_plugin: 'plugins', + import_plugin: 'plugins', + set_plugin_enabled: 'plugins', +}; + +// 只读动词(search_ / list_ / get_ 开头)与"申请上架"不改任何列表,故返回 undefined。 +// 注意别在块注释里写 `search_*` 加斜杠的形式——那会提前闭合注释。 +export function refreshTargetForTool(bareToolName: string): RefreshTarget | undefined { + return MUTATING_TOOL_REFRESH[bareToolName]; +}