From c9c2dc7ea83a3cffe992eab7a20ae7d3dbd12fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 12 Aug 2026 14:47:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=A0=E9=99=A4=EF=BC=8C=E5=B2=97=E4=BD=8D?= =?UTF-8?q?=E8=A1=8C=E5=8A=A8=E8=A7=84=E5=88=92=E5=AF=B9=E6=8E=A5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=B3=BB=E7=BB=9F=E5=AD=97=E5=85=B8=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=96=B0=E5=A2=9E=20Skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型管理新增 DELETE 接口与前端删除能力(支持含斜杠的模型名);行动规划改为 9 字段 JSON,prepare 传入 taskId/字典并绑定 position-roundtable-action-create Skill。 Co-authored-by: Cursor --- .gitignore | 4 +- frontend-web/src/core/model-management/api.ts | 7 + .../src/core/model-management/hooks.ts | 14 + .../src/pages/ModelManagementPage.tsx | 259 +++++++++++++++++- .../app/gateway/routers/_roundtable_seed.py | 49 +++- .../position-action-planner/SOUL.md | 28 +- .../position-action-planner/config.yaml | 2 + .../app/gateway/routers/model_management.py | 16 +- .../gateway/routers/position_roundtable.py | 86 +++++- offline-backend-20260512/backend/config.yaml | 128 ++++----- .../backend/tests/test_model_management.py | 59 ++++ .../test_position_roundtable_sessions.py | 140 ++++++++++ 12 files changed, 697 insertions(+), 95 deletions(-) diff --git a/.gitignore b/.gitignore index 64e2ad7d..f29e9f96 100644 --- a/.gitignore +++ b/.gitignore @@ -77,8 +77,8 @@ offline-backend-20260512/backend/.deer-flow/* # Per-user skill_evolution overrides — runtime data written by the API offline-backend-20260512/backend/skill_evolution.json -skills/ -hermes-agent-main/ +skills/ +hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* # Keep the model configuration regression coverage versioned even though diff --git a/frontend-web/src/core/model-management/api.ts b/frontend-web/src/core/model-management/api.ts index aaa74bef..75182171 100644 --- a/frontend-web/src/core/model-management/api.ts +++ b/frontend-web/src/core/model-management/api.ts @@ -96,6 +96,13 @@ export async function updateManagedModel(name: string, payload: ModelSavePayload return response.json() as Promise; } +export async function deleteManagedModel(name: string): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + if (!response.ok) return readError(response, "删除模型失败"); +} + export async function testManagedModel(model: ModelSavePayload): Promise { const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, { method: "POST", diff --git a/frontend-web/src/core/model-management/hooks.ts b/frontend-web/src/core/model-management/hooks.ts index 65a9e4b7..f4a504f8 100644 --- a/frontend-web/src/core/model-management/hooks.ts +++ b/frontend-web/src/core/model-management/hooks.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createManagedModel, + deleteManagedModel, loadManagedModels, testManagedModel, updateManagedModel, @@ -36,3 +37,16 @@ export function useSaveManagedModel() { export function useTestManagedModel() { return useMutation({ mutationFn: testManagedModel }); } + +export function useDeleteManagedModel() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteManagedModel, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: ["models"] }), + ]); + }, + }); +} diff --git a/frontend-web/src/pages/ModelManagementPage.tsx b/frontend-web/src/pages/ModelManagementPage.tsx index 567a867b..6a984adb 100644 --- a/frontend-web/src/pages/ModelManagementPage.tsx +++ b/frontend-web/src/pages/ModelManagementPage.tsx @@ -10,6 +10,7 @@ import { RefreshCwIcon, Settings2Icon, SparklesIcon, + Trash2Icon, WrenchIcon, } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; @@ -28,6 +29,7 @@ import { import { type ManagedModel, type ModelSavePayload, + useDeleteManagedModel, useManagedModels, useSaveManagedModel, useTestManagedModel, @@ -151,6 +153,41 @@ function Field({ label, required = false, children }: { label: string; required? return ; } +function ModelCapabilityBadge({ + icon: Icon, + label, + className, +}: { + icon?: typeof KeyRoundIcon; + label: string; + className: string; +}) { + return ( + + {Icon && } + {label} + + ); +} + +function showGlobalMessage( + theme: "success" | "error" | "warning", + content: string, +) { + // Schedule after the modal state update so the message is rendered above + // the page, rather than inside or behind a closing dialog. + window.setTimeout(() => { + MessagePlugin.closeAll(); + void MessagePlugin[theme]({ + content, + duration: 5_000, + placement: "top", + zIndex: 10_000, + attach: () => document.body, + }); + }, 0); +} + function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) { const isNew = model === null; const [draft, setDraft] = useState(() => model ? draftFromModel(model) : emptyDraft()); @@ -165,10 +202,10 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: const payload = buildPayload(draft); const result = await test.mutateAsync(payload); setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview }); - if (!result.success) return void MessagePlugin.error(`连接失败:${result.message}`); - MessagePlugin.success(`连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`); + if (!result.success) return void showGlobalMessage("error", `连接失败:${result.message}`); + showGlobalMessage("success", `连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`); } catch (error) { - MessagePlugin.error(error instanceof Error ? error.message : "连接测试失败"); + showGlobalMessage("error", error instanceof Error ? error.message : "连接测试失败"); } }; @@ -176,13 +213,18 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: try { const payload = buildPayload(draft); if (!payload.name || !payload.use || !payload.model) { - return void MessagePlugin.warning("请填写模型标识、驱动与实际模型名"); + return void showGlobalMessage("warning", "请填写模型标识、驱动与实际模型名"); } await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew }); - MessagePlugin.success(isNew ? "模型已添加,所有页面的模型选择器已刷新" : "模型配置已保存并立即生效"); onClose(); + showGlobalMessage( + "success", + isNew + ? `模型“${payload.name}”已添加,所有页面的模型选择器已刷新。` + : `模型“${payload.name}”的配置已保存并立即生效。`, + ); } catch (error) { - MessagePlugin.error(error instanceof Error ? error.message : "保存模型配置失败"); + showGlobalMessage("error", error instanceof Error ? error.message : "保存模型配置失败"); } }; @@ -231,6 +273,8 @@ export default function ModelManagementPage() { const [search, setSearch] = useState(""); const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all"); const [editor, setEditor] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const remove = useDeleteManagedModel(); const models = useMemo(() => { const keyword = search.trim().toLowerCase(); return (data?.models ?? []).filter((model) => { @@ -240,7 +284,208 @@ export default function ModelManagementPage() { }); }, [data?.models, filter, search]); const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]); + const handleDelete = async () => { + if (!deleteTarget) + return; + try { + await remove.mutateAsync(deleteTarget.name); + const deletedName = deleteTarget.name; + setDeleteTarget(null); + showGlobalMessage("success", `模型“${deletedName}”已删除,配置已立即生效。`); + } + catch (error) { + showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。"); + } + }; return ( -

模型配置

管理当前部署 config.yaml 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。

void refetch()}>刷新 setEditor("new")}>添加模型
{([ ["all", "全部"], ["thinking", "支持思考"], ["vision", "支持视觉"] ] as const).map(([value, label]) => setFilter(value)}>{label})}
setSearch(String(value))} />
{isLoading &&
加载模型配置…
}{error &&
{error.message}
}{!isLoading && !error && models.length === 0 &&
没有匹配的模型配置
}{!isLoading && !error && models.length > 0 &&
{models.map((model) =>

{model.display_name || model.name}

config.yaml

{model.name}

setEditor(model)}>配置
{model.description ?

{model.description}

:

未填写描述

}
驱动:{model.use}模型:{model.model}
{model.api_key_configured && 已配置密钥}{model.supports_thinking && 思考}{model.supports_vision && 视觉}{model.supports_reasoning_effort && 推理档位}
)}
}
{data?.config_path &&

当前配置文件:{data.config_path}{data.updated_at ? ` · 最近更新 ${new Date(data.updated_at).toLocaleString()}` : ""}

}
{editor !== null && setEditor(null)} />}
+ + + +
+
+
+
+
+ + + +

模型配置

+
+

+ 管理当前部署 config.yaml 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。 +

+
+
+ void refetch()}> + 刷新 + + setEditor("new")}> + 添加模型 + +
+
+ +
+ + + +
+ +
+
+
+ {([ + ["all", "全部"], + ["thinking", "支持思考"], + ["vision", "支持视觉"], + ] as const).map(([value, label]) => ( + setFilter(value)} + > + {label} + + ))} +
+ setSearch(String(value))} + /> +
+ + {isLoading && ( +
+ 加载模型配置… +
+ )} + {error && ( +
+ {error.message} +
+ )} + {!isLoading && !error && models.length === 0 && ( +
+ 没有匹配的模型配置 +
+ )} + {!isLoading && !error && models.length > 0 && ( +
+ {models.map((model) => ( +
+
+
+
+

{model.display_name || model.name}

+ config.yaml +
+

{model.name}

+
+
+ setEditor(model)}> + 配置 + + setDeleteTarget(model)} + > + 删除 + +
+
+ {model.description ? ( +

{model.description}

+ ) : ( +

未填写描述

+ )} +
+ 驱动:{model.use} + 模型:{model.model} +
+
+ {model.api_key_configured && ( + + )} + {model.supports_thinking && ( + + )} + {model.supports_vision && ( + + )} + {model.supports_reasoning_effort && ( + + )} +
+
+ ))} +
+ )} +
+ + {data?.config_path && ( +

+ 当前配置文件:{data.config_path} + {data.updated_at ? " · 最近更新 " + new Date(data.updated_at).toLocaleString() : ""} +

+ )} +
+
+
+ + {editor !== null && ( + setEditor(null)} + /> + )} + + {deleteTarget && ( + { + if (!remove.isPending) setDeleteTarget(null); + }} + onCancel={() => { + if (!remove.isPending) setDeleteTarget(null); + }} + onConfirm={() => void handleDelete()} + > +

+ 确定要删除模型 {deleteTarget.name} 吗?删除后会立即更新运行中的模型配置,依赖它的智能体或任务可能无法继续使用。 +

+
+ )} +
); } diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py index cb29cabe..b26e46a7 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -30,7 +30,9 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内 > ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。 > 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。 -> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。 +> ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。 +> 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的 +> ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。 为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建 ------------------------------------------------------------ @@ -52,6 +54,8 @@ import logging from collections.abc import Iterable from pathlib import Path +import yaml + from deerflow.config.paths import get_paths logger = logging.getLogger(__name__) @@ -108,6 +112,47 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( # seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。 _SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md") +_ACTION_PLAN_SKILL = "position-roundtable-action-create" + + +def _ensure_action_plan_skill(agent_dir: Path) -> bool: + """Merge the owned batch-create Skill into an existing action planner. + + The planner SOUL remains editable in the agent configuration drawer and is + therefore never overwritten here. The skill binding, however, is a runtime + integration contract: without it an already-deployed planner cannot read + the new SKILL.md or invoke the batch API script after a code upgrade. + """ + config_path = agent_dir / "config.yaml" + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {} + except (OSError, yaml.YAMLError): + logger.warning("Unable to read action planner config for skill binding: %s", config_path) + return False + if not isinstance(config, dict): + logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path) + return False + skills = config.get("skills") + if skills is None: + normalized: list[str] = [] + elif isinstance(skills, list): + normalized = [str(name).strip() for name in skills if str(name).strip()] + else: + logger.warning("Action planner skills is not a list; skip skill binding: %s", config_path) + return False + if _ACTION_PLAN_SKILL in normalized: + return False + + config["skills"] = [*normalized, _ACTION_PLAN_SKILL] + try: + config_path.write_text( + yaml.safe_dump(config, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + except OSError: + logger.warning("Unable to write action planner skill binding: %s", config_path) + return False + return True def _ensure_one(agent_id: str) -> bool: @@ -135,6 +180,8 @@ def _ensure_one(agent_id: str) -> bool: agent_dir = get_paths().agent_dir(agent_id) if agent_dir.exists(): + if agent_id == ACTION_PLAN_AGENT_ID: + return _ensure_action_plan_skill(agent_dir) if agent_id != POSITION_INTENT_AGENT_ID: # Other built-in agents may have been tuned on a deployed machine; # preserve the long-standing no-overwrite behavior for them. diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md index db088a9d..62de1c3a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md @@ -26,23 +26,29 @@ ```json { - "id": "subtask-001", - "title": "简短任务名称", - "goal": "任务目标", - "basis": ["方案总结章节或岗位交付依据"], - "owner_position": "建议责任岗位", - "dependencies": ["前置子任务 id"], - "deliverable": "可核验交付物", - "acceptance_criteria": ["验收条件"], - "priority": "P0" + "actionName": "行动名称", + "actionDescription": "行动描述", + "actionType": 0, + "publishChannel": 0, + "infoFormat": 0, + "responsiblePosition": 0, + "startTime": "", + "endTime": "", + "taskId": 0 } ``` -`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。 +`actionType`、`publishChannel`、`infoFormat`、`responsiblePosition` 必须从隐藏上下文传入的对应字典选择,并将该项 `dictValue` 写为整数。`taskId` 必须等于隐藏上下文中前端传入的任务 ID。不要输出注释、尾逗号、旧的 `id/title/goal/basis` 等字段,不能用 0 或空值代替未选择的字典项。 + +## 行动批量新增 + +首次生成完整行动清单后:仅当隐藏上下文的 `taskId` 是正整数且四类字典(发布渠道、行动类型、信息形式、责任岗位)均完整时,必须先用 `read_file` 读取 `position-roundtable-action-create` Skill 的 `SKILL.md` 全文,再按其说明调用脚本批量新增行动。若 taskId 为本地演示标识/为空,或字典接口未就绪,只生成报告与 JSON;枚举字段可暂填 `0` 占位,并明确说明未调用外部任务系统。不得逐条调用旧的 `/task/action/add`,也不得自行用 curl 或 requests 替代脚本。 + +脚本成功时才可以说明行动已入库;脚本失败时如实报告失败原因。相同任务、相同行动清单会由脚本自动幂等跳过。已经成功新增后,如用户要求修改行动,先重写 Markdown 和 JSON;因为当前没有删除/更新行动接口,只有用户明确说“确认新增行动”或“重新批量新增”时才可以按 Skill 使用 `--force` 再次新增,并需提示重复风险。 ## 边界 - 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。 -- 不调用联网、派活、澄清、展示文件等工具。 +- 不调用联网、派活、澄清、展示文件等工具;仅可为行动批量新增按专属 Skill 使用 `read_file` 与 `bash`。 - 不删除或修改上游岗位产物。 - 不在报告中添加“AI 生成”类水印。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml index 76bd1184..27613581 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml @@ -1,3 +1,5 @@ id: position-action-planner name: 行动规划智能体 description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。 +skills: + - position-roundtable-action-create diff --git a/offline-backend-20260512/backend/app/gateway/routers/model_management.py b/offline-backend-20260512/backend/app/gateway/routers/model_management.py index eba39fb2..e77b4b06 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/model_management.py +++ b/offline-backend-20260512/backend/app/gateway/routers/model_management.py @@ -378,7 +378,7 @@ async def create_model_configuration(body: ModelUpsertRequest, request: Request) return _to_admin_item(created) -@router.put("/{model_name}", response_model=ModelAdminItem) +@router.put("/{model_name:path}", response_model=ModelAdminItem) async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem: await _require_admin(request) if body.name != model_name: @@ -396,6 +396,20 @@ async def update_model_configuration(model_name: str, body: ModelUpsertRequest, return _to_admin_item(updated) +@router.delete("/{model_name:path}", status_code=204) +async def delete_model_configuration(model_name: str, request: Request) -> None: + """Delete one model configuration and activate the remaining model list.""" + + await _require_admin(request) + async with _MUTATION_LOCK: + path = _resolve_config_path() + raw_before, _, models = await asyncio.to_thread(_read_config_data, path) + index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None) + if index is None: + raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found") + await _save_models(request, raw_before, [*models[:index], *models[index + 1 :]]) + + @router.post("/test", response_model=ModelTestResponse) async def test_model_connection(body: ModelTestRequest, request: Request) -> ModelTestResponse: """Perform one bounded minimal completion without persisting the draft.""" diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index c4089144..eacc2a33 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -156,10 +156,33 @@ class RejectNodeRequest(BaseModel): reason: str = Field(default="", max_length=5_000) +class ActionPlanDictionaryOption(BaseModel): + """One task-system dictionary row, preserving dictValue as the model input.""" + + label: str = Field(default="", max_length=256) + value: str | int | float = "" + id: str | int | None = None + dict_type: str = Field(default="", max_length=128) + is_default: bool = False + + +class ActionPlanDictionaries(BaseModel): + """The four action fields that must be selected from task-system dictionaries.""" + + publish_channels: list[ActionPlanDictionaryOption] = Field(default_factory=list) + action_types: list[ActionPlanDictionaryOption] = Field(default_factory=list) + info_formats: list[ActionPlanDictionaryOption] = Field(default_factory=list) + responsible_positions: list[ActionPlanDictionaryOption] = Field(default_factory=list) + + class PrepareBuiltinRunRequest(BaseModel): """Optional follow-up instruction for one persisted built-in agent.""" message: str = Field(default="", max_length=20_000) + # 岗位会商会话允许本地演示使用非数字 taskId。真实任务系统新增行动 + # 时由专属 Skill 再按外部接口契约校验正整数,不能在 prepare 阶段阻断报告生成。 + task_id: str | int | None = Field(default=None, max_length=128) + action_dictionaries: ActionPlanDictionaries | None = None class BuiltinRunPreparationResponse(BaseModel): @@ -1060,6 +1083,8 @@ async def _build_builtin_prompt( session: dict[str, Any], nodes: list[dict[str, Any]], follow_up: str = "", + action_task_id: str | int | None = None, + action_dictionaries: ActionPlanDictionaries | None = None, ) -> str: task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {} intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {} @@ -1103,13 +1128,50 @@ async def _build_builtin_prompt( else: summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {} summary_content = _truncate_text(summary.get("content"), 12_000) + dictionary_groups = ( + ("发布渠道(publishChannel)", action_dictionaries.publish_channels if action_dictionaries else []), + ("行动类型(actionType)", action_dictionaries.action_types if action_dictionaries else []), + ("信息形式(infoFormat)", action_dictionaries.info_formats if action_dictionaries else []), + ("责任岗位(responsiblePosition)", action_dictionaries.responsible_positions if action_dictionaries else []), + ) + dictionaries_ready = bool(action_task_id is not None and all(options for _, options in dictionary_groups)) + dictionary_lines = [ + "【前端传入的行动字典(名称 → 必须原样使用的 dictValue)】", + f"- 当前 taskId:{action_task_id if action_task_id is not None else '(本地预览)'}", + ] + for label, options in dictionary_groups: + dictionary_lines.append(f"- {label}:") + dictionary_lines.extend( + f" - {option.label} → {option.value}" + for option in options + ) + dictionary_instruction = ( + "actionType、publishChannel、infoFormat、responsiblePosition 必须从下方同名字典复制 dictValue 并写成整数;" + "taskId 必须原样写为当前 taskId。不要保留 id、title、goal、basis、owner_position、dependencies 等旧字段," + "也不要用 0 或空值代替未选择的字典项。" + if dictionaries_ready + else "当前四类任务系统字典未获取完整,这是本地预览/接口未就绪状态。仍需生成 9 字段 JSON," + "但四个枚举字段可暂填 0 占位,且不得调用 position-roundtable-action-create Skill 或外部任务系统。" + ) + submit_instruction = ( + "若当前 taskId 是正整数,首次生成完整行动清单后按行动规划智能体的 SOUL 调用 position-roundtable-action-create Skill 批量新增行动;" + "若 taskId 为本地演示标识或为空,只生成报告和 JSON,明确说明未调用任务系统。Skill 返回失败时如实报告,不能伪称已入库。" + if dictionaries_ready + else "本轮字典不完整,只生成报告和 JSON 预览;不得调用 Skill、不得请求外部任务系统,也不能宣称行动已入库。" + ) instructions = [ "请基于以下方案总结和岗位会商材料,生成或按用户要求修订《岗位会商行动规划》。", "必须使用 write_file 分别写入两个最终交付:`/mnt/user-data/outputs/行动规划报告.md` 与 `/mnt/user-data/outputs/action-plan-subtasks.json`。", - "Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排;JSON 必须是严格合法数组,每个子任务都包含 id、title、goal、basis、owner_position、dependencies、deliverable、acceptance_criteria、priority。", + "Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排。" + "JSON 必须是严格合法数组;每个对象只能包含 actionName、actionDescription、actionType、" + "publishChannel、infoFormat、responsiblePosition、startTime、endTime、taskId 这 9 个字段。", + dictionary_instruction, + submit_instruction, "", "【已生效方案总结】", summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)", + "", + *dictionary_lines, ] if follow_up.strip(): instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()]) @@ -1549,6 +1611,8 @@ async def _prepare_builtin_run( *, kind: str, follow_up: str = "", + action_task_id: str | int | None = None, + action_dictionaries: ActionPlanDictionaries | None = None, ) -> BuiltinRunPreparationResponse: store, user_id, session = await _require_writable_session(request, session_id) nodes = await store.list_nodes(session_id, user_id) @@ -1582,6 +1646,8 @@ async def _prepare_builtin_run( session=session, nodes=nodes, follow_up=follow_up, + action_task_id=action_task_id, + action_dictionaries=action_dictionaries, ), source_revisions=_source_revisions(nodes), ) @@ -1689,14 +1755,28 @@ async def complete_summary( async def prepare_action_plan( request: Request, session_id: str, body: PrepareBuiltinRunRequest ) -> BuiltinRunPreparationResponse: - return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message) + return await _prepare_builtin_run( + request, + session_id, + kind="action_plan", + follow_up=body.message, + action_task_id=body.task_id, + action_dictionaries=body.action_dictionaries, + ) @router.post("/sessions/{session_id}/action-plan/ask", response_model=BuiltinRunPreparationResponse) async def ask_action_plan( request: Request, session_id: str, body: PrepareBuiltinRunRequest ) -> BuiltinRunPreparationResponse: - return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message) + return await _prepare_builtin_run( + request, + session_id, + kind="action_plan", + follow_up=body.message, + action_task_id=body.task_id, + action_dictionaries=body.action_dictionaries, + ) @router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index e4c9b6a7..45695821 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -33,32 +33,64 @@ concurrency: fail_open: true models: - - name: deepseek-chat - display_name: DeepSeek Chat - use: langchain_openai:ChatOpenAI - model: deepseek-chat - api_key: $DEEPSEEK_CHAT_API_KEY - base_url: https://api.deepseek.com - request_timeout: 600.0 - max_retries: 2 - max_tokens: 4096 - temperature: 1.0 - supports_vision: false - supports_thinking: true - - - name: zai-org/GLM-5-FP8 - display_name: zai-org/GLM-5-FP8 - use: langchain_openai:ChatOpenAI - model: zai-org/GLM-5-FP8 - api_key: $GLM5_FP8_API_KEY - base_url: https://api.us-west-2.modal.direct/v1 - request_timeout: 600.0 - max_retries: 2 - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: true - +- name: deepseek-chat + display_name: DeepSeek Chat + use: langchain_openai:ChatOpenAI + model: deepseek-chat + api_key: $DEEPSEEK_CHAT_API_KEY + base_url: https://api.deepseek.com + request_timeout: 600.0 + max_retries: 2 + max_tokens: 4096 + temperature: 1.0 + supports_vision: false + supports_thinking: true +- name: zai-org/GLM-5-FP8 + display_name: zai-org/GLM-5-FP8 + use: langchain_openai:ChatOpenAI + model: zai-org/GLM-5-FP8 + api_key: $GLM5_FP8_API_KEY + base_url: https://api.us-west-2.modal.direct/v2 + request_timeout: 600.0 + max_retries: 2 + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true + supports_reasoning_effort: false +- name: deepseek-ai/DeepSeek-V4-Flash + display_name: deepseek-ai/DeepSeek-V4-Flash + use: langchain_openai:ChatOpenAI + model: deepseek-ai/DeepSeek-V4-Flash + api_key: $SILICONFLOW_API_KEY + base_url: https://api.siliconflow.cn/v1/ + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true + when_thinking_disabled: + extra_body: + enable_thinking: false +- name: inclusionAI/Ling-flash-2.0 + display_name: inclusionAI/Ling-flash-2.0 + use: langchain_openai:ChatOpenAI + model: inclusionAI/Ling-flash-2.0 + api_key: $SILICONFLOW_API_KEY + base_url: https://api.siliconflow.cn/v1/ + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true +- name: deepseek-ai/DeepSeek-V4-Pro + display_name: deepseek-ai/DeepSeek-V4-Pro + use: langchain_openai:ChatOpenAI + model: deepseek-ai/DeepSeek-V4-Pro + api_key: $SILICONFLOW_API_KEY + base_url: https://api.siliconflow.cn/v1/ + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true # - name: deepseek-v4-flash # display_name: DeepSeek V4 Flash # use: langchain_openai:ChatOpenAI @@ -71,7 +103,6 @@ models: # temperature: 1.0 # supports_vision: false # supports_thinking: true - # - name: deepseek-v4-pro # display_name: DeepSeek V4 Pro # use: langchain_openai:ChatOpenAI @@ -136,52 +167,9 @@ models: # temperature: 1.0 # supports_vision: true # supports_thinking: true - - name: deepseek-ai/DeepSeek-V4-Flash - display_name: deepseek-ai/DeepSeek-V4-Flash - use: langchain_openai:ChatOpenAI - model: deepseek-ai/DeepSeek-V4-Flash - api_key: $SILICONFLOW_API_KEY - base_url: https://api.siliconflow.cn/v1/ - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: true # SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」, # 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后, # 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。 - when_thinking_disabled: - extra_body: - enable_thinking: false - - name: Pro/MiniMaxAI/MiniMax-M2.5 - display_name: Pro/MiniMaxAI/MiniMax-M2.5 - use: langchain_openai:ChatOpenAI - model: Pro/MiniMaxAI/MiniMax-M2.5 - api_key: $SILICONFLOW_API_KEY - base_url: https://api.siliconflow.cn/v1/ - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: true - - name: inclusionAI/Ling-flash-2.0 - display_name: inclusionAI/Ling-flash-2.0 - use: langchain_openai:ChatOpenAI - model: inclusionAI/Ling-flash-2.0 - api_key: $SILICONFLOW_API_KEY - base_url: https://api.siliconflow.cn/v1/ - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: true - - name: deepseek-ai/DeepSeek-V4-Pro - display_name: deepseek-ai/DeepSeek-V4-Pro - use: langchain_openai:ChatOpenAI - model: deepseek-ai/DeepSeek-V4-Pro - api_key: $SILICONFLOW_API_KEY - base_url: https://api.siliconflow.cn/v1/ - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: true # 识图(图片识别)配置。 # - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把 # 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持 diff --git a/offline-backend-20260512/backend/tests/test_model_management.py b/offline-backend-20260512/backend/tests/test_model_management.py index 728e6708..2e0ff11a 100644 --- a/offline-backend-20260512/backend/tests/test_model_management.py +++ b/offline-backend-20260512/backend/tests/test_model_management.py @@ -6,6 +6,7 @@ from types import SimpleNamespace import pytest import yaml +from starlette.routing import Match from app.gateway.routers import model_management as router @@ -53,6 +54,64 @@ def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None: assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***" +def test_model_mutation_routes_accept_names_with_slashes() -> None: + for method in ("PUT", "DELETE"): + route = next( + item + for item in router.router.routes + if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}" + ) + match, child_scope = route.matches( + { + "type": "http", + "method": method, + "path": "/api/admin/models/zai-org/GLM-5-FP8", + } + ) + assert match is Match.FULL + assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"} + + +@pytest.mark.asyncio +async def test_delete_model_configuration_removes_matching_model_and_activates_config( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.yaml" + config_path.write_text( + """models: + - name: zai-org/GLM-5-FP8 + use: langchain_openai:ChatOpenAI + model: GLM-5-FP8 + - name: keep-me + use: langchain_openai:ChatOpenAI + model: keep-me +""", + encoding="utf-8", + ) + saved: dict[str, object] = {} + + async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]: + saved["raw_before"] = raw_before + saved["models"] = models + return models + + monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path) + monkeypatch.setattr(router, "_save_models", save_models) + request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin"))) + + await router.delete_model_configuration("zai-org/GLM-5-FP8", request) + + assert "zai-org/GLM-5-FP8" in str(saved["raw_before"]) + assert saved["models"] == [ + { + "name": "keep-me", + "use": "langchain_openai:ChatOpenAI", + "model": "keep-me", + } + ] + + @pytest.mark.asyncio async def test_connection_test_uses_one_minimal_prompt_without_writing_config( tmp_path, diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index ed1b97df..b159196d 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -3,6 +3,9 @@ from __future__ import annotations import asyncio +import importlib.util +import json +import zipfile from types import SimpleNamespace from typing import Any @@ -65,6 +68,120 @@ def test_position_intent_agent_is_a_seeded_functional_agent(): assert POSITION_INTENT_AGENT_ID in set(required_agent_ids()) +def test_action_plan_seed_binds_the_batch_create_skill_and_nine_field_contract(): + """New deployments must not fall back to the retired subtask JSON contract.""" + action_assets = roundtable_seed._ASSETS_DIR / roundtable_seed.ACTION_PLAN_AGENT_ID + soul = (action_assets / "SOUL.md").read_text(encoding="utf-8") + config = (action_assets / "config.yaml").read_text(encoding="utf-8") + + assert "position-roundtable-action-create" in config + for field in ( + "actionName", + "actionDescription", + "actionType", + "publishChannel", + "infoFormat", + "responsiblePosition", + "startTime", + "endTime", + "taskId", + ): + assert field in soul + assert "/task/action/add" in soul + assert "批量新增" in soul + + +@pytest.mark.asyncio +async def test_action_plan_prompt_receives_frontend_task_id_and_dictionary_values(): + """The action planner must see exact dictValue choices, not frontend labels only.""" + dictionaries = position_roundtable_router.ActionPlanDictionaries( + publish_channels=[{"label": "微信公众号", "value": "11"}], + action_types=[{"label": "宣传", "value": "21"}], + info_formats=[{"label": "图文", "value": "31"}], + responsible_positions=[{"label": "规划岗", "value": "41"}], + ) + prompt = await position_roundtable_router._build_builtin_prompt( + kind="action_plan", + session={ + "task_snapshot": {"title": "任务 A", "description": "生成行动"}, + "summary_snapshot": {"status": "done", "content": "方案总结正文"}, + }, + nodes=[], + action_task_id=1001, + action_dictionaries=dictionaries, + ) + + assert "当前 taskId:1001" in prompt + assert "微信公众号 → 11" in prompt + assert "宣传 → 21" in prompt + assert "图文 → 31" in prompt + assert "规划岗 → 41" in prompt + assert "id、title、goal" in prompt + + +@pytest.mark.asyncio +async def test_action_plan_prompt_degrades_when_task_dictionaries_are_unavailable(): + """A 404 from the task-system dictionaries must not block local planning.""" + prompt = await position_roundtable_router._build_builtin_prompt( + kind="action_plan", + session={ + "task_snapshot": {"title": "本地演示任务"}, + "summary_snapshot": {"status": "done", "content": "方案总结正文"}, + }, + nodes=[], + action_task_id="abc123", + action_dictionaries=position_roundtable_router.ActionPlanDictionaries(), + ) + + assert "字典未获取完整" in prompt + assert "不得调用 Skill" in prompt + assert "0 占位" in prompt + + +def test_action_plan_prepare_keeps_a_local_mock_task_id_without_numeric_coercion(): + """A local demo id must not stop the action-plan conversation at prepare time.""" + body = position_roundtable_router.PrepareBuiltinRunRequest(task_id="abc123") + + assert body.task_id == "abc123" + + +def test_action_batch_skill_accepts_only_the_nine_field_array(tmp_path): + """The batch Skill must reject old task-subdivision fields before any HTTP call.""" + archive_path = roundtable_seed._ASSETS_DIR.parents[3] / "skill-packages" / "position-roundtable-action-create.skill" + with zipfile.ZipFile(archive_path) as archive: + archive.extractall(tmp_path / "skill-package") + script_path = tmp_path / "skill-package" / "position-roundtable-action-create" / "scripts" / "batch_add_actions.py" + spec = importlib.util.spec_from_file_location("position_action_batch_skill", script_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + valid = [{ + "actionName": "发布内容", + "actionDescription": "向目标渠道发布审核通过的信息。", + "actionType": "1", + "publishChannel": "2", + "infoFormat": "3", + "responsiblePosition": "4", + "startTime": "2026-08-12T09:00:00+08:00", + "endTime": "2026-08-12T18:00:00+08:00", + "taskId": 1001, + }] + source = tmp_path / "action-plan-subtasks.json" + source.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8") + assert module.read_actions(source, 1001) == [{ + **valid[0], + "actionType": 1, + "publishChannel": 2, + "infoFormat": 3, + "responsiblePosition": 4, + }] + + source.write_text(json.dumps([{**valid[0], "id": "legacy"}], ensure_ascii=False), encoding="utf-8") + with pytest.raises(module.ActionInputError, match="多余字段"): + module.read_actions(source, 1001) + + def test_position_intent_prompt_keeps_the_original_step_one_guardrails(): """Dedicated schema must not regress into solutioning or gatekeeping.""" prompt = ( @@ -165,6 +282,29 @@ def test_position_intent_seed_repairs_its_contract_without_touching_other_agents assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune" +def test_existing_action_planner_only_merges_the_owned_skill(tmp_path, monkeypatch): + """A code upgrade may add the Skill without overwriting a user-tuned SOUL.""" + agents_dir = tmp_path / "agents" + monkeypatch.setattr( + roundtable_seed, + "get_paths", + lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id), + ) + action_dir = agents_dir / roundtable_seed.ACTION_PLAN_AGENT_ID + action_dir.mkdir(parents=True) + (action_dir / "SOUL.md").write_text("deployed action-plan tune", encoding="utf-8") + (action_dir / "config.yaml").write_text( + "id: position-action-planner\nskills:\n - existing-skill\n", + encoding="utf-8", + ) + + assert roundtable_seed._ensure_one(roundtable_seed.ACTION_PLAN_AGENT_ID) is True + config = (action_dir / "config.yaml").read_text(encoding="utf-8") + assert "existing-skill" in config + assert "position-roundtable-action-create" in config + assert (action_dir / "SOUL.md").read_text(encoding="utf-8") == "deployed action-plan tune" + + @pytest.mark.asyncio async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch): """Step 1 initialization must not depend on the deployed Gateway port."""