From ed85e832f0c27f043e3f00b9271d4ddb4cc0d819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= Date: Sun, 30 Aug 2026 20:39:29 +0800 Subject: [PATCH] bugfix --- doc/system-feature-list.md | 12 +- .../capabilities/agent/lifecycle-service.ts | 34 ++- .../server/management-test/mcp-sync.test.ts | 49 +++ .../server/test/mcp-service.test.js | 19 ++ .../src/pages/AgentsPage.jsx | 279 +++++++++++++++++- .../src/pages/AgentsPage.test.jsx | 144 +++++++++ 6 files changed, 508 insertions(+), 29 deletions(-) create mode 100644 packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx diff --git a/doc/system-feature-list.md b/doc/system-feature-list.md index 30c9d01..5a78c4b 100644 --- a/doc/system-feature-list.md +++ b/doc/system-feature-list.md @@ -73,14 +73,14 @@ ### 2.6 数字员工(Agent)管理 - [x][T] Agent 新增、列表、详情、编辑、启用/停用、删除;ID/slug 校验并保持与远端 Agent 一致。 -- [x][T] 配置名称、描述、标签、模板、主/备选 LLM、每日 Token 限额、状态和授权用户;用户范围支持指定用户或所有人,所有人模式对当前及未来新增的 Agent 平台用户动态生效。 +- [x][T] 配置名称、描述、标签、模板、主/备选 LLM、每日 Token 限额、状态和授权用户;概览 tab 在编辑状态下可分别修改主模型、备选模型和每日配额;用户范围支持指定用户或所有人,所有人模式对当前及未来新增的 Agent 平台用户动态生效。 - [x][T] 数字员工名称和编辑按钮均从列表进入全屏详情,操作列仅保留编辑、删除;详情包含只读概览、文件(默认加载 `AGENTS.md`)、技能、业务系统、MCP 和分配 tabs;技能、业务系统、MCP 三个 tab 复用员工模板对应的设置布局(模板式 `List`、`Segmented`、搜索、勾选、状态摘要和保存区域),数字员工技能页仅展示当前 Agent 专属 Skill,不展示全局或内置 Skill,分配复合框占满区块宽度,支持从现有用户选择或直接添加用户名,并支持全部当前及未来用户。 -- [x][T] 状态包括 `normal`、`disabled`、`overlimit`;用量刷新超过每日限额时自动标记 `overlimit`,不可直接恢复为 `normal` 直到超额问题解决。 +- [x][T] 状态包括 `normal`、`disabled`、`overlimit`;用量刷新完成后,Agent 列表与已打开的详情概览同步展示最新 Token 用量;超过每日限额时自动标记 `overlimit`,不可直接恢复为 `normal` 直到超额问题解决。 - [x][T] 创建/更新具备远端先行、本地 SQLite 落库、失败补偿和回滚;远端已不存在的 Agent 可清理本地记录。 - [x][T] Agent 用户目录支持分页搜索、单个新增、换行批量导入、删除和用量/关联员工查询。 - [x][T] 为 Agent 分配或移除用户,支持带策略版本的“所有人/指定用户”访问范围;外部目录按 `userName`/`id` 返回用户有权限的 Agent,未传身份时仅返回“所有人”可用的 Agent,并在每个目录项返回 `is_public` 标记,所有人模式对新增用户即时生效。 - [x][T] 服务层维护 Agent 用户访问策略 `selected/all`、策略版本和乐观并发更新;数据库 v17 迁移将历史 Agent 初始化为 `selected`,保持原有显式授权语义,管理端提供“所有人/指定用户”配置入口。 -- [x][T] 配置 Agent 业务系统权限(全部/选定)和 MCP 权限(全部/选定),带策略版本号并进行乐观并发控制。 +- [x][T] 配置 Agent 业务系统权限(全部/选定)和 MCP 权限(全部/选定),带策略版本号并进行乐观并发控制;MCP 无权限服务同步为普通工具和沙箱工具策略中的 Agent deny 规则,避免通过沙箱会话绕过隔离。 - [x][T] Agent 工作区导出为 ZIP;导出制品写入本地临时制品存储,下载后清理,不依赖 S3。 - [x][T] Agent 工作区 Markdown 文件列表、读取、创建、保存、移动/重命名、删除、下载,使用 etag 做乐观并发校验;文件页不展示额外的“新会话生效”提示。 - [x][T] 恢复 Agent 默认 `AGENTS.md`;Agent Skill 列表、ZIP 安装/替换、删除、导出,支持 etag 和 slug 校验。 @@ -93,7 +93,7 @@ - [x][T] HTTP 支持无认证、OAuth、自定义 Header;CLI 支持白名单可执行文件、参数、受控工作目录和环境变量。 - [x][T] 配置连接/请求超时、Tool include/exclude 过滤和并行 Tool Call 能力。 - [x][T] 保存前或保存后 Probe,读取 Tool 列表并保存最后一次探测结果;MCP 配置变更失败时回滚数据库和运行时投影。 -- [x][T] Agent/模板按全部或选定 MCP 服务生成运行时配置;无权限服务投影为 Agent deny 规则。 +- [x][T] Agent/模板按全部或选定 MCP 服务生成运行时配置;无权限服务投影为 Agent deny 规则,并同步到 `agents.list[].tools.deny` 与 `agents.list[].tools.sandbox.tools.deny`。 ### 2.8 业务系统(Ontology)管理 @@ -165,8 +165,8 @@ | 范围 | 主要验证内容 | 代表测试位置 | | --- | --- | --- | -| 管理 Web 服务 | 路由、鉴权、用户/Agent/模板/Skill/LLM/MCP/业务系统 CRUD、内置模板编辑/重命名与删除保护、模板主/备选模型和 Agent 继承、Agent 用户访问策略、外部 Agent 目录匿名公开筛选与 `is_public` 标记、仅面向 `/api/external/*` 的 OpenAPI/Swagger 文档 5 个操作及静态资源路由、审计、同步、健康检查、日志下载、SQLite 回滚和制品安全 | `packages/daedalus-agent-management-web/server/test/app-smoke.test.js`、其他 `server/test/`、`server/management-test/` | -| 管理 Web 前端 | API 客户端鉴权/超时/错误归一化、同步门控、重启轮询、格式化、浏览器覆盖契约、员工与模板列表操作精简、内置模板编辑、名称/编辑详情入口、详情 tabs 保存流程、员工技能/业务系统/MCP 与模板设置布局一致及内置 Skill 隐藏、模板文本文件切换后的编辑器可见性、设置页 API 文档入口 | `packages/daedalus-agent-management-web/src/**/*.test.js`、`test/browser-coverage.test.js`、`test/run-browser-e2e.js`、`test/integration/website-full.js` | +| 管理 Web 服务 | 路由、鉴权、用户/Agent/模板/Skill/LLM/MCP/业务系统 CRUD、内置模板编辑/重命名与删除保护、模板主/备选模型和 Agent 继承、Agent 用户访问策略、Agent 多实例 MCP 选定服务同步及普通/沙箱 deny 投影、外部 Agent 目录匿名公开筛选与 `is_public` 标记、仅面向 `/api/external/*` 的 OpenAPI/Swagger 文档 5 个操作及静态资源路由、审计、同步、健康检查、日志下载、SQLite 回滚和制品安全 | `packages/daedalus-agent-management-web/server/test/app-smoke.test.js`、`packages/daedalus-agent-management-web/server/test/mcp-service.test.js`、`packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts`、其他 `server/test/`、`server/management-test/` | +| 管理 Web 前端 | API 客户端鉴权/超时/错误归一化、同步门控、重启轮询、格式化、浏览器覆盖契约、员工与模板列表操作精简、内置模板编辑、名称/编辑详情入口、详情 tabs 保存流程、员工概览主模型/备选模型/每日配额快捷修改及打开状态下的 Token 用量同步刷新、员工技能/业务系统/MCP 与模板设置布局一致及内置 Skill 隐藏、模板文本文件切换后的编辑器可见性、设置页 API 文档入口 | `packages/daedalus-agent-management-web/src/**/*.test.js`、`packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx`、`test/browser-coverage.test.js`、`test/run-browser-e2e.js`、`test/integration/website-full.js` | | invoke CLI | 参数、JSON/body/query 来源、Binding schema、socket 协议、超时、大小限制、Provider 错误和调用日志 | `packages/daedalus-agent-apps-invoke-cli/test/` | | MQTT 插件 | 账号配置、旧配置兼容、环境变量覆盖、连接/断线重连、通配符、入站路由、普通消息串行队列、停止与 steer/tell 控制队列、active-run 非阻塞 steer、运行时接收 Promise 未完成时的确认与原 final 投递、原始会话目标、OpenClaw deliver 的 text/kind 无加工透传、出站 envelope、日志 | `extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/*test.ts` | | Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承、工作区/Skill、Ontology、MQTT `/stop`/`/steer`/`/tell` 的 final 确认及普通队列保留、重启、启动钩子、包持久化、文件输出、会话隔离和压测 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/` | diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts index 674faa7..63d6094 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts @@ -555,14 +555,34 @@ export class AgentLifecycleService { ? ["bundle-mcp"] : serverSlugs.filter((slug) => !selected.has(slug)).map((slug) => `${slug}__*`); agent.tools ??= {}; - const existingDeny = Array.isArray(agent.tools.deny) ? agent.tools.deny : []; const previous = new Set(projection.previousDenyPatterns); - agent.tools.deny = [...new Set([ - ...existingDeny.filter((item: unknown) => !previous.has(String(item))), - ...denyPatterns - ])]; - if (agent.tools.deny.length === 0) { - delete agent.tools.deny; + + const applyDenyProjection = (target: Record): void => { + const existingDeny = Array.isArray(target.deny) ? target.deny : []; + target.deny = [...new Set([ + ...existingDeny.filter((item: unknown) => !previous.has(String(item))), + ...denyPatterns + ])]; + if (target.deny.length === 0) { + delete target.deny; + } + }; + + // MCP tools pass through the normal policy and, for sandboxed sessions, + // the nested sandbox policy. Keep both projections in sync so an agent + // cannot regain a denied MCP namespace through the sandbox gate. + applyDenyProjection(agent.tools); + const existingSandboxTools = agent.tools.sandbox?.tools; + if (denyPatterns.length > 0 || previous.size > 0 || existingSandboxTools) { + agent.tools.sandbox ??= {}; + agent.tools.sandbox.tools ??= {}; + applyDenyProjection(agent.tools.sandbox.tools); + if (Object.keys(agent.tools.sandbox.tools).length === 0) { + delete agent.tools.sandbox.tools; + } + if (Object.keys(agent.tools.sandbox).length === 0) { + delete agent.tools.sandbox; + } } if (Object.keys(agent.tools).length === 0) { delete agent.tools; diff --git a/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts b/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts index b2cd437..631f3ef 100644 --- a/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts @@ -56,6 +56,55 @@ describe("AgentLifecycleService MCP projection", () => { } }); + it("projects selected MCP policy into both normal and sandbox tool deny lists", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sync-agent-policy-")); + const configPath = path.join(rootDir, "openclaw.json"); + fs.writeFileSync(configPath, JSON.stringify({ + agents: { + list: [ + { id: "agent-a", tools: { deny: ["exec"], sandbox: { tools: { deny: ["custom__*"] } } } }, + { id: "agent-b", tools: { deny: ["read"] } } + ] + } + }), "utf8"); + + const service = new AgentLifecycleService({} as never, { + openclawHome: rootDir, + openclawConfigPath: configPath + } as never); + + try { + await service.syncMcpConfiguration({ + servers: [ + { slug: "docs", config: { enabled: true } }, + { slug: "search", config: { enabled: true } } + ], + agents: [ + { + agentId: "agent-a", + mode: "selected", + serverSlugs: ["docs"], + previousDenyPatterns: ["custom__*"] + }, + { + agentId: "agent-b", + mode: "selected", + serverSlugs: [], + previousDenyPatterns: [] + } + ] + }); + + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(config.agents.list[0].tools.deny).toEqual(["exec", "search__*"]); + expect(config.agents.list[0].tools.sandbox.tools.deny).toEqual(["search__*"]); + expect(config.agents.list[1].tools.deny).toEqual(["read", "bundle-mcp"]); + expect(config.agents.list[1].tools.sandbox.tools.deny).toEqual(["bundle-mcp"]); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + it("temporarily projects an unsaved server while probing and restores the config", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-probe-")); const configPath = path.join(rootDir, "openclaw.json"); diff --git a/packages/daedalus-agent-management-web/server/test/mcp-service.test.js b/packages/daedalus-agent-management-web/server/test/mcp-service.test.js index 23ef9d4..ab491f4 100644 --- a/packages/daedalus-agent-management-web/server/test/mcp-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/mcp-service.test.js @@ -400,6 +400,25 @@ describe("McpService", () => { `).get().deny_patterns_json)).toEqual(["bundle-mcp"]); }); + it("keeps MCP projections isolated when multiple agents have different selections", async () => { + const db = createDb(); + db.prepare("INSERT INTO agents (id, slug) VALUES (?, ?)").run(2, "agent-b"); + db.prepare("INSERT INTO agent_mcp_policies (agent_id, mode, policy_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") + .run(2, "selected", 1, "2026-08-12T00:00:00.000Z", "2026-08-12T00:00:00.000Z"); + const fixture = createService(db); + await fixture.service.createServer({ slug: "docs", name: "Docs", mode: "http", url: "https://mcp.example.com/docs" }); + await fixture.service.createServer({ slug: "search", name: "Search", mode: "http", url: "https://mcp.example.com/search" }); + + await fixture.service.setAgentPolicy(1, { mode: "selected", policy_version: 1, mcp_server_ids: [1] }); + await fixture.service.setAgentPolicy(2, { mode: "selected", policy_version: 1, mcp_server_ids: [2] }); + + const sync = fixture.calls.at(-1).params; + expect(sync.agents).toEqual([ + jasmine.objectContaining({ agentId: "agent-a", mode: "selected", serverSlugs: ["docs"] }), + jasmine.objectContaining({ agentId: "agent-b", mode: "selected", serverSlugs: ["search"] }) + ]); + }); + it("preserves the server and agent access when deletion projection fails", async () => { const db = createDb(); const fixture = createService(db); diff --git a/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx b/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx index c132f53..ab00b8d 100644 --- a/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx +++ b/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx @@ -118,6 +118,18 @@ function getAgentFallbackModelLabel(agent) { return agent?.fallback_llm_model_ref || fallbackFromRemote || "-"; } +function getAgentModelSelection(agent, fallback = false) { + const providerKey = fallback ? "fallback_llm_provider_id" : "llm_provider_id"; + const modelKey = fallback ? "fallback_llm_model_id" : "llm_model_id"; + const refKey = fallback ? "fallback_llm_model_ref" : "llm_model_ref"; + const ref = String(agent?.[refKey] || ""); + const slashIndex = ref.indexOf("/"); + return { + provider_id: agent?.[providerKey] || (slashIndex > 0 ? ref.slice(0, slashIndex) : undefined), + model_id: agent?.[modelKey] || (slashIndex > 0 ? ref.slice(slashIndex + 1) : undefined) + }; +} + function renderAgentModelSummary(agent) { const primary = getAgentPrimaryModelLabel(agent); const fallback = getAgentFallbackModelLabel(agent); @@ -183,7 +195,7 @@ function getStatusLabel(status) { } } -function getAgentDetailItems(agent) { +function getAgentDetailItems(agent, editable = {}) { if (!agent) { return []; } @@ -197,8 +209,30 @@ function getAgentDetailItems(agent) { children: {getStatusLabel(agent.status)} }, { key: "template", label: "使用模板", children: agent.template_name || "-" }, - { key: "model", label: "主模型", children: getAgentPrimaryModelLabel(agent) }, - { key: "fallback_model", label: "备选模型", children: getAgentFallbackModelLabel(agent) }, + { + key: "model", + label: "主模型", + children: editable.primaryModel ? ( + + {getAgentPrimaryModelLabel(agent)} + + + ) : getAgentPrimaryModelLabel(agent) + }, + { + key: "fallback_model", + label: "备选模型", + children: editable.fallbackModel ? ( + + {getAgentFallbackModelLabel(agent)} + + + ) : getAgentFallbackModelLabel(agent) + }, { key: "created_at", label: "创建时间", children: formatDateTime(agent.created_at) }, { key: "updated_at", label: "更新时间", children: formatDateTime(agent.updated_at) }, { @@ -236,6 +270,8 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe const [detailAgent, setDetailAgent] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [detailEditing, setDetailEditing] = useState(false); + const [quickEditType, setQuickEditType] = useState(null); + const [quickEditSaving, setQuickEditSaving] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [workspaceDownloadingSlug, setWorkspaceDownloadingSlug] = useState(""); const [testAgent, setTestAgent] = useState(null); @@ -252,9 +288,12 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe const [businessSystemForm] = Form.useForm(); const [mcpForm] = Form.useForm(); const [renameForm] = Form.useForm(); + const [quickEditForm] = Form.useForm(); const [messageApi, contextHolder] = message.useMessage(); const selectedProviderId = Form.useWatch("llm_provider_id", form); const fallbackSelectedProviderId = Form.useWatch("fallback_llm_provider_id", form); + const quickEditProviderId = Form.useWatch("provider_id", quickEditForm); + const quickEditFallbackProviderId = Form.useWatch("fallback_provider_id", quickEditForm); const businessSystemMode = Form.useWatch("mode", businessSystemForm); const mcpMode = Form.useWatch("mode", mcpForm); const permissionMode = Form.useWatch("mode", permissionForm); @@ -270,6 +309,28 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe value: item.model_id, label: item.name && item.name !== item.model_id ? `${item.model_id} (${item.name})` : item.model_id })), [llmModels, fallbackSelectedProviderId]); + const quickModelOptions = useMemo(() => llmModels + .filter((item) => !quickEditProviderId || item.provider_id === quickEditProviderId) + .map((item) => ({ + value: item.model_id, + label: item.name && item.name !== item.model_id ? `${item.model_id} (${item.name})` : item.model_id + })), [llmModels, quickEditProviderId]); + const quickFallbackModelOptions = useMemo(() => llmModels + .filter((item) => !quickEditFallbackProviderId || item.provider_id === quickEditFallbackProviderId) + .map((item) => ({ + value: item.model_id, + label: item.name && item.name !== item.model_id ? `${item.model_id} (${item.name})` : item.model_id + })), [llmModels, quickEditFallbackProviderId]); + + const updateAgentItems = (agents) => { + setItems(agents); + setDetailAgent((current) => { + if (!current) { + return current; + } + return agents.find((item) => Number(item.id) === Number(current.id)) || current; + }); + }; const load = async () => { setLoading(true); @@ -281,7 +342,7 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe api.get("/api/llm/providers"), api.get("/api/llm/models") ]); - setItems(agents); + updateAgentItems(agents); setTemplates(templateItems); setLlmProviders(providers); setLlmModels(models); @@ -294,14 +355,6 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe } }; - const refreshAgentReferences = (agents, agentId) => { - const nextAgent = agents.find((item) => Number(item.id) === Number(agentId)) || null; - if (nextAgent && detailOpen && Number(detailAgent?.id) === Number(agentId)) { - setDetailAgent(nextAgent); - } - return nextAgent; - }; - const reloadAll = async (focusAgentId = null) => { const [agents, agentUsers, templateItems, providers, models] = await Promise.all([ api.get("/api/agents"), @@ -310,7 +363,7 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe api.get("/api/llm/providers"), api.get("/api/llm/models") ]); - setItems(agents); + updateAgentItems(agents); setTemplates(templateItems); setLlmProviders(providers); setLlmModels(models); @@ -319,7 +372,7 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe label: item.username }))); if (focusAgentId !== null && focusAgentId !== undefined) { - return refreshAgentReferences(agents, focusAgentId); + return agents.find((item) => Number(item.id) === Number(focusAgentId)) || null; } return null; }; @@ -504,6 +557,67 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe setDetailOpen(true); }; + const openQuickEdit = (type) => { + if (!detailAgent) { + return; + } + if (type === "primary") { + const selection = getAgentModelSelection(detailAgent); + quickEditForm.setFieldsValue({ provider_id: selection.provider_id, model_id: selection.model_id }); + } else if (type === "fallback") { + const selection = getAgentModelSelection(detailAgent, true); + quickEditForm.setFieldsValue({ fallback_provider_id: selection.provider_id, fallback_model_id: selection.model_id }); + } else { + quickEditForm.setFieldsValue({ daily_limit: Number(detailAgent.daily_limit ?? -1) }); + } + setQuickEditType(type); + }; + + const closeQuickEdit = () => { + if (quickEditSaving) { + return; + } + setQuickEditType(null); + quickEditForm.resetFields(); + }; + + const saveQuickEdit = async () => { + if (!detailAgent || !quickEditType) { + return; + } + try { + const values = await quickEditForm.validateFields(); + const payload = { restart: false }; + if (quickEditType === "primary") { + payload.llm_provider_id = values.provider_id; + payload.llm_model_id = values.model_id; + } else if (quickEditType === "fallback") { + payload.fallback_llm_provider_id = values.fallback_provider_id || ""; + payload.fallback_llm_model_id = values.fallback_model_id || ""; + } else { + payload.daily_limit = Number(values.daily_limit); + } + + setQuickEditSaving(true); + const targetSlug = detailAgent.slug; + const nextAgent = await api.put(`/api/agents/${detailAgent.id}`, payload); + const refreshed = await reloadAll(detailAgent.id); + setDetailAgent(refreshed || nextAgent); + setQuickEditType(null); + quickEditForm.resetFields(); + messageApi.success(quickEditType === "quota" ? "每日配额已更新" : "模型配置已更新"); + if (quickEditType !== "quota") { + promptOpenClawRestart({ content: `数字员工 ${targetSlug} 的模型已更新。${PLATFORM_RESTART_REQUIRED_NOTICE}` }); + } + } catch (error) { + if (!error?.errorFields) { + messageApi.error(error.message || "保存配置失败"); + } + } finally { + setQuickEditSaving(false); + } + }; + const submitRename = async () => { const values = await renameForm.validateFields(); if (!detailAgent) { @@ -1005,6 +1119,118 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe + , + "data-testid": quickEditType === "quota" + ? "agent-quick-daily-submit" + : `agent-quick-${quickEditType || "edit"}-submit` + }} + cancelButtonProps={{ icon: }} + onCancel={closeQuickEdit} + onOk={() => void saveQuickEdit()} + > +
+ {quickEditType === "primary" ? ( + + + + + + + + ) : null} + {quickEditType === "fallback" ? ( + + + ({ + validator(_, value) { + if (!value && getFieldValue("fallback_model_id")) { + return Promise.reject(new Error("请选择服务商")); + } + return Promise.resolve(); + } + })]} + > + + + + + ) : null} + {quickEditType === "quota" ? ( + + + + ) : null} +
+
+ ) : null} onClose={() => { + closeQuickEdit(); setDetailOpen(false); setDetailAgent(null); setDetailEditing(false); @@ -1317,7 +1544,10 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe bordered size="small" column={{ xs: 1, md: 2 }} - items={getAgentDetailItems(detailAgent)} + items={getAgentDetailItems(detailAgent, detailEditing ? { + primaryModel: () => openQuickEdit("primary"), + fallbackModel: () => openQuickEdit("fallback") + } : {})} /> @@ -1347,7 +1577,24 @@ export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTe columns={[ { title: "周期", dataIndex: "label", width: 96 }, { title: "用量", render: (_, row) => formatTokenCount(row.current) }, - { title: "限额", render: (_, row) => formatQuota(row.limit) }, + { + title: "限额", + render: (_, row) => ( + + {formatQuota(row.limit)} + {detailEditing && row.key === "daily" ? ( + + ) : null} + + ) + }, { title: "占比", render: (_, row) => row.hasLimit ? ( diff --git a/packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx b/packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx new file mode 100644 index 0000000..5caf7aa --- /dev/null +++ b/packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AgentsPage } from "./AgentsPage.jsx"; +import { api } from "../app/api-client.js"; + +vi.mock("../app/api-client.js", () => ({ + api: { + get: vi.fn(), + put: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + download: vi.fn() + } +})); + +vi.mock("./AgentTestPanel.jsx", () => ({ AgentTestPanel: () =>
})); +vi.mock("./agents/AgentWorkspaceWorkbench.jsx", () => ({ + AgentWorkspaceWorkbench: ({ overview }) =>
{overview}
+})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const agent = { + id: 1, + slug: "finance-agent", + agent_name: "财务助手", + status: "normal", + template_name: "default", + llm_provider_id: "corp-openai", + llm_model_id: "primary-model", + llm_model_ref: "corp-openai/primary-model", + fallback_llm_provider_id: "corp-openai", + fallback_llm_model_id: "fallback-model", + fallback_llm_model_ref: "corp-openai/fallback-model", + daily_limit: 10000, + usage: { daily: 1200 }, + permissions: [], + user_policy: { mode: "selected" } +}; + +function setupApi(getAgent = () => agent) { + api.get.mockImplementation(async (path) => { + if (path === "/api/agents") return [getAgent()]; + if (path === "/api/agent-users") return { items: [] }; + if (path === "/api/templates") return [{ template_name: "default" }]; + if (path === "/api/llm/providers") return [{ provider_id: "corp-openai" }]; + if (path === "/api/llm/models") return [ + { provider_id: "corp-openai", model_id: "primary-model", model_ref: "corp-openai/primary-model" }, + { provider_id: "corp-openai", model_id: "fallback-model", model_ref: "corp-openai/fallback-model" } + ]; + throw new Error(`Unexpected GET ${path}`); + }); + api.put.mockImplementation(async (_path, payload) => ({ ...getAgent(), ...payload })); +} + +describe("AgentsPage overview quick edits", () => { + it("shows separate model and daily quota edit actions in edit mode", async () => { + setupApi(); + render(); + + await waitFor(() => expect(screen.getByTestId("agent-edit-finance-agent")).toBeInTheDocument()); + fireEvent.click(screen.getAllByTestId("agent-edit-finance-agent")[0]); + + expect(await screen.findByTestId("agent-detail-primary-model-edit")).toBeInTheDocument(); + expect(screen.getByTestId("agent-detail-fallback-model-edit")).toBeInTheDocument(); + expect(screen.getByTestId("agent-detail-daily-limit-edit")).toBeInTheDocument(); + }); + + it("refreshes token usage in an open overview when the agent list refreshes", async () => { + let currentAgent = agent; + setupApi(() => currentAgent); + const { rerender } = render(); + + await waitFor(() => expect(screen.getByTestId("agent-edit-finance-agent")).toBeInTheDocument()); + fireEvent.click(screen.getAllByTestId("agent-edit-finance-agent")[0]); + const drawer = await screen.findByTestId("agent-workspace-drawer"); + expect(within(drawer).getByText("1.2K")).toBeInTheDocument(); + + currentAgent = { ...agent, usage: { daily: 42332 } }; + rerender(); + + await waitFor(() => expect(within(drawer).getByText("42K")).toBeInTheDocument()); + }); + + it("submits the selected primary model through the agent update API", async () => { + setupApi(); + render(); + await waitFor(() => expect(screen.getByTestId("agent-edit-finance-agent")).toBeInTheDocument()); + fireEvent.click(screen.getAllByTestId("agent-edit-finance-agent")[0]); + fireEvent.click(await screen.findByTestId("agent-detail-primary-model-edit")); + + expect(await screen.findByText("修改主模型")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("agent-quick-primary-submit")); + await waitFor(() => expect(api.put).toHaveBeenCalledWith( + "/api/agents/1", + expect.objectContaining({ + llm_provider_id: "corp-openai", + llm_model_id: "primary-model", + restart: false + }) + )); + }); + + it("submits daily quota edits through the agent update API", async () => { + setupApi(); + render(); + await waitFor(() => expect(screen.getByTestId("agent-edit-finance-agent")).toBeInTheDocument()); + fireEvent.click(screen.getAllByTestId("agent-edit-finance-agent")[0]); + fireEvent.click(await screen.findByTestId("agent-detail-daily-limit-edit")); + + const input = await screen.findByTestId("agent-quick-daily-limit"); + fireEvent.change(input, { target: { value: "20000" } }); + fireEvent.click(screen.getByTestId("agent-quick-daily-submit")); + await waitFor(() => expect(api.put).toHaveBeenCalledWith( + "/api/agents/1", + expect.objectContaining({ daily_limit: 20000, restart: false }) + )); + }); + + it("submits fallback model edits through the agent update API", async () => { + setupApi(); + render(); + await waitFor(() => expect(screen.getByTestId("agent-edit-finance-agent")).toBeInTheDocument()); + fireEvent.click(screen.getAllByTestId("agent-edit-finance-agent")[0]); + fireEvent.click(await screen.findByTestId("agent-detail-fallback-model-edit")); + + expect(await screen.findByText("修改备选模型")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("agent-quick-fallback-submit")); + await waitFor(() => expect(api.put).toHaveBeenCalledWith( + "/api/agents/1", + expect.objectContaining({ + fallback_llm_provider_id: "corp-openai", + fallback_llm_model_id: "fallback-model", + restart: false + }) + )); + }); +}); -- Gitee