diff --git a/document/en/modules/chat.md b/document/en/modules/chat.md index 6d3a3dc..b3177e4 100644 --- a/document/en/modules/chat.md +++ b/document/en/modules/chat.md @@ -83,7 +83,7 @@ The stream terminates with `data: [DONE]`. Example frames: ``` data: {"type":"tool_call","tool_name":"internet_search","tool_display_name":"Web Search","tool_args":{"query":"Beijing IC industry"},"tool_id":"call_abc"} -data: {"type":"tool_result","tool_name":"internet_search","result":{...},"tool_id":"call_abc","citations":[{"id":"internet_search-1","title":"...","url":"...","snippet":"...","source_type":"internet"}]} +data: {"type":"tool_result","tool_name":"internet_search","result":{...},"tool_id":"call_abc","citations":[{"id":"e1","title":"...","url":"...","snippet":"...","source_type":"internet","item_index":0}]} data: {"type":"content","event":"ai_message","delta":"Based on the search results…","chat_id":"chat_x"} @@ -104,13 +104,16 @@ event, the frontend replaces the body in place, and the database stores only the reviewed final answer. It persists `ontology_governance` with the assistant message so the module remains available after a history refresh. -## Citation system +## Citation system (Evidence Anchors) -Citations make every fact in the answer traceable back to a specific tool result. The chain has three segments: +Citations make every fact in the answer traceable back to a specific tool result. Numbering authority belongs to a single backend source of truth — the model only **copies** ids, never computes them. The chain has four segments: -1. **Prompt contract**: the system prompt (fallback file `prompts/prompt_text/default/system/40_format.system.md`; the active DB version is authoritative at runtime) instructs the model to emit `[ref:tool_name-N]` markers when citing tool data, e.g. `[ref:internet_search-1]`, or `[ref:tool1-N][ref:tool2-M]` for multiple sources. -2. **Backend extraction**: every `tool_result` is normalized by `orchestration/citations.py` into `CitationItem` objects (`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type`). When the same tool is called multiple times in one turn, `extract_citations_with_offset()` keeps ids unique via a per-turn offset table. CE's `_SOURCE_TYPE_MAP` only defines `internet`, `knowledge_base`, and `database`. CE does not infer an industry-specific shape for unknown remote MCP results; the frontend renders them as generic JSON. EE adds its industry citation types separately. -3. **Frontend rendering**: citations ride on `tool_result` and `meta` events and are persisted with the message; `src/frontend/src/utils/citations.ts` parses inline markers with `/\[ref:([\w]+-\d+)\]/g`, `components/citation/CitationBadge.tsx` renders clickable badges, and `CitationMarkdownBlock` / `CitationHtmlBlock` handle in-body display. +1. **Anchor allocation & injection (backend middleware)**: `core/llm/middlewares.py::CitationAnchorMiddleware` hooks AgentScope 2.0's `on_acting` and, before a tool result reaches the model, calls `orchestration/citation_anchor.py` to extract → allocate → inject: each citable item gets a session-monotonic anchor id (`e1`, `e2`, … — unique across tools, calls, and turns; a new turn continues from the max anchor found in the chat's persisted messages), and `"cite_id": "e7"` is written into the result JSON in place (plain-text results get a trailing `[cite_id: e7]` line). **The allocator is bound to the agent instance** (`attach_allocator()` / `resolve_allocator()`), which is how the orchestrator and the middleware share one counter; the ContextVar is only a fallback for sub-agent chains, because `astream_chat_workflow` is an async generator whose context does not reach the task the agent actually runs in. Extraction degrades through four layers: tool-declared `__citations__` → the tool spec registry (`TOOL_SPECS` config: list paths + CN/EN field aliases) → a generic heuristic (unique dict-array field) → the whole result as one anchor. Operational tools (file writes, pin, etc. — `SKIP_TOOLS`) pass through untouched. Any exception passes the original result through — citations degrade, the conversation never breaks. +2. **Prompt contract**: the system prompt (fallback file `prompts/prompt_text/default/system/40_format.system.md`; the active DB version is authoritative at runtime) needs only one tool-count-independent rule: copy the `cite_id` annotated in the result verbatim into `[anchor text](cite:e7)` (or `[来源](cite:e7)` at sentence end); never self-number. +3. **Orchestration consumption**: each `tool_result` event calls `collect_citation_dicts()`, which fetches `CitationItem`s (`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type` / `item_index`) from the allocator registry keyed by `tool_id`; when no allocator is installed (legacy replay paths) it falls back to the old offset extraction in `orchestration/citations.py`. `source_type` values: CE ships `internet`, `knowledge_base`, and `database`; industry citation types such as `industry_news`, `ai_news`, `chain_info`, and `company_profile` are added by the Enterprise Edition (EE). +4. **Frontend rendering**: citations ride on `tool_result` and `meta` events and are persisted with the message (what is persisted is the annotated result, so replay/share shows the same numbering as generation). `components/citation/CitationMarkdownBlock.tsx` recognizes three marker forms in parallel — `[anchor text](cite:eN)` (rendered as a text link with a hover source card), `[[eN]]` (obsidian-style tolerance), and the legacy `[ref:tool_name-N]` (historical messages, rendered as a superscript badge). Tool cards show a matching `cite_id` chip on each item (`jx-tr-citeTag`), and tools without a dedicated renderer fall back to a generic list-card renderer. + +**Tool development convention**: a tool (in-house or MCP) that wants precise citation granularity should return a `__citations__` field in its JSON — `[{"title": "...", "url": "...", "snippet": "...", "source_type": "..."}, …]`, entries ordered to match the result body; the middleware adopts it verbatim and injects `cite_id` in place. Tools without the field fall back to registry config or heuristics — at worst the whole result becomes one anchor, so **every tool is citable by default**. See the citation-declaration section in [MCP tools](mcp-tools.md). ## Plan Mode diff --git a/document/en/modules/mcp-tools.md b/document/en/modules/mcp-tools.md index e52e144..443ec9a 100644 --- a/document/en/modules/mcp-tools.md +++ b/document/en/modules/mcp-tools.md @@ -179,6 +179,49 @@ The shared layer (root of `mcp_servers/`): Two iron rules: **stdout is reserved for the MCP protocol** (business logs go to stderr; server.py wraps calls in `contextlib.redirect_stdout` as a backstop), and **be tolerant of malformed LLM-generated arguments** (e.g. auto-unpacking when a dict lands in a string parameter). +## Declaring citations from a tool (`__citations__`) + +The platform's [citation system](chat.md) (evidence anchors) automatically extracts citable +items from every tool result before it reaches the model, allocates session-unique anchors +(`e1`, `e2`, …) and injects `cite_id` back into the result — **every tool is citable by +default**, with zero citation code. Extraction granularity, however, depends on the backend +recognizing your return shape, so when developing a new tool (in-house or MCP) follow this +priority order: + +1. **Result should be citable with precise granularity → return a `__citations__` field + in your JSON (recommended)**: + + ```json + { + "result": "…business payload…", + "__citations__": [ + {"title": "Source title", "url": "https://…", "snippet": "key excerpt", "source_type": "internet"}, + {"title": "Second source"} + ] + } + ``` + + - Entry order matches the result body (`item_index` records the declared order); + - `title` is strongly recommended (falls back to the tool display name); `url` / + `snippet` / `source_type` are optional; + - the middleware (`CitationAnchorMiddleware`) **adopts the declaration verbatim** and + injects `"cite_id": "eN"` into each entry in place; the model copies the id as-is. +2. **Standard list shape → one registry line**: list-style tools returning + `{"items": [{"title": …, "content": …}]}` just need an `items_paths` + field-alias + entry in `orchestration/citation_anchor.py::TOOL_SPECS` — per-item numbering with no + tool code change. +3. **Do nothing → automatic fallback**: a generic heuristic finds the unique dict-array + field at the top level (or one level under `result`) and numbers items; when nothing is + recognizable the whole result becomes a single anchor. +4. **Operational tools (file writes / publish / CRUD receipts) → add to `SKIP_TOOLS`**: + such results have no citation value; registering them avoids pointless anchor noise. + +Note: `__citations__` and `cite_id` are **platform-level conventions**, not MCP protocol +fields; third-party MCP servers can use them too (just return JSON). Injection happens +before the result enters the model context and before persistence; the frontend tool cards +render `cite_id` as per-item chips that match the `[anchor text](cite:eN)` references in +the answer body. + ## Backend client: connection pool & bare-name restoration The backend connects through AgentScope 2.0's `MCPClient`, centred on two files: diff --git a/document/zh-CN/modules/chat.md b/document/zh-CN/modules/chat.md index 91dc506..69ac57b 100644 --- a/document/zh-CN/modules/chat.md +++ b/document/zh-CN/modules/chat.md @@ -83,7 +83,7 @@ SSE follower:chat_run_executor.follow_run_as_sse() ``` data: {"type":"tool_call","tool_name":"internet_search","tool_display_name":"联网搜索","tool_args":{"query":"北京 集成电路 产业"},"tool_id":"call_abc"} -data: {"type":"tool_result","tool_name":"internet_search","result":{...},"tool_id":"call_abc","citations":[{"id":"internet_search-1","title":"...","url":"...","snippet":"...","source_type":"internet"}]} +data: {"type":"tool_result","tool_name":"internet_search","result":{...},"tool_id":"call_abc","citations":[{"id":"e1","title":"...","url":"...","snippet":"...","source_type":"internet","item_index":0}]} data: {"type":"content","event":"ai_message","delta":"根据检索结果……","chat_id":"chat_x"} @@ -94,13 +94,16 @@ data: [DONE] `meta` 之后,`chat_run_executor.py` 持久化助手消息、回填 artifact,并起后台任务生成追问问题(`orchestration/followups.py`,结果写进消息 `extra_data.follow_up_questions`,前端经 `GET /v1/chats/{chat_id}/messages/{message_id}/followups` 拉取)。本体事件在前端汇总为独立的“领域本体治理”模块,不再写入或显示在“思考过程”中。模型草稿保持逐 token 流式展示;委员会仅在实际修订答案时发送一次 `content_replace`,前端原位替换正文,数据库只保存评审后的最终答案。`ontology_governance` 随助手消息持久化,刷新历史会话后仍可回显。 -## 引用系统(Citations) +## 引用系统(Citations · 证据锚点) -引用让回答里的每个事实可溯源到具体工具结果,链路分三段: +引用让回答里的每个事实可溯源到具体工具结果。编号权收归后端唯一真源——模型只**复制**编号、不做任何计算,链路分四段: -1. **提示词约定**:系统提示词(`prompts/prompt_text/default/system/40_format.system.md` 的兜底版本,运行时以 DB 激活版本为准)要求模型引用工具数据时输出 `[ref:工具名-序号]` 标记,如 `[ref:internet_search-1]`、多来源并列 `[ref:tool1-N][ref:tool2-M]`。 -2. **后端抽取**:每个 `tool_result` 事件经 `orchestration/citations.py` 归一化为 `CitationItem`(`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type`)。同一回合内同一工具被多次调用时,`extract_citations_with_offset()` 用 per-turn 偏移表保证 id 不重复。CE 的 `_SOURCE_TYPE_MAP` 只内置 `internet`、`knowledge_base` 和 `database`;未知远程 MCP 结果不做行业结构猜测,前端统一展示通用 JSON。商业版 EE 另行扩展行业引用类型。 -3. **前端渲染**:citations 随 `tool_result` 与 `meta` 事件下发并随消息持久化;`src/frontend/src/utils/citations.ts` 用 `/\[ref:([\w]+-\d+)\]/g` 解析正文标记,`components/citation/CitationBadge.tsx` 渲染为可点击角标,`CitationMarkdownBlock` / `CitationHtmlBlock` 负责正文内嵌展示。 +1. **发号回注(后端中间件)**:`core/llm/middlewares.py::CitationAnchorMiddleware` 挂在 AgentScope 2.0 的 `on_acting` 钩子上,在工具结果回给模型前调用 `orchestration/citation_anchor.py` 完成 提取 → 发号 → 回注:为每条可引用条目分配会话内单调唯一的锚点 id(`e1`、`e2`、…,跨工具、跨调用、跨轮不重复,新一轮从该会话历史消息的最大锚点续号),并把 `"cite_id": "e7"` 就地写进结果 JSON(纯文本结果在文末追加 `[cite_id: e7]` 行)。**发号器绑在 agent 实例上**(`attach_allocator()` / `resolve_allocator()`)——编排层与中间件由此共享同一个计数器;ContextVar 只作子智能体链路的兜底,因为 `astream_chat_workflow` 是 async generator,其上下文与 agent 实际执行所在的 task 并不互通。提取按四层降级:工具自声明 `__citations__` → 工具规格注册表(`TOOL_SPECS` 配置,列表路径 + 中英字段别名)→ 通用启发式(唯一字典数组字段)→ 整份结果 1 个锚点;操作型工具(写文件、pin 等,`SKIP_TOOLS`)直接放行。任何异常原样放行、绝不阻断对话。 +2. **提示词约定**:系统提示词(`prompts/prompt_text/default/system/40_format.system.md` 的兜底版本,运行时以 DB 激活版本为准)只需一条与工具数量无关的通用规则:把结果里标注的 `cite_id` 原样复制进 `[锚文本](cite:e7)`(或句末 `[来源](cite:e7)`),禁止自行编号。 +3. **编排层消费**:每个 `tool_result` 事件经 `collect_citation_dicts()` 按 `tool_id` 从发号器注册表精确取 `CitationItem`(`id` / `tool_name` / `tool_id` / `title` / `url` / `snippet` / `source_type` / `item_index`);发号器缺位(旧对话回放等)时回退 `orchestration/citations.py` 的旧偏移提取。`source_type` 取值:CE 内置 `internet`、`knowledge_base`、`database`;`industry_news`、`ai_news`、`chain_info`、`company_profile` 等行业引用类型由商业版 EE 扩展。 +4. **前端渲染**:citations 随 `tool_result` 与 `meta` 事件下发并随消息持久化(落库的就是注号后的结果,回放/分享与生成时编号一致)。`components/citation/CitationMarkdownBlock.tsx` 并行识别三种标记——`[锚文本](cite:eN)`(渲染为带悬浮出处卡片的文字链接)、`[[eN]]`(obsidian 双链容错)、旧格式 `[ref:工具名-序号]`(历史消息,渲染为角标);工具卡片条目上同步显示 `cite_id` 小徽章(`jx-tr-citeTag`),没有专属渲染器的工具由通用列表渲染器兜底成标准卡片。 + +**工具开发约定**:需要精确控制引用粒度的工具(自研或 MCP),在返回 JSON 里带 `__citations__` 字段——`[{"title": "...", "url": "...", "snippet": "...", "source_type": "..."}, …]`,条目顺序与结果正文对应;中间件优先采用并就地注入 `cite_id`。未自声明的工具按注册表配置或启发式提取,最差整份结果 1 个锚点——**任何工具默认可引用**。详见《[MCP 工具](mcp-tools.md)》的引用声明一节。 ## 计划模式(Plan Mode) diff --git a/document/zh-CN/modules/mcp-tools.md b/document/zh-CN/modules/mcp-tools.md index 351a096..390d02f 100644 --- a/document/zh-CN/modules/mcp-tools.md +++ b/document/zh-CN/modules/mcp-tools.md @@ -156,6 +156,41 @@ mcp_servers/_mcp/ 两条铁律:**stdout 保留给 MCP 协议**(业务日志一律走 stderr,server.py 里用 `contextlib.redirect_stdout` 兜底);**对 LLM 生成的畸形参数保持容错**(如 dict 误塞进字符串参数时自动拆包)。 +## 工具引用声明(`__citations__`) + +平台的[引用系统](chat.md)(证据锚点)会在每个工具结果回给模型前自动提取可引用条目、 +分配全会话唯一锚点(`e1`、`e2`、…)并把 `cite_id` 回注进结果——**任何工具默认可引用**, +不写一行引用代码也能工作。但提取粒度取决于后端认不认识你的返回结构,因此开发新工具 +(自研工具或 MCP tool)时遵循以下优先级: + +1. **结果需要被引用、且希望精确控制粒度 → 返回 JSON 里带 `__citations__` 字段(推荐)**: + + ```json + { + "result": "……业务数据本体……", + "__citations__": [ + {"title": "来源标题", "url": "https://…", "snippet": "关键摘录", "source_type": "internet"}, + {"title": "第二个来源"} + ] + } + ``` + + - 条目顺序与结果正文对应(`item_index` 按声明顺序记录); + - `title` 必填倾向(缺省回退工具显示名),`url` / `snippet` / `source_type` 可选; + - 中间件(`CitationAnchorMiddleware`)会**优先采用**该声明,就地为每条注入 + `"cite_id": "eN"`,模型引用时原样复制。 +2. **标准列表结构 → 加一行注册表配置**:结果形如 `{"items": [{"title": …, "content": …}]}` + 的列表型工具,在 `orchestration/citation_anchor.py::TOOL_SPECS` 里登记 + `items_paths` + 字段别名即可逐条编号,不改工具代码。 +3. **什么都不做 → 自动兜底**:通用启发式能识别顶层(或 `result` 下一层)唯一的 + 字典数组字段并逐条编号;彻底认不出时整份结果作为 1 个锚点。 +4. **操作型工具(写文件 / 发布 / 增删改回执)→ 加进 `SKIP_TOOLS`**:这类结果没有 + 引用价值,登记跳过名单可免去无意义的锚点噪音。 + +注意:`__citations__` 与 `cite_id` 是**平台层约定**,不是 MCP 协议字段;第三方 MCP +Server 同样适用(返回 JSON 即可)。回注发生在结果进模型上下文与落库之前,前端工具卡片 +会把 `cite_id` 渲染成条目徽章,与正文 `[锚文本](cite:eN)` 引用一一对应。 + ## 后端客户端:连接池与裸名还原 后端基于 AgentScope 2.0 的 `MCPClient` 连接 MCP Server,核心在两个文件: diff --git a/src/backend/api/routes/v1/catalog.py b/src/backend/api/routes/v1/catalog.py index 1410188..057a9c9 100644 --- a/src/backend/api/routes/v1/catalog.py +++ b/src/backend/api/routes/v1/catalog.py @@ -131,10 +131,12 @@ def _plugin_component_ids(db) -> tuple: Union of two sources — both are required: 1. **DB install source**: ``AdminSkill/AdminMcpServer.source_plugin`` is non-null — written dynamically when a user installs a plugin. - 2. **Built-in manifest declaration**: ``components`` in - ``plugin_bundles/{default,marketplace}/*/plugin.json`` — MCPs of built-in plugins - (e.g. automation / skill-manager) go through ``_ports.py`` → catalog.json and - statically bubble up as first-class entries; the DB has no ``source_plugin`` row + 2. **Built-in bundle scan**: skills/MCP provided by + ``plugin_bundles/{default,marketplace}/*`` (derived from each bundle's + ``skills/*/`` dirs + MCP declarations; the Agent Plugins standard manifest + has no ``components`` list) — MCPs of built-in plugins (e.g. automation / + skill-manager) go through ``_ports.py`` → catalog.json and statically + bubble up as first-class entries; the DB has no ``source_plugin`` row for them, so source 1 alone cannot remove them. Filters **display** only; does not affect the enablement resolution of diff --git a/src/backend/api/routes/v1/plugins.py b/src/backend/api/routes/v1/plugins.py index 3b28a0a..893feec 100644 --- a/src/backend/api/routes/v1/plugins.py +++ b/src/backend/api/routes/v1/plugins.py @@ -11,6 +11,7 @@ POST /v1/plugins/import upload a .zip to import an external plugin (native/CC/Codex) DELETE /v1/plugins/installed/{id} uninstall PATCH /v1/plugins/installed/{id}/enable overall on/off switch +PATCH /v1/plugins/installed/{id}/meta edit my imported plugin's display metadata (name/category/icon) Permissions: browsing/viewing details is open to all logged-in users; **both installing from the marketplace and importing a zip require ``can_import_plugin``** (same as the skill marketplace's @@ -181,6 +182,33 @@ async def uninstall_plugin( return success_response(data=result) +class InstalledMetaRequest(BaseModel): + display_name: Optional[str] = Field(None, description="展示名") + category: Optional[str] = Field(None, description="分类") + icon: Optional[str] = Field(None, description="图标;空串=清除") + + +@router.patch("/installed/{install_id}/meta", summary="修改我导入插件的展示信息") +async def set_installed_meta( + install_id: str, + body: InstalledMetaRequest, + user: UserContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """展示信息(名称/分类/图标)是界面配置:用户自己导入/安装的私有插件由用户在此 + 配置;管理员全局插件走 admin 接口,普通用户不可改。""" + return success_response( + data=ps.set_installed_plugin_meta( + db, + install_id, + owner_user_id=str(user.user_id), + display_name=body.display_name, + category=body.category, + icon=body.icon, + ) + ) + + class EnableRequest(BaseModel): enabled: bool = Field(..., description="开/关") diff --git a/src/backend/core/channels/markdown.py b/src/backend/core/channels/markdown.py index bea16f4..5d75655 100644 --- a/src/backend/core/channels/markdown.py +++ b/src/backend/core/channels/markdown.py @@ -23,9 +23,14 @@ import re from typing import List -# Citation markers: [ref:internet_search-1] etc. (id shape: see orchestration/citations.py) +# 历史消息里的旧引用标记 [ref:internet_search-1](新格式见 orchestration/citation_anchor.py) _REF_RE = re.compile(r"\[ref:[^\[\]]{1,64}\]") +# 证据锚点引用(orchestration/citation_anchor.py): +# [锚文本](cite:e7) → 保留锚文本;[[e7]] / (cite:e7) 裸标 → 整体剥离 +_CITE_LINK_RE = re.compile(r"\[([^\[\]]{0,120})\]\(cite:e\d+\)") +_CITE_BARE_RE = re.compile(r"\[\[e\d+\]\]|\(cite:e\d+\)") + # Code fence lines (start with ``` / ~~~, may carry a language tag) _FENCE_RE = re.compile(r"^\s*(```|~~~)") @@ -34,11 +39,23 @@ def strip_citation_markers(text: str) -> str: - """Strip [ref:xxx-N] citation markers from LLM output (IM channels cannot render them; pure noise).""" + """Strip citation markers from LLM output (IM channels cannot render them; pure noise). + + 旧格式 ``[ref:xxx-N]`` 整体剥离;新证据锚点 ``[锚文本](cite:eN)`` 保留锚文本、 + 剥掉链接(IM 里退化成普通文字),裸标 ``[[eN]]``/``(cite:eN)`` 整体剥离。 + """ text = text or "" - if "[ref:" not in text: - return text - return _REF_RE.sub("", text) + if "[ref:" in text: + text = _REF_RE.sub("", text) + if "cite:e" in text or "[[e" in text: + # 纯句末标注("来源"/"source"/空)没有正文价值 → 整体剥离; + # 有实义锚文本的保留文字本身 + text = _CITE_LINK_RE.sub( + lambda m: "" if m.group(1).strip() in {"", "来源", "source", "#"} else m.group(1), + text, + ) + text = _CITE_BARE_RE.sub("", text) + return text def strip_inline_thinking(text: str) -> str: diff --git a/src/backend/core/config/user_intros.py b/src/backend/core/config/user_intros.py index 85c581f..6bbe43a 100644 --- a/src/backend/core/config/user_intros.py +++ b/src/backend/core/config/user_intros.py @@ -245,7 +245,7 @@ ## 输出示例 - 命中片段 + 文档标题 + 来源链接 - 综合多份材料后的归纳答复 -- [ref:retrieve_dataset_content-N] 格式的引用标注 +- `[锚文本](cite:eN)` 格式的证据锚点引用标注 - 检索覆盖范围说明(哪些知识库被检索) """, "internet_search": """\ diff --git a/src/backend/core/db/models/admin.py b/src/backend/core/db/models/admin.py index 01409a8..c2da07e 100644 --- a/src/backend/core/db/models/admin.py +++ b/src/backend/core/db/models/admin.py @@ -445,7 +445,8 @@ class InstalledPlugin(Base): version = Column(String(50), nullable=False, default="1.0.0") description = Column(Text, default="") category = Column(String(64), default="") - icon = Column(String(500)) + # Library path / URL / inline data-URI (uploaded custom icons, capped at ~200KB by the service layer) + icon = Column(Text) # NULL = global plugin (installed by admin, visible to all users); non-null = a user's private install. owner_user_id = Column(String(64), nullable=True) # builtin (built-in package) / imported_claude (imported CC plugin) / imported_codex (imported Codex plugin) @@ -492,7 +493,8 @@ class PluginMarketPackage(Base): version = Column(String(50), nullable=False, default="1.0.0") description = Column(Text, default="") category = Column(String(64), default="") - icon = Column(String(500)) + # Library path / URL / inline data-URI (uploaded custom icons, capped at ~200KB by the service layer) + icon = Column(Text) # Package kind: native / claude / codex (determined by normalize), display only kind = Column(String(16), nullable=False, default="native") skills_count = Column(Integer, nullable=False, default=0) diff --git a/src/backend/core/llm/agent_factory.py b/src/backend/core/llm/agent_factory.py index 1447370..35f90c3 100644 --- a/src/backend/core/llm/agent_factory.py +++ b/src/backend/core/llm/agent_factory.py @@ -24,6 +24,7 @@ from core.llm.mcp_pool import MCPConnectionPool from core.llm.middlewares import ( ActingToolCallIdMiddleware, + CitationAnchorMiddleware, AgentRuntimeState, DynamicModelMiddleware, FileContextMiddleware, @@ -1853,6 +1854,7 @@ def _build_toolkit() -> Toolkit: # almost all traffic takes. StallInterventionMiddleware(profile.intervention_rules), OntologyGateMiddleware(_ontology_runtime), # on_acting: zero-LLM L-a contract gate + CitationAnchorMiddleware(), # on_acting: 证据锚点——工具结果回给模型前发号回注 cite_id ActingToolCallIdMiddleware(), # on_acting: expose call_subagent's tool_call.id to tools (parent-child linkage) ] if not batch_mode: diff --git a/src/backend/core/llm/mcp_manager.py b/src/backend/core/llm/mcp_manager.py index f323221..65e93e5 100644 --- a/src/backend/core/llm/mcp_manager.py +++ b/src/backend/core/llm/mcp_manager.py @@ -63,7 +63,7 @@ class BareNameMCPClient(MCPClient): AgentScope 2.0's ``MCPTool`` adapter rewrites the outward-facing name to ``mcp____``, but this project's display-name mapping - (core/config/display_names), citation extraction (orchestration/citations + (core/config/display_names), citation extraction (orchestration/citation_anchor dispatches on bare names like ``internet_search``), catalog gating, tool references in system prompts and SKILL.md, and frontend icons/panels/renderers are all built on the 1.x bare names. ``MCPTool.__call__`` actually calls the diff --git a/src/backend/core/llm/middlewares.py b/src/backend/core/llm/middlewares.py index be511c5..83d1e29 100644 --- a/src/backend/core/llm/middlewares.py +++ b/src/backend/core/llm/middlewares.py @@ -29,7 +29,7 @@ from agentscope.message import Base64Source, DataBlock, Msg, TextBlock, ToolResultState from agentscope.middleware import MiddlewareBase from agentscope.state import AgentState -from agentscope.tool._response import ToolChunk +from agentscope.tool._response import ToolChunk, ToolResponse from core.llm.hooks import ( _FILE_ID_RE, _GOAL_ANCHOR_INTERVAL, @@ -94,6 +94,81 @@ async def on_acting(self, agent: Agent, input_kwargs: dict, next_handler): # no pass +class CitationAnchorMiddleware(MiddlewareBase): + """on_acting: 统一证据锚点——工具结果回给模型前完成 提取 → 发号 → cite_id 回注。 + + 协议依据(agentscope 2.0 toolkit.call_tool):中间 ToolChunk 是增量、最后一个 + yield 是**累积完整**的 ToolResponse;SSE 侧(orchestration/streaming.py)只在 + ToolResultEndEvent 时把 delta 累积成一条 tool_result,不实时消费中间增量。 + 因此这里缓冲中间块,在最终 ToolResponse 上注号后: + 1) 合成一个携带完整注号文本的 ToolChunk(SSE 累积到的就是注号后文本); + 2) 用注号文本替换 ToolResponse.content(模型上下文 / 落库拿到同一份)。 + 两侧看到的 cite_id 因此严格一致。跳过名单、非纯文本内容、非 SUCCESS 状态、 + 任何异常 → 原样放行(引用功能降级,绝不影响工具本身)。 + """ + + async def on_acting(self, agent: Agent, input_kwargs: dict, next_handler): # noqa: ANN001 + from orchestration.citation_anchor import ( + SKIP_TOOLS, + annotate_tool_result, + resolve_allocator, + ) + + tool_call = input_kwargs.get("tool_call") + tool_name = str(getattr(tool_call, "name", "") or "") + tool_id = str(getattr(tool_call, "id", "") or "") + if not tool_name or tool_name in SKIP_TOOLS: + async for item in next_handler(**input_kwargs): + yield item + return + + buffered: list = [] + final: ToolResponse | None = None + async for item in next_handler(**input_kwargs): + if isinstance(item, ToolResponse): + final = item + break # per call_tool protocol the ToolResponse is the last yield + buffered.append(item) + + if final is None: + for item in buffered: + yield item + return + + annotated = False + if final.state == ToolResultState.SUCCESS: + try: + blocks = list(final.content or []) + text_blocks = [b for b in blocks if isinstance(b, TextBlock)] + if text_blocks and len(text_blocks) == len(blocks): + # 发号器绑在 agent 上(run 入口注入);缺失时就地建并绑定, + # 保证编号在该 agent 的整条流里唯一 + allocator = resolve_allocator(agent) + full_text = "".join((b.text or "") for b in text_blocks) + new_text, items = annotate_tool_result( + tool_name, tool_id, full_text, allocator + ) + if items: + allocator.register(tool_id, items) + final.content = [TextBlock(type="text", text=new_text)] + yield ToolChunk( + content=[TextBlock(type="text", text=new_text)], + state=final.state, + metadata=dict(final.metadata or {}), + ) + annotated = True + except Exception: # noqa: BLE001 + logger.warning( + "[citation-anchor] middleware annotate failed tool=%s", tool_name, + exc_info=True, + ) + + if not annotated: + for item in buffered: + yield item + yield final + + class AgentRuntimeState(AgentState): """Extends AgentState to carry the runtime fields of the former ModelContext (replaces agent._jx_context).""" diff --git a/src/backend/core/services/plugin_importer.py b/src/backend/core/services/plugin_importer.py index ef721b9..661822b 100644 --- a/src/backend/core/services/plugin_importer.py +++ b/src/backend/core/services/plugin_importer.py @@ -29,6 +29,31 @@ SKILL_MD_NAME = "SKILL.md" +# Agent Plugins (agent-plugins.org) reverse-domain extension namespace for this +# platform. Standard-compliant manifests keep plugin.json top-level fields to the +# closed spec schema and carry platform-specific data (connection / admin_config / +# required_secrets / MCP display metadata) under extensions["org.hugagent"]. +# Lowercase on purpose: lowercase technical identifiers survive the CE brand +# transform unchanged. +EXTENSION_NAMESPACE = "org.hugagent" + + +def manifest_extensions(manifest: Dict[str, Any]) -> Dict[str, Any]: + """This platform's extension namespace object from a manifest ({} when absent).""" + ext = manifest.get("extensions") + if isinstance(ext, dict): + ns = ext.get(EXTENSION_NAMESPACE) + if isinstance(ns, dict): + return ns + return {} + + +def _ext_or_top(manifest: Dict[str, Any], ext: Dict[str, Any], key: str) -> Any: + """Read a platform field: extensions namespace first, legacy top-level as fallback.""" + if key in ext: + return ext.get(key) + return manifest.get(key) + # ── Unified intermediate representation ────────────────────────────────────── @@ -50,6 +75,7 @@ class NormalizedMcp: url: Optional[str] = None env_vars: Dict[str, str] = field(default_factory=dict) headers: Dict[str, str] = field(default_factory=dict) + cwd: Optional[str] = None # stdio working directory (Agent Plugins standard field) needs_runtime: bool = False # stdio → True: installed but disabled by default; enable only once the runtime is in place note: str = "" tools: List[Dict[str, Any]] = field(default_factory=list) # tool list the manifest may declare (display only, [{name,description}]) @@ -116,16 +142,18 @@ def _read_json(path: Path) -> Dict[str, Any]: def _rewrite_path_vars(text: str, *, skill_sandbox_dir: Optional[str] = None, plugin_sandbox_dir: str = "/workspace/plugins") -> str: - """Rewrite CC/Codex path variables to this platform's sandbox paths. + """Rewrite Agent Plugins / CC / Codex path variables to this platform's sandbox paths. - ${CLAUDE_PLUGIN_ROOT} / ${CODEX_PLUGIN_ROOT} → skill sandbox directory (in skill context) or the plugin directory - ${CLAUDE_PLUGIN_DATA} → /.data + ${PLUGIN_ROOT} (Agent Plugins standard) / ${CLAUDE_PLUGIN_ROOT} / ${CODEX_PLUGIN_ROOT} + → skill sandbox directory (in skill context) or the plugin directory + ${PLUGIN_DATA} (Agent Plugins standard) / ${CLAUDE_PLUGIN_DATA} → /.data ${CLAUDE_PROJECT_DIR} → /workspace ${user_config.X} → ${X} (environment variable / secret) ${ENV_VAR} → kept as-is """ root = skill_sandbox_dir or plugin_sandbox_dir out = text + out = out.replace("${PLUGIN_ROOT}", root).replace("${PLUGIN_DATA}", f"{root}/.data") out = out.replace("${CLAUDE_PLUGIN_ROOT}", root).replace("${CODEX_PLUGIN_ROOT}", root) out = out.replace("${CLAUDE_PLUGIN_DATA}", f"{root}/.data").replace("${CODEX_PLUGIN_DATA}", f"{root}/.data") out = out.replace("${CLAUDE_PROJECT_DIR}", "/workspace").replace("${CODEX_PROJECT_DIR}", "/workspace") @@ -167,7 +195,8 @@ def _discover_skills(plugin_dir: Path) -> List[NormalizedSkill]: # ── MCP auto-discovery ─────────────────────────────────────────────────────── def _find_mcp_map(plugin_dir: Path, manifest: Dict[str, Any]) -> Dict[str, Any]: - """Merge all MCP definition sources: root .mcp.json, mcp/servers.json, and mcpServers inside the manifest.""" + """Merge all MCP definition sources: root mcp.json (Agent Plugins standard), + root .mcp.json (CC/Codex), mcp/servers.json, and mcpServers inside the manifest.""" merged: Dict[str, Any] = {} def _merge(obj: Any) -> None: @@ -179,13 +208,14 @@ def _merge(obj: Any) -> None: if isinstance(v, dict): merged[str(k)] = v - # 1) Root .mcp.json (CC / Codex) - p = plugin_dir / ".mcp.json" - if p.is_file(): - try: - _merge(json.loads(p.read_text(encoding="utf-8"))) - except Exception as exc: # noqa: BLE001 - logger.warning("plugin .mcp.json broken: %s", exc) + # 1) Root mcp.json (Agent Plugins standard) / .mcp.json (CC / Codex) + for fname in ("mcp.json", ".mcp.json"): + p = plugin_dir / fname + if p.is_file(): + try: + _merge(json.loads(p.read_text(encoding="utf-8"))) + except Exception as exc: # noqa: BLE001 + logger.warning("plugin %s broken: %s", fname, exc) # 2) native: mcp/servers.json (array form [{server_id, ...}]) p2 = plugin_dir / "mcp" / "servers.json" if p2.is_file(): @@ -205,16 +235,23 @@ def _merge(obj: Any) -> None: def _normalize_mcp_entry(name: str, raw: Dict[str, Any]) -> NormalizedMcp: - """Single MCP definition → NormalizedMcp (incl. transport inference + path variable rewriting).""" + """Single MCP definition → NormalizedMcp (incl. transport resolution + path variable rewriting). + + Transport: the Agent Plugins standard ``type`` discriminator wins + (``stdio`` / ``streamable-http`` / ``sse``; legacy ``transport`` with + underscores is accepted too); without a recognized type, fall back to + inference (url present → HTTP, otherwise stdio). + """ url = raw.get("url") command = raw.get("command") - raw_type = str(raw.get("type") or raw.get("transport") or "").lower() + raw_type = str(raw.get("type") or raw.get("transport") or "").lower().replace("-", "_") - if url: + if url and raw_type != "stdio": transport = "sse" if raw_type == "sse" else "streamable_http" needs_runtime = False note = "" else: + # Explicit type=stdio, or no url (incl. malformed HTTP entries without url — safe fallback: install disabled) transport = "stdio" needs_runtime = True note = "stdio MCP:需运行时(node/python 等)+ 文件物化,默认装上即禁用" @@ -222,8 +259,9 @@ def _normalize_mcp_entry(name: str, raw: Dict[str, Any]) -> NormalizedMcp: env_vars = dict(raw.get("env") or raw.get("env_vars") or {}) headers = dict(raw.get("headers") or {}) args = list(raw.get("args") or []) + cwd = raw.get("cwd") - # Path variable rewriting (text values of command/args/url/env/headers) + # Path variable rewriting (text values of command/args/url/env/headers/cwd) plugin_dir_ph = f"/workspace/plugins/{name}" if command: command = _rewrite_path_vars(str(command), plugin_sandbox_dir=plugin_dir_ph) @@ -232,6 +270,7 @@ def _normalize_mcp_entry(name: str, raw: Dict[str, Any]) -> NormalizedMcp: url = _rewrite_path_vars(str(url), plugin_sandbox_dir=plugin_dir_ph) env_vars = {k: _rewrite_path_vars(str(v), plugin_sandbox_dir=plugin_dir_ph) for k, v in env_vars.items()} headers = {k: _rewrite_path_vars(str(v), plugin_sandbox_dir=plugin_dir_ph) for k, v in headers.items()} + cwd = _rewrite_path_vars(str(cwd), plugin_sandbox_dir=plugin_dir_ph) if cwd else None return NormalizedMcp( name=name, @@ -243,6 +282,7 @@ def _normalize_mcp_entry(name: str, raw: Dict[str, Any]) -> NormalizedMcp: url=url, env_vars=env_vars, headers=headers, + cwd=cwd, needs_runtime=needs_runtime, note=note, tools=[ @@ -255,13 +295,18 @@ def _normalize_mcp_entry(name: str, raw: Dict[str, Any]) -> NormalizedMcp: # ── userConfig / required_secrets normalization ────────────────────────────── -def _normalize_required_secrets(manifest: Dict[str, Any]) -> List[Dict[str, Any]]: +def _normalize_required_secrets( + manifest: Dict[str, Any], ext: Optional[Dict[str, Any]] = None +) -> List[Dict[str, Any]]: """Normalize native required_secrets / CC userConfig / Codex userConfig into [{key, label, required}] (the shape marketplace _inject_secrets expects). + + Standard-compliant manifests declare required_secrets under the extension + namespace; legacy top-level declarations are still honored. """ out: List[Dict[str, Any]] = [] # native: ["api_key", ...] or [{key,label,required}] - rs = manifest.get("required_secrets") + rs = _ext_or_top(manifest, ext or {}, "required_secrets") if isinstance(rs, list): for item in rs: if isinstance(item, str): @@ -341,13 +386,16 @@ def _slugify(value: str) -> str: return s or "plugin" -def _normalize_admin_config(manifest: Dict[str, Any]) -> Optional[Dict[str, Any]]: +def _normalize_admin_config( + manifest: Dict[str, Any], ext: Optional[Dict[str, Any]] = None +) -> Optional[Dict[str, Any]]: """Normalize the ``admin_config`` in the plugin manifest (admin-level provider credential declaration). Shape: {"mode":"any|all","group":...,"hint":...,"fields":[{key,label,secret,description}]}. - Missing or empty fields → None (this plugin needs no admin config). + Missing or empty fields → None (this plugin needs no admin config). Read from + the extension namespace first, legacy top-level as fallback. """ - ac = manifest.get("admin_config") + ac = _ext_or_top(manifest, ext or {}, "admin_config") if not isinstance(ac, dict): return None raw_fields = ac.get("fields") @@ -377,11 +425,21 @@ def _normalize_admin_config(manifest: Dict[str, Any]) -> Optional[Dict[str, Any] def normalize_plugin_dir(plugin_dir: Path) -> NormalizedPlugin: - """Read any plugin directory (native/CC/Codex) into a unified NormalizedPlugin.""" + """Read any plugin directory (native/CC/Codex) into a unified NormalizedPlugin. + + "native" means an Agent Plugins standard package (plugin.json restricted to + the closed spec schema, platform fields under extensions["org.hugagent"], + MCP in a standalone mcp.json). Legacy native manifests with platform fields + at the top level keep working as a compatibility fallback. + """ kind, manifest_path = detect_manifest(plugin_dir) manifest = _read_json(manifest_path) + ext = manifest_extensions(manifest) slug = _slugify(str(manifest.get("name") or plugin_dir.name)) + # Standard manifests carry no display fields (display_name/category/icon are + # UI-configured, seeded/overridden at the service layer); legacy top-level + # values are still honored for imported packages. name = str(manifest.get("display_name") or manifest.get("name") or slug) version = str(manifest.get("version") or "1.0.0") description = str(manifest.get("description") or "") @@ -392,11 +450,22 @@ def normalize_plugin_dir(plugin_dir: Path) -> NormalizedPlugin: skills = _discover_skills(plugin_dir) mcp_map = _find_mcp_map(plugin_dir, manifest) + # Per-server display metadata (display_name/description/tools) lives in the + # extension namespace under "mcp" — the standard mcp.json only carries + # transport config. Overlay fills gaps; transport config always wins. + ext_mcp_meta = ext.get("mcp") if isinstance(ext.get("mcp"), dict) else {} + for srv_name, meta in ext_mcp_meta.items(): + if isinstance(meta, dict) and srv_name in mcp_map: + merged = dict(mcp_map[srv_name]) + for k in ("display_name", "description", "tools"): + if k in meta and k not in merged: + merged[k] = meta[k] + mcp_map[srv_name] = merged mcp = [_normalize_mcp_entry(n, raw) for n, raw in sorted(mcp_map.items())] - required_secrets = _normalize_required_secrets(manifest) - admin_config = _normalize_admin_config(manifest) - connection = manifest.get("connection") + required_secrets = _normalize_required_secrets(manifest, ext) + admin_config = _normalize_admin_config(manifest, ext) + connection = _ext_or_top(manifest, ext, "connection") connection = str(connection).strip() if connection else None dropped = _collect_dropped(plugin_dir, manifest, kind) diff --git a/src/backend/core/services/plugin_service.py b/src/backend/core/services/plugin_service.py index 2ce2033..87aa6bd 100644 --- a/src/backend/core/services/plugin_service.py +++ b/src/backend/core/services/plugin_service.py @@ -59,7 +59,9 @@ from core.services.plugin_importer import ( NormalizedPlugin, NormalizedSkill, + _ext_or_top, _rewrite_path_vars, + manifest_extensions, normalize_plugin_dir, ) from sqlalchemy.orm import Session @@ -88,6 +90,136 @@ ) DEFAULT_BOOTSTRAP_MARKER_ID = "default_plugins_bootstrap_v1" +# ── Plugin market display metadata (display_name / category / icon) ────────── +# The Agent Plugins standard manifest carries no display fields — display +# metadata is UI configuration: admins edit it per market plugin in the admin +# console (stored as ContentBlock overrides), users edit their own imported +# plugins on the installed record. The seeds below are only the initial +# defaults for the builtin bundles (same pattern as marketplace_service's +# DEFAULT_SKILL_MARKET); entries for EE-only bundles are inert in CE (their +# bundle dirs are excluded from the CE tree). +PLUGIN_MARKET_META_BLOCK_ID = "plugin_market_meta" + +BUILTIN_PLUGIN_MARKET_META: Dict[str, Dict[str, Any]] = { + "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": "产业智能"}, + "sample-translator": {"display_name": "示例·快速翻译", "category": "办公效率"}, + "security-manager": {"display_name": "安全管理·系统自察", "category": "信息处理"}, + "sites": {"display_name": "站点·对话建站", "category": "信息处理"}, + "skill-manager": {"display_name": "技能管理", "category": "效率工具"}, + "yida": {"display_name": "宜搭低代码平台", "category": "办公协同"}, +} + +_META_KEYS = ("display_name", "category", "icon") + +# Icon value forms: built-in library path (/home/...), http(s) URL, or an inline +# data-URI from the picker's upload (raw image ≤80KB client-side → ≤~110KB base64). +MAX_ICON_LEN = 200_000 +MAX_ICON_URL_LEN = 500 + + +def _validate_icon(icon: str) -> str: + """Validate an icon value from the UI picker; returns the stripped value ('' = clear).""" + icon = (icon or "").strip() + if not icon: + return "" + if icon.startswith("data:"): + if not icon.startswith("data:image/"): + raise BadRequestError(message="图标 data URI 必须是 image 类型") + if len(icon) > MAX_ICON_LEN: + raise BadRequestError(message="图标过大(上传原图请控制在 80KB 以内)") + return icon + if not (icon.startswith("/") or icon.startswith("http://") or icon.startswith("https://")): + raise BadRequestError(message="图标须从图标库选择或上传(不支持任意文本)") + if len(icon) > MAX_ICON_URL_LEN: + raise BadRequestError(message="图标地址过长") + return icon + + +def _market_meta_overrides(db: Optional[Session]) -> Dict[str, Dict[str, Any]]: + """Admin display-metadata overrides for market plugins ({slug: {display_name, category, icon}}).""" + if db is None: + return {} + row = db.query(ContentBlock).filter(ContentBlock.id == PLUGIN_MARKET_META_BLOCK_ID).first() + payload = row.payload if row is not None and isinstance(row.payload, dict) else {} + return {k: v for k, v in payload.items() if isinstance(v, dict)} + + +def resolve_market_meta(db: Optional[Session], slug: str) -> Dict[str, Any]: + """Effective display metadata for one market plugin: DB override → builtin seed.""" + meta = dict(BUILTIN_PLUGIN_MARKET_META.get(slug) or {}) + override = _market_meta_overrides(db).get(slug) or {} + for k in _META_KEYS: + v = override.get(k) + if isinstance(v, str) and v.strip(): + meta[k] = v.strip() + return meta + + +def _overlay_market_meta(item: Dict[str, Any], meta: Dict[str, Any]) -> None: + """Apply effective display metadata onto a market list/detail dict (in place).""" + if meta.get("display_name"): + item["name"] = meta["display_name"] + if meta.get("category"): + item["category"] = meta["category"] + if meta.get("icon"): + item["icon"] = meta["icon"] + + +def set_market_meta( + db: Session, + slug: str, + *, + display_name: Optional[str] = None, + category: Optional[str] = None, + icon: Optional[str] = None, + updated_by: Optional[str] = None, +) -> Dict[str, Any]: + """Admin: set a market plugin's display metadata (display_name/category/icon). + + Only provided fields are written; passing an empty string clears the + override (falls back to the builtin seed). The slug must exist in the + market (filesystem bundle or uploaded DB package). + """ + if _resolve_plugin_dir(slug) is None and _market_row(db, slug) is None: + raise ResourceNotFoundError("plugin", slug) + row = db.query(ContentBlock).filter(ContentBlock.id == PLUGIN_MARKET_META_BLOCK_ID).first() + payload = dict(row.payload or {}) if row is not None else {} + entry = dict(payload.get(slug) or {}) + if icon is not None: + icon = _validate_icon(icon) + for key, val in (("display_name", display_name), ("category", category), ("icon", icon)): + if val is None: + continue + val = val.strip() + if val: + entry[key] = val + else: + entry.pop(key, None) + if entry: + payload[slug] = entry + else: + payload.pop(slug, None) + if row is not None: + row.payload = payload + flag_modified(row, "payload") + row.updated_by = updated_by or "admin" + else: + db.add( + ContentBlock( + id=PLUGIN_MARKET_META_BLOCK_ID, + payload=payload, + updated_by=updated_by or "admin", + ) + ) + db.commit() + logger.info("plugin_market_meta_set: slug=%s keys=%s", slug, sorted(entry.keys())) + return {"slug": slug, **resolve_market_meta(db, slug)} + def _iter_plugin_dirs(): """Iterate over all plugin bundle directories containing plugin.json under default + marketplace.""" @@ -321,6 +453,9 @@ def _apply_mcp( source_plugin=slug, updated_at=now, ) + if getattr(mc, "cwd", None): + # stdio working directory (Agent Plugins standard field) — kept for when the runtime lands + fields["extra_config"] = {"cwd": mc.cwd} if existing is not None: for key, val in fields.items(): setattr(existing, key, val) @@ -443,13 +578,16 @@ def _apply_normalized( ) now = datetime.utcnow() + # Display metadata for the installed record: UI-configured market metadata + # (DB override → builtin seed) wins over whatever the manifest carried. + market_meta = resolve_market_meta(db, np.slug) fields = dict( slug=np.slug, - name=np.name, + name=market_meta.get("display_name") or np.name, version=np.version, description=np.description, - category=np.category, - icon=np.icon, + category=market_meta.get("category") or np.category, + icon=market_meta.get("icon") or np.icon, owner_user_id=owner_user_id, source=source, component_ids=component_ids, @@ -515,35 +653,39 @@ def _refresh_after_change(owner_user_id: Optional[str]) -> None: def builtin_plugin_component_ids() -> Tuple[set, set]: - """Component ids declared in plugin.json by builtin plugin bundles (``plugin_bundles/{default,marketplace}``). - - Returns ``(skill_ids, mcp_ids)`` — the union of all plugin manifests' - ``components.skills`` / ``components.mcp``. Even when **not installed**, - these components already bubble up as first-class entries via static paths - (e.g. MCP via ``_ports.py`` → catalog.json), so this is used to remove them - from the "skill library / MCP tool library" and show them only under - "Plugins", complementing the DB installation source + """Component ids provided by builtin plugin bundles (``plugin_bundles/{default,marketplace}``). + + Returns ``(skill_ids, mcp_ids)``, derived by scanning each bundle's + ``skills/*/`` directories and its MCP declarations (standard ``mcp.json`` / + legacy ``.mcp.json`` / manifest-inline) — the Agent Plugins standard + manifest carries no ``components`` list, the filesystem is the truth. Even + when **not installed**, these components already bubble up as first-class + entries via static paths (e.g. MCP via ``_ports.py`` → catalog.json), so + this is used to remove them from the "skill library / MCP tool library" and + show them only under "Plugins", complementing the DB installation source (``AdminMcpServer.source_plugin`` non-empty). Pure filesystem scan, no DB dependency. """ import json + from core.services.plugin_importer import _find_mcp_map + skill_ids: set = set() mcp_ids: set = set() for child in _iter_plugin_dirs(): + skills_root = child / "skills" + if skills_root.is_dir(): + for c in skills_root.iterdir(): + if c.is_dir() and (c / "SKILL.md").is_file(): + skill_ids.add(c.name) try: m = json.loads((child / "plugin.json").read_text(encoding="utf-8")) except Exception: # noqa: BLE001 - continue - comps = m.get("components") if isinstance(m, dict) else None - if not isinstance(comps, dict): - continue - for sid in comps.get("skills") or []: - if isinstance(sid, str) and sid: - skill_ids.add(sid) - for mid in comps.get("mcp") or []: - if isinstance(mid, str) and mid: - mcp_ids.add(mid) + m = {} + try: + mcp_ids.update(_find_mcp_map(child, m if isinstance(m, dict) else {}).keys()) + except Exception: # noqa: BLE001 + pass return skill_ids, mcp_ids @@ -551,7 +693,12 @@ def builtin_plugin_component_ids() -> Tuple[set, set]: def _scan_native_manifest(plugin_dir: Path) -> Optional[Dict[str, Any]]: - """Lightweight read of a builtin plugin bundle's display metadata (no full normalize).""" + """Lightweight read of a builtin plugin bundle's metadata (no full normalize). + + Standard manifests carry no display fields — display metadata is overlaid + later from resolve_market_meta; platform fields are read from the extension + namespace (legacy top-level as fallback). + """ import json mp = plugin_dir / "plugin.json" @@ -563,12 +710,14 @@ def _scan_native_manifest(plugin_dir: Path) -> Optional[Dict[str, Any]]: return None if not isinstance(m, dict) or not m.get("name"): return None + ext = manifest_extensions(m) skills_root = plugin_dir / "skills" skills_count = ( sum(1 for c in skills_root.iterdir() if c.is_dir() and (c / "SKILL.md").is_file()) if skills_root.is_dir() else 0 ) + admin_config = _ext_or_top(m, ext, "admin_config") return { "slug": _sanitize_id(str(m.get("name")), 100), "name": str(m.get("display_name") or m.get("name")), @@ -577,9 +726,9 @@ def _scan_native_manifest(plugin_dir: Path) -> Optional[Dict[str, Any]]: "category": str(m.get("category") or ""), "icon": m.get("icon"), "skills_count": skills_count, - "required_secrets": list(m.get("required_secrets") or []), - "has_admin_config": isinstance(m.get("admin_config"), dict) - and bool((m.get("admin_config") or {}).get("fields")), + "required_secrets": list(_ext_or_top(m, ext, "required_secrets") or []), + "has_admin_config": isinstance(admin_config, dict) + and bool((admin_config or {}).get("fields")), } @@ -606,6 +755,15 @@ def list_plugins( continue items.append(_market_meta_dict(row)) seen_slugs.add(row.slug) + # Display metadata is UI configuration: DB override → builtin seed → manifest fallback + overrides = _market_meta_overrides(db) + for it in items: + meta = dict(BUILTIN_PLUGIN_MARKET_META.get(it["slug"]) or {}) + for k in _META_KEYS: + v = (overrides.get(it["slug"]) or {}).get(k) + if isinstance(v, str) and v.strip(): + meta[k] = v.strip() + _overlay_market_meta(it, meta) # Subtract skills the admin removed from the marketplace (aggregated once per slug) so skills_count reflects the real offering excl_by_slug: Dict[str, set] = {} for ex_slug, ex_name in db.query( @@ -896,7 +1054,7 @@ def _connection_for_slug(slug: str) -> Optional[str]: import json m = json.loads((plugin_dir / "plugin.json").read_text(encoding="utf-8")) - conn = m.get("connection") + conn = _ext_or_top(m, manifest_extensions(m), "connection") return str(conn).strip() if conn else None except Exception: # noqa: BLE001 return None @@ -911,7 +1069,7 @@ def _has_admin_config_for_slug(slug: str) -> bool: import json m = json.loads((plugin_dir / "plugin.json").read_text(encoding="utf-8")) - ac = m.get("admin_config") + ac = _ext_or_top(m, manifest_extensions(m), "admin_config") return isinstance(ac, dict) and bool(ac.get("fields")) except Exception: # noqa: BLE001 return False @@ -1032,7 +1190,9 @@ def get_plugin_detail(slug: str, db: Optional[Session] = None) -> Dict[str, Any] detail. """ np = _normalize_market_plugin(slug, db) - return _normalized_to_detail(np, excluded=get_market_skill_exclusions(db, slug)) + detail = _normalized_to_detail(np, excluded=get_market_skill_exclusions(db, slug)) + _overlay_market_meta(detail, resolve_market_meta(db, slug)) + return detail def exclude_market_skill( @@ -1544,3 +1704,41 @@ def set_plugin_enabled_for_user( _refresh_after_change(user_id) return {"install_id": install_id, "enabled": enabled} + + +def set_installed_plugin_meta( + db: Session, + install_id: str, + *, + owner_user_id: Optional[str], + display_name: Optional[str] = None, + category: Optional[str] = None, + icon: Optional[str] = None, +) -> Dict[str, Any]: + """Edit an installed plugin's display metadata (display_name/category/icon). + + Display metadata is UI configuration, not manifest data: a user edits their + own imported/private plugins here; global installs are edited by the admin + (owner_user_id=None via the admin route). Only provided fields change; an + empty icon/category clears it, display_name never becomes empty. + """ + 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 != owner_user_id: + raise BadRequestError(message="无权修改该插件") + if display_name is not None and display_name.strip(): + row.name = display_name.strip() + if category is not None: + row.category = category.strip() + if icon is not None: + row.icon = _validate_icon(icon) or None + row.updated_at = datetime.utcnow() + db.commit() + return { + "install_id": row.install_id, + "slug": row.slug, + "name": row.name, + "category": row.category or "", + "icon": row.icon, + } 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 efb6fc8..13e3495 100755 --- a/src/backend/mcp_servers/retrieve_dataset_content_mcp/server.py +++ b/src/backend/mcp_servers/retrieve_dataset_content_mcp/server.py @@ -151,9 +151,9 @@ def _get_header(ctx: Optional[Context], name: str) -> Optional[str]: _BASE_TOOL_DESCRIPTION = """从"知识库/数据集"检索政策文件、报告、非结构化文本片段。默认自动搜索所有可用数据集。 ⚠️ 【必须遵守的引用规则】 -回答中引用本工具返回的任何内容时,**必须**在引用句末尾加上 `[ref:retrieve_dataset_content-N]` 标记(N 为 items 列表中的序号,从1开始)。 +回答中引用本工具返回的任何内容时,**必须**带引用标记:把该条目自带的 `cite_id`(如 `e7`)原样复制进 `[锚文本](cite:e7)` 或句末 `[来源](cite:e7)`,禁止自行编号。 不带引用标记的回答视为不完整,前端将无法展示引用来源卡片。 -示例:根据报告,2024年工业增加值增速为5.2%[ref:retrieve_dataset_content-1]。 +示例:根据报告,2024年工业增加值增速为5.2%[来源](cite:e7)。 适用场景(当用户问题涉及以下内容时,应**主动**调用本工具,无需等待用户显式要求): - 政策文件原文、解读、申报条件 @@ -337,9 +337,9 @@ async def list_datasets( _BASE_LOCAL_KB_TOOL_DESCRIPTION = """从用户私有知识库中检索相关内容。 ⚠️ 【必须遵守的引用规则】 -回答中引用本工具返回的任何内容时,**必须**在引用句末尾加上 `[ref:retrieve_local_kb-N]` 标记(N 为 items 列表中的序号,从1开始)。 +回答中引用本工具返回的任何内容时,**必须**带引用标记:把该条目自带的 `cite_id`(如 `e7`)原样复制进 `[锚文本](cite:e7)` 或句末 `[来源](cite:e7)`,禁止自行编号。 不带引用标记的回答视为不完整,前端将无法展示引用来源卡片。 -示例:项目总投资额为3.5亿元[ref:retrieve_local_kb-1]。 +示例:项目总投资额为3.5亿元[来源](cite:e7)。 适用场景(当用户问题涉及以下内容时,应**主动**调用本工具,无需等待用户显式要求): - 用户私人上传的文档(项目材料、个人笔记、专属报告等) @@ -672,10 +672,10 @@ async def wiki_expand( 这是按 ID 直接取回,不是再检索一次——所以既快又不会取错段落。 ⚠️ 【必须遵守的引用规则】 -回答中引用本工具返回的任何内容时,**必须**在引用句末尾加上 `[ref:wiki_fetch_source-N]` -标记(N 为 items 列表中的序号,从 1 开始)。不带引用标记的回答视为不完整,前端将 -无法展示引用来源卡片。 -示例:《运营资质证书》有效期为五年[ref:wiki_fetch_source-1]。 +回答中引用本工具返回的任何内容时,**必须**带引用标记:把该条目自带的 `cite_id` +(如 `e7`)原样复制进 `[锚文本](cite:e7)` 或句末 `[来源](cite:e7)`,禁止自行编号。 +不带引用标记的回答视为不完整,前端将无法展示引用来源卡片。 +示例:《运营资质证书》有效期为五年[来源](cite:e7)。 **作答的事实依据必须来自本工具返回的原文**,不要用 Wiki 页面的概述代替原文。 diff --git a/src/backend/orchestration/autonomous_loop.py b/src/backend/orchestration/autonomous_loop.py index b52495a..c819d3d 100644 --- a/src/backend/orchestration/autonomous_loop.py +++ b/src/backend/orchestration/autonomous_loop.py @@ -333,7 +333,11 @@ async def _run_worker_iteration( tool_calls = 0 trace: List[Dict[str, Any]] = [] citations: List[Dict[str, Any]] = [] - citation_offsets: Dict[str, int] = {} + # 证据锚点:每轮 worker 一个独立发号器,绑到 agent 上与中间件共享 + from orchestration.citation_anchor import AnchorAllocator, attach_allocator + + _anchor_allocator = AnchorAllocator() + attach_allocator(agent, _anchor_allocator) from core.ontology.validator import requires_output_review runtime = ontology_runtime or {"enabled": False, "packs": [], "review_level": "none"} @@ -407,16 +411,10 @@ async def _run_worker_iteration( else tool_content ) if isinstance(parsed_result, dict): - from orchestration.citations import extract_citations_with_offset + from orchestration.citation_anchor import collect_citation_dicts citations.extend( - item.to_dict() - for item in extract_citations_with_offset( - tool_name or "unknown", - tool_id or "", - parsed_result, - citation_offsets, - ) + collect_citation_dicts(tool_id or "", _anchor_allocator) ) except (json.JSONDecodeError, TypeError, ValueError): pass diff --git a/src/backend/orchestration/batch_orchestrator.py b/src/backend/orchestration/batch_orchestrator.py index 899b96a..e346d82 100644 --- a/src/backend/orchestration/batch_orchestrator.py +++ b/src/backend/orchestration/batch_orchestrator.py @@ -355,18 +355,19 @@ async def _run_item_via_workflow( from core.config.display_names import TOOL_DISPLAY_NAMES from core.llm import workspace as _workspace_mod from core.llm.message_compat import extract_text_from_chat_response - from orchestration.citations import extract_citations_with_offset + from orchestration.citation_anchor import ( + AnchorAllocator, + attach_allocator, + collect_citation_dicts, + ) from orchestration.streaming import StreamingAgent tool_calls_log: List[Dict[str, Any]] = [] artifacts: List[Dict[str, Any]] = [] citations: List[Dict[str, Any]] = [] - # When the same tool is called multiple times within one item, citation ids restart - # from 1 (see the ``-`` naming in routing/citations.py). Without an - # offset, ``internet_search-1`` would appear repeatedly in the citations list, and - # downstream id-based lookup/dedup would mis-locate or drop entries. The main chat path - # (routing/workflow.py) uses the same extract_citations_with_offset helper; shared here. - citation_offsets: Dict[str, int] = {} + # 证据锚点:每个批量 item 一个独立发号器(item 间互不续号,item 内全局唯一); + # 中间件在结果回给模型前注号并登记,这里按 tool_id 精确取回 + _anchor_allocator = AnchorAllocator() from core.services.ontology_service import build_user_ontology_runtime ontology_enabled, ontology_runtime = build_user_ontology_runtime( @@ -402,6 +403,8 @@ async def _run_item_via_workflow( sandbox_session_id="", ontology_runtime=ontology_runtime, ) + # 证据锚点:发号器绑到 agent,中间件与本函数共享同一计数器 + attach_allocator(agent, _anchor_allocator) streaming_agent = StreamingAgent(agent, clients) # In a ReAct loop the agent emits multiple rounds of text deltas: @@ -471,13 +474,8 @@ async def _run_item_via_workflow( for ref in refs: ref["tool_name"] = tool_name or "" _extend_collected_artifacts(artifacts, refs) - # Citations (KB hits, internet search). The offset logic shares - # extract_citations_with_offset with the main chat path, avoiding - # id collisions when the same tool is called multiple times. - cit_items = extract_citations_with_offset( - tool_name, tool_id, tool_result_json, citation_offsets - ) - citations.extend([c.to_dict() for c in cit_items]) + # Citations (KB hits, internet search):按 tool_id 从发号器注册表精确取 + citations.extend(collect_citation_dicts(tool_id, _anchor_allocator)) elif event_type == "error": if isinstance(payload, BaseException): raise payload diff --git a/src/backend/orchestration/citation_anchor.py b/src/backend/orchestration/citation_anchor.py new file mode 100644 index 0000000..10304c3 --- /dev/null +++ b/src/backend/orchestration/citation_anchor.py @@ -0,0 +1,524 @@ +"""统一证据锚点(Evidence Anchor):引用编号的唯一真源。 + +设计(见 internal design docs): +- 编号权收归后端 —— ``AnchorAllocator`` 按会话内单调计数发 ``e1、e2、…`` 全局锚点; + 模型只负责把工具结果里标注的 ``cite_id`` 原样复制进 ``[锚文本](cite:eN)``,不做计算。 +- ``CitationAnchorMiddleware``(core/llm/middlewares.py)在工具结果回给模型前调用 + :func:`annotate_tool_result` 完成 提取 → 发号 → 回注,并把 :class:`AnchorCitation` + 登记进 allocator;workflow / batch / autonomous 按 tool_id 从 allocator 精确取引用。 + 发号器是唯一编号方——旧的 per-tool 偏移提取(orchestration/citations.py)已随本方案删除。 +- 提取四层降级:工具自声明 ``__citations__`` → 工具规格注册表(TOOL_SPECS 配置, + 加工具只加配置不改代码)→ 通用启发式(唯一字典数组字段)→ 整份结果 1 个锚点。 + 操作型工具(写文件 / pin / 任务增删改等)在 SKIP_TOOLS 里直接放行不发号。 + +约定(工具开发规范):需要精确控制引用粒度的工具,返回 JSON 里带 +``"__citations__": [{"title": …, "url": …, "snippet": …, "source_type": …}, …]``, +条目顺序与结果正文对应;中间件会就地为每条注入 ``cite_id`` 并登记。 +""" + +from __future__ import annotations + +import json +import logging +import re +from contextvars import ContextVar +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +logger = logging.getLogger(__name__) + +ANCHOR_RE = re.compile(r"^e(\d+)$") + + +# ── 引用条目 ──────────────────────────────────────────────────────────────── + + +@dataclass +class AnchorCitation: + """带全局锚点的引用条目(CitationItem 的超集,dict 形态向后兼容)。""" + + id: str # 全局锚点,如 "e7" + tool_name: str + tool_id: Optional[str] + title: str + url: str + snippet: str + source_type: str + item_index: int = -1 # 条目在该次工具结果列表中的 0-based 下标;整体型为 -1 + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +# ── 工具规格注册表(配置即真源;新工具加一行,不改代码) ───────────────────── + +# 标题/链接/摘要字段别名(按序取第一个非空;覆盖存量工具的中英文键名) +DEFAULT_TITLE_KEYS: Tuple[str, ...] = ( + "title", + "标题", + "文件名称", + "企业名称", + "产品名称", + "name", + "名称", + "document_name", +) +DEFAULT_URL_KEYS: Tuple[str, ...] = ("url", "链接", "link", "href") +DEFAULT_SNIPPET_KEYS: Tuple[str, ...] = ( + "content", + "文件内容", + "snippet", + "摘要", + "summary", + "description", + "abstract", + "text", +) +# 启发式识别列表字段时的候选名(按序优先) +LIST_FIELD_ALIASES: Tuple[str, ...] = ( + "items", + "results", + "events", + "pages", + "records", + "entries", + "data", + "list", + "rows", + "docs", +) + +_TITLE_MAX = 120 +_SNIPPET_MAX = 3000 +_WHOLE_SNIPPET_MAX = 500 + + +@dataclass(frozen=True) +class CitationSpec: + """单个工具的引用提取规格。mode: list(逐条)/ whole(整份 1 条)。""" + + mode: str = "whole" + # 列表字段路径(支持多层如 ("result", "results");支持并列多列表) + items_paths: Tuple[Tuple[str, ...], ...] = () + title_keys: Tuple[str, ...] = DEFAULT_TITLE_KEYS + url_keys: Tuple[str, ...] = DEFAULT_URL_KEYS + snippet_keys: Tuple[str, ...] = DEFAULT_SNIPPET_KEYS + # 摘要拼接键(如企业搜索的 法定代表人·注册资金·企业状态);命中则优先于 snippet_keys + snippet_join: Tuple[str, ...] = () + source_type: str = "unknown" + title: str = "" # whole 模式的显示标题;空则用工具名 + + +# 操作型 / 资产管理型工具:无可引用内容,中间件直接放行 +SKIP_TOOLS = frozenset( + { + # 自研文件/沙盒操作 + "Write", + "Edit", + "Delete", + "Move", + "CreateFolder", + "bash", + "Bash", + "sandbox_put_artifact", + "sandbox_get_artifact", + "pin_to_workspace", + "stage_myspace_file", + "channel_read_attachment", + "choose_design", + "view_text_file", + "get_data_context", + "load_skill", + "Glob", + "Grep", + # 产物/发布/批量 + "generate_chart_tool", + "batch_plan", + "publish_site", + # 定时任务写操作 + "create_scheduled_task", + "update_scheduled_task", + "pause_scheduled_task", + "resume_scheduled_task", + "delete_scheduled_task", + "list_scheduled_tasks", + "list_channel_conversations", + "get_scheduled_task", + # 技能管理 + "search_marketplace", + "list_my_skills", + "install_from_marketplace", + "register_skill", + "submit_to_marketplace", + "delete_skill", + "edit_skill", + # 能力/资产发现 + "list_myspace_files", + "list_favorite_chats", + "get_chat_messages", + "list_datasets", + # 子智能体的引用由其内部工具的嵌套事件承载 + "call_subagent", + } +) + +TOOL_SPECS: Dict[str, CitationSpec] = { + # 检索/知识类(逐条) + "internet_search": CitationSpec( + mode="list", + items_paths=(("result", "results"), ("results",)), + source_type="internet", + ), + "retrieve_dataset_content": CitationSpec( + mode="list", items_paths=(("items",),), source_type="knowledge_base" + ), + "retrieve_local_kb": CitationSpec( + mode="list", items_paths=(("items",),), source_type="knowledge_base" + ), + "wiki_fetch_source": CitationSpec( + mode="list", items_paths=(("items",),), source_type="knowledge_base" + ), + "wiki_locate": CitationSpec( + mode="list", items_paths=(("pages",),), source_type="knowledge_base" + ), + "wiki_read_page": CitationSpec(mode="whole", title="Wiki 页面", source_type="knowledge_base"), + "web_fetch": CitationSpec(mode="whole", title="网页内容", source_type="internet"), + # 数据/报告类(整份) + "query_database": CitationSpec(mode="whole", title="数据库查询结果", source_type="database"), +} + +# ── 发号器(run 级共享,子智能体经 ContextVar 继承同一实例) ──────────────── + + +class AnchorAllocator: + """会话内单调锚点计数器 + 按 tool_id 的引用注册表。 + + 并发安全性:分配是同步代码段(无 await 点),asyncio 并行工具任务 + 通过 ContextVar 共享同一实例,不会重号。 + """ + + def __init__(self, start: int = 1) -> None: + self._next = max(1, int(start or 1)) + self._by_tool_id: Dict[str, List[AnchorCitation]] = {} + self.all: List[AnchorCitation] = [] + + def next_id(self) -> str: + aid = f"e{self._next}" + self._next += 1 + return aid + + def register(self, tool_id: Optional[str], items: Sequence[AnchorCitation]) -> None: + if not items: + return + self.all.extend(items) + if tool_id: + self._by_tool_id.setdefault(str(tool_id), []).extend(items) + + def citations_for(self, tool_id: Optional[str]) -> List[AnchorCitation]: + if not tool_id: + return [] + return list(self._by_tool_id.get(str(tool_id), [])) + + +# 子智能体链路的兜底通道:call_subagent 的工具函数与父 agent 在同一 task 调用链, +# 能读到父中间件写入的实例。**不作为主通道**——`astream_chat_workflow` 是 async +# generator,其 `set()` 与 agent 实际执行所在的 task 上下文不互通(实测:中间件 +# 侧新建了自己的实例、编排层侧读到 None 回退旧提取)。主通道见 `_ALLOC_ATTR`。 +CITATION_ALLOCATOR: ContextVar[Optional[AnchorAllocator]] = ContextVar( + "jx_citation_allocator", default=None +) + +# 主通道:把发号器挂在 agent 实例上——中间件的 on_acting 拿得到 agent, +# 编排层持有 create_agent_executor 返回的同一个 agent,天然同源、不受上下文隔离影响。 +_ALLOC_ATTR = "_jx_citation_allocator" + + +def attach_allocator(agent: Any, allocator: AnchorAllocator) -> AnchorAllocator: + """run 入口调用:把发号器绑定到 agent(并写 ContextVar 供子链路兜底)。""" + try: + setattr(agent, _ALLOC_ATTR, allocator) + except Exception as exc: # noqa: BLE001 - pydantic 模型等禁止动态属性时降级到 ContextVar + logger.debug("[citation-anchor] attach to agent failed: %s", exc) + CITATION_ALLOCATOR.set(allocator) + return allocator + + +def resolve_allocator(agent: Any) -> AnchorAllocator: + """中间件调用:取本 run 的发号器;缺失时就地建一个并绑定,保证始终可用。""" + allocator = getattr(agent, _ALLOC_ATTR, None) + if isinstance(allocator, AnchorAllocator): + return allocator + allocator = CITATION_ALLOCATOR.get() + if not isinstance(allocator, AnchorAllocator): + allocator = AnchorAllocator() + return attach_allocator(agent, allocator) + + +def collect_citation_dicts( + tool_id: Optional[str], + allocator: Optional[AnchorAllocator] = None, +) -> List[Dict[str, Any]]: + """编排层统一取引用入口(dict 形态,直接可进 SSE / trace / 落库)。 + + 发号器是**唯一**编号方:中间件在结果回给模型前已完成注号并按 tool_id 登记, + 这里只做精确取回,不做二次提取。``allocator`` 由调用方显式传入(run 入口绑定 + 在 agent 上的那一个);不传时退到 ContextVar(子智能体链路)。 + """ + if allocator is None: + allocator = CITATION_ALLOCATOR.get() + if allocator is None: + return [] + return [item.to_dict() for item in allocator.citations_for(tool_id)] + + +def anchor_start_for_chat(chat_id: Optional[str]) -> int: + """跨轮续号:扫描该会话已落库消息的 citations,取最大锚点 + 1。 + + 任何异常都回退 1(引用功能降级,不影响对话)。 + """ + if not chat_id: + return 1 + try: + from core.db.engine import SessionLocal + from core.db.models import ChatMessage + + max_seen = 0 + with SessionLocal() as db: + rows = ( + db.query(ChatMessage.extra_data) + .filter(ChatMessage.chat_id == chat_id) + .order_by(ChatMessage.created_at.desc()) + .limit(200) + .all() + ) + for (extra,) in rows: + for cit in (extra or {}).get("citations") or []: + m = ANCHOR_RE.match(str(cit.get("id", "")) if isinstance(cit, dict) else "") + if m: + max_seen = max(max_seen, int(m.group(1))) + return max_seen + 1 + except Exception as exc: # noqa: BLE001 + logger.debug("[citation-anchor] start scan failed for chat %s: %s", chat_id, exc) + return 1 + + +# ── 提取 + 回注 ───────────────────────────────────────────────────────────── + + +def _first_str(item: Dict[str, Any], keys: Sequence[str]) -> str: + for key in keys: + val = item.get(key) + if isinstance(val, (str, int, float)) and str(val).strip(): + return str(val) + return "" + + +def _dig(data: Dict[str, Any], path: Sequence[str]) -> Any: + cur: Any = data + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return cur + + +def _display_title(tool_name: str, spec: Optional[CitationSpec]) -> str: + if spec and spec.title: + return spec.title + return f"{tool_name} 结果" + + +def _item_citation( + aid: str, + tool_name: str, + tool_id: Optional[str], + spec: CitationSpec, + item: Dict[str, Any], + index: int, +) -> AnchorCitation: + title = _first_str(item, spec.title_keys)[:_TITLE_MAX] or _display_title(tool_name, spec) + if spec.snippet_join: + parts = [_first_str(item, (k,)) for k in spec.snippet_join] + snippet = " · ".join(p for p in parts if p) + else: + snippet = _first_str(item, spec.snippet_keys) + return AnchorCitation( + id=aid, + tool_name=tool_name, + tool_id=tool_id, + title=title, + url=_first_str(item, spec.url_keys), + snippet=snippet[:_SNIPPET_MAX], + source_type=spec.source_type, + item_index=index, + ) + + +def _looks_like_error(data: Dict[str, Any]) -> bool: + if not data.get("error"): + return False + # 列表字段仍有内容时不算整体失败(部分工具 error+items 并存) + for key in LIST_FIELD_ALIASES: + val = data.get(key) + if isinstance(val, list) and val: + return False + return True + + +def _heuristic_list_field(data: Dict[str, Any]) -> Optional[Tuple[Tuple[str, ...], List[Any]]]: + """启发式找列表字段:别名优先;否则要求「唯一」的字典数组字段。""" + scopes: List[Tuple[Tuple[str, ...], Dict[str, Any]]] = [((), data)] + inner = data.get("result") + if isinstance(inner, dict): + scopes.append((("result",), inner)) + for prefix, scope in scopes: + for alias in LIST_FIELD_ALIASES: + val = scope.get(alias) + if isinstance(val, list) and val and all(isinstance(x, dict) for x in val): + return prefix + (alias,), val + for prefix, scope in scopes: + candidates = [ + (key, val) + for key, val in scope.items() + if isinstance(val, list) and val and all(isinstance(x, dict) for x in val) + ] + if len(candidates) == 1: + key, val = candidates[0] + return prefix + (key,), val + return None + + +def _extract_and_inject( + tool_name: str, + tool_id: Optional[str], + data: Dict[str, Any], + allocator: AnchorAllocator, +) -> List[AnchorCitation]: + """在 data 上就地注入 cite_id,返回登记的引用条目。""" + spec = TOOL_SPECS.get(tool_name) + + # L1:工具自声明 __citations__(优先级最高,条目顺序即 item_index) + declared = data.get("__citations__") + if isinstance(declared, list) and declared and all(isinstance(x, dict) for x in declared): + base = spec or CitationSpec(source_type="unknown") + out: List[AnchorCitation] = [] + for idx, entry in enumerate(declared): + aid = allocator.next_id() + entry["cite_id"] = aid + out.append( + AnchorCitation( + id=aid, + tool_name=tool_name, + tool_id=tool_id, + title=str(entry.get("title") or _display_title(tool_name, spec))[:_TITLE_MAX], + url=str(entry.get("url") or ""), + snippet=str(entry.get("snippet") or "")[:_SNIPPET_MAX], + source_type=str(entry.get("source_type") or base.source_type), + item_index=idx, + ) + ) + return out + + if _looks_like_error(data): + return [] + + # L2:配置映射(列表型) + if spec and spec.mode == "list": + out = [] + for path in spec.items_paths: + items = _dig(data, path) + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict) or item.get("error"): + continue + aid = allocator.next_id() + item["cite_id"] = aid + out.append(_item_citation(aid, tool_name, tool_id, spec, item, len(out))) + return out + + # L3:无 spec → 启发式找唯一字典数组 + if spec is None: + found = _heuristic_list_field(data) + if found is not None: + _path, items = found + generic = CitationSpec(mode="list", source_type="unknown") + out = [] + for item in items: + if item.get("error"): + continue + aid = allocator.next_id() + item["cite_id"] = aid + out.append(_item_citation(aid, tool_name, tool_id, generic, item, len(out))) + if out: + return out + + # L4:整份 1 条(显式 whole spec 或彻底认不出) + aid = allocator.next_id() + data["cite_id"] = aid + snippet = json.dumps(data, ensure_ascii=False)[:_WHOLE_SNIPPET_MAX] + return [ + AnchorCitation( + id=aid, + tool_name=tool_name, + tool_id=tool_id, + title=_display_title(tool_name, spec), + url=_first_str(data, (spec or CitationSpec()).url_keys), + snippet=snippet, + source_type=(spec.source_type if spec else "unknown"), + item_index=-1, + ) + ] + + +def annotate_tool_result( + tool_name: str, + tool_id: Optional[str], + text: str, + allocator: AnchorAllocator, +) -> Tuple[str, List[AnchorCitation]]: + """给一份工具结果文本注号。 + + 返回 (注号后的文本, 登记的引用条目)。不可注号(跳过名单 / 空结果 / + 解析失败且判定无引用价值)时原样返回 (text, [])。**绝不抛错**。 + """ + if not text or tool_name in SKIP_TOOLS: + return text, [] + try: + parsed = json.loads(text) + except (json.JSONDecodeError, TypeError): + parsed = None + + try: + if isinstance(parsed, dict): + items = _extract_and_inject(tool_name, tool_id, parsed, allocator) + if not items: + return text, [] + return json.dumps(parsed, ensure_ascii=False), items + if isinstance(parsed, list): + # 裸数组结果:包壳后按启发式处理,回注仍输出裸数组语义(保持兼容 → 整份 1 条) + parsed = {"items": parsed} + items = _extract_and_inject(tool_name, tool_id, parsed, allocator) + if not items: + return text, [] + return json.dumps(parsed["items"], ensure_ascii=False), items + # 纯文本结果:整份 1 条,文末追加锚点行 + spec = TOOL_SPECS.get(tool_name) + if spec and spec.mode == "list": + # 声明为列表型但拿到纯文本 → 形态异常,放行不注号 + return text, [] + aid = allocator.next_id() + item = AnchorCitation( + id=aid, + tool_name=tool_name, + tool_id=tool_id, + title=_display_title(tool_name, spec), + url="", + snippet=str(text)[:_SNIPPET_MAX], + source_type=(spec.source_type if spec else "unknown"), + item_index=-1, + ) + return f"{text}\n\n[cite_id: {aid}]", [item] + except Exception as exc: # noqa: BLE001 + logger.warning("[citation-anchor] annotate failed tool=%s: %s", tool_name, exc) + return text, [] diff --git a/src/backend/orchestration/citations.py b/src/backend/orchestration/citations.py deleted file mode 100644 index 635e790..0000000 --- a/src/backend/orchestration/citations.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Per-request citation extraction from tool results. - -Each tool has a different output shape; this module normalizes them -into CitationItem objects with a stable id format: "-". -The index is 1-based and scoped per tool call (not globally sequential), -so multiple concurrent tool calls don't collide. -""" - -from __future__ import annotations - -import json -from dataclasses import asdict, dataclass -from typing import Any, Dict, List, Optional - - -@dataclass -class CitationItem: - id: str # e.g. "internet_search-1" - tool_name: str - tool_id: Optional[str] - title: str - url: str - snippet: str - source_type: ( - str # internet | knowledge_base | database | unknown - ) - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - -_SOURCE_TYPE_MAP: Dict[str, str] = { - "internet_search": "internet", - "retrieve_dataset_content": "knowledge_base", - "retrieve_local_kb": "knowledge_base", - "query_database": "database", -} - - -def extract_citations( - tool_name: str, - tool_id: Optional[str], - result: Any, -) -> List[CitationItem]: - """Extract CitationItem list from a tool result. - - Returns an empty list on any error (never raises). - """ - source_type = _SOURCE_TYPE_MAP.get(tool_name, "unknown") - - # Normalise raw result to dict - if isinstance(result, str): - try: - result = json.loads(result) - except Exception: - result = {"result": result} - if isinstance(result, list): - result = {"items": result} - if not isinstance(result, dict): - result = {"result": str(result)} - - try: - if tool_name == "internet_search": - return _internet_search(tool_id, source_type, result) - if tool_name == "retrieve_dataset_content": - return _dataset_content(tool_id, source_type, result) - if tool_name == "retrieve_local_kb": - return _local_kb(tool_id, source_type, result) - if tool_name == "query_database": - return _database(tool_id, source_type, result) - except Exception: - pass - return [] - - -def extract_citations_with_offset( - tool_name: str, - tool_id: Optional[str], - result: Any, - citation_offsets: Dict[str, int], -) -> List[CitationItem]: - """Extract citations and rewrite their ids to stay unique across repeated - calls to the same tool within one turn / batch item. - - ``extract_citations`` numbers ids ``-`` starting at 1 - *per call* (see module docstring), so a ReAct loop that invokes the same - tool more than once would otherwise emit duplicate ids (e.g. two - ``internet_search-1``). This advances a per-tool counter in - ``citation_offsets`` (mutated in place) and shifts each id's trailing index - by the accumulated offset, so downstream id-based lookup / dedup - (frontend reference chips, trajectory distillation, report export) stays - correct. - - Callers own the ``citation_offsets`` dict (one per turn / item) and keep - their existing ``.to_dict()`` handling on the returned items. - """ - cit_items = extract_citations(tool_name, tool_id, result) - offset = citation_offsets.get(tool_name, 0) - if offset > 0: - for cit in cit_items: - try: - old_idx = int(cit.id.rsplit("-", 1)[-1]) - except (ValueError, IndexError): - continue - cit.id = f"{tool_name}-{old_idx + offset}" - citation_offsets[tool_name] = offset + len(cit_items) - return cit_items - - -# ── per-tool extractors ──────────────────────────────────────────────────── - - -def _internet_search(tool_id: Optional[str], source_type: str, data: dict) -> List[CitationItem]: - sr = data.get("result") or data - if isinstance(sr, dict): - results = sr.get("results", []) - elif isinstance(sr, list): - results = sr - else: - return [] - - out: List[CitationItem] = [] - for i, item in enumerate(results, 1): - if not isinstance(item, dict): - continue - title = str(item.get("title") or item.get("url") or "互联网搜索结果")[:120] - url = str(item.get("url", "")) - snippet = str(item.get("content") or item.get("snippet") or "")[:300] - out.append( - CitationItem( - id=f"internet_search-{i}", - tool_name="internet_search", - tool_id=tool_id, - title=title, - url=url, - snippet=snippet, - source_type=source_type, - ) - ) - return out - - -def _dataset_content(tool_id: Optional[str], source_type: str, data: dict) -> List[CitationItem]: - items = data.get("items", []) - out: List[CitationItem] = [] - for i, item in enumerate(items, 1): - if not isinstance(item, dict): - continue - # Support both the normalized format and generic external-provider records. - doc = item.get("document") or {} - seg = item.get("segment") or {} - title = str(item.get("文件名称") or doc.get("name") or doc.get("title") or "知识库文档")[ - :120 - ] - snippet = str(item.get("文件内容") or seg.get("content") or item.get("content") or "")[ - :3000 - ] - out.append( - CitationItem( - id=f"retrieve_dataset_content-{i}", - tool_name="retrieve_dataset_content", - tool_id=tool_id, - title=title, - url="", - snippet=snippet, - source_type=source_type, - ) - ) - return out - - -def _local_kb(tool_id: Optional[str], source_type: str, data: dict) -> List[CitationItem]: - items = data.get("items", []) - out: List[CitationItem] = [] - for i, item in enumerate(items, 1): - if not isinstance(item, dict): - continue - if item.get("error"): - continue - title = str(item.get("title") or "私有知识库文档")[:120] - snippet = str(item.get("content") or "")[:3000] - out.append( - CitationItem( - id=f"retrieve_local_kb-{i}", - tool_name="retrieve_local_kb", - tool_id=tool_id, - title=title, - url="", - snippet=snippet, - source_type=source_type, - ) - ) - return out - - -def _database(tool_id: Optional[str], source_type: str, data: dict) -> List[CitationItem]: - res = data.get("result", data) - snippet = str(res) if not isinstance(res, str) else res - return [ - CitationItem( - id="query_database-1", - tool_name="query_database", - tool_id=tool_id, - title="数据库查询结果", - url="", - snippet=snippet[:3000], - source_type=source_type, - ) - ] diff --git a/src/backend/orchestration/workflow.py b/src/backend/orchestration/workflow.py index beffa19..d917599 100644 --- a/src/backend/orchestration/workflow.py +++ b/src/backend/orchestration/workflow.py @@ -32,7 +32,12 @@ ) from core.services.ontology_service import resolve_runtime_asset_tags from core.services.project_scope import edition_project_context_keys -from orchestration.citations import extract_citations_with_offset +from orchestration.citation_anchor import ( + AnchorAllocator, + anchor_start_for_chat, + attach_allocator, + collect_citation_dicts, +) from orchestration.streaming import StreamingAgent # Project mode: extracted from chats.py's ctx and passed through to agent_factory so the system prompt renders the project section. @@ -318,7 +323,7 @@ def _capture_nested_ontology_evidence( payload: Dict[str, Any], trace: List[Dict[str, Any]], citations: List[Dict[str, Any]], - citation_offsets: Dict[str, int], + allocator: Optional[AnchorAllocator] = None, ) -> List[Dict[str, Any]]: """Merge trusted ``call_subagent`` bypass events into the outer review trace.""" sub_type = str(payload.get("sub_type") or "") @@ -345,13 +350,7 @@ def _capture_nested_ontology_evidence( return [] result = _parse_tool_result_value(payload.get("output")) - cit_items = extract_citations_with_offset( - tool_name, - tool_id, - result, - citation_offsets, - ) - cit_dicts = [item.to_dict() for item in cit_items] + cit_dicts = collect_citation_dicts(tool_id, allocator) citations.extend(cit_dicts) trace.append( { @@ -408,7 +407,8 @@ def _ontology_repair_prompt(payload: Dict[str, Any]) -> str: "并重新生成、交付修正后的文件。没有获得证据的风险项只能表述为‘待核验’、" "‘暂无数据支撑’或‘无法判断’,绝不能反向断言为‘不存在’、‘没有风险’或‘风险为零’。" "在输出终稿和生成文件前,必须在内部逐条检查‘确定性违规’中的每个条件:若要求" - "最低长度,终稿必须达到该长度;若要求引用,相关事实后必须包含真实 `[ref:工具名-序号]`" + "最低长度,终稿必须达到该长度;若要求引用,相关事实后必须包含真实引用标记" + "(从工具结果复制 cite_id,写成 `[锚文本](cite:eN)`)" "且按规则补齐参考资料;若要求区分事实、推断和待核验项,可在同一句中用分号和简短标签" "表达,不得省略。先确定唯一的合格终稿,再把完全相同的正文写入用户要求的文件," "不得先生成文件后又输出一份更短、缺引用或结论不同的候选正文。" @@ -416,8 +416,8 @@ def _ontology_repair_prompt(payload: Dict[str, Any]) -> str: " `完整候选答案` 中。" "在正式输出最终正文前,不要复述、引用或示例化这组标签,也不要用省略号代替正文。" "标签内必须是可独立阅读、内容完整的修订答案;若无法修订,则原样放入当前完整答案。" - "事实、推断和待核验项必须明确区分;引用已有或新增工具结果时使用" - " `[ref:工具名-序号]`。\n" + "事实、推断和待核验项必须明确区分;引用已有或新增工具结果时,把工具结果里" + "标注的 cite_id 原样复制进 `[锚文本](cite:eN)`,禁止自行编号。\n" f"用户最初指令:{json.dumps(original_task, ensure_ascii=False)[:12000]}\n" f"待修订原始输出:{json.dumps(current_answer, ensure_ascii=False)[:12000]}\n" f"修复轮次:{payload.get('attempt', 1)}\n" @@ -439,8 +439,8 @@ async def _run_ontology_repair_round( runtime: Dict[str, Any], trace: List[Dict[str, Any]], citations: List[Dict[str, Any]], - citation_offsets: Dict[str, int], - event_cursor: int, + allocator: Optional[AnchorAllocator] = None, + event_cursor: int = 0, subagent_log_id: Optional[str] = None, event_sink: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, ) -> Tuple[str, List[Dict[str, Any]], int, int]: @@ -596,13 +596,7 @@ def _is_revision_opening_boundary(line_before_open: str) -> bool: tool_name = str(event_payload.get("name") or "unknown") tool_id = str(event_payload.get("id") or "") result = _parse_tool_result_value(event_payload.get("content")) - cit_items = extract_citations_with_offset( - tool_name, - tool_id, - result, - citation_offsets, - ) - cit_dicts = [item.to_dict() for item in cit_items] + cit_dicts = collect_citation_dicts(tool_id, allocator) citations.extend(cit_dicts) trace.append( { @@ -629,7 +623,7 @@ def _is_revision_opening_boundary(line_before_open: str) -> bool: event_payload or {}, trace, citations, - citation_offsets, + allocator, ) sub_type = str((event_payload or {}).get("sub_type") or "") if sub_type in {"start", "thinking", "content", "tool_call", "tool_result", "end"}: @@ -1460,9 +1454,15 @@ async def _finish_direct_log( full_response = "" displayed_tools: set[str] = set() all_citations: List[Dict[str, Any]] = [] - citation_offsets: Dict[str, int] = {} _ontology_event_cursor = 0 _ontology_trace: List[Dict[str, Any]] = [] + # 证据锚点发号器:跨轮续号;创建后绑到 agent 上(见下方 attach_allocator), + # 中间件与本函数由此共享同一个计数器 + _anchor_allocator = AnchorAllocator( + await asyncio.to_thread( + anchor_start_for_chat, str(context.get("chat_id") or "") or None + ) + ) try: yield {"type": "thinking", "message": "正在连接子智能体..."} @@ -1506,6 +1506,11 @@ async def _finish_direct_log( logger.info("[subagent] agent created in %.0fms", (_time.monotonic() - _wf_start) * 1000) + # 证据锚点:把发号器绑到 agent 上(中间件与本函数由此共享同一个计数器; + # 仅靠 ContextVar 不行——本函数是 async generator,与 agent 执行所在的 + # task 上下文不互通) + attach_allocator(agent, _anchor_allocator) + # ── Frozen-block injection: user identity (always injected) + memory snapshot (loaded only when persistent memory is on) ─── _identity_block = await build_user_identity_block(_mem0_user_id) frozen_block = "" @@ -1702,10 +1707,7 @@ async def _finish_direct_log( "query", result_data.get("question", "") ) - cit_items = extract_citations_with_offset( - tool_name, tool_id, tool_result_json, citation_offsets - ) - cit_dicts = [c.to_dict() for c in cit_items] + cit_dicts = collect_citation_dicts(tool_id, _anchor_allocator) all_citations.extend(cit_dicts) _ontology_trace.append( { @@ -1752,7 +1754,7 @@ async def _finish_direct_log( payload or {}, _ontology_trace, all_citations, - citation_offsets, + _anchor_allocator, ) yield { "type": "subagent_event", @@ -1832,7 +1834,7 @@ async def _remediate(payload: Dict[str, Any]) -> str: runtime=_ontology_runtime, trace=_ontology_trace, citations=all_citations, - citation_offsets=citation_offsets, + allocator=_anchor_allocator, event_cursor=_ontology_event_cursor, subagent_log_id=_direct_log_id, event_sink=repair_event_queue.put, @@ -2210,12 +2212,18 @@ async def astream_chat_workflow( full_response = "" displayed_tools: set[str] = set() all_citations: List[Dict[str, Any]] = [] - citation_offsets: Dict[str, int] = {} _ontology_runtime = _request_ontology_runtime _ontology_event_cursor = 0 _ontology_trace: List[Dict[str, Any]] = [] _last_plan: Optional[Dict[str, Any]] = None _stream_errored = False + # 证据锚点发号器:跨轮续号;创建后绑到 agent 上(见下方 attach_allocator), + # 中间件与本函数由此共享同一个计数器 + _anchor_allocator = AnchorAllocator( + await asyncio.to_thread( + anchor_start_for_chat, str(context.get("chat_id") or "") or None + ) + ) try: import time as _time @@ -2353,6 +2361,11 @@ async def astream_chat_workflow( logger.info("[workflow] agent created in %.0fms", (_time.monotonic() - _wf_start) * 1000) + # 证据锚点:把发号器绑到 agent 上(中间件与本函数由此共享同一个计数器; + # 仅靠 ContextVar 不行——本函数是 async generator,与 agent 执行所在的 + # task 上下文不互通) + attach_allocator(agent, _anchor_allocator) + # ── Inject the per-turn sub-agent constraint into the current user message ── # Keeps it OUT of the system prompt so the LLM provider's prefix cache # hits across turns within a chat (otherwise every turn with different @@ -2639,10 +2652,7 @@ async def astream_chat_workflow( ) # Citations - cit_items = extract_citations_with_offset( - tool_name, tool_id, tool_result_json, citation_offsets - ) - cit_dicts = [c.to_dict() for c in cit_items] + cit_dicts = collect_citation_dicts(tool_id, _anchor_allocator) all_citations.extend(cit_dicts) _ontology_trace.append( { @@ -2775,7 +2785,7 @@ async def astream_chat_workflow( payload or {}, _ontology_trace, all_citations, - citation_offsets, + _anchor_allocator, ) yield { "type": "subagent_event", @@ -2855,7 +2865,7 @@ async def _remediate(payload: Dict[str, Any]) -> str: runtime=_ontology_runtime, trace=_ontology_trace, citations=all_citations, - citation_offsets=citation_offsets, + allocator=_anchor_allocator, event_cursor=_ontology_event_cursor, event_sink=repair_event_queue.put, ) diff --git a/src/backend/plugin_bundles/marketplace/automation/mcp.json b/src/backend/plugin_bundles/marketplace/automation/mcp.json new file mode 100644 index 0000000..551c583 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/automation/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "automation_task": { + "type": "streamable-http", + "url": "http://mcp:9108/mcp/" + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/automation/plugin.json b/src/backend/plugin_bundles/marketplace/automation/plugin.json index 7850f62..68df811 100644 --- a/src/backend/plugin_bundles/marketplace/automation/plugin.json +++ b/src/backend/plugin_bundles/marketplace/automation/plugin.json @@ -1,35 +1,53 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "automation", "version": "1.0.0", - "display_name": "定时任务管理", "description": "在对话里用自然语言管理定时任务:创建、查看、修改、暂停、恢复、删除;结果可投递到站内或飞书等渠道会话(多目标)。装上后智能体即可调度周期任务。", - "category": "效率工具", - "author": "HugAgentOS", - "components": { - "skills": ["scheduled-tasks"], - "mcp": ["automation_task"], - "prompts": [] + "author": { + "name": "HugAgentOS" }, - "default_enabled": { - "skills": ["scheduled-tasks"], - "mcp": ["automation_task"] - }, - "mcpServers": { - "automation_task": { - "transport": "streamable_http", - "url": "http://mcp:9108/mcp/", - "display_name": "定时任务", - "description": "创建/查看/修改/暂停/恢复/删除定时任务,结果投递到站内或飞书等渠道会话。", - "tools": [ - {"name": "create_scheduled_task", "description": "创建一个定时/周期任务。到点后系统自动执行你给定的 prompt 指令,并把结果投递出去。\n\n参数:\n· cron_expression:标准 5 段 cron「分 时 日 月 周」,按 Asia/Shanghai 时区。例:每天 9:00 = \"0 9 * * *\";每周一 9:00 = \"0 9 * * 1\";每小时整点 = \"0 * * * *\";每 5 分钟 = \"*/5 * * * *\"。\n· prompt:到点要执行的完整自包含指令(执行时没有当前对话上下文,需写清做什么、产出什么)。\n· name:任务名称(可选)。\n· deliver_to:结果发到哪,一般留空。留空 = 自动(在飞书等渠道会话里创建就推回该会话;在网页里创建就到点生成一条站内侧栏会话,与在「自动化」页面手动建任务一致)。也可填 \"inapp\" 只发站内;或填某个会话的 conversation_id 发到指定的另一个渠道会话。"}, - {"name": "list_channel_conversations", "description": "列出你的渠道机器人(飞书等)产生过的会话(群聊 / 私聊),返回每个会话的标题与 conversation_id。当你想把定时任务结果发到「别的」会话(而非当前会话)时,先用本工具拿到目标会话的 conversation_id,再作为 create_scheduled_task 的 deliver_to 传入。"}, - {"name": "list_scheduled_tasks", "description": "列出你当前的定时任务,包含任务 ID、名称、cron 表达式、下次执行时间、状态(生效中 / 已暂停 / 已完成)以及投递目标。可按状态筛选(active / paused / all)。"}, - {"name": "get_scheduled_task", "description": "查看某个定时任务的完整详情,以及它最近几次的执行记录(成功或失败、执行时间、结果摘要)。可用任务 ID 或任务名称定位;名称匹配到多个时会让你确认。"}, - {"name": "update_scheduled_task", "description": "修改一个已有的定时任务:可改执行时间(cron)、执行内容(prompt)或任务名称,只改你指定的字段,其余不变。可用任务 ID 或名称定位。"}, - {"name": "pause_scheduled_task", "description": "暂停一个定时任务。暂停后到点不再触发,但任务本身保留,之后可用 resume_scheduled_task 恢复。可用任务 ID 或名称定位。"}, - {"name": "resume_scheduled_task", "description": "恢复一个之前被暂停的定时任务,使其重新按 cron 到点触发。可用任务 ID 或名称定位。"}, - {"name": "delete_scheduled_task", "description": "彻底删除 / 取消一个定时任务,删除后不可恢复。可用任务 ID 或名称定位;若名称匹配到多个任务,会先要求你确认是哪一个,不会盲删。"} - ] + "extensions": { + "org.hugagent": { + "mcp": { + "automation_task": { + "display_name": "定时任务", + "description": "创建/查看/修改/暂停/恢复/删除定时任务,结果投递到站内或飞书等渠道会话。", + "tools": [ + { + "name": "create_scheduled_task", + "description": "创建一个定时/周期任务。到点后系统自动执行你给定的 prompt 指令,并把结果投递出去。\n\n参数:\n· cron_expression:标准 5 段 cron「分 时 日 月 周」,按 Asia/Shanghai 时区。例:每天 9:00 = \"0 9 * * *\";每周一 9:00 = \"0 9 * * 1\";每小时整点 = \"0 * * * *\";每 5 分钟 = \"*/5 * * * *\"。\n· prompt:到点要执行的完整自包含指令(执行时没有当前对话上下文,需写清做什么、产出什么)。\n· name:任务名称(可选)。\n· deliver_to:结果发到哪,一般留空。留空 = 自动(在飞书等渠道会话里创建就推回该会话;在网页里创建就到点生成一条站内侧栏会话,与在「自动化」页面手动建任务一致)。也可填 \"inapp\" 只发站内;或填某个会话的 conversation_id 发到指定的另一个渠道会话。" + }, + { + "name": "list_channel_conversations", + "description": "列出你的渠道机器人(飞书等)产生过的会话(群聊 / 私聊),返回每个会话的标题与 conversation_id。当你想把定时任务结果发到「别的」会话(而非当前会话)时,先用本工具拿到目标会话的 conversation_id,再作为 create_scheduled_task 的 deliver_to 传入。" + }, + { + "name": "list_scheduled_tasks", + "description": "列出你当前的定时任务,包含任务 ID、名称、cron 表达式、下次执行时间、状态(生效中 / 已暂停 / 已完成)以及投递目标。可按状态筛选(active / paused / all)。" + }, + { + "name": "get_scheduled_task", + "description": "查看某个定时任务的完整详情,以及它最近几次的执行记录(成功或失败、执行时间、结果摘要)。可用任务 ID 或任务名称定位;名称匹配到多个时会让你确认。" + }, + { + "name": "update_scheduled_task", + "description": "修改一个已有的定时任务:可改执行时间(cron)、执行内容(prompt)或任务名称,只改你指定的字段,其余不变。可用任务 ID 或名称定位。" + }, + { + "name": "pause_scheduled_task", + "description": "暂停一个定时任务。暂停后到点不再触发,但任务本身保留,之后可用 resume_scheduled_task 恢复。可用任务 ID 或名称定位。" + }, + { + "name": "resume_scheduled_task", + "description": "恢复一个之前被暂停的定时任务,使其重新按 cron 到点触发。可用任务 ID 或名称定位。" + }, + { + "name": "delete_scheduled_task", + "description": "彻底删除 / 取消一个定时任务,删除后不可恢复。可用任务 ID 或名称定位;若名称匹配到多个任务,会先要求你确认是哪一个,不会盲删。" + } + ] + } + } } } } diff --git a/src/backend/plugin_bundles/marketplace/dingtalk/plugin.json b/src/backend/plugin_bundles/marketplace/dingtalk/plugin.json index 480a625..877b9a6 100644 --- a/src/backend/plugin_bundles/marketplace/dingtalk/plugin.json +++ b/src/backend/plugin_bundles/marketplace/dingtalk/plugin.json @@ -1,14 +1,11 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "dingtalk", "version": "1.0.37", - "display_name": "钉钉工作台", "description": "以你的钉钉身份操作钉钉全产品:通讯录/搜人、日历日程、待办、审批、考勤、日志、群聊与机器人发消息、钉钉文档与云盘、AI 表格、AI 听记、邮箱、在线电子表格、知识库等。基于钉钉官方开源 dws CLI。安装后请在本插件详情页连接钉钉账号(OAuth 设备流),未连接时技能不可用。", - "category": "办公协同", - "connection": "dingtalk", - "components": { - "skills": ["dingtalk"] - }, - "default_enabled": { - "skills": ["dingtalk"] + "extensions": { + "org.hugagent": { + "connection": "dingtalk" + } } } diff --git a/src/backend/plugin_bundles/marketplace/email/plugin.json b/src/backend/plugin_bundles/marketplace/email/plugin.json index a9f05a8..8586231 100644 --- a/src/backend/plugin_bundles/marketplace/email/plugin.json +++ b/src/backend/plugin_bundles/marketplace/email/plugin.json @@ -1,18 +1,11 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "email", "version": "1.0.0", - "display_name": "电子邮箱", "description": "以本人邮箱身份收发与管理邮件:发送邮件(含附件)、阅读/搜索/整理收件箱、回复转发、管理文件夹与标记,基于 himalaya,通过 IMAP/SMTP 授权码连接 Gmail/Outlook/Exchange/网易企业邮/腾讯企业邮/自建邮箱等。安装后在本插件详情页填写邮箱地址与授权码完成绑定,无需 OAuth。", - "category": "办公协同", - "connection": "email", - "components": { - "skills": [ - "email" - ] - }, - "default_enabled": { - "skills": [ - "email" - ] + "extensions": { + "org.hugagent": { + "connection": "email" + } } } diff --git a/src/backend/plugin_bundles/marketplace/feishu-cli/plugin.json b/src/backend/plugin_bundles/marketplace/feishu-cli/plugin.json index d22ba9e..e1c347a 100644 --- a/src/backend/plugin_bundles/marketplace/feishu-cli/plugin.json +++ b/src/backend/plugin_bundles/marketplace/feishu-cli/plugin.json @@ -1,66 +1,11 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "feishu-cli", "version": "1.0.0", - "display_name": "飞书工作台", "description": "以本人身份操作飞书:即时通讯、云文档、多维表格、电子表格、日历、邮箱、任务、知识库、视频会议、妙记等全域能力,基于官方 lark-cli。安装后由管理员在本插件详情页一键初始化飞书应用,用户再于账号连接区域扫码授权。", - "category": "办公协同", - "connection": "lark", - "components": { - "skills": [ - "lark-shared", - "lark-im", - "lark-doc", - "lark-base", - "lark-sheets", - "lark-calendar", - "lark-contact", - "lark-drive", - "lark-mail", - "lark-task", - "lark-wiki", - "lark-markdown", - "lark-slides", - "lark-whiteboard", - "lark-minutes", - "lark-vc", - "lark-vc-agent", - "lark-event", - "lark-approval", - "lark-attendance", - "lark-okr", - "lark-openapi-explorer", - "lark-skill-maker", - "lark-workflow-meeting-summary", - "lark-workflow-standup-report" - ] - }, - "default_enabled": { - "skills": [ - "lark-shared", - "lark-im", - "lark-doc", - "lark-base", - "lark-sheets", - "lark-calendar", - "lark-contact", - "lark-drive", - "lark-mail", - "lark-task", - "lark-wiki", - "lark-markdown", - "lark-slides", - "lark-whiteboard", - "lark-minutes", - "lark-vc", - "lark-vc-agent", - "lark-event", - "lark-approval", - "lark-attendance", - "lark-okr", - "lark-openapi-explorer", - "lark-skill-maker", - "lark-workflow-meeting-summary", - "lark-workflow-standup-report" - ] + "extensions": { + "org.hugagent": { + "connection": "lark" + } } } diff --git a/src/backend/plugin_bundles/marketplace/firecrawl/plugin.json b/src/backend/plugin_bundles/marketplace/firecrawl/plugin.json index bb90ce9..7f221d8 100644 --- a/src/backend/plugin_bundles/marketplace/firecrawl/plugin.json +++ b/src/backend/plugin_bundles/marketplace/firecrawl/plugin.json @@ -1,54 +1,29 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "firecrawl", "version": "1.0.0", - "display_name": "Firecrawl·网页抓取检索", "description": "Firecrawl 官方技能套件:把任意网页抓成 LLM 友好的 markdown,支持网页搜索、单页抓取、站点地图、整站爬取、AI 结构化抽取、浏览器交互、本地文件解析、整站下载、变化监控。沙箱内预装 firecrawl CLI,凭据由管理员在本插件详情里统一配置(云版 API Key 或自托管地址,两种皆可),用户无需自行配置。", - "category": "信息处理", - "admin_config": { - "mode": "any", - "group": "firecrawl", - "hint": "云版填 API Key,或自托管填实例地址(二选一即可,也可都填)。配置后全体用户即可直接使用。", - "fields": [ - { - "key": "firecrawl.api_key", - "label": "Firecrawl API Key", - "secret": true, - "description": "firecrawl 云版 API Key(fc- 开头),在 firecrawl.dev 注册获取。用自托管实例时可留空。" - }, - { - "key": "firecrawl.api_url", - "label": "Firecrawl 自托管地址", - "secret": false, - "description": "自托管 firecrawl 实例地址(如 http://firecrawl:3002)。填了即走自建实例、CLI 跳过云端鉴权;用云版时留空。" + "extensions": { + "org.hugagent": { + "admin_config": { + "mode": "any", + "group": "firecrawl", + "hint": "云版填 API Key,或自托管填实例地址(二选一即可,也可都填)。配置后全体用户即可直接使用。", + "fields": [ + { + "key": "firecrawl.api_key", + "label": "Firecrawl API Key", + "secret": true, + "description": "firecrawl 云版 API Key(fc- 开头),在 firecrawl.dev 注册获取。用自托管实例时可留空。" + }, + { + "key": "firecrawl.api_url", + "label": "Firecrawl 自托管地址", + "secret": false, + "description": "自托管 firecrawl 实例地址(如 http://firecrawl:3002)。填了即走自建实例、CLI 跳过云端鉴权;用云版时留空。" + } + ] } - ] - }, - "components": { - "skills": [ - "search", - "scrape", - "map", - "crawl", - "agent", - "interact", - "monitor", - "parse", - "download", - "cli" - ] - }, - "default_enabled": { - "skills": [ - "search", - "scrape", - "map", - "crawl", - "agent", - "interact", - "monitor", - "parse", - "download", - "cli" - ] + } } } diff --git a/src/backend/plugin_bundles/marketplace/sample-translator/plugin.json b/src/backend/plugin_bundles/marketplace/sample-translator/plugin.json index a734775..50a44fc 100644 --- a/src/backend/plugin_bundles/marketplace/sample-translator/plugin.json +++ b/src/backend/plugin_bundles/marketplace/sample-translator/plugin.json @@ -1,13 +1,6 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sample-translator", "version": "1.0.0", - "display_name": "示例·快速翻译", - "description": "一个内置示例插件,演示插件包结构:打包一个翻译技能。可作为编写自有插件的参考模板。", - "category": "办公效率", - "components": { - "skills": ["quick-translate"] - }, - "default_enabled": { - "skills": ["quick-translate"] - } + "description": "一个内置示例插件,演示插件包结构:打包一个翻译技能。可作为编写自有插件的参考模板。" } diff --git a/src/backend/plugin_bundles/marketplace/sites/mcp.json b/src/backend/plugin_bundles/marketplace/sites/mcp.json new file mode 100644 index 0000000..4e51449 --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/sites/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "site_publish": { + "type": "streamable-http", + "url": "http://mcp:9113/mcp/" + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/sites/plugin.json b/src/backend/plugin_bundles/marketplace/sites/plugin.json index 0b1ad76..5a2322b 100644 --- a/src/backend/plugin_bundles/marketplace/sites/plugin.json +++ b/src/backend/plugin_bundles/marketplace/sites/plugin.json @@ -1,27 +1,25 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sites", "version": "1.1.0", - "display_name": "站点·对话建站", "description": "用对话把想法做成真实网站并一键发布上线:简单内容直接生成静态站,复杂/精美需求用预装的 React 工程模板(antd + echarts + tailwind)在沙箱内构建,建站前还会生成 3 个设计方案预览图供你挑选。调 publish_site 发布成平台托管站点,拿到形如 /site// 的链接即可访问。支持自定义访问地址、公开/私密可见、版本回滚、访问统计,以及站点内置轻后端(KV 存储 + 表单收集)。装上后在对话里描述需求即可建站,也可在「实验室 → 站点」里管理。", - "category": "信息处理", - "author": "HugAgentOS", - "components": { - "skills": ["site-builder"], - "mcp": ["site_publish"] + "author": { + "name": "HugAgentOS" }, - "default_enabled": { - "skills": ["site-builder"], - "mcp": ["site_publish"] - }, - "mcpServers": { - "site_publish": { - "transport": "streamable_http", - "url": "http://mcp:9113/mcp/", - "display_name": "对话建站发布", - "description": "把沙箱里的静态网站目录发布为平台托管站点,返回可访问 URL。身份/会话走 X-Current-User-Id / X-Conversation-Id 头;发布动作转发到后端内部接口完成(后端有沙箱访问权)。", - "tools": [ - {"name": "publish_site", "description": "把沙箱里的一个网站目录(/workspace 下,必须含 index.html)发布为平台托管站点,返回形如 /site// 的可访问 URL。用户要'做个网站/页面/门户/展示站/看板/H5 并能访问'时:静态站先用 write/bash 生成后直接发布;React 构建型站点(site-builder 技能路径 B)构建出产物后发布,src_dir 指产物目录、source_dir 指源码工程目录(两参必传,源码镜像进项目、产物进托管)。更新已有站点:带 site_id 重新发布,URL 不变、版本 +1。可选 slug(自定义地址)/visibility(public/private)/description。站点支持内置轻后端 API:相对路径 fetch __api/kv/(KV 存储)与 POST __api/forms/(表单收集)。限制 ≤300 文件、≤30MB。"} - ] + "extensions": { + "org.hugagent": { + "mcp": { + "site_publish": { + "display_name": "对话建站发布", + "description": "把沙箱里的静态网站目录发布为平台托管站点,返回可访问 URL。身份/会话走 X-Current-User-Id / X-Conversation-Id 头;发布动作转发到后端内部接口完成(后端有沙箱访问权)。", + "tools": [ + { + "name": "publish_site", + "description": "把沙箱里的一个网站目录(/workspace 下,必须含 index.html)发布为平台托管站点,返回形如 /site// 的可访问 URL。用户要'做个网站/页面/门户/展示站/看板/H5 并能访问'时:静态站先用 write/bash 生成后直接发布;React 构建型站点(site-builder 技能路径 B)构建出产物后发布,src_dir 指产物目录、source_dir 指源码工程目录(两参必传,源码镜像进项目、产物进托管)。更新已有站点:带 site_id 重新发布,URL 不变、版本 +1。可选 slug(自定义地址)/visibility(public/private)/description。站点支持内置轻后端 API:相对路径 fetch __api/kv/(KV 存储)与 POST __api/forms/(表单收集)。限制 ≤300 文件、≤30MB。" + } + ] + } + } } } } diff --git a/src/backend/plugin_bundles/marketplace/skill-manager/mcp.json b/src/backend/plugin_bundles/marketplace/skill-manager/mcp.json new file mode 100644 index 0000000..177f6ee --- /dev/null +++ b/src/backend/plugin_bundles/marketplace/skill-manager/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "skill_manager": { + "type": "streamable-http", + "url": "http://mcp:9112/mcp/" + } + } +} diff --git a/src/backend/plugin_bundles/marketplace/skill-manager/plugin.json b/src/backend/plugin_bundles/marketplace/skill-manager/plugin.json index 545e468..1ad6eb8 100644 --- a/src/backend/plugin_bundles/marketplace/skill-manager/plugin.json +++ b/src/backend/plugin_bundles/marketplace/skill-manager/plugin.json @@ -1,34 +1,49 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "skill-manager", "version": "1.0.0", - "display_name": "技能管理", "description": "在对话里用自然语言管理技能:搜索并安装技能市场里的技能、用打包的 skill-creator 技能从零创建你自己的私有技能、编辑修改已有私有技能的内容、把私有技能申请上架到技能市场、查看/删除你的技能,也支持给一个 web 链接从中下载并安装技能或插件。装上后智能体即可原生地创建、编辑、管理、删除、申请上架技能。", - "category": "效率工具", - "author": "HugAgentOS", - "components": { - "skills": ["skill-creator"], - "mcp": ["skill_manager"], - "prompts": [] + "author": { + "name": "HugAgentOS" }, - "default_enabled": { - "skills": ["skill-creator"], - "mcp": ["skill_manager"] - }, - "mcpServers": { - "skill_manager": { - "transport": "streamable_http", - "url": "http://mcp:9112/mcp/", - "display_name": "技能管理", - "description": "搜索/安装技能市场、把沙箱里创作好的技能落库成私有技能、申请上架市场、查看/删除我的技能。身份走 X-Current-User-Id 头,所有写操作按用户归属。", - "tools": [ - {"name": "search_marketplace", "description": "搜索技能市场,返回匹配的可安装技能列表(slug/名称/简介/分类/是否已安装)。用户想'看看有没有现成的 X 技能 / 技能市场里有什么 / 找一个能做 Y 的技能'时调用。query=关键词(留空列全部),category=按分类过滤。找到目标后用 install_from_marketplace(slug) 安装。"}, - {"name": "install_from_marketplace", "description": "从技能市场安装一个技能到'我的私有技能库',装完即可在对话中使用。用户说'装上那个技能 / 安装 X 技能 / 把它加到我的能力里'时调用。slug 取自 search_marketplace。若技能需要凭据(返回缺凭据报错),先向用户要,再把 required_secrets 对应的 key/value 放进 secrets 后重试。需要管理员开启'自助添加技能'权限。"}, - {"name": "register_skill", "description": "把在沙箱里创作好的技能'存进我的技能库'(创建私有技能)。配合 skill-creator 技能:先在沙箱 /workspace 产出技能目录→tar 打包→调 sandbox_get_artifact 取得 artifact_id→把 artifact_id 传给本工具落库。也用于'从 web 链接安装'(沙箱内 curl 下载并打包后走本工具;含 plugin.json 的包会自动按插件导入)。自助入口始终落成当前用户私有技能;需要'自助添加技能'权限。"}, - {"name": "list_my_skills", "description": "列出'我的私有技能'(skill_id/名称/版本/启用状态)。用户问'我有哪些自己的技能 / 我创建过什么技能 / 管理我的技能'时调用。拿到 skill_id 后可 submit_to_marketplace 申请上架或 delete_skill 删除。"}, - {"name": "submit_to_marketplace", "description": "把我的私有技能申请上架到技能市场(进管理员审核队列,通过后其他人可安装)。用户说'把我的技能分享出去 / 申请上架 / 发布到市场'时调用。skill_id 取自 list_my_skills。category 必须从这 8 个固定值里挑一个:写作助手/文档处理/数据分析/政策产业/营销创意/法务合规/办公效率/研发效率。summary=一句话简介,note=给审核管理员的说明。这是申请、非直接上架。需要'自助添加技能'权限。"}, - {"name": "delete_skill", "description": "删除'我的一个私有技能'(不可恢复)。skill_ref 传 skill_id 或技能名称。用户说'删掉我的 X 技能 / 移除那个技能'时调用。匹配到多个时必须先向用户确认,禁止猜删。只能删自己的私有技能。"}, - {"name": "edit_skill", "description": "修改'我的一个已有私有技能'的内容(无需删了重建)。skill_ref 传 skill_id 或技能名称。用户说'改一下我那个 X 技能 / 把技能描述/正文/名字改成…… / 给技能加个文件 / 更新技能里的脚本'时调用。只更新传入的字段,未传的保持原样:display_name/description/instructions(技能正文)/tags/version,以及 files_upsert(新增或覆盖附属文件 {文件名:文本})/files_delete(删附属文件)。技能 id 不可改;匹配到多个时必须先让用户指明,禁止猜改。只能改自己的私有技能。需要'自助添加技能'权限。"} - ] + "extensions": { + "org.hugagent": { + "mcp": { + "skill_manager": { + "display_name": "技能管理", + "description": "搜索/安装技能市场、把沙箱里创作好的技能落库成私有技能、申请上架市场、查看/删除我的技能。身份走 X-Current-User-Id 头,所有写操作按用户归属。", + "tools": [ + { + "name": "search_marketplace", + "description": "搜索技能市场,返回匹配的可安装技能列表(slug/名称/简介/分类/是否已安装)。用户想'看看有没有现成的 X 技能 / 技能市场里有什么 / 找一个能做 Y 的技能'时调用。query=关键词(留空列全部),category=按分类过滤。找到目标后用 install_from_marketplace(slug) 安装。" + }, + { + "name": "install_from_marketplace", + "description": "从技能市场安装一个技能到'我的私有技能库',装完即可在对话中使用。用户说'装上那个技能 / 安装 X 技能 / 把它加到我的能力里'时调用。slug 取自 search_marketplace。若技能需要凭据(返回缺凭据报错),先向用户要,再把 required_secrets 对应的 key/value 放进 secrets 后重试。需要管理员开启'自助添加技能'权限。" + }, + { + "name": "register_skill", + "description": "把在沙箱里创作好的技能'存进我的技能库'(创建私有技能)。配合 skill-creator 技能:先在沙箱 /workspace 产出技能目录→tar 打包→调 sandbox_get_artifact 取得 artifact_id→把 artifact_id 传给本工具落库。也用于'从 web 链接安装'(沙箱内 curl 下载并打包后走本工具;含 plugin.json 的包会自动按插件导入)。自助入口始终落成当前用户私有技能;需要'自助添加技能'权限。" + }, + { + "name": "list_my_skills", + "description": "列出'我的私有技能'(skill_id/名称/版本/启用状态)。用户问'我有哪些自己的技能 / 我创建过什么技能 / 管理我的技能'时调用。拿到 skill_id 后可 submit_to_marketplace 申请上架或 delete_skill 删除。" + }, + { + "name": "submit_to_marketplace", + "description": "把我的私有技能申请上架到技能市场(进管理员审核队列,通过后其他人可安装)。用户说'把我的技能分享出去 / 申请上架 / 发布到市场'时调用。skill_id 取自 list_my_skills。category 必须从这 8 个固定值里挑一个:写作助手/文档处理/数据分析/政策产业/营销创意/法务合规/办公效率/研发效率。summary=一句话简介,note=给审核管理员的说明。这是申请、非直接上架。需要'自助添加技能'权限。" + }, + { + "name": "delete_skill", + "description": "删除'我的一个私有技能'(不可恢复)。skill_ref 传 skill_id 或技能名称。用户说'删掉我的 X 技能 / 移除那个技能'时调用。匹配到多个时必须先向用户确认,禁止猜删。只能删自己的私有技能。" + }, + { + "name": "edit_skill", + "description": "修改'我的一个已有私有技能'的内容(无需删了重建)。skill_ref 传 skill_id 或技能名称。用户说'改一下我那个 X 技能 / 把技能描述/正文/名字改成…… / 给技能加个文件 / 更新技能里的脚本'时调用。只更新传入的字段,未传的保持原样:display_name/description/instructions(技能正文)/tags/version,以及 files_upsert(新增或覆盖附属文件 {文件名:文本})/files_delete(删附属文件)。技能 id 不可改;匹配到多个时必须先让用户指明,禁止猜改。只能改自己的私有技能。需要'自助添加技能'权限。" + } + ] + } + } } } } diff --git a/src/backend/plugin_bundles/marketplace/yida/plugin.json b/src/backend/plugin_bundles/marketplace/yida/plugin.json index 38341f7..229ce48 100644 --- a/src/backend/plugin_bundles/marketplace/yida/plugin.json +++ b/src/backend/plugin_bundles/marketplace/yida/plugin.json @@ -1,14 +1,11 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "yida", "version": "2026.7.12", - "display_name": "宜搭低代码平台", "description": "以对话方式搭建和管理钉钉宜搭低代码应用:创建应用/表单/流程/自定义页面、发布页面、增删改查表单数据、配置公式与业务规则、生成报表/图表/数据大屏、管理应用与表单权限等。基于宜搭官方开源 OpenYida CLI(沙箱内预装)。安装后在本插件详情页扫码连接宜搭账号(也可在对话内首次使用时扫码),登录态跨会话保持。", - "category": "办公协同", - "connection": "yida", - "components": { - "skills": ["yida"] - }, - "default_enabled": { - "skills": ["yida"] + "extensions": { + "org.hugagent": { + "connection": "yida" + } } } 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 67f5e66..f225a97 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 @@ -1,25 +1,33 @@ ## 格式与输出规范 -### 引用标注 -引用工具返回的数据时使用 `[ref:工具名-序号]` 格式: -- 使用下列提到的工具时若回答正文中包含工具引用的部分必须按照以下引用规范引用工具内容,保证内容真实性与准确性 -- `序号`从1开始,代表该工具返回列表中第N条 -- 同一工具多次调用时序号接续递增(第一次返回5条为1-5,第二次从6开始) -- 整体性工具(如数据库查询)每次调用视为1条 -- 多来源并列:`[ref:tool1-N][ref:tool2-M]` -- 标记在引用句末、句号前 -- 只标记工具实际返回的内容,分析推理部分不标记 - -**工具名对照表:** - -| 工具名 | 说明 | -|---|---| -| `internet_search` | 互联网搜索 | -| `retrieve_dataset_content` | 知识库检索 | -| `retrieve_local_kb` | 私有知识库 | -| `query_database` | 数据库查询 | -**示例:** -> 公开网页显示该项目已于本月发布[ref:internet_search-1],知识库文档补充了实施范围[ref:retrieve_dataset_content-1]。 +### 引用标注(证据锚点) +工具结果里的可引用条目都带有系统标注的锚点 `cite_id`(形如 `e7`;JSON 里是 +`"cite_id": "e7"` 字段,纯文本结果是文末 `[cite_id: e7]` 行)。 + +**唯一写法:`[锚文本](cite:e7)` —— 把锚点挂在正文的实义文字上**,锚文本取来源名、 +主体名或被引事实的关键短语,让读者点这段文字本身就能溯源。 + +- 锚点 ID **一律从工具结果原样复制**,禁止自行编号、推算或杜撰 +- **禁止把引用当句末小尾巴**单独挂在句号前——那是已废弃的旧写法 +- 一句话有多个来源时,各自挂在对应的那段文字上,不要在句末堆叠多个标记 +- 只标注工具实际返回的内容,分析推理部分不标注 +- 结果里没有 cite_id 的工具(写文件、生成图表等操作类)不需要引用 + +**正确 ↔ 错误对照(务必照正确写法输出):** +> ✅ [Meta](cite:e1) 于 8 月 11 日发布开放权重模型 Muse Glimmer,专为智能体任务设计。 +> ❌ Meta 于 8 月 11 日发布开放权重模型 Muse Glimmer,专为智能体任务设计[来源](cite:e1)。 +> +> ✅ 据 [IDC 2026 全球机器人报告](cite:e7),出货量同比增长 23%;[某券商研报](cite:e8) 则给出 18% 的估计。 +> ❌ 出货量同比增长 23%[来源](cite:e7)[来源](cite:e8)。 +> +> ✅ [某上市公司年报](cite:e3)显示营收同比增长 12%,[行业白皮书](cite:e9)预计明年市场规模翻番。 +> ❌ 营收同比增长 12%[来源](cite:e3),明年市场规模翻番[来源](cite:e9)。 + +列表/表格里同样照此办理:把锚点挂在条目标题或主体名上(如 +`- **[字节跳动发布 SeedRealtime](cite:e4)**:原生音视频全双工大模型……`), +不要写成 `- **字节跳动发布 SeedRealtime**:原生音视频全双工大模型……[来源](cite:e4)`。 + +只有实在无法自然嵌入正文时(例如整段纯数字表格),才允许退化为句末 `[来源](cite:e7)`。 ### 数据处理 - 单位换算:**100000千元 = 1亿元**,通常保留两位小数 diff --git a/src/backend/prompts/prompt_text/turbo/turbo.system.md b/src/backend/prompts/prompt_text/turbo/turbo.system.md index 885a0ce..26fef28 100644 --- a/src/backend/prompts/prompt_text/turbo/turbo.system.md +++ b/src/backend/prompts/prompt_text/turbo/turbo.system.md @@ -44,6 +44,6 @@ - **先给结论**:第一句话直接回答用户问的问题,再给依据与细节。 - **短而全**:正文控制在 300 字以内为宜(生产力类请求的内容交付除外);条目多时用列表,不写冗长铺垫。 -- **标注来源**:引用检索结果时使用 `[ref:工具名-序号]` 引用标记;政策类回答注明文号、发布机构与日期(检索结果中有则必须给出)。 +- **标注来源**:引用检索结果时把结果里标注的 `cite_id` 原样复制进 `[锚文本](cite:eN)`,锚点挂在正文实义文字上(如 `[Meta](cite:e1) 发布…`),**禁止**在句末挂孤立标记,也禁止自行编号;政策类回答注明文号、发布机构与日期(检索结果中有则必须给出)。 - **时效提醒**:政策信息注明检索到的发布/更新时间;无法确认现行有效时明确提示"以官方最新发布为准"。 - 全程使用中文(用户明确要求其他语言除外)。 diff --git a/src/backend/tests/orchestration/test_citation_offset.py b/src/backend/tests/orchestration/test_citation_offset.py deleted file mode 100644 index 874485f..0000000 --- a/src/backend/tests/orchestration/test_citation_offset.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for ``routing.citations.extract_citations_with_offset``. - -The helper de-collides citation ids when the same tool is called more than -once within a single turn / batch item. ``extract_citations`` numbers ids -``-`` from 1 *per call*, so without the offset rewrite a -ReAct loop that searches twice would emit duplicate ids — breaking frontend -reference chips and any id-keyed dedup downstream (trajectory distillation, -report export). - -The helper is shared by all three call sites: ``routing/workflow.py`` (the two -main-chat streaming branches) and ``routing/batch_orchestrator.py``. - -``routing.citations`` pulls in no heavy deps (json / dataclasses / typing), so -this imports the real function — no AST/stub gymnastics needed. -""" - -from __future__ import annotations - -from orchestration.citations import extract_citations_with_offset - - -def _internet_result(n: int) -> dict: - """A fake internet_search result carrying *n* hits → n citations.""" - return {"result": [{"title": f"t{i}", "url": f"u{i}", "content": "x"} for i in range(n)]} - - -def test_first_call_unchanged_when_offset_zero() -> None: - offsets: dict = {} - items = extract_citations_with_offset("internet_search", "tc1", _internet_result(2), offsets) - assert [c.id for c in items] == ["internet_search-1", "internet_search-2"] - assert offsets["internet_search"] == 2 - - -def test_same_tool_two_calls_no_collision() -> None: - offsets: dict = {} - first = extract_citations_with_offset("internet_search", "tc1", _internet_result(2), offsets) - second = extract_citations_with_offset("internet_search", "tc2", _internet_result(2), offsets) - ids = [c.id for c in first + second] - assert ids == [ - "internet_search-1", - "internet_search-2", - "internet_search-3", - "internet_search-4", - ] - assert len(ids) == len(set(ids)) - assert offsets["internet_search"] == 4 - - -def test_three_call_chain_produces_unique_ids() -> None: - offsets: dict = {} - all_ids: list[str] = [] - for tc in ("tc1", "tc2", "tc3"): - all_ids += [ - c.id - for c in extract_citations_with_offset( - "internet_search", tc, _internet_result(2), offsets - ) - ] - assert len(all_ids) == len(set(all_ids)) == 6 - assert offsets["internet_search"] == 6 - - -def test_empty_call_leaves_earlier_ids_intact() -> None: - offsets: dict = {} - first = extract_citations_with_offset("internet_search", "tc1", _internet_result(2), offsets) - # A call that yields no citations must not disturb the running offset. - empty = extract_citations_with_offset("internet_search", "tc2", _internet_result(0), offsets) - third = extract_citations_with_offset("internet_search", "tc3", _internet_result(1), offsets) - assert [c.id for c in first] == ["internet_search-1", "internet_search-2"] - assert empty == [] - assert [c.id for c in third] == ["internet_search-3"] - assert offsets["internet_search"] == 3 - - -def test_per_tool_offsets_independent() -> None: - offsets: dict = {} - a1 = extract_citations_with_offset("internet_search", "tc1", _internet_result(2), offsets) - b1 = extract_citations_with_offset("get_industry_news", "tc2", _internet_result(2), offsets) - a2 = extract_citations_with_offset("internet_search", "tc3", _internet_result(1), offsets) - # Each tool keeps its own counter; the news call doesn't shift search ids. - assert [c.id for c in a1] == ["internet_search-1", "internet_search-2"] - assert [c.id for c in a2] == ["internet_search-3"] - assert offsets["internet_search"] == 3 - # get_industry_news goes through the generic _news extractor; just assert - # its ids are namespaced to its own tool and start fresh at 1. - assert all(c.id.startswith("get_industry_news-") for c in b1) - assert offsets["get_industry_news"] == len(b1) - - -def test_unparseable_id_suffix_is_skipped_not_crashed() -> None: - """If a future extractor emits an id without a numeric suffix, the rewrite - skips that item instead of raising (offset still advances by count).""" - - class _FakeCitation: - def __init__(self, cid: str) -> None: - self.id = cid - - import orchestration.citations as cm - - original = cm.extract_citations - try: - cm.extract_citations = lambda *a, **k: [_FakeCitation("internet_search-weird")] - offsets = {"internet_search": 5} # offset > 0 forces the rewrite branch - items = extract_citations_with_offset("internet_search", "tc", {}, offsets) - # Non-numeric suffix → left untouched, no exception. - assert items[0].id == "internet_search-weird" - assert offsets["internet_search"] == 6 - finally: - cm.extract_citations = original diff --git a/src/backend/tests/test_citation_anchor.py b/src/backend/tests/test_citation_anchor.py new file mode 100644 index 0000000..f3c4267 --- /dev/null +++ b/src/backend/tests/test_citation_anchor.py @@ -0,0 +1,311 @@ +"""统一证据锚点(orchestration/citation_anchor.py)单元测试。 + +覆盖:发号器连续性、四层提取降级(__citations__ / 配置映射 / 启发式 / 整体兜底)、 +JSON/纯文本回注、跳过名单、错误结果放行、collect_citation_dicts 双路径。 +""" + +import json + +import pytest + +from orchestration.citation_anchor import ( + CITATION_ALLOCATOR, + SKIP_TOOLS, + AnchorAllocator, + anchor_start_for_chat, + annotate_tool_result, + attach_allocator, + collect_citation_dicts, + resolve_allocator, +) + + +@pytest.fixture(autouse=True) +def _clear_allocator_ctx(): + token = CITATION_ALLOCATOR.set(None) + yield + CITATION_ALLOCATOR.reset(token) + + +def _alloc(start: int = 1) -> AnchorAllocator: + return AnchorAllocator(start) + + +# ── 发号器 ────────────────────────────────────────────────────────────────── + + +def test_allocator_sequences_across_tools(): + alloc = _alloc() + assert alloc.next_id() == "e1" + assert alloc.next_id() == "e2" + alloc2 = _alloc(start=7) + assert alloc2.next_id() == "e7" + + +def test_allocator_registry_by_tool_id(): + alloc = _alloc() + text = json.dumps({"items": [{"title": "文档A", "content": "正文"}]}, ensure_ascii=False) + _, items = annotate_tool_result("retrieve_local_kb", "call-1", text, alloc) + alloc.register("call-1", items) + got = alloc.citations_for("call-1") + assert [c.id for c in got] == ["e1"] + assert alloc.citations_for("call-other") == [] + + +# ── 配置映射(L2)────────────────────────────────────────────────────────── + + +def test_internet_search_double_nested_list(): + alloc = _alloc() + payload = { + "result": { + "query": "机器人", + "results": [ + {"title": "报告A", "url": "https://a.com", "content": "内容A"}, + {"title": "报告B", "url": "https://b.com", "content": "内容B"}, + ], + } + } + new_text, items = annotate_tool_result( + "internet_search", "t1", json.dumps(payload, ensure_ascii=False), alloc + ) + assert [c.id for c in items] == ["e1", "e2"] + assert items[0].title == "报告A" + assert items[0].url == "https://a.com" + assert items[0].source_type == "internet" + assert items[0].item_index == 0 and items[1].item_index == 1 + annotated = json.loads(new_text) + assert annotated["result"]["results"][0]["cite_id"] == "e1" + assert annotated["result"]["results"][1]["cite_id"] == "e2" + + +def test_chinese_keys_kb_items(): + alloc = _alloc(start=5) + payload = {"items": [{"文件名称": "政策文件", "文件内容": "第一条……", "document_id": "d1"}]} + new_text, items = annotate_tool_result( + "retrieve_dataset_content", "t2", json.dumps(payload, ensure_ascii=False), alloc + ) + assert items[0].id == "e5" + assert items[0].title == "政策文件" + assert items[0].snippet.startswith("第一条") + assert json.loads(new_text)["items"][0]["cite_id"] == "e5" + + +def test_search_company_snippet_join(): + alloc = _alloc() + payload = {"items": [{"企业名称": "某某科技", "法定代表人": "张三", "企业状态": "存续"}]} + _, items = annotate_tool_result( + "search_company", "t3", json.dumps(payload, ensure_ascii=False), alloc + ) + assert items[0].title == "某某科技" + assert "张三" in items[0].snippet and "存续" in items[0].snippet + + +def test_whole_mode_spec(): + alloc = _alloc() + payload = {"result": "✅ 查询成功\n\n[{\"a\": 1}]"} + new_text, items = annotate_tool_result( + "query_database", "t4", json.dumps(payload, ensure_ascii=False), alloc + ) + assert len(items) == 1 + assert items[0].id == "e1" + assert items[0].title == "数据库查询结果" + assert items[0].item_index == -1 + assert json.loads(new_text)["cite_id"] == "e1" + + +# ── 自声明(L1)──────────────────────────────────────────────────────────── + + +def test_self_declared_citations_field(): + alloc = _alloc() + payload = { + "data": "whatever", + "__citations__": [ + {"title": "来源甲", "url": "https://x.com", "snippet": "片段", "source_type": "internet"}, + {"title": "来源乙"}, + ], + } + new_text, items = annotate_tool_result( + "some_new_tool", "t5", json.dumps(payload, ensure_ascii=False), alloc + ) + assert [c.id for c in items] == ["e1", "e2"] + assert items[0].source_type == "internet" + assert items[1].title == "来源乙" + annotated = json.loads(new_text) + assert annotated["__citations__"][0]["cite_id"] == "e1" + assert annotated["__citations__"][1]["cite_id"] == "e2" + + +# ── 启发式(L3)与整体兜底(L4)──────────────────────────────────────────── + + +def test_heuristic_unknown_tool_with_alias_list(): + alloc = _alloc() + payload = {"ok": True, "items": [{"title": "条目1", "snippet": "s1"}, {"title": "条目2"}]} + new_text, items = annotate_tool_result( + "brand_new_tool", "t6", json.dumps(payload, ensure_ascii=False), alloc + ) + assert [c.id for c in items] == ["e1", "e2"] + assert json.loads(new_text)["items"][0]["cite_id"] == "e1" + + +def test_heuristic_unique_dict_list_without_alias(): + alloc = _alloc() + payload = {"total": 2, "hits": [{"name": "甲"}, {"name": "乙"}]} + _, items = annotate_tool_result( + "another_tool", "t7", json.dumps(payload, ensure_ascii=False), alloc + ) + assert len(items) == 2 + assert items[0].title == "甲" + + +def test_unknown_tool_whole_fallback(): + alloc = _alloc() + payload = {"answer": "42", "unit": "无"} + new_text, items = annotate_tool_result( + "opaque_tool", "t8", json.dumps(payload, ensure_ascii=False), alloc + ) + assert len(items) == 1 + assert items[0].item_index == -1 + assert json.loads(new_text)["cite_id"] == "e1" + + +def test_plain_text_result_footer(): + alloc = _alloc() + new_text, items = annotate_tool_result("web_fetch_like_tool", "t9", "纯文本网页内容……", alloc) + assert len(items) == 1 + assert new_text.endswith("[cite_id: e1]") + assert items[0].snippet.startswith("纯文本") + + +def test_list_spec_with_plain_text_passthrough(): + alloc = _alloc() + text = "not-json at all" + new_text, items = annotate_tool_result("internet_search", "t10", text, alloc) + assert new_text == text + assert items == [] + + +# ── 跳过与错误 ────────────────────────────────────────────────────────────── + + +def test_skip_tools_passthrough(): + alloc = _alloc() + assert "pin_to_workspace" in SKIP_TOOLS and "Write" in SKIP_TOOLS + text = json.dumps({"ok": True, "pinned": [{"file_id": "abc"}]}) + new_text, items = annotate_tool_result("pin_to_workspace", "t11", text, alloc) + assert new_text == text and items == [] + + +def test_error_result_not_annotated(): + alloc = _alloc() + text = json.dumps({"error": "上游超时", "items": []}, ensure_ascii=False) + new_text, items = annotate_tool_result("get_industry_news", "t12", text, alloc) + assert new_text == text and items == [] + + +def test_empty_list_result_not_annotated(): + alloc = _alloc() + text = json.dumps({"items": []}) + new_text, items = annotate_tool_result("retrieve_local_kb", "t13", text, alloc) + assert new_text == text and items == [] + + +def test_annotate_never_raises_on_garbage(): + alloc = _alloc() + new_text, items = annotate_tool_result("query_database", "t14", "", alloc) + assert new_text == "" and items == [] + + +# ── collect_citation_dicts 双路径 ─────────────────────────────────────────── + + +def test_collect_reads_allocator_registry_from_contextvar(): + alloc = _alloc() + CITATION_ALLOCATOR.set(alloc) + text = json.dumps({"items": [{"title": "文档", "content": "x"}]}, ensure_ascii=False) + _, items = annotate_tool_result("retrieve_local_kb", "call-9", text, alloc) + alloc.register("call-9", items) + got = collect_citation_dicts("call-9") + assert [c["id"] for c in got] == ["e1"] + assert got[0]["item_index"] == 0 + # 未注册的 tool_id:发号器是唯一编号方,取不到就是空(不做二次提取) + assert collect_citation_dicts("call-x") == [] + + +def test_collect_returns_empty_without_any_allocator(): + """发号器完全缺位(理论上不该发生)时返回空,而不是抛错或退回旧编号。""" + assert collect_citation_dicts("call-1") == [] + + +def test_anchor_start_without_chat_defaults_to_one(): + assert anchor_start_for_chat(None) == 1 + assert anchor_start_for_chat("") == 1 + + +# ── agent 绑定通道(回归:ContextVar 跨 async-generator/task 不互通) ───────── + + +class _FakeAgent: + """最小 agent 桩:只需要能挂属性。""" + + +def test_allocator_shared_via_agent_not_contextvar(): + """run 入口绑定在 agent 上后,中间件侧必须拿到**同一个**实例。 + + 回归点:编排层曾只写 ContextVar,而 agent 实际在另一个 task 上下文执行, + 导致中间件新建了自己的发号器、编排层读到 None 回退旧提取(引用 id 退化成 + `internet_search-1`,模型抄到的 cite_id 与之对不上)。 + """ + agent = _FakeAgent() + run_alloc = AnchorAllocator(start=5) + attach_allocator(agent, run_alloc) + # 模拟中间件在另一个上下文里执行:ContextVar 被清空也必须仍取到同一个实例 + CITATION_ALLOCATOR.set(None) + assert resolve_allocator(agent) is run_alloc + assert resolve_allocator(agent).next_id() == "e5" + + +def test_resolve_allocator_creates_and_binds_when_missing(): + agent = _FakeAgent() + alloc = resolve_allocator(agent) + assert isinstance(alloc, AnchorAllocator) + assert resolve_allocator(agent) is alloc # 二次调用复用同一个 + + +def test_middleware_registration_flow_end_to_end(): + """注号 → register → 按 tool_id 取回,链路必须闭合。 + + 回归点:中间件曾漏调 ``allocator.register()``,导致即便共享了发号器, + ``collect_citation_dicts`` 仍取不到任何引用(SSE citations 为空)。 + """ + agent = _FakeAgent() + alloc = attach_allocator(agent, AnchorAllocator()) + payload = {"result": {"results": [{"title": "A", "url": "u", "content": "c"}]}} + + # 中间件侧 + mw_alloc = resolve_allocator(agent) + new_text, items = annotate_tool_result( + "internet_search", "call-1", json.dumps(payload, ensure_ascii=False), mw_alloc + ) + mw_alloc.register("call-1", items) + assert json.loads(new_text)["result"]["results"][0]["cite_id"] == "e1" + + # 编排层侧:拿同一个 allocator 精确取 + got = collect_citation_dicts("call-1", alloc) + assert [c["id"] for c in got] == ["e1"] + + +def test_second_call_continues_numbering_not_restart(): + """同一 run 内第二次调用同一工具必须接续编号,不得从 e1 重来。""" + agent = _FakeAgent() + alloc = attach_allocator(agent, AnchorAllocator()) + payload = {"result": {"results": [{"title": "A"}, {"title": "B"}]}} + for call_id in ("call-1", "call-2"): + _, items = annotate_tool_result( + "internet_search", call_id, json.dumps(payload, ensure_ascii=False), alloc + ) + alloc.register(call_id, items) + assert [c.id for c in alloc.citations_for("call-1")] == ["e1", "e2"] + assert [c.id for c in alloc.citations_for("call-2")] == ["e3", "e4"] diff --git a/src/backend/tests/test_plugin_system.py b/src/backend/tests/test_plugin_system.py index 8fcf059..b203eec 100644 --- a/src/backend/tests/test_plugin_system.py +++ b/src/backend/tests/test_plugin_system.py @@ -808,3 +808,217 @@ def _override_db(): ) finally: app.dependency_overrides.clear() + + +# ── Agent Plugins standard package (agent-plugins.org 1.0.0) ───────────────── + + +def _make_standard_plugin(root: Path) -> Path: + """Build an Agent Plugins standard package: closed-schema plugin.json + + extensions["org.hugagent"] + standalone mcp.json with type discriminators.""" + pdir = root / "std-toolkit" + pdir.mkdir(parents=True) + (pdir / "plugin.json").write_text( + json.dumps( + { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "std-toolkit", + "version": "1.2.3", + "description": "An Agent Plugins standard demo package", + "author": {"name": "Acme", "url": "https://acme.example"}, + "keywords": ["demo"], + "extensions": { + "org.hugagent": { + "connection": "lark", + "required_secrets": [{"key": "api_key", "label": "API Key"}], + "admin_config": { + "mode": "any", + "fields": [{"key": "std.url", "label": "URL", "secret": False}], + }, + "mcp": { + "std-remote": { + "display_name": "标准远程", + "description": "标准 streamable-http server", + "tools": [{"name": "ping", "description": "ping"}], + } + }, + } + }, + } + ), + encoding="utf-8", + ) + sk = pdir / "skills" / "std-skill" + sk.mkdir(parents=True) + (sk / "SKILL.md").write_text( + "---\nname: std-skill\ndescription: Standard demo skill\n---\n\n" + "Data lives in ${PLUGIN_ROOT}/data, cache in ${PLUGIN_DATA}.\n", + encoding="utf-8", + ) + (pdir / "mcp.json").write_text( + json.dumps( + { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "std-remote": { + "type": "streamable-http", + "url": "https://mcp.example.com/mcp", + }, + "std-local": { + "type": "stdio", + "command": "node", + "args": ["server.js"], + "cwd": "${PLUGIN_ROOT}/srv", + }, + }, + } + ), + encoding="utf-8", + ) + # A client-specific reverse-domain namespace dir must be ignored, not imported + other = pdir / "com.example.client" / "hooks" + other.mkdir(parents=True) + (other / "hooks.json").write_text("{}", encoding="utf-8") + return pdir + + +def test_normalize_standard_plugin(tmp_path): + np = pi.normalize_plugin_dir(_make_standard_plugin(tmp_path)) + assert np.kind == "native" + assert np.slug == "std-toolkit" + assert np.version == "1.2.3" + # Platform fields come from the extension namespace + assert np.connection == "lark" + assert np.admin_config and np.admin_config["fields"][0]["key"] == "std.url" + assert [s["key"] for s in np.required_secrets] == ["api_key"] + # mcp.json type discriminator wins; ext metadata overlays display fields + by_name = {m.name: m for m in np.mcp} + remote = by_name["std-remote"] + assert remote.transport == "streamable_http" and not remote.needs_runtime + assert remote.display_name == "标准远程" + assert [t["name"] for t in remote.tools] == ["ping"] + local = by_name["std-local"] + assert local.transport == "stdio" and local.needs_runtime + assert local.cwd and "${PLUGIN_ROOT}" not in local.cwd + # default_enabled derives from the filesystem: skills + remote MCP on, stdio off + assert np.default_enabled["skills"] == ["std-skill"] + assert np.default_enabled["mcp"] == ["std-remote"] + + +def test_standard_plugin_import_persists_cwd_and_meta(tmp_path, db_session): + pdir = _make_standard_plugin(tmp_path) + ps.import_plugin(db_session, pdir, owner_user_id=OWNER, secrets={"api_key": "k"}) + # Standard path variables (${PLUGIN_ROOT}/${PLUGIN_DATA}) rewritten at persist time + sk = ( + db_session.query(AdminSkill) + .filter(AdminSkill.source_plugin == "std-toolkit") + .first() + ) + assert "${PLUGIN_ROOT}" not in sk.skill_content + assert "${PLUGIN_DATA}" not in sk.skill_content + assert "/workspace/skills/" in sk.skill_content + servers = { + m.server_id: m + for m in db_session.query(AdminMcpServer) + .filter(AdminMcpServer.source_plugin == "std-toolkit") + .all() + } + local = next(m for sid, m in servers.items() if m.transport == "stdio") + assert (local.extra_config or {}).get("cwd", "").endswith("/srv") + assert local.is_enabled is False # stdio installed disabled + remote = next(m for sid, m in servers.items() if m.transport == "streamable_http") + assert remote.display_name == "标准远程" + assert [t["name"] for t in (remote.tools_json or [])] == ["ping"] + + +def test_legacy_topfield_manifest_still_imports(tmp_path): + """Legacy native manifests with platform fields at the top level keep working.""" + pdir = tmp_path / "legacy-pack" + pdir.mkdir() + (pdir / "plugin.json").write_text( + json.dumps( + { + "name": "legacy-pack", + "display_name": "旧版包", + "category": "效率工具", + "connection": "dingtalk", + "mcpServers": { + "old-remote": {"transport": "streamable_http", "url": "http://x/mcp/"} + }, + } + ), + encoding="utf-8", + ) + sk = pdir / "skills" / "legacy-skill" + sk.mkdir(parents=True) + (sk / "SKILL.md").write_text( + "---\nname: legacy-skill\ndescription: legacy demo\n---\n\nBody.\n", encoding="utf-8" + ) + np = pi.normalize_plugin_dir(pdir) + assert np.name == "旧版包" and np.category == "效率工具" + assert np.connection == "dingtalk" + assert np.mcp[0].transport == "streamable_http" + + +# ── Display metadata is UI configuration (market meta + installed meta) ────── + + +def test_market_meta_seed_override_and_install(db_session): + # Seed applies without any override + meta = ps.resolve_market_meta(db_session, "automation") + assert meta["display_name"] == "定时任务管理" + # Admin override wins over the seed and flows into the market list + ps.set_market_meta(db_session, "automation", display_name="自动化任务", category="效率") + meta = ps.resolve_market_meta(db_session, "automation") + assert meta["display_name"] == "自动化任务" and meta["category"] == "效率" + items = {it["slug"]: it for it in ps.list_plugins(db_session, owner_user_id=None, include_disabled=True)} + assert items["automation"]["name"] == "自动化任务" + # Install picks up the effective metadata for the installed record + ps.install_plugin(db_session, "automation", owner_user_id=None) + row = ( + db_session.query(InstalledPlugin) + .filter(InstalledPlugin.install_id == "automation@global") + .first() + ) + assert row.name == "自动化任务" + # Clearing the override falls back to the seed + ps.set_market_meta(db_session, "automation", display_name="", category="") + assert ps.resolve_market_meta(db_session, "automation")["display_name"] == "定时任务管理" + + +def test_market_meta_rejects_unknown_slug(db_session): + with pytest.raises(Exception): + ps.set_market_meta(db_session, "no-such-plugin", display_name="x") + + +def test_installed_meta_owner_guard(tmp_path, db_session): + pdir = _make_standard_plugin(tmp_path) + res = ps.import_plugin(db_session, pdir, owner_user_id=OWNER, secrets={"api_key": "k"}) + install_id = res["install_id"] + out = ps.set_installed_plugin_meta( + db_session, install_id, owner_user_id=OWNER, display_name="我的工具箱", category="效率" + ) + assert out["name"] == "我的工具箱" and out["category"] == "效率" + with pytest.raises(BadRequestError): + ps.set_installed_plugin_meta( + db_session, install_id, owner_user_id="someone_else", display_name="劫持" + ) + + +def test_market_meta_icon_validation(db_session): + # Library path and uploaded data-URI are accepted + ps.set_market_meta(db_session, "automation", icon="/home/mcp/internet.svg") + assert ps.resolve_market_meta(db_session, "automation")["icon"] == "/home/mcp/internet.svg" + ps.set_market_meta(db_session, "automation", icon="data:image/svg+xml;base64,PHN2Zy8+") + assert ps.resolve_market_meta(db_session, "automation")["icon"].startswith("data:image/") + # Non-image data URIs and arbitrary text are rejected + with pytest.raises(BadRequestError): + ps.set_market_meta(db_session, "automation", icon="data:text/html;base64,eA==") + with pytest.raises(BadRequestError): + ps.set_market_meta(db_session, "automation", icon="not-an-icon") + # Oversized data URIs are rejected + with pytest.raises(BadRequestError): + ps.set_market_meta(db_session, "automation", icon="data:image/png;base64," + "A" * 300_000) + # Empty clears the override + ps.set_market_meta(db_session, "automation", icon="") + assert "icon" not in ps.resolve_market_meta(db_session, "automation") diff --git a/src/backend/tests/test_yida_integration.py b/src/backend/tests/test_yida_integration.py index ff43481..b6a0ed7 100644 --- a/src/backend/tests/test_yida_integration.py +++ b/src/backend/tests/test_yida_integration.py @@ -292,15 +292,17 @@ async def close_session(self, session_id): def test_yida_plugin_declares_connection(): - """plugin.json declares connection=yida → the frontend plugin detail page renders the YidaConnect panel.""" - import json as _json + """plugin.json declares connection=yida (extensions["org.hugagent"], Agent Plugins standard) + → the frontend plugin detail page renders the YidaConnect panel.""" import pathlib + from core.services.plugin_importer import normalize_plugin_dir + p = ( pathlib.Path(__file__).resolve().parents[1] - / "plugin_bundles" / "marketplace" / "yida" / "plugin.json" + / "plugin_bundles" / "marketplace" / "yida" ) - assert _json.loads(p.read_text(encoding="utf-8")).get("connection") == "yida" + assert normalize_plugin_dir(p).connection == "yida" # ── Marketplace plugin installability ──────────────────────────────────── diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index 41bbdbb..df0e51c 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -1700,6 +1700,18 @@ export async function setPluginEnabled(installId: string, enabled: boolean): Pro }); } +// Edit my imported/private plugin's display metadata (name/category/icon are UI +// config — the Agent Plugins standard plugin.json carries no display fields). +export async function setInstalledPluginMeta( + installId: string, + meta: { display_name?: string; category?: string; icon?: string }, +): Promise { + await apiRequest(`/v1/plugins/installed/${encodeURIComponent(installId)}/meta`, { + method: 'PATCH', + body: JSON.stringify(meta), + }); +} + export interface LarkAppInitStatus { configured: boolean; app_id: string | null; diff --git a/src/frontend/src/components/catalog/PluginIconPicker.tsx b/src/frontend/src/components/catalog/PluginIconPicker.tsx new file mode 100644 index 0000000..d3831a7 --- /dev/null +++ b/src/frontend/src/components/catalog/PluginIconPicker.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { Popover, Button, Upload, message } from 'antd'; +import type { UploadProps } from 'antd'; +import { AppstoreOutlined, UploadOutlined, UndoOutlined } from '@ant-design/icons'; +import { t } from '../../i18n'; +import { APP_ICON_LIBRARY, PLUGIN_ICON_LIBRARY } from '../../utils/iconLibrary'; + +const MAX_UPLOAD_BYTES = 80 * 1024; // raw image cap; ~107KB after base64, < the backend's 200KB cap + +/** Plugin avatar: built-in library path / data-URI / URL; falls back to the generic plugin glyph. */ +export function PluginAvatar({ icon, size = 36 }: { icon?: string | null; size?: number }) { + const value = String(icon || '').trim(); + if (value) { + return ( + + ); + } + return ( +
+ +
+ ); +} + +// Plugin icon picker: pick from the built-in SVG library or upload a custom image +// (stored inline as a data-URI). No URL typing — icons are chosen, not pasted. +// Implements the antd Form.Item value/onChange contract. +export function PluginIconPicker({ + value, onChange, +}: { value?: string; onChange?: (icon: string) => void }) { + const [open, setOpen] = useState(false); + + const pick = (icon: string) => { + onChange?.(icon); + setOpen(false); + }; + + const beforeUpload: UploadProps['beforeUpload'] = (file) => { + const okType = /^image\/(svg\+xml|png|jpeg|webp)$/.test(file.type); + if (!okType) { + message.error(t('仅支持 SVG / PNG / JPG / WebP 图标')); + return Upload.LIST_IGNORE; + } + if (file.size > MAX_UPLOAD_BYTES) { + message.error(t('图标过大,请控制在 {n}KB 以内', { n: MAX_UPLOAD_BYTES / 1024 })); + return Upload.LIST_IGNORE; + } + const reader = new FileReader(); + reader.onload = () => { + pick(String(reader.result || '')); + message.success(t('图标已选用')); + }; + reader.onerror = () => message.error(t('读取图标失败')); + reader.readAsDataURL(file); + return Upload.LIST_IGNORE; // save inline as a data-URI, no server upload round-trip + }; + + const section = (label: string, icons: string[]) => ( + <> +
{label}
+
+ {icons.map((url) => ( + + ))} +
+ + ); + + const content = ( +
+
+ {section(t('插件图标'), PLUGIN_ICON_LIBRARY)} + {section(t('应用图标'), APP_ICON_LIBRARY)} +
+
+ + + + +
+
+ ); + + return ( + + + + ); +} diff --git a/src/frontend/src/components/catalog/PluginsPage.tsx b/src/frontend/src/components/catalog/PluginsPage.tsx index 9c6c3a8..3eadae3 100644 --- a/src/frontend/src/components/catalog/PluginsPage.tsx +++ b/src/frontend/src/components/catalog/PluginsPage.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion } from 'motion/react'; import { Switch, Tag, Input, Typography, Button, Modal, Form, Popconfirm, message, Empty, Spin, Dropdown, Alert } from 'antd'; import { - SearchOutlined, LeftOutlined, DeleteOutlined, AppstoreOutlined, PlusOutlined, DownOutlined, - AppstoreAddOutlined, UploadOutlined, ApiOutlined, BulbOutlined, CheckCircleOutlined, WarningOutlined, StopOutlined, + SearchOutlined, LeftOutlined, DeleteOutlined, PlusOutlined, DownOutlined, + AppstoreAddOutlined, EditOutlined, UploadOutlined, ApiOutlined, BulbOutlined, CheckCircleOutlined, WarningOutlined, StopOutlined, } from '@ant-design/icons'; import { t } from '../../i18n'; import { useCatalogStore, useAuthStore, useEditionStore, usePluginStore } from '../../stores'; @@ -16,9 +16,10 @@ import { LarkConnect } from '../settings/LarkConnect'; import { EmailConnect } from '../settings/EmailConnect'; import { YidaConnect } from '../settings/YidaConnect'; import { LarkAppInitCard } from './LarkAppInitCard'; +import { PluginAvatar, PluginIconPicker } from './PluginIconPicker'; import { listPlugins, listInstalledPlugins, getInstalledPluginDetail, - installPlugin, importPlugin, uninstallPlugin, setPluginEnabled, + installPlugin, importPlugin, uninstallPlugin, setPluginEnabled, setInstalledPluginMeta, } from '../../api'; import type { PluginListItem, InstalledPluginItem, InstalledPluginDetail, @@ -34,12 +35,8 @@ function normSecret(s: string | PluginRequiredSecret): PluginRequiredSecret { return typeof s === 'string' ? { key: s, label: s, required: true } : s; } -function PluginIcon({ size = 36 }: { size?: number }) { - return ( -
- -
- ); +function PluginIcon({ icon, size = 36 }: { icon?: string | null; size?: number }) { + return ; } function sourceLabel(source?: string): string | null { @@ -104,6 +101,38 @@ export function PluginsPage() { ]); }, [refresh, fetchCatalog]); + // ── Display metadata edit (my imported/private plugins only; name/category are + // UI config — the Agent Plugins standard plugin.json carries no display fields) ── + const [metaTarget, setMetaTarget] = useState(null); + const [metaBusy, setMetaBusy] = useState(false); + const [metaForm] = Form.useForm(); + + const openMeta = useCallback((p: InstalledPluginItem) => { + setMetaTarget(p); + metaForm.setFieldsValue({ display_name: p.name || '', category: p.category || '', icon: p.icon || '' }); + }, [metaForm]); + + const submitMeta = useCallback(async () => { + if (!metaTarget) return; + const values = await metaForm.validateFields().catch(() => null); + if (!values) return; + setMetaBusy(true); + try { + await setInstalledPluginMeta(metaTarget.install_id, { + display_name: values.display_name ?? '', + category: values.category ?? '', + icon: values.icon ?? '', + }); + message.success(t('展示信息已保存')); + setMetaTarget(null); + await afterChange(); + } catch (e) { + message.error((e as Error).message || t('保存失败')); + } finally { + setMetaBusy(false); + } + }, [metaTarget, metaForm, afterChange]); + // ── Navigation ── const openInstalled = useCallback(async (installId: string) => { try { @@ -341,7 +370,7 @@ export function PluginsPage() { - + {d.name} v{d.version} {srcLabel && {srcLabel}} @@ -522,7 +551,7 @@ export function PluginsPage() {
void openInstalled(p.install_id)}>
- +
{p.name} {!isCE && p.is_global && {t('管理员')}} @@ -535,6 +564,10 @@ export function PluginsPage() { void handleToggle(p.install_id, v)} checkedChildren={t('启用')} unCheckedChildren={t('停用')} /> + {!p.is_global && ( +