Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy
This commit is contained in:
commit
1cbc0e2dd6
68 files changed
+4438
-379
No files matched your search
@@ -105,5 +105,11 @@ docs/backups
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
|
||||
# Local-only scratch directory (not committed)
|
||||
0622/
|
||||
|
||||
# Python bytecode cache
|
||||
__pycache__/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -82,12 +82,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate Line truncated
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations staLine truncated
|
||||
- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_SQ_REPORT=true
|
||||
VITE_MOCK_SQ_REPORT=false
|
||||
|
||||
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
@@ -250,6 +251,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
// 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。
|
||||
const live = useLiveAccount();
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const isBuiltin = agent.builtin;
|
||||
const isOwned =
|
||||
@@ -424,18 +427,38 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已取消发布" : "已发布");
|
||||
toast.success(
|
||||
agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的
|
||||
// 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。
|
||||
function warnIfAccountSwitched(): boolean {
|
||||
if (!live.switched) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -443,7 +466,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -1117,6 +1140,23 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
accountNotice={
|
||||
<div className="text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
发布操作账号:
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
|
||||
{live.displayName}」。如需以「{live.mountDisplayName}
|
||||
」操作,请刷新页面确认。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,8 @@ import {
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
sortThreadsPinnedFirst,
|
||||
@@ -338,14 +340,14 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
// 排除调度/引导/会商类会话,与首页一致;置顶优先。
|
||||
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
|
||||
// (带机器人图标、点击回创建页);置顶优先。
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
@@ -450,7 +452,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const href = pathOf(thread);
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
@@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
type UpdateAgentRequest,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { isAgentSkillCandidate } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
@@ -135,6 +137,7 @@ export function AgentDetailPanel({
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
showRecentChats,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
@@ -142,6 +145,8 @@ export function AgentDetailPanel({
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
@@ -159,6 +164,8 @@ export function AgentDetailPanel({
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
// 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。
|
||||
const live = useLiveAccount();
|
||||
|
||||
const updateAgent = useUpdateAgent();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
@@ -343,19 +350,39 @@ export function AgentDetailPanel({
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已下架" : "已发布");
|
||||
toast.success(
|
||||
agent.published ? "已下架" : `已发布(操作账号:${live.displayName})`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// 发布以实时 token 执行。会话中途账号被切换(常见于另一标签页登录了别的账号)时
|
||||
// 先弹确认,避免误以本人之外的账号执行发布。返回 false = 用户取消。
|
||||
function warnIfAccountSwitched(): boolean {
|
||||
if (!live.switched) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!agent) return;
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -363,7 +390,7 @@ export function AgentDetailPanel({
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -791,6 +818,9 @@ export function AgentDetailPanel({
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -921,6 +951,23 @@ export function AgentDetailPanel({
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
accountNotice={
|
||||
<div className="text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
发布操作账号:
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
|
||||
{live.displayName}」。如需以「{live.mountDisplayName}
|
||||
」操作,请刷新页面确认。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RECENT_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。
|
||||
* 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。
|
||||
*/
|
||||
export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
const pathname = usePathname();
|
||||
const { data } = useThreadsPage({
|
||||
page: 1,
|
||||
pageSize: RECENT_LIMIT,
|
||||
metadata: { agent_id: agentId },
|
||||
});
|
||||
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) =>
|
||||
th.metadata?.thread_type !== "bootstrap" &&
|
||||
th.metadata?.thread_type !== "scheduler" &&
|
||||
th.metadata?.thread_type !== "roundtable",
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<MessageSquareIcon className="size-3.5" />
|
||||
最近对话
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{threads.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{threads.length === 0 ? (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无对话</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
href={href}
|
||||
title={desensitize(titleOfThread(thread, "新对话"))}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/5 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
* 通过 `tone` 适配不同底色:
|
||||
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
|
||||
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
|
||||
*/
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
tone === "banner-dark"
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: tone === "banner-light"
|
||||
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
||||
title="换肤"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Shirt className="size-4" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
|
||||
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
|
||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
|
||||
{THEME_SWATCHES.map((swatch) => {
|
||||
const active = theme === swatch.id;
|
||||
return (
|
||||
<button
|
||||
key={swatch.id}
|
||||
type="button"
|
||||
title={swatch.label}
|
||||
aria-label={swatch.label}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setTheme(swatch.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: swatch.color,
|
||||
boxShadow: active
|
||||
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LAYOUT_OPTIONS = [
|
||||
{
|
||||
id: "full" as const,
|
||||
label: "全量模式",
|
||||
description: "展示侧边栏与顶栏导航",
|
||||
icon: PanelLeftIcon,
|
||||
},
|
||||
{
|
||||
id: "compact" as const,
|
||||
label: "简洁模式",
|
||||
description: "隐藏侧边栏与顶栏,获得更大工作区",
|
||||
icon: LayoutIcon,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
|
||||
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
|
||||
* - 全量模式 Header 右上角。
|
||||
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
|
||||
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
|
||||
*/
|
||||
export function LayoutModeSwitcher({
|
||||
tone,
|
||||
fallback = "compact",
|
||||
}: {
|
||||
tone: AppearanceControlTone;
|
||||
fallback?: "full" | "compact";
|
||||
}) {
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
const current = mode ?? fallback;
|
||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
||||
title="界面模式"
|
||||
aria-label="界面模式"
|
||||
>
|
||||
<TriggerIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8} className="min-w-56 rounded-lg">
|
||||
<DropdownMenuLabel>界面模式</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{LAYOUT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = current === option.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={() => setLayoutMode(option.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm leading-tight">{option.label}</span>
|
||||
<span className="text-muted-foreground text-xs leading-tight">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{active && <Check className="ml-auto size-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -75,8 +75,10 @@ import {
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
@@ -213,11 +215,12 @@ export function GeneralQaSidebar() {
|
||||
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
@@ -249,9 +252,9 @@ export function GeneralQaSidebar() {
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
@@ -417,7 +420,9 @@ export function GeneralQaSidebar() {
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -426,7 +431,7 @@ export function GeneralQaSidebar() {
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={pathOfThread(thread)}
|
||||
href={href}
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
@@ -434,12 +439,22 @@ export function GeneralQaSidebar() {
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -26,6 +26,8 @@ interface PublishSquarePickerDialogProps {
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** 可选提示节点,渲染在描述下方 —— 用于显示「本次发布将以哪个账号进行」及账号切换告警。 */
|
||||
accountNotice?: ReactNode;
|
||||
}
|
||||
|
||||
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
|
||||
@@ -39,6 +41,7 @@ export function PublishSquarePickerDialog({
|
||||
loading,
|
||||
title = "选择发布广场",
|
||||
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
|
||||
accountNotice,
|
||||
}: PublishSquarePickerDialogProps) {
|
||||
const [selected, setSelected] = useState<string>(
|
||||
defaultSquareId || squares[0]?.id || "",
|
||||
@@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{accountNotice}
|
||||
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
|
||||
{squares.map((sq) => {
|
||||
const active = selected === sq.id;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
@@ -64,8 +65,10 @@ import {
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
listPathOfThread,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
@@ -130,9 +133,9 @@ export function RecentChatList({
|
||||
});
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
@@ -189,11 +192,12 @@ export function RecentChatList({
|
||||
[t],
|
||||
);
|
||||
|
||||
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
|
||||
// 仅排除调度 / 会商类系统会话。
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
);
|
||||
@@ -239,7 +243,9 @@ export function RecentChatList({
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{shownThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -261,17 +267,29 @@ export function RecentChatList({
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
href={href}
|
||||
>
|
||||
{compact && (
|
||||
<MessageSquare
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
(isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-muted-foreground/70")
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
(isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-muted-foreground/70")
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
|
||||
@@ -71,9 +71,10 @@ function StatusBadge({ draft }: { draft: DraftMeta }) {
|
||||
if (draft.status === "error") {
|
||||
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
|
||||
}
|
||||
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
|
||||
return (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
第 {draft.furthestStep} 步
|
||||
{draft.chainTitle || "自由模式"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
|
||||
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
|
||||
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
|
||||
@@ -221,3 +222,43 @@ export async function fetchDashboardInputJson(
|
||||
if (resolved) persistDashboardInputJson(resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
|
||||
|
||||
/**
|
||||
* 「3q(研判情报报告)是否完成」状态:
|
||||
* - ``complete`` = selectSqReport 数组里有有效内容;
|
||||
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
|
||||
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
|
||||
*/
|
||||
export type SqReportStatus = "complete" | "incomplete" | "unknown";
|
||||
|
||||
/**
|
||||
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
|
||||
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
|
||||
* - 有可用内容 → ``complete``(正常);
|
||||
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
|
||||
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
|
||||
*
|
||||
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
|
||||
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
|
||||
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
|
||||
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
|
||||
*/
|
||||
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> {
|
||||
if (!taskId) return "unknown";
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
|
||||
if (cfg.test) {
|
||||
return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete";
|
||||
}
|
||||
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
|
||||
const url = sqReportUrl();
|
||||
if (!url) return "unknown";
|
||||
try {
|
||||
const body = await requestCrudService.get(url, { taskId });
|
||||
return extractSqReport(body, taskId) ? "complete" : "incomplete";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig {
|
||||
/** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */
|
||||
detail3qUrl: string;
|
||||
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success};
|
||||
* actionDetail = 原始 {state,msg,data:{taskId,...}}。 */
|
||||
* actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]}
|
||||
* (rwfx 3q 预检用,test 为真时生效)。 */
|
||||
mock: {
|
||||
purposeDetail?: unknown;
|
||||
delete?: { message?: string; success?: boolean };
|
||||
actionDetail?: unknown;
|
||||
sqReport?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig {
|
||||
? (mock.delete as { message?: string; success?: boolean })
|
||||
: undefined,
|
||||
actionDetail: mock.action_detail,
|
||||
sqReport: mock.sq_report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,12 +233,16 @@ export interface TaskDeeplinkTarget {
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。 */
|
||||
function buildOpeningText(detail: CopTaskDetail, taskId: string): string {
|
||||
/**
|
||||
* 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。
|
||||
* xdfx(行动分析)额外附上「行动id(action_id)」——传入 actionId 时追加该行。
|
||||
*/
|
||||
function buildOpeningText(detail: CopTaskDetail, taskId: string, actionId?: string): string {
|
||||
return [
|
||||
detail.taskName ? `任务名称:${detail.taskName}` : "",
|
||||
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
|
||||
taskId ? `任务id(task_id)为${taskId}` : "",
|
||||
actionId ? `行动id(action_id)为${actionId}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@@ -312,11 +316,15 @@ export async function resolveTaskDeeplinkTarget(
|
||||
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
|
||||
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
|
||||
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
|
||||
// xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加 action_id。
|
||||
const isXdfx = parsed.businessCode === XDFX_BUSINESS_CODE;
|
||||
const actionId = rawId || taskId;
|
||||
persistTaskContext({
|
||||
taskId,
|
||||
agentId,
|
||||
openingText: buildOpeningText(detail, taskId),
|
||||
actionId: rawId || taskId,
|
||||
openingText: buildOpeningText(detail, taskId, isXdfx ? actionId : undefined),
|
||||
actionId,
|
||||
partitionByAction: isXdfx,
|
||||
});
|
||||
return { path: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -41,6 +41,12 @@ export interface TaskContext {
|
||||
openingText: string;
|
||||
/** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */
|
||||
actionId?: string;
|
||||
/**
|
||||
* xdfx(行动分析)专属:聊天记录按「行动id」(actionId) 而非「任务id」划分。
|
||||
* 为 true 时,新建对话额外打 `metadata.actionId` 标签、对话列表按 actionId 过滤
|
||||
* (仍保留 taskId 标签以维持任务深链对话「全局共享、不分用户」的后端语义)。
|
||||
*/
|
||||
partitionByAction?: boolean;
|
||||
}
|
||||
|
||||
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
|
||||
@@ -311,12 +317,29 @@ export function readTaskContext(): TaskContext | null {
|
||||
agentId: String(parsed.agentId),
|
||||
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
|
||||
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
|
||||
partitionByAction: parsed.partitionByAction === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务深链对话的 thread metadata 标签(既用于「新建对话」打标签,也用于对话列表过滤)。
|
||||
* - 普通业务(rwfx/3qfx):`{ taskId }` —— 按任务划分(1:N)。
|
||||
* - xdfx(partitionByAction):`{ taskId, actionId }` —— 额外按「行动id」划分聊天记录;
|
||||
* 仍带 taskId 以保留后端「任务深链对话全局共享」的语义(后端据 metadata.taskId 跳过分用户过滤)。
|
||||
* 无有效 taskId 时返回 undefined(调用方据此不打标签)。
|
||||
*/
|
||||
export function taskThreadMetadata(
|
||||
ctx: TaskContext | null | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!ctx?.taskId) return undefined;
|
||||
return ctx.partitionByAction && ctx.actionId
|
||||
? { taskId: ctx.taskId, actionId: ctx.actionId }
|
||||
: { taskId: ctx.taskId };
|
||||
}
|
||||
|
||||
export function clearTaskContext(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(TASK_CONTEXT_STORAGE_KEY);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
export interface LiveAccount {
|
||||
/** 实时登录用户 id(随 storage/focus 刷新)。null = 未登录。 */
|
||||
userId: string | null;
|
||||
/** 实时显示名(邮箱 @ 前缀,否则 user_id)。 */
|
||||
displayName: string;
|
||||
/** 组件首次挂载时捕获的用户 id。 */
|
||||
mountUserId: string | null;
|
||||
/** 组件首次挂载时捕获的显示名。 */
|
||||
mountDisplayName: string;
|
||||
/**
|
||||
* 当前登录账号与挂载时不一致 —— 即本视图加载后登录被切换过(通常是在另一个
|
||||
* 标签页登录了别的账号)。归属/发布类操作必须据此提醒,避免误归属。
|
||||
*/
|
||||
switched: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟踪「实时」登录账号,而不是挂载时缓存的值。
|
||||
*
|
||||
* 鉴权 token 存在 localStorage(非嵌入态下被同浏览器所有标签页共享),所以在
|
||||
* 另一个标签页登录会**静默替换**本标签页 API 调用所用的 token。任何会决定归属的
|
||||
* 界面(创建/发布智能体)都必须显示请求实际将以哪个账号执行,并在会话中途被切换
|
||||
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
|
||||
*/
|
||||
export function useLiveAccount(): LiveAccount {
|
||||
const mountRef = useRef<{ id: string | null; name: string } | null>(null);
|
||||
if (mountRef.current === null) {
|
||||
const auth = getStoredAuth();
|
||||
mountRef.current = {
|
||||
id: auth?.user_id ?? null,
|
||||
name: getAccountDisplayName(auth),
|
||||
};
|
||||
}
|
||||
const [live, setLive] = useState(() => mountRef.current!);
|
||||
|
||||
useEffect(() => {
|
||||
function refresh() {
|
||||
const auth = getStoredAuth();
|
||||
const id = auth?.user_id ?? null;
|
||||
const name = getAccountDisplayName(auth);
|
||||
setLive((prev) =>
|
||||
prev.id === id && prev.name === name ? prev : { id, name },
|
||||
);
|
||||
}
|
||||
// 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
|
||||
// —— 只挂在正常态;focus/visibilitychange 兜底刷新两种形态都有效。
|
||||
function onStorage(e: StorageEvent) {
|
||||
if (e.key === AUTH_STORAGE_KEY) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
refresh();
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mount = mountRef.current!;
|
||||
return {
|
||||
userId: live.id,
|
||||
displayName: live.name,
|
||||
mountUserId: mount.id,
|
||||
mountDisplayName: mount.name,
|
||||
switched: live.id !== null && mount.id !== null && live.id !== mount.id,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
* 同一个 taskId 可能挂在多条业务链上(rwfx / 3qfx / xdfx 共用任务 id),每条业务用各自的
|
||||
* 智能体。传入 ``agentId`` 时额外按 ``metadata.agent_id`` 过滤(后端 metadata 过滤是 AND/子集
|
||||
* 匹配),把不同业务的对话列表隔离开——否则三种业务的对话会混在一起。
|
||||
*
|
||||
* xdfx(行动分析):聊天记录按「行动id」划分——传入 ``actionId`` 时额外按 ``metadata.actionId``
|
||||
* 过滤(同一任务下的不同行动各自一份对话列表);仍保留 taskId 过滤以命中后端「任务深链对话
|
||||
* 全局共享」的判定(后端据 metadata.taskId 跳过分用户过滤)。
|
||||
*/
|
||||
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
@@ -16,13 +20,16 @@ import type { AgentThread } from "@/core/threads/types";
|
||||
export function useTaskThreads(
|
||||
taskId: string | null | undefined,
|
||||
agentId?: string | null | undefined,
|
||||
actionId?: string | null | undefined,
|
||||
) {
|
||||
const enabled = !!taskId;
|
||||
// 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。
|
||||
const metadata = enabled
|
||||
? agentId
|
||||
? { taskId, agent_id: agentId }
|
||||
: { taskId }
|
||||
? {
|
||||
taskId,
|
||||
...(actionId ? { actionId } : {}),
|
||||
...(agentId ? { agent_id: agentId } : {}),
|
||||
}
|
||||
: { taskId: "__none__" };
|
||||
const query = useThreadsPage({ page: 1, pageSize: 100, metadata });
|
||||
const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : [];
|
||||
|
||||
@@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type
|
||||
* === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。
|
||||
*/
|
||||
export function isBootstrapThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
return thread.metadata?.thread_type === "bootstrap";
|
||||
}
|
||||
|
||||
/**
|
||||
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
|
||||
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
|
||||
* 不随当前 route base 变化。
|
||||
*/
|
||||
export function bootstrapResumePath(threadId: string): string {
|
||||
return `/page/workspace/agents/new/${threadId}`;
|
||||
}
|
||||
|
||||
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
|
||||
export function listPathOfThread(thread: AgentThread): string {
|
||||
return isBootstrapThread(thread)
|
||||
? bootstrapResumePath(thread.thread_id)
|
||||
: pathOfThread(thread);
|
||||
}
|
||||
|
||||
export function isThreadPinned(thread: AgentThread): boolean {
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ export function AIWritingForm({
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局..."
|
||||
placeholder="描述你想写的文章"
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AIWritingPanel({
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
|
||||
const writingForm = useWritingFormOptional();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions();
|
||||
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
|
||||
|
||||
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
|
||||
@@ -207,24 +207,31 @@ export function AIWritingPanel({
|
||||
}, [closeHistory, reset]);
|
||||
|
||||
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
// 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget;
|
||||
// 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。
|
||||
const handleBackgroundSuspend = useCallback(() => {
|
||||
const sid = state.sessionId;
|
||||
if (!sid) {
|
||||
toast.error('当前没有可挂起的写作会话');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await suspendAIWritingToBackground(sid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '后台挂起失败');
|
||||
return;
|
||||
}
|
||||
toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果');
|
||||
// reset() 会 stop 客户端 SSE 订阅并回到 idle —— 后端驱动器接管续跑,不冲突。
|
||||
// 1) 立刻:乐观标记 + 重置 UI 回到 idle(reset() 会 stop 客户端 SSE 订阅,
|
||||
// 后端驱动器接管续跑,不冲突)。
|
||||
markBackground(sid);
|
||||
reset();
|
||||
// 刷新历史列表,让「挂起中」徽标立刻出现。
|
||||
refresh();
|
||||
}, [state.sessionId, reset, refresh]);
|
||||
toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果');
|
||||
// 2) 后台:fire-and-forget 通知后端拉起驱动;完成后刷新真实状态并清除乐观标记。
|
||||
void (async () => {
|
||||
try {
|
||||
await suspendAIWritingToBackground(sid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '后台挂起失败');
|
||||
} finally {
|
||||
await refresh();
|
||||
clearBackground(sid);
|
||||
}
|
||||
})();
|
||||
}, [state.sessionId, reset, refresh, markBackground, clearBackground]);
|
||||
|
||||
// 离页 / 刷新 / 关标签页时,若写作仍在进行中则自动转后台(与圆桌「自动挂起」一致)。
|
||||
// 用 ref 持有最新的「是否在跑 + 会话 id」,让卸载/卸载前回调读到即时值。
|
||||
|
||||
@@ -19,6 +19,7 @@ const DISPLAYED_TYPES = new Set([
|
||||
'await_user',
|
||||
'resumed',
|
||||
'notice',
|
||||
'model_switch',
|
||||
'done',
|
||||
'error',
|
||||
]);
|
||||
|
||||
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
|
||||
value={userQuery}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? '例如:检索新能源汽车销量数据。按 Enter 检索并追加…'}
|
||||
placeholder={placeholder ?? '按 Enter 检索并追加…'}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TimelineStepProps {
|
||||
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
||||
if (event.type === 'error') return 'error';
|
||||
if (event.type === 'notice') return 'warning';
|
||||
if (event.type === 'model_switch') return 'warning';
|
||||
if (event.type === 'await_user') return 'paused';
|
||||
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
|
||||
if (event.type === 'step_start') return isLast ? 'running' : 'done';
|
||||
@@ -30,8 +31,11 @@ const AGENT_COLOR: Record<string, string> = {
|
||||
|
||||
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
|
||||
const status = statusOf(event, isLast ?? true);
|
||||
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」
|
||||
const isAlert = event.type === 'error' || event.type === 'notice';
|
||||
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,model_switch=模型自动容错切换(amber 同 notice)
|
||||
const isAlert =
|
||||
event.type === 'error' ||
|
||||
event.type === 'notice' ||
|
||||
event.type === 'model_switch';
|
||||
const isError = event.type === 'error';
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,7 +44,7 @@ export function WritingWelcome() {
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||
这是一个专业的自动化写作系统。你只需在下方对话框中
|
||||
<strong className="font-semibold text-slate-700 dark:text-white/80">
|
||||
输入你的本意(例如:「帮我写一篇 1500 字的新能源汽车报告」)
|
||||
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
|
||||
</strong>
|
||||
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAIWritingSessions,
|
||||
@@ -10,6 +10,27 @@ import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
|
||||
export function useAIWritingSessions() {
|
||||
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 「后台挂起」乐观标记:点击挂起后立刻让历史下拉显示「挂起中」,不必等后端确认。
|
||||
// 后端状态(status='background')刷新到位后由调用方 clearBackground 清除。
|
||||
const [pendingBackground, setPendingBackground] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const markBackground = useCallback((id: string) => {
|
||||
setPendingBackground((prev) => {
|
||||
if (prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearBackground = useCallback((id: string) => {
|
||||
setPendingBackground((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -41,5 +62,18 @@ export function useAIWritingSessions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { sessions, loading, refresh, rename, remove };
|
||||
// 把乐观「挂起中」标记叠加到真实列表上:已在列表里的会话立刻显示 background。
|
||||
const displaySessions = useMemo(
|
||||
() =>
|
||||
pendingBackground.size === 0
|
||||
? sessions
|
||||
: sessions.map((s) =>
|
||||
pendingBackground.has(s.id) && s.status !== 'done'
|
||||
? { ...s, status: 'background' }
|
||||
: s,
|
||||
),
|
||||
[sessions, pendingBackground],
|
||||
);
|
||||
|
||||
return { sessions: displaySessions, loading, refresh, rename, remove, markBackground, clearBackground };
|
||||
}
|
||||
@@ -388,6 +388,37 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
case 'notice':
|
||||
return base;
|
||||
|
||||
case 'model_switch': {
|
||||
// 模型自动容错:丢弃当前阶段已累积的流式缓冲,避免上一个失败模型的半截输出
|
||||
// 与下一个模型的输出粘在一起;时间线展示侧由 TimelineStep 按 amber 渲染(同 notice)。
|
||||
const stage = event.modelSwitch?.stage;
|
||||
const sectionIndex = event.modelSwitch?.sectionIndex;
|
||||
const resetByStage: Partial<AIWritingState> = (() => {
|
||||
switch (stage) {
|
||||
case 'intent':
|
||||
return { intentStreamingText: '', intentThinkingText: '' };
|
||||
case 'outline':
|
||||
return { outlineStreamingText: '', outlineThinkingText: '' };
|
||||
case 'draft': {
|
||||
// 只清掉失败那一节的流式片段,已写好的章节保持不动
|
||||
if (typeof sectionIndex === 'number') {
|
||||
return {
|
||||
streamingDraftSections: prev.streamingDraftSections.filter(
|
||||
(s) => s.sectionIndex !== sectionIndex,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { streamingDraftSections: [], streamingDraftTitle: '' };
|
||||
}
|
||||
case 'review':
|
||||
return { streamingReviewItems: [] };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
return { ...base, ...resetByStage };
|
||||
}
|
||||
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
@@ -506,6 +537,22 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
|
||||
out.reviewItemIssues = (out.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
|
||||
// model_switch(updates 通道里的 progress_events 也可能带):折成 modelSwitch 子对象
|
||||
if (out.type === 'model_switch' && !out.modelSwitch) {
|
||||
out.modelSwitch = {
|
||||
stage: (out.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent',
|
||||
failedModel: String(out.failed_model ?? out.failedModel ?? ''),
|
||||
nextModel: String(out.next_model ?? out.nextModel ?? ''),
|
||||
reason: String(out.reason ?? ''),
|
||||
sectionIndex: typeof out.section_index === 'number'
|
||||
? (out.section_index as number)
|
||||
: (typeof out.sectionIndex === 'number' ? (out.sectionIndex as number) : undefined),
|
||||
sectionTitle: typeof out.section_title === 'string'
|
||||
? (out.section_title as string)
|
||||
: (typeof out.sectionTitle === 'string' ? (out.sectionTitle as string) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
if (!out.message) out.message = '';
|
||||
return out as unknown as ProgressEvent;
|
||||
}
|
||||
@@ -522,6 +569,7 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
'skill_search_step', 'skill_select_chunk',
|
||||
'model_switch',
|
||||
];
|
||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
||||
|
||||
@@ -536,10 +584,30 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
if (Array.isArray(merged.issues)) {
|
||||
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
if ('agent_name' in merged && !merged.agentName) merged.agentName = merged.agent_name;
|
||||
// skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用
|
||||
if (type === 'skill_search_step') {
|
||||
merged.skillStep = normalizeSkillStep(o);
|
||||
}
|
||||
// model_switch:把 snake_case 字段折成 modelSwitch 子对象(reducer + TimelineStep 取用)
|
||||
if (type === 'model_switch') {
|
||||
merged.modelSwitch = {
|
||||
stage: (o.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent',
|
||||
failedModel: String(o.failed_model ?? (o as Record<string, unknown>).failedModel ?? ''),
|
||||
nextModel: String(o.next_model ?? (o as Record<string, unknown>).nextModel ?? ''),
|
||||
reason: String(o.reason ?? ''),
|
||||
sectionIndex: typeof o.section_index === 'number'
|
||||
? (o.section_index as number)
|
||||
: (typeof (o as Record<string, unknown>).sectionIndex === 'number'
|
||||
? ((o as Record<string, unknown>).sectionIndex as number)
|
||||
: undefined),
|
||||
sectionTitle: typeof o.section_title === 'string'
|
||||
? (o.section_title as string)
|
||||
: (typeof (o as Record<string, unknown>).sectionTitle === 'string'
|
||||
? ((o as Record<string, unknown>).sectionTitle as string)
|
||||
: undefined),
|
||||
};
|
||||
}
|
||||
return merged as unknown as ProgressEvent;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export type ProgressEventType =
|
||||
| "await_user"
|
||||
| "resumed"
|
||||
| "notice"
|
||||
| "model_switch"
|
||||
| "skill_search_step"
|
||||
| "skill_select_chunk"
|
||||
| "done"
|
||||
@@ -215,6 +216,16 @@ export interface ProgressEvent {
|
||||
blockedSections?: BlockedSection[];
|
||||
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
|
||||
skillStep?: SkillSearchStep;
|
||||
// model_switch:模型自动容错切换(某模型挂掉,换下一个模型继续)
|
||||
// stage ∈ intent / outline / draft / review,前端 reducer 据此清掉对应阶段的流式缓冲。
|
||||
modelSwitch?: {
|
||||
stage: "intent" | "outline" | "draft" | "review";
|
||||
failedModel: string;
|
||||
nextModel: string;
|
||||
reason: string;
|
||||
sectionIndex?: number;
|
||||
sectionTitle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Replace,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
@@ -10,6 +11,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
@@ -19,6 +21,7 @@ import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { SkillAddressesPanel } from "./SkillAddressesPage";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -52,27 +55,47 @@ export default function AdminSkillsPage() {
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<Tabs
|
||||
defaultValue="skills"
|
||||
className="flex size-full min-h-0 flex-col gap-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b px-6 pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">技能管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-64 shrink-0">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
<TabsList>
|
||||
<TabsTrigger value="skills">
|
||||
<SparklesIcon />
|
||||
技能列表
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="addresses">
|
||||
<Replace />
|
||||
技能地址管理
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||
{isLoading && (
|
||||
<TabsContent
|
||||
value="skills"
|
||||
className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||
</p>
|
||||
<div className="relative w-64 shrink-0">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
@@ -132,8 +155,16 @@ export default function AdminSkillsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="addresses"
|
||||
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<SkillAddressesPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { readTaskContext } from "@/core/auth/task-purpose";
|
||||
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
|
||||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
@@ -143,8 +143,9 @@ export default function AgentChatPage({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤。
|
||||
threadMetadata: taskId ? { taskId } : undefined,
|
||||
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
|
||||
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
|
||||
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
@@ -469,6 +470,7 @@ export default function AgentChatPage({
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed={taskCtxOn}
|
||||
showRecentChats={!taskCtxOn}
|
||||
/>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -26,6 +26,10 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { QuestionNavigator } from "@/components/workspace/question-navigator";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import {
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
@@ -484,8 +488,12 @@ export default function ChatPage() {
|
||||
<ArtifactTrigger />
|
||||
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
|
||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{!compactChrome && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div
|
||||
className="bg-border/70 mx-1 h-6 w-px shrink-0"
|
||||
aria-hidden
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
updateAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
|
||||
@@ -75,7 +76,11 @@ function getCreateAgentErrorMessage(
|
||||
export default function NewAgentPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
// 从「常用对话」点击某条智能体创建(bootstrap)会话回到本页时,URL 带 thread_id,
|
||||
// 用它恢复该会话(加载历史消息、继续对话);正常进入则新开一个会话。
|
||||
const { thread_id: resumeThreadId } = useParams<{ thread_id?: string }>();
|
||||
const generatedThreadId = useMemo(() => uuid(), []);
|
||||
const threadId = resumeThreadId || generatedThreadId;
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [descriptionInput, setDescriptionInput] = useState("");
|
||||
@@ -94,10 +99,15 @@ export default function NewAgentPage() {
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
const [skillSearch, setSkillSearch] = useState("");
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
|
||||
const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId);
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const { models, isLoading: isModelsLoading } = useModels();
|
||||
|
||||
// 创建请求的归属账号取自「实时」token(apiFetch 每次都重读 localStorage),而非
|
||||
// 本页加载时缓存的身份 —— 用 live 账号提示归属并检测账号是否在会话中途被切换。
|
||||
const live = useLiveAccount();
|
||||
|
||||
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
|
||||
// 其他用户未发布的自定义技能不可选。
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
@@ -292,13 +302,37 @@ export default function NewAgentPage() {
|
||||
]);
|
||||
|
||||
const handleSaveAgent = useCallback(async () => {
|
||||
// 新建时归属账号 = 创建请求所用的实时 token 对应账号。若本页加载后登录被切换
|
||||
// (常见于另一标签页登录了别的账号),现在创建会把智能体静默归到新账号名下。
|
||||
// 先弹确认,避免「用 A 账号创建却归到 B 账号」。更新已有智能体不改归属,故跳过。
|
||||
if (!agentId && live.switched) {
|
||||
const ok = window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`现在创建将把该智能体归属到「${live.displayName}」名下。\n` +
|
||||
`如需归属「${live.mountDisplayName}」,请取消并刷新页面后重新登录。\n\n仍要以「${live.displayName}」创建吗?`,
|
||||
);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const saved = await ensureAgent();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
toast.success(`智能体「${saved.name}」已保存`);
|
||||
toast.success(
|
||||
agentId
|
||||
? `智能体「${saved.name}」已保存`
|
||||
: `智能体「${saved.name}」已创建(归属账号:${live.displayName})`,
|
||||
);
|
||||
router.push("/page/workspace/agents");
|
||||
}, [ensureAgent, router]);
|
||||
}, [
|
||||
agentId,
|
||||
ensureAgent,
|
||||
live.displayName,
|
||||
live.mountDisplayName,
|
||||
live.switched,
|
||||
router,
|
||||
]);
|
||||
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
@@ -666,6 +700,25 @@ export default function NewAgentPage() {
|
||||
<p className="text-destructive text-sm">{formError}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border px-3 py-2 text-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
<p className="text-muted-foreground">
|
||||
{agentId ? "当前登录账号:" : "创建后归属账号:"}
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 本页加载时为「{live.mountDisplayName}」,现已切换为「
|
||||
{live.displayName}」。
|
||||
{agentId
|
||||
? "继续保存将以当前账号执行。"
|
||||
: `继续创建将归属「${live.displayName}」。`}
|
||||
建议刷新页面确认账号。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleSaveAgent()}
|
||||
|
||||
@@ -225,9 +225,9 @@ export default function RoundtableDiagnosticsPage() {
|
||||
返回
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-slate-800 dark:text-foreground">会商诊断日志</h1>
|
||||
<h1 className="text-base font-semibold text-slate-800 dark:text-foreground">会商报错日志</h1>
|
||||
<p className="text-xs text-slate-400 dark:text-muted-foreground">
|
||||
多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等)
|
||||
多智能体会商全流程报错日志(席位失败 / 空交付 / 超时 / 模型容错 / 产物缺失 / 崩溃等;运行进度类 info 不再收集)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
FileCode,
|
||||
Globe,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Network,
|
||||
Replace,
|
||||
ScanLine,
|
||||
Search,
|
||||
Undo2,
|
||||
Wand2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type AddressGroup,
|
||||
type AddressKind,
|
||||
type ApplyProgress,
|
||||
type PreviewResult,
|
||||
type Replacement,
|
||||
type ScanProgress,
|
||||
type ScanResult,
|
||||
applyReplacementsStream,
|
||||
previewReplacements,
|
||||
rollbackBackup,
|
||||
scanAddressesStream,
|
||||
useSkillAddressBackups,
|
||||
} from "@/strategy-components/api/skill-addresses";
|
||||
|
||||
const ALL_KINDS: AddressKind[] = ["url", "ip"];
|
||||
|
||||
function ProgressBar({ done, total, label }: { done: number; total: number; label: string }) {
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-200/70 bg-blue-50/60 p-3.5">
|
||||
<div className="mb-1.5 flex items-center justify-between text-xs font-medium text-blue-700">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{done} / {total}({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-blue-100">
|
||||
<div className="h-full rounded-full bg-blue-500 transition-all duration-200" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KindBadge({ kind }: { kind: AddressKind }) {
|
||||
const isUrl = kind === "url";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold ${
|
||||
isUrl ? "bg-indigo-50 text-indigo-600" : "bg-emerald-50 text-emerald-600"
|
||||
}`}
|
||||
>
|
||||
{isUrl ? <Globe className="size-3" /> : <Network className="size-3" />}
|
||||
{isUrl ? "URL" : "IP"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillAddressesPanel() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
|
||||
const [kinds, setKinds] = useState<AddressKind[]>(ALL_KINDS);
|
||||
const [scan, setScan] = useState<ScanResult | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanProg, setScanProg] = useState<ScanProgress | null>(null);
|
||||
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyProg, setApplyProg] = useState<ApplyProgress | null>(null);
|
||||
const [lastBackupId, setLastBackupId] = useState<string | null>(null);
|
||||
|
||||
// 修改记录(每次「应用」生成一条可单独回退的记录)。
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [confirmRollbackId, setConfirmRollbackId] = useState<string | null>(null);
|
||||
const [rollingBack, setRollingBack] = useState(false);
|
||||
|
||||
const [segFind, setSegFind] = useState("");
|
||||
const [segRepl, setSegRepl] = useState("");
|
||||
|
||||
const { data: backups = [], refetch: refetchBackups } = useSkillAddressBackups();
|
||||
|
||||
const runScan = useCallback(
|
||||
async (useKinds: AddressKind[]) => {
|
||||
setScanning(true);
|
||||
setScanProg({ done: 0, total: 0 });
|
||||
setPreview(null);
|
||||
try {
|
||||
const result = await scanAddressesStream({ kinds: useKinds, onProgress: setScanProg });
|
||||
setScan(result);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "扫描失败");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setScanProg(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 首次进入自动扫描。
|
||||
useEffect(() => {
|
||||
if (isAdmin) void runScan(ALL_KINDS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdmin]);
|
||||
|
||||
const groups = scan?.groups ?? [];
|
||||
|
||||
const toggleKind = (k: AddressKind) => {
|
||||
const next = kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k];
|
||||
if (next.length === 0) return; // 至少保留一种
|
||||
setKinds(next);
|
||||
void runScan(next);
|
||||
};
|
||||
|
||||
const setEdit = (addr: string, val: string) => setEdits((p) => ({ ...p, [addr]: val }));
|
||||
|
||||
const toggleExpand = (addr: string) =>
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(addr)) n.delete(addr);
|
||||
else n.add(addr);
|
||||
return n;
|
||||
});
|
||||
|
||||
/** 片段批量套用:把所有地址里出现的「查找片段」整体替换为「替换片段」,填入各行的「替换为」。 */
|
||||
const applySegment = () => {
|
||||
const find = segFind.trim();
|
||||
if (!find) {
|
||||
toast.error("请先填写要查找的片段(如 uat.4.cn)");
|
||||
return;
|
||||
}
|
||||
let n = 0;
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const g of groups) {
|
||||
if (g.address.includes(find)) {
|
||||
const replaced = g.address.split(find).join(segRepl);
|
||||
if (replaced !== g.address) {
|
||||
next[g.address] = replaced;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (n === 0) toast.info(`没有地址包含片段「${find}」`);
|
||||
else toast.success(`已为 ${n} 个地址套用替换(请预览后应用)`);
|
||||
};
|
||||
|
||||
const buildReplacements = useCallback((): Replacement[] => {
|
||||
const out: Replacement[] = [];
|
||||
for (const g of groups) {
|
||||
const to = (edits[g.address] ?? "").trim();
|
||||
if (to && to !== g.address) out.push({ from: g.address, to, kind: g.kind });
|
||||
}
|
||||
return out;
|
||||
}, [groups, edits]);
|
||||
|
||||
const pendingCount = useMemo(() => buildReplacements().length, [buildReplacements]);
|
||||
|
||||
const runPreview = async () => {
|
||||
const repls = buildReplacements();
|
||||
if (repls.length === 0) {
|
||||
toast.error("还没有填写任何「替换为」");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await previewReplacements(repls);
|
||||
setPreview(result);
|
||||
if (result.total === 0) toast.info("没有任何文件会被改动(请检查填写的替换值)");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "预览失败");
|
||||
}
|
||||
};
|
||||
|
||||
const runApply = async () => {
|
||||
if (!preview) return;
|
||||
const repls = buildReplacements();
|
||||
if (repls.length === 0) return;
|
||||
setApplying(true);
|
||||
setApplyProg({ done: 0, totalFiles: 0 });
|
||||
try {
|
||||
const result = await applyReplacementsStream(repls, {
|
||||
expectedTotal: preview.expectedTotal,
|
||||
backup: true,
|
||||
onProgress: setApplyProg,
|
||||
});
|
||||
toast.success(`已修改 ${result.changedFiles.length} 个文件,共替换 ${result.replacedTotal} 处`);
|
||||
setLastBackupId(result.backupId);
|
||||
setEdits({});
|
||||
setPreview(null);
|
||||
setShowHistory(true); // 应用后自动展开修改记录,便于查看/回退本次
|
||||
await refetchBackups();
|
||||
await runScan(kinds);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "应用失败");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
setApplyProg(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** 回退单次修改记录:从该次备份恢复文件(不影响其他记录)。 */
|
||||
const rollbackOne = async (backupId: string) => {
|
||||
setRollingBack(true);
|
||||
try {
|
||||
const { restored } = await rollbackBackup(backupId);
|
||||
toast.success(`已从该记录回退 ${restored} 个文件`);
|
||||
setConfirmRollbackId(null);
|
||||
if (backupId === lastBackupId) setLastBackupId(null);
|
||||
await refetchBackups();
|
||||
await runScan(kinds);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "回退失败");
|
||||
} finally {
|
||||
setRollingBack(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
// 多关键词检索:空格分隔,每个关键词都要命中(地址或技能名任一包含)才显示。
|
||||
// 这样「uat 4.cn」能同时收窄到既含 uat 又含 4.cn 的地址,应对地址过多的场景。
|
||||
const tokens = useMemo(
|
||||
() => query.trim().toLowerCase().split(/\s+/).filter(Boolean),
|
||||
[query],
|
||||
);
|
||||
const visible = useMemo(() => {
|
||||
if (tokens.length === 0) return groups;
|
||||
return groups.filter((g) => {
|
||||
const haystack = `${g.address} ${g.occurrences.map((o) => o.skill).join(" ")}`.toLowerCase();
|
||||
return tokens.every((t) => haystack.includes(t));
|
||||
});
|
||||
}, [groups, tokens]);
|
||||
|
||||
const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]);
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* 顶部信息卡 */}
|
||||
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
|
||||
<Replace className="size-44 text-slate-900" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-600">
|
||||
<Wand2 className="size-3" />
|
||||
技能维护工具
|
||||
</span>
|
||||
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
|
||||
技能地址管理
|
||||
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
|
||||
{groups.length} 地址 / {totalOccurrences} 处
|
||||
{scan ? ` / ${scan.scannedSkills} 技能` : ""}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
className={`inline-flex cursor-pointer items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-semibold shadow-sm transition-all ${
|
||||
showHistory
|
||||
? "border-amber-300 bg-amber-50 text-amber-700"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
|
||||
}`}
|
||||
title="查看每次修改记录,可针对单次回退"
|
||||
>
|
||||
<History className="size-[15px]" />
|
||||
修改记录{backups.length > 0 ? `(${backups.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runScan(kinds)}
|
||||
disabled={scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-blue-600 bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{scanning ? <Loader2 className="size-[15px] animate-spin" /> : <ScanLine className="size-[15px]" />}
|
||||
{scanning ? "扫描中…" : "重新扫描"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex max-w-3xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
扫描运行中的技能(<code className="rounded bg-slate-100 px-1">skills/public</code> 与{" "}
|
||||
<code className="rounded bg-slate-100 px-1">skills/custom</code> 下所有 .md / .py),按<strong>唯一地址</strong>聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后<strong>预览</strong>命中再<strong>应用</strong>;每次应用都会自动磁盘备份并生成一条<strong>修改记录</strong>,可在「修改记录」里针对<strong>单次记录回退</strong>。替换为精确整串匹配,不会误伤子串。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{scanning && scanProg && (
|
||||
<div className="mb-6">
|
||||
<ProgressBar done={scanProg.done} total={scanProg.total} label={`正在扫描技能${scanProg.skill ? `:${scanProg.skill}` : "…"}`} />
|
||||
</div>
|
||||
)}
|
||||
{applying && applyProg && (
|
||||
<div className="mb-6">
|
||||
<ProgressBar done={applyProg.done} total={applyProg.totalFiles} label={`正在修改文件${applyProg.file ? `:${applyProg.file}` : "…"}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 修改记录:每次应用一条,可针对单次回退 */}
|
||||
{showHistory && (
|
||||
<div className="mb-6 overflow-hidden rounded-2xl border border-amber-200/70 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-amber-100 bg-amber-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
|
||||
<span className="flex items-center gap-2">
|
||||
<History className="size-4 text-amber-600" />
|
||||
修改记录({backups.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetchBackups()}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{backups.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-slate-400">
|
||||
暂无修改记录。应用一次替换后,这里会生成一条可回退的记录。
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-96 divide-y divide-slate-100 overflow-auto">
|
||||
{backups.map((b) => (
|
||||
<li key={b.backupId} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
{fmtTime(b.createdAt)}
|
||||
{b.backupId === lastBackupId && (
|
||||
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700">最近</span>
|
||||
)}
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">
|
||||
{b.files} 文件 / {b.replacedTotal} 处
|
||||
</span>
|
||||
</div>
|
||||
{b.replacements.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{b.replacements.slice(0, 4).map((r, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-500">
|
||||
<span className="line-through">{r.from}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-medium text-blue-700">{r.to}</span>
|
||||
</span>
|
||||
))}
|
||||
{b.replacements.length > 4 && (
|
||||
<span className="text-[11px] text-slate-400">等 {b.replacements.length} 项</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 font-mono text-xs text-slate-400">{b.backupId}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{confirmRollbackId === b.backupId ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rollbackOne(b.backupId)}
|
||||
disabled={rollingBack || scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-amber-500 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white transition-all hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rollingBack ? <Loader2 className="size-3.5 animate-spin" /> : <Undo2 className="size-3.5" />}
|
||||
确认回退
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRollbackId(null)}
|
||||
disabled={rollingBack}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRollbackId(b.backupId)}
|
||||
disabled={scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="把文件恢复到这次修改之前"
|
||||
>
|
||||
<Undo2 className="size-3.5" />
|
||||
回退此次
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */}
|
||||
<div className="mb-6 space-y-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{ALL_KINDS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => toggleKind(k)}
|
||||
disabled={scanning || applying}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
kinds.includes(k)
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{k === "url" ? <Globe className="size-3.5" /> : <Network className="size-3.5" />}
|
||||
{k === "url" ? "URL" : "IP"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="按关键词检索地址 / 技能名(空格分隔多个,全部命中)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-9 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
title="清除"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 片段批量套用 */}
|
||||
<div className="flex flex-col items-stretch gap-2.5 rounded-xl border border-dashed border-slate-200 bg-slate-50/50 p-3 md:flex-row md:items-center">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<Wand2 className="size-3.5" />
|
||||
片段批量套用
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="查找片段,如 uat.4.cn"
|
||||
value={segFind}
|
||||
onChange={(e) => setSegFind(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10"
|
||||
/>
|
||||
<span className="shrink-0 text-slate-400">→</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="替换为,如 prod.4.cn"
|
||||
value={segRepl}
|
||||
onChange={(e) => setSegRepl(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySegment}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3.5 py-1.5 text-sm font-semibold text-slate-700 transition-all hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
套用到匹配项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览面板 */}
|
||||
{preview && (
|
||||
<div className="mb-6 overflow-hidden rounded-2xl border border-blue-200/70 bg-white shadow-sm">
|
||||
<div className="flex flex-col items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/50 px-4 py-3 md:flex-row md:items-center">
|
||||
<div className="text-sm font-semibold text-slate-700">
|
||||
预览:将改动 <span className="font-mono text-blue-700">{preview.total}</span> 处
|
||||
({preview.items.filter((i) => i.count > 0).length} 个地址命中)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runApply}
|
||||
disabled={applying || preview.total === 0}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-blue-600 bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{applying ? <Loader2 className="size-[15px] animate-spin" /> : <Replace className="size-[15px]" />}
|
||||
{applying ? "应用中…" : "确认应用"}
|
||||
</button>
|
||||
</div>
|
||||
{preview.warnings.length > 0 && (
|
||||
<div className="border-b border-amber-100 bg-amber-50/60 px-4 py-2.5">
|
||||
{preview.warnings.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-700">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ul className="max-h-72 divide-y divide-slate-100 overflow-auto">
|
||||
{preview.items.map((it) => (
|
||||
<li key={it.from} className="px-4 py-2.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<KindBadge kind={it.kind} />
|
||||
<span className="font-mono text-slate-500 line-through">{it.from}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-medium text-blue-700">{it.to}</span>
|
||||
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{it.count} 处</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 地址列表 */}
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
|
||||
<span>地址({visible.length}{tokens.length > 0 ? ` / ${groups.length}` : ""})</span>
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPreview}
|
||||
disabled={scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-300 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 transition-all hover:bg-blue-100 disabled:opacity-50"
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
预览改动({pendingCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanning && !scan ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">扫描中…</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{groups.length === 0 ? "未发现任何 URL / IP 地址" : "没有匹配的地址"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{visible.map((g) => (
|
||||
<GroupRow
|
||||
key={g.address}
|
||||
group={g}
|
||||
value={edits[g.address] ?? ""}
|
||||
expanded={expanded.has(g.address)}
|
||||
onToggle={() => toggleExpand(g.address)}
|
||||
onChange={(v) => setEdit(g.address, v)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillAddressesPage() {
|
||||
return <SkillAddressesPanel />;
|
||||
}
|
||||
|
||||
function GroupRow({
|
||||
group,
|
||||
value,
|
||||
expanded,
|
||||
onToggle,
|
||||
onChange,
|
||||
}: {
|
||||
group: AddressGroup;
|
||||
value: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const dirty = value.trim() !== "" && value.trim() !== group.address;
|
||||
return (
|
||||
<li className={`px-4 py-3 transition-colors hover:bg-slate-50/60 ${dirty ? "bg-blue-50/30" : ""}`}>
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button type="button" onClick={onToggle} className="flex size-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
||||
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
<KindBadge kind={group.kind} />
|
||||
<span className="truncate font-mono text-sm text-slate-700" title={group.address}>
|
||||
{group.address}
|
||||
</span>
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{group.count} 处</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="替换为(留空 = 不改)"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10 lg:w-96"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<ul className="mt-2.5 ml-8 space-y-1 border-l border-slate-100 pl-3">
|
||||
{group.occurrences.map((o, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<FileCode className="mt-0.5 size-3.5 shrink-0 text-slate-400" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-slate-600">{o.skill}</span>
|
||||
<span className="mx-1 text-slate-300">·</span>
|
||||
<span className="font-mono">
|
||||
{o.file}:{o.line}
|
||||
</span>
|
||||
<div className="truncate font-mono text-slate-400" title={o.snippet}>
|
||||
{o.snippet}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
@@ -404,6 +405,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skill-addresses"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<SkillAddressesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
|
||||
@@ -57,6 +57,12 @@ function NotebookDetailPageWithKey() {
|
||||
return <StudioNotebookDetailPage key={notebookId} />;
|
||||
}
|
||||
|
||||
// 恢复某条 bootstrap 会话时按 thread_id 重挂,切换会话不串台。
|
||||
function NewAgentPageWithKey() {
|
||||
const { thread_id } = useParams<{ thread_id: string }>();
|
||||
return <NewAgentPage key={thread_id} />;
|
||||
}
|
||||
|
||||
function isInIframe(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
@@ -279,6 +285,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 从「常用对话」点击智能体创建(bootstrap)会话回到创建页并恢复该会话。
|
||||
key 绑定 thread_id —— 切换不同 bootstrap 会话时重挂一个干净的页面。 */}
|
||||
<Route
|
||||
path="agents/new/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<NewAgentPageWithKey />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
|
||||
@@ -188,6 +188,11 @@ export interface DraftMeta {
|
||||
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||
title: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
/**
|
||||
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
|
||||
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
|
||||
*/
|
||||
chainTitle?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
@@ -250,6 +255,7 @@ export interface ServerDraftMeta {
|
||||
task_id?: string | null;
|
||||
title: string;
|
||||
furthest_step: number;
|
||||
chain_title?: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
status?: string | null;
|
||||
@@ -269,6 +275,7 @@ export function toMeta(s: ServerDraftMeta): DraftMeta {
|
||||
taskId: s.task_id ?? null,
|
||||
title: s.title ?? "",
|
||||
furthestStep: step,
|
||||
chainTitle: s.chain_title ?? null,
|
||||
createdAt: s.created_at ?? "",
|
||||
updatedAt: s.updated_at ?? "",
|
||||
status: (s.status as JobStatus | null | undefined) ?? null,
|
||||
|
||||
@@ -296,6 +296,10 @@ export default function RoundtablePlanningPage() {
|
||||
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
|
||||
!prefillRef.current?.taskId,
|
||||
);
|
||||
// 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间
|
||||
// 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后
|
||||
// 强制撤掉「启动加载遮罩」,避免万一初始化卡死把界面永久锁住。
|
||||
const [bootLoadingExpired, setBootLoadingExpired] = useState<boolean>(false);
|
||||
|
||||
const { models: availableModels, isLoading: modelsLoading } = useModels();
|
||||
|
||||
@@ -1143,6 +1147,35 @@ export default function RoundtablePlanningPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── 启动加载遮罩(深链/预填自动会话)──────────────────────────────
|
||||
// 是否是「自动会话」:深链 -A、taskId、businessCode/chainId 任一存在即自动进入问答,
|
||||
// 用户没有手动操作,进入页面到问答之间必须给出明确加载反馈。
|
||||
const isAutoSession = Boolean(
|
||||
prefillRef.current?.autoStart ||
|
||||
prefillRef.current?.taskId ||
|
||||
prefillRef.current?.businessCode ||
|
||||
prefillRef.current?.chainId,
|
||||
);
|
||||
// 「问答已开始」:自动发出的首条用户消息已入列 / 正在流式 / 已得到意图结果,任一为真即认为
|
||||
// 已经动起来,可以撤掉遮罩交给 Step1Panel 自身的流式占位。
|
||||
const conversationStarted =
|
||||
step1.isIntentSending ||
|
||||
step1.intentReady != null ||
|
||||
step1.interactiveMessages.some((m) => m.sender === "user");
|
||||
// 遮罩展示条件:自动会话 + 仍在 Step1 + 问答还没开始 + 没有初始化报错 + 没到安全超时。
|
||||
const showBootLoading =
|
||||
isAutoSession &&
|
||||
currentStep === 1 &&
|
||||
!conversationStarted &&
|
||||
!step1.intentInitError &&
|
||||
!bootLoadingExpired;
|
||||
// 安全超时:无论如何最多遮 30s,避免初始化卡死锁住界面(用户仍可手动操作底层页面)。
|
||||
useEffect(() => {
|
||||
if (!showBootLoading) return;
|
||||
const timer = window.setTimeout(() => setBootLoadingExpired(true), 30_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [showBootLoading]);
|
||||
|
||||
/** 组装无界回传载荷:总控共识 + 各智能体最终交付(剔除总控席位)。 */
|
||||
const buildWujieConsensusPayload = useCallback(
|
||||
() => ({
|
||||
@@ -1780,10 +1813,34 @@ export default function RoundtablePlanningPage() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"roundtable-planning theme-transition flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
|
||||
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
|
||||
isDark && "roundtable-planning--dark",
|
||||
)}
|
||||
>
|
||||
{/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿)
|
||||
期间全屏覆盖,避免线程创建慢时页面像卡住。问答一旦开始或超时即撤掉。 */}
|
||||
{showBootLoading && (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-white"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-7 px-8">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-lg font-semibold tracking-wide text-slate-800">
|
||||
正在准备多智能体会商
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">正在初始化任务理解智能体,请稍候…</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400 [animation-delay:-0.3s]" />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400 [animation-delay:-0.15s]" />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecommendAgentsDialog
|
||||
open={isRecommendDialogOpen}
|
||||
intent={step1.intentReady}
|
||||
@@ -3111,13 +3168,16 @@ function RoundtableEntryRail({
|
||||
{completed ? "已完成" : "未完成"}
|
||||
</span>
|
||||
</div>
|
||||
{/* 下:时间(左下) + 步骤(右下) */}
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-neutral-400">
|
||||
{/* 下:时间(左下) + 业务链(右下,无链条显示「自由模式」) */}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[10px] text-neutral-400 shrink-0">
|
||||
{formatTime(rec.updatedAt)}
|
||||
</span>
|
||||
<span className="rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500">
|
||||
第 {rec.furthestStep} 步
|
||||
<span
|
||||
className="max-w-[60%] truncate rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
|
||||
title={rec.chainTitle || "自由模式"}
|
||||
>
|
||||
{rec.chainTitle || "自由模式"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bell, ChevronDown, FileText, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "@/components/page-sdebar.css";
|
||||
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme, type ThemeType } from "../contexts/ThemeContext";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
const iconButtonClass = (isDark: boolean) =>
|
||||
cn(
|
||||
@@ -32,93 +33,6 @@ const iconButtonClass = (isDark: boolean) =>
|
||||
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
|
||||
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
function ThemeSwitcher({ isDark }: { isDark: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
// 切换按钮:悬浮只改背景,不改图标颜色(显式锁定 hover 文字色等于常态色)
|
||||
const triggerClass = cn(
|
||||
"size-8 shrink-0 rounded-sm",
|
||||
isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={triggerClass}
|
||||
title="换肤"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Shirt className="size-4" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
|
||||
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
|
||||
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
|
||||
{THEME_SWATCHES.map((swatch) => {
|
||||
const active = theme === swatch.id;
|
||||
return (
|
||||
<button
|
||||
key={swatch.id}
|
||||
type="button"
|
||||
title={swatch.label}
|
||||
aria-label={swatch.label}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setTheme(swatch.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: swatch.color,
|
||||
boxShadow: active
|
||||
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
@@ -210,7 +124,12 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<ThemeSwitcher isDark={isDark} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
|
||||
<LayoutModeSwitcher
|
||||
tone={isDark ? "banner-dark" : "banner-light"}
|
||||
fallback="full"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 技能地址(URL/IP)批量替换 API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/skill-addresses`(见 app/gateway/routers/skill_addresses.py)。
|
||||
* 全部仅管理员可用。扫描/应用提供 **NDJSON 流式进度**端点(150+ 技能时显示
|
||||
* 「已处理 X / 共 N」进度条),并保留一次性端点作为兜底。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`;
|
||||
|
||||
export type AddressKind = "url" | "ip";
|
||||
|
||||
export interface AddressOccurrence {
|
||||
skill: string;
|
||||
category: string; // "public" | "custom"
|
||||
file: string; // 相对技能根的 POSIX 路径
|
||||
line: number;
|
||||
col: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface AddressGroup {
|
||||
address: string;
|
||||
kind: AddressKind;
|
||||
count: number;
|
||||
occurrences: AddressOccurrence[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
groups: AddressGroup[];
|
||||
scannedFiles: number;
|
||||
scannedSkills: number;
|
||||
kinds: string[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
export interface Replacement {
|
||||
from: string;
|
||||
to: string;
|
||||
kind?: AddressKind;
|
||||
}
|
||||
|
||||
export interface PreviewFileHit {
|
||||
file: string;
|
||||
line: number;
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: AddressKind;
|
||||
count: number;
|
||||
files: PreviewFileHit[];
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
items: PreviewItem[];
|
||||
total: number;
|
||||
warnings: string[];
|
||||
expectedTotal: number;
|
||||
}
|
||||
|
||||
export interface ChangedFile {
|
||||
file: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
changedFiles: ChangedFile[];
|
||||
replacedTotal: number;
|
||||
backupId: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface BackupReplacement {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: AddressKind;
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
backupId: string;
|
||||
files: number;
|
||||
createdAt: string;
|
||||
replacedTotal: number;
|
||||
replacements: BackupReplacement[];
|
||||
}
|
||||
|
||||
/** 扫描进度(逐技能)。 */
|
||||
export interface ScanProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
/** 应用进度(逐文件)。 */
|
||||
export interface ApplyProgress {
|
||||
done: number;
|
||||
totalFiles: number;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
// ── 错误处理 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function ensureOk(res: Response, ctx: string): Promise<void> {
|
||||
if (res.ok) return;
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} 失败 (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
await ensureOk(res, ctx);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── 序列化辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function toScanResult(d: any): ScanResult {
|
||||
return {
|
||||
groups: (d.groups ?? []) as AddressGroup[],
|
||||
scannedFiles: d.scanned_files ?? 0,
|
||||
scannedSkills: d.scanned_skills ?? 0,
|
||||
kinds: d.kinds ?? [],
|
||||
scannedAt: d.scanned_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toPreviewResult(d: any): PreviewResult {
|
||||
return {
|
||||
items: (d.items ?? []).map((i: any) => ({
|
||||
from: i.from,
|
||||
to: i.to,
|
||||
kind: i.kind,
|
||||
count: i.count ?? 0,
|
||||
files: i.files ?? [],
|
||||
})),
|
||||
total: d.total ?? 0,
|
||||
warnings: d.warnings ?? [],
|
||||
expectedTotal: d.expected_total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toApplyResult(d: any): ApplyResult {
|
||||
return {
|
||||
changedFiles: (d.changed_files ?? []).map((c: any) => ({ file: c.file, count: c.count })),
|
||||
replacedTotal: d.replaced_total ?? 0,
|
||||
backupId: d.backup_id ?? null,
|
||||
warnings: d.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── NDJSON 流式读取 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 逐行解析 NDJSON 流,对每个事件对象调用 onEvent。 */
|
||||
async function readNdjson(res: Response, ctx: string, onEvent: (ev: any) => void): Promise<void> {
|
||||
await ensureOk(res, ctx);
|
||||
if (!res.body) throw new Error(`${ctx}:响应无数据流`);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const flush = (chunk: string) => {
|
||||
buf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (line) onEvent(JSON.parse(line));
|
||||
}
|
||||
};
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
flush(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
const tail = (buf + decoder.decode()).trim();
|
||||
if (tail) onEvent(JSON.parse(tail));
|
||||
}
|
||||
|
||||
// ── 端点 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 一次性扫描(兜底,无进度)。 */
|
||||
export async function scanAddresses(kinds?: AddressKind[], skill?: string): Promise<ScanResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (kinds && kinds.length) params.set("kinds", kinds.join(","));
|
||||
if (skill) params.set("skill", skill);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" });
|
||||
return toScanResult(await readJson<any>(res, "扫描"));
|
||||
}
|
||||
|
||||
/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */
|
||||
export async function scanAddressesStream(
|
||||
opts: { kinds?: AddressKind[]; skill?: string; onProgress?: (p: ScanProgress) => void } = {},
|
||||
): Promise<ScanResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.kinds && opts.kinds.length) params.set("kinds", opts.kinds.join(","));
|
||||
if (opts.skill) params.set("skill", opts.skill);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${BASE()}/scan/stream${qs ? `?${qs}` : ""}`, { method: "GET" });
|
||||
let result: ScanResult | null = null;
|
||||
await readNdjson(res, "扫描", (ev) => {
|
||||
if (ev.type === "start") opts.onProgress?.({ done: 0, total: ev.total ?? 0 });
|
||||
else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, total: ev.total, skill: ev.skill });
|
||||
else if (ev.type === "result") result = toScanResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("扫描未返回结果");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 干跑预览。 */
|
||||
export async function previewReplacements(replacements: Replacement[]): Promise<PreviewResult> {
|
||||
const res = await apiFetch(`${BASE()}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ replacements }),
|
||||
});
|
||||
return toPreviewResult(await readJson<any>(res, "预览"));
|
||||
}
|
||||
|
||||
/** 流式应用:逐文件上报进度,返回最终结果。`expectedTotal` 做乐观锁。 */
|
||||
export async function applyReplacementsStream(
|
||||
replacements: Replacement[],
|
||||
opts: { expectedTotal?: number; backup?: boolean; onProgress?: (p: ApplyProgress) => void } = {},
|
||||
): Promise<ApplyResult> {
|
||||
const body = {
|
||||
replacements,
|
||||
expected_total: opts.expectedTotal ?? null,
|
||||
backup: opts.backup ?? true,
|
||||
};
|
||||
const res = await apiFetch(`${BASE()}/apply/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let result: ApplyResult | null = null;
|
||||
await readNdjson(res, "应用", (ev) => {
|
||||
if (ev.type === "start") opts.onProgress?.({ done: 0, totalFiles: ev.total_files ?? 0 });
|
||||
else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, totalFiles: ev.total_files, file: ev.file });
|
||||
else if (ev.type === "result") result = toApplyResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("应用未返回结果");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listBackups(): Promise<BackupEntry[]> {
|
||||
const res = await apiFetch(`${BASE()}/backups`, { method: "GET" });
|
||||
const data = await readJson<{ backups: any[] }>(res, "读取备份");
|
||||
return (data.backups ?? []).map((b) => ({
|
||||
backupId: b.backup_id,
|
||||
files: b.files ?? 0,
|
||||
createdAt: b.created_at ?? "",
|
||||
replacedTotal: b.replaced_total ?? 0,
|
||||
replacements: (b.replacements ?? []).map((r: any) => ({ from: r.from, to: r.to, kind: r.kind })),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
|
||||
const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" });
|
||||
const data = await readJson<{ restored_files: string[] }>(res, "撤销");
|
||||
return { restored: (data.restored_files ?? []).length };
|
||||
}
|
||||
|
||||
// ── React Query ────────────────────────────────────────────────────────────
|
||||
|
||||
export const SKILL_ADDRESS_BACKUPS_KEY = "skill-address-backups";
|
||||
|
||||
export function useSkillAddressBackups() {
|
||||
return useQuery({
|
||||
queryKey: [SKILL_ADDRESS_BACKUPS_KEY],
|
||||
queryFn: listBackups,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
+104
-8
@@ -30,7 +30,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||||
import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink";
|
||||
import {
|
||||
fetchActionDetail,
|
||||
readCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
type ActionDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons";
|
||||
import { useTaskThreads } from "@/core/threads/use-task-threads";
|
||||
import { titleOfThread } from "@/core/threads/utils";
|
||||
@@ -40,11 +46,19 @@ import AgentChatPage from "@/pages/AgentChatPage";
|
||||
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
|
||||
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
|
||||
|
||||
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
|
||||
function useChatsBase(taskBase: string): {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
/** xdfx(partitionByAction):按「行动id」划分聊天记录用;其它业务为 ""(仍按 taskId 划分)。 */
|
||||
actionId: string;
|
||||
chatsBase: string;
|
||||
} {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
const agentId = agent_id ?? "";
|
||||
const taskId = readTaskContext()?.taskId ?? "";
|
||||
return { taskId, agentId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
|
||||
const ctx = readTaskContext();
|
||||
const taskId = ctx?.taskId ?? "";
|
||||
const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : "";
|
||||
return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,19 +150,22 @@ function parseThreadId(pathname: string, chatsBase: string): string | null {
|
||||
function TaskConversationsList({
|
||||
taskId,
|
||||
agentId,
|
||||
actionId,
|
||||
chatsBase,
|
||||
business,
|
||||
lite,
|
||||
}: {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
/** xdfx:按「行动id」划分聊天记录;其它业务为 ""。 */
|
||||
actionId: string;
|
||||
chatsBase: string;
|
||||
business: TaskBusiness;
|
||||
lite?: boolean;
|
||||
}) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { threads, isLoading } = useTaskThreads(taskId, agentId);
|
||||
const { threads, isLoading } = useTaskThreads(taskId, agentId, actionId);
|
||||
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
|
||||
const businessName = desensitize(TASK_BUSINESSES.find((b) => b.code === business)?.name ?? "");
|
||||
|
||||
@@ -276,6 +293,82 @@ function TaskClearButton({ taskId }: { taskId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口
|
||||
* (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空
|
||||
* (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」,
|
||||
* 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。
|
||||
* 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。
|
||||
*/
|
||||
function RwfxSqReportGate({ taskId }: { taskId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [jumping, setJumping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((status) => {
|
||||
// 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。
|
||||
if (alive && status === "incomplete") setOpen(true);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail || !taskId) {
|
||||
toast.error("缺少任务信息,无法跳转");
|
||||
return;
|
||||
}
|
||||
setJumping(true);
|
||||
try {
|
||||
// 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
|
||||
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
|
||||
if (target) {
|
||||
setOpen(false);
|
||||
navigate(target.path, { state: target.state });
|
||||
} else {
|
||||
toast.error("未配置三情分析(3qfx)对应的业务智能体");
|
||||
}
|
||||
} catch {
|
||||
toast.error("跳转失败,请稍后再试");
|
||||
} finally {
|
||||
setJumping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !jumping && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>3q 未完成</DialogTitle>
|
||||
<DialogDescription>
|
||||
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={jumping}>
|
||||
暂不
|
||||
</Button>
|
||||
<Button
|
||||
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故不能用
|
||||
// 局部的 `--color-main`(红主题侥幸有值、蓝主题失效→按钮消失)。改用全局按主题
|
||||
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
|
||||
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
|
||||
onClick={handleGo3qfx}
|
||||
disabled={jumping}
|
||||
>
|
||||
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务工作区布局:左列表 + 右 Outlet。
|
||||
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
|
||||
@@ -289,13 +382,14 @@ export function TaskFxWorkspace({
|
||||
business: TaskBusiness;
|
||||
lite?: boolean;
|
||||
}) {
|
||||
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
|
||||
const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase);
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<TaskConversationsList
|
||||
taskId={taskId}
|
||||
agentId={agentId}
|
||||
actionId={actionId}
|
||||
chatsBase={chatsBase}
|
||||
business={business}
|
||||
lite={lite}
|
||||
@@ -305,15 +399,17 @@ export function TaskFxWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
<TaskJumpBar business={business} taskId={taskId} />
|
||||
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
|
||||
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
|
||||
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
|
||||
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
|
||||
const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase);
|
||||
const navigate = useNavigate();
|
||||
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId);
|
||||
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId, actionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -243,11 +243,12 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
|
||||
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); tests `tests/test_open_chat.py`. |
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
|
||||
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `<skill_system>` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") toLine truncated
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
|
||||
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts` (each meta also carries `chain_title`, same `_extract_chain_title` regex as the personal store); `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (warning/error — **`info` 级运行日志不再收集**:`record_diagnostic` 在写库前丢弃 `level=="info"`,只留报错;begin/consensus/gather/job_started 等纯进度事件即使仍调 `.diag(level="info")` 也成 no-op,故诊断页 = 报错日志页), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `<skill_system>` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.Line truncated
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
|
||||
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
@@ -393,7 +394,10 @@ query-time type, `recommended_question`.
|
||||
Sync only ever adds/removes `origin='position'` rows, so a user's own
|
||||
favorites/subscriptions are never clobbered. Default sets (no binding for a
|
||||
type): agents = built-ins (`user_id IS NULL`); skills = empty (square stays
|
||||
full, nothing added to 我的); scheduled tasks = empty; questions = unrestricted.
|
||||
full, nothing added to 我的); scheduled tasks = empty; questions = **untagged
|
||||
only** (默认岗位 / 无标签绑定 只看到未打标的题目;打标的题目只在绑定了对应标签的
|
||||
岗位展示 — the router interprets `resolve_visible → None` as "show only untagged",
|
||||
not "show everything").
|
||||
`origin`/`position_id` columns were added to `agent_favorites` and
|
||||
`scheduled_task_subscriptions` — see migration `20260616_01` +
|
||||
`scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these
|
||||
@@ -415,8 +419,12 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label
|
||||
changes: `GET /api/recommended-questions` is now
|
||||
filtered by the caller's position (`?all=1` bypasses for admin management) and
|
||||
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`.
|
||||
Each listed question now carries its assigned `tags` (bulk-attached via
|
||||
`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids`
|
||||
**Default-position rule**: tags are bulk-attached to **all** questions first,
|
||||
then the visibility filter applies — a position binding recommended_question 标签
|
||||
sees only those tagged questions; a 默认岗位 / 无绑定 (`resolve_visible → None`)
|
||||
sees only the **untagged** questions (打标的题目不再泄漏到默认岗位). `?all=1`
|
||||
(admin management) skips this filter and returns every question with its tags.
|
||||
The list also accepts a repeatable `tag_ids`
|
||||
OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by
|
||||
them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests:
|
||||
`tests/test_recommended_questions_tags.py`.
|
||||
@@ -520,6 +528,61 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
|
||||
|
||||
#### Skill Address Management (技能地址管理 — URL/IP 批量替换)
|
||||
|
||||
Admin-only maintenance tool to find every `http(s)://` URL and IPv4 (`ip[:port]`)
|
||||
address in the **running** skills (`skills/{public,custom}`, all `.md` + `.py`,
|
||||
recursing into `scripts/`/`references/`/`templates/`) and rewrite them in bulk —
|
||||
e.g. swapping a UAT endpoint for production across all skills at once. The same
|
||||
address commonly lives in both a skill's `SKILL.md` and its `scripts/*.py`, so
|
||||
results are **aggregated by unique address** with every occurrence (`file:line` +
|
||||
snippet) and replaced together.
|
||||
|
||||
- **Pure engine** — `deerflow/skills/address_scan.py` (harness, no `app.*`):
|
||||
`find_spans` / `scan_occurrences` (URL regex with trailing-punctuation strip;
|
||||
IPv4 with 0–255 octet check + optional port; an IP inside a URL is *not*
|
||||
separately surfaced), `apply_replacements` (**substring-safe**: re-detects exact
|
||||
token spans and only rewrites a span whose text equals a requested `from`, so
|
||||
`1.2.3.4` is never rewritten inside `11.2.3.40`), `infer_kind`,
|
||||
`is_valid_replacement`. Tested in `tests/test_skill_addresses.py`.
|
||||
- **Router** — `app/gateway/routers/skill_addresses.py` (`/api/skill-addresses`,
|
||||
**admin only**). **Availability at scale (150+ skills):** every filesystem
|
||||
walk/read/write runs via `asyncio.to_thread` so the event loop is never blocked.
|
||||
Routes: `GET /scan` (aggregated, one-shot) + `GET /scan/stream` (per-skill NDJSON
|
||||
progress `{type:start|progress|result}`); `POST /preview` (dry-run diff +
|
||||
validation warnings); `POST /apply` + `POST /apply/stream` (per-file NDJSON
|
||||
progress); `GET /backups`; `POST /rollback/{backup_id}`. **Apply safety:**
|
||||
optimistic lock via `expected_total` (409 if files changed since the scan),
|
||||
path-guard (`is_relative_to` the skills root), auto disk backup to
|
||||
`{skills_root}/.address-edits/{timestamp}/` (the `.`-prefixed dir is excluded
|
||||
from scans), then `refresh_skills_system_prompt_cache_async()` so edits take
|
||||
effect immediately. Registered in `app.py` after `sensitive_words.router`.
|
||||
**Per-edit history:** each apply also writes a sibling manifest
|
||||
`{skills_root}/.address-edits/{timestamp}.manifest.json` (best-effort) capturing
|
||||
`{created_at, replaced_total, replacements:[{from,to,kind}], changed_files}`; the
|
||||
sibling-not-child placement keeps it out of both the restore set and the file
|
||||
count. `GET /backups` returns each record enriched with `replaced_total` +
|
||||
`replacements` (from the manifest; falls back to dir mtime when absent), and
|
||||
`POST /rollback/{backup_id}` restores a single record — so the UI shows a full
|
||||
modification history and rolls back any one entry independently.
|
||||
- **Frontend** — the panel is the reusable `SkillAddressesPanel` (exported from
|
||||
`pages/SkillAddressesPage.tsx`; its default export keeps the standalone route
|
||||
`/page/strategy/admin/skill-addresses` as a direct-link fallback). It is
|
||||
**embedded as a「技能地址管理」tab inside the admin「技能管理」page**
|
||||
`pages/AdminSkillsPage.tsx` (Tabs: 技能列表 + 技能地址管理; reached from the
|
||||
「技能管理」item in the「设置和更多」dropdown `workspace-nav-menu.tsx` →
|
||||
`/page/workspace/admin/skills`) — it is no longer a separate entry in the
|
||||
`components/page-sidebar.tsx` settings dropdown. The panel:
|
||||
auto-scan on open with a **progress bar** (扫描逐技能 / 应用逐文件 X/N), URL/IP
|
||||
type filter, search, a「片段批量套用」helper (e.g. all `uat.4.cn` → `prod.4.cn`),
|
||||
per-address「替换为」input with expandable occurrences, inline 预览 panel, 应用,
|
||||
**multi-keyword search** (space-separated, all-must-hit), and a **修改记录**
|
||||
(modification history) panel — each apply is one record (time / which addresses
|
||||
changed from→to / file & replacement counts) with a per-record 回退此次
|
||||
(single-record rollback, with inline confirm). Client + NDJSON stream parsing in
|
||||
`strategy-components/api/skill-addresses.ts`. Scope is the running skills only;
|
||||
the `deploy/minimal/skills/` copy is not touched (sync separately).
|
||||
|
||||
#### Skill Compression (技能压缩)
|
||||
|
||||
When the skill catalogue grows large, injecting every enabled skill full-text
|
||||
@@ -615,6 +678,7 @@ scheduler runs once per deployment. Per run:
|
||||
|
||||
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
||||
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
|
||||
- `force_disable_thinking=True` (kwarg) hard-disables thinking even when the model config declares **no** `when_thinking_*`/`thinking` shape — many internal vLLM/Qwen models only set `supports_thinking: true`, so `thinking_enabled=False` alone injects nothing and they keep reasoning on. The flag injects both OpenAI-compatible disable shapes (`extra_body.chat_template_kwargs.enable_thinking=false` for vLLM/Qwen + `extra_body.thinking.type="disabled"` for Zhipu GLM), only for classes that accept `extra_body`. Mirrors `TitleMiddleware._force_disable_thinking`. The lead agent reads the runtime flag `thinking_force_disabled` and forwards it (used by `/api/open/chat`)
|
||||
- Supports vLLM-style thinking toggles via `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking` for Qwen reasoning models, while normalizing legacy `thinking` configs for backward compatibility
|
||||
- Supports `supports_vision` flag for image understanding models
|
||||
- Config values starting with `$` resolved as environment variables
|
||||
|
||||
@@ -50,6 +50,7 @@ from app.gateway.routers import (
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
positions,
|
||||
@@ -69,6 +70,7 @@ from app.gateway.routers import (
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
sensitive_words,
|
||||
skill_addresses,
|
||||
skills,
|
||||
suggestions,
|
||||
system_settings,
|
||||
@@ -1359,6 +1361,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
|
||||
app.include_router(sensitive_words.router)
|
||||
|
||||
# Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
|
||||
app.include_router(skill_addresses.router)
|
||||
|
||||
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
|
||||
app.include_router(dashboard_sessions.router)
|
||||
|
||||
@@ -1456,6 +1461,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||
app.include_router(runs.router)
|
||||
|
||||
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
||||
app.include_router(open_chat.router)
|
||||
|
||||
# --- ?????????? /page/workspace/roundtable/planning?---
|
||||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||||
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
||||
|
||||
@@ -35,6 +35,9 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/public/",
|
||||
# 开放问答接口:无需登录即可指定模型 + 智能体做问答(见 routers/open_chat.py)。
|
||||
# 以 "default" 用户桶运行,只暴露受控的问答能力。
|
||||
"/api/open/",
|
||||
)
|
||||
|
||||
# Exact auth paths that are public (login/register/status check).
|
||||
|
||||
@@ -52,6 +52,10 @@ def should_check_csrf(request: Request) -> bool:
|
||||
# PUT/POST returns 403 even though auth was bypassed earlier in the chain.
|
||||
if path.startswith("/api/public/"):
|
||||
return False
|
||||
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
|
||||
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""开放问答接口(无需登录)。
|
||||
|
||||
给外部系统一个最简单的 HTTP 入口来「调用本项目的智能体做问答」:
|
||||
|
||||
- 可指定 **模型**(``model_name``,对应 ``config.yaml`` 中 ``models[]`` 的 name)。
|
||||
- 可指定 **智能体**(``agent_id``,对应 ``.deer-flow/agents/<id>/``;留空 = 默认 lead agent)。
|
||||
- 可关闭 **大模型思考模式**(``thinking_enabled=false``,默认即关闭)。内网 vLLM/Qwen
|
||||
模型仅靠 ``thinking_enabled=False`` 往往关不掉思考(其 config 只声明
|
||||
``supports_thinking: true``),本接口在关闭思考时会把
|
||||
``thinking_force_disabled`` 一并下发,经
|
||||
``create_chat_model(force_disable_thinking=True)`` 强制注入
|
||||
``extra_body.chat_template_kwargs.enable_thinking=false``(及 GLM 的
|
||||
``thinking.type=disabled``)——与「生成标题」时关闭思考的做法一致。
|
||||
|
||||
该路由前缀 ``/api/open/`` 已在 ``auth_middleware`` 白名单里,**绕过登录鉴权**,
|
||||
以 ``"default"`` 用户桶运行,只暴露受控的问答能力(无文件/线程持久化)。
|
||||
|
||||
请求示例::
|
||||
|
||||
POST /api/open/chat
|
||||
{
|
||||
"message": "用一句话介绍一下你自己",
|
||||
"model_name": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"agent_id": null,
|
||||
"thinking_enabled": false
|
||||
}
|
||||
|
||||
返回::
|
||||
|
||||
{"answer": "...", "model_name": "...", "agent_id": null,
|
||||
"thinking_enabled": false, "thread_id": "open-...."}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||
from deerflow.config.agents_config import list_custom_agents, validate_agent_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/open", tags=["open"])
|
||||
|
||||
_THINK_TAG_RE = re.compile(r"<think>[\s\S]*?</think>", re.IGNORECASE)
|
||||
|
||||
|
||||
class OpenChatMessage(BaseModel):
|
||||
"""多轮历史中的一条消息。"""
|
||||
|
||||
role: Literal["user", "assistant", "system"] = "user"
|
||||
content: str
|
||||
|
||||
|
||||
class OpenChatRequest(BaseModel):
|
||||
"""开放问答请求体。
|
||||
|
||||
``message`` 与 ``messages`` 二选一:单轮直接传 ``message``;需要带上下文时传
|
||||
``messages``(完整历史,本接口本身无状态、不落库)。
|
||||
"""
|
||||
|
||||
message: str | None = Field(default=None, description="单轮问题文本;与 messages 二选一")
|
||||
messages: list[OpenChatMessage] | None = Field(default=None, description="多轮历史;与 message 二选一")
|
||||
model_name: str | None = Field(default=None, description="models[] 中的模型名;留空用默认模型")
|
||||
agent_name: str | None = Field(default=None, description="智能体名称(中文显示名);留空用默认 lead agent")
|
||||
agent_id: str | None = Field(default=None, description="智能体 id;一般不用,优先级高于 agent_name")
|
||||
thinking_enabled: bool = Field(default=False, description="是否开启思考;默认 False = 关闭思考(内网模型也会被强制关闭)")
|
||||
strip_think: bool = Field(default=True, description="关闭思考时,从答案里剥离残留的 <think>…</think> 块")
|
||||
thread_id: str | None = Field(default=None, description="可选;仅用于文件隔离,留空自动生成")
|
||||
recursion_limit: int = Field(default=100, ge=1, le=300, description="智能体单次问答的最大步数")
|
||||
include_files: bool = Field(default=True, description="是否随结果回传智能体在 outputs 目录生成的文件(名称+内容)")
|
||||
|
||||
|
||||
class OpenChatFile(BaseModel):
|
||||
"""智能体本次运行在 /mnt/user-data/outputs 下生成的一个文件。"""
|
||||
|
||||
name: str # outputs/ 下的相对路径,如 "报告.md"
|
||||
virtual_path: str # 沙箱虚拟路径,如 "/mnt/user-data/outputs/报告.md"
|
||||
encoding: Literal["text", "base64"] = "text" # text=可直接用的文本;base64=二进制需解码
|
||||
content: str
|
||||
|
||||
|
||||
class OpenChatResponse(BaseModel):
|
||||
answer: str
|
||||
model_name: str | None
|
||||
agent_name: str | None
|
||||
agent_id: str | None
|
||||
thinking_enabled: bool
|
||||
thread_id: str
|
||||
files: list[OpenChatFile] = Field(default_factory=list, description="本次生成的输出文件(含内容)")
|
||||
|
||||
|
||||
def _resolve_agent_id(agent_id: str | None, agent_name: str | None) -> str | None:
|
||||
"""把 agent_id / 中文名称 解析成运行 id。
|
||||
|
||||
优先级:显式 agent_id > agent_name(按 .deer-flow/agents/*/config.yaml 的 name 匹配)。
|
||||
都没传 = None(默认 lead agent)。名称匹配先精确、再忽略大小写/首尾空格。
|
||||
"""
|
||||
if agent_id and agent_id.strip():
|
||||
try:
|
||||
return validate_agent_id(agent_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"非法的 agent_id:{exc}") from exc
|
||||
|
||||
if not agent_name or not agent_name.strip():
|
||||
return None
|
||||
|
||||
target = agent_name.strip()
|
||||
agents = list_custom_agents()
|
||||
for a in agents:
|
||||
if (a.name or "").strip() == target:
|
||||
return a.id
|
||||
lowered = target.lower()
|
||||
for a in agents:
|
||||
if (a.name or "").strip().lower() == lowered:
|
||||
return a.id
|
||||
raise HTTPException(status_code=404, detail=f"未找到名称为「{agent_name}」的智能体。")
|
||||
|
||||
|
||||
def _build_lc_messages(body: OpenChatRequest) -> list[BaseMessage]:
|
||||
"""把请求体转成 LangChain 消息列表。"""
|
||||
if body.messages:
|
||||
out: list[BaseMessage] = []
|
||||
for m in body.messages:
|
||||
text = (m.content or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if m.role == "assistant":
|
||||
out.append(AIMessage(content=m.content))
|
||||
elif m.role == "system":
|
||||
out.append(SystemMessage(content=m.content))
|
||||
else:
|
||||
out.append(HumanMessage(content=m.content))
|
||||
if out:
|
||||
return out
|
||||
if body.message and body.message.strip():
|
||||
return [HumanMessage(content=body.message)]
|
||||
raise HTTPException(status_code=422, detail="必须提供非空的 'message' 或 'messages'。")
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
"""从 AIMessage.content(str 或 block 列表)抽取纯文本。"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
text_value = block.get("text")
|
||||
if isinstance(text_value, str):
|
||||
parts.append(text_value)
|
||||
return "".join(parts) if parts else ""
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_answer(result: Any, *, strip_think: bool) -> str:
|
||||
"""从 ainvoke 的最终状态里取最后一条 AI 文本。"""
|
||||
messages = (result or {}).get("messages") if isinstance(result, dict) else None
|
||||
answer = ""
|
||||
if isinstance(messages, list):
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, AIMessage):
|
||||
text = _extract_text(msg.content).strip()
|
||||
if text:
|
||||
answer = text
|
||||
break
|
||||
if strip_think and answer:
|
||||
answer = _THINK_TAG_RE.sub("", answer).strip()
|
||||
return answer
|
||||
|
||||
|
||||
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs"
|
||||
_MAX_OUTPUT_FILES = 20
|
||||
_MAX_OUTPUT_FILE_BYTES = 5 * 1024 * 1024 # 单文件 5MB 上限,避免把大二进制塞进 JSON 响应
|
||||
|
||||
|
||||
def _collect_output_files(state: Any, *, include: bool) -> list[OpenChatFile]:
|
||||
"""读取本次运行在沙箱 outputs 目录生成的文件,随结果一并回传。
|
||||
|
||||
开放问答是无状态的(不持久化线程),客户端没法用 artifacts 路由按线程取件;但本端点
|
||||
就跑在服务端进程里,沙箱把生成的文件落在 ``state['thread_data']['outputs_path']``,
|
||||
所以这里直接扫描该目录,把文件内容带回(文本按 utf-8,二进制按 base64)。
|
||||
受 ``_MAX_OUTPUT_FILES`` / ``_MAX_OUTPUT_FILE_BYTES`` 限制。
|
||||
"""
|
||||
if not include or not isinstance(state, dict):
|
||||
return []
|
||||
thread_data = state.get("thread_data") or {}
|
||||
outputs_path = thread_data.get("outputs_path")
|
||||
if not outputs_path:
|
||||
return []
|
||||
base = Path(outputs_path)
|
||||
if not base.is_dir():
|
||||
return []
|
||||
try:
|
||||
entries = sorted((p for p in base.rglob("*") if p.is_file()), key=lambda p: p.as_posix())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
files: list[OpenChatFile] = []
|
||||
for entry in entries:
|
||||
if len(files) >= _MAX_OUTPUT_FILES:
|
||||
break
|
||||
try:
|
||||
if entry.stat().st_size > _MAX_OUTPUT_FILE_BYTES:
|
||||
continue
|
||||
data = entry.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
rel = entry.relative_to(base).as_posix()
|
||||
try:
|
||||
content = data.decode("utf-8")
|
||||
encoding: Literal["text", "base64"] = "text"
|
||||
except UnicodeDecodeError:
|
||||
content = base64.b64encode(data).decode("ascii")
|
||||
encoding = "base64"
|
||||
files.append(OpenChatFile(name=rel, virtual_path=f"{_OUTPUTS_VIRTUAL_PREFIX}/{rel}", encoding=encoding, content=content))
|
||||
return files
|
||||
|
||||
|
||||
def _cleanup_ephemeral_thread(state: Any, thread_id: str | None) -> None:
|
||||
"""删除本次开放问答的临时线程目录(open-*),落实"聊天记录不存储"。
|
||||
|
||||
生成文件已在 _collect_output_files 读进内存(随响应回传),这里把磁盘上整个临时线程
|
||||
目录清掉,避免无状态调用在服务端堆积残留。仅对 ``open-*`` 线程生效,best-effort。
|
||||
"""
|
||||
if not thread_id or not str(thread_id).startswith("open-"):
|
||||
return
|
||||
thread_data = state.get("thread_data") if isinstance(state, dict) else None
|
||||
outputs_path = (thread_data or {}).get("outputs_path")
|
||||
if not outputs_path:
|
||||
return
|
||||
try:
|
||||
p = Path(outputs_path).resolve()
|
||||
# outputs_path 形如 .../threads/<thread_id>/user-data/outputs;只删到 <thread_id> 这层
|
||||
thread_root = next((parent for parent in p.parents if parent.name == thread_id), None)
|
||||
if thread_root is not None and thread_root.exists():
|
||||
shutil.rmtree(thread_root, ignore_errors=True)
|
||||
except Exception as exc: # noqa: BLE001 — 清理失败不影响返回结果
|
||||
logger.warning("清理临时线程目录失败 (%s): %s", thread_id, exc)
|
||||
|
||||
|
||||
# 流式响应每隔这么久没有真实事件就发一个 SSE 注释心跳,持续制造下行字节,
|
||||
# 让前置反向代理(nginx proxy_read_timeout 默认 60s 等)不会把长任务判定成空闲超时。
|
||||
_HEARTBEAT_SECONDS = 15.0
|
||||
|
||||
|
||||
def _build_run_context(body: OpenChatRequest) -> tuple[dict[str, Any], str | None, str]:
|
||||
"""把请求体翻译成 (config, agent_id, thread_id)。两个端点共用,确保关思考等行为一致。"""
|
||||
agent_id = _resolve_agent_id(body.agent_id, body.agent_name)
|
||||
thread_id = body.thread_id or f"open-{uuid.uuid4()}"
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": thread_id,
|
||||
"model_name": body.model_name,
|
||||
"thinking_enabled": body.thinking_enabled,
|
||||
# 关闭思考时强制下发,确保内网模型也真的关掉(见模块 docstring)。
|
||||
"thinking_force_disabled": not body.thinking_enabled,
|
||||
# 一次性问答:跳过自动起标题(省一次 LLM 调用)、不挂定时任务工具。
|
||||
"is_scheduled_run": True,
|
||||
# 临时/无状态运行:跳过记忆与知识库的读写(不召回、不留存、不自动沉淀),
|
||||
# 做到"通过本接口的聊天记录不存储"(见 _build_middlewares 的 is_ephemeral)。
|
||||
"ephemeral": True,
|
||||
"memory_injection_enabled": False,
|
||||
}
|
||||
if agent_id:
|
||||
configurable["agent_id"] = agent_id
|
||||
config: dict[str, Any] = {"configurable": configurable, "recursion_limit": body.recursion_limit}
|
||||
return config, agent_id, thread_id
|
||||
|
||||
|
||||
def _sse(event: str | None, data: Any) -> str:
|
||||
"""序列化一条 SSE 消息。event=None 时只发 data 行。"""
|
||||
payload = json.dumps(data, ensure_ascii=False)
|
||||
head = f"event: {event}\n" if event else ""
|
||||
return f"{head}data: {payload}\n\n"
|
||||
|
||||
|
||||
@router.post("/chat", response_model=OpenChatResponse)
|
||||
async def open_chat(body: OpenChatRequest) -> OpenChatResponse:
|
||||
"""指定模型 + 智能体做一次问答(无状态、可关闭思考)。
|
||||
|
||||
同步阻塞版:整段调研跑完才返回。**长任务(联网调研)请改用 /chat/stream**——
|
||||
本端点期间不产生任何下行字节,容易被前置反代的 proxy_read_timeout 掐成 504。
|
||||
"""
|
||||
lc_messages = _build_lc_messages(body)
|
||||
config, agent_id, thread_id = _build_run_context(body)
|
||||
|
||||
try:
|
||||
agent = make_lead_agent(config)
|
||||
result = await agent.ainvoke({"messages": lc_messages}, config=config)
|
||||
except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 502
|
||||
logger.exception("open_chat 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id)
|
||||
raise HTTPException(status_code=502, detail=f"智能体调用失败:{exc}") from exc
|
||||
|
||||
answer = _extract_answer(result, strip_think=body.strip_think and not body.thinking_enabled)
|
||||
files = _collect_output_files(result, include=body.include_files)
|
||||
_cleanup_ephemeral_thread(result, thread_id) # 取完文件即清理临时目录,不在服务端留存
|
||||
if not answer and not files:
|
||||
raise HTTPException(status_code=502, detail="智能体未返回任何文本答案或文件。")
|
||||
|
||||
return OpenChatResponse(
|
||||
answer=answer,
|
||||
model_name=body.model_name,
|
||||
agent_name=body.agent_name,
|
||||
agent_id=agent_id,
|
||||
thinking_enabled=body.thinking_enabled,
|
||||
thread_id=thread_id,
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
async def _open_chat_event_stream(body: OpenChatRequest) -> AsyncIterator[str]:
|
||||
"""驱动智能体并产出 SSE 事件流。
|
||||
|
||||
- 边跑边转发 AI 文本增量(``event: delta``,仅供实时观看,客户端可忽略);
|
||||
- 没有真实事件时每 ``_HEARTBEAT_SECONDS`` 发一个心跳注释行保活;
|
||||
- 全部跑完后用 ``event: result`` 给出最终(已按需剥离 <think> 的)答案;
|
||||
- 出错则 ``event: error``。
|
||||
|
||||
用「后台生产任务 + 队列 + 超时取队列」的方式把「智能体进度」与「定时心跳」解耦,
|
||||
所以即便某次工具调用静默很久,心跳仍照常下行,连接不会被反代判空闲。
|
||||
"""
|
||||
lc_messages = _build_lc_messages(body)
|
||||
config, agent_id, thread_id = _build_run_context(body)
|
||||
strip_think = body.strip_think and not body.thinking_enabled
|
||||
|
||||
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
||||
final_state: dict[str, Any] = {}
|
||||
|
||||
async def produce() -> None:
|
||||
nonlocal final_state
|
||||
try:
|
||||
agent = make_lead_agent(config)
|
||||
async for mode, chunk in agent.astream({"messages": lc_messages}, config=config, stream_mode=["values", "messages"]):
|
||||
if mode == "values" and isinstance(chunk, dict):
|
||||
final_state = chunk
|
||||
await queue.put(("progress", {"messages": len(chunk.get("messages") or [])}))
|
||||
elif mode == "messages":
|
||||
msg = chunk[0] if isinstance(chunk, tuple) and chunk else chunk
|
||||
# 仅转发 AI 正文增量;DeepSeek 等把思考放在 additional_kwargs.reasoning_content,
|
||||
# 不在 content 里,所以这里天然不会泄漏 <think>。
|
||||
if isinstance(msg, AIMessage):
|
||||
text = _extract_text(getattr(msg, "content", "")).strip()
|
||||
if text:
|
||||
await queue.put(("delta", {"text": text}))
|
||||
except Exception as exc: # noqa: BLE001 — 开放接口需把内部错误归一成 error 事件
|
||||
logger.exception("open_chat_stream 调用智能体失败 (model=%s, agent=%s)", body.model_name, agent_id)
|
||||
await queue.put(("error", {"detail": f"智能体调用失败:{exc}"}))
|
||||
finally:
|
||||
await queue.put(("__end__", None))
|
||||
|
||||
task = asyncio.create_task(produce())
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event, data = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n" # SSE 注释行,客户端忽略,仅用于保活
|
||||
continue
|
||||
if event == "__end__":
|
||||
break
|
||||
if event == "error":
|
||||
yield _sse("error", data)
|
||||
return
|
||||
yield _sse(event, data)
|
||||
|
||||
answer = _extract_answer(final_state, strip_think=strip_think)
|
||||
files = _collect_output_files(final_state, include=body.include_files)
|
||||
_cleanup_ephemeral_thread(final_state, thread_id) # 取完文件即清理临时目录,不在服务端留存
|
||||
if not answer and not files:
|
||||
yield _sse("error", {"detail": "智能体未返回任何文本答案或文件。"})
|
||||
return
|
||||
yield _sse(
|
||||
"result",
|
||||
{
|
||||
"answer": answer,
|
||||
"model_name": body.model_name,
|
||||
"agent_name": body.agent_name,
|
||||
"agent_id": agent_id,
|
||||
"thinking_enabled": body.thinking_enabled,
|
||||
"thread_id": thread_id,
|
||||
"files": [f.model_dump() for f in files],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def open_chat_stream(body: OpenChatRequest) -> StreamingResponse:
|
||||
"""流式版 /chat:边跑边发心跳保活,跑完用 ``event: result`` 给最终答案。
|
||||
|
||||
解决两件事:(1) 长任务(联网调研、报告)同步阻塞会被前置反代 proxy_read_timeout
|
||||
掐成 504;流式持续有字节下行,代理不再判定空闲超时。(2) 客户端只需读到
|
||||
``event: result`` 解析 ``answer`` 即可,中间的 ``delta``/``progress``/心跳都可忽略。
|
||||
"""
|
||||
# 入参校验放在流外:非法请求直接抛 4xx,而不是开了流再报错。
|
||||
_build_lc_messages(body)
|
||||
return StreamingResponse(
|
||||
_open_chat_event_stream(body),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
# 关掉 nginx 对该响应的缓冲,确保 SSE/心跳实时下行(即便前置 nginx 默认开缓冲)。
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def open_list_agents() -> dict[str, Any]:
|
||||
"""列出可用智能体的名称(方便知道 agent_name 该填什么)。"""
|
||||
agents = list_custom_agents()
|
||||
items = [{"name": a.name, "id": a.id} for a in agents if (a.name or "").strip()]
|
||||
return {"agents": items, "count": len(items)}
|
||||
@@ -67,24 +67,11 @@ async def list_recommended_questions(
|
||||
) -> RecommendedQuestionsListResponse:
|
||||
store = _get_store(request)
|
||||
rows = await store.list_all()
|
||||
if not all:
|
||||
# Filter by the caller's 岗位 (position) visibility. ``None`` =
|
||||
# unrestricted (no skill/question tag binding → show all default
|
||||
# questions); a set = show only the questions carrying the bound tags.
|
||||
sync = getattr(request.app.state, "position_sync", None)
|
||||
if sync is not None:
|
||||
try:
|
||||
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
|
||||
except Exception:
|
||||
logger.debug("recommended-question visibility resolve failed", exc_info=True)
|
||||
visible = None
|
||||
if visible is not None:
|
||||
rows = [r for r in rows if r["id"] in visible]
|
||||
responses = [RecommendedQuestionResponse(**r) for r in rows]
|
||||
|
||||
# Bulk-attach the tags assigned to each question so the management list can
|
||||
# display them and filter by 标签. Best-effort: a missing/failing tag store
|
||||
# never breaks the question list.
|
||||
# display them and the 岗位 filter below can tell tagged from untagged.
|
||||
# Best-effort: a missing/failing tag store never breaks the question list.
|
||||
tag_store = getattr(request.app.state, "tag_store", None)
|
||||
if tag_store is not None and responses:
|
||||
try:
|
||||
@@ -95,6 +82,24 @@ async def list_recommended_questions(
|
||||
for resp in responses:
|
||||
resp.tags = assignments.get(resp.id, [])
|
||||
|
||||
if not all:
|
||||
# Filter by the caller's 岗位 (position) visibility:
|
||||
# * ``set`` — the position binds recommended_question 标签 → show only
|
||||
# the questions carrying those bound tags.
|
||||
# * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions.
|
||||
# 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。
|
||||
sync = getattr(request.app.state, "position_sync", None)
|
||||
if sync is not None:
|
||||
try:
|
||||
visible = await sync.resolve_visible(_current_user_id(request), "recommended_question")
|
||||
except Exception:
|
||||
logger.debug("recommended-question visibility resolve failed", exc_info=True)
|
||||
visible = None
|
||||
if visible is not None:
|
||||
responses = [r for r in responses if r.id in visible]
|
||||
else:
|
||||
responses = [r for r in responses if not r.tags]
|
||||
|
||||
if tag_ids:
|
||||
wanted = set(tag_ids)
|
||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||
|
||||
@@ -47,6 +47,8 @@ class DraftMetaResponse(BaseModel):
|
||||
task_id: str | None = None
|
||||
title: str = ""
|
||||
furthest_step: int = 1
|
||||
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
|
||||
chain_title: str | None = None
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
# 最近一次后台作业的状态(Phase 6 历史下拉「进行中」loading 用);无作业时为 None。
|
||||
|
||||
@@ -42,6 +42,8 @@ class TaskDraftMetaResponse(BaseModel):
|
||||
task_id: str
|
||||
title: str = ""
|
||||
furthest_step: int = 1
|
||||
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
|
||||
chain_title: str | None = None
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
# Latest background-job status for this draft (history-dropdown loading badge).
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"""技能地址(URL/IP)批量替换 API (技能地址管理).
|
||||
|
||||
Scans the **running** skills directory (``skills/{public,custom}``) — the files
|
||||
the agent actually reads — for ``http(s)://`` URLs and IPv4 (``ip[:port]``)
|
||||
addresses, aggregates each unique address with every place it appears (across
|
||||
``.md`` and ``.py``, across files), and lets an admin rewrite them in bulk.
|
||||
|
||||
Why this shape: the same address routinely lives in both a skill's ``SKILL.md``
|
||||
(documented endpoint) and its ``scripts/*.py`` (the actual call), so the natural
|
||||
unit is *"one address → replace all of its occurrences at once"*. The pure
|
||||
scan/replace logic lives in ``deerflow.skills.address_scan`` (harness layer,
|
||||
substring-safe span replacement); this router only walks the tree, enforces
|
||||
admin auth + path safety, backs up before writing, and refreshes the skills
|
||||
system-prompt cache so edits take effect immediately.
|
||||
|
||||
**Scale / availability (150+ skills):** every filesystem walk / read / write is
|
||||
offloaded to a worker thread via ``asyncio.to_thread`` so the event loop is
|
||||
never blocked while scanning or rewriting — the Gateway stays responsive. The
|
||||
streaming endpoints additionally emit NDJSON progress (``已处理 X / 共 N``) so the
|
||||
UI can render a progress bar for both the scan and the apply phases.
|
||||
|
||||
Routes (prefix ``/api/skill-addresses``, **admin only**):
|
||||
GET /scan?kinds=url,ip&skill=<name> read-only aggregated scan (one shot)
|
||||
GET /scan/stream?kinds=...&skill=... scan with per-skill NDJSON progress
|
||||
POST /preview dry-run diff of a replacement set
|
||||
POST /apply write changes (one shot, with backup)
|
||||
POST /apply/stream write changes with per-file progress
|
||||
GET /backups list restorable backups (newest first)
|
||||
POST /rollback/{backup_id} restore a backup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.skills.address_scan import (
|
||||
VALID_KINDS,
|
||||
apply_replacements,
|
||||
infer_kind,
|
||||
is_valid_replacement,
|
||||
normalize_kinds,
|
||||
scan_occurrences,
|
||||
)
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/skill-addresses", tags=["skill-addresses"])
|
||||
|
||||
_BACKUP_DIRNAME = ".address-edits"
|
||||
_SCAN_SUBDIRS = ("public", "custom")
|
||||
_SCAN_EXTS = (".md", ".py")
|
||||
|
||||
|
||||
# ── schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class OccurrenceModel(BaseModel):
|
||||
skill: str
|
||||
category: str # "public" | "custom"
|
||||
file: str # POSIX path relative to the skills root
|
||||
line: int
|
||||
col: int
|
||||
snippet: str
|
||||
|
||||
|
||||
class AddressGroup(BaseModel):
|
||||
address: str
|
||||
kind: str # "url" | "ip"
|
||||
count: int
|
||||
occurrences: list[OccurrenceModel]
|
||||
|
||||
|
||||
class ScanResponse(BaseModel):
|
||||
groups: list[AddressGroup]
|
||||
scanned_files: int
|
||||
scanned_skills: int
|
||||
kinds: list[str]
|
||||
scanned_at: str
|
||||
|
||||
|
||||
class Replacement(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
from_: str = Field(alias="from", min_length=1)
|
||||
to: str = Field(min_length=1)
|
||||
kind: str | None = None
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
replacements: list[Replacement] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PreviewFileHit(BaseModel):
|
||||
file: str
|
||||
line: int
|
||||
before: str
|
||||
after: str
|
||||
|
||||
|
||||
class PreviewItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
from_: str = Field(serialization_alias="from")
|
||||
to: str
|
||||
kind: str
|
||||
count: int
|
||||
files: list[PreviewFileHit]
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
items: list[PreviewItem]
|
||||
total: int
|
||||
warnings: list[str]
|
||||
expected_total: int
|
||||
|
||||
|
||||
class ApplyRequest(BaseModel):
|
||||
replacements: list[Replacement] = Field(default_factory=list)
|
||||
expected_total: int | None = None
|
||||
backup: bool = True
|
||||
|
||||
|
||||
class ChangedFile(BaseModel):
|
||||
file: str
|
||||
count: int
|
||||
|
||||
|
||||
class ApplyResponse(BaseModel):
|
||||
changed_files: list[ChangedFile]
|
||||
replaced_total: int
|
||||
backup_id: str | None
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class ReplacementSummary(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
from_: str = Field(serialization_alias="from")
|
||||
to: str
|
||||
kind: str
|
||||
|
||||
|
||||
class BackupEntry(BaseModel):
|
||||
backup_id: str
|
||||
files: int
|
||||
created_at: str
|
||||
replaced_total: int = 0
|
||||
replacements: list[ReplacementSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
backups: list[BackupEntry]
|
||||
|
||||
|
||||
class RollbackResponse(BaseModel):
|
||||
backup_id: str
|
||||
restored_files: list[str]
|
||||
|
||||
|
||||
# ── auth / path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
"""Admin-only. Lets the call through when auth is disabled (no user)."""
|
||||
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="技能地址管理仅限管理员")
|
||||
|
||||
|
||||
def _skills_root() -> Path:
|
||||
return Path(get_or_new_skill_storage().get_skills_root_path()).resolve()
|
||||
|
||||
|
||||
def _is_within(root: Path, p: Path) -> bool:
|
||||
try:
|
||||
p.resolve().relative_to(root)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _skill_of(root: Path, path: Path) -> tuple[str, str, str]:
|
||||
"""Return ``(category, skill_name, posix_rel_path)`` for a scanned file."""
|
||||
|
||||
rel = path.relative_to(root)
|
||||
parts = rel.parts
|
||||
category = parts[0] if parts else ""
|
||||
skill_name = parts[1] if len(parts) > 1 else ""
|
||||
return category, skill_name, rel.as_posix()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
"""Read UTF-8 text preserving original line endings; skip binary/bad files."""
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", newline="") as fh:
|
||||
return fh.read()
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_text(path: Path, text: str) -> None:
|
||||
with open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
# ── synchronous workers (run via asyncio.to_thread; never block the loop) ─────
|
||||
|
||||
|
||||
def _iter_skill_files(root: Path, skill: str | None = None) -> list[Path]:
|
||||
"""All ``*.md`` / ``*.py`` under ``{root}/{public,custom}``.
|
||||
|
||||
Hidden directories (``.address-edits`` backups, ``.history``) are skipped.
|
||||
When *skill* is given, only that skill's directory is walked.
|
||||
"""
|
||||
|
||||
files: list[Path] = []
|
||||
for sub in _SCAN_SUBDIRS:
|
||||
base = root / sub
|
||||
if skill:
|
||||
base = base / skill
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for p in base.rglob("*"):
|
||||
if not p.is_file() or p.suffix.lower() not in _SCAN_EXTS:
|
||||
continue
|
||||
try:
|
||||
rel_parts = p.relative_to(root).parts
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in rel_parts):
|
||||
continue
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
|
||||
def _group_files_by_skill(root: Path, files: list[Path]) -> dict[tuple[str, str], list[Path]]:
|
||||
"""Bucket scanned files under their owning ``(category, skill_name)``."""
|
||||
|
||||
buckets: dict[tuple[str, str], list[Path]] = {}
|
||||
for p in files:
|
||||
category, skill_name, _ = _skill_of(root, p)
|
||||
buckets.setdefault((category, skill_name), []).append(p)
|
||||
return buckets
|
||||
|
||||
|
||||
def _occurrence_kind(value: str) -> str:
|
||||
return infer_kind(value) or "url"
|
||||
|
||||
|
||||
def _merge_occurrences(groups: dict[str, AddressGroup], pairs: list[tuple[str, OccurrenceModel]]) -> None:
|
||||
"""Merge ``(address, occurrence)`` pairs into the aggregate group map."""
|
||||
|
||||
for address, occ in pairs:
|
||||
g = groups.get(address)
|
||||
if g is None:
|
||||
g = AddressGroup(address=address, kind=_occurrence_kind(address), count=0, occurrences=[])
|
||||
groups[address] = g
|
||||
g.count += 1
|
||||
g.occurrences.append(occ)
|
||||
|
||||
|
||||
def _plan_apply(root: Path, mapping: dict[str, str]) -> tuple[list[tuple[Path, str, int, str]], int]:
|
||||
"""Compute the rewrite plan without touching disk → (plan, current_total)."""
|
||||
|
||||
plan: list[tuple[Path, str, int, str]] = []
|
||||
current_total = 0
|
||||
for path in _iter_skill_files(root):
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
new_text, cnt = apply_replacements(text, mapping, VALID_KINDS)
|
||||
if cnt <= 0:
|
||||
continue
|
||||
if not _is_within(root, path):
|
||||
raise HTTPException(status_code=400, detail=f"拒绝写入技能目录之外的文件:{path}")
|
||||
_, _, rel = _skill_of(root, path)
|
||||
plan.append((path, new_text, cnt, rel))
|
||||
current_total += cnt
|
||||
return plan, current_total
|
||||
|
||||
|
||||
def _backup_file(root: Path, backup_id: str, path: Path, rel: str) -> None:
|
||||
dest = root / _BACKUP_DIRNAME / backup_id / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dest)
|
||||
|
||||
|
||||
_MANIFEST_SUFFIX = ".manifest.json"
|
||||
|
||||
|
||||
def _manifest_path(root: Path, backup_id: str) -> Path:
|
||||
"""Manifest lives as a *sibling* of the backup dir (``{id}.manifest.json``),
|
||||
not inside it — so it is neither restored back into the skills tree on
|
||||
rollback nor counted as a backed-up file."""
|
||||
|
||||
return root / _BACKUP_DIRNAME / f"{backup_id}{_MANIFEST_SUFFIX}"
|
||||
|
||||
|
||||
def _write_manifest(
|
||||
root: Path,
|
||||
backup_id: str,
|
||||
norm: list[tuple[str, str, str]],
|
||||
changed: list["ChangedFile"],
|
||||
replaced_total: int,
|
||||
created_at: str,
|
||||
) -> None:
|
||||
"""Record what this edit changed so the UI can show a meaningful, per-edit
|
||||
history (原地址 → 新地址, 文件数, 处数) and roll a single record back."""
|
||||
|
||||
data = {
|
||||
"created_at": created_at,
|
||||
"replaced_total": replaced_total,
|
||||
"replacements": [{"from": frm, "to": to, "kind": kind} for frm, to, kind in norm],
|
||||
"changed_files": [{"file": c.file, "count": c.count} for c in changed],
|
||||
}
|
||||
try: # best-effort: a metadata write failure must not fail the apply itself
|
||||
path = _manifest_path(root, backup_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False)
|
||||
except OSError:
|
||||
logger.warning("Failed to write skill-address backup manifest for %s", backup_id, exc_info=True)
|
||||
|
||||
|
||||
def _read_manifest(root: Path, backup_id: str) -> dict | None:
|
||||
try:
|
||||
with open(_manifest_path(root, backup_id), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── scan extraction shared by one-shot + stream ───────────────────────────────
|
||||
|
||||
|
||||
def _scan_files_with_addresses(root: Path, files: list[Path], wanted: tuple[str, ...]) -> tuple[list[tuple[str, OccurrenceModel]], int]:
|
||||
"""Scan files → ([(address, occurrence)], files_read)."""
|
||||
|
||||
pairs: list[tuple[str, OccurrenceModel]] = []
|
||||
read = 0
|
||||
for path in files:
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
read += 1
|
||||
category, skill_name, rel = _skill_of(root, path)
|
||||
for o in scan_occurrences(text, wanted):
|
||||
pairs.append(
|
||||
(
|
||||
o.value,
|
||||
OccurrenceModel(skill=skill_name, category=category, file=rel, line=o.line, col=o.col, snippet=o.snippet),
|
||||
)
|
||||
)
|
||||
return pairs, read
|
||||
|
||||
|
||||
def _ordered_groups(groups: dict[str, AddressGroup]) -> list[AddressGroup]:
|
||||
return sorted(groups.values(), key=lambda g: (-g.count, g.address))
|
||||
|
||||
|
||||
def _build_mapping(replacements: list[Replacement]) -> tuple[dict[str, str], list[tuple[str, str, str]], list[str]]:
|
||||
"""Validate replacements → ``(mapping, [(from,to,kind)], warnings)``.
|
||||
|
||||
Drops (with a warning) any entry that is empty, duplicated, not a recognized
|
||||
URL/IP, a no-op (``from == to``), or whose ``to`` is malformed for its kind.
|
||||
"""
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
norm: list[tuple[str, str, str]] = []
|
||||
warnings: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for r in replacements:
|
||||
frm = r.from_.strip()
|
||||
to = r.to.strip()
|
||||
if not frm:
|
||||
continue
|
||||
if frm in seen:
|
||||
warnings.append(f"重复的原始地址已忽略:{frm}")
|
||||
continue
|
||||
kind = (r.kind or "").strip().lower() or infer_kind(frm)
|
||||
if kind not in VALID_KINDS:
|
||||
warnings.append(f"无法识别为 URL/IP,已忽略:{frm}")
|
||||
continue
|
||||
if to == frm:
|
||||
warnings.append(f"替换值与原值相同,已忽略:{frm}")
|
||||
continue
|
||||
if not is_valid_replacement(kind, to):
|
||||
warnings.append(f"替换值不是合法的 {kind},已忽略:{to}")
|
||||
continue
|
||||
seen.add(frm)
|
||||
mapping[frm] = to
|
||||
norm.append((frm, to, kind))
|
||||
return mapping, norm, warnings
|
||||
|
||||
|
||||
def _ndjson(obj: dict) -> bytes:
|
||||
return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
# ── routes: scan ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/scan", response_model=ScanResponse)
|
||||
async def scan_addresses(
|
||||
request: Request,
|
||||
kinds: str | None = Query(default=None, description="逗号分隔:url,ip(默认全部)"),
|
||||
skill: str | None = Query(default=None, description="仅扫描指定技能目录"),
|
||||
) -> ScanResponse:
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
wanted = normalize_kinds(kinds)
|
||||
files = await asyncio.to_thread(_iter_skill_files, root, skill)
|
||||
buckets = _group_files_by_skill(root, files)
|
||||
groups: dict[str, AddressGroup] = {}
|
||||
scanned = 0
|
||||
for paths in buckets.values():
|
||||
pairs, read = await asyncio.to_thread(_scan_files_with_addresses, root, paths, wanted)
|
||||
_merge_occurrences(groups, pairs)
|
||||
scanned += read
|
||||
return ScanResponse(
|
||||
groups=_ordered_groups(groups),
|
||||
scanned_files=scanned,
|
||||
scanned_skills=len(buckets),
|
||||
kinds=list(wanted),
|
||||
scanned_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scan/stream")
|
||||
async def scan_addresses_stream(
|
||||
request: Request,
|
||||
kinds: str | None = Query(default=None),
|
||||
skill: str | None = Query(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Scan with per-skill NDJSON progress.
|
||||
|
||||
Emits ``{"type":"start","total":N}`` then one
|
||||
``{"type":"progress","done":i,"total":N,"skill":name}`` per skill, finally
|
||||
``{"type":"result", ...ScanResponse...}``.
|
||||
"""
|
||||
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
wanted = normalize_kinds(kinds)
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
files = await asyncio.to_thread(_iter_skill_files, root, skill)
|
||||
buckets = _group_files_by_skill(root, files)
|
||||
total = len(buckets)
|
||||
yield _ndjson({"type": "start", "total": total})
|
||||
groups: dict[str, AddressGroup] = {}
|
||||
scanned = 0
|
||||
done = 0
|
||||
for (category, skill_name), paths in buckets.items():
|
||||
pairs, read = await asyncio.to_thread(_scan_files_with_addresses, root, paths, wanted)
|
||||
_merge_occurrences(groups, pairs)
|
||||
scanned += read
|
||||
done += 1
|
||||
yield _ndjson({"type": "progress", "done": done, "total": total, "skill": skill_name or category})
|
||||
result = ScanResponse(
|
||||
groups=_ordered_groups(groups),
|
||||
scanned_files=scanned,
|
||||
scanned_skills=total,
|
||||
kinds=list(wanted),
|
||||
scanned_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
yield _ndjson({"type": "result", **result.model_dump()})
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
# ── routes: preview ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _compute_preview(root: Path, mapping: dict[str, str], norm: list[tuple[str, str, str]]) -> tuple[list[PreviewItem], int]:
|
||||
counts: dict[str, int] = {frm: 0 for frm, _, _ in norm}
|
||||
files: dict[str, list[PreviewFileHit]] = {frm: [] for frm, _, _ in norm}
|
||||
total = 0
|
||||
if mapping:
|
||||
for path in _iter_skill_files(root):
|
||||
text = _read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
_, _, rel = _skill_of(root, path)
|
||||
for o in scan_occurrences(text, VALID_KINDS):
|
||||
if o.value not in mapping:
|
||||
continue
|
||||
to = mapping[o.value]
|
||||
counts[o.value] += 1
|
||||
total += 1
|
||||
files[o.value].append(PreviewFileHit(file=rel, line=o.line, before=o.snippet, after=o.snippet.replace(o.value, to)))
|
||||
items = [PreviewItem(from_=frm, to=to, kind=kind, count=counts[frm], files=files[frm]) for frm, to, kind in norm]
|
||||
return items, total
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewResponse)
|
||||
async def preview_replacements(request: Request, body: PreviewRequest) -> PreviewResponse:
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
mapping, norm, warnings = _build_mapping(body.replacements)
|
||||
items, total = await asyncio.to_thread(_compute_preview, root, mapping, norm)
|
||||
return PreviewResponse(items=items, total=total, warnings=warnings, expected_total=total)
|
||||
|
||||
|
||||
# ── routes: apply ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _new_backup_id() -> str:
|
||||
return datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
async def apply_replacements_route(request: Request, body: ApplyRequest) -> ApplyResponse:
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
mapping, norm, warnings = _build_mapping(body.replacements)
|
||||
if not mapping:
|
||||
return ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
|
||||
|
||||
plan, current_total = await asyncio.to_thread(_plan_apply, root, mapping)
|
||||
if body.expected_total is not None and body.expected_total != current_total:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"文件自上次扫描后已变化(预期 {body.expected_total} 处,现为 {current_total} 处),请重新扫描后再应用。",
|
||||
)
|
||||
if not plan:
|
||||
return ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
|
||||
|
||||
backup_id: str | None = None
|
||||
if body.backup:
|
||||
backup_id = _new_backup_id()
|
||||
for path, _new, _cnt, rel in plan:
|
||||
await asyncio.to_thread(_backup_file, root, backup_id, path, rel)
|
||||
|
||||
changed: list[ChangedFile] = []
|
||||
replaced_total = 0
|
||||
for path, new_text, cnt, rel in plan:
|
||||
await asyncio.to_thread(_write_text, path, new_text)
|
||||
changed.append(ChangedFile(file=rel, count=cnt))
|
||||
replaced_total += cnt
|
||||
|
||||
if backup_id:
|
||||
await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat())
|
||||
|
||||
await _refresh_cache_safe()
|
||||
logger.info("Skill address apply: %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id)
|
||||
return ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings)
|
||||
|
||||
|
||||
@router.post("/apply/stream")
|
||||
async def apply_replacements_stream(request: Request, body: ApplyRequest) -> StreamingResponse:
|
||||
"""Apply with per-file NDJSON progress.
|
||||
|
||||
Validation + the optimistic-lock check happen *before* streaming (so a 409
|
||||
is a normal HTTP error). Then: ``{"type":"start","total_files":M,...}`` →
|
||||
one ``{"type":"progress","done":k,...}`` per written file →
|
||||
``{"type":"result", ...ApplyResponse...}``.
|
||||
"""
|
||||
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
mapping, norm, warnings = _build_mapping(body.replacements)
|
||||
|
||||
plan: list[tuple[Path, str, int, str]] = []
|
||||
current_total = 0
|
||||
if mapping:
|
||||
plan, current_total = await asyncio.to_thread(_plan_apply, root, mapping)
|
||||
if body.expected_total is not None and body.expected_total != current_total:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"文件自上次扫描后已变化(预期 {body.expected_total} 处,现为 {current_total} 处),请重新扫描后再应用。",
|
||||
)
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
if not plan:
|
||||
result = ApplyResponse(changed_files=[], replaced_total=0, backup_id=None, warnings=warnings)
|
||||
yield _ndjson({"type": "result", **result.model_dump()})
|
||||
return
|
||||
|
||||
backup_id: str | None = None
|
||||
if body.backup:
|
||||
backup_id = _new_backup_id()
|
||||
|
||||
yield _ndjson({"type": "start", "total_files": len(plan), "total_replacements": current_total})
|
||||
|
||||
changed: list[ChangedFile] = []
|
||||
replaced_total = 0
|
||||
done = 0
|
||||
for path, new_text, cnt, rel in plan:
|
||||
if backup_id:
|
||||
await asyncio.to_thread(_backup_file, root, backup_id, path, rel)
|
||||
await asyncio.to_thread(_write_text, path, new_text)
|
||||
changed.append(ChangedFile(file=rel, count=cnt))
|
||||
replaced_total += cnt
|
||||
done += 1
|
||||
yield _ndjson({"type": "progress", "done": done, "total_files": len(plan), "file": rel, "count": cnt})
|
||||
|
||||
if backup_id:
|
||||
await asyncio.to_thread(_write_manifest, root, backup_id, norm, changed, replaced_total, datetime.now(UTC).isoformat())
|
||||
|
||||
await _refresh_cache_safe()
|
||||
logger.info("Skill address apply(stream): %d files, %d replacements, backup=%s", len(changed), replaced_total, backup_id)
|
||||
result = ApplyResponse(changed_files=changed, replaced_total=replaced_total, backup_id=backup_id, warnings=warnings)
|
||||
yield _ndjson({"type": "result", **result.model_dump()})
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
# ── routes: backups / rollback ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _list_backups(root: Path) -> list[BackupEntry]:
|
||||
backup_root = root / _BACKUP_DIRNAME
|
||||
entries: list[BackupEntry] = []
|
||||
if backup_root.is_dir():
|
||||
for d in backup_root.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
file_count = sum(1 for p in d.rglob("*") if p.is_file())
|
||||
manifest = _read_manifest(root, d.name)
|
||||
created = ""
|
||||
replaced_total = 0
|
||||
replacements: list[ReplacementSummary] = []
|
||||
if manifest:
|
||||
created = str(manifest.get("created_at") or "")
|
||||
replaced_total = int(manifest.get("replaced_total") or 0)
|
||||
replacements = [
|
||||
ReplacementSummary(from_=str(r.get("from", "")), to=str(r.get("to", "")), kind=str(r.get("kind", "")))
|
||||
for r in manifest.get("replacements", [])
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
if not created:
|
||||
try:
|
||||
created = datetime.fromtimestamp(d.stat().st_mtime, UTC).isoformat()
|
||||
except OSError:
|
||||
created = ""
|
||||
entries.append(
|
||||
BackupEntry(
|
||||
backup_id=d.name,
|
||||
files=file_count,
|
||||
created_at=created,
|
||||
replaced_total=replaced_total,
|
||||
replacements=replacements,
|
||||
)
|
||||
)
|
||||
entries.sort(key=lambda e: e.backup_id, reverse=True)
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/backups", response_model=BackupListResponse)
|
||||
async def list_backups(request: Request) -> BackupListResponse:
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
entries = await asyncio.to_thread(_list_backups, root)
|
||||
return BackupListResponse(backups=entries)
|
||||
|
||||
|
||||
def _restore_backup(root: Path, backup_root: Path) -> list[str]:
|
||||
restored: list[str] = []
|
||||
for src in backup_root.rglob("*"):
|
||||
if not src.is_file():
|
||||
continue
|
||||
rel = src.relative_to(backup_root)
|
||||
dest = root / rel
|
||||
if not _is_within(root, dest):
|
||||
continue
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
restored.append(rel.as_posix())
|
||||
return restored
|
||||
|
||||
|
||||
@router.post("/rollback/{backup_id}", response_model=RollbackResponse)
|
||||
async def rollback(request: Request, backup_id: str) -> RollbackResponse:
|
||||
await _require_admin(request)
|
||||
root = _skills_root()
|
||||
if "/" in backup_id or "\\" in backup_id or backup_id in (".", ".."):
|
||||
raise HTTPException(status_code=400, detail="非法的备份编号")
|
||||
backup_root = (root / _BACKUP_DIRNAME / backup_id).resolve()
|
||||
if not _is_within(root, backup_root) or not backup_root.is_dir():
|
||||
raise HTTPException(status_code=404, detail="备份不存在")
|
||||
|
||||
restored = await asyncio.to_thread(_restore_backup, root, backup_root)
|
||||
await _refresh_cache_safe()
|
||||
logger.info("Skill address rollback: backup=%s, restored %d files", backup_id, len(restored))
|
||||
return RollbackResponse(backup_id=backup_id, restored_files=restored)
|
||||
|
||||
|
||||
# ── misc ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _refresh_cache_safe() -> None:
|
||||
try:
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
except Exception: # noqa: BLE001 — cache refresh is best-effort
|
||||
logger.warning("Failed to refresh skills prompt cache", exc_info=True)
|
||||
@@ -222,6 +222,10 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"model_name",
|
||||
"mode",
|
||||
"thinking_enabled",
|
||||
# 强制关闭思考(内网 vLLM/Qwen 模型只声明 supports_thinking 时,单靠
|
||||
# thinking_enabled=False 不会注入 enable_thinking=false)。透传到 run config
|
||||
# 后由 lead agent 交给 create_chat_model(force_disable_thinking=...)。
|
||||
"thinking_force_disabled",
|
||||
"reasoning_effort",
|
||||
"rag_mode_enabled",
|
||||
"writing_mode",
|
||||
|
||||
@@ -123,6 +123,12 @@ models:
|
||||
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
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
"""AI 写作模型自动容错(按 ``config.yaml models[]`` 顺序换下一个模型继续)。
|
||||
|
||||
与多智能体会商 ``app/gateway/roundtable_model_fallback.py`` 同思路,但**只放在 harness 层**
|
||||
避免 ``app→harness`` 单向依赖被反向打破:
|
||||
|
||||
- ``fallback_chain(current)``:候选模型链 ``[当前, …其余]``(按 config.yaml 顺序,去重保序);
|
||||
- ``classify_exception(exc)``:把异常归到一个用户可读的失败原因码;
|
||||
- ``reason_text(reason)``:原因码 → 简短中文(用于「『X』模型 <原因>,已切换『Y』继续」);
|
||||
- ``emit_model_switch_event(writer, ...)``:把 ``model_switch`` 推到 LangGraph custom 通道
|
||||
供前端时间线展示;
|
||||
- ``build_switch_progress_event(...)``:节点 return 的 ``progress_events`` 里同样塞一份
|
||||
(走 updates 通道,前端 reducer 据此刷新缓冲并落到时间线)。
|
||||
|
||||
总开关 / 上限沿用会商的 env:
|
||||
- ``ROUNDTABLE_MODEL_FALLBACK``:默认开;``0/false/off/no`` 关闭(链只含当前模型);
|
||||
- ``ROUNDTABLE_MODEL_FALLBACK_MAX``:候选数上限(含当前),0/未设 = 不限。
|
||||
|
||||
放在 ai_writing 节点目录下而非 harness/agents/middlewares:本模块不是中间件,
|
||||
且仅服务 ai_writing 的几个流式节点;如果未来圆桌也想下沉合并,再统一拎出去。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_REASON_TEXT: dict[str, str] = {
|
||||
"quota": "额度不足/账单异常",
|
||||
"auth": "鉴权失败/无访问权限",
|
||||
"busy": "服务繁忙",
|
||||
"transient": "网络/连接异常",
|
||||
"upstream": "上游响应异常",
|
||||
"config": "模型未配置或配置异常",
|
||||
"exception": "调用异常",
|
||||
"generic": "调用失败",
|
||||
}
|
||||
|
||||
|
||||
def reason_text(reason: str | None) -> str:
|
||||
"""原因码 → 简短中文(未知码 → 「调用失败」)。"""
|
||||
if not reason:
|
||||
return "调用失败"
|
||||
return _REASON_TEXT.get(reason, "调用失败")
|
||||
|
||||
|
||||
_QUOTA_PATTERNS = ("insufficient_quota", "quota", "billing", "credit", "payment",
|
||||
"余额不足", "超出限额", "额度不足", "欠费")
|
||||
_AUTH_PATTERNS = ("authentication", "unauthorized", "invalid api key", "invalid_api_key",
|
||||
"permission", "forbidden", "access denied", "无权", "未授权")
|
||||
_BUSY_PATTERNS = ("server busy", "temporarily unavailable", "try again later", "please retry",
|
||||
"please try again", "overloaded", "high demand", "rate limit",
|
||||
"负载较高", "服务繁忙", "稍后重试", "请稍后重试")
|
||||
_UPSTREAM_PATTERNS = ("bad_response_status_code", "bad response status",
|
||||
"expecting ',' delimiter", "expecting value", "expecting property name",
|
||||
"unexpected character", "unterminated string", "jsondecodeerror")
|
||||
_TRANSIENT_EXC_NAMES = {"APITimeoutError", "APIConnectionError", "InternalServerError",
|
||||
"ReadError", "RemoteProtocolError", "TimeoutError"}
|
||||
|
||||
|
||||
def classify_exception(exc: BaseException) -> str:
|
||||
"""异常 → 失败原因码(quota/auth/busy/transient/upstream/config/generic/exception)。
|
||||
|
||||
与 ``LLMErrorHandlingMiddleware._classify_error`` 同口径,简化版:不区分是否可重试
|
||||
(ai_writing 的容错只换模型,不在同模型上重试)。``create_chat_model`` 抛
|
||||
``ValueError("Model X not found in config")`` 这类配置缺失另归 ``config``。
|
||||
"""
|
||||
name = exc.__class__.__name__
|
||||
detail = str(exc).lower()
|
||||
|
||||
if isinstance(exc, ValueError) and "not found in config" in detail:
|
||||
return "config"
|
||||
if any(p in detail for p in _QUOTA_PATTERNS):
|
||||
return "quota"
|
||||
if any(p in detail for p in _AUTH_PATTERNS):
|
||||
return "auth"
|
||||
if name in _TRANSIENT_EXC_NAMES:
|
||||
return "transient"
|
||||
if any(p in detail for p in _BUSY_PATTERNS):
|
||||
return "busy"
|
||||
if any(p in detail for p in _UPSTREAM_PATTERNS):
|
||||
return "upstream"
|
||||
return "exception"
|
||||
|
||||
|
||||
def _fallback_enabled() -> bool:
|
||||
raw = (os.getenv("ROUNDTABLE_MODEL_FALLBACK", "1") or "1").strip().lower()
|
||||
return raw not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _max_attempts() -> int:
|
||||
try:
|
||||
return max(0, int(os.getenv("ROUNDTABLE_MODEL_FALLBACK_MAX", "0") or "0"))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _config_model_names() -> list[str]:
|
||||
try:
|
||||
from deerflow.config import get_app_config
|
||||
cfg = get_app_config()
|
||||
return [m.name for m in (cfg.models or []) if getattr(m, "name", None)]
|
||||
except Exception: # noqa: BLE001 — 拿不到配置(测试桩)时退化为空
|
||||
logger.debug("ai_writing fallback_chain: get_app_config().models unavailable", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def fallback_chain(current: str | None) -> list[str]:
|
||||
"""``[当前模型, …config.yaml 其余模型]``(去重保序)。
|
||||
|
||||
- ``current`` 为空 → 链头落到 ``models[0]``;
|
||||
- ``current`` 在配置内 → 排第一,余者按配置顺序补齐;
|
||||
- ``current`` 不在配置内 → 仍排第一(让 ``create_chat_model`` 在第一轮按原行为抛,
|
||||
之后再换模型),再补齐全部配置模型;
|
||||
- 总开关关闭 → 只返回头一个;
|
||||
- 拿不到配置 → ``[current]``(current 为空则空列表)。
|
||||
"""
|
||||
all_names = _config_model_names()
|
||||
cur = (current or "").strip()
|
||||
ordered: list[str] = []
|
||||
if cur:
|
||||
ordered.append(cur)
|
||||
for n in all_names:
|
||||
if n and n not in ordered:
|
||||
ordered.append(n)
|
||||
if not ordered:
|
||||
return [cur] if cur else []
|
||||
if not _fallback_enabled():
|
||||
return ordered[:1]
|
||||
cap = _max_attempts()
|
||||
if cap > 0:
|
||||
ordered = ordered[:cap]
|
||||
return ordered
|
||||
|
||||
|
||||
def build_switch_progress_event(
|
||||
*,
|
||||
agent_name: str,
|
||||
stage: str,
|
||||
failed_model: str,
|
||||
next_model: str,
|
||||
reason: str,
|
||||
section_index: int | None = None,
|
||||
section_title: str | None = None,
|
||||
) -> dict:
|
||||
"""构造一条 ``model_switch`` ``progress_event`` 字典(供节点 return 时塞进 ``progress_events`` 数组)。
|
||||
|
||||
``stage`` ∈ {intent, outline, draft, review}:前端 reducer 据此清掉对应阶段的流式缓冲,
|
||||
避免半段输出被下一个模型的输出粘在一起。
|
||||
"""
|
||||
fm = (failed_model or "").strip() or "当前模型"
|
||||
nm = (next_model or "").strip() or "下一个模型"
|
||||
rt = reason_text(reason)
|
||||
detail = f"「{agent_name}」所用模型「{fm}」{rt},已自动切换到「{nm}」继续。"
|
||||
payload: dict[str, Any] = {
|
||||
"type": "model_switch",
|
||||
"agent_name": agent_name,
|
||||
"title": "模型容错",
|
||||
"detail": detail,
|
||||
"message": detail,
|
||||
"stage": stage,
|
||||
"failed_model": fm,
|
||||
"next_model": nm,
|
||||
"reason": reason,
|
||||
}
|
||||
if section_index is not None:
|
||||
payload["section_index"] = section_index
|
||||
if section_title:
|
||||
payload["section_title"] = section_title
|
||||
return payload
|
||||
|
||||
|
||||
def emit_model_switch_event(
|
||||
writer: Callable[[dict], None] | None,
|
||||
*,
|
||||
agent_name: str,
|
||||
stage: str,
|
||||
failed_model: str,
|
||||
next_model: str,
|
||||
reason: str,
|
||||
section_index: int | None = None,
|
||||
section_title: str | None = None,
|
||||
) -> None:
|
||||
"""把 ``model_switch`` 事件推到 LangGraph custom 通道(前端 useStream onCustomEvent 收)。"""
|
||||
if writer is None:
|
||||
return
|
||||
payload = build_switch_progress_event(
|
||||
agent_name=agent_name, stage=stage,
|
||||
failed_model=failed_model, next_model=next_model, reason=reason,
|
||||
section_index=section_index, section_title=section_title,
|
||||
)
|
||||
try:
|
||||
writer(payload)
|
||||
except Exception: # noqa: BLE001 — writer 在无 runtime 时是 no-op,理论不抛
|
||||
logger.debug("emit_model_switch_event: writer raised", exc_info=True)
|
||||
+57
-15
@@ -21,7 +21,13 @@ from ._agent_runner import (
|
||||
run_writing_agent,
|
||||
use_builtin_agent,
|
||||
)
|
||||
from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
||||
from ._model_fallback import (
|
||||
build_switch_progress_event,
|
||||
classify_exception,
|
||||
emit_model_switch_event,
|
||||
fallback_chain,
|
||||
)
|
||||
from ._utils import get_ai_writing_config, parse_json, resolve_writing_model_name, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,22 +160,17 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
"title": "模型错误", "detail": str(e), "message": "审核失败",
|
||||
}],
|
||||
}
|
||||
# 旧路径下模型由 fallback_chain 在每维度审查前按候选顺序解析;模型挂掉自动换下一个继续。
|
||||
editor_chain: list[str] = []
|
||||
current_model_name: str | None = None
|
||||
if not use_agent:
|
||||
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
|
||||
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
|
||||
try:
|
||||
model = get_writing_model(config, state)
|
||||
except Exception as e:
|
||||
logger.error("editor model creation failed: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"progress_events": [{
|
||||
"type": "error", "agent_name": "编辑",
|
||||
"title": "模型错误", "detail": str(e), "message": "审核失败",
|
||||
}],
|
||||
}
|
||||
initial_name, _ud = resolve_writing_model_name(config, state)
|
||||
editor_chain = fallback_chain(initial_name)
|
||||
current_model_name = editor_chain[0] if editor_chain else None
|
||||
from deerflow.models import create_chat_model
|
||||
results: dict[str, dict] = {}
|
||||
review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示
|
||||
switch_notices: list[dict] = [] # 模型容错切换记录,最终塞进 progress_events 走时间线
|
||||
|
||||
_CATEGORY_LABEL = {"fact": "事实性", "logic": "逻辑性", "language": "语言文字"}
|
||||
|
||||
@@ -192,7 +193,47 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
if res.get("_llm_error"):
|
||||
review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{res['_llm_error']}")
|
||||
else:
|
||||
full_text = await _stream_one_review(model, user_prompt, category, writer)
|
||||
# 旧路径:按 fallback_chain 试候选模型,失败就换下一个继续本维度。
|
||||
# 与 writer_draft 同思路:以「上一维度成功使用的模型」为头部。
|
||||
dim_chain: list[str] = []
|
||||
if current_model_name:
|
||||
dim_chain.append(current_model_name)
|
||||
for n in editor_chain:
|
||||
if n and n not in dim_chain:
|
||||
dim_chain.append(n)
|
||||
if not dim_chain:
|
||||
dim_chain = [None] # type: ignore[list-item]
|
||||
full_text = ""
|
||||
dim_succeeded = False
|
||||
last_dim_exc: BaseException | None = None
|
||||
for d_idx, candidate in enumerate(dim_chain):
|
||||
try:
|
||||
m = create_chat_model(name=candidate) if candidate else create_chat_model()
|
||||
full_text = await _stream_one_review(m, user_prompt, category, writer)
|
||||
dim_succeeded = True
|
||||
current_model_name = candidate or current_model_name
|
||||
break
|
||||
except Exception as e:
|
||||
last_dim_exc = e
|
||||
reason = classify_exception(e)
|
||||
logger.error(
|
||||
"editor review [%s] failed on model=%s (reason=%s): %s",
|
||||
category, candidate, reason, e, exc_info=True,
|
||||
)
|
||||
next_candidate = dim_chain[d_idx + 1] if d_idx + 1 < len(dim_chain) else None
|
||||
if next_candidate:
|
||||
emit_model_switch_event(
|
||||
writer, agent_name=agent_label, stage="review",
|
||||
failed_model=candidate or "", next_model=next_candidate, reason=reason,
|
||||
)
|
||||
switch_notices.append(build_switch_progress_event(
|
||||
agent_name=agent_label, stage="review",
|
||||
failed_model=candidate or "", next_model=next_candidate, reason=reason,
|
||||
))
|
||||
continue
|
||||
if not dim_succeeded:
|
||||
assert last_dim_exc is not None
|
||||
raise last_dim_exc
|
||||
res = parse_json(full_text, fallback)
|
||||
except Exception as e:
|
||||
logger.warning("editor review [%s] failed: %s", category, e)
|
||||
@@ -252,6 +293,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
"review_result": review,
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": agent_label, "message": "开始审核"},
|
||||
*switch_notices,
|
||||
*notices,
|
||||
{
|
||||
"type": "review_ready",
|
||||
|
||||
+73
-39
@@ -9,10 +9,15 @@ from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from ..state import AIWritingState
|
||||
from ._model_fallback import (
|
||||
build_switch_progress_event,
|
||||
classify_exception,
|
||||
emit_model_switch_event,
|
||||
fallback_chain,
|
||||
)
|
||||
from ._utils import (
|
||||
ThinkTagParser,
|
||||
_content_to_text,
|
||||
get_model,
|
||||
parse_json,
|
||||
reasoning_delta,
|
||||
safe_get_stream_writer,
|
||||
@@ -207,47 +212,74 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
|
||||
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
|
||||
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
|
||||
#
|
||||
# 模型自动容错:失败时按 fallback_chain 顺序换下一个模型重试本环节。每次切换
|
||||
# 都通过 ``model_switch`` custom 事件 + ``progress_events`` 同步告诉前端,前端
|
||||
# 清掉本环节已累积的 intent_chunk 缓冲,避免半段输出和下个模型的输出拼接错乱。
|
||||
writer = safe_get_stream_writer()
|
||||
llm_error: str | None = None
|
||||
initial_model = (configurable.get("model_name") or "").strip() or None
|
||||
chain = fallback_chain(initial_model)
|
||||
progress_events: list[dict] = []
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
full_text = ""
|
||||
parser = ThinkTagParser()
|
||||
try:
|
||||
model = get_model(config)
|
||||
async for chunk in model.astream(
|
||||
[SystemMessage(content=system), HumanMessage(content=user)]
|
||||
):
|
||||
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True})
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
success_model: str | None = None
|
||||
last_error: BaseException | None = None
|
||||
for idx, candidate in enumerate(chain or [None]):
|
||||
full_text = "" # 每次重试都从头累积,避免不同模型的输出粘在一起
|
||||
parser = ThinkTagParser()
|
||||
try:
|
||||
model = create_chat_model(name=candidate) if candidate else create_chat_model()
|
||||
async for chunk in model.astream(
|
||||
[SystemMessage(content=system), HumanMessage(content=user)]
|
||||
):
|
||||
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True})
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
full_text += text
|
||||
writer({
|
||||
"type": "intent_chunk",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
})
|
||||
success_model = candidate or ""
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
reason = classify_exception(exc)
|
||||
logger.error(
|
||||
"intent_parser streaming failed on model=%s (reason=%s): %s",
|
||||
candidate, reason, exc, exc_info=True,
|
||||
)
|
||||
next_candidate = chain[idx + 1] if idx + 1 < len(chain) else None
|
||||
if next_candidate:
|
||||
emit_model_switch_event(
|
||||
writer, agent_name="系统", stage="intent",
|
||||
failed_model=candidate or "", next_model=next_candidate, reason=reason,
|
||||
)
|
||||
progress_events.append(build_switch_progress_event(
|
||||
agent_name="系统", stage="intent",
|
||||
failed_model=candidate or "", next_model=next_candidate, reason=reason,
|
||||
))
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
full_text += text
|
||||
writer({
|
||||
"type": "intent_chunk",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
})
|
||||
except Exception as exc:
|
||||
# 模型 / 网络挂掉 —— 不阻断流程,用 fallback 继续。
|
||||
logger.error("intent_parser streaming failed: %s", exc, exc_info=True)
|
||||
llm_error = f"{type(exc).__name__}: {exc}"
|
||||
# 链尾仍失败:保留 fallback 默认值,向用户报一条非致命 notice,流程继续。
|
||||
progress_events.append({
|
||||
"type": "notice",
|
||||
"agent_name": "系统",
|
||||
"title": "模型错误",
|
||||
"detail": (
|
||||
f"意图解析失败,已按默认配置兜底。原因:{type(exc).__name__}: {exc}"
|
||||
),
|
||||
})
|
||||
|
||||
result = parse_json(full_text, fallback_result) if full_text else fallback_result
|
||||
|
||||
# 意图解析模型调用失败时以非致命 notice 提示用户(fallback 已兜底,流程仍可继续)
|
||||
progress_events: list[dict] = []
|
||||
if llm_error:
|
||||
progress_events.append({
|
||||
"type": "notice",
|
||||
"agent_name": "系统",
|
||||
"title": "模型错误",
|
||||
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
|
||||
})
|
||||
|
||||
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
|
||||
state, result, fallback_result, valid_keys, default_key
|
||||
)
|
||||
@@ -267,9 +299,11 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
# 捕获用户表单选定的主模型进 state(仅本节点能可靠拿到 configurable.model_name —
|
||||
# 它运行在首次 invocation;后续大纲/写作/审核在 resume 后跑,config 已不带它)。
|
||||
# 大纲/写作/审核经 get_writing_model 严格读 state['model_name'],不回退默认。
|
||||
selected_model = (configurable.get("model_name") or "").strip()
|
||||
if selected_model:
|
||||
updates["model_name"] = selected_model
|
||||
# 容错切换后用实际成功的模型作为后续阶段起点,避免每个阶段都重复经历同一次失败。
|
||||
persisted_model = (success_model or initial_model or "").strip()
|
||||
if persisted_model:
|
||||
updates["model_name"] = persisted_model
|
||||
_ = last_error # 链尾失败已生成 notice,这里保留变量供调试时断点查看
|
||||
updates["article_type"] = final_article_type
|
||||
updates["target_audience"] = final_target_audience
|
||||
updates["word_count_target"] = final_word_count
|
||||
|
||||
+99
-50
@@ -21,6 +21,12 @@ from ._agent_runner import (
|
||||
stream_writing_agent,
|
||||
use_builtin_agent,
|
||||
)
|
||||
from ._model_fallback import (
|
||||
build_switch_progress_event,
|
||||
classify_exception,
|
||||
emit_model_switch_event,
|
||||
fallback_chain,
|
||||
)
|
||||
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -234,31 +240,25 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
|
||||
}],
|
||||
}
|
||||
# 旧路径的模型由 fallback_chain 在每章流式调用前按候选顺序解析(per-section),
|
||||
# 不再在循环外预创建:模型名配错 / 模型挂掉时不再硬崩溃,按链顺序换下一个模型重写本章节。
|
||||
draft_chain: list[str] = []
|
||||
if not use_agent:
|
||||
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
|
||||
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
|
||||
try:
|
||||
model_name, used_default = resolve_writing_model_name(config, state)
|
||||
if used_default:
|
||||
model_default_notice = {
|
||||
"type": "notice", "agent_name": "作家",
|
||||
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
|
||||
}
|
||||
from deerflow.models import create_chat_model
|
||||
model = create_chat_model(name=model_name)
|
||||
except Exception as e:
|
||||
logger.error("draft model creation failed: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"progress_events": [{
|
||||
"type": "error", "agent_name": "作家",
|
||||
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
|
||||
}],
|
||||
initial_name, used_default = resolve_writing_model_name(config, state)
|
||||
if used_default:
|
||||
model_default_notice = {
|
||||
"type": "notice", "agent_name": "作家",
|
||||
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
|
||||
}
|
||||
draft_chain = fallback_chain(initial_name)
|
||||
from deerflow.models import create_chat_model
|
||||
section_titles: list[str] = []
|
||||
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
|
||||
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
|
||||
blocked_sections: list[dict] = [] # 严格模式下素材不足、无法写作的章节
|
||||
switch_notices: list[dict] = [] # 模型容错切换记录,最终塞进 progress_events 走时间线
|
||||
# 上一节成功使用的模型 —— 优先尝试,命中则零开销;与会商「当前模型先试」一致。
|
||||
current_model_name: str | None = (draft_chain[0] if draft_chain else None)
|
||||
|
||||
for i, section in enumerate(sections):
|
||||
sec_title = _section_title(i, section)
|
||||
@@ -292,9 +292,12 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
)
|
||||
|
||||
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
|
||||
parser = ThinkTagParser()
|
||||
section_succeeded = False
|
||||
try:
|
||||
if use_agent:
|
||||
parser = ThinkTagParser() # 内置 agent 走自有路径,本变量保留以避免类型告警
|
||||
_ = parser
|
||||
|
||||
def _on_text(text: str, is_thinking: bool, _i: int = i, _t: str = sec_title) -> None:
|
||||
writer({
|
||||
"type": "draft_chunk",
|
||||
@@ -312,36 +315,78 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
config=config,
|
||||
on_text=_on_text,
|
||||
)
|
||||
section_succeeded = True
|
||||
else:
|
||||
async for chunk in model.astream([
|
||||
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
||||
HumanMessage(content=user_msg),
|
||||
]):
|
||||
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
writer({
|
||||
"type": "draft_chunk",
|
||||
"section_index": i,
|
||||
"section_title": sec_title,
|
||||
"text": reasoning,
|
||||
"is_thinking": True,
|
||||
"is_section_done": False,
|
||||
})
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
section_buf += text
|
||||
writer({
|
||||
"type": "draft_chunk",
|
||||
"section_index": i,
|
||||
"section_title": sec_title,
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
"is_section_done": False,
|
||||
})
|
||||
# 旧路径:按 fallback_chain 试候选模型,失败就换下一个继续本章节。
|
||||
# 以「上一节成功使用的模型」为头部 —— 同 config.yaml 顺序的链尾再补全。
|
||||
section_chain: list[str] = []
|
||||
if current_model_name:
|
||||
section_chain.append(current_model_name)
|
||||
for n in draft_chain:
|
||||
if n and n not in section_chain:
|
||||
section_chain.append(n)
|
||||
if not section_chain:
|
||||
section_chain = [None] # type: ignore[list-item]
|
||||
last_section_exc: BaseException | None = None
|
||||
for s_idx, candidate in enumerate(section_chain):
|
||||
section_buf = ""
|
||||
parser = ThinkTagParser()
|
||||
try:
|
||||
m = create_chat_model(name=candidate) if candidate else create_chat_model()
|
||||
async for chunk in m.astream([
|
||||
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
||||
HumanMessage(content=user_msg),
|
||||
]):
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
writer({
|
||||
"type": "draft_chunk",
|
||||
"section_index": i,
|
||||
"section_title": sec_title,
|
||||
"text": reasoning,
|
||||
"is_thinking": True,
|
||||
"is_section_done": False,
|
||||
})
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
section_buf += text
|
||||
writer({
|
||||
"type": "draft_chunk",
|
||||
"section_index": i,
|
||||
"section_title": sec_title,
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
"is_section_done": False,
|
||||
})
|
||||
section_succeeded = True
|
||||
current_model_name = candidate or current_model_name
|
||||
break
|
||||
except Exception as e:
|
||||
last_section_exc = e
|
||||
reason = classify_exception(e)
|
||||
logger.error(
|
||||
"draft section [%s] failed on model=%s (reason=%s): %s",
|
||||
sec_title, candidate, reason, e, exc_info=True,
|
||||
)
|
||||
next_candidate = section_chain[s_idx + 1] if s_idx + 1 < len(section_chain) else None
|
||||
if next_candidate:
|
||||
emit_model_switch_event(
|
||||
writer, agent_name=agent_label, stage="draft",
|
||||
failed_model=candidate or "", next_model=next_candidate,
|
||||
reason=reason, section_index=i, section_title=sec_title,
|
||||
)
|
||||
switch_notices.append(build_switch_progress_event(
|
||||
agent_name=agent_label, stage="draft",
|
||||
failed_model=candidate or "", next_model=next_candidate,
|
||||
reason=reason, section_index=i, section_title=sec_title,
|
||||
))
|
||||
continue
|
||||
if not section_succeeded:
|
||||
assert last_section_exc is not None
|
||||
raise last_section_exc
|
||||
|
||||
# 章节完成
|
||||
writer({
|
||||
@@ -387,6 +432,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
"status": "awaiting_section_help",
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
|
||||
*switch_notices,
|
||||
{
|
||||
"type": "notice", "agent_name": agent_label, "title": "素材不足",
|
||||
"detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。",
|
||||
@@ -432,7 +478,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
on_text=None,
|
||||
)).strip()
|
||||
else:
|
||||
resp = await model.ainvoke([
|
||||
# 扩写是兜底优化,不再做模型链兜底:拿当前已成功的模型试一次即可,失败就保留原文。
|
||||
expand_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
|
||||
resp = await expand_model.ainvoke([
|
||||
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
||||
HumanMessage(content=expand_msg),
|
||||
])
|
||||
@@ -479,6 +527,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
"status": "awaiting_draft_confirm",
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
|
||||
*switch_notices,
|
||||
*notices,
|
||||
{
|
||||
"type": "draft_ready",
|
||||
|
||||
+86
-38
@@ -29,7 +29,13 @@ from ._agent_runner import (
|
||||
stream_writing_agent,
|
||||
use_builtin_agent,
|
||||
)
|
||||
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
|
||||
from ._model_fallback import (
|
||||
build_switch_progress_event,
|
||||
classify_exception,
|
||||
emit_model_switch_event,
|
||||
fallback_chain,
|
||||
)
|
||||
from ._utils import ThinkTagParser, _content_to_text, llm_json, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -292,10 +298,11 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
writer = safe_get_stream_writer()
|
||||
|
||||
full_text = "" # 只累积正文(非思考)用于 JSON 解析
|
||||
parser = ThinkTagParser()
|
||||
extra_progress_events: list[dict] = []
|
||||
|
||||
try:
|
||||
if use_agent:
|
||||
if use_agent:
|
||||
# 内置 agent 路径走 stream_writing_agent,不在本模块做模型链兜底(agent 自带模型解析)。
|
||||
try:
|
||||
def _on_text(text: str, is_thinking: bool) -> None:
|
||||
writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
|
||||
|
||||
@@ -306,41 +313,81 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
config=config,
|
||||
on_text=_on_text,
|
||||
)
|
||||
else:
|
||||
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
|
||||
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
|
||||
model = get_writing_model(config, state)
|
||||
async for chunk in model.astream([
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_prompt),
|
||||
]):
|
||||
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
except Exception as e:
|
||||
logger.error("outline streaming failed: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"progress_events": [{
|
||||
"type": "error",
|
||||
"agent_name": agent_label,
|
||||
"title": "模型错误",
|
||||
"detail": str(e),
|
||||
"message": "大纲生成失败",
|
||||
}],
|
||||
}
|
||||
else:
|
||||
# 旧路径:按 fallback_chain 顺序尝试,失败即换下一个模型重跑该环节。
|
||||
initial_name, _used_default = resolve_writing_model_name(config, state)
|
||||
chain = fallback_chain(initial_name)
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
success = False
|
||||
last_exc: BaseException | None = None
|
||||
for idx, candidate in enumerate(chain or [None]):
|
||||
full_text = ""
|
||||
parser = ThinkTagParser()
|
||||
try:
|
||||
model = create_chat_model(name=candidate) if candidate else create_chat_model()
|
||||
async for chunk in model.astream([
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_prompt),
|
||||
]):
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
full_text += text
|
||||
writer({
|
||||
"type": "outline_chunk",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
})
|
||||
success = True
|
||||
break
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
reason = classify_exception(e)
|
||||
logger.error(
|
||||
"outline streaming failed on model=%s (reason=%s): %s",
|
||||
candidate, reason, e, exc_info=True,
|
||||
)
|
||||
next_candidate = chain[idx + 1] if idx + 1 < len(chain) else None
|
||||
if next_candidate:
|
||||
emit_model_switch_event(
|
||||
writer, agent_name=agent_label, stage="outline",
|
||||
failed_model=candidate or "", next_model=next_candidate, reason=reason,
|
||||
)
|
||||
extra_progress_events.append(build_switch_progress_event(
|
||||
agent_name=agent_label, stage="outline",
|
||||
failed_model=candidate or "", next_model=next_candidate, reason=reason,
|
||||
))
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
full_text += text
|
||||
writer({
|
||||
"type": "outline_chunk",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("outline streaming failed: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"progress_events": [{
|
||||
"type": "error",
|
||||
"agent_name": agent_label,
|
||||
"title": "模型错误",
|
||||
"detail": str(e),
|
||||
"message": "大纲生成失败",
|
||||
}],
|
||||
}
|
||||
if not success:
|
||||
assert last_exc is not None
|
||||
return {
|
||||
"status": "error",
|
||||
"progress_events": extra_progress_events + [{
|
||||
"type": "error",
|
||||
"agent_name": agent_label,
|
||||
"title": "模型错误",
|
||||
"detail": str(last_exc),
|
||||
"message": "大纲生成失败(所有候选模型均失败)",
|
||||
}],
|
||||
}
|
||||
|
||||
# 编辑模式解析失败时回退为「当前大纲」,避免把用户已有大纲弄丢
|
||||
if is_revise:
|
||||
@@ -387,6 +434,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
"status": "awaiting_outline_confirm",
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": agent_label, "message": "开始规划大纲"},
|
||||
*extra_progress_events,
|
||||
{"type": "outline_ready", "agent_name": agent_label, "message": f"大纲已生成,共 {section_count} 个章节"},
|
||||
],
|
||||
}
|
||||
+20
-7
@@ -306,17 +306,25 @@ def _build_middlewares(
|
||||
if not cfg.get("is_scheduled_run", False):
|
||||
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
|
||||
# Ephemeral runs (e.g. the open Q&A API /api/open/chat) must NOT touch any
|
||||
# server-side per-user state: no memory recall/retain, no knowledge-base
|
||||
# recall/auto-ingest. Those runs use the shared "default" user bucket, so
|
||||
# persisting their conversations there would both store records the caller
|
||||
# explicitly asked NOT to keep and leak content across unrelated callers.
|
||||
# Skip both write-capable middlewares entirely.
|
||||
is_ephemeral = cfg.get("ephemeral", False)
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
|
||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||
if not is_bootstrap and not is_writing_setup:
|
||||
if not is_bootstrap and not is_writing_setup and not is_ephemeral:
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
||||
|
||||
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
|
||||
# per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting
|
||||
# to the global ``knowledge.rag_enabled`` config. No-op when knowledge is
|
||||
# disabled. Skipped in bootstrap/writing-setup like MemoryMiddleware.
|
||||
# disabled. Skipped in bootstrap/writing-setup/ephemeral like MemoryMiddleware.
|
||||
knowledge_cfg = getattr(resolved_app_config, "knowledge", None)
|
||||
if not is_bootstrap and not is_writing_setup and knowledge_cfg is not None and knowledge_cfg.enabled:
|
||||
if not is_bootstrap and not is_writing_setup and not is_ephemeral and knowledge_cfg is not None and knowledge_cfg.enabled:
|
||||
from deerflow.agents.middlewares.knowledge_rag_middleware import KnowledgeRagMiddleware
|
||||
|
||||
middlewares.append(KnowledgeRagMiddleware(limit=knowledge_cfg.rag_limit, default_enabled=knowledge_cfg.rag_enabled))
|
||||
@@ -412,6 +420,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
resolved_app_config = app_config
|
||||
|
||||
thinking_enabled = cfg.get("thinking_enabled", True)
|
||||
# Hard "thinking off" switch for internal vLLM/Qwen models whose config only
|
||||
# declares `supports_thinking: true` (no disable shape) — passes through to
|
||||
# create_chat_model(force_disable_thinking=...) so enable_thinking=false is
|
||||
# actually injected. Default off → no behavior change for normal chats.
|
||||
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
|
||||
reasoning_effort = cfg.get("reasoning_effort", None)
|
||||
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
@@ -501,7 +514,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
tools=[setup_writing, ask_clarification_tool],
|
||||
middleware=[
|
||||
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
@@ -514,7 +527,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
if is_bootstrap:
|
||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
tools=get_available_tools(
|
||||
model_name=model_name,
|
||||
subagent_enabled=subagent_enabled,
|
||||
@@ -593,7 +606,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
)
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
system_prompt=system_prompt,
|
||||
|
||||
+4
-3
@@ -5,7 +5,8 @@
|
||||
随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。
|
||||
|
||||
测试开关(线上接口未通时用假数据,互相独立):
|
||||
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。
|
||||
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据;
|
||||
同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。
|
||||
- ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。
|
||||
- ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。
|
||||
xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。
|
||||
@@ -25,7 +26,7 @@ class TaskDeeplinkConfig(BaseModel):
|
||||
|
||||
test: bool = Field(
|
||||
default=False,
|
||||
description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)",
|
||||
description="为真时前端目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检(selectSqReport)走 mock.sq_report 假数据(都不打真实接口)",
|
||||
)
|
||||
delete_test: bool = Field(
|
||||
default=False,
|
||||
@@ -61,5 +62,5 @@ class TaskDeeplinkConfig(BaseModel):
|
||||
)
|
||||
mock: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}",
|
||||
description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}, sq_report: {state, msg, data: [...]}}(sq_report 供 rwfx 3q 预检,test 为真时用)",
|
||||
)
|
||||
@@ -47,11 +47,22 @@ def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_con
|
||||
model_settings_from_config["stream_usage"] = True
|
||||
|
||||
|
||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, **kwargs) -> BaseChatModel:
|
||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, **kwargs) -> BaseChatModel:
|
||||
"""Create a chat model instance from the config.
|
||||
|
||||
Args:
|
||||
name: The name of the model to create. If None, the first model in the config will be used.
|
||||
thinking_enabled: Turn the model's extended thinking / reasoning on.
|
||||
force_disable_thinking: Hard override that disables thinking even when the
|
||||
per-model config declares no ``when_thinking_*`` shape. Many internal
|
||||
vLLM / Qwen models only set ``supports_thinking: true`` (no disable
|
||||
shape), so passing ``thinking_enabled=False`` alone injects nothing and
|
||||
the model keeps reasoning on. When this flag is set (and thinking is
|
||||
off) we additionally inject both common OpenAI-compatible disable shapes
|
||||
— vLLM/Qwen ``chat_template_kwargs.enable_thinking=False`` and Zhipu GLM
|
||||
``thinking.type="disabled"`` — mirroring
|
||||
``TitleMiddleware._force_disable_thinking``. No effect when thinking is
|
||||
enabled, and skipped for model classes that don't accept ``extra_body``.
|
||||
|
||||
Returns:
|
||||
A chat model instance.
|
||||
@@ -120,6 +131,21 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
||||
kwargs.pop("reasoning_effort", None)
|
||||
model_settings_from_config.pop("reasoning_effort", None)
|
||||
|
||||
# Hard override: force-disable thinking regardless of the per-model config shape.
|
||||
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
|
||||
# other providers would reject the kwarg, so they are skipped (they ignore it).
|
||||
# Three disable shapes are injected so the common OpenAI-compatible gateways are
|
||||
# all covered: SiliconFlow / DashScope-style **top-level** ``enable_thinking``
|
||||
# (DeepSeek-V3.1+/Qwen3/GLM hybrid models on api.siliconflow.cn — the knob the
|
||||
# nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native
|
||||
# ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``.
|
||||
# Unknown fields are ignored by these gateways, so sending all three is safe.
|
||||
if force_disable_thinking and not thinking_enabled and "extra_body" in getattr(model_class, "model_fields", {}):
|
||||
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
||||
model_settings_from_config.get("extra_body"),
|
||||
{"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
|
||||
)
|
||||
|
||||
_enable_stream_usage_by_default(model_config.use, model_settings_from_config)
|
||||
|
||||
# For Codex Responses API models: map thinking mode to reasoning_effort
|
||||
|
||||
+8
-1
@@ -41,10 +41,17 @@ def get_default_store() -> RoundtableDiagnosticRepository | None:
|
||||
|
||||
|
||||
async def record_diagnostic(store: RoundtableDiagnosticRepository | None = None, **fields: Any) -> None:
|
||||
"""Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。"""
|
||||
"""Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。
|
||||
|
||||
会商诊断**只收集报错日志**:``info`` 级是纯运行进度(begin/consensus/gather/job_started
|
||||
等「运行日志」),对排障没价值、还会淹没真正的报错,直接不落库;只保留 ``warning`` /
|
||||
``error``(席位失败、空交付、超时、模型容错、产物缺失、崩溃等)。
|
||||
"""
|
||||
repo = store or _default_store
|
||||
if repo is None:
|
||||
return
|
||||
if str(fields.get("level") or "").strip().lower() == "info":
|
||||
return
|
||||
try:
|
||||
await repo.record(**fields)
|
||||
except Exception: # noqa: BLE001 — 诊断日志写失败绝不打断会商
|
||||
|
||||
+26
@@ -11,6 +11,7 @@ All write/read paths are scoped by ``user_id`` for ownership isolation.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -24,6 +25,29 @@ from deerflow.persistence.roundtable_drafts.model import (
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
# 业务链名抽取:在已加载进内存的 step2 JSON 串里直接找 `"chain":{...,"title":"X"}`,
|
||||
# 不整体 json.loads(step2 是 LONGTEXT 大块),只做一次廉价正则扫描。`"chain":null`
|
||||
# (recommend/自由模式)不匹配 → 返回 None。`[^{}]*?` 限定在 chain 对象内({id,title}
|
||||
# 无嵌套花括号)。
|
||||
_CHAIN_TITLE_RE = re.compile(r'"chain"\s*:\s*\{[^{}]*?"title"\s*:\s*"((?:\\.|[^"\\])*)"')
|
||||
|
||||
|
||||
def _extract_chain_title(step2_raw: Any) -> str | None:
|
||||
"""从 step2 原始 JSON 串里抽取所用业务链名(无链条 → None,前端显示「自由模式」)。"""
|
||||
if not isinstance(step2_raw, str) or not step2_raw:
|
||||
return None
|
||||
m = _CHAIN_TITLE_RE.search(step2_raw)
|
||||
if not m:
|
||||
return None
|
||||
title = m.group(1)
|
||||
try:
|
||||
# 还原 JSON 字符串转义(\" \\ \n \uXXXX 等)。
|
||||
title = json.loads(f'"{title}"')
|
||||
except Exception:
|
||||
pass
|
||||
title = title.strip()
|
||||
return title or None
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
@@ -56,6 +80,8 @@ class RoundtableDraftRepository:
|
||||
"task_id": row.task_id,
|
||||
"title": row.title,
|
||||
"furthest_step": row.furthest_step,
|
||||
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
|
||||
"chain_title": _extract_chain_title(row.step2),
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
+24
@@ -15,6 +15,7 @@ in ``PortableLongText`` and round-tripped with ``json.dumps`` / ``json.loads``.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +26,27 @@ from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraf
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
# 业务链名抽取:在已加载进内存的 step2 JSON 串里直接找 `"chain":{...,"title":"X"}`,
|
||||
# 不整体 json.loads(step2 是 LONGTEXT 大块)。`"chain":null`(自由模式)不匹配 → None。
|
||||
# 与 roundtable_drafts/sql.py 保持一致。
|
||||
_CHAIN_TITLE_RE = re.compile(r'"chain"\s*:\s*\{[^{}]*?"title"\s*:\s*"((?:\\.|[^"\\])*)"')
|
||||
|
||||
|
||||
def _extract_chain_title(step2_raw: Any) -> str | None:
|
||||
"""从 step2 原始 JSON 串里抽取所用业务链名(无链条 → None,前端显示「自由模式」)。"""
|
||||
if not isinstance(step2_raw, str) or not step2_raw:
|
||||
return None
|
||||
m = _CHAIN_TITLE_RE.search(step2_raw)
|
||||
if not m:
|
||||
return None
|
||||
title = m.group(1)
|
||||
try:
|
||||
title = json.loads(f'"{title}"')
|
||||
except Exception:
|
||||
pass
|
||||
title = title.strip()
|
||||
return title or None
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
@@ -55,6 +77,8 @@ class RoundtableTaskDraftRepository:
|
||||
"task_id": row.task_id,
|
||||
"title": row.title,
|
||||
"furthest_step": row.furthest_step,
|
||||
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
|
||||
"chain_title": _extract_chain_title(row.step2),
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
+11
-6
@@ -16,8 +16,11 @@ Resolution rule (per resource type):
|
||||
- ``scheduled_task`` → empty (no built-in tasks)
|
||||
- ``skill`` → empty (the 广场 still shows every visible skill;
|
||||
a position only *adds* its tagged skills to the member's "我的技能")
|
||||
- ``recommended_question`` → *unrestricted* (handled at query time, never
|
||||
materialized — see :meth:`resolve_visible`)
|
||||
- ``recommended_question`` → *untagged-only* (handled at query time, never
|
||||
materialized — see :meth:`resolve_visible`). The default set is the
|
||||
questions carrying **no** 标签; tagged questions surface only for a position
|
||||
that binds their tag. (The recommended-questions router interprets a
|
||||
``None`` from :meth:`resolve_visible` as "show only untagged".)
|
||||
|
||||
Materialization (``sync_user``) only ever touches rows it created itself
|
||||
(``origin='position'``), so a user's own favorites / subscriptions are never
|
||||
@@ -67,12 +70,14 @@ class PositionSyncService:
|
||||
return await self._positions.get_default_position_id()
|
||||
|
||||
async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None:
|
||||
"""Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted.
|
||||
"""Return the set of visible ``resource_type`` ids, or ``None`` = no tag binding.
|
||||
|
||||
Used by the recommended-questions filter (the only remaining query-time
|
||||
visibility type; skills are now *materialized*, not filtered). ``None``
|
||||
means "no position restriction → show everything"; a set means "restrict
|
||||
to exactly these ids".
|
||||
visibility type; skills are now *materialized*, not filtered). A set
|
||||
means "restrict to exactly these ids". ``None`` means the effective
|
||||
position binds no tag for this type — the recommended-questions router
|
||||
treats that as "默认岗位 → show only untagged questions" (see
|
||||
``app/gateway/routers/recommended_questions.py``).
|
||||
"""
|
||||
pid = await self._effective_position_id(user_id)
|
||||
if not pid:
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Scan skill text for URL / IP addresses and apply safe batch replacements.
|
||||
|
||||
Pure-logic layer for the 技能地址(URL/IP)批量替换 feature. This module knows
|
||||
**only about text** — it never walks the filesystem. The HTTP router
|
||||
(``app/gateway/routers/skill_addresses.py``) walks ``skills/{public,custom}``,
|
||||
reads each ``*.md`` / ``*.py`` file, and feeds the text here. Keeping the
|
||||
extraction / replacement logic here (no ``app.*`` import) makes it unit-testable
|
||||
in isolation and respects the harness/app boundary.
|
||||
|
||||
Two address kinds are supported (the feature scope):
|
||||
|
||||
* ``"url"`` — full ``http(s)://…`` URLs.
|
||||
* ``"ip"`` — dotted-quad IPv4, optionally ``:port``. Each octet is range
|
||||
checked (0–255) so version-number noise like ``1.2.3.400`` is not matched.
|
||||
|
||||
The single most important invariant is **substring-safe replacement**: we never
|
||||
``str.replace`` raw file text (that would rewrite ``1.2.3.4`` *inside*
|
||||
``11.2.3.40``). Instead we re-detect the exact token spans and only rewrite a
|
||||
span whose text is *exactly* one of the requested ``from`` values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"VALID_KINDS",
|
||||
"AddressSpan",
|
||||
"Occurrence",
|
||||
"normalize_kinds",
|
||||
"find_spans",
|
||||
"scan_occurrences",
|
||||
"count_matches",
|
||||
"apply_replacements",
|
||||
"is_valid_replacement",
|
||||
"infer_kind",
|
||||
]
|
||||
|
||||
# Supported address kinds (the configured feature scope: URL + IP).
|
||||
VALID_KINDS: tuple[str, ...] = ("url", "ip")
|
||||
|
||||
# A URL body stops at whitespace, quotes, brackets, angle brackets, backticks
|
||||
# and backslashes — everything that commonly *delimits* a URL in md / py. The
|
||||
# trailing punctuation that often clings to the end (a sentence period, a
|
||||
# closing paren already excluded, a Markdown comma) is stripped afterwards.
|
||||
_URL_RE = re.compile(r"https?://[^\s'\"`<>)\]}\\]+", re.IGNORECASE)
|
||||
|
||||
# Dotted-quad IPv4 (optionally :port). Guarded so it is not glued to a longer
|
||||
# number run (``(?<![\d.])`` / ``(?![\d.])``); octets are range-checked below.
|
||||
_IP_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?(?![\d.])")
|
||||
|
||||
# Punctuation (ASCII + CJK) that should not be considered part of a URL token
|
||||
# when it appears at the very end of the match.
|
||||
_URL_TRAILING = set(".,;:!?'\")]}>`" + ")。,;:!?】》、…")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AddressSpan:
|
||||
"""An address token located at an exact ``[start, end)`` offset in text."""
|
||||
|
||||
kind: str
|
||||
value: str
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Occurrence:
|
||||
"""A located address enriched with 1-based line/column + the source line."""
|
||||
|
||||
kind: str
|
||||
value: str
|
||||
line: int
|
||||
col: int
|
||||
snippet: str
|
||||
|
||||
|
||||
def normalize_kinds(kinds: object) -> tuple[str, ...]:
|
||||
"""Coerce arbitrary input into an ordered, de-duped subset of ``VALID_KINDS``.
|
||||
|
||||
``None`` / empty means "all kinds". Unknown kinds are dropped.
|
||||
"""
|
||||
|
||||
if kinds is None:
|
||||
return VALID_KINDS
|
||||
if isinstance(kinds, str):
|
||||
raw = [p.strip() for p in kinds.split(",")]
|
||||
else:
|
||||
try:
|
||||
raw = [str(p).strip() for p in kinds] # type: ignore[assignment]
|
||||
except TypeError:
|
||||
return VALID_KINDS
|
||||
seen: list[str] = []
|
||||
for k in raw:
|
||||
kl = k.lower()
|
||||
if kl in VALID_KINDS and kl not in seen:
|
||||
seen.append(kl)
|
||||
return tuple(seen) if seen else VALID_KINDS
|
||||
|
||||
|
||||
def _valid_ipv4(quad: str) -> bool:
|
||||
parts = quad.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
for p in parts:
|
||||
if not p.isdigit() or len(p) > 3:
|
||||
return False
|
||||
if int(p) > 255:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _valid_port(port: str | None) -> bool:
|
||||
if port is None:
|
||||
return True
|
||||
return port.isdigit() and 0 <= int(port) <= 65535
|
||||
|
||||
|
||||
def _url_spans(text: str) -> list[AddressSpan]:
|
||||
spans: list[AddressSpan] = []
|
||||
for m in _URL_RE.finditer(text):
|
||||
start, end = m.start(), m.end()
|
||||
value = m.group(0)
|
||||
# Strip clinging trailing punctuation.
|
||||
while value and value[-1] in _URL_TRAILING:
|
||||
value = value[:-1]
|
||||
end -= 1
|
||||
if value:
|
||||
spans.append(AddressSpan(kind="url", value=value, start=start, end=end))
|
||||
return spans
|
||||
|
||||
|
||||
def _ip_spans(text: str, exclude: list[AddressSpan]) -> list[AddressSpan]:
|
||||
spans: list[AddressSpan] = []
|
||||
for m in _IP_RE.finditer(text):
|
||||
quad = m.group(1)
|
||||
port = m.group(2)
|
||||
if not _valid_ipv4(quad) or not _valid_port(port):
|
||||
continue
|
||||
start, end = m.start(), m.end()
|
||||
# Skip an IP that lives *inside* a URL (e.g. http://1.2.3.4:8080/x) —
|
||||
# replacing just the IP there would corrupt the URL.
|
||||
if any(s.start <= start < s.end for s in exclude):
|
||||
continue
|
||||
spans.append(AddressSpan(kind="ip", value=m.group(0), start=start, end=end))
|
||||
return spans
|
||||
|
||||
|
||||
def find_spans(text: str, kinds: object = None) -> list[AddressSpan]:
|
||||
"""Return every address token span in *text*, ordered by position.
|
||||
|
||||
URL regions are always computed first so that IPs embedded inside a URL are
|
||||
not separately surfaced, even when only ``"ip"`` is requested.
|
||||
"""
|
||||
|
||||
wanted = normalize_kinds(kinds)
|
||||
url_spans = _url_spans(text)
|
||||
result: list[AddressSpan] = []
|
||||
if "url" in wanted:
|
||||
result.extend(url_spans)
|
||||
if "ip" in wanted:
|
||||
result.extend(_ip_spans(text, exclude=url_spans))
|
||||
result.sort(key=lambda s: s.start)
|
||||
return result
|
||||
|
||||
|
||||
def scan_occurrences(text: str, kinds: object = None, *, snippet_max: int = 240) -> list[Occurrence]:
|
||||
"""Locate addresses with 1-based line/column and a trimmed source line."""
|
||||
|
||||
spans = find_spans(text, kinds)
|
||||
if not spans:
|
||||
return []
|
||||
# Precompute line-start offsets for O(log n) line lookup.
|
||||
line_starts = [0]
|
||||
for i, ch in enumerate(text):
|
||||
if ch == "\n":
|
||||
line_starts.append(i + 1)
|
||||
|
||||
occ: list[Occurrence] = []
|
||||
import bisect
|
||||
|
||||
for s in spans:
|
||||
line_idx = bisect.bisect_right(line_starts, s.start) - 1
|
||||
line_no = line_idx + 1
|
||||
col = s.start - line_starts[line_idx] + 1
|
||||
line_end = line_starts[line_idx + 1] - 1 if line_idx + 1 < len(line_starts) else len(text)
|
||||
raw_line = text[line_starts[line_idx]:line_end].rstrip("\r")
|
||||
snippet = raw_line.strip()
|
||||
if len(snippet) > snippet_max:
|
||||
snippet = snippet[: snippet_max - 1] + "…"
|
||||
occ.append(Occurrence(kind=s.kind, value=s.value, line=line_no, col=col, snippet=snippet))
|
||||
return occ
|
||||
|
||||
|
||||
def count_matches(text: str, mapping: dict[str, str], kinds: object = None) -> int:
|
||||
"""How many address spans in *text* would be rewritten by *mapping*."""
|
||||
|
||||
if not mapping:
|
||||
return 0
|
||||
return sum(1 for s in find_spans(text, kinds) if s.value in mapping)
|
||||
|
||||
|
||||
def apply_replacements(text: str, mapping: dict[str, str], kinds: object = None) -> tuple[str, int]:
|
||||
"""Substring-safe batch replace.
|
||||
|
||||
Only spans whose text is *exactly* a key of *mapping* are rewritten, applied
|
||||
right-to-left so earlier offsets stay valid. Returns ``(new_text, count)``.
|
||||
A no-op mapping (or no hits) returns the original text and ``0``.
|
||||
"""
|
||||
|
||||
if not mapping:
|
||||
return text, 0
|
||||
spans = [s for s in find_spans(text, kinds) if s.value in mapping]
|
||||
if not spans:
|
||||
return text, 0
|
||||
out = text
|
||||
for s in sorted(spans, key=lambda s: s.start, reverse=True):
|
||||
out = out[: s.start] + mapping[s.value] + out[s.end:]
|
||||
return out, len(spans)
|
||||
|
||||
|
||||
def infer_kind(value: str) -> str | None:
|
||||
"""Return the address kind of *value* if it is a single, whole address.
|
||||
|
||||
Used to classify a user-supplied ``from`` value (so the matching ``to`` can
|
||||
be validated against the right rules). Returns ``None`` when *value* is not
|
||||
exactly one URL or IP token.
|
||||
"""
|
||||
|
||||
if not value:
|
||||
return None
|
||||
spans = find_spans(value, VALID_KINDS)
|
||||
if len(spans) == 1 and spans[0].value == value:
|
||||
return spans[0].kind
|
||||
return None
|
||||
|
||||
|
||||
def is_valid_replacement(kind: str, value: str) -> bool:
|
||||
"""Validate that a user-supplied replacement is well-formed for its kind.
|
||||
|
||||
Keeps an admin from turning a URL into garbage (e.g. dropping the scheme) or
|
||||
an IP into a non-IP. Empty replacements are rejected.
|
||||
"""
|
||||
|
||||
if not value or value.strip() != value or any(c.isspace() for c in value):
|
||||
return False
|
||||
if kind == "url":
|
||||
return bool(re.fullmatch(r"https?://[^\s'\"`<>]+", value, re.IGNORECASE))
|
||||
if kind == "ip":
|
||||
m = _IP_RE.fullmatch(value)
|
||||
if not m:
|
||||
return False
|
||||
return _valid_ipv4(m.group(1)) and _valid_port(m.group(2))
|
||||
return False
|
||||
@@ -77,3 +77,65 @@ async def test_list_without_tag_store_does_not_break() -> None:
|
||||
|
||||
assert [q.id for q in resp.questions] == ["q1", "q2"]
|
||||
assert all(q.tags == [] for q in resp.questions)
|
||||
|
||||
|
||||
class _FakeSync:
|
||||
"""Stub position-sync whose ``resolve_visible`` returns a fixed value.
|
||||
|
||||
``None`` mimics 默认岗位 / 无标签绑定; a set mimics a position that binds
|
||||
recommended_question 标签.
|
||||
"""
|
||||
|
||||
def __init__(self, visible: set[str] | None) -> None:
|
||||
self._visible = visible
|
||||
|
||||
async def resolve_visible(self, _user_id, _resource_type): # noqa: ANN001
|
||||
return self._visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_position_shows_only_untagged() -> None:
|
||||
"""默认岗位(resolve_visible → None)只看到未打标的题目。"""
|
||||
tag_store = MemoryTagStore()
|
||||
tag = await tag_store.create_tag(
|
||||
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
|
||||
)
|
||||
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
|
||||
|
||||
req = _request(_FakeQuestionStore(_rows()), tag_store)
|
||||
req.app.state.position_sync = _FakeSync(None)
|
||||
resp = await rq_router.list_recommended_questions(req, all=False)
|
||||
|
||||
assert [q.id for q in resp.questions] == ["q1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bound_position_shows_only_its_tagged_questions() -> None:
|
||||
"""绑定了标签的岗位(resolve_visible → set)只看到对应打标的题目。"""
|
||||
tag_store = MemoryTagStore()
|
||||
tag = await tag_store.create_tag(
|
||||
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
|
||||
)
|
||||
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
|
||||
|
||||
req = _request(_FakeQuestionStore(_rows()), tag_store)
|
||||
req.app.state.position_sync = _FakeSync({"q2"})
|
||||
resp = await rq_router.list_recommended_questions(req, all=False)
|
||||
|
||||
assert [q.id for q in resp.questions] == ["q2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_view_ignores_position_filter() -> None:
|
||||
"""``all=True`` 管理视图照旧返回全部(含打标),不受岗位过滤影响。"""
|
||||
tag_store = MemoryTagStore()
|
||||
tag = await tag_store.create_tag(
|
||||
{"id": "t1", "name": "理赔", "tag_type": "recommended_question"}
|
||||
)
|
||||
await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]])
|
||||
|
||||
req = _request(_FakeQuestionStore(_rows()), tag_store)
|
||||
req.app.state.position_sync = _FakeSync(None)
|
||||
resp = await rq_router.list_recommended_questions(req, all=True)
|
||||
|
||||
assert [q.id for q in resp.questions] == ["q1", "q2"]
|
||||
@@ -77,6 +77,12 @@ models:
|
||||
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
|
||||
@@ -331,7 +337,7 @@ summarization:
|
||||
# ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。
|
||||
task_deeplink:
|
||||
# 线上接口未通时用假数据;三个开关互相独立。
|
||||
test: true # 目的树查询走 mock.purpose_detail 假数据
|
||||
test: true # 目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检走 mock.sq_report 假数据
|
||||
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
|
||||
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
|
||||
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
|
||||
@@ -382,3 +388,15 @@ task_deeplink:
|
||||
actionName: ""
|
||||
actionStatus: 25
|
||||
narration: ""
|
||||
# test=true 时 rwfx 进入「3q 是否完成」预检(selectSqReport,外部不可用)走这份假数据。
|
||||
# 结构同真实 GET /consumer/cop/selectSqReport?taskId= 返回;前端判定 data 为空 / 每条
|
||||
# contentJson 都是 null/空 = 「3q 未完成」→ 弹框引导去 3qfx;否则视为已完成(正常,不弹框)。
|
||||
# 默认给一条有内容的数据 = 已完成(不打扰)。要测「3q 未完成」弹框:把下面 data 改成 []。
|
||||
sq_report:
|
||||
state: "200"
|
||||
msg: "操作成功!"
|
||||
data:
|
||||
- id: "sq-mock-1"
|
||||
taskId: 3338
|
||||
categoryType: "敌情关键事件 JSON"
|
||||
contentJson: "[{\"topic\":\"示例研判事件\",\"count\":1}]"
|
||||
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""开放问答接口测试脚本(流式版,Jupyter / 命令行均可)。
|
||||
|
||||
接口只返回「最终结果」:智能体把调研 / 工具调用全部跑完后,只把最后那段
|
||||
文本(markdown)放在 answer 里返回 —— 不含中间过程、工具调用、思考。
|
||||
|
||||
为什么默认用流式(/api/open/chat/stream):
|
||||
- 长任务(联网调研、出报告)同步阻塞那版整段没有下行字节,容易被前置 nginx 反代
|
||||
的 proxy_read_timeout(默认 60s)掐成 504。流式版每隔几秒就有心跳/增量下行,
|
||||
代理不会判定空闲超时,**多久都不会超时**。
|
||||
- 客户端只需读到 `event: result` 解析 answer 即可,中途 delta/progress/心跳都忽略。
|
||||
|
||||
用法:改下面「配置」段,然后整段运行;answer 变量就是最终 md 文本。
|
||||
无第三方依赖(纯标准库 urllib)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# ====================== 配置(按需修改这里) ======================
|
||||
BASE_URL = "http://47.94.209.59:2026" # 走 nginx 用 :2026;Gateway 直连用 :8001
|
||||
STREAM = True # True=流式(推荐,不超时);False=同步阻塞(短问题用)
|
||||
|
||||
MESSAGE = "帮我调研一下 赖清德,输出 markdown 报告。" # 要问的问题
|
||||
MODEL_NAME = "deepseek-ai/DeepSeek-V4-Flash" # 模型名(config.yaml models[].name);None=默认模型
|
||||
AGENT_NAME = "你好君" # 智能体中文名称;None=默认 lead agent。不确定填啥先跑 ping_agents()
|
||||
THINKING_ENABLED = False # False=关闭思考(SiliconFlow/内网模型也会被强制关掉);True=开启
|
||||
SHOW_LIVE = True # 流式时是否实时打印增量正文(看着它一点点出),只看结果可设 False
|
||||
READ_TIMEOUT = 120 # 单次 socket 读超时(秒)。服务端每 ~15s 发心跳,故 120s 绰绰有余
|
||||
TIMEOUT = 6000 # 同步(非流式)模式的整体超时(秒)
|
||||
# ===============================================================
|
||||
|
||||
|
||||
def open_chat(message, model_name=None, agent_name=None, thinking_enabled=False,
|
||||
base_url=BASE_URL, timeout=TIMEOUT):
|
||||
"""同步阻塞版:整段跑完才返回。短问题用;长任务请用 open_chat_stream。"""
|
||||
url = base_url.rstrip("/") + "/api/open/chat"
|
||||
payload = {
|
||||
"message": message,
|
||||
"model_name": model_name,
|
||||
"agent_name": agent_name, # 按中文名称选智能体
|
||||
"thinking_enabled": thinking_enabled,
|
||||
}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
return result["answer"] # 只返回最终结果
|
||||
|
||||
|
||||
def open_chat_stream(message, model_name=None, agent_name=None, thinking_enabled=False,
|
||||
base_url=BASE_URL, read_timeout=READ_TIMEOUT, show_live=SHOW_LIVE):
|
||||
"""流式版:读 SSE,边跑边收心跳/增量(不超时),最终从 event: result 取 answer。"""
|
||||
url = base_url.rstrip("/") + "/api/open/chat/stream"
|
||||
payload = {
|
||||
"message": message,
|
||||
"model_name": model_name,
|
||||
"agent_name": agent_name,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={"Content-Type": "application/json; charset=utf-8", "Accept": "text/event-stream"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
answer = None
|
||||
error = None
|
||||
event_name = None
|
||||
data_lines = []
|
||||
|
||||
with urllib.request.urlopen(req, timeout=read_timeout) as resp:
|
||||
for raw in resp: # 按行读取,服务端心跳让读取永不空闲超时
|
||||
line = raw.decode("utf-8").rstrip("\r\n")
|
||||
if line.startswith(":"): # SSE 注释行(心跳),忽略
|
||||
continue
|
||||
if line == "": # 空行 = 一个事件结束,分发
|
||||
if data_lines:
|
||||
payload_str = "\n".join(data_lines)
|
||||
try:
|
||||
obj = json.loads(payload_str)
|
||||
except json.JSONDecodeError:
|
||||
obj = {}
|
||||
if event_name == "result":
|
||||
answer = obj.get("answer")
|
||||
elif event_name == "error":
|
||||
error = obj.get("detail") or payload_str
|
||||
elif event_name == "delta" and show_live:
|
||||
print(obj.get("text", ""), end="", flush=True)
|
||||
event_name, data_lines = None, []
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_name = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].lstrip())
|
||||
|
||||
if error:
|
||||
raise RuntimeError(f"接口返回错误:{error}")
|
||||
if answer is None:
|
||||
raise RuntimeError("未收到 event: result(流可能被中途断开)。")
|
||||
return answer
|
||||
|
||||
|
||||
def ping_agents(base_url=BASE_URL, timeout=30):
|
||||
"""探活:GET /api/open/agents。秒回说明链路 OK,顺带看有哪些 agent_name 可填。"""
|
||||
with urllib.request.urlopen(base_url.rstrip("/") + "/api/open/agents", timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
# ====================== 调用 ======================
|
||||
if __name__ == "__main__":
|
||||
# 先探活(失败说明是链路/部署问题,而非模型问题)
|
||||
try:
|
||||
info = ping_agents()
|
||||
print(f"[探活OK] 可用智能体 {info.get('count', 0)} 个\n")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[探活失败] {exc!r} —— 先排查 BASE_URL / 端口 / 服务是否在跑\n")
|
||||
|
||||
_t0 = time.time()
|
||||
if STREAM:
|
||||
answer = open_chat_stream(MESSAGE, MODEL_NAME, AGENT_NAME, THINKING_ENABLED)
|
||||
else:
|
||||
answer = open_chat(MESSAGE, MODEL_NAME, AGENT_NAME, THINKING_ENABLED)
|
||||
print(f"\n\n(耗时 {time.time() - _t0:.1f}s)\n")
|
||||
print(answer) # answer 就是最终 markdown 文本
|
||||
|
||||
# ============ (可选)Jupyter 里渲染 markdown ============
|
||||
# from IPython.display import Markdown, display
|
||||
# display(Markdown(answer))
|
||||
|
||||
# ============ (可选)存成 .md ============
|
||||
# with open("result.md", "w", encoding="utf-8") as f:
|
||||
# f.write(answer)
|
||||
Reference in new issue
Block a user