This commit is contained in:
Rocky Kwok committed 2026-06-23 09:21:01 +08:00
commit 1cbc0e2dd6
68 files changed
+4438 -379

No files matched your search

+6
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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,
},
};
}
+12 -4
View File
@@ -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 ?? []) : [];
+26
View File
@@ -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;
};
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
+50 -19
View File
@@ -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>
);
+5 -3
View File
@@ -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>
+8
View File
@@ -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
+58 -5
View File
@@ -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>
);
}
+11
View File
@@ -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>
+10 -91
View File
@@ -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,
});
}
@@ -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;
+70 -6
View File
@@ -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
@@ -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)
@@ -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",
@@ -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
@@ -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",
@@ -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} 个章节"},
],
}
@@ -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,
@@ -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
@@ -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 — 诊断日志写失败绝不打断会商
@@ -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,
}
@@ -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,
}
@@ -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"]
+19 -1
View File
@@ -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}]"
+140
View File
@@ -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)