Compare commits

..
1 Commits
Author SHA1 Message Date
zjy a206a43063 'feat:智能体技能折叠、多智能体规划弹框展示详情' 2026-06-08 15:08:48 +08:00
1865 changed files with 159783 additions and 322298 deletions

No files matched your search

+1 -2
View File
@@ -4,8 +4,7 @@
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })",
"Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)"
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
]
}
}
-35
View File
@@ -1,35 +0,0 @@
# Never send source-control history, local dependencies, runtime state, or
# credentials to the Docker daemon. The offline images are built from the
# repository root by deploy/1panel-offline/Dockerfile.*.
.git
.runtime
.history
tmp
dist
# Credentials and environment-specific configuration are supplied at runtime
# through the offline delivery package, never copied into an image layer.
**/.env
**/.env.*
offline-backend-20260512/backend/.deer-flow
offline-backend-20260512/backend/logs
offline-backend-20260512/backend/docker-data
offline-backend-20260512/backend/dist
offline-backend-20260512/backend/wheelhouse
offline-backend-20260512/backend/offline-wheels
offline-backend-20260512/backend/.venv
offline-backend-20260512/backend/.pytest_cache
offline-backend-20260512/backend/.ruff_cache
# Reproducibly install frontend dependencies inside the image instead.
frontend-web/node_modules
frontend-web/dist
frontend-web/.vite
frontend-web/.offline-pack-build
frontend-web/.offline-extract-test
frontend-web/node_modules-offline-*.tar.gz
# Editor and operating-system metadata.
**/__pycache__
**/*.py[cod]
**/.DS_Store
+7 -23
View File
@@ -1,7 +1,7 @@
# Workspace/editor
.vscode/
frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
@@ -81,25 +81,18 @@ skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# MAF roundtable tests must be tracked (excepted from the broad tests/* ignore above)
!offline-backend-20260512/backend/tests/test_maf_*.py
!offline-backend-20260512/backend/tests/maf_test_helpers.py
deploy
.history
# Cloned third-party repos (local only)
git-clone/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
frontend-web-node_modules-offline-*.tar.gz
.codegraph
codegraph.mdc
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
@@ -108,12 +101,3 @@ 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
@@ -1,122 +0,0 @@
# MAF 多智能体会商 Phase-4 前端实施计划
## 决策回顾(已与用户确认)
- **入口**:独立顶级菜单 `/page/maf/*`,新增侧边栏一级项「MAF 会商」
- **实时更新**:纯前端轮询,`GET /runs/{id}/events?after_seq=N&limit=1000`,间隔 1.8s,复用现有后端,零 SSE、零后端改动
- **流程图**:要。基于 `confirmed_facts`(来自 `run.maf_checkpoint.fact_ledger`)+ 末轮 `final_result.content` 解析的 `flow-json`,复用 `@xyflow/react` + `flow-graph.ts` + `FlowGraphPanel`
- **审批**:卡片式,仿 `IntentClarificationCard` 的视觉/交互模式(参数摘要 + 批准/拒绝按钮 + 备注 textarea),落到 `POST /approvals/{id}/decision`
## 关键数据契约(已核实后端源码)
- **后端只读**:16 个端点全部命中,无改动。`GET /health` 返回 `enabled` 决定页面内容(入口仍可见,未启用时显示空态)。
- **事件流**:单调 `(run_id, seq)`,`GET /events?after_seq=N` 返回 `{events:[...], latest_seq}`;前端推进 `after_seq=latest_seq`。payload 已在后端**写入时脱敏**(`sanitize_payload`),前端直接渲染。
- **事件分类**(`event_normalizer.py`):`run.started/completed/failed/cancelled`、`agent.turn_started/message.delta/message.completed`、`tool.requested/started/completed/failed`、`skill.started/completed/failed/selected`、`artifact.created`、`action.waiting_approval/approved/rejected`、`workflow.checkpointed`。
- **事实账本**(`fact_ledger.empty_ledger`):`candidate_facts`/`confirmed_facts`/`evidence`/`tool_results`/`executed_actions`/`pending_actions`/`failed_or_unknown_actions`/`conflicts`。`confirmed_facts` 条目含 `{candidate_id, text, agent_id, confirmed_by, evidence_refs[]}`。
- **Run 字段**(`run_to_dict`):`id, session_id, status, maf_checkpoint(含 fact_ledger + final_result + seat_outcomes), last_event_seq, started_at, finished_at, error_code, error_message_safe, token_usage, latency_ms`。
- **终态判定**:`runIsLive(status) = ["pending","running","waiting_approval","cancelling","cancel_failed"].includes(status)`,非 live 即停轮询。
- **Action/Approval**:`GET /runs/{id}/actions`、`GET /runs/{id}/approvals`;`POST /approvals/{id}/decision` `{decision:"approved"|"rejected"}`。
## 复用清单(已定位到文件:行)
- `apiFetch`(`core/api/fetch-client.ts`):内部自动注入 `Authorization`(`getAuthorizationHeaderValue()`)+ `credentials:"include"`,**返回 Response 不解析、不拼 URL**。调用方拼 `${getBackendBaseURL()}/api/maf-roundtable/...`,自行 `res.ok` 检查 + `res.json()`。
- `getBackendBaseURL`(`core/config/index.ts`):未设时返回 `""`,相对路径同源可用。
- TanStack Query 模式(`strategy-components/api/task-buttons.ts`):`useQuery`+`refetchOnWindowFocus:false`;`useMutation`+`onSuccess invalidate`;queryKey 用嵌套数组 `["maf-roundtable", runId, "events"]`。
- 轮询模式(`core/scheduled-tasks/hooks.ts:105-115`):函数式 `refetchInterval: (query) => runIsLive(query.state.data?.status) ? ms : false`。
- `FlowGraphPanel`(`roundtable-planning/components/FlowGraphPanel.tsx`):props `{data: FlowGraphData, streaming, onClose, ...}`,自动 `useTheme()` 取明暗。`FlowGraphData = {title, nodes:[{id,label,type,detail?,group?}], edges:[{from,to,label?}]}`。`extractFlowGraph(text)`(`flow-graph.ts:263`)容错解析 ```flow-json``` 代码块(含半截 JSON)。
- `IntentClarificationCard`(`roundtable-planning/components/IntentClarificationCard.tsx`):卡片视觉模板(`rounded-xl border-red-100` + 「需要确认」徽标 + 选项按钮 + textarea + 提交)。
- 时间线 feed(`roundtable-planning/components/Step1Panel.tsx` + `MessageBubble` + `useAutoScroll` + `ScrollToBottomButton`):数组入、按 seq 序渲染。
- `useDesensitizeSubtree`(`roundtable-planning/lib/use-desensitize-subtree.ts`):仅挂 header/面板框架;时间线 body 不挂(payload 已脱敏 + hook 自身警告勿用于消息列表)。
- 路由(`pages/PageRoutes.tsx:143-169`):`<Route path="maf/*" element={<MafRoutes/>}/>`(仿 `RoundtableRoutes.tsx`)。
- 菜单(`core/page-layout/sidebar-menu.ts`):在 `sidebarMenuItems` 数组加一级项,`icon` 用 lucide 组件(非字符串 key)。新节点经 `use-sidebar-menu.ts` 自动走 static→inject→override→role→desensitize 流水线,无需额外注册。
## 文件夹结构(镜像 `roundtable-planning/`,全新 `src/maf-roundtable/`)
```
frontend-web/src/maf-roundtable/
├── api/
│ ├── types.ts # TS 类型(snake_case 直传,后端即 snake_case)
│ ├── client.ts # BASE()=getBackendBaseURL()+"/api/maf-roundtable";apiFetch 封装 + readJson<T>/parseDetail
│ ├── sessions.ts # 会话 CRUD hooks(list/get/create/update/delete)
│ ├── runs.ts # run hooks(get/list/create/cancel/resume/recover)+ useMafHealth
│ ├── events.ts # listEvents(runId, afterSeq) 原始 fetch(供轮询 hook 用)
│ └── approvals.ts # 审批 hooks(list + decide mutation)
├── hooks/
│ ├── use-maf-run-status.ts # GET /runs/{id} useQuery,函数式 refetchInterval 停终态
│ └── use-maf-events.ts # ★ cursor 轮询 hook(核心)
├── lib/
│ ├── event-timeline.ts # 纯函数:events[] → TimelineEntry[](delta 聚合、tool 生命周期折叠、approval 锚点)
│ └── fact-flow-graph.ts # 纯函数:confirmed_facts+evidence → FlowGraphData
├── components/
│ ├── MafRunHeader.tsx # 状态徽标 + token/延迟 + 取消/恢复/重试
│ ├── MafEventTimeline.tsx # 时间线 feed(MessageBubble + useAutoScroll)
│ ├── MafSeatStatusPanel.tsx # 席位状态矩阵 + execution_stage 徽标
│ ├── MafFactFlowPanel.tsx # 包一层 FlowGraphPanel,数据来自 fact-flow-graph
│ ├── MafApprovalCard.tsx # 卡片式审批(批准/拒绝 + 备注)
│ └── MafHealthGate.tsx # health.enabled=false → 空态
├── pages/
│ ├── MafSessionsPage.tsx # 会话列表 + 新建
│ └── MafRunDetailPage.tsx # 单 run 工作区(三栏)
└── index.ts
```
## 核心:cursor 轮询 hook(`hooks/use-maf-events.ts`)
不用纯 `useQuery+refetchInterval`:cursor `after_seq` 需每次推进,queryFn 不持有「上次 latest_seq」,塞 queryKey 会抖动。**用自定义 hook**(结合 scheduled-tasks 的 refetchInterval 思路 + roundtable-planning 的 reader-loop 家族):
```ts
export function useMafEvents(runId?: string, active=false) {
const [events, setEvents] = useState<MafEvent[]>([]);
const afterSeqRef = useRef(0); // 避免 setInterval 闭包旧值
useEffect(() => {
if (!runId || !active) return;
let cancelled = false;
const poll = async () => {
const res = await listEvents(runId, afterSeqRef.current);
if (cancelled || !res.events.length) return;
setEvents(prev => mergeBySeq(prev, res.events)); // seq 去重
afterSeqRef.current = res.latest_seq;
};
poll(); // 进入即补拉(after_seq=0 全量历史)
const id = setInterval(poll, 1800);
return () => { cancelled = true; clearInterval(id); };
}, [runId, active]);
return events;
}
```
- **补拉历史**:进入页面 `after_seq=0` 立即拉全部已落库事件 → 天然支持断线补拉/刷新恢复。
- **终态停止**:`active` 由 `useMafRunStatus` 的 `runIsLive(status)` 传入,终态时 effect 清理 interval。
- **去重**:后端 `(run_id,seq)` + `source_event_id` 双重去重,前端再按 `seq` 做 `mergeBySeq`,幂等。
- **切换 run**:`runId` 变 → effect 重置(events 清空、afterSeq 归零)。
## 流程图数据流(`lib/fact-flow-graph.ts`)
两源按优先级合并 → `FlowGraphData`:
1. **`confirmed_facts`(主)**:每条已确认事实 → `task` 节点(label=text 截断、detail=全文、group=confirmed_by);`evidence_refs` 连到 `evidence`(→ `role`)或 `tool_results`(`tool:{name}:{turn}` → `deliverable`)节点;证据根用 `start`。
2. **`final_result.content` 的 ```flow-json```(次)**:用现成 `extractFlowGraph(text)` 解析叠加为「结论流程图」。
输出喂 `<FlowGraphPanel data={...} streaming={!terminal}/>`(非终态蚂蚁线动)。两源皆空 → 空态「尚无可绘制事实」。
## 页面布局(`MafRunDetailPage`,三栏 / 窄屏堆叠)
- **左:时间线**(`MafEventTimeline`)—— `event-timeline.ts` 折叠成可读条目(delta 聚合气泡、tool 折叠「调用 X→完成」、action.waiting_approval 插锚点),复用 MessageBubble + useAutoScroll + ScrollToBottomButton。
- **中上:席位状态**(`MafSeatStatusPanel`)—— `maf_checkpoint.seat_outcomes` 的 agent × status 矩阵 + `execution_stage` 徽标。
- **中下:事实/结论流程图**(`MafFactFlowPanel`)—— 折叠面板,默认展开。
- **右:审批卡片**(`MafApprovalCard` 列表)—— 仅 pending 时显示;含工具名、参数摘要(脱敏)、批准/拒绝按钮 + 备注 textarea → `POST /approvals/{id}/decision`,成功后 invalidate `["maf-roundtable","approvals",runId]`,事件轮询自然看到 `action.approved/rejected`。
## 路由 + 菜单接入(3 处)
1. **新建** `pages/MafRoutes.tsx`(仿 `RoundtableRoutes.tsx`):`index→MafSessionsPage`、`runs/:runId→MafRunDetailPage`、`*→重定向`。
2. **改** `pages/PageRoutes.tsx`:加 `import MafRoutes` + `<Route path="maf/*" element={<MafRoutes/>}/>`(插 `notebook` 后)。
3. **改** `core/page-layout/sidebar-menu.ts`:数组末尾加 `{ id:"maf-center", label:"MAF 会商", icon:Network, children:[{id:"maf-roundtable", label:"会商列表", icon:MessageSquareText, path:"/page/maf"}] }`。**不加 `adminOnly`**(全员可见;`/health.enabled=false` 由 `MafHealthGate` 客户端控制页面空态)。
## 实施顺序
1. `api/types.ts` + `api/client.ts`(地基)
2. `api/{sessions,runs,events,approvals}.ts`(端点 hook)
3. `hooks/{use-maf-run-status,use-maf-events}.ts`(轮询核心)
4. `lib/{event-timeline,fact-flow-graph}.ts`(纯函数)
5. `components/*`(6 个组件)
6. `pages/*`(2 个页面)+ `index.ts`
7. `pages/MafRoutes.tsx` + 接入 `PageRoutes.tsx` + `sidebar-menu.ts`
8. `pnpm typecheck` + 手动核对路由/菜单显隐
## 文件清单
- **新建**(14):`maf-roundtable/api/{types,client,sessions,runs,events,approvals}.ts`、`maf-roundtable/hooks/{use-maf-run-status,use-maf-events}.ts`、`maf-roundtable/lib/{event-timeline,fact-flow-graph}.ts`、`maf-roundtable/components/{MafRunHeader,MafEventTimeline,MafSeatStatusPanel,MafFactFlowPanel,MafApprovalCard,MafHealthGate}.tsx`、`maf-roundtable/pages/{MafSessionsPage,MafRunDetailPage}.tsx`、`maf-roundtable/index.ts`、`pages/MafRoutes.tsx`
- **改动**(2):`pages/PageRoutes.tsx`、`core/page-layout/sidebar-menu.ts`
- **后端**:无改动
## 验证
- `pnpm typecheck`(tsc --noEmit)通过
- 手动:菜单「MAF 会商」→ 列表 → 新建会话 → 启动 run → 时间线滚动 → 席位状态更新 → 流程图随 confirmed_facts 增长 → 审批可批准 → 终态停轮询
- 退路:`/health.enabled=false` → 入口可见但页面显示未启用提示;旧 `roundtable-planning` 零改动
-103
View File
@@ -1,103 +0,0 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects:
- `offline-backend-20260512/backend/` — Python backend (FastAPI Gateway + LangGraph agent runtime)
- `frontend-web/` — Vite + React 19 frontend (TypeScript, pnpm)
Managed together via shell scripts in `scripts/`.
## Commands
### Full Application (from repo root)
```bash
./scripts/start-all.sh # Start backend + frontend as background daemons
./scripts/stop-all.sh # Stop both
./scripts/restart-all.sh # Restart both
./scripts/status.sh # Check running status
```
Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`.
### Backend Only (from `offline-backend-20260512/backend/`)
```bash
make install # Install Python dependencies (uv)
make dev # Run Gateway API with hot-reload on port 8001
make gateway # Run Gateway API without reload
make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v
make lint # Lint with ruff
make format # Format with ruff
```
Run a single test file:
```bash
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
```
### Frontend Only (from `frontend-web/`)
```bash
pnpm install # Install dependencies
pnpm dev # Start Vite dev server on port 5174
pnpm build # Production build
pnpm typecheck # TypeScript type checking (tsc --noEmit)
```
## Service Ports
| Service | Port | Notes |
|---------|------|-------|
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
| Frontend (Vite) | 5174 | React SPA |
Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`).
## Architecture
### Backend
The backend (`offline-backend-20260512/backend/`) is split into two layers:
- **`packages/harness/deerflow/`** — publishable agent framework (`deerflow.*` imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. **Never imports from `app.*`.**
- **`app/`** — application layer (`app.*` imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk).
The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection.
**Middleware order matters** — middlewares are appended in a fixed sequence; `ClarificationMiddleware` must always be last. See `offline-backend-20260512/backend/AGENTS.md` for the full ordered list.
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
### Frontend
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (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). **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
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory.
## Detailed Backend Reference
The backend has its own comprehensive documentation at:
- `offline-backend-20260512/backend/AGENTS.md` — architecture, middleware chain, all API routes, config schema, sandbox/memory/subagent systems
- `offline-backend-20260512/backend/README.md` — setup guide, technology stack
+1 -14
View File
@@ -78,24 +78,11 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (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).
- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`.
- **敏感词管理 (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, openMode, loginParams, appendAuth, authTokenParam, authNameParam, 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). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=<JWT>&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`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. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三Line truncated
- **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」.
- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error.
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks)
- **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). **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`。(2.5) **`?hideChrome=1`** — **仅**去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权(区别于 `embed`:只想要一个干净全屏页面,如「大屏绘制」`/page/strategy/dashboard-agent?taskId=…&hideChrome=1`)。新增 `EmbedSession.hideChrome` 字段 + `readHideChromeParam()` + `isChromeHidden()`(取 `embed || hideChrome` 之并),与 `hideJump` 完全对称;`PageRoutes.tsx` 的 `PageLayout` 把 `chromeHidden`(`isChromeHidden() || readHideChromeParam(location.search)==="on"`)并入 `pageSidebarAllowed`,且其**优先级高于** dashboard-agent 路由原本的强制全局 chrome(`forceChromeForDashboardAgent`)。`hideChrome=0` 可显式关闭。(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 提示。
- **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「CM助手-全量-…」(**禁内部框架名**)。埋点**广覆盖**各 API client 的成功路径——原则是「一个用户操作(查看/查询/新增/编辑/删除/启停/导入导出)= 一条日志」,每个 API client 的**单条详情 GET(查看→view 4)/ 列表查询 GET(search 3)/ 新增 POST(create 9)/ 改 PUT·PATCH(edit 5)/ 删 DELETE(delete 6)/ 启停(enabled 11 / disabled 10)/ 导入导出(import 7 / export 8)**都各落一条,仅跳过高频 typeahead/状态轮询/纯 bootstrap config 读。覆盖文件:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps,business-mapping}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs,recommend,chains,multi-agent,dashboard-sessions}.ts`、`core/{agents,scheduled-tasks,knowledge,positions,tags,studio,system-settings,memory,mcp,models,custom-prompts,user-preferences,notifications,recommended-questions,user-qa,curator,admin,thread-shares,roundtable-draft-shares,tool-metrics,llm-metrics,note,artifacts}/api.ts`、`core/api/markdown-docx-export.ts`。**问答(Q&A)改为「一次大模型调用 = 一条日志」**:在共享的 `core/threads/hooks.ts` `useThreadStream` 的 `onLangChainEvent` 里监听 `on_chat_model_end`,每次模型调用结束就上报一条(optType 12,`reqParam` 取本次 input 的最后一条 user 文本 + 模型名,`returnParam` 取本次输出文本)——一个问答轮次里 lead agent / 子代理的**每次** LLM 调用都各记一条,覆盖所有走 `useThreadStream` 的页面(`ChatPage`/`AgentChatPage`/创建智能体/iframe/会商等);`ChatPage`/`AgentChatPage` 的 `onFinish` 仍额外保留一条轮次汇总。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。
### Configuration
Submodule _skills_src/ai-skills deleted from cde1c8385e.
Submodule _skills_src/anthropics-skills deleted from 5754626092.
Submodule _skills_src/awesome-claude-skills deleted from 92568c1eda.
Submodule _skills_src/ce-kit deleted from d4973c4827.
Submodule _skills_src/csm deleted from 3fa16a94e0.
Submodule _skills_src/superpowers deleted from 6fd4507659.
Submodule _skills_src/tapestry-skills deleted from 80e1dc56df.
-33
View File
@@ -1,33 +0,0 @@
# 批量人物蒸馏 → llmwiki
一个**完全独立的单文件脚本 + 内置定时器**:读 Excel 名单,串行调用线上 DeerFlow 服务问答接口,
复用「女娲 / huashu-nuwa」技能逐个蒸馏人物,**由脚本抓取回复并保存**成 llmwiki 风格 markdown。
- **不联网**:只用已配置的知识库查询技能(`knowledge-search-v2`)检索资料。
- **定时执行**:每天 **23:00 ~ 次日 08:00** 自动跑,到点暂停、次日继续。
- **md 由 py 保存**:agent 只产出内容,脚本抓最终回复写成 `.md`(不让项目自己写文件)。
## 你只需要做两件事
1. **改一行地址**:打开 `distill_people.py`,把顶部 `BASE_URL = "..."` 改成你的线上服务地址。
2. **放名单**:同目录放 `people.xlsx`(或 `people.csv`),**第一列 = 人名**,其余列=可选提示。
其余(用户名 `distiller`、口令 `123ewq`、技能名、时间窗)都已内置,不用填。
## 运行
```bash
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行(推荐挂后台)
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
```
读 `.xlsx` 需要 `openpyxl`;用 `.csv` 则零额外依赖。
## 输出(都在 output/ 一个文件夹)
- `<人名>.md` —— 每个人的 llmwiki 档案
- `manifest.json` —— 执行清单(状态/耗时/run_id)
- `index.md` —— 可读清单表(谁完成/失败,点开看 md)
**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。
-563
View File
@@ -1,563 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量人物蒸馏 → llmwiki markdown(完全独立的单文件客户端 + 内置定时器)
特点
----
- 串行(一个一个来)调用正在运行的 DeerFlow 服务问答接口
- 复用线上「女娲 / huashu-nuwa」技能做提炼;**不联网**,只用已配置的
知识库查询技能(knowledge-search-v2)检索资料
- agent 只负责产出内容,**markdown 由本脚本抓取最终回复后自己保存**
- 内置时间窗:每天 23:00 ~ 次日 08:00 才执行,到点自动暂停、次日继续
- 断点续跑:已完成的人自动跳过;中途加名单也会被带上
你只需要改一处:下面的 BASE_URL(线上服务地址)。其余都不用填。
名单
----
脚本同目录放 people.xlsx 或 people.csv,**第一列 = 人名**,其余列=可选提示。
运行
----
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime, time as dtime
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("缺少依赖 requests:pip install requests")
# ════════════════════════════════════════════════════════════════════════
# 只改这一行:你的线上服务地址(不带结尾斜杠)
# ════════════════════════════════════════════════════════════════════════
BASE_URL = "http://47.94.209.59:2026/"
# ════════════════════════════════════════════════════════════════════════
# ---- 以下都已内置,正常无需改动 ----
USERNAME = "distiller" # 免密自动注册
PASSWORD = "123ewq" # 共享口令(你们开了口令门)
NUWA_SKILL = "huashu-nuwa" # 女娲技能
KB_SKILL = "knowledge-search-v2" # 知识库查询技能(不联网,用它检索)
DEPTH = "full" # full 完整 / fast 精简
HERE = Path(__file__).resolve().parent
OUTPUT_DIR = HERE / "output"
PEOPLE_FILE_DEFAULT = HERE / "people.example.csv"
# 执行时间窗:23:00 ~ 次日 08:00(跨午夜)
WIN_START = dtime(23, 0)
WIN_END = dtime(8, 0)
PER_PERSON_TIMEOUT = 1800 # 单人最长等待(秒)
POLL_INTERVAL = 5 # 轮询间隔(秒)
HTTP_TIMEOUT = 60
TERMINAL_STATUS = {"success", "error", "timeout", "interrupted"}
# ================================ llmwiki 模板 ================================
LLMWIKI_TEMPLATE = """\
---
name: <中文名/常用名>
aliases: [<别名/英文名...>]
type: person
era: <在世 | 历史人物>
domains: [<领域1>, <领域2>]
distilled_at: <YYYY-MM-DD>
sources_count: <检索到的来源条数>
primary_source_ratio: <一手来源占比, 0~1 的小数>
confidence: <high | medium | low>
---
# <人物名>
> **一句话定位**:<此人看世界的独特方式,一句话>
## 身份卡
- **活跃年代**:
- **核心身份**:
- **自述(50字, 以其语气)**:
## 核心观点(真信念 · 反复出现的主张)
1. ...
## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源)
### 1. <模型名>
- **一句话**:
- **证据场景**:
- **应用方式**:
- **局限**:
- **来源**:
## 决策启发式(5–10 条 · 如果 X 则 Y,带案例)
- 若 <X> → <Y>
## 表达 DNA
| 维度 | 特征 |
|------|------|
| 句式 | |
| 高频词 | |
| 幽默 | |
| 确定性 | |
| 引用习惯 | |
## 价值观与反模式
- **价值观排序**:
- **明确反对**:
## 内在张力(≥2 对矛盾)
- <A> ↔ <B>
## 经历时间线
| 年份 | 事件 | 思想意义 |
|------|------|---------|
| | | |
## 代表语录
- “...” —(出处/年份,标一手/二手)
## 智识谱系
- **受影响于**:
- **影响了**:
## 外部评价与争议
-
## 诚实边界
- <至少 3 条具体局限,含信息截止日期>
## 调研来源
**一手(N)**:
**二手(N)**:
"""
def build_prompt(name: str, hints: str, depth: str) -> str:
depth_note = (
"本次走【完整模式】:尽量覆盖著作/对话/表达/他者评价/决策/时间线 6 个维度。"
if depth == "full"
else "本次走【精简模式】:重点覆盖核心观点、心智模型、时间线三块即可。"
)
hint_block = f"\n补充提示(来自名单其它列,可作为检索关键词):{hints}\n" if hints.strip() else ""
return f"""\
你的任务:把人物【{name}】蒸馏成一份 llmwiki 风格的人物档案 markdown。
请复用本服务中已启用的「女娲 / {NUWA_SKILL}」技能的提炼方法论
(心智模型三重验证、表达DNA分析、诚实边界)。
⚠️ 本环境**无法联网,严禁使用 WebSearch 等任何联网工具**。
所有资料**只能通过已配置的知识库查询技能「{KB_SKILL}」检索获取**。
请充分调用该知识库技能,围绕该人物多角度检索(生平、观点、著作、语录、决策、评价等),
基于检索到的内容做提炼。知识库里查不到的部分,如实在「诚实边界」标注,不要编造。
{depth_note}
{hint_block}
输出要求:
1. **不要写任何文件,不要调用 write_file / present_files**。
2. 直接在你的回复正文里输出**完整中文 markdown 全文**,严格按下面模板结构
(保留各级标题,逐项填实,不要保留尖括号占位符),**不要任何额外解说、前言或结语**:
------------------ 模板开始 ------------------
{LLMWIKI_TEMPLATE}
------------------ 模板结束 ------------------
现在开始,独立完成,不要向我提问。"""
# ================================ HTTP 客户端 ================================
class DeerFlowClient:
def __init__(self, base_url: str):
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.token: str | None = None
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}" # 只用 Bearer,不带 cookie → 跳过 CSRF
return h
def login(self) -> bool:
url = f"{self.base}/api/v1/auth/login/username"
params = {"password": PASSWORD} if PASSWORD else None
try:
r = self.s.post(url, params=params, json={"username": USERNAME}, timeout=HTTP_TIMEOUT)
except requests.RequestException as e:
print(f" [登录] 连接失败:{e}")
return False
if r.status_code == 200:
self.token = r.json().get("access_token")
self.s.cookies.clear()
print(f" [登录] 成功,用户={USERNAME}")
return True
print(f" [登录] 返回 {r.status_code}:{r.text[:200]}")
return False
def ensure_skill_enabled(self, name: str) -> None:
try:
r = self.s.get(f"{self.base}/api/skills", headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
skills = r.json().get("skills", [])
hit = next((x for x in skills if x.get("name") == name), None)
if hit is None:
print(f" [技能] 警告:服务里没找到 '{name}'")
return
if hit.get("enabled"):
print(f" [技能] '{name}' 已启用")
return
pr = self.s.put(f"{self.base}/api/skills/{name}", headers=self._headers(),
json={"enabled": True}, timeout=HTTP_TIMEOUT)
print(f" [技能] 启用 '{name}' → {pr.status_code}")
except requests.RequestException as e:
print(f" [技能] 检查 '{name}' 失败(忽略):{e}")
def create_thread(self) -> str:
r = self.s.post(f"{self.base}/api/threads", headers=self._headers(), json={}, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["thread_id"]
def start_run(self, thread_id: str, prompt: str) -> str:
body = {
"input": {"messages": [{"role": "user", "content": prompt}]},
"excluded_tools": ["ask_clarification"], # 禁澄清,无人值守不卡住
}
r = self.s.post(f"{self.base}/api/threads/{thread_id}/runs",
headers=self._headers(), json=body, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["run_id"]
def poll_run(self, thread_id: str, run_id: str) -> str:
deadline = time.monotonic() + PER_PERSON_TIMEOUT
last = ""
while time.monotonic() < deadline:
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
st = r.json().get("status", "")
if st != last:
print(f" ...状态:{st}")
last = st
if st in TERMINAL_STATUS:
return st
except requests.RequestException as e:
print(f" 轮询出错(重试):{e}")
time.sleep(POLL_INTERVAL)
return "timeout"
def fetch_result_text(self, thread_id: str, run_id: str) -> str | None:
"""抓取该 thread 的最终 AI 回复文本(由 py 保存为 md)。
注意:本部署的 `/runs/{rid}/messages`、`/messages`、`/events` 端点都返回空
(事件库按登录用户过滤,后台 worker 写入时未带 user_id → 读不到)。
可靠来源是 LangGraph 检查点状态 `/api/threads/{tid}/state` → values.messages,
其中每条消息形如 {"type": "human"|"ai"|"tool", "content": <str | 内容块列表>, ...}。
取最后一条有正文的 ai 消息即可。
"""
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/state",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code != 200:
print(f" 取回复返回 {r.status_code}:{r.text[:200]}")
return None
messages = (r.json().get("values") or {}).get("messages") or []
# 从后往前找最后一条「有正文」的 AI 回复(跳过只含工具调用的中间消息)
for msg in reversed(messages):
if not isinstance(msg, dict) or msg.get("type") != "ai":
continue
txt = _extract_text(msg)
if txt and txt.strip():
return txt
except requests.RequestException as e:
print(f" 取回复出错:{e}")
return None
def _extract_text(payload) -> str:
"""从一条消息的 content 里提取纯文本。
payload 可能是:
- LangChain 消息的 model_dump 字典(正文在 payload["content"])
- 直接的字符串
- 内容块列表([{"type":"text","text":"..."}, ...])
"""
if payload is None:
return ""
# 若是整条消息的 model_dump,正文在其 content 字段
if isinstance(payload, dict):
c = payload.get("content", payload.get("text", ""))
else:
c = payload
if isinstance(c, str):
return c
if isinstance(c, list):
parts = []
for b in c:
if isinstance(b, dict):
# 只取文本块,跳过 tool_use / thinking 等非文本块
if b.get("type") in (None, "text", "text_block"):
parts.append(b.get("text") or b.get("content") or "")
elif b.get("text"):
parts.append(b["text"])
elif isinstance(b, str):
parts.append(b)
return "\n".join(p for p in parts if p)
return str(c) if c else ""
def _clean_markdown(text: str) -> str:
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏,并规整 YAML frontmatter。"""
t = text.strip()
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
if m:
t = m.group(1).strip()
return _normalize_frontmatter(t)
def _normalize_frontmatter(text: str) -> str:
"""修复模型常见的 frontmatter 跑偏,让 YAML 能正常解析。
模型经常把字段名写成 markdown 加粗(``**name:**``)、加空行、留行尾双空格,
这些都会让 frontmatter 解析失败。这里把开头 ``---`` 到下一个 ``---`` 之间的内容
规整为干净的 ``key: value`` 形式。正文(第二个 ``---`` 之后)保持原样。
"""
if not text.startswith("---"):
return text
# 拆出 frontmatter 块:开头 --- 与下一行单独的 --- 之间
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n?(.*)$", text, re.S)
if not m:
return text
body_fm, rest = m.group(1), m.group(2)
out_lines = []
for line in body_fm.splitlines():
s = line.rstrip() # 去行尾双空格
s = s.replace("**", "") # 去 markdown 加粗标记
if not s.strip():
continue # 丢掉空行
out_lines.append(s)
return "---\n" + "\n".join(out_lines) + "\n---\n\n" + rest.lstrip("\n")
# ================================ 名单读取 ================================
def slugify(name: str) -> str:
s = re.sub(r'[\\/:*?"<>|]+', "", name).strip()
s = re.sub(r"\s+", "_", s)
return s or "unnamed"
def read_people(path: Path) -> list[dict]:
if not path.exists():
return []
rows: list[list[str]] = []
if path.suffix.lower() in (".xlsx", ".xlsm"):
try:
import openpyxl
except ImportError:
sys.exit("读取 .xlsx 需要 openpyxl:pip install openpyxl(或把名单存成 .csv)")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
for row in wb.active.iter_rows(values_only=True):
rows.append(["" if c is None else str(c).strip() for c in row])
elif path.suffix.lower() == ".csv":
with open(path, encoding="utf-8-sig", newline="") as f:
rows = [[c.strip() for c in r] for r in csv.reader(f)]
else:
sys.exit(f"不支持的名单格式:{path.suffix}(用 .xlsx 或 .csv)")
if rows and re.search(r"(姓名|名字|人物|name)", rows[0][0] if rows[0] else "", re.I):
rows = rows[1:]
people, seen = [], set()
for r in rows:
if not r or not r[0].strip():
continue
name = r[0].strip()
if name in seen:
continue
seen.add(name)
hints = " | ".join(x for x in r[1:] if x.strip())
people.append({"name": name, "hints": hints})
return people
# ================================ 时间窗 ================================
def in_window(now: datetime | None = None) -> bool:
now = now or datetime.now()
t = now.time()
return t >= WIN_START or t < WIN_END # 跨午夜
def seconds_until_window(now: datetime | None = None) -> int:
now = now or datetime.now()
if in_window(now):
return 0
target = now.replace(hour=WIN_START.hour, minute=WIN_START.minute, second=0, microsecond=0)
return max(1, int((target - now).total_seconds()))
def sleep_interruptible(total: int) -> None:
end = time.monotonic() + total
while time.monotonic() < end:
time.sleep(min(30, max(1, int(end - time.monotonic()))))
# ================================ 蒸馏单人 ================================
def distill_one(client: DeerFlowClient, person: dict, out_dir: Path,
manifest: dict, save_manifest, depth: str) -> None:
name, hints = person["name"], person["hints"]
slug = slugify(name)
md_path = out_dir / f"{slug}.md"
t0 = time.time()
rec = {"name": name, "slug": slug, "status": "running",
"started_at": _now(), "file": f"{slug}.md"}
manifest[name] = rec
save_manifest()
try:
tid = client.create_thread()
run_id = client.start_run(tid, build_prompt(name, hints, depth))
status = client.poll_run(tid, run_id)
rec.update(thread_id=tid, run_id=run_id, run_status=status)
text = client.fetch_result_text(tid, run_id) if status == "success" else None
if status == "success" and text:
md = _clean_markdown(text)
md_path.write_text(md, encoding="utf-8")
rec["status"] = "succeeded"
rec["chars"] = len(md)
print(f" ✅ 完成 → {md_path.name}({len(md)} 字,耗时 {int(time.time()-t0)}s)")
else:
rec["status"] = "failed"
rec["error"] = f"run={status}, text={'有' if text else '无'}"
if text:
(out_dir / f"{slug}.partial.md").write_text(text, encoding="utf-8")
print(f" ❌ 失败(run={status})")
except Exception as e:
rec["status"] = "failed"
rec["error"] = str(e)
print(f" ❌ 异常:{e}")
rec["finished_at"] = _now()
rec["seconds"] = int(time.time() - t0)
manifest[name] = rec
save_manifest()
# ================================ 主流程 ================================
def main():
ap = argparse.ArgumentParser(description="批量人物蒸馏 → llmwiki(定时窗 23:00~08:00)")
ap.add_argument("excel", nargs="?", default=str(PEOPLE_FILE_DEFAULT),
help="名单文件(.xlsx 或 .csv),默认同目录 people.xlsx")
ap.add_argument("--now", action="store_true", help="忽略时间窗,立刻跑完整批(测试用)")
ap.add_argument("--redo", action="store_true", help="忽略已完成,全部重做")
args = ap.parse_args()
excel = Path(args.excel)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
manifest_path = OUTPUT_DIR / "manifest.json"
manifest: dict = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
# 校验名单存在
if not read_people(excel):
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
# --redo:一次性清掉名单里这些人的旧记录,让他们重做一遍;
# 之后本次会话内跑成功的会正常计为「已完成」,循环才会往后推进
# (否则每轮 pending() 都把已完成的人当未完成,永远卡在第 1 个人)
if args.redo:
for p in read_people(excel):
manifest.pop(p["name"], None)
client = DeerFlowClient(BASE_URL)
if not client.login():
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
client.ensure_skill_enabled(NUWA_SKILL)
client.ensure_skill_enabled(KB_SKILL)
def save_manifest():
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index(OUTPUT_DIR, read_people(excel), manifest)
def pending() -> list[dict]:
people = read_people(excel)
out = []
for p in people:
r = manifest.get(p["name"], {})
done = r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
if not done:
out.append(p)
return out
print(f"启动 | 服务={BASE_URL} | 输出={OUTPUT_DIR}")
print(f"模式={'立即跑完' if args.now else '定时窗 23:00~08:00'} | 名单={excel}")
try:
while True:
todo = pending()
if not todo:
print(f"[{_now()}] 全部已完成,待命中(每小时复查名单)...")
if args.now:
break
sleep_interruptible(3600)
continue
if not args.now and not in_window():
wait = seconds_until_window()
nxt = datetime.now().replace(hour=WIN_START.hour, minute=0, second=0, microsecond=0)
print(f"[{_now()}] 不在执行窗口,剩 {len(todo)} 人待蒸;"
f"休眠到 {nxt.strftime('%H:%M')}(约 {wait//60} 分钟)...")
sleep_interruptible(wait)
continue
# 在窗口内(或 --now):处理一个人,然后回到循环重新判断窗口/名单
p = todo[0]
idx_total = len(read_people(excel))
done_n = sum(1 for v in manifest.values() if v.get("status") == "succeeded")
print(f"[{_now()}] ({done_n}/{idx_total} 已完成) 蒸馏:{p['name']}")
distill_one(client, p, OUTPUT_DIR, manifest, save_manifest, DEPTH)
except KeyboardInterrupt:
print("\n已手动停止。重跑脚本会从未完成处继续。")
ok = sum(1 for r in manifest.values() if r.get("status") == "succeeded")
fail = sum(1 for r in manifest.values() if r.get("status") == "failed")
print(f"\n结束:成功 {ok} / 失败 {fail}")
print(f"结果目录:{OUTPUT_DIR} | 清单:{manifest_path} | 索引:{OUTPUT_DIR/'index.md'}")
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _write_index(out_dir: Path, people: list[dict], manifest: dict) -> None:
lines = ["# 蒸馏执行清单", "", f"更新时间:{_now()}", "",
"| # | 人物 | 状态 | 字数 | 耗时 | 文件 |",
"|---|------|------|------|------|------|"]
badge = {"succeeded": "✅完成", "failed": "❌失败", "running": "⏳进行中"}
for i, p in enumerate(people, 1):
r = manifest.get(p["name"], {})
st = badge.get(r.get("status", ""), "⌛排队")
f = r.get("file", "")
link = f"[{f}]({f})" if r.get("status") == "succeeded" and f else "-"
lines.append(f"| {i} | {p['name']} | {st} | {r.get('chars','-')} | {r.get('seconds','-')} | {link} |")
(out_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
-4
View File
@@ -1,4 +0,0 @@
姓名,领域(可选提示),资料链接或备注(可选)
查理·芒格,投资/多元思维,
理查德·费曼,物理/教学,
纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/
1 姓名 领域(可选提示) 资料链接或备注(可选)
2 查理·芒格 投资/多元思维
3 理查德·费曼 物理/教学
4 纳瓦尔·拉维坎特 创业/杠杆 https://nav.al/
@@ -1,136 +0,0 @@
# zncm Web Clipper
这是给 zncm 使用的 Chrome 插件,安装后默认连接 `config.js` 中配置好的服务地址。
默认地址配置在 `browser-extension/deer-web-clipper/config.js`:
```js
globalThis.ZNCM_WEB_CLIPPER_CONFIG = Object.freeze({
defaultWebBaseUrl: "http://47.94.209.59:2026",
defaultApiBaseUrl: "http://47.94.209.59:2026",
});
```
如果部署地址变化,只需要修改 `config.js` 里的这两个字段,然后重新加载插件。
正常情况下不需要手动配置 URL,也不需要手动复制 token。
## 能做什么
1. 把当前浏览器标签页发送到 zncm,作为 Markdown 附件进入聊天。
2. 接收 zncm 聊天中由 `browser_fetch_page` 工具创建的网页抓取任务。
3. 插件在 Chrome 中打开目标网页,读取可见文本和表格,再把结果回传给 zncm。
4. 在普通网页右下角显示“zncm 页面问答”浮窗,直接把当前页面内容交给 zncm 问答。
5. 接收 zncm 的 `browser_act` 交互式操作任务:在 Chrome 中打开页面并按 zncm 的指令**操作页面**——点击、输入、滚动、等待、页内跳转,每一步把页面上的可交互元素清单和正文回传给 zncm,由 zncm 决定下一步(observe→act 循环)。
## 页面操作(browser_act)
除了只读抓取,zncm 还能驱动插件**实际操作网页**,完成登录、填表、翻页找数据等多步任务:
1. zncm 调用 `browser_act` 工具,先 `navigate` 打开页面。
2. 插件打开标签页(同一会话会复用这个标签页),扫描页面上的可交互元素,给每个元素编号后连同正文回传给 zncm。
3. zncm 根据元素编号决定下一步(点第 3 个按钮、在第 1 个输入框填字……),再次调用 `browser_act`。
4. 插件执行动作、重新扫描页面、回传新状态,如此循环,直到任务完成。
5. 任务结束时 zncm 用 `action="close"` 关闭该会话标签页。
支持的动作:`navigate`(打开/跳转 URL)、`click`(点击元素)、`type`(输入文字,可选回车提交)、`scroll`(滚动到元素或下翻一屏)、`wait`(等待某 CSS 选择器出现)、`extract`(重新读取正文)、`observe`(仅重新扫描)、`close`(关闭会话标签页)。
> 提示:页面操作能力较强(可点击任意按钮、提交表单),可在后端 `config.yaml` 用 `browser_context.actions_enabled: false` 关闭,只保留只读抓取。
## 后端启用
在 `offline-backend-20260512/backend/config.yaml` 中保持:
```yaml
browser_context:
enabled: true # 只读抓取 + 页面问答
actions_enabled: true # 交互式页面操作 browser_act(不需要可设为 false)
```
修改配置后需要重启后端。
## 安装插件
1. 打开 Chrome 的 `chrome://extensions`。
2. 开启右上角的“开发者模式”。
3. 点击“加载已解压的扩展程序”。
4. 选择目录:`browser-extension/deer-web-clipper`。
5. 安装后插件名会显示为 `zncm Web Clipper`。
## 安装即用
插件默认已经开启:
- 自动识别已打开 zncm 页面的服务地址。
- 自动同步已登录 zncm 的 token。
- 自动轮询 zncm 聊天触发的浏览器抓取任务。
- 页面问答框默认开启。
如果你打开的是 `config.js` 里配置的默认服务器,通常无需进入设置页。
如果部署地址变了,保持一个已登录的 zncm 页面打开,然后在插件设置里点击:
- `重新识别服务地址`
- `同步 token`
## 自动同步 Token
插件会自动从两个位置获取登录 token:
1. zncm API 域名下的 `access_token` Cookie,包括 HttpOnly Cookie。
2. 已登录 zncm 前端页面里的浏览器本地登录态。
切换登录账号后,插件后台每次轮询任务前都会重新同步 token;点击插件里的“同步 token”也可以立即刷新。
如果当前浏览器已经登出 zncm,且打开的 zncm 页面里没有 token,插件会清空旧 token,避免继续用上一个账号请求。
## 页面问答框
安装插件后,普通 `http` / `https` 页面右下角会出现 `AI` 按钮。点击后可以在当前网页上直接提问,例如:
```text
总结这页的核心结论
提取页面里的价格和时间
这张表里哪个指标最高?
```
插件会读取当前页面可见文本和表格,发送到 zncm,并把 zncm 的回答显示回浮窗里。多轮追问会复用同一个 zncm 会话;如果页面 URL 变化,会自动重新开始当前页会话。
是否显示问答框可以自己控制:
- 插件弹窗里勾选或取消“启用页面问答框”。
- 插件设置页里勾选或取消“启用页面问答框”。
关闭问答框只会隐藏页面浮窗,不会影响“发送当前页”和 zncm 聊天中的 `browser_fetch_page` 抓取任务。
## 手动发送当前网页
点击插件图标,选择“发送当前页”。
插件会读取当前页面的可见文本和表格,发送到 zncm,并打开一个新的 zncm 聊天页面。zncm 会把网页内容作为上传文件注入到首条问题里。
## 在 zncm 聊天中触发浏览器抓取
在 zncm 中输入带有明确 URL 的问题,例如:
```text
使用 browser_fetch_page 打开 https://example.com/report,提取页面里的关键表格数据并总结。
```
执行流程:
1. zncm 调用 `browser_fetch_page` 工具创建浏览器任务。
2. 插件后台轮询到任务。
3. 插件在 Chrome 中打开目标网页。
4. 插件提取页面文本和表格。
5. 插件把结果回传给 zncm。
6. zncm 基于回传内容回答你的问题。
这个方式适合读取需要浏览器登录态、内网权限或当前 Chrome 环境才能访问的页面。
## 注意事项
- 目标 URL 必须是明确的 `http` 或 `https` 地址。
- 插件不能读取 `chrome://` 等浏览器内部页面。
- 如果目标网站阻止扩展脚本注入,抓取可能失败。
- 后台任务默认会打开新标签页;是否抓取完成后关闭标签页,可在插件设置里配置。
@@ -1,905 +0,0 @@
importScripts("config.js", "shared.js");
const ALARM_NAME = "deer-browser-task-poll";
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
let pollTimer = null;
let isPolling = false;
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
const updates = {};
if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) {
updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl;
}
if (!settings.webBaseUrl || settings.webBaseUrl === LEGACY_LOCAL_WEB_BASE_URL) {
updates.webBaseUrl = DEER_CLIPPER_DEFAULTS.webBaseUrl;
}
if (!settings.workerId) {
updates.workerId = deerMakeWorkerId();
}
if (Object.keys(updates).length > 0) {
await chrome.storage.sync.set(updates);
Object.assign(settings, updates);
}
return settings;
}
function pollIntervalMs(settings) {
const seconds = Number(settings.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds);
return Math.max(3, seconds) * 1000;
}
function originMatchPattern(value) {
try {
const url = new URL(deerNormalizeBaseUrl(value));
return `${url.protocol}//${url.host}/*`;
} catch {
return "";
}
}
function normalizeServiceBase(value) {
return deerNormalizeBaseUrl(value || "");
}
function serviceBaseFromTabUrl(value) {
try {
const url = new URL(value);
const path = url.pathname.replace(/\/+$/, "");
if (path === "/deerflow" || path.startsWith("/deerflow/")) {
return `${url.origin}/deerflow`;
}
return url.origin;
} catch {
return "";
}
}
function apiCandidatesForWebBase(webBase, currentApiBase) {
const candidates = [];
const add = (value) => {
const normalized = normalizeServiceBase(value);
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized);
}
};
add(currentApiBase);
add(webBase);
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
add(LEGACY_LOCAL_API_BASE_URL);
try {
const url = new URL(normalizeServiceBase(webBase));
if (["5173", "5174", "3000"].includes(url.port)) {
const apiUrl = new URL(url.href);
apiUrl.port = "8001";
add(apiUrl.href);
}
} catch {
// Ignore invalid candidates.
}
return candidates;
}
async function browserContextStatus(apiBaseUrl) {
try {
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (data && typeof data.enabled === "boolean") {
return data;
}
} catch {
return null;
}
return null;
}
function tabLooksLikeZncm(tab) {
const value = `${tab.url || ""} ${tab.title || ""}`.toLowerCase();
return (
value.includes("deerflow") ||
value.includes("deer") ||
value.includes("zncm") ||
value.includes("/page/workspace") ||
value.includes("/chats/") ||
value.includes("#/login")
);
}
async function findOpenZncmTabs(settings) {
const tabs = [];
const seen = new Set();
const addTabs = (items) => {
for (const tab of items || []) {
if (tab?.id && !seen.has(tab.id)) {
seen.add(tab.id);
tabs.push(tab);
}
}
};
for (const pattern of [
originMatchPattern(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl),
originMatchPattern(DEER_CLIPPER_DEFAULTS.webBaseUrl),
]) {
if (!pattern) {
continue;
}
try {
addTabs(await chrome.tabs.query({ url: pattern }));
} catch {
// Continue with broader discovery below.
}
}
try {
const allTabs = await chrome.tabs.query({ url: ["http://*/*", "https://*/*"] });
addTabs(allTabs.filter(tabLooksLikeZncm));
} catch {
// Ignore tab discovery failures.
}
return tabs;
}
async function discoverServiceBases(settings, force = false) {
if (!settings.autoDiscoverServiceUrl && !force) {
return settings;
}
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
let discoveredWebBaseUrl = webBaseUrl;
let source = "configured";
const tabs = await findOpenZncmTabs(settings);
if (tabs.length > 0) {
const tabBase = serviceBaseFromTabUrl(tabs[0].url);
if (tabBase) {
discoveredWebBaseUrl = tabBase;
source = `tab:${tabs[0].url || tabBase}`;
}
}
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
let verified = false;
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
const status = await browserContextStatus(candidate);
if (status) {
apiBaseUrl = candidate;
webBaseUrl = discoveredWebBaseUrl;
verified = true;
source = status.enabled ? `${source}:browser_context_enabled` : `${source}:browser_context_disabled`;
break;
}
}
if (!verified && force) {
source = `${source}:api_not_verified`;
}
const next = {
...settings,
apiBaseUrl,
webBaseUrl,
lastServiceSyncAt: new Date().toISOString(),
lastServiceSyncSource: source,
};
if (
force ||
settings.apiBaseUrl !== next.apiBaseUrl ||
settings.webBaseUrl !== next.webBaseUrl ||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
) {
await chrome.storage.sync.set({
apiBaseUrl: next.apiBaseUrl,
webBaseUrl: next.webBaseUrl,
lastServiceSyncAt: next.lastServiceSyncAt,
lastServiceSyncSource: next.lastServiceSyncSource,
});
}
return next;
}
async function getCookieToken(settings) {
if (!chrome.cookies?.get) {
return null;
}
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
try {
const cookie = await chrome.cookies.get({ url: apiBaseUrl, name: "access_token" });
if (cookie?.value) {
return { token: cookie.value, source: `cookie:${new URL(apiBaseUrl).host}:access_token` };
}
} catch (error) {
console.warn("Failed to read zncm access_token cookie", error);
}
return null;
}
async function getTokenFromOpenZncmTab(settings) {
const tabs = await findOpenZncmTabs(settings);
let sawZncmTab = false;
for (const tab of tabs) {
if (!tab.id) {
continue;
}
sawZncmTab = true;
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: deerExtractAuthTokenFromPage,
});
const tokenInfo = result?.result;
if (tokenInfo?.token) {
const tabBase = serviceBaseFromTabUrl(tab.url);
if (tabBase && settings.autoDiscoverServiceUrl && settings.webBaseUrl !== tabBase) {
await chrome.storage.sync.set({
webBaseUrl: tabBase,
lastServiceSyncAt: new Date().toISOString(),
lastServiceSyncSource: `token-tab:${tab.url || tabBase}`,
});
}
return {
token: tokenInfo.token,
source: tokenInfo.source || `tab:${tab.url || "open-deer-tab"}`,
};
}
} catch (error) {
console.warn("Failed to read zncm token from tab", tab.id, error);
}
}
if (sawZncmTab) {
return { token: "", source: "open-deer-tab", clearStale: true };
}
return null;
}
async function syncAuthToken(settings = null, forceStatus = false) {
let current = settings || (await getSettings());
current = await discoverServiceBases(current);
if (!current.tokenSyncEnabled && !forceStatus) {
return current;
}
const tokenInfo = (await getTokenFromOpenZncmTab(current)) || (await getCookieToken(current));
if (!tokenInfo?.token) {
if (tokenInfo?.clearStale && current.accessToken) {
const next = {
...current,
accessToken: "",
lastTokenSyncSource: "cleared:open-deer-tab-no-token",
lastTokenSyncAt: new Date().toISOString(),
};
await chrome.storage.sync.set({
accessToken: "",
lastTokenSyncSource: next.lastTokenSyncSource,
lastTokenSyncAt: next.lastTokenSyncAt,
});
return next;
}
return current;
}
const next = {
...current,
accessToken: tokenInfo.token,
lastTokenSyncSource: tokenInfo.source,
lastTokenSyncAt: new Date().toISOString(),
};
if (
forceStatus ||
current.accessToken !== next.accessToken ||
current.lastTokenSyncSource !== next.lastTokenSyncSource
) {
await chrome.storage.sync.set({
accessToken: next.accessToken,
lastTokenSyncSource: next.lastTokenSyncSource,
lastTokenSyncAt: next.lastTokenSyncAt,
});
}
return next;
}
async function refreshAlarm(settings) {
await chrome.alarms.clear(ALARM_NAME);
if (!settings.taskPollingEnabled) {
return;
}
const minutes = Math.max(0.5, pollIntervalMs(settings) / 60000);
await chrome.alarms.create(ALARM_NAME, { delayInMinutes: minutes, periodInMinutes: minutes });
}
async function scheduleNextPoll(delayMs = null) {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
const settings = await getSettings();
if (!settings.taskPollingEnabled) {
return;
}
const ms = delayMs ?? pollIntervalMs(settings);
pollTimer = setTimeout(() => {
void pollOnce().catch((error) => console.warn("zncm task poll failed", error));
}, ms);
}
async function waitForTabLoad(tabId, timeoutSeconds) {
const timeoutMs = Math.max(5, Number(timeoutSeconds || 30)) * 1000;
try {
const tab = await chrome.tabs.get(tabId);
if (tab.status === "complete") {
return true;
}
} catch {
return false;
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve(false);
}, timeoutMs);
function listener(updatedTabId, changeInfo) {
if (updatedTabId === tabId && changeInfo.status === "complete") {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve(true);
}
}
chrome.tabs.onUpdated.addListener(listener);
});
}
async function captureFromTab(tabId, maxContentChars) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerExtractCurrentPage,
});
const page = result?.result;
if (!page?.content) {
throw new Error("当前页面没有可读取的文本。");
}
const limit = Math.max(1000, Number(maxContentChars || 200000));
if (page.content.length > limit) {
page.content = `${page.content.slice(0, limit)}\n\n[Truncated by zncm browser task limit]`;
}
return page;
}
async function postTaskResult(taskId, settings, payload) {
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
const response = await fetch(`${apiBaseUrl}/api/browser-context/tasks/${encodeURIComponent(taskId)}/result`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
throw new Error(detail || `HTTP ${response.status}`);
}
return data;
}
function zncmApiBaseCandidates(settings) {
const candidates = [];
const add = (value) => {
const normalized = deerNormalizeBaseUrl(value || "");
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized);
}
};
add(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
try {
const url = new URL(deerNormalizeBaseUrl(settings.apiBaseUrl || ""));
if (url.pathname && url.pathname !== "/") {
add(url.origin);
}
} catch {
// Ignore invalid configured API URL.
}
add(DEER_CLIPPER_DEFAULTS.apiBaseUrl);
return candidates;
}
async function zncmFetchJson(settings, path, options) {
let lastError = null;
for (const apiBaseUrl of zncmApiBaseCandidates(settings)) {
try {
const response = await fetch(`${apiBaseUrl}${path}`, options);
const contentType = response.headers.get("content-type") || "";
const data = contentType.includes("application/json")
? await response.json().catch(() => ({}))
: { detail: await response.text().catch(() => "") };
if (response.ok) {
return { data, apiBaseUrl, status: response.status };
}
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
lastError = new Error(detail || `HTTP ${response.status}`);
lastError.status = response.status;
lastError.detail = detail;
lastError.apiBaseUrl = apiBaseUrl;
if (response.status !== 404) {
throw lastError;
}
} catch (error) {
lastError = error;
if (error?.status && error.status !== 404) {
throw error;
}
}
}
throw lastError || new Error("zncm 服务请求失败。");
}
function zncmMakeThreadId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function zncmBuildPagePrompt(page, content, truncated, followUp) {
const title = page.title || "未知标题";
const url = page.url || "未知 URL";
const capturedAt = new Date().toISOString();
if (followUp) {
return [
"你是 zncm 页面问答助手。请继续基于本会话里已经读取的网页内容回答用户问题。",
"如果已有网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
`当前页面标题:${title}`,
`当前页面 URL:${url}`,
`本轮提问时间:${capturedAt}`,
].join("\n\n");
}
return [
"你是 zncm 页面问答助手。请直接基于当前网页内容回答用户问题。",
"如果网页内容不足以回答,请说明缺少哪些信息;不要编造,也不要再尝试抓取其它网页。",
`页面标题:${title}`,
`页面 URL:${url}`,
`读取时间:${capturedAt}`,
truncated ? "说明:页面内容较长,本次已按插件限制截断。" : "说明:页面内容如下。",
"当前网页内容:",
page.contentType === "text" ? `\`\`\`text\n${content}\n\`\`\`` : content,
].join("\n\n");
}
function zncmExtractLatestAnswer(values) {
const messages = Array.isArray(values?.messages) ? values.messages : [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index] || {};
const type = String(message.type || message.role || "").toLowerCase();
if (type !== "ai" && type !== "assistant") {
continue;
}
const content = message.content;
if (typeof content === "string" && content.trim()) {
return content.trim();
}
if (Array.isArray(content)) {
const text = content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part.text === "string") return part.text;
return "";
})
.filter(Boolean)
.join("\n")
.trim();
if (text) {
return text;
}
}
}
return "";
}
async function askZncmViaRunsWait(settings, payload, page, question) {
const contentLimit = 60000;
const rawContent = String(page.content || "");
const truncated = rawContent.length > contentLimit;
const content = truncated ? `${rawContent.slice(0, contentLimit)}\n\n[Truncated locally by zncm Web Clipper]` : rawContent;
const threadId = payload.threadId || zncmMakeThreadId();
const promptPrefix = zncmBuildPagePrompt(page, content, truncated, Boolean(payload.threadId));
const { data } = await zncmFetchJson(settings, "/api/runs/wait", {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify({
assistant_id: "lead_agent",
input: {
messages: [
{
type: "human",
content: question,
additional_kwargs: {
prompt_prefix: promptPrefix,
},
},
],
},
config: {
configurable: {
thread_id: threadId,
},
},
metadata: {
source: "zncm_page_assistant",
browser_context_url: page.url || "",
browser_context_title: page.title || "",
browser_context_fallback: true,
},
stream_mode: ["values"],
on_disconnect: "continue",
multitask_strategy: "reject",
}),
});
return {
success: true,
thread_id: threadId,
run_id: "",
answer: zncmExtractLatestAnswer(data) || "zncm 没有返回可显示的答案。",
truncated,
message: "zncm 页面问答已完成。",
};
}
async function askZncmAboutPage(payload) {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
if (!settings.pageAssistantEnabled) {
throw new Error("页面问答框已关闭。");
}
const page = payload?.page || {};
const question = String(payload?.question || "").trim();
if (!question) {
throw new Error("请输入要询问的问题。");
}
if (!page.content) {
throw new Error("当前页面没有可读取的正文。");
}
try {
const { data } = await zncmFetchJson(settings, "/api/browser-context/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify({
thread_id: payload.threadId || null,
title: page.title || "",
url: page.url || "",
content: page.content || "",
content_type: page.contentType || "markdown",
question,
}),
});
return data;
} catch (error) {
const detail = String(error?.detail || error?.message || "");
if (error?.status === 404 && !/browser context integration is disabled/i.test(detail)) {
return askZncmViaRunsWait(settings, payload, page, question);
}
throw error;
}
}
async function getActSessionMap() {
try {
const stored = await chrome.storage.session.get({ actSessions: {} });
return stored.actSessions && typeof stored.actSessions === "object" ? stored.actSessions : {};
} catch {
return {};
}
}
async function setActSessionTab(sessionId, tabId) {
if (!sessionId) return;
const map = await getActSessionMap();
map[sessionId] = tabId;
await chrome.storage.session.set({ actSessions: map }).catch(() => {});
}
async function removeActSession(sessionId) {
if (!sessionId) return;
const map = await getActSessionMap();
if (sessionId in map) {
delete map[sessionId];
await chrome.storage.session.set({ actSessions: map }).catch(() => {});
}
}
async function resolveSessionTabId(sessionId) {
const map = await getActSessionMap();
const tabId = map[sessionId];
if (!tabId) return null;
try {
await chrome.tabs.get(tabId);
return tabId;
} catch {
await removeActSession(sessionId);
return null;
}
}
async function performActionInTab(tabId, action) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerPerformAction,
args: [action],
});
return result?.result || { status: "ok", message: "" };
}
async function observeTab(tabId, maxElements) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerObservePage,
args: [maxElements],
});
const observation = result?.result;
if (!observation) {
throw new Error("当前页面无法读取交互元素。");
}
return observation;
}
async function runActTask(task, settings) {
const sessionId = task.session_id || "";
const action = task.action || {};
const actionType = String(action.type || "").toLowerCase();
const maxElements = 150;
try {
if (!sessionId) {
throw new Error("act 任务缺少 session_id。");
}
if (actionType === "close") {
const tabId = await resolveSessionTabId(sessionId);
if (tabId) {
await chrome.tabs.remove(tabId).catch(() => {});
}
await removeActSession(sessionId);
await postTaskResult(task.id, settings, {
kind: "act",
observation: { action_status: "ok", message: "Tab closed.", url: "", title: "", elements: [], text: "" },
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
});
return;
}
let tabId = await resolveSessionTabId(sessionId);
if (actionType === "navigate") {
const url = String(action.url || task.url || "");
if (!url) {
throw new Error("navigate 动作缺少 url。");
}
if (tabId) {
await chrome.tabs.update(tabId, { url });
} else {
const tab = await chrome.tabs.create({ url, active: false });
tabId = tab.id;
await setActSessionTab(sessionId, tabId);
}
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
await delay(800);
} else {
if (!tabId) {
throw new Error("会话标签页不存在,请先用 navigate 打开页面。");
}
const outcome = await performActionInTab(tabId, action);
// Give the page a moment to react / navigate, then settle.
await delay(400);
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
await delay(500);
if (outcome.status === "error") {
const observation = await observeTab(tabId, maxElements).catch(() => ({ elements: [], text: "", url: "", title: "" }));
await postTaskResult(task.id, settings, {
kind: "act",
observation: { ...observation, action_status: outcome.status, message: outcome.message },
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
});
return;
}
}
const observation = await observeTab(tabId, maxElements);
await postTaskResult(task.id, settings, {
kind: "act",
title: observation.title || "",
url: observation.url || "",
observation: { ...observation, action_status: "ok", message: "" },
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
});
} catch (error) {
await postTaskResult(task.id, settings, {
kind: "act",
error: error instanceof Error ? error.message : String(error),
metadata: { source: "zncm-web-clipper", worker_id: settings.workerId, session_id: sessionId },
}).catch((submitError) => console.warn("Failed to submit zncm act task error", submitError));
}
}
async function runFetchTask(task, settings) {
let tabId = null;
try {
const tab = await chrome.tabs.create({ url: task.url, active: false });
tabId = tab.id;
await waitForTabLoad(tabId, settings.taskPageLoadTimeoutSeconds);
await delay(750);
const page = await captureFromTab(tabId, task.max_content_chars);
await postTaskResult(task.id, settings, {
kind: "fetch",
title: page.title,
url: page.url,
content: page.content,
content_type: page.contentType || "markdown",
metadata: {
source: "zncm-web-clipper",
worker_id: settings.workerId,
instruction: task.instruction || "",
},
});
} catch (error) {
await postTaskResult(task.id, settings, {
kind: "fetch",
error: error instanceof Error ? error.message : String(error),
metadata: {
source: "zncm-web-clipper",
worker_id: settings.workerId,
},
}).catch((submitError) => console.warn("Failed to submit zncm task error", submitError));
} finally {
if (tabId && settings.closeTaskTabs) {
await chrome.tabs.remove(tabId).catch(() => {});
}
}
}
async function runTask(task, settings) {
if ((task.kind || "fetch") === "act") {
await runActTask(task, settings);
return;
}
await runFetchTask(task, settings);
}
async function pollOnce() {
if (isPolling) {
return { ok: true, message: "任务轮询正在运行。" };
}
isPolling = true;
try {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
if (!settings.taskPollingEnabled) {
return { ok: false, message: "任务轮询已关闭。" };
}
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
const url = `${apiBaseUrl}/api/browser-context/tasks/next?worker_id=${encodeURIComponent(settings.workerId)}`;
const response = await fetch(url, { headers: deerAuthHeaders(settings) });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
throw new Error(detail || `HTTP ${response.status}`);
}
if (!data.task) {
return { ok: true, message: "暂无待处理的 zncm 浏览器任务。" };
}
await runTask(data.task, settings);
return { ok: true, message: `已完成 zncm 浏览器任务 ${data.task.id}。` };
} finally {
isPolling = false;
await scheduleNextPoll();
}
}
async function startup() {
let settings = await getSettings();
settings = await discoverServiceBases(settings);
settings = await syncAuthToken(settings);
await refreshAlarm(settings);
await scheduleNextPoll(1000);
}
chrome.runtime.onInstalled.addListener(() => void startup());
chrome.runtime.onStartup.addListener(() => void startup());
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "sync") {
void startup();
}
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM_NAME) {
void pollOnce().catch((error) => console.warn("zncm alarm poll failed", error));
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "zncm-discover-service") {
getSettings()
.then((settings) => discoverServiceBases(settings, true))
.then((settings) =>
sendResponse({
ok: Boolean(settings.apiBaseUrl && settings.webBaseUrl),
message: `服务地址:${settings.webBaseUrl};API:${settings.apiBaseUrl}`,
apiBaseUrl: settings.apiBaseUrl,
webBaseUrl: settings.webBaseUrl,
source: settings.lastServiceSyncSource || "",
}),
)
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type === "zncm-sync-token") {
syncAuthToken(null, true)
.then((settings) => {
const hasToken = Boolean(settings.accessToken);
sendResponse({
ok: hasToken,
message: hasToken
? `Token 已从 ${settings.lastTokenSyncSource || "zncm"} 同步。`
: "没有找到 zncm token。请打开已登录的 zncm 页面后重试。",
source: settings.lastTokenSyncSource || "",
});
})
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type === "zncm-page-agent-ask") {
askZncmAboutPage(message)
.then((result) => sendResponse({ ok: true, ...result }))
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type === "zncm-open-options") {
chrome.runtime.openOptionsPage()
.then(() => sendResponse({ ok: true }))
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
}
if (message?.type !== "zncm-poll-now") {
return false;
}
pollOnce()
.then((result) => sendResponse(result))
.catch((error) => sendResponse({ ok: false, message: error instanceof Error ? error.message : String(error) }));
return true;
});
void startup();
@@ -1,9 +0,0 @@
// Default deployment settings for zncm Web Clipper.
//
// Change these two values before loading or packaging the extension when the
// zncm service address changes. Users can still override them in the extension
// settings page after installation.
globalThis.ZNCM_WEB_CLIPPER_CONFIG = Object.freeze({
defaultWebBaseUrl: "http://47.94.209.59:2026",
defaultApiBaseUrl: "http://47.94.209.59:2026",
});
@@ -1,737 +0,0 @@
(() => {
if (window.top !== window || window.__zncmPageAssistantLoaded) {
return;
}
window.__zncmPageAssistantLoaded = true;
const HOST_ID = "zncm-page-assistant-host";
const state = {
enabled: true,
open: false,
busy: false,
threadId: "",
pageUrl: location.href,
messages: [],
};
let host = null;
let shadow = null;
let ui = {};
function sendRuntimeMessage(message) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || {});
});
});
}
function makeElement(tag, className = "", text = "") {
const element = document.createElement(tag);
if (className) {
element.className = className;
}
if (text) {
element.textContent = text;
}
return element;
}
function setStatus(message) {
if (ui.status) {
ui.status.textContent = message || "";
}
}
function setBusy(busy) {
state.busy = busy;
if (ui.send) ui.send.disabled = busy;
if (ui.input) ui.input.disabled = busy;
if (ui.panel) ui.panel.classList.toggle("is-busy", busy);
}
function resetThreadIfPageChanged() {
if (state.pageUrl === location.href) {
return;
}
state.pageUrl = location.href;
state.threadId = "";
state.messages = [];
renderMessages();
setStatus("页面已变化,下一次提问会重新读取当前页面。");
}
function destroyWidget() {
if (host) {
host.remove();
}
host = null;
shadow = null;
ui = {};
}
function renderMessages() {
if (!ui.messages) {
return;
}
ui.messages.replaceChildren();
if (state.messages.length === 0) {
const empty = makeElement("div", "empty");
const emptyTitle = makeElement("strong", "", "问问当前页面");
const emptyText = makeElement("span", "", "可以提问页面里的数据、表格、段落和结论。");
empty.append(emptyTitle, emptyText);
ui.messages.appendChild(empty);
return;
}
for (const message of state.messages) {
const item = makeElement("div", `message ${message.role}`);
const avatar = makeElement("div", "avatar", message.role === "user" ? "你" : "zn");
const bubble = makeElement("div", "bubble");
const label = makeElement("div", "message-label", message.role === "user" ? "你" : "zncm");
const body = makeElement("div", "message-body", message.text);
if (message.pending) {
body.classList.add("pending");
}
bubble.append(label, body);
item.append(avatar, bubble);
ui.messages.appendChild(item);
}
ui.messages.scrollTop = ui.messages.scrollHeight;
}
function setOpen(open) {
state.open = open;
if (!ui.panel || !ui.launcher) {
return;
}
ui.panel.classList.toggle("open", open);
ui.launcher.classList.toggle("open", open);
ui.launcher.setAttribute("aria-expanded", String(open));
if (open) {
resetThreadIfPageChanged();
setTimeout(() => ui.input?.focus(), 50);
}
}
async function ask(question) {
const normalized = String(question || "").trim();
if (!normalized || state.busy) {
return;
}
resetThreadIfPageChanged();
setBusy(true);
setStatus("正在读取页面并发送给 zncm...");
state.messages.push({ role: "user", text: normalized });
const pending = { role: "assistant", text: "zncm 正在理解当前页面...", pending: true };
state.messages.push(pending);
renderMessages();
ui.input.value = "";
try {
if (typeof deerExtractCurrentPage !== "function") {
throw new Error("页面读取脚本未加载,请刷新当前页面后重试。");
}
const page = deerExtractCurrentPage();
const response = await sendRuntimeMessage({
type: "zncm-page-agent-ask",
page,
question: normalized,
threadId: state.threadId || "",
});
if (!response.ok) {
throw new Error(response.message || "zncm 没有返回答案。");
}
state.threadId = response.thread_id || state.threadId;
pending.text = response.answer || "zncm 没有返回答案。";
pending.pending = false;
setStatus(response.truncated ? "已回答。页面内容较长,本次按配置做了截断。" : "已回答。");
} catch (error) {
pending.text = error instanceof Error ? error.message : "页面问答失败。";
pending.pending = false;
setStatus("本次问答失败。");
} finally {
setBusy(false);
renderMessages();
ui.input.focus();
}
}
function buildWidget() {
host = document.createElement("div");
host.id = HOST_ID;
document.documentElement.appendChild(host);
shadow = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host {
all: initial;
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.launcher,
.panel {
position: fixed;
z-index: 2147483647;
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.launcher {
right: 20px;
bottom: 20px;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
height: 44px;
min-width: 112px;
border: 1px solid rgba(15, 118, 110, 0.28);
border-radius: 8px;
padding: 0 12px 0 10px;
background: #0f766e;
color: #fff;
box-shadow: 0 12px 34px rgba(15, 118, 110, 0.28), 0 2px 8px rgba(17, 24, 39, 0.12);
cursor: pointer;
font-weight: 750;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.launcher:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 118, 110, 0.32), 0 4px 12px rgba(17, 24, 39, 0.14);
}
.launcher-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.18);
font-size: 12px;
letter-spacing: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.launcher.open {
background: #115e59;
}
.panel {
right: 20px;
bottom: 72px;
width: min(430px, calc(100vw - 24px));
max-height: min(640px, calc(100vh - 96px));
display: none;
grid-template-rows: auto auto minmax(180px, 1fr) auto;
overflow: hidden;
border: 1px solid #d6d8dc;
border-radius: 8px;
background: #ffffff;
color: #171717;
box-shadow: 0 24px 70px rgba(17, 24, 39, 0.2), 0 1px 2px rgba(17, 24, 39, 0.08);
}
.panel.open {
display: grid;
animation: zncm-panel-in 160ms ease-out;
}
@keyframes zncm-panel-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.header {
display: grid;
gap: 10px;
padding: 14px;
background: linear-gradient(180deg, #f4fffc 0%, #ffffff 84%);
border-bottom: 1px solid #e5e7eb;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: 0 0 auto;
border-radius: 8px;
background: #0f766e;
color: #fff;
font-weight: 800;
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.18);
}
.brand-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.brand-copy strong {
color: #111827;
font-size: 15px;
line-height: 1.25;
}
.brand-copy span {
overflow: hidden;
color: #6b7280;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
.icon-button {
display: grid;
place-items: center;
width: auto;
min-width: 34px;
height: 30px;
padding: 0 8px;
border: 1px solid #dfe3e8;
border-radius: 7px;
background: #fff;
color: #374151;
cursor: pointer;
font: inherit;
font-size: 12px;
white-space: nowrap;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.icon-button:hover {
border-color: #0f766e;
color: #0f766e;
background: #f0fdfa;
}
.page-chip {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid #dbeafe;
border-radius: 8px;
padding: 8px 10px;
background: #f8fbff;
color: #334155;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.page-chip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #0f766e;
}
.page-chip-text {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
min-height: 20px;
padding: 9px 14px;
border-bottom: 1px solid #f0f2f5;
color: #475569;
background: #fbfcfd;
font-size: 12px;
}
.messages {
display: grid;
align-content: start;
gap: 12px;
overflow: auto;
padding: 14px;
background: linear-gradient(180deg, #f7f8fa 0%, #f3f6f8 100%);
}
.empty {
display: grid;
gap: 5px;
padding: 16px;
border: 1px solid #dfe7ed;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #fbfefd 100%);
color: #4b5563;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.empty strong {
color: #111827;
font-size: 14px;
}
.empty span {
font-size: 13px;
}
.message {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.message.user {
grid-template-columns: minmax(0, 1fr) 28px;
}
.message.user .avatar {
grid-column: 2;
}
.message.user .bubble {
grid-column: 1;
justify-self: end;
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #e6fffb;
color: #0f766e;
font-size: 11px;
font-weight: 800;
}
.message.user .avatar {
background: #111827;
color: #fff;
}
.bubble {
display: grid;
gap: 4px;
max-width: 100%;
}
.message-label {
color: #6b7280;
font-size: 11px;
}
.message.user .message-label {
text-align: right;
}
.message-body {
max-width: 330px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #171717;
white-space: pre-wrap;
overflow-wrap: anywhere;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.message.user .message-body {
border-color: #0f172a;
background: #0f172a;
color: #fff;
}
.message-body.pending {
color: #6b7280;
}
.message-body.pending::after {
content: "";
display: inline-block;
width: 18px;
height: 4px;
margin-left: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #0f766e 0 33%, transparent 33% 100%);
background-size: 9px 4px;
animation: zncm-thinking 900ms linear infinite;
vertical-align: middle;
}
@keyframes zncm-thinking {
from {
background-position: 0 0;
}
to {
background-position: 18px 0;
}
}
.composer-wrap {
display: grid;
gap: 10px;
padding: 12px 14px 14px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
.quick {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.quick button {
border: 1px solid #d6d8dc;
border-radius: 8px;
padding: 8px 10px;
background: #fbfcfd;
color: #374151;
cursor: pointer;
font: inherit;
font-size: 12px;
text-align: left;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.quick button:hover {
border-color: #0f766e;
color: #0f766e;
background: #f0fdfa;
transform: translateY(-1px);
}
.composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: end;
}
.composer textarea {
min-height: 46px;
max-height: 130px;
resize: vertical;
border: 1px solid #cfd6dd;
border-radius: 8px;
padding: 10px 11px;
color: #171717;
background: #fbfcfd;
font: inherit;
outline: none;
}
.composer textarea:focus {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
.composer button {
height: 42px;
border: 0;
border-radius: 8px;
padding: 0 14px;
background: #0f766e;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 750;
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.2);
transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
.composer button:hover {
background: #115e59;
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.24);
}
.panel.is-busy .brand-mark,
.panel.is-busy .launcher-mark {
opacity: 0.82;
}
.composer button:disabled,
.quick button:disabled,
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 480px) {
.launcher {
right: 12px;
bottom: 12px;
min-width: 96px;
}
.panel {
right: 12px;
bottom: 64px;
width: calc(100vw - 24px);
max-height: calc(100vh - 80px);
}
.message-body {
max-width: calc(100vw - 104px);
}
.quick {
grid-template-columns: 1fr;
}
}
`;
const launcher = makeElement("button", "launcher");
launcher.type = "button";
launcher.title = "打开 zncm 页面问答";
launcher.setAttribute("aria-expanded", "false");
launcher.append(makeElement("span", "launcher-mark", "zn"), makeElement("span", "", "页面问答"));
const panel = makeElement("section", "panel");
const header = makeElement("header", "header");
const headerTop = makeElement("div", "header-top");
const brand = makeElement("div", "brand");
const brandCopy = makeElement("div", "brand-copy");
const title = makeElement("strong", "", "zncm 页面问答");
const subtitle = makeElement("span", "", "读取当前页面并回答");
brandCopy.append(title, subtitle);
brand.append(makeElement("div", "brand-mark", "zn"), brandCopy);
const actions = makeElement("div", "header-actions");
const settings = makeElement("button", "icon-button", "设置");
settings.type = "button";
settings.title = "打开插件设置";
const minimize = makeElement("button", "icon-button", "-");
minimize.type = "button";
minimize.title = "收起";
actions.append(settings, minimize);
headerTop.append(brand, actions);
const pageChip = makeElement("div", "page-chip");
const pageTitle = makeElement("span", "page-chip-text", document.title || location.hostname);
pageChip.append(makeElement("span", "page-chip-dot"), pageTitle);
header.append(headerTop, pageChip);
const status = makeElement("div", "status", "已连接当前页面。");
const messages = makeElement("div", "messages");
const composerWrap = makeElement("div", "composer-wrap");
const quick = makeElement("div", "quick");
for (const text of ["总结此页", "提取关键数据", "列出待办", "解释表格"]) {
const chip = makeElement("button", "", text);
chip.type = "button";
chip.addEventListener("click", () => ask(text));
quick.appendChild(chip);
}
const composer = makeElement("form", "composer");
const input = makeElement("textarea");
input.placeholder = "问当前页面...";
input.rows = 2;
const send = makeElement("button", "", "发送");
send.type = "submit";
composer.append(input, send);
composerWrap.append(quick, composer);
panel.append(header, status, messages, composerWrap);
shadow.append(style, panel, launcher);
ui = { launcher, panel, subtitle, pageTitle, status, messages, input, send };
renderMessages();
launcher.addEventListener("click", () => setOpen(!state.open));
minimize.addEventListener("click", () => setOpen(false));
settings.addEventListener("click", () => {
sendRuntimeMessage({ type: "zncm-open-options" }).catch((error) => {
setStatus(error instanceof Error ? error.message : "无法打开插件设置。");
});
});
composer.addEventListener("submit", (event) => {
event.preventDefault();
ask(input.value);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
ask(input.value);
}
});
}
function ensureWidget() {
if (!state.enabled) {
destroyWidget();
return;
}
if (document.getElementById(HOST_ID)) {
return;
}
buildWidget();
}
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
state.enabled = Boolean(settings.pageAssistantEnabled);
ensureWidget();
}
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync" || !changes.pageAssistantEnabled) {
return;
}
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
}
});
setInterval(() => {
if (!state.enabled || !ui.pageTitle) {
return;
}
if (state.pageUrl !== location.href) {
resetThreadIfPageChanged();
}
ui.pageTitle.textContent = document.title || location.hostname;
}, 1200);
void loadSettings();
})();
@@ -1,24 +0,0 @@
{
"manifest_version": 3,
"name": "zncm Web Clipper",
"version": "0.2.0",
"description": "Send pages to zncm and execute browser-backed fetch tasks.",
"permissions": ["activeTab", "alarms", "cookies", "scripting", "storage", "tabs"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "zncm Web Clipper",
"default_popup": "popup.html"
},
"options_page": "options.html",
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["config.js", "shared.js", "content.js"],
"run_at": "document_idle",
"all_frames": false
}
]
}
@@ -1,74 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zncm Web Clipper Settings</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="options">
<h1>zncm Web Clipper Settings</h1>
<label>
API 地址
<input id="apiBaseUrl" />
</label>
<label>
前端地址
<input id="webBaseUrl" />
</label>
<label class="checkbox">
<input id="autoDiscoverServiceUrl" type="checkbox" />
自动识别已打开 zncm 页面的服务地址
</label>
<div class="actions">
<button id="discoverService" type="button">重新识别服务地址</button>
</div>
<p id="serviceStatus" class="status"></p>
<label>
Access Token
<textarea id="accessToken" rows="5" placeholder="会自动从已登录 zncm 页面或 access_token Cookie 获取。也可以手动粘贴。"></textarea>
</label>
<label class="checkbox">
<input id="tokenSyncEnabled" type="checkbox" />
自动同步已登录 zncm 的 token
</label>
<div class="actions">
<button id="syncToken" type="button">Sync token now</button>
</div>
<p id="tokenStatus" class="status"></p>
<label class="checkbox">
<input id="pageAssistantEnabled" type="checkbox" />
启用页面问答框
</label>
<label>
默认首问
<textarea id="defaultPrompt" rows="4"></textarea>
</label>
<label class="checkbox">
<input id="taskPollingEnabled" type="checkbox" />
启用 zncm 聊天触发的浏览器抓取任务
</label>
<label>
Worker ID
<input id="workerId" placeholder="自动生成" />
</label>
<label>
任务轮询间隔秒数
<input id="taskPollIntervalSeconds" type="number" min="3" step="1" />
</label>
<label>
页面加载超时秒数
<input id="taskPageLoadTimeoutSeconds" type="number" min="5" step="1" />
</label>
<label class="checkbox">
<input id="closeTaskTabs" type="checkbox" />
抓取完成后关闭任务标签页
</label>
<button id="save" type="button">保存</button>
<p id="status" class="status"></p>
</main>
<script src="config.js"></script>
<script src="shared.js"></script>
<script src="options.js"></script>
</body>
</html>
@@ -1,148 +0,0 @@
const textFields = [
"apiBaseUrl",
"webBaseUrl",
"accessToken",
"defaultPrompt",
"workerId",
"taskPollIntervalSeconds",
"taskPageLoadTimeoutSeconds",
];
const checkboxFields = [
"autoDiscoverServiceUrl",
"tokenSyncEnabled",
"pageAssistantEnabled",
"taskPollingEnabled",
"closeTaskTabs",
];
function formatServiceStatus(settings) {
if (!settings.lastServiceSyncAt) {
return `当前服务地址:${settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl};API:${settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl}`;
}
return `当前服务地址:${settings.webBaseUrl || ""};API:${settings.apiBaseUrl || ""}\n上次识别:${settings.lastServiceSyncAt}(${settings.lastServiceSyncSource || "unknown source"})`;
}
function formatTokenStatus(settings) {
if (!settings.lastTokenSyncAt) {
return "Token 尚未自动同步。";
}
return `上次 token 同步:${settings.lastTokenSyncAt}(${settings.lastTokenSyncSource || "unknown source"})`;
}
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
if (!settings.workerId) {
settings.workerId = deerMakeWorkerId();
await chrome.storage.sync.set({ workerId: settings.workerId });
}
document.getElementById("apiBaseUrl").placeholder =
DEER_CLIPPER_DEFAULTS.apiBaseUrl || "在 config.js 中配置默认 API 地址";
document.getElementById("webBaseUrl").placeholder =
DEER_CLIPPER_DEFAULTS.webBaseUrl || "在 config.js 中配置默认前端地址";
for (const field of textFields) {
document.getElementById(field).value = settings[field] ?? "";
}
for (const field of checkboxFields) {
document.getElementById(field).checked = Boolean(settings[field]);
}
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
document.getElementById("tokenStatus").textContent = formatTokenStatus(settings);
}
async function saveSettings() {
const next = {};
for (const field of textFields) {
next[field] = document.getElementById(field).value.trim();
}
for (const field of checkboxFields) {
next[field] = document.getElementById(field).checked;
}
next.apiBaseUrl = deerNormalizeBaseUrl(next.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
next.webBaseUrl = deerNormalizeBaseUrl(next.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
next.defaultPrompt = next.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
next.workerId = next.workerId || deerMakeWorkerId();
next.taskPollIntervalSeconds = Math.max(3, Number(next.taskPollIntervalSeconds || DEER_CLIPPER_DEFAULTS.taskPollIntervalSeconds));
next.taskPageLoadTimeoutSeconds = Math.max(5, Number(next.taskPageLoadTimeoutSeconds || DEER_CLIPPER_DEFAULTS.taskPageLoadTimeoutSeconds));
await chrome.storage.sync.set(next);
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("status").textContent = "已保存。";
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
document.getElementById("tokenStatus").textContent = formatTokenStatus(settings);
}
function sendDiscoverServiceMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-discover-service" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: false, message: "服务地址识别没有返回结果。" });
});
});
}
function sendSyncTokenMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: false, message: "Token 同步没有返回结果。" });
});
});
}
async function discoverServiceNow() {
const button = document.getElementById("discoverService");
button.disabled = true;
document.getElementById("serviceStatus").textContent = "正在识别服务地址...";
try {
await chrome.storage.sync.set({
autoDiscoverServiceUrl: document.getElementById("autoDiscoverServiceUrl").checked,
apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl),
webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl),
});
const response = await sendDiscoverServiceMessage();
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || "";
document.getElementById("webBaseUrl").value = settings.webBaseUrl || "";
document.getElementById("serviceStatus").textContent = response.message || formatServiceStatus(settings);
} catch (error) {
document.getElementById("serviceStatus").textContent = error instanceof Error ? error.message : "服务地址识别失败。";
} finally {
button.disabled = false;
}
}
async function syncTokenNow() {
const button = document.getElementById("syncToken");
button.disabled = true;
document.getElementById("tokenStatus").textContent = "正在同步 zncm token...";
try {
await chrome.storage.sync.set({
tokenSyncEnabled: document.getElementById("tokenSyncEnabled").checked,
apiBaseUrl: deerNormalizeBaseUrl(document.getElementById("apiBaseUrl").value || DEER_CLIPPER_DEFAULTS.apiBaseUrl),
webBaseUrl: deerNormalizeBaseUrl(document.getElementById("webBaseUrl").value || DEER_CLIPPER_DEFAULTS.webBaseUrl),
});
const response = await sendSyncTokenMessage();
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("accessToken").value = settings.accessToken || "";
document.getElementById("apiBaseUrl").value = settings.apiBaseUrl || "";
document.getElementById("webBaseUrl").value = settings.webBaseUrl || "";
document.getElementById("serviceStatus").textContent = formatServiceStatus(settings);
document.getElementById("tokenStatus").textContent = response.message || formatTokenStatus(settings);
} catch (error) {
document.getElementById("tokenStatus").textContent = error instanceof Error ? error.message : "Token 同步失败。";
} finally {
button.disabled = false;
}
}
document.getElementById("save").addEventListener("click", saveSettings);
document.getElementById("discoverService").addEventListener("click", discoverServiceNow);
document.getElementById("syncToken").addEventListener("click", syncTokenNow);
void loadSettings();
@@ -1,31 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>zncm Web Clipper</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="popup">
<h1>zncm Web Clipper</h1>
<label>
首问
<textarea id="prompt" rows="4"></textarea>
</label>
<label class="checkbox">
<input id="pageAssistantEnabled" type="checkbox" />
启用页面问答框
</label>
<div class="actions">
<button id="send" type="button">发送当前页</button>
<button id="syncToken" type="button">同步 token</button>
<button id="pollNow" type="button">检查任务</button>
<button id="options" type="button" class="secondary">设置</button>
</div>
<p id="status" class="status"></p>
</main>
<script src="config.js"></script>
<script src="shared.js"></script>
<script src="popup.js"></script>
</body>
</html>
-151
View File
@@ -1,151 +0,0 @@
function setStatus(message) {
document.getElementById("status").textContent = message;
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
throw new Error("No readable active tab found.");
}
return tab;
}
async function captureFromActiveTab(tabId) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: deerExtractCurrentPage,
});
if (!result?.result?.content) {
throw new Error("The current page has no readable text to send.");
}
return result.result;
}
async function submitToZncm(page, settings, prompt) {
const apiBaseUrl = deerNormalizeBaseUrl(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
const response = await fetch(`${apiBaseUrl}/api/browser-context/capture`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...deerAuthHeaders(settings),
},
body: JSON.stringify({
title: page.title,
url: page.url,
content: page.content,
content_type: page.contentType,
prompt,
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data);
throw new Error(detail || `HTTP ${response.status}`);
}
return data;
}
async function sendCurrentPage() {
const sendButton = document.getElementById("send");
sendButton.disabled = true;
setStatus("正在读取当前页面...");
try {
await syncToken({ silent: true });
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
const prompt = document.getElementById("prompt").value.trim() || settings.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
const tab = await getActiveTab();
const page = await captureFromActiveTab(tab.id);
setStatus("正在发送到 zncm...");
const result = await submitToZncm(page, settings, prompt);
const webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
await chrome.tabs.create({ url: `${webBaseUrl}/${result.chat_url}`.replace(/\/#/g, "/#") });
setStatus("已发送,zncm 聊天页面已打开。");
} catch (error) {
setStatus(error instanceof Error ? error.message : "发送失败。");
} finally {
sendButton.disabled = false;
}
}
function sendSyncTokenMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-sync-token" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: false, message: "Token 同步没有返回结果。" });
});
});
}
function sendPollNowMessage() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: "zncm-poll-now" }, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || { ok: true, message: "Poll requested." });
});
});
}
async function syncToken(options = {}) {
const button = document.getElementById("syncToken");
button.disabled = true;
if (!options.silent) {
setStatus("正在同步 zncm token...");
}
try {
const response = await sendSyncTokenMessage();
if (!options.silent) {
setStatus(response.message || "Token sync finished.");
}
return response;
} catch (error) {
if (!options.silent) {
setStatus(error instanceof Error ? error.message : "Token 同步失败。");
}
return { ok: false, message: error instanceof Error ? error.message : "Token 同步失败。" };
} finally {
button.disabled = false;
}
}
async function pollNow() {
const button = document.getElementById("pollNow");
button.disabled = true;
setStatus("正在检查 zncm 浏览器任务...");
try {
await syncToken({ silent: true });
const response = await sendPollNowMessage();
setStatus(response.message || "任务检查完成。");
} catch (error) {
setStatus(error instanceof Error ? error.message : "任务检查失败。");
} finally {
button.disabled = false;
}
}
async function togglePageAssistant() {
const enabled = document.getElementById("pageAssistantEnabled").checked;
await chrome.storage.sync.set({ pageAssistantEnabled: enabled });
setStatus(enabled ? "页面问答框已开启。" : "页面问答框已关闭。");
}
async function init() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
document.getElementById("prompt").value = settings.defaultPrompt || DEER_CLIPPER_DEFAULT_PROMPT;
document.getElementById("pageAssistantEnabled").checked = Boolean(settings.pageAssistantEnabled);
document.getElementById("send").addEventListener("click", sendCurrentPage);
document.getElementById("syncToken").addEventListener("click", () => syncToken());
document.getElementById("pollNow").addEventListener("click", pollNow);
document.getElementById("pageAssistantEnabled").addEventListener("change", togglePageAssistant);
document.getElementById("options").addEventListener("click", () => chrome.runtime.openOptionsPage());
}
void init();
@@ -1,389 +0,0 @@
const DEER_CLIPPER_DEFAULT_PROMPT =
"\u8bf7\u57fa\u4e8e\u521a\u521a\u53d1\u9001\u7684\u7f51\u9875\u5185\u5bb9\uff0c\u5148\u7528\u4e2d\u6587\u603b\u7ed3\u8981\u70b9\uff0c\u5e76\u7b49\u5f85\u6211\u7ee7\u7eed\u63d0\u95ee\u3002";
const ZNCM_EXTENSION_CONFIG = globalThis.ZNCM_WEB_CLIPPER_CONFIG || {};
const ZNCM_DEFAULT_WEB_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultWebBaseUrl || "").trim().replace(/\/+$/, "");
const ZNCM_DEFAULT_API_BASE_URL = String(ZNCM_EXTENSION_CONFIG.defaultApiBaseUrl || "").trim().replace(/\/+$/, "");
const DEER_CLIPPER_DEFAULTS = {
apiBaseUrl: ZNCM_DEFAULT_API_BASE_URL,
webBaseUrl: ZNCM_DEFAULT_WEB_BASE_URL,
autoDiscoverServiceUrl: true,
lastServiceSyncAt: "",
lastServiceSyncSource: "",
accessToken: "",
tokenSyncEnabled: true,
lastTokenSyncAt: "",
lastTokenSyncSource: "",
defaultPrompt: DEER_CLIPPER_DEFAULT_PROMPT,
pageAssistantEnabled: true,
taskPollingEnabled: true,
workerId: "",
taskPollIntervalSeconds: 5,
taskPageLoadTimeoutSeconds: 30,
closeTaskTabs: false,
};
function deerNormalizeBaseUrl(value) {
return String(value || "").trim().replace(/\/+$/, "");
}
function deerAuthHeaders(settings) {
const token = String(settings.accessToken || "").trim();
if (!token) {
return {};
}
return {
Authorization: /^bearer\s+/i.test(token) ? token : `Bearer ${token}`,
};
}
function deerMakeWorkerId() {
return `zncm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function deerExtractAuthTokenFromPage() {
const storageKeys = ["deerflow.auth"];
const tokenFieldNames = new Set(["access_token", "accessToken", "token", "id_token", "idToken"]);
function looksLikeToken(value) {
const text = String(value || "").trim();
return text.length > 20 && !/\s/.test(text);
}
function findToken(value, depth = 0) {
if (value == null || depth > 4) {
return "";
}
if (typeof value === "string") {
const text = value.trim();
if (!text) {
return "";
}
if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) {
try {
return findToken(JSON.parse(text), depth + 1);
} catch {
return looksLikeToken(text) ? text : "";
}
}
return looksLikeToken(text) ? text : "";
}
if (typeof value !== "object") {
return "";
}
for (const [key, nested] of Object.entries(value)) {
if (tokenFieldNames.has(key) && looksLikeToken(nested)) {
return String(nested).trim();
}
}
for (const nested of Object.values(value)) {
const token = findToken(nested, depth + 1);
if (token) {
return token;
}
}
return "";
}
function scanStorage(storage, storageName) {
for (const key of storageKeys) {
const token = findToken(storage.getItem(key));
if (token) {
return { token, source: `${storageName}:${key}` };
}
}
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (!key || !/(auth|token|session)/i.test(key)) {
continue;
}
const token = findToken(storage.getItem(key));
if (token) {
return { token, source: `${storageName}:${key}` };
}
}
return null;
}
try {
return scanStorage(window.localStorage, "localStorage") || scanStorage(window.sessionStorage, "sessionStorage");
} catch (error) {
return { token: "", source: "", error: error instanceof Error ? error.message : String(error) };
}
}
function deerExtractCurrentPage() {
const maxLocalChars = 220000;
function cleanText(value) {
return String(value || "")
.replace(/\s+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function escapeCell(value) {
return cleanText(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function tableToMarkdown(table, index) {
const rows = Array.from(table.rows || []).map((row) =>
Array.from(row.cells || []).map((cell) => escapeCell(cell.innerText)),
);
if (rows.length === 0 || rows.every((row) => row.length === 0)) {
return "";
}
const width = Math.max(...rows.map((row) => row.length));
const normalized = rows.map((row) => {
const next = row.slice();
while (next.length < width) next.push("");
return next;
});
const header = normalized[0];
const divider = Array.from({ length: width }, () => "---");
const body = normalized.slice(1);
return [
`### Table ${index + 1}`,
"",
`| ${header.join(" | ")} |`,
`| ${divider.join(" | ")} |`,
...body.map((row) => `| ${row.join(" | ")} |`),
].join("\n");
}
const clone = document.body ? document.body.cloneNode(true) : null;
if (clone) {
clone.querySelectorAll("script, style, noscript, svg, canvas, iframe").forEach((node) => node.remove());
}
const title = document.title || "";
const url = location.href;
const description = document.querySelector('meta[name="description"]')?.getAttribute("content") || "";
const main = clone?.querySelector("article, main, [role='main']") || clone;
const text = cleanText(main?.innerText || document.body?.innerText || "");
const tables = Array.from(document.querySelectorAll("table"))
.map((table, index) => tableToMarkdown(table, index))
.filter(Boolean);
const parts = [];
if (description.trim()) {
parts.push(`## Meta Description\n\n${cleanText(description)}`);
}
if (text) {
parts.push(`## Page Text\n\n${text}`);
}
if (tables.length > 0) {
parts.push(`## Tables\n\n${tables.join("\n\n")}`);
}
let content = parts.join("\n\n");
if (content.length > maxLocalChars) {
content = `${content.slice(0, maxLocalChars)}\n\n[Truncated locally by zncm Web Clipper]`;
}
return { title, url, content, contentType: "markdown" };
}
// Injected into the page to build an indexed list of interactive elements plus
// the visible page text. Each interactive element is tagged with
// `data-deer-index` so a follow-up deerPerformAction call can find it by index.
function deerObservePage(maxElements) {
const limit = Math.max(10, Number(maxElements || 150));
const DEER_ATTR = "data-deer-index";
document.querySelectorAll(`[${DEER_ATTR}]`).forEach((node) => node.removeAttribute(DEER_ATTR));
function isVisible(el) {
if (!(el instanceof Element)) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 1 && rect.height <= 1) return false;
const style = window.getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
if (el.disabled) return false;
return true;
}
function labelFor(el) {
const aria = el.getAttribute("aria-label");
if (aria && aria.trim()) return aria.trim();
const placeholder = el.getAttribute("placeholder");
const title = el.getAttribute("title");
const name = el.getAttribute("name");
const text = (el.innerText || el.textContent || "").trim();
if (text) return text.replace(/\s+/g, " ");
if (placeholder && placeholder.trim()) return placeholder.trim();
if (title && title.trim()) return title.trim();
if (el.value && String(el.value).trim()) return String(el.value).trim();
if (name && name.trim()) return name.trim();
const alt = el.querySelector?.("img[alt]")?.getAttribute("alt");
if (alt && alt.trim()) return alt.trim();
return "";
}
const selector = [
"a[href]",
"button",
"input:not([type=hidden])",
"select",
"textarea",
"[role=button]",
"[role=link]",
"[role=tab]",
"[role=menuitem]",
"[role=checkbox]",
"[role=radio]",
"[role=switch]",
"[contenteditable=true]",
"[onclick]",
].join(",");
const seen = new Set();
const elements = [];
let index = 0;
for (const el of document.querySelectorAll(selector)) {
if (index >= limit) break;
if (seen.has(el) || !isVisible(el)) continue;
seen.add(el);
el.setAttribute(DEER_ATTR, String(index));
const tag = el.tagName.toLowerCase();
let label = labelFor(el);
if (label.length > 200) label = label.slice(0, 200);
const entry = { index, tag, label };
const type = el.getAttribute("type") || el.getAttribute("role");
if (type) entry.type = type;
if (tag === "input" || tag === "textarea" || tag === "select") {
const value = String(el.value || "");
if (value) entry.value = value.slice(0, 80);
}
if (tag === "a") {
const href = el.getAttribute("href") || "";
if (href) entry.href = href.slice(0, 200);
}
elements.push(entry);
index += 1;
}
// Inline text extraction so this function stays self-contained when injected
// via chrome.scripting.executeScript (no dependency on other content-script fns).
let text = "";
try {
const clone = document.body ? document.body.cloneNode(true) : null;
if (clone) {
clone.querySelectorAll(`script, style, noscript, svg, canvas, iframe, [${DEER_ATTR}-skip]`).forEach((node) => node.remove());
}
const main = clone?.querySelector("article, main, [role='main']") || clone;
text = String(main?.innerText || document.body?.innerText || "")
.replace(/\s+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
} catch {
text = "";
}
return {
url: location.href,
title: document.title || "",
elements,
text,
};
}
// Injected into the page to perform a single action on the element previously
// tagged by deerObservePage (by index). Returns {status, message}.
async function deerPerformAction(action) {
const DEER_ATTR = "data-deer-index";
const type = String(action?.type || "").toLowerCase();
function findByIndex(idx) {
if (idx === undefined || idx === null || idx < 0) return null;
return document.querySelector(`[${DEER_ATTR}="${idx}"]`);
}
function fireInput(el, value) {
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
if (setter) {
setter.call(el, value);
} else {
el.value = value;
}
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
try {
if (type === "observe" || type === "extract") {
return { status: "ok", message: type === "extract" ? "Page text re-read." : "Observed page." };
}
if (type === "scroll") {
const el = findByIndex(action.index);
if (el) {
el.scrollIntoView({ block: "center", behavior: "instant" });
return { status: "ok", message: "Scrolled element into view." };
}
window.scrollBy(0, Math.round(window.innerHeight * 0.85));
return { status: "ok", message: "Scrolled down one viewport." };
}
if (type === "wait") {
const selector = String(action.wait_for || "").trim();
const timeoutMs = Math.max(500, Number(action.timeout_ms || 8000));
if (!selector) {
await new Promise((resolve) => setTimeout(resolve, Math.min(timeoutMs, 3000)));
return { status: "ok", message: "Waited." };
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (document.querySelector(selector)) {
return { status: "ok", message: `Element ${selector} appeared.` };
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
return { status: "timeout", message: `Element ${selector} did not appear within ${timeoutMs}ms.` };
}
if (type === "click") {
const el = findByIndex(action.index);
if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` };
el.scrollIntoView({ block: "center", behavior: "instant" });
if (typeof el.focus === "function") el.focus();
el.click();
return { status: "ok", message: `Clicked element ${action.index}.` };
}
if (type === "type") {
const el = findByIndex(action.index);
if (!el) return { status: "error", message: `No element with index ${action.index}. Re-observe the page.` };
el.scrollIntoView({ block: "center", behavior: "instant" });
if (typeof el.focus === "function") el.focus();
const value = String(action.text ?? "");
if (el.isContentEditable) {
el.textContent = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
} else {
fireInput(el, value);
}
if (action.submit) {
const enterInit = { bubbles: true, cancelable: true, key: "Enter", code: "Enter", keyCode: 13, which: 13 };
el.dispatchEvent(new KeyboardEvent("keydown", enterInit));
el.dispatchEvent(new KeyboardEvent("keyup", enterInit));
const form = el.form || el.closest("form");
if (form && typeof form.requestSubmit === "function") {
try {
form.requestSubmit();
} catch {
form.submit?.();
}
}
}
return { status: "ok", message: `Typed into element ${action.index}${action.submit ? " and submitted" : ""}.` };
}
return { status: "error", message: `Unsupported action type: ${type}` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : String(error) };
}
}
@@ -1,96 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 {
margin: 0 0 14px;
font-size: 16px;
}
.popup,
.options {
display: grid;
gap: 12px;
padding: 14px;
}
.popup {
width: 340px;
}
.options {
max-width: 720px;
}
label {
display: grid;
gap: 6px;
font-weight: 600;
}
label.checkbox {
grid-template-columns: auto 1fr;
align-items: center;
font-weight: 500;
}
label.checkbox input {
width: auto;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
input,
textarea {
width: 100%;
border: 1px solid #d4d4d4;
border-radius: 6px;
padding: 8px;
background: #fff;
color: inherit;
font: inherit;
font-weight: 400;
}
textarea {
resize: vertical;
}
button {
border: 0;
border-radius: 6px;
padding: 9px 12px;
background: #111827;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 600;
}
button.secondary {
background: #e5e7eb;
color: #111827;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.status {
min-height: 20px;
margin: 0;
color: #525252;
white-space: pre-wrap;
}
+5 -22
View File
@@ -1,18 +1,6 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_COP_TASK_DETAIL=true
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
@@ -36,17 +24,10 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_FRONTEND_URL=http://localhost:3000
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
@@ -54,3 +35,5 @@ VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# 我的笔记是否显示,侧边栏
VITE_STUDIO_ENABLED=false
@@ -15,15 +15,8 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
+2 -21
View File
@@ -14,34 +14,17 @@
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 GATEWAY_CORS_ORIGINS,包含前端源站;无界嵌入时还要加宿主源站,例如:
# GATEWAY_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:5183,http://localhost:5183
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
# --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# --- 任务看板(任务分析 / 三情分析)数据源开关 ---
# true=走真实接口;false 或未设=这两页使用本地假数据(演示用,无需后端)。
# .env.development / .env.production / .env.network / .env.sit 均默认 true(真实数据),
# 仅本机 .env 默认 false(假数据)。改后需重启 dev / 重新打包生效。
# VITE_USE_REAL_DATA=true
# --- 任务详情(登录深链 cop-task-detail)数据源开关 ---
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
# VITE_MOCK_COP_TASK_DETAIL=false
# VITE_POSITION_ROUNDTABLE_DEMO_MODE=false
# true=免登录直达岗位分析,完全使用前端 JSON,不请求任何后台接口。
# 仅限离线演示环境;生产环境请保持 false。
# VITE_POSITION_ROUNDTABLE_STANDALONE_MODE=false
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
@@ -54,6 +37,4 @@
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
# 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。
# VITE_FORCE_COLLAPSED_SIDEBAR=false
# VITE_GITHUB_OAUTH_TOKEN=
+23 -48
View File
@@ -1,57 +1,32 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# 内网 / 局域网生产打包(npm run build:network 或 pnpm build:network)
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
# CMZS 后端接口
VITE_API_BASE_URL=http://192.168.1.100/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
VITE_AIPI_CHAT_URL=http://192.168.1.100/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_GENERATE_CHAT_URL=http://192.168.1.100/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=http://192.168.1.100/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=http://192.168.1.100/knowlibrary
VITE_AIPI_B1COH_URL=http://192.168.1.100/web
VITE_AIPI_B2ACTOR_URL=http://192.168.1.100
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=http://192.168.1.100/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=http://10.19.200.5:8002/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+1 -20
View File
@@ -1,23 +1,12 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
@@ -30,21 +19,13 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的空间前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,5 +3,3 @@
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
-26
View File
@@ -1,26 +1,12 @@
# SIT 环境打包(pnpm build:sit / npm run build:sit)— 接口地址为 UAT 的 sit 版本
# 打包后变量会写入 dist,运行时不可再改
# 任务看板数据源:true=真实接口(默认);改成 false 可让任务分析/三情分析两页走本地假数据。
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.sit.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
# 多轮检索接口
@@ -33,24 +19,12 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.sit.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
-46
View File
@@ -1,46 +0,0 @@
**Comparison target**
- Source visual truth (dark intelligent-agent list): `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-75f6ce1c-cd70-4b8c-a80a-a3d100105c69.png`
- Additional source references: `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-00567484-ba8a-4595-b386-52e1467ae9cf.png` (segmented switch), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-02808803-05ba-4f2c-be50-c72b484a9f06.png` (business-chain node card), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-adf38fec-7baf-4d01-9c73-bdbae94b774e.png` (business-chain selector)
- Implementation screenshots: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-light.png`, `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-dark.png`
- Full-view comparison evidence: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-comparison.png`
- Viewport: 1280 × 720. Light route: `#/page/strategy/qa/position-roundtable`; dark embedded route: `#/page/strategy/qa/position-roundtable?embed=1&theme=dark`.
- State: local browser has no authenticated API session. The original intent API is invoked and reports `Authentication required`; business-chain and agent endpoints return no usable data.
**Findings**
- [P1] Populated agent and business-chain states cannot be compared yet.
Location: right agent list and non-planning-role chain diagram.
Evidence: reference images show populated cards and nodes; the implementation has `0 个` agents and no available business chain in the unauthenticated preview.
Impact: card density, selected-card indicator, chain node flow, and agent-detail transition cannot be truthfully verified against live data.
Fix: verify the route in an authenticated session with at least one public chain and tagged agents.
**Open Questions**
- The final business-chain stage arrangement is still pending product confirmation. The compact graph maps whatever stage configuration is returned, while intentionally omitting dependency-label text as requested.
**Implementation Checklist**
1. Left task panel is now display-only and retains its independent position-roundtable task adapter.
2. Business-chain selection uses the original roundtable card/radio/relationship-entry pattern.
3. Every position uses the original `useStep1Intent` interface and `Step1Panel` message list/input panel; only the position header changes.
4. The right switch now uses the exact `课题研究 / ai写作` segmented-control treatment (light gray track, white active state; dark blue glass equivalent).
5. The embedded layout widens the left panel to 360px and applies the deep-blue right-panel tokens used by the reference.
6. Other roles render a taller top-to-bottom card-node flow without dependency labels. The canvas supports mouse-drag panning, and each node retains only a name plus a single-line description.
**Follow-up Polish**
- [P3] If live content is unusually long, tune the compact flow node width or horizontal scrolling after seeing the production chain data.
**Comparison history**
- Iteration 1: task fields were editable and the right TDesign card tabs looked unlike the reference. Replaced the task form with display-only information and replaced the tabs with the existing `课题研究 / ai写作` segmented switch.
- Iteration 2: business-chain selection was a dropdown and the flow was vertical avatar chips. Replaced it with original-roundtable selection cards and compact business-chain configuration-style node cards with link lines but no dependency labels.
- Iteration 3: dark right-panel background differed from the supplied deep-blue reference. Applied the `#032650` / `#042b59` / `#2b8ffe` token family and captured the revised embedded dark state in the full-view comparison.
- Iteration 4: removed the custom disabled input from non-planning positions and reused the original roundtable input panel. Enlarged the left flow viewport, retained its connection lines, and removed node level/seat/status metadata.
- Iteration 5: converted the compact flow from left-to-right to top-to-bottom. Added mouse-drag panning and single-line description truncation; populated-chain visual QA remains blocked by the unauthenticated preview.
- Iteration 6: constrained the flow canvas to its card viewport, narrowed nodes from 190px to 158px, and realigned branch gaps plus connector anchors to prevent clipping and offset lines.
**Final result**
blocked
@@ -1,253 +0,0 @@
# AI 写作「四阶段内置智能体化」改造方案
> 目标:把 AI 写作现有的 **素材收集专家 / 写作大纲 / 作家写作 / 编辑审核** 四个功能,
> 从「硬编码的 LangGraph 节点 + 硬编码提示词」改造为由**内置功能型智能体**驱动——
> 每个阶段对应一个可配置的内置 agent(独立人设 SOUL、可配模型、可挂技能/工具)。
本文给出现状梳理、目标设计、以及**分步可独立交付的实现计划**。
---
## 0. 一句话结论
不推倒重来。**保留现有图的编排骨架、4 个用户干预点、流式与状态机,只把每个阶段节点的「大脑」从「硬编码 prompt 直调 LLM」替换为「加载对应内置 agent 的 SOUL+配置、在子线程上跑该 agent、按结构化契约解析其输出」。** 这样前端几乎不动,复杂逻辑(严格模式、素材不足求助、逐章流式、技能检索步骤)全部保留。
---
## 1. 现状梳理(精确到文件)
AI 写作是一条 LangGraph 图:`packages/harness/deerflow/agents/ai_writing/graph.py`
```
intent_parser → researcher → (pause 素材确认) → writer_outline → (pause 大纲确认)
→ writer_draft → (pause 草稿确认 / 素材不足求助) → editor → (pause 编辑打回) → done
```
四个待改造阶段(均为**硬编码节点**,直接调 LLM、不走 agent 运行时):
| 阶段(用户语言) | 节点文件 | 提示词 | 产出(`state.py`) |
|---|---|---|---|
| 素材收集专家 | `nodes/researcher.py` | `prompts/researcher_prompts.py` | `material_package`(keywords + materials) |
| 写作大纲 | `nodes/writer_outline.py` | `prompts/writer_prompts.py` | `current_outline`(Outline JSON) |
| 作家写作 | `nodes/writer_draft.py` | `prompts/writer_prompts.py` | `current_draft`(Markdown,逐章流式) |
| 编辑审核 | `nodes/editor.py` | `prompts/editor_prompts.py` | `review_result`(评分 + 问题列表 JSON) |
关键事实:
- **LLM 调用方式**:节点通过 `nodes/_utils.py` 的 `get_model()` + `llm_json()` **直接调 LLM**,提示词写死在 `prompts/*.py`。**没有**走 agent 人设 / 技能 / 工具体系。
- **模型**:全程单一 `state.model_name`(用户在写作表单里选的主模型),各阶段无法分别配。
- **干预点 / 流式 / 严格模式 / 素材不足求助**:都在 `graph.py` 的 pause 节点和 `writer_draft` 里,逻辑较重,需原样保留。
- **前端**:`frontend-web/src/open-canvas/contexts/AIWritingContext.tsx` 是状态机(`researching/writing_outline/writing_draft/reviewing/...`),时间线 `AIWritingTimeline.tsx` 按 `progress_events`(已带 `agent_name` 字段)渲染。
---
## 2. 可复用的现成机制(不用造轮子)
代码库里**已经有**一套成熟的「内置功能型智能体」范式——圆桌的 `roundtable-intent / roundtable-recommender / roundtable-coordinator`:
1. **资源即代码**:`app/gateway/routers/_roundtable_seed_assets/<agent-id>/`
- `config.yaml`(`id` / `name` / `description`,可扩展 `model` / `skills` / `tool_groups`)
- `SOUL.md`(人设 + 职责 + 工作流 + **结构化输出契约**)
2. **种子器**:`app/gateway/routers/_roundtable_seed.py::ensure_roundtable_functional_agents`
- 启动时 + 相关路由入口自愈:目录不存在才从资源字节级落盘到 `.deer-flow/agents/<id>/` + DB(**已存在则永不覆盖**,尊重本地编辑)。
3. **运行**:在**独立 thread** 上跑该内置 agent(见 `app/gateway/routers/intent.py`,`INTENT_AGENT_ID = "roundtable-intent"`),通过 lead agent 运行时把 SOUL.md 作系统提示,流式经 LangGraph `custom`/`messages` 通道透传。
4. **结构化输出约定**:SOUL 规定模型末尾输出固定 **marker + ```json```**(如 `[INTENT_READY]\n```json{...}```),后端用正则解析(`intent.py::_INTENT_READY_PATTERN`)。
> 我们要做的,本质就是**照搬这套范式,给 AI 写作做 4 个功能型 agent**,并让对应的图节点改成「跑这个 agent + 解析其结构化输出」。
另有 agents 通用体系可复用:`agents` 表、`/api/agents` CRUD、智能体管理页(配模型/技能/工具)——4 个写作 agent 落盘后天然出现在这里,可被 admin 编辑。
---
## 3. 目标设计
### 3.1 四个内置写作智能体(建议 id / 职责)
| Agent id | 名称 | 取代节点 | 结构化输出 marker | 产出 schema |
|---|---|---|---|---|
| `ai-writing-researcher` | 素材收集专家 | `researcher` | `[MATERIALS_READY]` | `MaterialPackage` |
| `ai-writing-outliner` | 大纲规划师 | `writer_outline` | `[OUTLINE_READY]` | `Outline` |
| `ai-writing-writer` | 作家 | `writer_draft` | `[SECTION_READY]`(逐章) | 章节 Markdown |
| `ai-writing-editor` | 编辑 | `editor` | `[REVIEW_READY]` | `ReviewResult` |
每个 agent 自带:
- `config.yaml`:`id / name / description` +(扩展)`model`(该阶段专用模型,可空=回退用户主模型)、`skills`(如 researcher 挂检索技能)、`tool_groups`。
- `SOUL.md`:由现有 `prompts/*.py` 内容迁移改写,**追加结构化输出契约**(严格 marker + JSON 格式 + 反例,仿 `roundtable-intent` SOUL 的「硬规则」写法,压制弱模型乱输出)。
### 3.2 节点改造方式(核心)
每个阶段节点保留**输入/输出契约不变**(仍读写 `state` 的同一批字段),内部从:
```
# 旧:硬编码 prompt 直调
model = get_model(config, state["model_name"])
result = llm_json(model, SYSTEM_PROMPT, USER_PROMPT.format(...))
```
改为:
```
# 新:跑对应内置 agent,按 marker 解析
result = await run_writing_agent(
agent_id="ai-writing-outliner",
state=state, config=config,
user_payload=<本阶段输入>, # 意图/素材/大纲/草稿等拼成的一段输入
marker="[OUTLINE_READY]", # 结构化输出契约
)
```
新增统一助手 `nodes/_agent_runner.py::run_writing_agent(...)`:在子 thread 上跑指定内置 agent(复用 `intent.py` 的 run/stream 机制)、把流式 chunk 透传到 `custom` 通道(前端时间线不回归)、用复用的 `_utils.parse_json` 多级容错解析 marker 后的 JSON。
### 3.3 为什么选这个方案(A)而非全多智能体重写(B)
- **方案 A(推荐)**:保留图骨架,逐节点替换大脑。前端基本不动;干预点/流式/严格模式/素材求助原样保留;可**按阶段灰度 + feature flag 回退**,风险可控。
- **方案 B(不推荐)**:仿圆桌重做成多 agent 编排,需重写编排、状态、流式、4 个干预点对接,工作量数倍、回归面巨大,收益(更"纯"的多 agent)对本场景并不必要。
---
## 4. 配置与可管理性
- **落盘**:仿 roundtable 新增 `_ai_writing_seed_assets/` + 一个 `ensure_ai_writing_functional_agents()` 种子器,在 Gateway 启动与 ai_writing 会话入口自愈。
- **管理界面**:4 个 agent 落盘后即出现在智能体管理页,admin 可编辑 **SOUL(人设)/ 模型 / 挂载技能**。可选:在「文章类型配置页」旁加一个「写作智能体配置」入口,或给这 4 个打一个 `tag` 便于筛选。
- **模型优先级**:节点跑 agent 时 `该 agent config.model > 用户写作表单选的主模型 model_name`(保留现有"用户选模型"语义,新增 per-阶段覆盖能力)。
- **技能**:researcher 现有的技能检索能力(`search/skill_search.py`)改为 `ai-writing-researcher` 的**挂载技能**,admin 可增减;检索步骤事件(`SkillStep`)形态保留给前端展示。
---
## 5. 分步实现计划(每步可独立交付 / 验收)
### Phase 0 · 契约冻结
- 定 4 个 agent 的 `id / name`、每个的 **marker + JSON schema**(与 `state.py` 的 `MaterialPackage/Outline/DraftArticle/ReviewResult` 严格对齐)。
- **验收**:本文 §3.1 / §6 的契约评审通过、字段与现有 TypedDict 一一对应。
### Phase 1 · 内置 agent 种子化(后端,纯新增,不接管流程)
- 新建 `app/gateway/routers/_ai_writing_seed_assets/<4 个 agent>/{config.yaml, SOUL.md}`;SOUL 由现有 `prompts/*.py` 迁移改写 + 追加结构化输出契约。
- 仿 `_roundtable_seed.py` 写 `ensure_ai_writing_functional_agents()`,挂到启动 lifespan + ai_writing 入口。
- **验收**:启动后 4 个 agent 出现在 `.deer-flow/agents/`、DB、`/api/agents`,能在智能体管理页查看/编辑;**此阶段不改图,写作流程行为不变**。
### Phase 2 · 节点接入「跑内置 agent」(后端,逐阶段灰度)
- 新增 `nodes/_agent_runner.py::run_writing_agent(...)`(子线程跑 agent + 流式透传 + marker 解析)。
- 加 feature flag:`config.yaml → ai_writing.use_builtin_agents.{editor,outliner,researcher,writer}`(默认 false=走旧节点)。
- **接入顺序(由易到难)**:
1. **editor**(最简单:小 JSON、无检索、无逐章流式)→ flag 打开端到端验证。
2. **outliner**(输出 Outline JSON)。
3. **researcher**(保留技能检索步骤事件、素材确认暂停)。
4. **writer**(最难:保留**逐章流式** + **严格模式素材不足求助** `blocked_sections`;建议**仍由节点控制"逐章循环"**,每章调一次 `ai-writing-writer`,`section_help` 暂停逻辑不动)。
- 每接一个,旧 prompt 与旧逻辑**保留**,节点按 flag 二选一。
- **验收**:每个阶段 flag 打开后端到端走通,产出结构与旧版一致,干预点 / 流式 / 严格模式 / 求助暂停均不回归。
### Phase 3 · 每阶段 agent 配置化
- per-agent 模型覆盖生效(`agent.config.model > model_name`)。
- researcher 的检索能力改为挂载技能,admin 可增减。
- 智能体管理页可编辑这 4 个的 SOUL / 模型 / 技能;config 热加载,新会话即生效、不重启前端。
- **验收**:admin 改某阶段 agent 的模型 / SOUL / 技能后,新会话立即按新配置运行。
### Phase 4 · 前端适配(小改)
- 时间线 / 状态文案:阶段名读对应 agent 的 `name`(`progress_events.agent_name` 已存在,基本沿用,去掉写死文案)。
- 可选:写作表单 / 设置加「查看·配置写作智能体」入口,跳智能体管理页。
- **验收**:前端展示各阶段对应的智能体名;配置入口可达。
### Phase 5 · 清理收口
- 四阶段稳定后,默认打开全部 flag、移除旧 prompt 直调路径与 flag(prompts 历史留在 git)。
- 更新 `offline-backend-20260512/backend/CLAUDE.md` 架构描述。
- **验收**:回归测试通过、冗余删除、文档同步。
---
## 6. 结构化输出契约(与现有 state 对齐)
各 agent SOUL 末尾必须输出 `marker + ```json``` 块,schema 直接对齐 `state.py`:
- `ai-writing-researcher` → `[MATERIALS_READY]`:`{ "keywords": string[], "materials": Material[] }`(`Material` 见 `state.py`,含 `id/title/content/source/relevance_score/source_type/...`)。
- `ai-writing-outliner` → `[OUTLINE_READY]`:`Outline = { "title": string, "sections": OutlineSection[] }`(`OutlineSection = { section_title, key_points: KeyPoint[], material_ids }`)。
- `ai-writing-writer` → 逐章 `[SECTION_READY]`:`{ "section_title": string, "markdown": string, "needs_more_material"?: { "reason": string } }`(`needs_more_material` 触发严格模式求助 `blocked_sections`)。
- `ai-writing-editor` → `[REVIEW_READY]`:`ReviewResult = { "verdict": "pass"|"reject", "fact_score", "logic_score", "language_score", "overall_score", "pass_threshold", "revision_notes": ReviewIssue[] }`。
解析复用 `nodes/_utils.py::parse_json`(多级容错),marker 正则仿 `intent.py::_INTENT_READY_PATTERN`。
---
## 7. 风险与注意
1. **弱模型结构化输出不稳**:SOUL 写严格格式 + 反例 + 「输出 marker 后立刻闭嘴」硬规则(仿 `roundtable-intent` SOUL §0);解析层复用 `parse_json` 多级降级。
2. **writer 的逐章流式 + 严格模式求助最复杂**:建议**循环控制权留在节点**(每章调一次 agent),不要让 agent 一次吐全文,否则 `blocked_sections` / `section_help` / 逐章流式都要重做。
3. **researcher 技能检索步骤事件**(`SkillStep`)前端有专门卡片:迁移时保留这些 `progress_events` 形态。
4. **种子器 never-overwrite**:迭代 SOUL 后**老部署不会自动更新**(与圆桌同一问题)。运维更新办法:删 `.deer-flow/agents/<id>/` 重新种子,或后续给种子器加「内置功能 agent 按版本/hash 覆盖」开关。
5. **子线程隔离**:跑写作 agent 的子 thread 与主 ai_writing thread 状态隔离,注意 checkpointer 与清理(沿用 ai_writing 既有的会话清理)。
6. **离线内网**:researcher 的检索一律走技能(不联网),与现有「离线部署禁 web_search、走 skill」策略一致。
---
## 8. 涉及文件清单(落地时对照)
**后端(主要改动)**
- 新增 `app/gateway/routers/_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}`
- 新增 `app/gateway/routers/_ai_writing_seed.py`(种子器,仿 `_roundtable_seed.py`)
- 新增 `packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py`(`run_writing_agent`)
- 改 `nodes/{researcher,writer_outline,writer_draft,editor}.py`(按 flag 二选一)
- 改 `config.yaml`(新增 `ai_writing.use_builtin_agents.*` 开关;可选 per-agent 默认模型)
- 保留 `prompts/*.py`(回退用,Phase 5 删)
**前端(小改)**
- 改 `src/open-canvas/components/ai-writing/AIWritingTimeline.tsx`(阶段名读 agent name)
- 可选:写作表单/设置加「配置写作智能体」入口(跳 `/page/...` 智能体管理页)
**文档**
- 本文件
- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md`
---
## 9. 实施记录(2026-06-11,Phase 1-4 已落地)
### 已交付
- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。
- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。
- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。
- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。
- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。
### 与 §6 契约的修订(以实现为准)
为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**:
| Agent | 实际契约 |
|---|---|
| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 |
| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) |
| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) |
| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) |
另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
## 10. Phase 3 收尾:检索类型两项化 + 配置技能驱动检索(2026-06-13 已实施)
本节落地了 §9 遗留的「researcher 检索改挂载技能」,并按新需求重新设计了检索类型与 PAUSE-1 交互。
### 10.1 检索类型(material_source)收敛为两项
- 写作表单「素材来源」改名「检索类型」,只剩 `general`(通用检索,默认)/ `notebook`(我的空间);旧值 `knowledge_base` / `skill` 由 researcher 节点按 `general` 兼容(老会话 resume 不受影响)。
- 前端:`AIWritingForm.tsx`(选项/标签)、`WritingFormContext.tsx`(默认值/类型/applyProposal 旧值映射)、`types/ai-writing.ts`、`message-list.tsx`(审批卡标签兼容旧值)。
### 10.2 通用检索 = 素材收集专家配置的技能编排
- `ai-writing-researcher` agent 的 `config.yaml → skills` 是权威配置(智能体管理页可增删/替换);种子默认 `[knowledge-base-search]`。
- 内置技能 **knowledge-base-search(知识库检索)**:把原 web/intranet 关键词并发检索包装成技能。researcher 对它特殊处理——不拼 description,直接对 Step1 生成的检索词做原生并发检索(行为与旧 knowledge_base 模式一致)。其它配置技能按 `意图 + description` 拼定向 query 检索。按配置顺序执行、累积达 `max_materials` 早停(`stop_early`)。
- 技能配置为空/全部失效 → 内存兜底回退知识库检索(`researcher._load_configured_skills`),检索永不瘫痪。
- 种子:`_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` → `skills/public/`(never-overwrite);旧部署的 researcher `config.yaml` 缺 `skills` 键时就地补默认(显式 `skills: []` 不动)。`_ai_writing_seed.py::ensure_ai_writing_functional_agents()` 一并处理。
### 10.3 PAUSE-1 输入框 → 匹配技能检索并**追加**
- 用户在素材确认卡输入自然语言(如「检索 A 的数据」)→ `re_search + user_query`:配置技能 >1 个时 `SkillPicker(candidates=配置技能)` 让模型按输入匹配排序(流式打字机保留),单技能直接用;用用户原话(非知识库技能附 description)检索。
- **追加语义**:保留原素材列表与顺序,新素材按 URL 去重后追加在末尾(ID 续号),跳过 LLM 重排与 max_materials 截断(仅 100 条硬上限),summary 显示「追加检索到 X 条新素材,当前共 Y 条」。原 20 条 + 新 20 条 = 40 条。
- 「技能检索」独立按钮已删除(通用检索本来就走技能编排);后端继续兼容旧 `enable_skill_search` action(按 user_query 追加处理)。无输入的「重新搜索」仍为覆盖式重检。
- 前端:`InterventionCard.tsx`(按钮/文案,hasQuery 时按钮文案「检索并追加素材」)、`MaterialsToolbar.tsx`(提示语)、`useAIWritingStream.ts`(素材卡回到 researching 一律置 skillSearchPending,让 SkillStepsCard 占位即时挂载)。
### 10.4 测试
- `tests/test_ai_writing_material_source.py` 重写(9 例):缺省/旧值→通用检索、自定义技能 description query、user_query 追加+URL 去重+多技能 picker 匹配(candidates 断言)、无输入重检覆盖、空配置回退。
- `tests/test_ai_writing_seed.py` 扩展(+5 例):技能资产存在、researcher 种子含默认 skills、技能种子 never-overwrite、缺 skills 键就地补丁、显式 `[]` 不动。
@@ -0,0 +1,228 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -1,507 +0,0 @@
# AI 写作「对话驱动写作台」实现方案(可落地版)
> 需求:**复制**现有 AI 写作代码,新增一个独立页面(**原页面/原路由零改动**)。在新页面里
> **大改交互流程**——去掉「素材收集 / 大纲规划 / 作家写作 / 编辑审核」四个阶段干预卡里的**输入框**,
> 只**保留原来的确认按钮**;在页面**底部新增一个统一的多行智能输入框**,具备**强意图理解**能力:
> 重新搜索、新增检索词、添加检索素材、重新规划大纲、按要求改大纲、提修改意见、审核意见、
> 素材不足继续搜集、对写作内容提问……**覆盖现有全部交互甚至更多**。
>
> 交互节奏不变:**一步一确认**——进行到某个专家阶段时,交互就针对该专家(阶段作用域);
> 用户可以**点原来的确认按钮**,也可以**在底部输入框里发「继续下一步」的意图**推进。
> 意图**拿不准时**主动向用户**发起协助/澄清**,绝不盲目触发高代价动作(定稿 / 重搜等)。
本文是**实现指引**,落地时按第 9 节路线图分阶段推进。文中所有文件路径、符号名均对齐当前代码。
---
## 1. 现状梳理(改造地基)
现有 AI 写作是一套**独立 LangGraph 子系统**(不是 lead_agent),前后端都已成型、可复用。
### 1.1 后端 `ai_writing` graph
- 图工厂:`offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py`
(`make_ai_writing_graph`,registered as `assistant_id="ai_writing"`)
- 状态:`.../ai_writing/state.py`(`AIWritingState`)
- 四个「阶段大脑」节点:
| 阶段 | 节点 | 文件 |
|------|------|------|
| 素材收集 | `researcher` | `nodes/researcher.py` |
| 大纲规划 | `writer_outline` | `nodes/writer_outline.py` |
| 作家写作 | `writer_draft` | `nodes/writer_draft.py` |
| 编辑审核 | `editor` | `nodes/editor.py` |
- **五个暂停点**(`interrupt()`,`Command(resume=payload)` 续跑)——这是对话改造的核心可复用资产:
| pause_point | 语义 | 现有 action |
|-------------|------|-------------|
| `material_confirm` | 素材确认 | `confirm` / `re_search` / `enable_skill_search` |
| `outline_confirm` | 大纲确认 | `confirm` / `re_outline` |
| `draft_confirm` | 草稿确认 | `to_editor` / `finalize` / `user_revise` |
| `review_confirm` | 编辑打回确认 | `accept_review` / `force_finalize` |
| `section_help` | 素材不足求助 | `section_help`(+`sectionDecisions`) |
- resume 载荷(`UserInterventionPayload`,`types/ai-writing.ts`)字段:`action`、`approvedMaterialIds`、
`userQuery`(自然语言补充检索)、`editedOutline`、`outlineFeedback`、`userRevisionNotes`、
`overrideVerdict`、`selectedIssueIndices`、`sectionDecisions`。
**协议已同时支持「自然语言 + 结构化 action」**——这是对话层能复用的关键。
> 结论:**状态机 / 流式 / 暂停·续跑协议 / 持久化都可复用**,无需重写引擎。改造本质是
> 「把分散在干预卡里的输入框,换成一个底部智能输入框 + 意图路由层」,再把意图翻译回**现有
> `action + payload`**;仅少量新意图(对内容提问、素材不足自动回退重搜、追加审核意见)需新增能力。
### 1.2 前端 `open-canvas` 子系统
- 主页面:`frontend-web/src/open-canvas/pages/AIWritingPage.tsx`
- 路由注册:`open-canvas/routes/OpenCanvasRoutes.tsx`(`/page/canvas/ai-writing/*`)、
`pages/WorkspaceRoutes.tsx`(`/page/workspace/ai-writing/*`)、`pages/NotebookAIWritingPage.tsx`
- 状态机 + 流式:`contexts/AIWritingContext.tsx`(`useReducer` + LangGraph `useStream`,`assistantId:'ai_writing'`)
- 事件翻译 / reducer:`hooks/useAIWritingStream.ts`
- 类型:`types/ai-writing.ts`
- **左栏面板**:`components/ai-writing/AIWritingPanel.tsx`(时间线 + 干预卡 + 状态栏;idle 时挂启动表单)
- **要改造的输入 UI**(本次重点):
- 干预卡 `components/ai-writing/InterventionCard.tsx`:内含
`MaterialConfirmCard`(素材:有 `MaterialsToolbar` 输入框)、
`OutlineConfirmCard`(大纲:有 feedback textarea)、
`DraftConfirmCard`(草稿:有「修改意见」textarea)、
`ReviewConfirmCard`(审核:有「补充修改指令」textarea)、
`SectionHelpCard`(素材不足:按钮组)。
**每张卡里都既有输入框又有确认/动作按钮**——本次要**去掉输入框、保留按钮**。
- 启动表单 `components/ai-writing/AIWritingForm.tsx`(idle 阶段的配置表单)——本次由底部输入框接管启动。
- **已存在的对话原型**(可借鉴):`components/ai-writing/WritingSetupChat.tsx`
(`useThreadStream` 起 lead_agent 线程 + `setup_writing` 审批卡回填表单;仅管启动前配置,不直接复用)。
- 底部输入框可直接复用 UI 组件:`@/components/ai-elements/prompt-input`
(`PromptInput` / `PromptInputTextarea` / `PromptInputSubmit` / `PromptInputFooter`,见 `WritingSetupChat`)。
---
## 2. 目标与范围
### 2.1 必须满足
1. **复制式新增**:新目录 / 新路由 / 新侧边栏入口,**旧页面、旧路由、旧组件零改动**(「初始的不用动」)。
2. 页面**底部单一多行智能输入框**接管全流程交互;四个阶段干预卡里的**输入框全部移除**。
3. **保留确认按钮**:每个阶段到达暂停点时,消息流里仍渲染**产物卡 + 原来的确认/动作按钮**
(素材「确认素材」、大纲「确认大纲」、草稿「让编辑审核 / 确认定稿」、审核「按编辑意见修改 / 强制定稿」、
素材不足的三选一按钮)。用户**点按钮**或**在底部输入框发自然语言意图**二选一均可推进。
4. **强意图理解**:把用户自由文本准确路由到正确动作,**覆盖现有全部交互 + 更多**(见 3.2)。
5. **阶段作用域**:意图解析必须知道「当前停在哪个专家阶段」,把同一句话按当前阶段翻译成对应 action。
6. **素材不足自动回退重搜**:修改意见需要的信息现有素材没有时,先重搜补素材、补完自动继续原意图。
7. **拿不准就协助**:低置信意图**不猜着执行**,走澄清追问(clarify)或用户协助。
8. 保留右侧草稿实时预览(可编辑)与时间线 / 进度展示。
### 2.2 明确不做(本期)
- 不改旧 `AIWritingPage` 及其 `AIWritingForm` / `InterventionCard`(这两个是要在**副本**里改的)。
- 不替换 `ai_writing` graph 四个阶段大脑逻辑,不动五个暂停点 / checkpoint / 持久化。
- 不引新状态管理库(继续 `useReducer` + `useStream`)。
### 2.3 复制策略(关键决策)
「复制一份」= **复制会被大改的表现层组件**(页面 + 干预卡 + 面板),**复用不改的引擎层**:
| 复制(新建副本,大改) | 复用(原样,不改;仅对 `types` 做**追加式**扩展) |
|------------------------|--------------------------------------------------|
| 新页面 `AIWritingChatPage.tsx` | `contexts/AIWritingContext.tsx`(状态机 + 流式) |
| 新面板 `WritingChatPanel.tsx`(消息流 + 底部输入框) | `hooks/useAIWritingStream.ts`(事件 → state) |
| 新消息卡 `chat-cards/*`(产物卡+确认按钮,**无输入框**) | `components/ai-writing/AIWritingDraftPanel.tsx`(右侧草稿预览) |
| 新意图客户端 `api/ai-writing-intent.ts` | 各流式展示子组件(`MaterialsCard`/`OutlineCard`/`ReviewCard`/`SkillStepsCard`…) |
| (可选)新 `contexts/WritingChatContext.tsx` 包一层「pending 意图 / 澄清态」 | `types/ai-writing.ts`(**只加字段不改旧字段**,保证旧页面不受影响) |
> **不复用、也不改** 的三个「输入框组件」:`AIWritingForm` / `InterventionCard` / `WritingFormContext`——
> 旧页面继续用它们,新页面完全不引用它们。这样「初始的不用动」得到硬保证。
>
> 为什么复用 `AIWritingContext` 而不复制它?它**不含任何输入框 UI**,只是「状态机 + 与后端 graph 的
> 流式桥」,复用它**不会改动旧页面的任何呈现**(旧页面照常在自己的 Provider 实例里跑)。复用能显著
> 降低维护成本、避免两套引擎漂移。若要绝对物理隔离,也可整目录复制 `AIWritingContext` +
> `useAIWritingStream` 到新目录——**推荐先复用**,仅当后续需要给对话态加大量图内新协议时再分叉。
---
## 3. 核心设计
### 3.1 三层结构
```
┌──────────────────────────────────────────────────────────────┐
│ 对话 UI 层(新) WritingChatPanel │
│ - 消息流:用户消息 + AI 阶段产物卡(含【保留的确认按钮】)+ 进度 │
│ - 底部:统一多行智能输入框(唯一自由输入入口) │
│ - 右侧:草稿实时预览(复用 AIWritingDraftPanel) │
└───────────────┬──────────────────────────────────────────────┘
│ 用户自然语言(+ 当前 status/pausePoint 上下文)
▼
┌──────────────────────────────────────────────────────────────┐
│ 意图解析层(新,核心) POST /api/ai-writing/sessions/{id}/intent │
│ 输出:{ intent, pausePoint, action, payload, │
│ needResearch, researchQuery, answer, confidence, clarify }│
└───────────────┬──────────────────────────────────────────────┘
│ 结构化 action + payload
▼
┌──────────────────────────────────────────────────────────────┐
│ 执行层(复用现有 AIWritingContext) │
│ idle → startWriting(AIWritingRequest) │
│ 暂停 → submitIntervention(UserInterventionPayload) │
│ 问答 → 旁路回答(不改写作状态) │
│ 素材不足 → 先重搜、缓存 pending、补完自动续跑 │
└──────────────────────────────────────────────────────────────┘
```
**设计原则**:意图解析层是「翻译官」——把自由文本翻译成**执行层已经懂的指令**。能复用
`action + payload` 的绝不新造协议;确认按钮点击**直接走执行层**(不经意图层,零延迟、零误判)。
### 3.2 意图分类体系(覆盖全流程 + 更多)
按「意图 → 触发时机(当前 status/pausePoint)→ 映射执行」组织,即意图解析引擎的目标 schema。
**「甚至更多」** = G(问答)、H(控制)、以及 B/C/D/E 里的「素材不足自动回退重搜」。
#### A. 启动类(status = `idle`)
| 意图 | 用户说法示例 | 映射 |
|------|-------------|------|
| `start_writing` | 「写一篇关于新能源出口的深度分析,2000 字」 | 解析成 `AIWritingRequest` → `startWriting()` |
| `start_with_outline` | 「按这个大纲写:…」 | `AIWritingRequest.userOutline` |
| `start_imitate` | 「仿照这篇样文写…」 | `writingModeType='imitate'` + `sampleText` |
#### B. 素材类(pausePoint = `material_confirm`)
| 意图 | 示例 | 映射 |
|------|------|------|
| `confirm_materials` | 「素材可以,继续 / 下一步」 | `{action:'confirm', approvedMaterialIds}` |
| `research_again` | 「重新搜索素材」 | `{action:'re_search'}`(无 userQuery=覆盖式重检) |
| `add_material`/`add_keyword` | 「再补点关于 X 的素材」「加个关键词 Y」 | `{action:'re_search', userQuery:'X/Y'}`(追加) |
| `select_materials` | 「只用第 1、3 条」 | `{action:'confirm', approvedMaterialIds:[…]}` |
| `drop_materials` | 「去掉第 2 条」 | 同上(反选后的子集) |
#### C. 大纲类(pausePoint = `outline_confirm`)
| 意图 | 示例 | 映射 |
|------|------|------|
| `confirm_outline` | 「大纲 OK,开始写」 | `{action:'confirm'}` |
| `reoutline` | 「重写大纲」 | `{action:'re_outline'}`(feedback 可空) |
| `revise_outline` | 「第二章拆成两节」「调整顺序」 | `{action:'re_outline', outlineFeedback:自然语言}` |
#### D. 写作 / 草稿类(pausePoint = `draft_confirm`)
| 意图 | 示例 | 映射 |
|------|------|------|
| `to_editor` | 「交给编辑审核」 | `{action:'to_editor'}` |
| `finalize` | 「就这样定稿」 | `{action:'finalize'}` **(高代价:低置信必澄清)** |
| `revise_draft` | 「第三段太啰嗦,精简」「补个 2024 案例」 | `{action:'user_revise', userRevisionNotes:意见}` |
| `rewrite_section` | 「重写第二章」 | `user_revise` + 章节定位写进 notes |
| `change_tone/style` | 「更口语一点 / 更正式」 | `user_revise` + notes |
| `apply_review`(若已有审核结果) | 「按编辑意见改」「只接受第 1、2 条」 | `{action:'user_revise', selectedIssueIndices:[…]}` |
#### E. 编辑审核类(pausePoint = `review_confirm`)
| 意图 | 示例 | 映射 |
|------|------|------|
| `accept_review` | 「按编辑意见改」 | `{action:'accept_review', selectedIssueIndices}` |
| `partial_accept_review` | 「只接受第 1、2 条」 | `accept_review` + `selectedIssueIndices` 子集 |
| `add_review_opinion` | 「再补一条:核查数据准确性」 | `accept_review` + `userRevisionNotes`(并入补充指令,现协议已支持) |
| `force_finalize` | 「不改了,强制定稿」 | `{action:'force_finalize', overrideVerdict:true}` **(高代价:必澄清)** |
#### F. 素材不足求助类(pausePoint = `section_help`)
| 意图 | 映射 |
|------|------|
| `section_supplement` | `{action:'section_help', sectionDecisions:[…'supplement']}` |
| `section_loose` | `sectionDecisions:[…'loose']` |
| `section_delete` | `sectionDecisions:[…'delete']` |
#### G. 问答类(**新**;任意 status;旁路,不改写作状态)
| 意图 | 示例 | 映射 |
|------|------|------|
| `ask_about_content` | 「第二章讲了啥?」「为什么用这个论点?」 | 意图节点直接带回 `answer`,对话流渲染(见 5.4) |
| `ask_meta` | 「现在到哪步了?」「用了哪些素材?」 | 读当前 state 直接回答,不走图 |
#### H. 控制类(**新 / 半新**)
| 意图 | 映射 |
|------|------|
| `next_step`(通用「继续/下一步」) | 按**当前 pausePoint** 映射到该阶段的默认确认(material/outline→`confirm`,draft→`to_editor`…;等价点确认按钮) |
| `restart_stage` | 重跑当前阶段(复用 `resumeWriting()` 空 command 续跑) |
| `run_background` | 转后台自动跑:`suspendAIWritingToBackground()` |
| `cancel` | `pauseWriting()`(`stream.stop()`) |
| `unknown/clarify` | 无法判定 → 反问澄清 / 用户协助(**防误触发硬指标**) |
### 3.3 「素材不足自动重搜」判定与回退(用户强调点)
触发:用户在**大纲/草稿/审核**阶段提出修改意见,但意见需要的信息现有素材里没有。
判定来源(组合):
1. **意图节点直接判**:把「素材摘要(keywords + 每条 title/source)」喂给意图 prompt,让它判断
「这条意见能否用现有素材满足」;不能 → `needResearch=true` + `researchQuery`。
2. **执行阶段兜底**:`writer_draft` 严格模式已有 `blocked_sections` → `section_help` 暂停点,
对话层把它翻译成「素材不足,是否重搜?」的追问。
回退动作(**首期纯前端实现,不动后端协议**):`needResearch=true` 时,前端把用户的修改
`payload` 缓存为 `pendingIntent`,先 `submitIntervention({action:'re_search', userQuery:researchQuery})`
补素材;监听到新一轮 `material_confirm`(素材追加完成)后,**自动提交** `pendingIntent`。
需处理「重搜期间用户又改主意」→ 允许覆盖 pending。
---
## 4. 意图解析引擎:方案与契约
**采用方案 A:后端 LLM 意图节点(独立 REST)**,叠加高频短句**规则快路径**优化。
理由:`needResearch` 判定需要**完整写作上下文**(素材/大纲/草稿),后端天然持有 checkpoint state;
与 `setup_writing` 一脉相承,离线模型选择策略统一;输出 schema 稳定、前端只消费。
### 4.1 后端入口
`app/gateway/routers/ai_writing.py` 新增:
```
POST /api/ai-writing/sessions/{session_id}/intent
Body: { text: string, status: string, pausePoint?: string }
Resp: IntentResult(见 4.3)
```
实现:读该 session 的 checkpoint state(素材摘要 / 大纲纲要 / 草稿字数·章节 / 审核意见)→
用 `ai_writing` 配置模型(复用 `intent_parser.py` 同套 `create_chat_model` + `parse_json` +
模型容错 `_model_fallback`)解析 → 返回结构化契约。**不改图拓扑**。
新增 prompt:`agents/ai_writing/prompts/intent_router_prompts.py`
- 输入:用户文本、`current_status`/`pause_point`、素材摘要、大纲纲要、草稿字数+章节标题、审核意见列表。
- 输出:严格 JSON(4.3 契约)。硬要求:**只在证据充分时给高 confidence,否则给 `clarify`**;
`finalize`/`force_finalize`/`re_search` 等高代价意图**必须**高置信才不澄清。
### 4.2 前端快路径(先跑规则,未命中再调后端)
`api/ai-writing-intent.ts` 里对**当前 pausePoint** 做少量确定性短语命中,直接产出 payload,省一次往返:
- 「继续/下一步/确认/可以/ok/好的」→ 当前阶段默认 `confirm`(draft 阶段=`to_editor`)。
- 「重新搜索/重搜」(material)→ `re_search`。「重写大纲」(outline)→ `re_outline`。
- 「定稿/强制定稿」→ **不进快路径**(高代价,一律走后端 + 可能澄清)。
- 命中即执行;未命中 → 调后端 `intent` 接口。
### 4.3 意图解析输出契约
```jsonc
{
"intent": "revise_draft", // 见 3.2 分类
"pausePoint": "draft_confirm", // 需要 resume 时;否则 null
"action": "user_revise", // 映射到现有 action
"payload": { // 直接可喂 submitIntervention 的字段(camelCase)
"userRevisionNotes": "第三段精简,并补一个 2024 年的案例"
},
"needResearch": true, // 素材是否不足
"researchQuery": "2024 行业案例", // needResearch 时的检索意图
"answer": null, // 问答类意图的直接回答(旁路)
"confidence": 0.86,
"clarify": null // 低置信时的反问话术(非空则不执行、只追问)
}
```
前端消费顺序:
1. `answer` 非空 → 对话流渲染回答(问答旁路,不动写作状态)。
2. `clarify` 非空 **或** `confidence < 阈值(建议 0.6)` → 渲染反问气泡,等用户澄清(**防误触发**)。
3. `needResearch=true` → 缓存 `pendingIntent=payload`,先重搜,收到新素材后自动提交。
4. 否则 → `idle` 走 `startWriting(...)`;暂停态走 `submitIntervention({action, ...payload})`。
---
## 5. 前端改造方案
### 5.1 新页面与路由(复制式)
- 新页面:`open-canvas/pages/AIWritingChatPage.tsx`
(骨架照抄 `AIWritingPage.tsx` 的 Provider 包裹与左右分栏;左栏换成 `WritingChatPanel`,右栏仍
`AIWritingDraftPanel`;idle 不再挂 `AIWritingForm`,改由底部输入框启动)。
- 路由注册(**新增,不动旧行**):
- `open-canvas/routes/OpenCanvasRoutes.tsx` 加 `path="ai-writing-chat/*"`。
- 侧边栏 `core/page-layout/sidebar-menu.ts`:在 `qa-research` 旁新增
`{ id:"qa-research-chat", label:"AI写作(对话版)", icon:FileText, path:"/page/canvas/ai-writing-chat" }`。
(或先不加、内部灰度直链验证。)
### 5.2 新面板 `WritingChatPanel.tsx`(左栏主体)
职责 = 消息流容器 + 底部输入框,替代 `AIWritingPanel` 里的「时间线 + InterventionCard + 状态栏」。
复用 `useAIWriting()` 读 state / 调 `startWriting` / `submitIntervention` / `pauseWriting` /
`suspendAIWritingToBackground`。
消息流渲染(把 `ProgressEvent` / 阶段产物适配成消息卡,复用现有展示子组件):
- 运行中:`research_progress`/`outline_chunk`/`draft_chunk`/`review_chunk` → 进度 / 流式卡
(复用 `SkillStepsCard`/`OutlineStreamingView`/`DraftGeneratingView`/`ReviewStreamingView` 的展示部分)。
- 到达暂停点:渲染 **产物卡 + 保留的确认按钮**(见 5.3),不再有输入框。
- 用户消息、AI 澄清追问、问答回答:普通气泡。
底部输入框:直接复用 `@/components/ai-elements/prompt-input`(`PromptInput`+`PromptInputTextarea`+
`PromptInputSubmit`),Enter 发送、Shift+Enter 换行;发送时读当前 `state.status`/`currentPause?.pausePoint`
交给意图路由(见 5.5)。运行中(非暂停点)可禁用发送或转「排队/提示当前在生成」。
### 5.3 新消息卡 `chat-cards/*`(**保留按钮、去掉输入框**)
把 `InterventionCard.tsx` 的五张卡各复制一份到 `open-canvas/components/ai-writing-chat/chat-cards/`,
**删掉其中的 textarea / MaterialsToolbar 输入部分,保留产物展示 + 确认/动作按钮**:
| 新卡 | 展示(复用) | 保留的按钮 | 删除的输入 |
|------|-------------|-----------|-----------|
| `MaterialConfirmChatCard` | `MaterialsCard`(勾选保留)+ `SkillStepsCard` | 「确认素材,规划大纲」「重新搜索」 | `MaterialsToolbar` 的 userQuery 输入框 |
| `OutlineConfirmChatCard` | `OutlineCard`(可点标题微调保留) | 「确认大纲,开始写作」 | feedback textarea + 发送 |
| `DraftConfirmChatCard` | `ReviewCard`(若有审核,勾选保留) | 「按编辑审核意见修改」「让编辑继续审核」「确认定稿」 | 「修改意见」textarea |
| `ReviewConfirmChatCard` | `ReviewCard`(勾选保留) | 「按编辑意见修改」「强制定稿」 | 「补充修改指令」textarea |
| `SectionHelpChatCard` | 章节 + 三选一按钮组 | 三选一 + 「提交并继续」 | (本就无自由输入,原样保留) |
> 勾选态(素材/审核意见)**保留**——它是「点按钮」路径的一部分,不算自由输入框。用户想用自然语言
> 「只用第 1、3 条」也能走底部输入框(意图层产出 `approvedMaterialIds`/`selectedIssueIndices`)。
> 按钮点击 **直接调 `submitIntervention`**,与旧卡完全一致,零改动风险。
### 5.4 问答旁路(新能力)
「对写作内容提问」不改写作 state。首期**轻量实现**:意图节点检出 `ask_*` 时读 state 直接带回 `answer`,
前端在消息流渲染为普通 AI 气泡即可(一次调用出答案)。完整版(后续)可另起只读 lead_agent 线程注入
草稿/大纲作背景,类似 `WritingSetupChat` 的 `useThreadStream`。
### 5.5 交互流程(对话版)串起来
1. **空闲**:底部输入「写一篇关于 X 的深度分析,2000 字」→ 意图 `start_writing` → 组 `AIWritingRequest`
→ `startWriting()`。(缺关键字段时先 `clarify` 追问字数/读者/类型。)
2. **运行中**:进度/流式卡渲染。
3. **到暂停点**:渲染产物卡 + 按钮 + 一句提示「你可以点上面的按钮,或直接说:确认继续 / 补充素材 / 改大纲…」。
- **点按钮** → 直接 `submitIntervention`(旧逻辑)。
- **发文字** → `resolveIntent(text, status, pausePoint)`(先快路径、后后端)→ 按 4.3 消费。
4. **问答**:任意时刻提问 → 旁路 `answer`,不打断写作。
5. **素材不足**:`needResearch` 或 `section_help` → 追问/执行「重搜 → 自动续跑 pending 意图」。
6. **拿不准**:`clarify`/低置信 → 反问气泡,不执行。
### 5.6 pending 意图 & 澄清态(新增本地状态)
在 `WritingChatPanel`(或新 `WritingChatContext`)维护:
- `pendingIntent: UserInterventionPayload | null` —— 素材不足重搜完成后自动提交。
- `awaitingClarify: { originalText: string } | null` —— 澄清态,用户下一句与上一句拼接再解析。
- pending 生命周期:设置 → 提交 `re_search` → 监听 reducer 里新一轮 `awaiting_material_confirm`
(`state.status` 从 `researching` 回到 `awaiting_material_confirm`)→ 自动 `submitIntervention(pending)` → 清空。
---
## 6. 后端改造方案
### 6.1 意图解析入口(新增,唯一后端改动主体)
- `app/gateway/routers/ai_writing.py`:新增 `POST /sessions/{id}/intent`(见 4.1)。
读 checkpoint state 用 `app.state.checkpointer` + `make_ai_writing_graph().aget_state(...)`
(参照 `ai_writing_job_executor.py` 的读法)取素材/大纲/草稿/审核摘要。
- `agents/ai_writing/prompts/intent_router_prompts.py`:新增意图 prompt(见 4.1)。
- 复用 `nodes/intent_parser.py` 的模型创建 / `parse_json` / `_model_fallback` 容错。
### 6.2 不需要改的部分
- 四个阶段大脑节点、五个暂停点、checkpoint、StreamBridge、持久化、sessions/transcript REST——**全不动**。
- 「素材不足自动续跑」「问答旁路」首期都在**前端 / 意图节点**实现,**不扩 resume 协议**。
### 6.3 可选增强(阶段 3,按需)
- `add_review_opinion` 完整版:`review_confirm` resume 增字段 `extra_review_notes`(首期已用现有
`userRevisionNotes` 降级承载,够用)。
- `resume_intent` 后端化(把「重搜完自动续跑」搬到后端,减少前端状态机复杂度)。
- undo / 回退到上一暂停点(依赖 checkpoint 回滚)。
---
## 7. 数据流时序(改稿 + 素材不足自动重搜)
```
用户(draft_confirm 暂停): "第三段补个2024年的行业案例"
│ 底部输入框 → resolveIntent(text, status='awaiting_draft_confirm', pausePoint='draft_confirm')
▼ POST /api/ai-writing/sessions/{id}/intent { text, status, pausePoint }
后端: 读 state(素材摘要) + LLM 解析
│ 返回 { intent:revise_draft, action:user_revise,
│ payload:{userRevisionNotes:"第三段…补2024案例"},
│ needResearch:true, researchQuery:"2024 行业案例", confidence:0.9 }
▼
前端: needResearch=true → pendingIntent = payload
│ submitIntervention({action:'re_search', userQuery:"2024 行业案例"})
▼ LangGraph resume → researcher 追加素材 → status 回到 awaiting_material_confirm
前端: 监听到新一轮素材确认 → 自动提交 pendingIntent
│ submitIntervention({action:'user_revise', userRevisionNotes:"第三段…补2024案例"})
▼ writer_draft 用新素材改稿 → draft_chunk… → draft_ready → draft_confirm
前端: 消息流展示新草稿卡 + "已按你的意见改写并补充了 2024 案例"
```
---
## 8. 关键实现细节与坑
1. **阶段作用域**:`resolveIntent` 必须带 `state.currentPause?.pausePoint` + `state.status`——
同一句「重新来」在 material 是重搜、在 outline 是重写大纲。`AIWritingContext` 已有这两个字段,直接读。
2. **确认按钮零改动**:新卡的按钮 `onClick` 原样调 `submitIntervention(payload)`,与旧卡逐字一致,
风险最低;**只删输入框**。
3. **防误触发**:低置信(<0.6)或 `clarify` 非空一律追问;`finalize`/`force_finalize`/`re_search`
即使命中也要求高置信,否则澄清。绝不盲目定稿/覆盖重搜。
4. **pending 意图队列**:素材不足是「先插重搜、再回原意图」,用 5.6 的小状态机;重搜完成前允许覆盖 pending。
5. **问答不污染 transcript**:问答/澄清消息在前端标记 `kind:'qa'|'clarify'`,与写作进度事件区分(
`AIWritingContext` 的 transcript 只存 progressEvents/completedInterventions,问答气泡是纯前端消息,
默认不入 transcript,避免历史回看错乱)。
6. **流式产物→消息卡**:复用 `OutlineStreamingView`/`DraftGeneratingView`/`ReviewStreamingView`/
`SkillStepsCard` 的**展示部分**,套进消息容器,不重写渲染。
7. **右侧草稿预览**:`AIWritingDraftPanel` + `DraftSync` 原样复用(`DraftSync` 依据 status/markdown
同步右侧画布)。
8. **离线内网**:意图模型走 `config.yaml → ai_writing` 配置模型;检索仍走技能/内网 ES,不联网。
9. **模型选择透传**:`submitIntervention` 的 resume 已把 `state.request?.modelName` 一并回传(见
`AIWritingContext`),新页面沿用即可。
10. **旧页面隔离验证**:改完后回归旧 `/page/canvas/ai-writing` 与 `/page/workspace/ai-writing`、
笔记本嵌入页,确认零变化(新代码不 import 旧的 `AIWritingForm`/`InterventionCard`/`WritingFormContext`)。
---
## 9. 分阶段实施路线图
**阶段 0 — 骨架(不含意图)**
- 复制 `AIWritingChatPage` + 注册新路由 + 侧边栏入口;复用 `AIWritingContext`/`useStream`/`DraftPanel`。
- 复制五张 `chat-cards`(删输入框、留按钮);底部输入框先只做 idle 直发 `start_writing`
(文本原样当 `userIntent`,其余字段取默认)+ 暂停点靠按钮推进。端到端跑通。
**阶段 1 — 意图解析(核心)**
- 后端 `POST /sessions/{id}/intent` + `intent_router_prompts.py`,返回 4.3 契约。
- 前端 `api/ai-writing-intent.ts`(快路径 + 后端)接入 `WritingChatPanel`,覆盖 A~F 类意图。
- 低置信 `clarify` 澄清态(5.6)。
**阶段 2 — 问答旁路 + 素材不足自动重搜**
- G 类问答(意图节点直接带 `answer`)。
- `needResearch` + pending 意图机制(前端实现重搜后自动续跑,5.6)。
**阶段 3 — 增强(按需)**
- `add_review_opinion` 扩协议、`resume_intent` 后端化、undo/回退、完整问答只读线程。
**阶段 4 — 打磨**
- 消息卡视觉、历史回看(transcript)、后台挂起入口、灰度到侧边栏。
---
## 10. 关键文件清单(落地对照)
### 新增(前端)
- `open-canvas/pages/AIWritingChatPage.tsx` — 新页面(复制 `AIWritingPage` 骨架改造)
- `open-canvas/components/ai-writing-chat/WritingChatPanel.tsx` — 消息流 + 底部输入框
- `open-canvas/components/ai-writing-chat/chat-cards/{MaterialConfirmChatCard,OutlineConfirmChatCard,DraftConfirmChatCard,ReviewConfirmChatCard,SectionHelpChatCard}.tsx` — 保留按钮、去掉输入框
- `open-canvas/components/ai-writing-chat/progressToMessage.ts` — `ProgressEvent` → 消息卡适配
- `open-canvas/api/ai-writing-intent.ts` — 意图解析客户端(快路径 + `POST …/intent`)
- (可选)`open-canvas/contexts/WritingChatContext.tsx` — pending 意图 / 澄清态
### 修改(前端,仅追加,不动旧逻辑)
- `open-canvas/routes/OpenCanvasRoutes.tsx` — 加一条 `ai-writing-chat/*` 路由
- `core/page-layout/sidebar-menu.ts` — 加一个侧边栏入口(可选)
- `open-canvas/types/ai-writing.ts` — **仅追加** `IntentResult` 等新类型,不改旧字段
### 新增(后端)
- `app/gateway/routers/ai_writing.py` — 加 `POST /sessions/{id}/intent`
- `agents/ai_writing/prompts/intent_router_prompts.py` — 意图 prompt
- `tests/test_ai_writing_intent.py` — 意图接口单测(TDD 强制)
### 复用(不改)
- `open-canvas/contexts/AIWritingContext.tsx`、`hooks/useAIWritingStream.ts`
- `open-canvas/components/ai-writing/{AIWritingDraftPanel,DraftSync,MaterialsCard,OutlineCard,ReviewCard,SkillStepsCard,OutlineStreamingView,DraftGeneratingView,ReviewStreamingView}.tsx`
- `agents/ai_writing/graph.py`、四个阶段节点、五个暂停点、`nodes/intent_parser.py`
### 绝不动(保证「初始的不用动」)
- `open-canvas/pages/AIWritingPage.tsx` 及其引用的
`components/ai-writing/{AIWritingForm,InterventionCard}.tsx`、`contexts/WritingFormContext.tsx`
- 旧路由 `/page/canvas/ai-writing`、`/page/workspace/ai-writing`、笔记本嵌入页
---
## 11. 待确认(默认取值已给出,可直接推进)
1. 意图引擎:**后端 LLM 节点(方案 A)+ 前端短句快路径** —— 默认采用。
2. `AIWritingContext` / `useAIWritingStream`:**复用**(不物理复制)——默认采用;需绝对隔离可改为复制。
3. 新页面入口:**侧边栏新增「AI写作(对话版)」** —— 默认加;若要先灰度可只留直链。
4. 「素材不足自动重搜」:**首期前端 pending 续跑**(不动后端 resume 协议)——默认采用。
5. 问答旁路:**首期意图节点直接带 `answer`** —— 默认采用。
> 以上默认按「A + 复用引擎 + 侧边栏入口 + 前端 pending + 意图节点带答案」推进,如需调整请指出。
@@ -1,454 +0,0 @@
# AI 写作「对话驱动 + 意图解析」改造方案
> 目标:在**不动现有 AI 写作页面**的前提下,新建一个「对话驱动」的写作工作台。
> 去掉素材收集 / 大纲规划 / 作家写作 / 编辑审核各阶段**分散的输入框/表单**,
> 用**底部一个统一的智能输入框**接管全流程交互;该输入框具备**强意图解析**能力,
> 能准确识别用户意图(重新搜索、加素材、加关键词、重写大纲、按要求改大纲、改写作、
> 改编辑、提编辑意见、对写作内容提问……),并在**素材不足**时自动回退去重新检索素材。
本文只做**方案与实现指引**,不含代码改动。落地时按第 9 节的路线图分阶段推进。
---
## 1. 现状梳理(改造的地基)
现有 AI 写作是一套**独立的 LangGraph 子系统**(不是 lead_agent),前后端都已成型。
### 1.1 后端:`ai_writing` graph
- 图工厂:`offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py`
- 状态:`.../agents/ai_writing/state.py`(`AIWritingState` + `UserInterventionPayload`)
- 注册:`offline-backend-20260512/backend/langgraph.json` → `assistant_id = "ai_writing"`
- 执行:与 lead agent **共用** RunManager + StreamBridge + checkpointer(`app/gateway/services.py` 里 `assistant_id=="ai_writing"` 分发到 `make_ai_writing_graph`)
节点拓扑(实际):
```
sample_analyzer → intent_parser → researcher
→ [cond] pause_material | writer_draft
→ writer_outline → pause_outline
→ writer_draft → [cond] pause_section_help | pause_draft | END
→ editor → [cond] pause_draft | pause_review
→ pause_review → END | writer_draft
```
四个「阶段大脑」节点:
| 阶段 | 节点 | 文件 |
|------|------|------|
| 素材收集 | `researcher` | `nodes/researcher.py` |
| 大纲规划 | `writer_outline` | `nodes/writer_outline.py` |
| 作家写作 | `writer_draft` | `nodes/writer_draft.py` |
| 编辑审核 | `editor` | `nodes/editor.py` |
五个**暂停点**(`interrupt()`,靠 `Command(resume=payload)` 续跑):
| pause_point | 语义 | 可用 action(现有) |
|-------------|------|---------------------|
| `material_confirm` | 素材确认 | `confirm` / `re_search` / `supplement_search` / `enable_skill_search` |
| `outline_confirm` | 大纲确认 | `confirm` / `re_outline` |
| `draft_confirm` | 草稿确认 | `to_editor` / `finalize` / `user_revise` / `custom_revise` |
| `review_confirm` | 编辑打回确认 | `accept_review` / `force_finalize` |
| `section_help` | 素材不足求助 | `supplement` / `loose` / `delete` |
> resume 载荷字段(`UserInterventionPayload`):`action`、`approved_material_ids`、
> `extra_keywords`、`user_query`(自然语言补充检索)、`edited_outline`、`outline_feedback`、
> `user_revision_notes`、`override_verdict`、`selected_issue_indices`、`section_decisions`。
> **关键点**:现有协议已经支持「自然语言 + 结构化 action」,这是对话改造的核心可复用资产。
REST(业务元数据,非图执行):`app/gateway/routers/ai_writing.py`,前缀 `/api/ai-writing`
(sessions CRUD、transcript 保存、article-types、后台挂起 `/background` + `/progress` 等)。
持久化:`ai_writing_sessions` 表(`transcript` 为 `PortableLongText`)
+ `ai_writing_article_types`(`deerflow/persistence/...`)。
配置:`config.yaml → ai_writing`(`keyword_model`、`rank_model`、`max_materials`、
`use_builtin_agents.{researcher,outliner,writer,editor}` 等)。
### 1.2 前端:`open-canvas` 子系统
- 主页面:`frontend-web/src/open-canvas/pages/AIWritingPage.tsx`
- 路由:`/page/canvas/ai-writing`、`/page/workspace/ai-writing`
- 状态机:`contexts/AIWritingContext.tsx`(`useReducer` + LangGraph `useStream`)
- 事件翻译/reducer:`hooks/useAIWritingStream.ts`
- 类型:`types/ai-writing.ts`(`WritingStatus`、`ProgressEvent`、`UserInterventionPayload`、`AIWritingRequest`…)
- 流式:LangGraph `useStream`,`assistantId: 'ai_writing'`,`streamMode: ['values','updates','custom','messages-tuple']`
- **要去掉的输入 UI**(改造重点):
- 启动表单 `components/ai-writing/AIWritingForm.tsx` + `WritingFormContext`
- 阶段暂停输入 `components/ai-writing/InterventionCard.tsx`(素材/大纲/草稿/审核四套内嵌表单)
- **已存在的对话原型**:`components/ai-writing/WritingSetupChat.tsx`
(用 `useThreadStream` 起一条 lead_agent 线程 + `setup_writing` 工具审批卡回填表单)——
这是「对话 → 结构化配置」的现成范式,可借鉴但**不直接复用**(它只管启动前配置)。
### 1.3 结论
- **状态机、流式、暂停/续跑协议、持久化都可复用**,无需重写引擎。
- 改造本质 = **把「分散表单」换成「一个对话输入框 + 意图路由层」**,
再把意图映射回**现有的 `action + payload`**(大部分场景),
少量新意图(如「对内容提问」「素材不足自动重搜」)需要**新增协议/节点**。
---
## 2. 目标与范围
### 2.1 必须满足
1. 新页面/新路由,**旧页面零改动**(用户要求「初始的不用动」)。
2. 底部**单一对话输入框**控制所有阶段交互,移除各阶段独立输入框。
3. **强意图解析**:准确识别用户自然语言意图并路由到正确动作,覆盖全流程 + 更多。
4. 用户给修改意见后,**若现有素材不足以满足要求 → 自动/建议重新检索素材**再继续。
5. 保留右侧草稿实时预览(可编辑)与时间线/进度展示。
### 2.2 明确不做(本期)
- 不改旧 `AIWritingPage` 及其表单/干预卡组件。
- 不替换底层 `ai_writing` graph 的四个阶段大脑逻辑(只加意图层与少量新协议)。
- 不引入新的状态管理库(继续 `useReducer` + `useStream`)。
---
## 3. 核心设计
### 3.1 总体思路:三层结构
```
┌─────────────────────────────────────────────────────────┐
│ 对话 UI 层(新) │
│ - 左:消息流(用户消息 + AI 阶段产物卡片 + 进度) │
│ - 底:统一智能输入框(唯一交互入口) │
│ - 右:草稿实时预览(复用 AIWritingDraftPanel) │
└───────────────┬─────────────────────────────────────────┘
│ 用户自然语言
▼
┌─────────────────────────────────────────────────────────┐
│ 意图解析层(新,核心) │
│ - 输入:用户文本 + 当前 WritingStatus + 上下文快照 │
│ - 输出:{ intent, action, payload, needResearch, ... } │
└───────────────┬─────────────────────────────────────────┘
│ 结构化 action + payload
▼
┌─────────────────────────────────────────────────────────┐
│ 执行层(复用现有) │
│ - 空闲态:startWriting(AIWritingRequest) │
│ - 暂停态:submitIntervention(UserInterventionPayload) │
│ - 运行中:stream.stop() → 续跑 / 新命令 │
│ - 提问态:旁路问答(不改写作状态) │
└─────────────────────────────────────────────────────────┘
```
**设计原则**:意图解析层是「翻译官」——把自由文本翻译成**现有执行层已经懂的指令**。
能复用 `action + payload` 的绝不新造协议;只有现有协议表达不了的才扩展。
### 3.2 意图分类体系(覆盖用户全流程 + 更多)
按「意图类别 → 触发时机(当前 status)→ 映射到的执行」组织。这是**意图解析引擎的目标 schema**。
#### A. 启动类(status = idle)
| 意图 | 说明 | 映射 |
|------|------|------|
| `start_writing` | 用户描述要写什么 | 解析成 `AIWritingRequest` → `startWriting()` |
| `start_with_outline` | 用户自带大纲 | `AIWritingRequest.userOutline` |
| `start_imitate` | 样文仿写 | `writingModeType=imitate` + 样文 |
#### B. 素材类(status ≈ awaiting_material_confirm / 运行中)
| 意图 | 用户说法示例 | 映射 |
|------|-------------|------|
| `confirm_materials` | 「素材可以,继续」 | `material_confirm` + `confirm`(可带勾选 ids) |
| `research_again` | 「重新搜索素材」 | `material_confirm` + `re_search` |
| `add_material` / `add_keyword` | 「补充关于 X 的素材」「加个关键词 Y」 | `material_confirm` + `supplement_search`,`user_query`=X/Y |
| `use_skill_search` | 「用 XX 技能查」 | `material_confirm` + `enable_skill_search`,`user_query`=技能/来源 |
| `select_materials` | 「只用第 1、3 条」 | `confirm` + `approved_material_ids` 子集 |
| `drop_materials` | 「去掉第 2 条」 | 同上,反选后 `approved_material_ids` |
#### C. 大纲类(status ≈ awaiting_outline_confirm)
| 意图 | 示例 | 映射 |
|------|------|------|
| `confirm_outline` | 「大纲 OK」 | `outline_confirm` + `confirm` |
| `reoutline` | 「重写大纲」 | `outline_confirm` + `re_outline`(`outline_feedback` 可空) |
| `revise_outline` | 「第二章拆成两节」「调整顺序」 | `re_outline` + `outline_feedback`=自然语言 |
| `edit_outline_manually` | 用户直接给出新大纲文本 | 解析成 `edited_outline` |
#### D. 写作/草稿类(status ≈ awaiting_draft_confirm / writing_draft)
| 意图 | 示例 | 映射 |
|------|------|------|
| `to_editor` | 「交给编辑审核」 | `draft_confirm` + `to_editor` |
| `finalize` | 「就这样定稿」 | `draft_confirm` + `finalize` |
| `revise_draft` | 「第三段太啰嗦,精简」「补个案例」 | `draft_confirm` + `user_revise`,`user_revision_notes`=意见 |
| `rewrite_section` | 「重写第二章」 | `user_revise` + 定位信息(章节)写进 notes |
| `change_tone/style` | 「更口语一点」 | `user_revise` + notes |
#### E. 编辑审核类(status ≈ awaiting_review_confirm)
| 意图 | 示例 | 映射 |
|------|------|------|
| `accept_review` | 「按编辑意见改」 | `review_confirm` + `accept_review`(可带 `selected_issue_indices`) |
| `partial_accept_review` | 「只接受第 1、2 条意见」 | `accept_review` + `selected_issue_indices` |
| `force_finalize` | 「不改了,强制定稿」 | `review_confirm` + `force_finalize` |
| `add_review_opinion` | 「你再补一条:检查数据准确性」 | 追加自定义审核意见(见 5.3 扩展) |
#### F. 素材不足求助类(status = awaiting_section_help)
| 意图 | 映射 |
|------|------|
| `section_supplement` | `section_help` + `supplement`(去重搜) |
| `section_loose` | `section_help` + `loose`(放宽续写) |
| `section_delete` | `section_help` + `delete` |
#### G. 问答类(**新**,任意 status,旁路,不改写作状态)
| 意图 | 示例 | 映射 |
|------|------|------|
| `ask_about_content` | 「第二章讲了啥?」「为什么用这个论点?」 | 旁路问答(见 5.4) |
| `ask_meta` | 「现在进行到哪一步?」「用了哪些素材?」 | 读当前 state 直接回答,不走图 |
#### H. 控制类(**新/半新**)
| 意图 | 映射 |
|------|------|
| `undo/back` | 回退到上一暂停点(依赖 checkpoint 回滚,见 8.4) |
| `restart_stage` | 重跑当前阶段 |
| `run_background` | 挂起后台自动跑 → `POST /sessions/{id}/background` |
| `cancel` | `stream.stop()` |
| `unknown/clarify` | 无法判定 → 反问澄清(对话追问,不误触发) |
> **覆盖「甚至更多」**:G(问答)、H(控制)、以及 D/E 里「素材不足自动回退重搜」都是超出原表单能力的新增。
### 3.3 「素材不足自动重搜」的判定与回退(用户强调的重点)
触发场景:用户在**大纲/草稿/审核**阶段提出修改意见,但意见需要的**信息现有素材里没有**。
判定放在**意图解析层**输出一个 `needResearch: boolean` + `researchQuery`,判定来源二选一(推荐组合):
1. **意图解析 LLM 直接判断**:把「当前素材摘要(keywords + 每条 title/来源)」喂给意图解析
prompt,让它判断「用户这条意见能否用现有素材满足」。不能 → `needResearch=true` 并给出检索词。
2. **执行阶段兜底**:`writer_draft` 严格模式本就有 `blocked_sections` → `pause_section_help`,
这是**已有的**素材不足闸门;对话层把它翻译成「素材不足,是否重新检索?」的对话追问。
回退动作:`needResearch=true` 时,先走 `material_confirm + supplement_search`(或新的
「带目标的重搜」协议,见 8.3)补素材,**补完自动继续**用户原本的修改意图(需要在对话层
记住「pending 修改意图」,重搜结束后再提交)。
---
## 4. 意图解析引擎:方案对比与推荐
| 方案 | 做法 | 优点 | 缺点 |
|------|------|------|------|
| **A. 后端 LLM 意图节点**(推荐) | 新增 `/api/ai-writing/intent/parse`(或图内新节点),后端用 LLM 把 `{text, status, contextSnapshot}` → 结构化意图 JSON | 能吃到完整 state(素材/大纲/草稿);可判 `needResearch`;prompt/模型集中可控;离线内网可用配置模型 | 多一次网络往返(可接受,交互本就等待) |
| B. 前端调模型接口分类 | 前端直接调现有 `/api/models` 聊天做分类 | 少一层后端改动 | 前端难拿全后端 state 快照;prompt 分散;鉴权/模型选择复杂 |
| C. 规则 + LLM 混合 | 常见短句走关键词规则快路径,兜底 LLM | 快、省 token | 规则易漏、维护成本高;中文表达多样命中率低 |
**推荐 A(后端 LLM 意图节点)**,并在其上**叠加 C 的快路径**做优化(如「确认/继续/定稿」等
高频短句先走轻量规则,未命中再调 LLM)。理由:
- 意图判定需要**当前写作上下文**(尤其 `needResearch` 判定),后端天然持有 state/checkpoint。
- 与现有「配置助手 `setup_writing`」一脉相承,模型选择/离线部署策略统一。
- 输出 schema 稳定,前端只消费结构化结果,UI 简单。
### 4.1 意图解析输出契约(建议)
```jsonc
{
"intent": "revise_draft", // 见第 3.2 分类
"pausePoint": "draft_confirm", // 若需 resume;否则 null
"action": "user_revise", // 映射到现有 action;新意图可为新值
"payload": { // 直接可喂 submitIntervention 的字段
"userRevisionNotes": "第三段精简,并补一个 2024 年的案例"
},
"needResearch": true, // 素材是否不足
"researchQuery": "2024 行业案例", // needResearch 时的检索意图
"answer": null, // 问答类意图的直接回答(旁路)
"confidence": 0.86,
"clarify": null // 低置信时的反问话术
}
```
前端拿到后:
- `answer` 非空 → 直接在对话流渲染回答(问答旁路,不动写作状态)。
- `clarify` 非空或 `confidence` 低 → 渲染反问,等用户澄清(**防误触发**)。
- `needResearch=true` → 先重搜,缓存 `payload` 为 pending,重搜完再提交。
- 否则 → `submitIntervention({action, ...payload})` 或 `startWriting(...)`。
---
## 5. 前端改造方案
### 5.1 新页面与路由
- 新目录:`frontend-web/src/open-canvas/pages/AIWritingChatPage.tsx`(或 `conversational/` 子目录)。
- 新路由:`/page/canvas/ai-writing-chat`(在 `OpenCanvasRoutes.tsx` 注册;旧路由保留)。
- 侧边栏:`sidebar-menu.ts` 加一个入口(如「AI 写作(对话版)」),或先不加、内部灰度。
### 5.2 复用 vs 新建
| 复用(尽量不改) | 新建 |
|------------------|------|
| `AIWritingContext` / `useAIWritingStream`(状态机 + 流式) | 统一对话输入框组件 `WritingChatComposer` |
| `AIWritingDraftPanel`(右侧草稿预览) | 消息流容器 `WritingChatThread`(渲染阶段产物为消息卡片) |
| `types/ai-writing.ts`(按需扩展) | 意图解析客户端 `api/ai-writing-intent.ts` |
| `ProgressEvent` 事件模型 | 阶段产物 → 消息卡片的适配器 `progressToMessage.ts` |
| 会话持久化 REST | 「pending 意图」暂存逻辑(重搜后续跑) |
**不复用**:`AIWritingForm` / `WritingFormContext` / `InterventionCard`(这三个就是要去掉的表单)。
### 5.3 交互流程(对话版)
1. 空闲:用户在底部输入「写一篇关于 X 的深度分析,2000 字」→ 意图解析 `start_writing`
→ 组装 `AIWritingRequest` → `startWriting()`。
2. 运行中:`ProgressEvent`(`research_progress`/`outline_chunk`/`draft_chunk`…)
→ 适配成消息流里的**进度卡片/流式卡片**(复用现有流式视图组件的展示部分)。
3. 到达暂停点:不再弹 `InterventionCard`,而是在消息流里展示**阶段产物卡片**
(素材列表 / 大纲 / 草稿 / 审核结果)+ 一句提示「你可以说:确认继续 / 补充素材 / 改大纲…」。
用户下一句自然语言 → 意图解析 → `submitIntervention`。
4. 问答:任意时刻用户提问 → 旁路回答,不打断写作状态。
5. 素材不足:意图解析 `needResearch` 或后端 `section_help` → 对话追问「素材不足,帮你重搜?」
→ 确认后重搜 → 自动续跑 pending 意图。
### 5.4 问答旁路(新能力)
「对写作内容提问」不应改写作 state。两种实现:
- **轻量(推荐先做)**:意图解析节点直接带回 `answer`(后端能读 state,一次调用出答案)。
- **完整**:新起一条**只读**问答线程(lead_agent,context 注入当前草稿/大纲作为背景),
类似 `WritingSetupChat` 的 `useThreadStream` 模式,但只读不回填。
---
## 6. 后端改造方案
### 6.1 意图解析入口(二选一)
- **方案 A1(推荐,独立 REST)**:`app/gateway/routers/ai_writing.py` 新增
`POST /api/ai-writing/sessions/{id}/intent`,入参 `{text}`,后端读该 session 的
checkpoint state(素材/大纲/草稿摘要)+ 用 `ai_writing` 配置模型解析,返回第 4.1 的契约。
- 好处:不改图拓扑;前端拿到结果后再决定调 `startWriting`/`submitIntervention`。
- 方案 A2(图内节点):在图里加 `conversation_router` 节点。改动大、状态耦合高,**不推荐**首期。
### 6.2 意图解析 prompt
新增 `agents/ai_writing/prompts/intent_router_prompts.py`(或复用/扩展 `intent_parser.py`):
- 输入:用户文本、`current_status`、素材摘要(keywords + 每条 title/source)、大纲纲要、
草稿字数/章节标题、审核意见列表。
- 输出:严格 JSON(第 4.1 契约)。要求模型**只在证据充分时给高 confidence**,
否则给 `clarify`(防误触发是硬指标)。
### 6.3 「带目标的重搜」协议扩展(可选增强)
现有 `supplement_search` 用 `user_query` 追加检索。为支持「改稿意见 → 缺素材 → 重搜 → 自动续跑」,
建议在 `material_confirm` 的 resume 里新增可选字段 `resume_intent`(重搜完成后要自动执行的原始意图)。
- 前端也可**纯前端**实现(重搜完成事件回来后再自动提交缓存的 pending payload),**首期优先前端实现**,避免动后端协议。
### 6.4 追加自定义审核意见(`add_review_opinion`)
`editor`/`pause_review` 目前只接受「接受/强制定稿」。要支持「你再补一条审核意见」,
需在 `review_confirm` resume 里接受 `extra_review_notes`,`editor` 或 `writer_draft`
修订时并入。首期可先降级为 `user_revise` + notes(把用户的审核诉求当作改稿意见),
**完整版**再扩协议。
### 6.5 不需要改的部分
- 四个阶段大脑节点(researcher/writer_outline/writer_draft/editor)逻辑不动。
- 五个暂停点、checkpoint、StreamBridge、持久化不动。
---
## 7. 数据流时序(改稿 + 素材不足自动重搜)
```
用户: "第三段补个2024年的行业案例"
│
▼ POST /api/ai-writing/sessions/{id}/intent { text }
后端: 读 state(素材摘要) + LLM 解析
│ 返回 { intent:revise_draft, action:user_revise,
│ payload:{userRevisionNotes:...},
│ needResearch:true, researchQuery:"2024 行业案例" }
▼
前端: needResearch=true → 缓存 pending=payload
│ 对话追问/直接执行: submitIntervention(material_confirm, supplement_search, user_query="2024 行业案例")
▼ LangGraph resume → researcher 补素材 → materials_ready
前端: 收到新素材 → 自动提交 pending
│ submitIntervention(draft_confirm, user_revise, userRevisionNotes=...)
▼ writer_draft 用新素材改稿 → draft_chunk... → draft_ready → pause_draft
前端: 消息流展示新草稿 + "已按你的意见改写并补充了案例"
```
---
## 8. 关键实现细节与坑
1. **暂停态识别**:对话层必须知道「现在停在哪个 pause_point」才能正确映射 action。
`AIWritingContext` 已有 `currentPause`/`status`,直接读。
2. **防误触发**:低置信意图一律走 `clarify` 反问,绝不猜着执行(尤其 `finalize`/`re_search` 这种代价大的)。
3. **pending 意图队列**:素材不足重搜是「先插一段、再回原意图」,需要一个小状态机记住 pending,
并处理「重搜后用户又改主意」的情况(重搜完成前允许覆盖 pending)。
4. **回退/undo(H 类)**:依赖 checkpoint 回滚,`runtime/runs/worker.py` 有 `rollback` 概念,
首期可**不做**或只做「重跑当前阶段」。
5. **问答旁路不污染 transcript**:问答消息可标记 `kind:'qa'`,与写作进度事件区分持久化。
6. **流式产物→消息卡片**:复用现有 `OutlineStreamingView`/`DraftStreamingPreview` 等的**展示部分**,
套进消息气泡即可,不必重写渲染。
7. **离线内网**:意图解析模型走 `config.yaml → ai_writing` 里的配置模型;检索仍走技能/内网 ES,
不联网(与现有并行席位「禁 web_search」的离线策略一致)。
---
## 9. 分阶段实施路线图
**阶段 0 — 骨架(不含意图)**
- 新路由 + 新页面 `AIWritingChatPage`,复用 `AIWritingContext`/`useStream`/`DraftPanel`。
- 底部对话框先只做「空闲 `start_writing` + 暂停点固定按钮」跑通端到端(相当于把干预卡换成消息)。
**阶段 1 — 意图解析(核心)**
- 后端 `POST /sessions/{id}/intent` + intent prompt,返回第 4.1 契约。
- 前端接入:自然语言 → 意图 → `startWriting`/`submitIntervention`,覆盖 A~F 类意图。
- 加高频短句规则快路径 + 低置信 `clarify` 反问。
**阶段 2 — 问答旁路 + 素材不足自动重搜**
- G 类问答(先用 intent 节点直接带 `answer`)。
- `needResearch` + pending 意图机制(前端实现重搜后自动续跑)。
**阶段 3 — 增强协议(按需)**
- `add_review_opinion` 扩 `review_confirm` 协议、`resume_intent` 后端化、undo/回退。
**阶段 4 — 打磨**
- 消息卡片视觉、历史回看(复用 transcript)、后台挂起入口、灰度到侧边栏。
---
## 10. 关键文件清单(落地时对照)
### 新增(前端)
- `open-canvas/pages/AIWritingChatPage.tsx` — 新页面
- `open-canvas/components/ai-writing-chat/WritingChatComposer.tsx` — 统一输入框
- `open-canvas/components/ai-writing-chat/WritingChatThread.tsx` — 消息流
- `open-canvas/components/ai-writing-chat/progressToMessage.ts` — 事件→消息适配
- `open-canvas/api/ai-writing-intent.ts` — 意图解析客户端
- 路由注册:`open-canvas/OpenCanvasRoutes.tsx`(+ 可选 `sidebar-menu.ts`)
### 新增(后端)
- `app/gateway/routers/ai_writing.py` — 加 `POST /sessions/{id}/intent`
- `agents/ai_writing/prompts/intent_router_prompts.py` — 意图 prompt
- (阶段 3)`state.py` / `nodes/*` — 扩 `review_confirm`、`resume_intent`
### 复用(尽量不改)
- `open-canvas/contexts/AIWritingContext.tsx`、`hooks/useAIWritingStream.ts`
- `open-canvas/components/ai-writing/AIWritingDraftPanel.tsx` 及各流式展示组件
- `open-canvas/types/ai-writing.ts`(按需扩字段)
- `agents/ai_writing/graph.py`、四个阶段节点、五个暂停点
### 不动
- 旧 `open-canvas/pages/AIWritingPage.tsx` 及 `AIWritingForm`/`WritingFormContext`/`InterventionCard`
---
## 11. 待确认
1. 意图解析引擎是否采用**推荐的方案 A(后端 LLM 节点)**?
2. 新页面是**独立入口**(侧边栏新增)还是先**内部灰度**(仅直链)?
3. 「素材不足自动重搜」首期是否接受**前端实现 pending 续跑**(不动后端 resume 协议)?
4. 问答旁路首期是否接受**意图节点直接带答案**(不新起只读线程)?
> 以上默认按「A / 内部灰度 / 前端 pending / 意图节点带答案」推进,如需调整请指出。
@@ -0,0 +1,274 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
+3 -3
View File
@@ -107,7 +107,7 @@ http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest
## 4. 公开 HTTP API(无需鉴权)
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://127.0.0.1:8001`)。
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
@@ -315,7 +315,7 @@ document.getElementById("deerflow-embed").src =
### 5.2 轮询直到结束
```javascript
const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL
const API = "http://127.0.0.1:8001"; // VITE_BACKEND_BASE_URL
async function pollUntilFinished(sessionId) {
const res = await fetch(
@@ -348,7 +348,7 @@ const timer = setInterval(async () => {
### 5.3 跨域注意
父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
父页面从浏览器 `fetch` Gateway(如 `http://127.0.0.1:8001`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
@@ -1,154 +0,0 @@
# 公开页 · iframe 嵌入外观对接说明(主题 / 背景色)
本文档面向**嵌入方(宿主系统)**,说明如何在用 `<iframe>` 嵌入 DeerFlow 公开页时,从外部控制页面的**整体明暗主题(light/dark)**与**背景色 / 文字色**,使其与宿主站点的视觉风格保持一致。
前端实现见:`src/core/embed/background.ts`(参数解析与 postMessage 监听)、`src/App.tsx`(主题接入 `forcedTheme`)。
---
## 0. 速读
| 想做什么 | 怎么做 |
|----------|--------|
| 嵌入时指定深色主题 | iframe `src` 加 `?theme=dark` |
| 嵌入时指定背景/文字色 | iframe `src` 加 `?bg=%23001529&text=%23ffffff`(`#` 需编码为 `%23`) |
| 宿主切主题时同步切换 | `postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")` |
| 宿主动态改背景色 | `postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*")` |
| 什么都不传 | 页面使用 DeerFlow 自带默认配色,互不影响 |
---
## 1. 适用页面
以下公开页(免登录、只读)均支持本文档的全部参数:
| 页面 | 路由 |
|------|------|
| 会话分享页 | `/#/share/{shareCode}` |
| 圆桌分享页 | `/#/roundtable-share/{shareCode}`、`/#/roundtable-share/{shareCode}/report` |
| 用户排行榜 | `/#/public/leaderboard` |
| 定时任务公开页 | `/#/public/scheduled-tasks/{taskId}`(含 `/runs/{runId}/fullscreen` 全屏页) |
> 注意:站点使用 **hash 路由**,路径在 `#` 之后。
> 嵌入聊天页(`/#/embed/chats/*`、`/#/embed/roundtable`)有自己的主题约定,见 `embed-iframe-chat.md`,不在本文范围内。
---
## 2. URL 查询参数(嵌入时一次性指定)
### 2.1 参数列表
| 参数 | 别名 | 作用 | 合法值 |
|------|------|------|--------|
| `theme` | — | 切整体明暗主题 | 见 2.2 取值映射表 |
| `bg` | `background` | 页面根容器背景色 | 见 2.3 颜色格式 |
| `text` | `fg` | 页面根容器文字色 | 见 2.3 颜色格式 |
三个参数相互独立,可任意组合;不传或传非法值时该项忽略、沿用默认。
### 2.2 `theme` 取值映射
| 传入值(不区分大小写) | 实际主题 |
|------|------|
| `dark`、`dark-blue`、`深蓝`、`深色` | 深色 |
| `light`、`red`、`红白`、`浅色` | 浅色 |
| 其他 / 不传 | 忽略,沿用站内默认(浅色) |
### 2.3 颜色格式(`bg` / `text`)
颜色值经过白名单校验,仅接受以下写法,其余一律丢弃(防注入):
- **hex**:`#fff`、`#ffffffcc` 等 3/4/6/8 位(`#` 可省略;URL 里 `#` 必须编码为 `%23`)
- **函数式**:`rgb()` / `rgba()` / `hsl()` / `hsla()`,括号内仅允许数字、逗号、百分号、空格、`/`
- **命名色**:纯英文字母,如 `white`、`transparent`
### 2.4 参数位置
参数放在 hash 路由之后(推荐)或 `#` 之前都能识别;两处都传时**hash 路由内的优先**:
```
https://<host>/#/public/leaderboard?theme=dark ← 推荐
https://<host>/?theme=dark#/public/leaderboard ← 也支持
```
### 2.5 示例
```html
<!-- 深色主题 -->
<iframe src="https://<host>/#/public/leaderboard?theme=dark"></iframe>
<!-- 浅色主题 + 自定义背景与文字色(# 编码为 %23) -->
<iframe src="https://<host>/#/share/Ab3xYz?theme=light&bg=%23f5f7fa&text=%23333333"></iframe>
<!-- 主题与背景叠加:整页走深色变量,根背景用宿主品牌色 -->
<iframe src="https://<host>/#/roundtable-share/Ab3xYz?theme=dark&bg=%23001529"></iframe>
```
---
## 3. postMessage(嵌入后动态切换)
适用于宿主站点支持用户切主题、需要 iframe 实时跟随的场景。向 iframe 的 `contentWindow` 发消息即可,**无需等待子页面回执**(监听器在应用挂载时注册)。
### 3.1 消息格式
```js
const frame = document.getElementById("deerflow-frame");
// 切整体明暗主题(theme 取值同 2.2)
frame.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*");
// 改背景色 / 文字色(字段名与 URL 参数一致,bg/background、text/fg 均可)
frame.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*");
// 兼容别名:type 也可写 "deerflow:set-background"
```
说明:
- 两类消息相互独立,可只发其一;`set-bg` 里 `bg`、`text` 也可只传其一,未传的字段保持当前值。
- 非法颜色值 / 不认识的 `theme` 取值会被忽略,不会清空已有设置。
- targetOrigin 建议按需收紧(写 DeerFlow 站点的 origin 而非 `"*"`)。
### 3.2 宿主主题联动示例
```js
function syncDeerflowTheme(isDark) {
const win = document.getElementById("deerflow-frame").contentWindow;
win.postMessage({ type: "deerflow:set-theme", theme: isDark ? "dark" : "light" }, "*");
win.postMessage(
{ type: "deerflow:set-bg", bg: isDark ? "#001529" : "#ffffff" },
"*",
);
}
// iframe 首次加载用 URL 参数兜底,之后切换走 postMessage
myThemeStore.subscribe((theme) => syncDeerflowTheme(theme === "dark"));
```
---
## 4. 优先级与默认行为
同一项配置多处传入时,按以下优先级取值(高 → 低):
1. **postMessage** 动态设置的值
2. hash 路由内的 URL 参数(`#/路由?theme=...`)
3. `#` 之前的 URL 参数(`?theme=...#/路由`)
4. 都没传 → DeerFlow 默认配色(浅色主题 + 页面自带 Tailwind 配色)
其他保证:
- 外部主题只影响**当前 iframe 的渲染**,不写 localStorage——不会污染用户直接访问 DeerFlow 主站时自己选择的主题。
- `bg`/`text` 是内联样式,作用在页面根容器上,优先级高于主题变量;与 `theme` 叠加时以 `bg`/`text` 为准。
---
## 5. 常见问题排查
| 现象 | 排查 |
|------|------|
| `theme=dark` 不生效 | 确认路由属于第 1 节列出的公开页;确认参数拼在 hash 路由后(`/#/share/xx?theme=dark`),而不是写成了 `/#/share/xx#theme=dark` |
| `bg=#fff` 不生效 | URL 里 `#` 没编码会被浏览器截断,必须写 `%23fff`;或直接省略 `#` 写 `bg=fff` |
| postMessage 无反应 | 确认消息体是对象且 `type` 拼写为 `deerflow:set-theme` / `deerflow:set-bg`;确认在 iframe `load` 之后发送 |
| 颜色被忽略 | 检查是否符合 2.3 白名单格式(如 `linear-gradient(...)` 等复杂值不支持) |
| 主题切了但个别区域颜色没变 | 该区域可能使用了固定配色;把页面路由与截图反馈给 DeerFlow 维护方补充 `dark:` 样式 |
@@ -1,198 +0,0 @@
# MAF-R0 阶段 0 交付文档:旧会商交互基线与组件映射
> 状态:已完成
> 更新时间:2026-08-03
---
## 1. 实验原型隐藏说明
### 已完成的操作
- `/page/maf/*` 路由(`MafSessionsPage` + `MafRunDetailPage`)已从正式菜单移除。
- `workspace-nav-chat-list.tsx:199` 保留了墓碑注释:`{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}`
- `MafRoutes.tsx` 文件头部标注为 `[MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]`。
- 该原型仅可通过手动输入 URL `/page/maf` 访问,不在任何侧边栏、导航菜单或工作区入口中出现。
- 停止在该原型上新增业务功能。
### 原型文件位置
| 文件 | 用途 |
|------|------|
| `src/pages/MafRoutes.tsx` | 路由定义(index → MafSessionsPage, runs/:runId → MafRunDetailPage) |
| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 会话列表页(642 行) |
| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 单 run 工作区(195 行) |
| `src/maf-roundtable/` | 完整模块(33 文件:api/hooks/components/lib/tests) |
| `vitest.maf.config.ts` | MAF 专用测试配置 |
| `package.json` → `test:maf` | MAF 测试脚本 |
---
## 2. 旧会商 8 类页面状态交互地图
旧会商入口:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(4376 行,三步合一)。
### 状态 1:空白 / 新建
- 触发:点击「新建会商」或首次进入。
- 表现:中心区域显示欢迎语 + 业务链选择入口 + 底部输入框。左侧历史列表为空或显示已有草稿。
- 关键组件:`RoundtablePlanningPage`(currentStep=1 初始态)、`Step1Panel`(空消息列表 + 输入栏)。
- 数据:`useDraftPersistence` 创建新草稿记录。
### 状态 2:历史切换
- 触发:点击左侧草稿列表中的某一条。
- 表现:加载该草稿的 Step1/Step2/Step3 快照,恢复到对应步骤。
- 关键组件:`useRoundtableDrafts`(列表)、`useDraftPersistence`(加载 + hydrate)。
- 数据:`GET /api/roundtable-drafts/{id}` 或 `GET /api/roundtable-task-drafts/{id}`。
### 状态 3:用户提问
- 触发:用户在 Step1 输入框输入任务文本并点击发送。
- 表现:用户气泡立即插入,随后出现 `MessageLoadingPlaceholder`(spinner + 跳动点),等待 SSE 首帧。
- 关键组件:`Step1Panel`(输入栏 → 发送)、`MessageBubble`(用户气泡)、`MessageLoadingPlaceholder`。
- 数据:`POST /api/intent/init` → `POST /api/intent/stream`(SSE)。
### 状态 4:意图澄清
- 触发:总控/意图智能体判断信息不足,返回 `ask_clarification` 帧。
- 表现:对话流中嵌入 `IntentClarificationCard`(单选/多选 + 自定义输入),用户选择后提交。
- 关键组件:`IntentClarificationCard`(选项卡)、`Step1Panel`(澄清提交逻辑)。
- 数据:SSE 帧 `phase=clarification`,用户回答后继续 `POST /api/intent/stream`。
### 状态 5:总控派活
- 触发:Step1 完成(INTENT_READY)→ 进入 Step2 → 总控输出派活计划。
- 表现:总控气泡显示派活计划(谁做什么),`ParallelSeatStrip` 显示参与席位头像条(running/done/error 徽章)。
- 关键组件:`Step2Panel`(派活叙事)、`ParallelSeatStrip`(席位条)、`MessageBubble`(总控气泡)。
- 数据:`POST /api/multi-agent/init`(SSE,建立 thread map)→ `POST /api/multi-agent/run/stream`。
### 状态 6:席位发言
- 触发:总控派活后,各席位按序/并行执行。
- 表现:每个席位的气泡流式出现(`StreamingCursor` 闪烁尾标),工具调用显示为 `MessageStepsCard`(ChainOfThought 折叠卡)。
- 关键组件:`MessageBubble`(席位气泡 + avatar)、`StreamingCursor`、`MessageStepsCard`(工具步骤)、`DialogueSpeedMeter`(tok/s)。
- 数据:SSE delta 帧按 message id 合并。
### 状态 7:审批
- 触发:席位执行外部写操作,WriteGuard 拦截并请求审批。
- 表现:对话流中嵌入审批卡(工具名 + 风险等级 + 摘要),用户点击批准/拒绝。
- 关键组件:MAF 原型中为 `MafApprovalCard`;旧会商中审批通过 `roundtable-jobs` 的 clarify-resume 机制。
- 数据:`POST /api/maf-roundtable/approvals/{id}/decision`(MAF)或 `POST /api/roundtable-jobs/{id}/clarify-resume`(旧)。
### 状态 8:完成 / 失败恢复
- 触发:所有席位完成 + 总控收束 → Step3;或 SSE 错误帧 / 网络断开。
- 表现(完成):Step3 显示最终报告(Markdown 总结 / HTML 看板 / 大屏),可追问。
- 表现(失败):错误提示 + 恢复按钮;后台任务可挂起/恢复。
- 关键组件:`Step3Panel` → `HighFidelityReport`(Report/Dashboard/Summary/FileBrowse 模式);失败时 `RoundtableJobNotifier`。
- 数据:`useStep3Summary` / `useStep3Report` / `useStep3Dashboard`;失败恢复 via `POST /api/roundtable-jobs/{id}/resume`。
---
## 3. 旧组件 → 新 MAF view-model 映射表
| 旧组件 / hook | 新 MAF 对应 | 映射说明 |
|---|---|---|
| `RoundtablePlanningPage`(三步壳) | `MafRoundtableConversationPage` | 不再有步骤切换;单一连续时间线。 |
| `Step1Panel`(意图问答) | `ConversationTimeline` + `ClarificationCard` | 意图理解变为 `manager.intent` 事件卡。 |
| `Step2Panel`(研讨对话流) | `ConversationTimeline` + `SeatMessageBubble` | 席位发言由 SSE `agent.message.*` 驱动。 |
| `Step3Panel` / `HighFidelityReport` | `FinalResultCard` | 结论作为时间线中的结果卡。 |
| `MessageBubble` | `SeatMessageBubble` / 总控气泡 | 重写为 MAF view model;不复用旧 props。 |
| `MessageStepsCard` | `ToolProgressCard` | 工具过程由 `tool.started/progress/completed` 事件驱动。 |
| `IntentClarificationCard` | `ClarificationCard` | 由 `clarification.required` 事件触发。 |
| `ParallelSeatStrip` | `ParticipantStrip` | 状态改由 `manager.dispatch` + `seat.delivery.submitted` + `manager.review.decision` 驱动。 |
| `useStep1Intent` | 删除 | 意图理解完全服务端化。 |
| `useStep2Orchestration` | 删除 | 编排完全服务端化;前端只消费 SSE。 |
| `useDraftPersistence` | `use-maf-conversations` | 历史改为 conversation 列表。 |
| `useRoundtableDrafts` | `use-maf-conversations` | 同上。 |
| `useStep3Summary/Report/Dashboard` | 删除 | 结论由服务端生成,前端只渲染结果卡。 |
| `useAutoScroll` | 可复用(无业务依赖) | 纯 UI 滚动逻辑。 |
| `useDialogueSpeedMetrics` | 可复用(无业务依赖) | 纯 UI 速度指标。 |
| `MafApprovalCard`(原型) | `ApprovalMessageCard` | 重写为 conversation 时间线内卡片。 |
| `MafEventTimeline`(原型) | 删除 | 不再暴露 run/event 管理后台。 |
| `MafSeatStatusPanel`(原型) | `ParticipantStrip` | 合并到右侧席位条。 |
---
## 4. 禁止复用的旧业务 hook / API / 状态清单
以下模块 **严禁** 被新 MAF conversation 页面直接 import 或调用:
### 4.1 旧会商 API 客户端(`src/roundtable-planning/api/`)
| 文件 | 禁止原因 |
|------|----------|
| `multi-agent.ts` | 调用 `/api/multi-agent/*`,包含前端 thread map 和编排逻辑 |
| `intent.ts` | 调用 `/api/intent/*`,前端驱动意图流 |
| `drafts.ts` | 调用 `/api/roundtable-drafts`,旧草稿状态 |
| `task-drafts.ts` | 调用 `/api/roundtable-task-drafts`,旧任务草稿 |
| `roundtable-jobs.ts` | 调用 `/api/roundtable-jobs`,旧后台任务 |
| `chains.ts` | 调用 `/api/roundtable-chains`,旧业务链 CRUD |
| `recommend.ts` | 调用 `/api/recommend/stream`,旧推荐流 |
| `flow-extract.ts` | 调用 `/api/flow-extract/*`,旧流程图提取 |
| `ingest.ts` | 调用 `/api/flow-extract/ingest-eligibility` |
| `dashboard-sessions.ts` | 调用 `/api/dashboard-sessions` |
| `diagnostics-report.ts` | 调用 `/api/roundtable-diagnostics/report` |
### 4.2 旧会商业务 Hook(`src/roundtable-planning/hooks/`)
| 文件 | 禁止原因 |
|------|----------|
| `useStep1Intent.ts` | 前端驱动意图状态机 |
| `useStep2Orchestration.ts` | 前端编排循环(thread map、派活、轮次控制) |
| `useDraftPersistence.ts` | 旧草稿持久化逻辑 |
| `useRoundtableDrafts.ts` | 旧草稿列表 |
| `useStep3Summary.ts` | 前端驱动总结生成 |
| `useStep3Report.ts` | 前端驱动看板生成 |
| `useStep3Dashboard.ts` | 前端驱动大屏生成 |
### 4.3 旧会商业务状态类型
- `Step1Snapshot`、`Step2Snapshot`、`Step2Dialogue`、`StubAiMessage`、`ParallelSeatView`
- `ThreadIdMap`、`ClarificationPayload`、`LeaderDispatch`、`StreamPhase`
- `DraftRecord`、`DraftMeta`、`DraftStep1Snapshot`、`DraftStep2Snapshot`、`DraftStep3Snapshot`
- `RoundtableJob`、`JobStatus`、`DispatchChainNode`、`StartJobPayload`
- `IntentSummary`、`IntentFileRef`、`IntentStreamPhase`
### 4.4 MAF 原型中禁止继续扩展的模块
| 文件 | 禁止原因 |
|------|----------|
| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 实验原型,不作为产品主界面 |
| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 实验原型,暴露 run/session 管理 |
| `src/maf-roundtable/hooks/use-maf-events.ts` | 原型轮询 hook |
| `src/maf-roundtable/api/sessions.ts` | 原型 session CRUD |
| `src/maf-roundtable/api/runs.ts` | 原型 run 管理 |
| `src/maf-roundtable/api/events.ts` | 原型事件轮询 |
| `src/maf-roundtable/lib/session-form.ts` | 原型表单逻辑 |
### 4.5 允许参考但必须重写的视觉组件
以下组件的 **交互模式和信息架构** 可作为参考,但新 MAF 页面必须使用独立实现(MAF view model + tdesign-react),不得直接 import:
- `MessageBubble.tsx`(气泡布局)
- `MessageStepsCard.tsx`(工具步骤折叠)
- `IntentClarificationCard.tsx`(澄清选项卡)
- `ParallelSeatStrip.tsx`(席位头像条)
- `ScrollToBottomButton.tsx`(回到底部按钮)
- `Avatars.tsx`(头像 SVG)
### 4.6 可安全复用的无业务依赖工具
- `useAutoScroll.ts`(纯滚动逻辑)
- `useDialogueSpeedMetrics.ts`(纯速度指标)
- `src/lib/use-desensitize-subtree.ts`(DOM 脱敏,已标注多模块共享)
- `markdown-components.tsx`(markdown 渲染配置,需确认无旧状态依赖)
---
## 5. 验收确认
- [x] 旧会商 `RoundtablePlanningPage` 无代码行为变化(未修改任何 `src/roundtable-planning/` 文件)。
- [x] `/page/maf/*` 原型已从菜单隐藏,仅保留路由供开发诊断。
- [x] 8 类页面状态已记录,含触发条件、表现、关键组件和数据流。
- [x] 旧组件到新 MAF view-model 的映射表已完成。
- [x] 禁止复用的 hook/API/状态清单已明确。
- [x] 任何人可根据本文档判断新版是否「像原页面」。
@@ -1,680 +0,0 @@
# MAF 多智能体会商升级版实施计划(V2:保留原会商体验,替换底层编排)
> 文档状态:**V2 重写,待按本计划重新实施**
> 更新时间:2026-08-02
> 本轮决策:原 `/page/maf/*` 的「会话列表 + run 详情 + 前端轮询」原型不再作为目标产品继续扩展。
> 产品目标:新 MAF 会商是**原多智能体会商页面的底层升级版**,不是另造一套会商产品。
---
## 1. 本次重新确认的产品目标
用户认可的是现有多智能体会商的视觉语言和交互链路:左侧历史会话、中心对话流、输入框发问、意图理解、总控派活、席位发言、过程卡片、澄清与审批、最终结论。
本次升级只替换以下“看不见的发动机”:
- 旧的前端驱动编排、多个前端可见线程和旧会商作业,替换为 MAF Workflow + DeerFlow Agent Adapter;
- 浏览器定时轮询,替换为一条可恢复的 SSE 对话事件流;
- 用户手动创建/管理多个智能体线程,收敛为服务端按需创建和持久化的内部执行线程;
- 保留现有工具、技能、MCP、沙箱、WriteGuard、审批和审计能力,并让它们在原有会商体验中自然出现。
**不能接受的结果:** 新页面没有对话列表、没有输入框、让用户先创建 session/run、需要用户选择或理解多个内部线程、靠多个定时 GET 拼出实时效果,或把审批/流程图做成脱离聊天主链路的独立后台页。
### 1.1 必须保留的用户体验
| 原会商能力 | 升级版要求 |
|---|---|
| 左侧会商历史、新建会商 | 保留。一个条目就是一个用户可见会商,不暴露 MAF session/run/thread。 |
| 中央连续对话流 | 保留。用户消息、总控消息、席位发言、工具进度、澄清、审批、最终报告按时间顺序呈现。 |
| 底部对话输入框 | 保留。用户可在首轮发起任务、在澄清时回答、在会商结束后追问/继续会商。 |
| 意图理解 | 保留并服务端化。先识别“可直接会商 / 需要澄清 / 只回答问题 / 需要工具或写入”。 |
| 总控智能体派活 | 保留。总控是唯一能选择席位、分派任务、收束轮次和生成结论的主体。 |
| 业务链、席位与过程可视化 | 保留其信息架构和交互,不复用旧业务状态;数据改由 MAF 事件驱动。 |
| 澄清、取消、恢复、审批 | 保留,并成为对话流中的卡片,而不是跳转到 run 管理页。 |
### 1.2 明确不保留的实现方式
- 不在浏览器端 `setInterval` 轮询 run、events、actions、approvals;
- 不让浏览器创建或维护 seat thread、manager thread、thread map;
- 不让用户先填写“经理、席位、模式、最大轮数”才能开始一次正常会商;
- 不把 `MafSessionsPage` / `MafRunDetailPage` 当作新产品主界面继续堆功能;
- 不调用旧的 `/api/roundtable-*`、`/api/multi-agent/*`、`roundtable_jobs`、旧草稿或旧会商状态;
- 不修改旧 `RoundtablePlanningPage` 的业务状态机、历史数据和对外行为。
---
## 2. 总体架构与边界
```mermaid
flowchart LR
U[用户:会商对话页] -->|POST 新建/发送消息| API[MAF Conversation API]
API --> C[(MAF Conversation / Message)]
API --> D[MAF Dispatcher]
D --> W[MAF Workflow]
W --> M[总控:意图、派活、收束]
M --> S[服务端按需创建席位执行线程]
S --> A[DeerFlow Agent Adapter]
A --> T[工具 / 技能 / MCP / 沙箱]
T --> G[WriteGuard + 审批]
W --> E[(持久化会商事件)]
E --> H[SSE Hub:重放 + 实时推送]
H -->|一条 SSE 流| U
```
### 2.1 前后端职责
| 层 | 职责 | 禁止事项 |
|---|---|---|
| 前端 | 呈现会商、发送用户消息、消费 SSE、执行用户明确点击的取消/审批/重试 | 编排席位、决定下一位发言者、创建内部线程、轮询事件。 |
| MAF Conversation Service | 会话聚合、消息持久化、运行创建、SSE 重放、所有权校验 | 让浏览器传入 user_id、工具能力快照、目标键或内部幂等键。 |
| MAF Workflow | 意图判断、总控派活、席位协作、交叉审查、结论、恢复 | 把模型输出直接当作可写外部系统的授权。 |
| DeerFlow Adapter | 为总控/席位提供既有模型、工具、技能、MCP、沙箱和用户上下文 | 从 `packages/harness/deerflow/**` 导入 `app.*`。 |
| WriteGuard | 风险识别、审批、目标租约、一次执行、审计 | 将密钥、target_key、idempotency_key 回传给浏览器或模型。 |
### 2.2 新旧隔离规则
1. 旧会商继续运行,旧路由、旧 API、旧数据表、旧任务深链全部不变。
2. 新页面独立使用 `/api/maf-roundtable/*` 和 `maf_roundtable_*` 表;只允许参考旧页面的布局、文案、交互和通用视觉规范。
3. 旧 `roundtable-planning/**` 中的业务 hook、草稿、Job、SSE 消费器和状态类型不能被新页面直接 import。
4. 若需要复用旧组件的视觉效果,必须复制/迁移为 `maf-roundtable/legacy-compatible/**` 中的新组件,替换为 MAF view model;或抽到无业务依赖的公共展示组件。旧页面不得因此改变行为。
5. 新旧页面都只能使用仓库本地依赖;UI 基础组件统一采用 **`tdesign-react`**,不接远程 UI、CDN 或外部运行时。
---
## 3. 用户可见的新版会商流程
### 3.1 首次进入
1. 用户点击独立菜单“MAF 会商(升级版)”,进入与旧会商相同层级的页面壳。
2. 左侧显示自己的 MAF 会商历史和“新建会商”。新建只创建一个**用户可见会商**,不显示 session/run/thread 表单。
3. 中心区域显示欢迎语、业务链/任务上下文入口和对话输入框。
4. 用户输入任务后,前端立即插入本地的用户气泡,调用一次 `POST /messages`,然后消费同一会商的 SSE 流。
### 3.2 一次会商轮次
1. SSE 推送“正在理解任务”事件;页面以旧会商的过程卡/总控气泡表现。
2. 若信息不足,推送 `clarification.required`,页面显示可回复的澄清卡;用户回答后仍通过输入框或卡片提交。
3. 若可以执行,总控输出“派活计划”;页面以旧的席位条、派活卡和消息气泡展示。
4. 每个席位的可见发言、工具进度、技能过程、交叉审查和总控收束都写入同一条时间线。
5. 出现外部写操作时,SSE 推送审批卡。用户批准/拒绝后,后端恢复工作流,SSE 继续推送;前端不手工轮询 run 状态。
6. 最终结论、事实账本、执行动作和报告作为对话流中的结果卡呈现;用户可以继续在同一会商输入追问。
### 3.3 “一个会商”与内部线程的关系
- 用户只认识 `conversation_id`;左侧历史、URL 和前端状态都以它为主键。
- 一个 conversation 可以有多轮用户消息,每轮消息产生一个内部 `maf_run`。
- `maf_session`、manager/seat thread、seat run 都是服务端内部执行身份。服务端使用既有 `ExecutionPrincipal` 绑定当前用户,并在真正需要某席位执行时懒创建/复用线程。
- 浏览器只收到经过脱敏的消息和事件,不会收到内部 thread id、能力快照、目标键、幂等键、密钥或原始工具结果。
### 3.4 总控质量闸门:派活不是“发出即完成”
当前旧会商的问题不是没有派活,而是席位只说一两句话、格式和证据都不合格时,总控仍把它当作完成并直接进入下一步。新版 MAF 必须把总控从“消息转发者”升级为**每项席位交付的验收责任人**。
每一个由总控发布的席位任务都必须有服务端持久化的 `task_contract`:
| 字段 | 含义 |
|---|---|
| `objective` | 本席位要回答的具体问题,不能只写泛泛角色描述。 |
| `expected_deliverable` | 交付类型和结构,例如“风险清单 + 依据 + 优先级 + 建议”。 |
| `acceptance_criteria` | 由总控给出的可验收标准:覆盖范围、最少关键点、证据/工具依据、格式与约束。 |
| `required` | 是否为本阶段必需交付;必需项不能被静默跳过。 |
| `max_rework_attempts` | 同一席位最多返工次数,默认 **2**(初稿之外最多两次返工),服务端配置且写入快照。 |
| `fallback_policy` | 到达返工上限后的策略:改派、请求用户澄清,或带缺口受控推进。 |
#### 3.4.1 席位交付与总控验收状态机
```mermaid
stateDiagram-v2
[*] --> assigned
assigned --> delivering
delivering --> submitted
submitted --> manager_review
manager_review --> accepted: 达到合同标准
manager_review --> rework: 不合格且未到返工上限
rework --> delivering: 带明确退回意见
manager_review --> reassigned: 达到上限且可改派
reassigned --> delivering
manager_review --> exhausted: 达到上限且无合适改派
exhausted --> controlled_gap: 记录缺口、风险与原因
accepted --> [*]
controlled_gap --> [*]
```
验收规则如下:
1. 席位先提交结构化 `delivery`,至少包含结论、依据/证据、未解决项、自检结果;不能只输出“已完成”或一两句空泛结论。
2. 进入 `manager_review` 前先执行确定性检查:空内容、最小有效长度、必须章节、必填字段、所需证据/工具引用、重复文本、错误 schema。确定性不通过时不允许直接 accepted。
3. 总控随后根据 `task_contract` 做结构化审查,决策只能是 `accept`、`return_for_rework`、`reassign`、`ask_user`、`controlled_gap`,并必须写明 `reason` 和针对性修改要求。
4. **总控的模型自评不是验收证据。** 席位自评分只作为参考;验收由确定性规则 + 总控审查 + 必要时交叉审查共同决定。
5. 对同一席位,默认最多两次返工。每次打回都必须把“缺什么、怎样补、哪些原结论不可用”带入下一次任务上下文。
6. 到达上限后绝不把不合格结果伪装成合格:总控应优先改派给一个合适备用席位(默认最多一次改派);仍无法满足时生成 `controlled_gap`,明确标记未覆盖问题、风险和对最终结论的影响,再受控进入下一阶段。
7. 一个阶段的所有 `required` 任务必须处于 `accepted` 或 `controlled_gap` 等明确终态,阶段闸门才可通过。总控不能因为“轮数到了”或“已有部分回答”跳过未决必需项。
8. 最终报告不得将 `controlled_gap` 的内容写成已验证事实;它必须在结论中的风险/待补信息部分可追溯显示。
这既满足“打回几次后不要死循环”的要求,也避免“超过次数就把垃圾答案强行推进”的错误行为。
#### 3.4.2 对话页必须可见的质量反馈
- 总控派活卡展示任务目标、交付要求和当前第几次尝试;
- 席位提交后显示“总控审核中”,而不是立即标为完成;
- 打回时以总控消息卡显示具体退回理由和补充要求;
- 改派时显示原席位未通过和新席位承接范围,但不羞辱性展示内部评分;
- 受控降级时显示“存在未解决缺口”,并在最终结果卡中保留风险提示;
- 右侧席位条区分“执行中、待返工、已验收、已改派、存在缺口”,不能只显示“发过言”。
---
## 4. API 与 SSE 契约(必须先定,前端不得猜测)
### 4.1 Conversation API
以下接口全部位于 `/api/maf-roundtable`,身份只从服务端鉴权上下文读取。
| 接口 | 用途 | 前端调用时机 |
|---|---|---|
| `GET /conversations` | 左侧历史列表(分页) | 首次进入、用户显式刷新、创建/删除后失效刷新;**无定时轮询**。 |
| `POST /conversations` | 创建空白用户会商 | 点击“新建会商”。只接收 title、任务上下文等安全字段。 |
| `GET /conversations/{id}` | 获取会商快照、权限和当前状态 | 进入会商或浏览器刷新时。 |
| `GET /conversations/{id}/messages` | 获取历史消息分页 | 进入会商、向上滚动加载历史。 |
| `POST /conversations/{id}/messages` | 原子写入用户消息并启动 MAF run | 用户点击发送或提交澄清。返回 `message_id`、`run_id`、`conversation_id`。 |
| `POST /conversations/{id}/cancel` | 取消当前活跃 run | 用户显式点击取消。 |
| `POST /approvals/{id}/decision` | 批准/拒绝写入动作 | 审批卡点击;成功后由后端恢复 workflow。 |
| `POST /conversations/{id}/recover` | 显式恢复异常中断的活跃会商 | 仅在后端判定可恢复时展示。 |
不再让页面以“创建 session → 创建 run → GET events 循环”的方式驱动会商。原有低层 session/run 接口可以作为内部诊断 API 暂时保留,但从用户页面和导航移除。
### 4.2 SSE:唯一的实时通道
```text
GET /api/maf-roundtable/conversations/{conversation_id}/stream?after_seq={last_seen_seq}
Accept: text/event-stream
Authorization: 与现有 fetch 客户端一致
```
- 前端在进入一个 conversation 后只建立**一条** fetch-reader SSE 连接;切换会商、卸载页面、权限失效时 Abort。
- 浏览器不能用定时 GET 代替 SSE。连接断开后采用指数退避重连,并携带最后已确认的 `after_seq`;不产生全量重复气泡。
- `after_seq` 是 conversation 维度、服务端单调递增的序号,不能复用每个 run 独立的 `seq`。
- 首次连接先由 `GET messages` 加载历史,再从该历史的最大 `seq` 订阅 SSE;服务端也必须支持重放,避免“历史请求和订阅之间”丢事件。
- SSE 使用 `fetch` + `ReadableStream` 解析,以复用项目鉴权头;响应必须带 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`X-Accel-Buffering: no`,并每 15 秒发送心跳。
- 服务端负责等待和推送。单 worker 可用进程内 conversation event hub;生产多 worker 部署前必须接入 Redis Pub/Sub 或等价分布式广播,不能退回浏览器轮询。
### 4.3 SSE 事件信封
所有事件统一为:
```json
{
"seq": 42,
"event_id": "evt_...",
"conversation_id": "conv_...",
"run_id": "run_...",
"type": "agent.message.delta",
"occurred_at": "2026-08-02T12:00:00Z",
"data": {}
}
```
允许的首批 `type`:
| 类型 | 对话页表现 |
|---|---|
| `conversation.snapshot` | 首帧状态同步,不重复插入消息。 |
| `user.message.created` | 刷新/多标签同步时补齐用户气泡。 |
| `manager.intent` | 总控“正在理解/已理解”的过程卡。 |
| `clarification.required` / `clarification.resolved` | 澄清卡及其回答状态。 |
| `manager.dispatch` | 总控派活卡、席位条状态更新。 |
| `seat.delivery.submitted` | 席位提交交付,进入总控审核而非直接完成。 |
| `manager.review.decision` | 展示验收、打回、改派或受控降级及其安全摘要。 |
| `seat.rework.assigned` / `seat.reassigned` | 更新第几次返工、原/新席位和新的任务要求。 |
| `stage.quality_gate` | 阶段质量闸门通过、阻塞或带缺口推进。 |
| `agent.message.start` / `delta` / `complete` | 席位或总控流式气泡。 |
| `tool.started` / `tool.progress` / `tool.completed` | 现有步骤卡/工具过程,不显示敏感参数。 |
| `approval.required` / `approval.decided` | 嵌入对话流的审批卡。 |
| `fact.confirmed` / `review.completed` | 事实/交叉审查摘要卡。 |
| `run.status` / `run.error` / `run.completed` | 顶部状态、失败重试与最终结果。 |
事件 `data` 必须先经过现有 MAF 安全规范化和二次展示脱敏;前端不负责猜测工具风险或修复敏感数据。
---
## 5. 后端改造计划
### MAF-R0:冻结错误原型并建立旧会商基线
**难度:D2(中低)|负责人:前端/测试各 1 人|前置:无**
- 将现有 `/page/maf/*` 标记为“实验原型”,从正式菜单移除;不得在其上继续新增业务功能。
- 记录旧会商的页面状态:空会商、发送后、意图澄清、总控派活、席位流式发言、审批、完成、失败恢复、历史切换。
- 为每种状态保存可复现的测试数据与截图基线;截图只作为视觉验收参考,不作为数据契约。
- 输出旧组件到新 MAF view-model 的映射清单,禁止直接搬旧 hook。
**验收:** 旧会商无行为回归;团队对“复制体验、替换编排”有书面映射表。
### MAF-R1:Conversation 聚合与消息持久化
**难度:D5(高)|负责人:后端资深 1 人|前置:R0**
新增/迁移 MAF 专用持久化模型:
- `maf_roundtable_conversations`:用户可见会商,至少包含 id、owner_user_id、title、task_context、安全状态、active_run_id、created_at、updated_at;
- `maf_roundtable_messages`:用户消息、总控/席位消息、澄清/审批/结果卡的稳定历史表示;
- `maf_roundtable_conversation_events`:conversation 维度严格递增的 `seq`、event_id、run_id、type、安全 data、created_at;
- 现有 `maf_roundtable_sessions/runs/events/seat_runs/actions/approvals` 保留为底层执行与审计记录;conversation 与内部 session/run 显式关联。
要求:
1. 发送用户消息、创建/占用 run、写入首个用户消息事件必须在清晰的事务边界内完成;失败不留“消息已显示但未运行”的孤儿状态。
2. 消息历史必须可在刷新后重建对话;不能只依赖内存或 SSE 临时帧。
3. 所有读写继续 owner-only;管理员诊断只读不扩大写权限。
4. 新迁移必须 SQLite/PostgreSQL/MySQL 可重复执行;旧 MAF 原型数据可保留,不强制伪造为新对话历史。
**验收:** 会话创建、连续发消息、刷新恢复、并发发送冲突、跨用户隔离、迁移幂等都有 API 和存储测试。
### MAF-R2:服务端 SSE Hub 与断线续传
**难度:D5(高)|负责人:后端流式/并发经验者 1 人|前置:R1**
- 新增 conversation stream 路由和 `ConversationEventHub`;Workflow/Dispatcher 的规范化事件落库后再发布,顺序是“先持久化、后通知”。
- SSE 连接先按 `after_seq` 重放,再订阅实时事件;重放和订阅切换期间必须有序、不丢帧、不重复。
- workflow 终态、审批等待、审批恢复、取消、失败重试都必须通过同一流通知。
- 不允许每个 SSE 客户端创建数据库轮询循环。服务端可以阻塞等待 hub 通知,事件重放只在连接/重连时发生。
- 写出单 worker 与多 worker 的实现边界;若部署配置多 worker,未接入分布式广播时健康检查必须明确告警并禁止该模式上线。
**验收:** 测试覆盖慢客户端、断线重连、`after_seq` 重放、同时打开两个标签、切换会商、取消、审批后恢复、进程内 hub 异常和终态自动关流。
### MAF-R3:总控意图理解与派活语义对齐
**难度:D5(高)|负责人:MAF/Agent 负责人 1 人|前置:R1,可与 R2 并行**
在现有 MAF Workflow 上增加用户对话入口状态,而不是让前端预先填 manager/seat/run:
1. 总控首先进行意图判断:普通问答、需要澄清、单席位任务、并行专家会商、链式业务会商、需要工具/写入。
2. 总控产出受 schema 校验的派活计划;只有服务端可将计划转成实际席位执行。
3. 每个计划项必须生成并冻结 `task_contract`(目标、交付结构、验收标准、必需性、返工上限和降级策略),席位的发言不等于交付完成。
4. 席位完成后先做确定性完整性检查,再由总控做结构化验收;总控可接受、打回同一席位、改派、请求用户补充或记录受控缺口。所有决策、次数和理由持久化到 checkpoint 与审计事件。
5. 默认初稿后最多两次同席位返工;到达上限优先改派一次,仍失败才以带风险标记的 `controlled_gap` 结束。严禁自动把不合格交付推进为合格事实。
6. 阶段迁移需经过质量闸门:所有必需任务均有 `accepted` 或受控缺口终态;最终总结只能消费已验收事实,缺口必须显式传入风险章节。
7. 席位集合来自业务链、可信能力快照和上下文;前端可呈现“建议/已参与席位”,但不能提交任意工具权限或 thread id。
8. 总控每轮应吸收已确认事实、验收结果、退回原因和用户追问;弱模型无效派活、重复派活、无收敛时走确定性纠偏/终止策略。
9. 已有 structured/group_chat、事实账本、交叉审查、审批 checkpoint、执行阶段身份均继续复用;只重构其输入输出为 conversation event。
**验收:** 用同一任务对照旧会商的意图澄清、总控派活、席位协作、打回/改派和收束结论;弱模型的短答、空答、错误 schema、缺证据、重复派活、非法席位、超轮次、返工上限和受控降级都有确定性保护测试。
#### R3 当前实施记录(2026-08-03)
后端已先完成 R3 的服务端核心骨架,作为 R1/R2 Conversation API/SSE 的后续:
- `app/maf_roundtable/dispatch_contracts.py` 提供确定性意图分类、服务端业务路线解析、schema 校验的 `MafDispatchPlan` 和弱模型防护;请求、模型输出都不能提供 agent id、thread id、tool metadata 或能力快照。
- `MafRoundtableConfig` 增加 `default_manager_agent_id`、`default_seat_agent_ids`、`business_routes` 和 `max_seats_per_run`。仓库配置保持空路线;上线前必须由部署配置填入实际 agent catalog 中的精确 id,未知业务路线失败关闭。
- 首条消息在同一事务内创建内部 session、服务端选定的 manager/seat participants 和冻结能力快照;澄清意图进入 `waiting_input`,不创建 run,用户补充信息后才允许重新 claim。
- Conversation service 已接入现有 `MafRunDispatcher`/`WorkflowRunner`,持久化 `manager.intent`、`manager.dispatch` 和 run 状态桥接事件;现有结构化工作流的席位集合仍只来自服务端 participants,manager 文本不能扩席或改派非法席位。
- V2 `POST /conversations/{id}/messages` 只返回 `message_id`、`conversation_id`、`seq`;`run_id` 仍是持久层/服务端内部关联字段,不返回给浏览器。
- 新增 `tests/test_maf_phase3_conversation_dispatch.py`,覆盖未知业务路线、默认路线、澄清、完成状态桥接、重复/越权派发计划等确定性用例。R3 尚未整体验收完成:仍需完成新页面、完整真实 MAF Workflow E2E、质量门禁和与旧会商体验的浏览器对照验收。
### MAF-R4:将审批和外部写入嵌回对话主链路
**难度:D4(较高)|负责人:后端安全 + 前端各 1 人|前置:R2、R3**
- 保留 Phase 5 的 WriteGuard、能力快照、HMAC 目标键、租约、加密参数、一次执行和审计;不得为了 SSE 改弱任何安全边界。
- `approval.required` 只携带白名单展示字段和安全摘要。审批决定后由后端自动续跑,前端只等待同一 SSE 流中的新事件。
- 拒绝、过期、执行失败、unknown 均形成清晰对话卡和可恢复状态;不得把“未知结果”伪装成成功。
- 行为审计可在最终结果区展开查看,但不能替代对话中的关键用户反馈。
**验收:** HTTP → MAF → ToolNode/outbox → 审批 → SSE → 前端卡片 的端到端测试;确保 target_key、idempotency_key、internal_canary、密钥和原始工具结果从不进入消息/API/SSE。
---
## 6. 前端改造计划:复刻体验,替换状态源
### MAF-R5:新会商页面壳与历史列表
**难度:D4(较高)|负责人:熟悉旧会商的前端 1 人|前置:R0、R1**
新建 `src/maf-roundtable/conversation/**`,建议结构:
```text
maf-roundtable/conversation/
pages/MafRoundtableConversationPage.tsx
components/
ConversationSidebar.tsx
ConversationTimeline.tsx
ConversationComposer.tsx
ManagerDispatchCard.tsx
SeatMessageBubble.tsx
ToolProgressCard.tsx
ClarificationCard.tsx
ApprovalMessageCard.tsx
FinalResultCard.tsx
ParticipantStrip.tsx
hooks/
use-maf-conversations.ts
use-maf-conversation-stream.ts
use-maf-conversation-reducer.ts
api/conversations.ts
lib/event-reducer.ts
styles/maf-roundtable-legacy-compatible.css
```
页面要求:
- 左侧 `ConversationSidebar` 的层级、历史列表、新建、激活态、删除/重命名交互对齐旧会商;
- 中间 `ConversationTimeline` 使用连续消息流,保留旧会商的用户气泡、总控气泡、席位气泡和时间锚点;
- `ConversationComposer` 常驻底部;发送、停止、澄清回答、追问都在该入口完成;
- 右侧仅保留旧体验中真正有用的参与席位、当前阶段和业务链摘要,不再摆放 run/session 管理后台;
- 使用本地 `tdesign-react` 组件实现按钮、输入、Dialog、Tag、Tabs 等基础交互,同时通过 scoped CSS 对齐旧视觉;
- 视觉相似不等于 import 旧业务组件。旧组件仅作参考,数据类型、hook 和 API 必须是 MAF 专用实现。
**验收:** 不连接后端时,使用固定 MAF 事件样本可完整演示旧会商同等的 8 种关键状态;没有 session/run/thread 表单。
### MAF-R6:SSE 客户端、事件 reducer 与刷新恢复
**难度:D5(高)|负责人:前端流式经验者 1 人|前置:R2、R5**
- `use-maf-conversation-stream` 只维护一个连接、一个 cursor、一个 AbortController;不得含 `setInterval`、`refetchInterval` 或轮询 fallback。
- reducer 以 `(conversation_id, seq)` 去重并排序,将原始事件转为稳定 view model;流式 delta 必须按 `message_id` 合并,不能用数组下标。
- 切换会商时先 abort 旧流并增加 generation;旧 SSE 帧永远不能污染新会商。
- 刷新时先加载消息历史,再以最后 seq 订阅;网络断开显示“正在重连”,重连成功不重复气泡。
- 只有历史列表等非实时资源可在用户主动操作后调用 GET;不能靠后台刷新伪装实时性。
**验收:** 模拟 1 万条历史、跨页加载、慢旧流切换、新流先到、断线重连、重复帧、乱序帧、终态关流和多标签同步;所有案例必须组件级测试。
### MAF-R7:意图/派活/审批卡的旧体验兼容
**难度:D4(较高)|负责人:前端业务负责人 1 人|前置:R3、R4、R6**
- 用旧 `IntentClarificationCard`、`MessageStepsCard`、`ParallelSeatStrip`、`MessageBubble` 的交互作为视觉和信息架构参照,重新实现 MAF 版本。
- 总控派活计划必须先显示“将由谁做什么”,席位开始/结束、工具过程和交叉审查要回到原有对话叙事中。
- 席位“发言结束”只能显示为“已提交,待总控审核”;只有收到 `manager.review.decision=accept` 才能显示“已验收”。打回次数、改派和缺口必须清楚可见。
- 审批卡需要同步防双击锁、二次脱敏、拒绝/通过后的“等待工作流继续”状态;不可另外跳转到 action 审计页。
- 最终结论卡要区分“建议动作”“审批中动作”“已执行动作”“失败/未知动作”。
- 继续追问时,页面不新建多个用户可见线程;后端根据 conversation 上下文决定是否创建新 run 或继续恢复。
**验收:** 产品人员可在不看技术术语的情况下完成“发起会商 → 澄清 → 观察派活 → 审批 → 查看结论 → 追问”全链路。
---
## 7. 测试、上线与回滚
### 7.1 必须新增的自动化测试
| 类别 | 核心用例 |
|---|---|
| 后端事务 | 发消息/创建 run 原子性、并发发送、跨用户隔离、迁移幂等。 |
| SSE 服务端 | 重放、顺序、心跳、取消、断线、审批恢复、多订阅者、终态关流。 |
| Workflow | 意图澄清、总控派活、弱模型格式错误、重复派活、最大轮次、恢复。 |
| 质量闸门 | 短答/空答、缺少必填章节、无证据、总控打回、两次返工上限、一次改派、受控缺口、阶段阻塞和最终报告不把缺口伪装成事实。 |
| 安全 | SSE/API/历史消息不泄露敏感键和值;审批和一次写入仍保持现有 Phase 5 保证。 |
| 前端 reducer | delta 合并、去重、乱序、切换代次隔离、重连 cursor。 |
| 前端组件 | 输入发送、澄清、派活、审批锁、取消、失败恢复、历史切换、无轮询断言。 |
| E2E | 从用户输入到真实 MAF Workflow、ToolNode/outbox、审批、最终结果的 SSE 全链路。 |
| 回归 | 旧 `roundtable-planning`、旧 DeerFlow、AI 写作以及既有 MAF Phase 1–5 用例全部通过。 |
### 7.2 人工视觉验收
在同一浏览器尺寸下对照旧会商,至少核对:
1. 空白页和新建会商;
2. 左侧历史/当前会商切换;
3. 用户输入、流式总控消息、席位消息;
4. 意图澄清和总控派活;
5. 工具过程、审批、取消、失败恢复;
6. 最终结论和连续追问;
7. 窄屏滚动、长内容、断线重连和页面刷新。
### 7.3 灰度与回滚
- 新入口使用独立 feature flag,例如 `maf_roundtable.conversation_ui_enabled`;旧会商不受该开关影响。
- 先仅向内部账号开放,记录连接数、重连率、事件延迟、工作流失败率、审批耗时和前端异常。
- 出现问题时关闭新入口即可;不迁移或删除旧会商数据,也不需要回滚旧页面。
- 当前错误原型可保留为仅开发者诊断路由,或在新版稳定后删除;它不应继续出现在用户菜单。
---
## 8. 分阶段任务分配、难度与交付顺序
本节是分工的唯一依据。难度定义:D1=很低(局部文案/样式),D2=低(明确边界的小模块),D3=中(跨 2–3 个模块),D4=较高(跨前后端或需回归),D5=高(并发、状态机、安全或 Agent 行为,必须由有经验者负责)。
### 阶段 0:冻结错误原型,提取旧会商交互基线(R0)
**难度:D2|建议人员:1 名熟悉旧会商的前端 + 1 名测试|依赖:无**
具体要做:
1. 将现有 `/page/maf/*` 的 session/run 详情原型从正式菜单隐藏,标注为开发诊断原型,停止继续加功能。
2. 逐一记录旧会商的 8 类页面状态:空白/新建、历史切换、用户提问、意图澄清、总控派活、席位发言、审批、完成/失败恢复。
3. 给每种状态保留可复现 mock 数据、页面截图和操作步骤;梳理旧组件到新 MAF view-model 的映射。
4. 明确哪些只能参考视觉和交互,哪些旧业务 hook/API/状态禁止复用。
交付物:旧会商交互地图、截图基线、组件映射表、被隐藏的原型入口说明。
验收:旧会商没有代码行为变化;任何人可以根据基线判断新版是否“像原页面”。
### 阶段 1:建立用户可见 Conversation 与消息历史(R1)
**难度:D5|建议人员:1 名资深后端|依赖:阶段 0**
具体要做:
1. 新增 `maf_roundtable_conversations`、`maf_roundtable_messages`、`maf_roundtable_conversation_events`,设计 owner、标题、消息、conversation 全局序号、活跃 run 和安全状态字段。
2. 建立 conversation 与现有 MAF session/run/seat-run 的内部关联;用户只得到 conversation id,不能看到内部 thread id。
3. 实现会商历史列表、创建空白会商、读取会商快照、读取消息分页、发送用户消息五类 API。
4. 发送消息时,用户消息持久化、run 创建/占用和首个事件必须有明确事务边界;并发发送、重复请求、失败回滚都要处理。
5. 完成 SQLite/PostgreSQL/MySQL 迁移与 owner-only 权限测试。
交付物:迁移脚本、repository/service/router、API schema、后端事务与隔离测试。
验收:刷新后能从消息历史恢复完整会商;跨用户不可读写;失败不会留下“已显示但没有实际运行”的孤儿消息。
### 阶段 2:服务端 SSE 对话流、事件 Hub 与断线续传(R2)
**难度:D5|建议人员:1 名熟悉 FastAPI 异步流与并发的后端|依赖:阶段 1**
具体要做:
1. 实现 `GET /conversations/{id}/stream?after_seq=`,按 conversation 全局 seq 先重放、后订阅实时事件。
2. 新建 `ConversationEventHub`;所有 workflow 事件先安全落库,再按顺序广播。禁止 SSE 客户端通过定时查库等待更新。
3. SSE 实现心跳、连接取消、权限校验、终态关流、异常帧和断线后的 cursor 续传。
4. 明确多 worker 部署方案:单 worker 可进程内广播;多 worker 必须 Redis Pub/Sub 或等价方案,未满足时不能灰度上线。
5. 把 run 状态、总控派活、席位消息 delta、工具过程、审批、质量审核、最终结论全部映射为统一事件信封。
交付物:SSE router/hub、事件发布适配器、集成测试、部署说明。
验收:慢连接、断线重连、乱序/重复防护、多标签订阅、审批后续跑、取消和终态均不丢事件;前端不需要轮询任何实时资源。
### 阶段 3:总控意图理解、业务链选择与服务端派活(R3)
**难度:D5|建议人员:1 名 MAF/Agent 工程师|依赖:阶段 1;可与阶段 2 并行**
具体要做:
1. 将旧会商的意图理解能力迁到 MAF 总控入口:识别直接问答、需澄清、单席位、并行会商、链式业务会商、工具/写入请求。
2. 总控根据业务链、用户上下文、可信能力快照选择席位,生成 schema 校验的派活计划;前端只展示,不参与编排。
3. Dispatcher 在服务端按需创建/复用 manager 和 seat 的内部执行线程,并由 `ExecutionPrincipal` 绑定当前用户。
4. 对弱模型增加确定性保护:非法席位、空派活、重复派活、无效 schema、超轮次、无法收束必须被拒绝、纠偏或终止。
5. 保持既有工具、技能、MCP、沙箱、事实账本、交叉审查和 checkpoint 恢复可用。
交付物:总控状态 schema、workflow executor、派活事件、弱模型测试矩阵。
验收:用户只输入任务,就能看到“意图理解 → 总控派活 → 席位执行”;浏览器没有 thread map 和派活决策代码。
### 阶段 4:席位任务合同、总控验收、打回与强制收束(R3-Q)
**难度:D5|建议人员:1 名 Agent/MAF 工程师 + 1 名后端状态机工程师|依赖:阶段 3**
具体要做:
1. 总控发布任务时冻结 `task_contract`:具体目标、交付格式、验收标准、必需性、返工次数、改派与降级策略。
2. 为每项席位任务持久化状态机:`assigned → delivering → submitted → manager_review → accepted/rework/reassigned/controlled_gap`。
3. 席位提交后先做确定性质量检查:空答、过短、必填章节缺失、格式错、无证据、重复内容等均不能自动完成。
4. 总控必须生成结构化验收决定和原因;不合格时打回并带精确补充要求。默认最多两次同席位返工,之后最多一次改派。
5. 改派仍失败时只能产生 `controlled_gap`,标记风险、缺失项和最终报告影响;严禁伪装为成功。
6. 增加阶段质量闸门:所有必需任务处于 `accepted` 或 `controlled_gap`,才允许进入交叉审查/下一阶段/最终总结。
7. 将尝试次数、退回理由、验收结果和缺口写入 checkpoint、审计和 SSE。
交付物:任务合同模型、持久化表/迁移、质量状态机、总控审查 prompt/schema、事件和测试。
验收:席位只说一两句话、无依据或内容垃圾时会被明确打回;达到上限不会死循环,也不会被当作合格产出;最终报告可追溯所有缺口。
### 阶段 5:审批、WriteGuard 与外部动作嵌入对话流(R4)
**难度:D4|建议人员:1 名安全后端 + 1 名前端|依赖:阶段 2、3、4**
具体要做:
1. 保留现有 Phase 5 的可信能力快照、WriteGuard、目标租约、HMAC、加密参数、一次执行与审计,不允许为新页面降低安全性。
2. 将工具开始、审批请求、批准/拒绝、执行成功/失败/unknown 变成安全 SSE 事件和对话卡。
3. 审批点击只调用明确决策 API;后端负责恢复 checkpoint/workflow,浏览器只等待 SSE。
4. 安全展示统一二次脱敏,禁止 target_key、idempotency_key、internal_canary、密钥、凭据和原始工具结果进入 API、消息、SSE 或 DOM。
交付物:事件映射、审批消息卡契约、端到端 ToolNode/outbox 测试。
验收:一次真实测试写入能在对话里经历“申请 → 审批 → 继续 → 结果”;重复点击或断线都不会重复外部写入。
### 阶段 6:复刻旧会商页面壳、历史列表和输入框(R5)
**难度:D4|建议人员:1 名熟悉旧会商代码的前端|依赖:阶段 0、1**
具体要做:
1. 新建 MAF 专用 conversation 页面、路由和独立 feature flag;不修改旧 `RoundtablePlanningPage`。
2. 依据阶段 0 的映射,复刻左侧历史会话、新建会商、中心时间线、常驻输入框、参与席位区和业务链摘要的视觉与交互层级。
3. 基础组件使用本地 `tdesign-react`,旧页面仅参考;需要的视觉组件在 MAF 目录重写或抽无业务依赖公共组件。
4. 接上 Conversation API:创建、历史、切换、消息分页。此阶段不允许先做任何轮询实时逻辑。
5. 删除新页面中暴露给用户的 manager/seat/max round/session/run 创建表单。
交付物:新页面壳、MAF 专用 view model、静态/接口 mock 组件测试、视觉对照截图。
验收:即使 SSE 未接通,用户也能看到与旧会商同类的历史、对话与输入体验;新旧业务状态零耦合。
### 阶段 7:前端 SSE 消费、对话 reducer 与过程交互(R6 + R7)
**难度:D5|建议人员:1 名前端流式工程师 + 1 名前端业务工程师|依赖:阶段 2、4、5、6**
具体要做:
1. 实现单连接 `use-maf-conversation-stream`:fetch-reader SSE、cursor、AbortController、指数退避重连和会商切换 generation 隔离。
2. 实现 event reducer:按 `conversation_id + seq` 去重排序;按 message_id 合并 delta;刷新后历史与 SSE 无缝衔接。
3. 重写旧会商对应的 MAF 版组件:总控意图卡、派活卡、席位条、席位气泡、工具步骤、澄清卡、审批卡、质量审核/打回卡、结果卡。
4. 所有卡片在时间线内叙事,不另开 run/session 管理后台;继续追问仍使用同一输入框。
5. 页面必须显示待审核、已验收、返工次数、改派、受控缺口和恢复状态,不能把“发过言”显示为“已完成”。
6. 代码审查和测试中检查不存在 `setInterval`、`refetchInterval` 或事件轮询 fallback。
交付物:SSE hook、reducer、对话组件、组件/API mock 测试、无轮询静态检查。
验收:切换会商、断线重连、审批续跑、慢旧流回包、长会话翻页均不污染、不丢消息、不重复;整个会商过程只依赖 SSE 实时更新。
### 阶段 8:端到端验收、灰度和回滚演练(R8)
**难度:D4|建议人员:1 名测试/集成人 + 各阶段负责人配合|依赖:阶段 1–7**
具体要做:
1. 跑完后端事务/SSE/Workflow/质量闸门/WriteGuard 测试、前端 reducer/组件测试、旧会商与 DeerFlow 回归。
2. 在真实浏览器按阶段 0 截图基线逐项对照,并完成用户任务:发起、澄清、派活、短答打回、两次返工、改派、审批、受控缺口、结论和追问。
3. 内部账号灰度,观测 SSE 建连/重连、事件时延、派活/验收/返工次数、workflow 失败率、审批耗时和前端错误。
4. 验证关闭 feature flag 后立即回到旧会商入口,旧数据和旧任务不受影响。
5. 若需生产外部写入,补齐 UAT 系统、最小权限凭据、核验 API、补偿联系人和生产 `MAF_ACTION_SECRET_KEY`。
交付物:自动化结果、人工验收记录、灰度指标面板、回滚演练记录、上线清单。
验收:满足第 9 节全部条件后才允许标记开发完成。
### 8.1 可并行边界
- 阶段 2 与阶段 3 可在阶段 1 的 schema/API 定稿后并行,但二者必须共用同一事件信封。
- 阶段 6 可在阶段 1 的 API mock 契约定稿后提前开始;不得自行发明事件字段。
- 阶段 5 必须等待阶段 2、3、4 的安全事件与状态机定稿。
- 阶段 7 必须等待阶段 2、4、6,不能以轮询临时替代 SSE。
- 阶段 8 只做集成、验收和上线准备,不补写前面阶段缺失的核心设计。
建议主顺序:`阶段 0 → 阶段 1 → (阶段 2 + 阶段 3 + 阶段 6) → 阶段 4 → 阶段 5 → 阶段 7 → 阶段 8`。
---
## 9. 最终验收定义
只有同时满足以下条件,才可以说“本次 MAF 会商升级开发完成”:
- 新页面在视觉和主交互上可被用户认作原多智能体会商的升级版;
- 页面具备历史会话、连续对话流、输入框、意图理解、总控派活、席位协作、澄清、审批、结果和追问;
- 每个必需席位任务都有任务合同、总控验收、有限返工和改派/受控缺口处理;席位短答或垃圾产出不能自动被当作完成;
- 浏览器没有会商事件/run/approval 的定时轮询;实时交互通过可重连 SSE 完成;
- 用户不需要创建或管理多个智能体线程,内部线程由服务端按用户身份安全创建和恢复;
- 新页面未调用旧会商业务 API/状态,旧会商零回归;
- 工具、技能、MCP、沙箱、审批、WriteGuard、一次写入和审计都仍可用;
- 自动化测试、SSE 故障测试、真实浏览器人工验收和旧功能回归全部通过;
- 真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议及生产 `MAF_ACTION_SECRET_KEY` 已按 Phase 5 前置条件完成。
在这之前,任何“会话/run 原型页面通过构建或轮询测试”的结果,都只能算技术验证,不算产品升级验收。
---
## 阶段 3 实施记录(当前代码基线)
本轮已补齐阶段 3 的服务端安全路由与真实 MAF Workflow 入口,范围仅针对新 MAF 会商;旧 DeerFlow、旧 roundtable 和 AI 写作行为不变。
### 配置与入口闸门
- 仓库默认 `maf_roundtable.enabled: false`。部署环境必须注入真实的 `default_manager_agent_id`、席位 ID 和业务路由后再开启,避免出现“入口显示正常但首条消息必然 503”的空配置。
- 健康接口返回非敏感的 `conversation_dispatch_ready` 与 `conversation_dispatch_status`;新会商入口只有在配置路由可用且服务已启用时才开放。
- 首条消息使用服务端可信能力快照校验 manager/seat 的可访问性;浏览器、会话请求和模型输出都不能提供或扩大 agent、thread、tool、capability 范围。
- 配置模型会清理并校验 manager/seat ID,拒绝 manager 出现在 seat 列表、重复席位、超过 `max_seats_per_run` 的路由和非法业务 code。
### 受约束的总控派活
服务端先由业务链与可信 capability snapshot 生成候选席位集合。总控模型只能返回严格 JSON:`intent_type`、`selected_seat_agent_ids`、每席 `task_goal`/`delivery_requirements`、`execution_mode`、`convergence_path` 和 `reason`。Pydantic schema、服务端 allowlist 和顺序校验全部通过后,最终计划才会写入 checkpoint,并产生 `manager.dispatch` 审计/SSE 投影。
非法席位、重复席位、空任务、错误 schema、席位超限、缺少收敛路径或链式顺序错误,统一回退到服务端安全计划,不会扩大权限或启动越权席位。
四种结构化拓扑已固定为:
- `direct_answer`:manager-only Workflow,不创建专家席位任务;
- `single_seat` / `tool_write`:一个可信席位;
- `parallel_consultation`:至少两个可信席位并行;
- `chain_consultation`:按服务端业务链顺序串行。
### 阶段 3 验证状态
已覆盖配置校验、候选路由、非法总控计划回退、manager-only 真实 `WorkflowBuilder` 拓扑、线程/执行阶段回归、旧 DeerFlow 回归和会话 API 回归。当前仍不能据此标记整个 MAF 项目或阶段 5 为“验收完成”;真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY` 仍按阶段 5/8 前置条件保持阻塞。
### Stage 3 P0 dispatch correction
- New MAF conversations no longer classify user text with server keyword rules. The server first freezes only the business-route candidate set and trusted, redacted capability summary; `ManagerDispatchExecutor` then returns the strict intent-and-dispatch JSON contract.
- The final plan is Pydantic-validated and allowlisted before it is checkpointed. Invalid JSON, illegal intent or seat, missing convergence, manager failure, and timeout all finish on the clarification branch; they do not create expert threads or start expert/tool work.
- A conversation initially persists and binds the manager only. After a valid decision, only `selected_seat_agent_ids` are added to the participant store and receive internal threads.
- Every non-clarification route, including `group_chat`, starts at the same manager dispatch node. Group speakers are limited to the verified selected seats; static candidate configuration cannot bypass the plan.
---
## 阶段 4 实施记录(当前代码基线)
本轮完成阶段 4(R3-Q:席位任务合同、总控验收、打回/改派/受控缺口、阶段质量闸门)的服务端实现。范围仅限 V2 structured 派活(`dispatch_contract_enabled` 且 `quality_gate_enabled`);旧 DeerFlow、旧 roundtable、AI 写作、group_chat 与 quality 关闭的 V2 路径行为不变。
### 纯策略层(`app/maf_roundtable/quality_contracts.py`,无 I/O、无 SDK)
- `MafTaskContract`:objective / expected_deliverable / acceptance_criteria / required / max_rework_attempts(默认 2)/ fallback_policy(`reassign_then_gap` / `controlled_gap` / `ask_user`),由校验后的派发 assignment 服务端冻结。
- 确定性检查 `check_delivery_deterministic`:empty_content、schema_invalid、missing_conclusion、below_min_length、missing_evidence、duplicate_content;任一失败即不可 accept(总控模型自评不是验收证据)。
- 总控验收 schema `MafManagerReviewOutput`(decision ∈ accept / return_for_rework / reassign / ask_user / controlled_gap + reason + revision_requirements),`finalize_review_decision` 服务端终审:确定性失败永不 accept;返工受合同上限约束;改派受 `max_reassignments` 约束;耗尽后只能 `controlled_gap`(带风险标记)或 `ask_user` 暂停,绝不伪装成功;总控输出缺失/非法时确定性降级。
### 持久化
- 新表 `maf_roundtable_seat_tasks`(`model.py` + `seat_tasks.py` + store facade 注册):task_key 稳定(`NN:agent_id`),改派只变 `agent_id` 不新建任务;`(run_id, task_key)` 唯一;状态机 CHECK(assigned/delivering/submitted/manager_review/accepted/rework/reassigned/exhausted/controlled_gap)。
- `maf_roundtable_seat_runs.execution_stage` 闭合枚举新增 `rework`、`manager_review`(seat_runs 常量 + ORM CHECK + 迁移脚本同步)。
- 迁移脚本 `scripts/migrate_maf_roundtable_stage4.py`(建表 + SQLite 重建 seat_runs 以放宽 CHECK,PG/MySQL drop+add,幂等,`validate_schema`)。
### 执行器与接线
- `QualitySeatTasksExecutor`(id=`quality_seat_tasks`)替换 V2 structured 的席位 fan-out:有界质量轮(初稿 + 最多 2 次返工 + 每次改派 1 轮),每轮「交付(复用 SeatExecutor,静默 ctx 防消息重入图)→ 确定性检查 → 批处理总控验收 → 服务端终审落库」,`ask_user` 置 `waiting_input`;改派席位须为服务端候选且未占用其他任务(一个席位同时只承担一个任务,避免四元组冲突)。
- `QualityGateExecutor`(id=`quality_gate`):所有 required 任务达到 accepted / controlled_gap 终态才放行(`passed` / `passed_with_gaps`);非终态 required 任务确定性阻塞(硬失败安全网);`waiting_input` 路由到 `WaitingInputTerminalExecutor`(不跑 cross_review/finalizer)。
- 最终报告:受控缺口注入综合 prompt 的「风险/待补信息」章节并逐条要求不得写成已验证事实;`final_result.quality_decision` 有缺口即为 `controlled_gap`,`final_result.controlled_gaps` 可追溯。
- 配置:`quality_gate_enabled`(默认 True,V2 会话可用 `maf_v2.quality_gate_enabled` 显式关闭)、`max_rework_attempts`、`max_reassignments`、`min_delivery_chars`、`min_delivery_evidence`、`default_fallback_policy`。
- 事件:`seat.delivery.submitted`、`manager.review.decision`(带 task_key/attempt/source)、`seat.rework.assigned`、`seat.reassigned`、`stage.quality_gate`;conversation 投影白名单与安全键已扩展。
### 测试
- `tests/test_maf_quality_contracts.py`(34 例纯策略:合同冻结、确定性检查、验收解析、终审规则矩阵)。
- `tests/test_maf_phase4_quality_gate.py`(6 例 Runner 级端到端:好交付接受、短答→2 返工→1 改派→受控缺口(含 DB/事件/最终报告断言)、ask_user→waiting_input、质量闸门阻塞、缺失验收的确定性降级、事件落库)。
- `tests/test_maf_migration_stage4.py`(3 例迁移:建表+CHECK 放宽、幂等、旧数据保留、新阶段可写入)。
- 回归:全量 MAF 测试 459 通过;`ruff check` 全绿。
仍按阶段 5/8 前置条件保持阻塞:真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY`。前端对话页的质量反馈(派活卡显示尝试次数、「总控审核中」、打回/改派/缺口卡片、席位条状态区分)属阶段 7 的前端工作,尚未实施。
+265
View File
@@ -0,0 +1,265 @@
# 多智能体协调 API 接口文档
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
路由前缀:`/api/multi-agent`
## 概览
提供一个"总控 + 多个子智能体"的协调编排接口。
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
调用方按下面顺序使用:
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
## 1. 初始化多智能体集群
### `POST /api/multi-agent/init`
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
#### 请求体
```json
{
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
"main_agent_name": "main-controller",
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
#### 响应(200)
```json
{
"status": "success",
"main_agent_name": "main-controller",
"thread_ids": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
"us-intelligence-gathering-agent": "thread-uuid-zzz"
}
}
```
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_names` 为空 |
| `401` | 未携带 `Authorization` 头 |
| `500` | 协调智能体创建失败且不是"已存在"错误 |
---
## 2. 流式调度
### `POST /api/multi-agent/run/stream`
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
#### 请求体
```json
{
"agent_type": "leader",
"d_agent_thread_id": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
},
"agent_name": "main-controller",
"new_message": "请分析特朗普的支持率",
"skill_stop_names": [],
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
#### 响应
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
##### a. 透传上游事件
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
##### b. 最后一帧:调度状态
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
**`agent_type == "leader"`:**
```
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
```
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
**`agent_type == "special"`:**
```
data: {"status": "子智能体xxx完成yyy工作"}
```
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
#### 副作用(重要)
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
| `401` | 未携带 `Authorization` 头 |
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
---
## 3. 前端调用流程示例
```ts
// 1. 拿 token
const loginResp = await fetch("/api/v1/auth/login/username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "yourname" }),
});
const { access_token } = await loginResp.json();
const authHeaders = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
};
// 2. 初始化
const initResp = await fetch("/api/multi-agent/init", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_names: [
"taiwan-intelligence-gathering-agent",
"us-intelligence-gathering-agent",
],
main_agent_name: "main-controller",
model: "deepseek-chat",
}),
});
const { thread_ids } = await initResp.json();
// 3. 流式调用 leader
async function streamLeader(message: string, model = "deepseek-chat") {
const resp = await fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "leader",
d_agent_thread_id: thread_ids,
agent_name: "main-controller",
new_message: message,
skill_stop_names: [],
model,
}),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
let dispatched: [string, string][] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const payload = JSON.parse(line.slice(5).trim());
if (Array.isArray(payload.status)) {
dispatched = payload.status;
}
// 其余事件按 LangGraph SDK 协议自行渲染
} catch {
/* 非 JSON 帧,可忽略或交给现有渲染器 */
}
}
}
return dispatched;
}
// 4. 对派活清单逐个调用 special
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
return fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "special",
d_agent_thread_id: thread_ids,
agent_name: agentName,
new_message: task,
skill_stop_names: [],
model,
}),
});
// 同样按 SSE 解析
}
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
const dispatched = await streamLeader("请分析特朗普的支持率");
for (const [agentName, task] of dispatched) {
await streamSpecial(agentName, task);
}
const next = await streamLeader("根据上面收集到的信息出最终结论");
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
```
---
## 4. 注意事项
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
---
## 5. 相关接口(底层依赖)
本接口内部转发以下 Gateway 原生接口,了解细节可查:
| 接口 | 路由 | 用途 |
|---|---|---|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
@@ -1,514 +0,0 @@
# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案
> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到
> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中
> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 +
> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在
> 进行中的任务实时观察。
---
## 0. 现状与架构事实(动手前必读)
研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。**
### 0.1 前端现状
| 事实 | 位置 |
|------|------|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` |
| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` |
| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` |
| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` |
| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` |
### 0.2 后端现状
| 事实 | 位置 |
|------|------|
| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` |
| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` |
| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) |
| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` |
| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` |
### 0.3 可复用模板:AI 写作的「后台多阶段流程」
AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**:
| 模板要素 | 位置 |
|------|------|
| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) |
| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` |
| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` |
| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` |
| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` |
| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` |
| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 |
### 0.4 决策(已与产品确认)
1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。
2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。
> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`)
> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里
> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。
---
## 1. 总体架构
```
┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐
│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │
│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │
│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │
│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │
└───────────────────────────────────────────────────────────────────────────────────┘
│ SSE 进度 / 快照
┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐
│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │
│ │ 启动后台 run │
│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │
│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │
│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │
│ │ 每次状态转移持久化进度 │
│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│
│ cleanup cron (仿 ai_writing_cleanup.py) │
└───────────────────────────────────────────────────────────────────────────────────┘
```
**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力
(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的
循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。
---
## 2. 数据契约(前后端共享,先定义后实现)
### 2.1 作业状态机 `JobStatus`
```
queued # 已创建,等待 worker 拉起
running # 编排进行中(Step2 派活循环 / Step3 绘制)
awaiting_input # 命中 clarification,等用户回答(interrupt 挂起)
done # 已跑到 Step3 绘制完成
error # 出错终止
cancelled # 用户取消
```
### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用)
```
initializing # 建 thread / 总控就绪
leader_thinking # 总控在决策派活
dispatching # 正在顺序派活给席位(配合 active_speaker)
consensus # 共识达成(dispatched=[])
report_drawing # Step3 报告绘制中
report_done # 绘制完成
```
### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据)
```ts
interface DispatchChainNode {
agentId: string; // 席位 agent_id(小写)
name: string; // 显示名
avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色
order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)
state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画)
lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip)
}
```
### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件)
```ts
type JobProgressEvent =
| { type: 'status'; status: JobStatus }
| { type: 'phase'; phase: JobPhase; cycle: number }
| { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量
| { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无)
| { type: 'consensus'; percentage: number }
| { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染)
| { type: 'clarification'; question: string } // 命中澄清
| { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照
| { type: 'error'; detail: string };
```
> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values`
> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。
### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示)
```ts
interface RoundtableJob {
id: string;
draftId: string; // 关联的草稿
userId: string;
status: JobStatus;
phase: JobPhase;
cycle: number; // 当前轮次(0..MAX_CYCLES)
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
threadIds: Record<string,string> | null; // 复用 ThreadIdMap
coordinatorName: string | null;
orchestrationMode: 'recommend' | 'chain';
chain?: { id: string; title: string } | null;
error: string | null;
createdAt: string;
updatedAt: string;
}
```
---
## Phase 1 —— 后端:圆桌作业持久化模型
**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路,
并在 `roundtable_drafts` 列表里能 join 出当前作业状态。
**涉及文件(新增/改)**
- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`)
- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`)
- 新增 alembic 迁移 `persistence/migrations/versions/<date>_create_roundtable_jobs.py`
- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下)
**步骤**
1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、
`dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、
`pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、
`chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。
2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。
3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。
**推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN
`roundtable_jobs` 取最新作业的 `status`。
4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。
**验收**
- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。
- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。
---
## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量)
**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然
checkpoint,一路跑到 Step3 绘制完成。
**涉及文件(新增)**
- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`)
- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、
mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等)
- `.../roundtable_orchestrator/nodes/*.py`(各节点)
- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`)
**图节点设计(把前端循环逐段翻译过来)**
| 节点 | 职责 | 对应前端逻辑 |
|------|------|------|
| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` |
| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` |
| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` |
| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) |
| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` |
| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` |
| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` |
| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` |
**关键实现要点**
1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的
单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。
2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播
(`multi_agent.py:487-502`),让席位读到全局上下文。
3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。
4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的
部分(对齐 `runChainOrchestration` `1228-1439`)。
5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId,
consensus, dialogues_delta})`,前端 SSE 才有实时数据。
6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。
**验收**
- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent +
selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。
- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。
- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。
---
## Phase 3 —— 后端:作业控制 + 进度 SSE 接口
**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。
**涉及文件(新增/改)**
- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳)
- 在 gateway `app.py` 注册路由
- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron
**接口契约**
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` |
| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) |
| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 |
| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` |
| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` |
| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) |
| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) |
**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)**
- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。
- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。
- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。
- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。
**验收**
- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。
- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。
- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。
---
## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展
**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。
**涉及文件(新增/改)**
- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`:
```ts
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
export async function getJob(id: string): Promise<RoundtableJob>
export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe
export async function resumeJob(id: string, answer: string): Promise<void>
export async function cancelJob(id: string): Promise<void>
```
- `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把
LangGraph `values` 映射成 §2.4 的归一化事件。
- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string`
(`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。
- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。
**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。
---
## Phase 5 —— 前端:Step2「后台挂起」按钮
**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。
**涉及文件(改)**
- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`)
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式)
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法)
**步骤**
1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。
2. 页面 `handleBackgroundSuspend`:
- `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。
- 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、
`orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。
- `startJob(...)` → 拿到 `jobId`。
- **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记,
让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541`
的启动条件里加 `&& !backgrounded`)。
- toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。
- 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。
3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成
对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。
**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId`
互斥;加载草稿时若发现有 running 作业,直接进观察者模式。
**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。
---
## Phase 6 —— 前端:历史下拉框「进行中」加载动画
**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中
条目不直接 load,而是打开流程图弹窗。
**涉及文件(改)**
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`)
**步骤**
1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记:
- `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。
- `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。
- `done`/无 → 维持现状(显示 `Step {furthestStep}`)。
- `error` → 红点 +「出错」。
2. 点击行为分流:
- `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。
- 其它 → 维持 `onLoad(id)`。
3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。
**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。
---
## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画)
**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的
节点和它的连接线带加载动画;含「查看」按钮。
**涉及文件(新增)**
- `src/roundtable-planning/components/DispatchChainModal.tsx`
**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画,
**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated`
边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。
**结构(手写方案草图)**
```tsx
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl"> {/* 大弹窗 */}
<DialogHeader>… 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) …</DialogHeader>
{/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */}
<div className="custom-scrollbar overflow-x-auto py-8">
<div className="flex items-center gap-0 min-w-max px-6">
<CoordinatorNode active={phase==='leader_thinking'} />
{nodes.map((n,i)=>(
<Fragment key={n.agentId}>
<Connector active={n.state==='active'} /> {/* 活动连线:流动虚线动画 */}
<ChainNode node={n} /> {/* 活动节点:彩色+脉冲光环 */}
</Fragment>
))}
<Connector active={phase==='consensus'} />
<MilestoneNode label="共识" done={consensus===100} />
<Connector active={phase==='report_drawing'} />
<MilestoneNode label="报告绘制" active={phase==='report_drawing'} done={phase==='report_done'} />
</div>
</div>
<DialogFooter className="border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">轮次 {cycle}/8 · {statusLabel}</span>
<Button onClick={onView}>查看</Button> {/* Phase 8 */}
</DialogFooter>
</DialogContent>
</Dialog>
```
**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)`
(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。
**节点状态样式**
- `pending`:灰色描边、低透明度。
- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。
- `done`:席位色实心 + 右上角 ✓。
**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光:
```css
/* roundtable-planning.css 追加 */
@keyframes chain-flow { to { background-position: 20px 0; } }
.chain-connector--active {
background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%);
background-size: 20px 100%;
animation: chain-flow 0.6s linear infinite;
}
```
**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新
`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。
弹窗关闭时 `unsubscribe`。
**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。
---
## Phase 8 —— 前端:「查看」进入进行中任务并实时观察
**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新),
跑完自动呈现 Step3 结果。
**涉及文件(改)**
- `RoundtablePlanningPage.tsx`(接 `onView`)
- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支)
- `useStep2Orchestration.ts`(观察者渲染)
**步骤**
1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`),
但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。
2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件
驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。
3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 →
`resumeJob(jobId, answer)`(而非本地 `submitClarification`)。
4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告
(`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。
**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。
---
## Phase 9 —— 边界、健壮性与收尾
1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2
前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。
2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」;
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
需 sticky 或全靠 DB 态(推荐后者)。
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。
---
## 端到端验证清单(提测前逐条过)
- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。
- [ ] 历史下拉里该任务显示加载动画(转圈)。
- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。
- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。
- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。
- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。
- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。
- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。
- [ ] 取消后台 → `cancelled`,无残留双跑。
- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。
- [ ] cleanup 干跑不误删活跃作业。
---
## 工作量与排期建议
| 阶段 | 内容 | 粗估 | 依赖 |
|------|------|------|------|
| P0 | 数据契约定型(本文 §2) | 0.5d | — |
| P1 | 后端作业表 + repo + 迁移 | 1d | P0 |
| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 |
| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 |
| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 |
| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 |
| P6 | 历史下拉 loading 动画 | 0.5d | P4 |
| P7 | 派活链路流程图弹窗 | 1.5d | P4 |
| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 |
| P9 | 边界/健壮性/清理 | 1d | 全部 |
> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。
> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。
---
## 附:关键参考代码索引
- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541`
- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595`
- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652`
- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368`
- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892`
- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293`
- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释
- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`)
- 流程图备选库:`@xyflow/react@12`(已安装)
```
@@ -1,404 +0,0 @@
# 多智能体会商 Step2 多结果线方案
## 背景
当前「多智能体会商 / 多智能体规划」里,一个历史草稿只保存一份 `step2` 快照。用户从历史记录加载草稿后,如果再次点击「智能体推荐」并在弹窗里点击「进入研讨」,新的 Step2 研讨会继续写入同一份 `draft.step2`,结果是第一次生成内容会被第二次生成内容覆盖,或者页面只能展示最新一次结果。
产品期望是:
- 用户从历史记录进入已有草稿时,进入第二步后,左下角能看到第一次生成结果。
- 用户再次点击「智能体推荐」并点击「进入研讨」时,要开启一条新的研讨结果线。
- 第二次生成结果显示在左下角,但不能影响第一次生成结果。
- 用户之后再次查看同一个历史草稿时,可以分别查看第一次生成结果和第二次生成结果。
因此,这个需求的核心不是简单增加一个展示区域,而是把 Step2 从「单结果」升级为「一个草稿下多条研讨 run」。
## 现状
相关位置:
- `src/roundtable-planning/api/drafts.ts`
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`
- `src/roundtable-planning/components/Step2Panel.tsx`
- `src/roundtable-planning/components/RecommendAgentsDialog.tsx`
当前 `DraftStep2Snapshot` 结构大致是:
```ts
export interface DraftStep2Snapshot {
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这意味着一个草稿只有一份:
- 参会智能体
- 多智能体 thread 映射
- 研讨气泡
- 总控最终内容
- 共识状态
- 沙箱产物 stub
- 编排模式和业务链条
当前 `RoundtablePlanningPage.tsx` 里的 `handleConfirmAgents` 在用户点击「进入研讨」后会:
1. 规范化选中的 agents。
2. 写入 `step2.selectedAgents`。
3. 调用 `step2.resetInitGate()`。
4. 设置 `orchestrationMode` / `chainMeta`。
5. 切到 `currentStep = 2`。
它没有先把当前 Step2 结果归档成「第一次生成结果」,也没有为新的研讨创建独立 run。因此第二次研讨天然会复用同一个 `draft.step2` 写入位置。
## 目标模型
建议引入 Step2 run 概念:一个草稿可以有多条 Step2 结果线,每条结果线保存一份完整的 Step2 快照。
```ts
interface Step2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
step3?: DraftStep3Snapshot | null;
}
```
`DraftStep2Snapshot` 保留现有字段作为当前激活 run 的镜像,同时新增 `runs` 和 `activeRunId`:
```ts
export interface DraftStep2Snapshot {
activeRunId?: string;
runs?: Step2RunSnapshot[];
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这样做的好处:
- 老代码仍然可以从 `draft.step2.step2RoundtableDialogues` 读取当前结果。
- 新 UI 可以从 `draft.step2.runs` 展示第一次、第二次、第三次生成结果。
- 老草稿没有 `runs` 时,可以在 hydrate 时把原来的 `step2` 包装成一条默认 run。
- 暂时不需要改数据库表结构,因为 `step2` 本身就是 JSON 快照。
## 行为设计
### 1. 历史记录加载
用户点击历史记录加载草稿时:
1. 拉取完整草稿。
2. 如果 `draft.step2.runs` 存在,使用 `activeRunId` 找到当前结果线。
3. 如果 `draft.step2.runs` 不存在,但旧字段里有 `step2RoundtableDialogues`,自动包装成一条 run:
```ts
{
id: "legacy-run",
title: "第一次生成结果",
createdAt: draft.updatedAt,
source: "initial",
...draft.step2
}
```
4. hydrate 当前 active run 到 Step2 runtime。
5. 左下角展示 run 列表。
### 2. 再次点击智能体推荐进入研讨
用户在已有历史草稿中再次点击「智能体推荐」并确认进入研讨时:
1. 先把当前页面里的 Step2 状态保存为当前 active run。
2. 创建一条新的 run:
```ts
{
id: crypto.randomUUID(),
title: `第 ${runs.length + 1} 次生成结果`,
createdAt: new Date().toISOString(),
source: result.mode,
selectedAgents: normalizedAgents,
threadIds: null,
coordinatorName: null,
step2RoundtableDialogues: [],
lastLeaderContent: "",
hasConsensus: false,
consensusPercentage: 0,
budgetLimit: DEFAULT_BUDGET,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null
}
```
3. 将 `activeRunId` 切到新 run。
4. 清空 Step2 runtime。
5. 设置新选中的 agents / mode / chain。
6. `resetInitGate()`,进入 Step2 后重新创建一组独立 thread。
7. 自动保存草稿。
关键点:第二次 run 必须使用新的 `threadIds`,不能沿用第一次 run 的 `threadIds`,否则后端 thread 历史会混在一起。
### 3. 左下角结果列表
左下角建议展示一个「生成结果」区域:
- 第一次生成结果
- 第二次生成结果
- 第三次生成结果
每条展示:
- 标题
- 创建时间
- 状态:研讨中 / 已达成共识 / 未完成 / 已生成报告
- 使用模式:智能体推荐 / 业务链条
点击某条结果时:
1. 保存当前 active run。
2. 切换 `activeRunId`。
3. hydrate 被点击的 run。
4. 中间 Step2 对话区显示该 run 的研讨气泡。
5. 如果该 run 已有 `step3`,进入 Step3 时展示对应报告。
### 4. Step3 结果归属
目前 `draft.step3` 是草稿级别的一份报告。如果只保留这一份,第二次结果生成报告后仍会覆盖第一次报告。
建议:
- `draft.step3` 继续作为当前 active run 的镜像,兼容现有 Step3 页面。
- 每条 `Step2RunSnapshot.step3` 保存自己的报告。
- `onReportSaved` 时同时写入当前 active run 的 `step3`。
这样用户切换到第一次生成结果时,可以看到第一次的 Step3 报告;切换到第二次生成结果时,可以看到第二次的 Step3 报告。
## 建议改动点
### 1. `api/drafts.ts`
扩展类型:
- 新增 `Step2RunSnapshot`。
- `DraftStep2Snapshot` 增加 `activeRunId?: string` 和 `runs?: Step2RunSnapshot[]`。
同时保留旧字段,避免影响现有保存、加载和 Step3。
### 2. `useStep2Orchestration.ts`
新增或暴露两个能力:
- `getCurrentRunSnapshot()`:从当前 Step2 runtime 生成一条 run 快照。
- `hydrateRunSnapshot(run)`:把某条 run 恢复到 Step2 runtime。
`hydrateFromDraft` 继续存在,但内部可以优先选择 active run 来 hydrate。
### 3. `RoundtablePlanningPage.tsx`
新增页面级状态:
```ts
const [step2Runs, setStep2Runs] = useState<Step2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
```
调整几个流程:
- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。
- `hydrateStep2` 兼容旧草稿并恢复 runs。
- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。
- `onReportSaved` 把 Step3 报告写回当前 active run。
### 4. `Step2Panel.tsx`
新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大:
```tsx
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={handleSelectStep2Run}
/>
```
组件职责只做展示和选择,不直接读写 draft。
### 5. `Step3Panel.tsx`
传入当前 active run 的报告:
- 如果当前 run 有 `step3`,展示它。
- 如果没有,按现有逻辑生成新报告。
- 生成完成后通过 `onReportSaved` 写回 run。
## 兼容策略
老草稿没有 `runs` 字段时,不做数据迁移,前端加载时即时包装。
判断规则:
```ts
function normalizeStep2Runs(step2: DraftStep2Snapshot): {
runs: Step2RunSnapshot[];
activeRunId: string | null;
} {
if (step2.runs?.length) {
return {
runs: step2.runs,
activeRunId: step2.activeRunId ?? step2.runs[0].id,
};
}
const hasLegacyContent =
step2.step2RoundtableDialogues?.length > 0 ||
Boolean(step2.threadIds) ||
Boolean(step2.lastLeaderContent);
if (!hasLegacyContent) {
return { runs: [], activeRunId: null };
}
const legacyRun: Step2RunSnapshot = {
id: "legacy-run",
title: "第一次生成结果",
createdAt: new Date().toISOString(),
source: step2.orchestrationMode ?? "initial",
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode: step2.orchestrationMode,
chain: step2.chain,
};
return { runs: [legacyRun], activeRunId: legacyRun.id };
}
```
## 风险点
### 1. 自动保存覆盖
Step2 现在会在每个气泡收尾时自动保存。如果引入多 run,自动保存必须先把当前 runtime 合并回 active run,再写入 `draft.step2.runs`。
否则会出现 UI 切到第二次结果,但保存时仍更新第一次 run 的情况。
### 2. threadIds 混用
每条 run 必须拥有自己的 `threadIds`。新建 run 时要清空 `threadIds` 并让 Step2 重新 init。
如果复用旧 thread,会导致两次研讨的后端上下文互相污染。
### 3. Step3 报告覆盖
如果只保存草稿级 `step3`,多 run 的报告无法分别查看。
建议把报告写入当前 run,同时保留草稿级 `step3` 作为 active run 镜像。
### 4. 正在运行中的 run
如果配合后台任务能力,run 还需要带 job 信息:
```ts
jobId?: string | null;
jobStatus?: JobStatus | null;
```
这样左下角可以显示「第二次生成结果正在运行」,并打开派活链路弹窗。
## 分阶段实现建议
### P0:纯前端多 run 快照
目标:先解决“第一次 / 第二次结果分别可查看”。
- 扩展 `DraftStep2Snapshot` 类型。
- 页面维护 `step2Runs` / `activeStep2RunId`。
- `handleConfirmAgents` 新建 run。
- 左下角展示 run 列表。
- 切换 run 能恢复不同 Step2 对话。
不改后端表结构。
### P1:Step3 绑定 run
目标:每次研讨结果对应自己的报告。
- `Step2RunSnapshot.step3` 保存报告。
- `onReportSaved` 写回 active run。
- 切换 run 时同步 Step3 显示。
### P2:后台任务绑定 run
目标:和后台挂起执行方案打通。
- run 增加 `jobId` / `jobStatus`。
- 历史列表和左下角都能显示某条 run 正在执行。
- 点击正在执行的 run 打开派活链路弹窗。
### P3:后端 run 表
如果后续一个草稿下 run 数量很多,或者需要服务端按 run 查询、删除、重命名,再考虑新增后端表:
- `roundtable_runs`
- `draft_id`
- `run_id`
- `title`
- `status`
- `step2_snapshot`
- `step3_snapshot`
- `created_at`
- `updated_at`
短期不建议一开始就上表,先用现有 `step2` JSON 快照落地更快。
## 验收清单
- 从历史记录加载旧草稿时,左下角出现「第一次生成结果」。
- 点击「第一次生成结果」能看到原来的 Step2 研讨气泡。
- 在历史草稿中重新点击「智能体推荐」并「进入研讨」后,左下角新增「第二次生成结果」。
- 第二次研讨开始时,Step2 对话区为空或只显示新 run 初始化内容。
- 第二次 run 使用新的 `threadIds`,不会复用第一次 run 的后端线程。
- 切回「第一次生成结果」时,第一次的对话、共识状态、沙箱产物仍可查看。
- 切回「第二次生成结果」时,第二次的对话继续显示。
- 刷新页面后再次加载该历史草稿,两条结果线都还在。
- 如果两次都生成了 Step3 报告,切换不同结果线时能看到各自对应的报告。
@@ -1,198 +0,0 @@
# 后台挂起「在当前进度继续」实现方案
> 状态:**待实现**(仅方案,未改代码)
> 关联:[[multi-agent-background-run-dev.md]]、[[multi-agent-frontend-dev.md]]、memory `project-roundtable-background`
> 目标:点「后台挂起」后,后端作业**在前端当前研讨进度上接着跑**,而不是新建空线程从头重跑。
---
## 1. 现象与根因
### 现象
在第二步研讨进行中点「后台挂起」,转入后台的作业是**从头重新开始执行**(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。
### 根因
后端「起作业」路由 `start_job` **从不设 `is_resume`**(默认 `False`),而执行器据此**新建一套全新线程**:
```python
# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行)
init = getattr(gateway, "init_threads", None)
if init is not None and not params.is_resume:
thread_ids, coordinator_name = await init() # ← 建全新空线程
```
加上前端 `startBackgroundJob` **故意不传 `threadIds`**、用 `buildInitialIntent()` 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。
> 这是**原设计的有意取舍**。`startBackgroundJob` 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。
### 后端其实已支持续跑
澄清续跑路由 `resume_job`(`roundtable_jobs.py` 约 293 行)已经在做「在原线程上接着派活」:
```python
executor.start_job(StartParams(
...,
seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent
thread_ids=row.get("thread_ids"), # 复用作业自己的线程
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
is_resume=True, # ← 关键:跳过 init_threads,直接复用
...
))
```
执行器在 `is_resume=True` 时直接用 `params.thread_ids` + `params.coordinator_name`(不调 `init_threads`),交给 `run_orchestration` 续跑。**所以能力已存在,`start_job` 只是没走这条路。**
---
## 2. 设计目标与关键事实
- 「同进度续跑」= 后台作业复用**前端当前会话的真实线程**(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。
- 前端编排是**逐轮**的:每轮一个 `POST /api/multi-agent/run/stream`(leader 或某席位),**轮与轮之间线程空闲**。只有「某一轮在飞时」线程才被占用。
- 后端复用现成线程是**已验证路径**(`resume_job` 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。
---
## 3. 改造点(最小集)
### 3.1 后端:让 `start_job` 支持复用线程
文件:`offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py`
1. `StartJobRequest` 增加字段:
```python
resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建
```
2. `start_job` 里把它透传给 `StartParams`:
```python
is_resume = bool(body.resume and body.threadIds)
executor.start_job(StartParams(
...,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
is_resume=is_resume, # ← 新增
...
))
```
其余执行器逻辑**不动**(它在 `is_resume=True` 时已能复用 `params.thread_ids`)。
> 幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。
### 3.2 前端 API:补 `resume` 入参
文件:`frontend-web/src/roundtable-planning/lib/job-types.ts`
- `StartJobPayload` 增加 `resume?: boolean`。
文件:`frontend-web/src/roundtable-planning/api/roundtable-jobs.ts`
- `toStartBody` 增加 `resume: p.resume ?? false`。
### 3.3 前端:`startBackgroundJob` 改为复用线程续跑
文件:`frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(`startBackgroundJob` 约 1119 行)
当前端已有现成会话(`step2.threadIds` 且 `step2.coordinatorName` 非空,说明研讨已 init)时,改传:
```ts
const canContinue = !!(step2.threadIds && step2.coordinatorName);
await startJob({
draftId: draftId ?? undefined,
// 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务)
intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt
seedMessage: canContinue
? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。"
: undefined,
agents,
model: selectedModel || undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 关键三件套:复用前端真实线程 + 协调名 + resume 标志
threadIds: canContinue ? step2.threadIds ?? undefined : undefined,
coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined,
resume: canContinue,
});
```
> 没有现成会话(极早期、线程未建)时 `canContinue=false`,自动回退到「新建线程 + 原 intent」的旧行为,安全。
### 3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞)
文件:同上,`handleBackgroundSuspend`(约 1161 行)
当前顺序是 `startBackgroundJob()` → `resetToStep1()`(reset 里才 `stopOrchestration`)。复用线程时必须**先停再交**:
```ts
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ }
// 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
try {
// 2. 再起后台作业(复用线程续跑)
await startBackgroundJob();
} catch (err) { /* toast 失败后 return */ }
// 3. 最后重置回第一步(后台作业继续在历史记录里跑)
resetToStep1();
triggerToast("🚀 已转入后台,将基于当前进度继续研讨…");
void drafts.refresh();
}, [...]);
```
---
## 4. 关键风险:线程占用(409)与三种处理
复用前端线程的唯一风险:**在某一轮在飞时挂起**,`stopOrchestration()` 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。
| 方案 | 做法 | 取舍 |
|---|---|---|
| **A(推荐起步)** | 仅前端 stop + 后端 `is_resume` 复用,接受小概率 409 | 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环**现有 rollback/重试容错**兜底 |
| **B(最稳)** | 同 A,但后端在复用线程续跑前,**先 cancel 这些线程上可能残留的 run**(参考 takeover 里 `cancelJob` 的 `task.cancel()` 思路,落到 thread-run 级别)再 post | 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 |
| **C(不推荐)** | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但**席位子线程上下文全丢**、不是真正同进度、leader prompt 臃肿 |
> 建议:先上 **A**,真机验证 409 命中率;若频繁再加 **B** 的后端线程级 cancel。
---
## 5. 适用范围(哪些挂起入口改续跑)
三个触发源(见 `autoSuspendRef`):
1. **手动「后台挂起」按钮**(`handleBackgroundSuspend`)——**改续跑**。能在 start 前干净 stop,最适合复用线程。
2. **路由卸载自动挂起**(切到其它页面,`fire`)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。
3. **刷新/关标签页 keepalive**(`beforeunload` 的 `beacon` → `startJobKeepalive`)——**建议保持新建线程**。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。
> 落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。
---
## 6. 与已实现「查看=接管回前端」的关系
二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实:
- **后台挂起(本方案)**:前端 → 后端。前端 stop,把前端线程交给后台 `is_resume` 续跑。
- **查看接管(已实现,见 memory)**:后端 → 前端。`cancelJob` 停后台,`hydrateFromDraft` 恢复作业真实线程,`resumeLiveTakeover()` 由前端在那些线程上续跑。
实现本方案时可直接参照 takeover 的线程复用与 `is_resume` 经验。
---
## 7. 测试
后端(`make test`):
- 新增/扩展 `tests/test_roundtable_jobs.py`:`start_job` 带 `resume=true + threadIds` ⇒ `StartParams.is_resume=True` 且**不调** `init_threads`、直接用传入线程;`resume=false` 或无 `threadIds` ⇒ 走 `init_threads` 新建(回归旧行为)。
- 回归:`tests/test_roundtable_inprocess_request.py`、`tests/test_roundtable_step3_pipeline.py`(确认续跑不破坏 Step3 双产物)。
前端(`pnpm typecheck`)+ 真机:
- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是**接着已有对话**往下派活,而非从第 1 轮重来。
- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。
---
## 8. 落地清单(TL;DR)
- [ ] 后端 `StartJobRequest.resume` 字段 + `start_job` 透传 `is_resume`
- [ ] 前端 `StartJobPayload.resume` + `toStartBody`
- [ ] 前端 `startBackgroundJob`:有会话时传 `threadIds/coordinatorName/seedMessage/resume`
- [ ] 前端 `handleBackgroundSuspend`:调整为 **先 stop 再 start 再 reset**
- [ ] 后端测试(resume 复用 vs 新建分支)
- [ ] 真机验证「接着进度跑」+ 409 命中率
- [ ](可选)方案 B:后端复用线程前按 thread_id 取消残留 run
@@ -1,362 +0,0 @@
# 岗位会商交付闭环与内置智能体实现计划
> 页面:`/page/strategy/qa/position-roundtable`
>
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
## 实施核对(2026-07-21)
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
```text
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
```
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
## 数据持久化与灾难恢复(2026-07-23)
岗位会商现在使用“两层持久化”,两层职责不同:
1. LangGraph checkpoint/thread 是智能体继续推理与流式问答的执行源。
2. `position_roundtable_sessions` / `position_roundtable_nodes` 是页面历史的数据库恢复源。
数据库恢复源会保存:
- 情报分析岗完整消息、步骤时间线和未发送输入草稿;
- 每个业务链岗位的完整消息和未发送输入草稿;
- 方案总结、行动规划两个收口智能体的完整消息和未发送输入草稿;
- 已确认意图、冻结任务与业务链快照、节点状态、版本、驳回记录;
- Markdown / JSON 等文本产物正文(单文件最多 400 万字符)和文件元数据。
因此即使线上重启后 checkpoint 或沙箱 outputs 卷丢失,历史页仍能从数据库展示对话与产物正文,后续收口智能体也能继续读取数据库中的有效上游交付。新任务在意图识别开始前就会创建 Session,用户不必等到“启动业务链”才获得持久化保护。历史下拉支持删除,删除会显式清理 Session 与全部节点恢复快照,不依赖 SQLite 外键开关。
对应迁移:`20260723_05_position_roundtable_conversations.py`。部署时必须执行到 Alembic head。
## 0. 核心结论
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
- 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。
- 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。
- 行动规划智能体建议作为新的内置智能体新增。
- 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。
- 输出:行动规划报告、结构化子任务清单。
- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。
- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。
## 1. 目标业务流程
1. 情报分析岗进入页面,查看任务信息并完成意图识别。
2. 用户点击“确认意图并启动业务链”。
3. 启动前做校验:
- 未选择业务链:提示“请先选择业务链”。
- 业务链存在未配置岗位的席位:提示并引导去业务链配置页。
- 业务链席位缺少智能体:提示具体席位名称。
- 意图尚未完成:提示先完成任务意图识别。
4. 校验通过后冻结业务链快照,初始化岗位会商节点。
5. 各岗位只对分配给本岗位的智能体进行问答。
6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。
7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。
8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。
9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。
10. 方案总结完成后开放“行动规划”。
11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。
12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。
## 2. 状态设计
### 2.1 节点状态
现有节点状态需要扩展或映射为更贴近页面的展示状态:
| 展示状态 | 建议内部状态 | 含义 |
| --- | --- | --- |
| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 |
| 进行中 | `running` | 当前智能体正在回答或产物正在生成 |
| 已完成 | `done` | 当前节点已有有效交付 |
| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 |
| 异常 | `error` | 生成或保存失败 |
建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。
### 2.2 产物状态
产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。
| 状态 | 含义 |
| --- | --- |
| `pending` | 还没有交付 |
| `running` | 正在生成 |
| `delivered` | 已交付且当前有效 |
| `rejected` | 被用户驳回 |
| `stale` | 受上游驳回或重做影响,已经失效 |
### 2.3 内置收口节点
在流程图中把两个内置节点追加到业务链末尾:
1. `builtin:summary`:方案总结智能体。
2. `builtin:action-plan`:行动规划智能体。
这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。
## 3. 数据模型调整
### 3.1 Session 扩展
`position_roundtable_sessions` 已使用下列持久化字段:
| 字段 | 含义 |
| --- | --- |
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
| `summary_thread_id` | 方案总结智能体线程 |
| `summary_snapshot` | 最新有效总结报告快照 |
| `action_plan_thread_id` | 行动规划智能体线程 |
| `action_plan_snapshot` | 最新有效行动规划快照 |
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
### 3.2 Node 扩展
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
| 字段 | 含义 |
| --- | --- |
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
| `rejection_count` | 当前节点累计驳回次数 |
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
| `revision` | 当前有效交付版本 |
| `invalidated_by` | 导致当前节点失效的上游节点 key |
### 3.3 Deliverable 新表(后续可选)
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
| 字段 | 含义 |
| --- | --- |
| `id` | 产物 ID |
| `session_id` | 会话 ID |
| `node_key` | 来源节点 |
| `agent_id` | 来源智能体 |
| `position_id` | 来源岗位 |
| `title` | 产物名称 |
| `kind` | `answer / file / summary / action_plan` |
| `content` | 文本产物内容摘要或正文 |
| `artifact_path` | 文件产物虚拟路径 |
| `thread_id` | 所属线程 |
| `status` | `pending / running / delivered / rejected / stale` |
| `revision` | 来源节点版本 |
| `rejection_title` | 驳回标题 |
| `rejection_reason` | 驳回理由 |
| `created_at` | 创建时间 |
| `updated_at` | 更新时间 |
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
## 4. 驳回与级联失效规则
### 4.1 基本驳回
用户点击产物卡片的“驳回”后弹窗填写:
- 驳回标题。
- 驳回理由。
提交后:
- 当前产物状态改为 `rejected`。
- 当前节点状态改为 `rejected` 或 `ready`。
- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。
- 记录 `last_rejection`,用于页面展示和后续追踪。
### 4.2 级联失效
如果驳回的节点位于第 N 阶段:
- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。
- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。
- 后续节点状态回到未完成展示态。
- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。
- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。
### 4.3 重新交付
被驳回节点重新问答并完成后:
- 当前节点产生新的 `revision`。
- 当前节点产物状态变为 `delivered`。
- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。
- 方案总结和行动规划必须基于最新有效产物重新生成。
## 5. 接口规划
### 5.1 启动与校验
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 |
| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 |
第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。
### 5.2 产物
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
### 5.3 方案总结与行动规划
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
## 6. 前端实施步骤
### 阶段 1:状态与启动校验(已完成)
- 扩展 `PositionNodeStatus` 和状态显示映射。
- “确认意图并启动业务链”前补齐校验提示。
- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。
- 左侧流程图节点右上角添加状态小图标。
- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
### 阶段 2:流程图弹窗(已完成)
- 左侧保留小型从上到下流程图。
- 流程图右上角增加“查看”按钮。
- 点击后打开弹窗,展示更大的流程图。
- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
### 阶段 3:产物卡片与预览(已完成)
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
- 点击卡片打开详情弹窗。
- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。
- 每个有效产物提供“驳回”入口。
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
### 阶段 4:驳回弹窗与级联失效(已完成)
- 新增 `RejectDeliverableDialog`。
- 驳回表单包含标题和理由,均必填。
- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。
- 前端刷新 Session、Node、Deliverable 和流程图状态。
- 被驳回节点在对话区显示驳回信息,提示用户重新交付。
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
### 阶段 5:方案总结智能体(已完成)
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
- 输入材料改为岗位会商材料:
- 任务信息。
- 情报分析岗意图结果。
- 业务链快照。
- 所有状态为 `delivered` 的岗位产物。
- 已驳回或失效产物只作为历史参考,不进入默认总结材料。
- 方案总结节点追加到流程图末尾。
- 所有产物有效交付后才允许点击“生成方案总结”。
- 总结结果写入 `summary_snapshot`,同时进入产物列表。
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
### 阶段 6:行动规划智能体(已完成)
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
- 新增行动规划 prompt:
- 读取方案总结报告。
- 读取各岗位最终有效产物。
- 拆解为若干子任务。
- 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。
- 输出两类产物:
- `行动规划报告.md`。
- `action-plan-subtasks.json`。
- 行动规划节点追加到方案总结之后。
- 方案总结有效完成后才允许启动行动规划。
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
### 阶段 7:返回修改与重新生成(已完成)
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
- 驳回后自动显示需要重做的节点。
- 重做节点完成后,允许重新生成方案总结和行动规划。
- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
### 阶段 8:测试与回归(已完成)
- 后端测试:
- activate 校验。
- 节点完成后产物入库。
- 驳回当前节点。
- 驳回第一阶段时后续阶段级联失效。
- 总结和行动规划失效。
- 前端测试:
- 启动校验提示。
- 状态图标显示。
- 产物卡片筛选和预览。
- 驳回弹窗必填校验。
- 回归验证:
- 原多智能体会商页面不受影响。
- 业务链普通配置入口不显示岗位配置。
- 岗位会商页面刷新恢复正常。
- 深色模式和嵌入模式布局不溢出。
## 7. 推荐提交拆分
1. `feat(position-roundtable): add delivery status model`
2. `feat(position-roundtable): validate chain activation`
3. `feat(position-roundtable): add workflow status graph`
4. `feat(position-roundtable): add deliverable cards and preview`
5. `feat(position-roundtable): support rejection cascade`
6. `feat(position-roundtable): add summary builtin node`
7. `feat(position-roundtable): add action planning builtin node`
8. `test(position-roundtable): cover delivery workflow`
## 8. 第一轮建议范围
第一轮优先做最小可闭环:
```text
意图识别
→ 启动校验
→ 业务链节点状态
→ 节点产物列表
→ 产物详情查看
→ 驳回与级联失效
→ 全部交付后生成方案总结
→ 基于总结生成行动规划
```
如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。
File diff suppressed because it is too large. Load diff
@@ -1,610 +0,0 @@
# 圆桌 DAG 编排 + 并行执行显示 —— 分步实现需求文档
> 目标读者:实现本功能的前端 / 后端工程师。
> 本文是**可逐步落地**的需求 + 设计文档,每个 Phase 都能独立交付、独立验收。
---
## 0. 背景与目标
### 0.1 现状
Step 2 圆桌目前有两种编排模式(`orchestrationMode`,见 `hooks/useStep2Orchestration.ts`):
- **`free`(自由模式)**:总控(leader)自由决定派活给谁,`runOrchestration` 循环驱动。
- **`chain`(线性链条)**:用户把席位排成一条**线性**链 `A → B → C`,`runChainOrchestration` 按顺序逐个派活,最后总控收口。链路用 `DispatchChainModal.tsx` 的**横向流程图**展示(总控 → 席位1 → 席位2 → … → 共识 → 报告)。
链条数据现在只是一个**有序数组** `selectedAgents`(线性),节点状态类型见 `lib/job-types.ts`(`ChainNodeState = pending | active | done`)。
### 0.2 目标(本次新增)
1. **DAG 编排**:用户可视化指定**智能体之间的直接依赖关系**——有的串行(`A → B → C`),有的并行(`A B C` 同时跑)。编排结果是一张**流程图(DAG)**。
2. **并行执行**:DAG 里同一层(互不依赖)的智能体**并行**跑,墙钟时间从「串行求和」降到「并行取最慢」。
3. **并行禁止写文件**:并行的智能体**禁用文件写入 / 沙箱命令**,避免它们同时打开沙箱互相打架。产物以消息正文交付;落文件留给串行 / 收口阶段。
4. **并行高性能显示**:并行时收起左侧、顶部头像条切换、主区同时显示 2 个智能体的消息流,其余**只接流不渲染**,避免页面卡死。
### 0.3 设计总原则
- **复用** 现有 special run / leader run / rollback 抢占 / 轻量交付状态接口(`GET /threads/{id}/last-ai-message`),不另起炉灶。
- **线性链是 DAG 的特例**:`chain` 模式可平滑迁移成"每层 1 个节点"的 DAG,老数据不破坏。
- **分层(Stage)优先于自由连线**:编辑交互首选「分层」模型(每个 stage 放若干并行席位,stage 间串行),实现成本低、覆盖 95% 的"串 / 并"诉求;自由连线画布作为后续可选增强(见 §2.3)。
### 0.4 已确认的设计决策(✅ 本期按这些实现)
> 以下三项已与需求方确认锁定,实现时直接按此,不再二选一。
1. **✅ 编排模型 = 分层 Stage**:本期只做分层(每个 stage 放若干并行席位、stage 间串行)。**自由连线画布本期不做**,仅在数据模型层预留(§1.2)以免将来返工。
2. **✅ 并行跳过 leader、前端直派**:DAG 由用户显式指定,执行时前端**直接**对 stage 内席位发起 special run,**不**每个 stage 再跑一轮总控派活;只在最后 `finalSynthesis` 跑一轮 leader 综合(即 §3.2 的方案 B)。
3. **✅ 主区固定 2 列,窄屏降 1 列**:并行时主区最多并排显示 2 个被选中智能体的消息流;窄屏(断点见 §5.3)降为 1 列 + 头像条横向滚动。不做 3+ 列。
---
## 1. 数据模型
### 1.1 DAG 的「分层」表示(推荐,主模型)
把 DAG 表示成**有序的 stage 列表**,每个 stage 内的席位并行、stage 之间串行:
```ts
// lib/dag-types.ts (新建)
/** 一个并行批次:stage 内所有席位并行执行。 */
export interface OrchestrationStage {
/** stage 稳定 id(拖拽重排 / diff 用)。 */
id: string;
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
agentIds: string[];
}
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
export interface OrchestrationPlan {
/** 编排模式标识,持久化进 step2。 */
mode: "dag";
/** 串行执行的批次;每批内并行。 */
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
}
```
- **串行** `A → B → C` = `stages: [{agentIds:[A]}, {agentIds:[B]}, {agentIds:[C]}]`。
- **并行** `A B C` = `stages: [{agentIds:[A,B,C]}]`。
- **混合** `A →(B C)→ D` = `stages: [{[A]}, {[B,C]}, {[D]}]`。
> 为什么用分层而不是「邻接表 / 边」:执行调度本就是「拓扑分批」,分层就是分批的结果,前端不必再做拓扑排序,也天然避免环;编辑器交互更直观(拖卡片进 stage)。真正的自由连线 DAG 见 §2.3(可选)。
### 1.2 通用 DAG 表示(§2.3 自由连线时才需要)
若将来做自由连线画布,再引入边表示,并在执行前做一次**拓扑排序 → 分层**,落回 §1.1 的 stages 结构给调度器用:
```ts
export interface DagEdge { from: string; to: string } // from 完成后才能跑 to
export interface DagGraph { nodes: string[]; edges: DagEdge[] }
// 执行前:toposortToStages(graph) -> OrchestrationStage[]
```
### 1.3 持久化
- 复用 `roundtable_drafts.step2` JSON(见后端 `app.gateway.routers` roundtable-drafts + `hooks/useStep2Orchestration.ts` 里对 `step2` 的读写)。
- 在 `step2` 增加字段:`orchestrationPlan: OrchestrationPlan | null`。
- `orchestrationMode` 扩展为 `"free" | "chain" | "dag"`;`chain` 旧数据读取时即时转换为单节点-stage 的 `dag`(兼容层,不改老 draft)。
### 1.4 校验(纯函数,可单测)
`lib/dag-types.ts` 内提供:
```ts
validatePlan(plan: OrchestrationPlan, selectedAgentIds: string[]): {
ok: boolean;
errors: string[]; // 面向用户的中文提示
}
```
校验项:
- 每个 `agentId` 必须在本次选定席位 `selectedAgents` 内(防止编排了不存在的席位 → 复用后端 `_partition_dispatch` 同思路的"必须在 roster 内")。
- 不允许空 stage;不允许同一席位出现在多个 stage(一个席位只跑一次)。
- 至少 1 个 stage。
- (可选)单 stage 并行度上限提示(如 > 6 并行给 warning:远程模型并发 + 沙箱压力)。
---
## 2. 编排编辑器(流程图构建)交互
### 2.1 入口与整体布局
- 在 Step 2 顶部模式切换里新增 **「依赖编排」**(`dag`)模式(与现有 自由 / 链条 并列)。
- 选择 `dag` 后弹出 / 进入**编排编辑器**(复用 Dialog 大弹窗,参考 `DispatchChainModal` 的尺寸 `w-[94vw] max-w-[1240px]`)。
### 2.2 推荐交互:分层(Stage)编辑器(Phase 2 实现)
```
┌ 依赖编排 ───────────────────────────────────────── [校验✓] [保存] ┐
│ 可选席位(点/拖加入某个 stage) │
│ [情报收集] [环境评估] [方案设计] [风险审查] [执行规划] [前端实现] │
│ ─────────────────────────────────────────────────────────────── │
│ Stage 1(并行) Stage 2(并行) Stage 3 │
│ ┌──────────┐ ─────▶ ┌──────────┐ ┌────────┐ ─▶ ┌──────────┐ │
│ │ 情报收集 │ │ 方案设计 │ │风险审查│ │ 执行规划 │ │
│ └──────────┘ └──────────┘ └────────┘ └──────────┘ │
│ [+ 拖席位到此] [+] [+] │
│ [+ 新建 Stage(串在最后)] │
│ ─────────────────────────────────────────────────────────────── │
│ 说明:同一 Stage 内的席位会**并行**执行;Stage 之间**串行** │
│ (后一个 Stage 能看到前面所有 Stage 的交付摘要)。 │
└───────────────────────────────────────────────────────────────────┘
```
交互要点:
- **加入 stage**:从顶部可选席位**拖拽**到某个 stage,或点席位 →「加入 Stage N」。
- **移动 / 重排**:席位可在 stage 间拖动;stage 可整体左右拖动重排(改串行顺序)。
- **删除**:席位 / 空 stage 可删。
- **并行度提示**:stage 内 ≥2 席位即标「并行」徽标;过多给 warning。
- **实时校验**:`validatePlan` 实时跑,错误在顶部红条提示,「保存」在校验通过前禁用。
- **预览流程图**:编辑区下方实时渲染只读流程图缩略(复用 §7 的 DAG 流程图组件)。
> 拖拽可用现成轻量方案(HTML5 DnD 或 `@dnd-kit`,看项目是否已有依赖;没有就先用「点选 + 加入/移出按钮」的无拖拽版,Phase 2 先上无拖拽、Phase 2.5 再加拖拽)。
### 2.3 可选增强:自由连线画布(后续,非本期必须)
- 节点自由摆放 + 拉线建边 → `DagGraph`(§1.2)。
- 实现成本高(画布 / 连线 / 命中检测 / 自动布局),且分层模型已覆盖绝大多数诉求。**本期不做**,仅在数据模型上预留 §1.2,避免将来返工。
### 2.4 验收(Phase 2)
- 能拖 / 点把席位分配到多个 stage,保存进 `step2.orchestrationPlan`。
- 刷新 / 重进 draft 能恢复编排。
- 非法编排(空 stage / 席位重复 / 席位不在 roster)有明确中文提示且无法保存。
---
## 3. 执行调度(前端编排循环 + 后端复用)
### 3.1 新增 `runDagOrchestration`(`useStep2Orchestration.ts`)
与现有 `runOrchestration` / `runChainOrchestration` 并列,逻辑:
```
for (stageIndex, stage of plan.stages):
if isStale() break // 复用现有 epoch 抢占(人工干预/暂停)
// 1) 总控按本 stage 的席位并行派活(一轮 leader,dispatched = stage.agentIds)
// —— 或跳过 leader,前端直接对 stage 内每个席位发起 special run(见 3.2)
// 2) 并行跑 stage 内所有席位
await Promise.all(stage.agentIds.map(seat =>
streamMultiAgent({ agentType:"special", agentName:seat,
newMessage: buildStageTask(seat, plan, stageIndex),
parallelNoFile: stage.agentIds.length > 1, // §4
... })))
// 3) 等本 stage 全部 ✅(Promise.all 天然 barrier)再进入下一 stage
// 4) finalSynthesis:最后一轮 leader(synthesisMode=true)综合所有 stage 交付
```
要点:
- **批内并行**:`Promise.all`(不是现在的 `for await` 串行)。
- **批间串行**:`Promise.all` 即 barrier,自动等齐。
- **依赖可见性**:进入 stage N 的席位时,把**前面所有 stage 已交付席位的摘要**拼进它的 `newMessage`(复用 §交付状态:网关 `_collect_delivery_status` / `GET last-ai-message?max_chars=N`,前端也可在 stage 边界拉一次摘要拼进 task)。这样 `(B C)` 能看到 `A` 的产出。
- **抢占 / 干预 / 暂停**:每个 stage 边界检查 `isStale()`(epoch),与现有自由 / 链条模式一致。
- **rollback**:special run 已统一 `multitask_strategy=rollback`,并行多席位各自不同 thread,互不冲突。
### 3.2 是否每 stage 都走 leader? — ✅ 已定:前端直派(方案 B)
**本期采用方案 B**:DAG 由用户**显式**指定,不需要 leader 再"决定派给谁"。前端**直接**对 stage 内每个席位发起 special run,省掉每 stage 的 leader LLM 往返(更快、更可控);只在所有 stage 完成后、`finalSynthesis` 为真时跑**一轮** leader(`synthesisMode=true`)综合所有交付。
> (备选方案 A:每 stage 跑一轮 leader 派活——更贴现有架构但每 stage 多一次 leader LLM、更慢。**本期不采用**,留档备查。)
### 3.3 后端改动
- **基本不需要新接口**:special run(`POST /api/multi-agent/run/stream` agent_type=special)、leader run、轻量交付读(`GET /threads/{id}/last-ai-message`)、轻量追加(`POST /threads/{id}/messages/append`)都已就绪。
- 仅需 §4 的「并行禁写文件」run policy 开关。
### 3.4 验收(Phase 3)
- `(B C)` 两席位**同时**出现在执行(后端日志两条 special run 时间重叠;前端两个流并行增长)。
- stage 间严格串行(stage 2 不早于 stage 1 全部完成)。
- stage N 席位的输入里能看到 stage <N 的交付摘要。
---
## 4. 并行时禁止写文件(防沙箱打架)
### 4.1 后端 run policy
`app/gateway/roundtable_run_policy.py` 现有角色:`leader` / `seat` / `report`。新增一个角色或开关:
- 方案:给 `seat` 增加一个「并行变体」`seat_parallel`,其 `excluded_tools` 在 `seat` 基础上**再禁** `write_file` / `bash` / `str_replace` / `present_files`(与 `report` 角色禁写的思路一致)。
- `_special_run`(`multi_agent.py`)按请求里的一个新 flag(如 `no_file: true`)选择 `seat_parallel` policy。
### 4.2 前端传参
- `streamMultiAgent` 请求体新增 `parallel_no_file?: boolean`(或复用 `no_file`)。
- `runDagOrchestration` 在 `stage.agentIds.length > 1`(真并行)时置 `true`;单节点 stage(实际串行)不限制,可正常写文件。
### 4.3 产物归属
- 并行席位的产物 = **消息正文**(被 `_collect_delivery_status` / 综合块读取)。
- 需要落文件 / 画图的(如 Step3 报告、`roundtable-report`)放在**串行的收口阶段**单独跑,不在并行批次里。
### 4.4 验收(Phase 4)
- 并行 stage 的席位 run 日志显示 `Excluded ... ['write_file','bash','str_replace','present_files', ...]`。
- 并行期间不再出现多个席位抢同一沙箱 / 写文件冲突。
---
## 5. 并行执行的前端显示
### 5.1 布局总览(并行进行时)
```
┌ 多智能体圆桌会商中心 ─────────────────────────────────────────────┐
│ [头像条:进行中的并行智能体] [模型选择 ▾] [暂停会商] │
│ (情报)● (方案)● (风险)● (执行) … │
│ ↑选中 ↑选中 loading hidden │
│ ───────────────────────────────────────────────────────────────── │
│ ┌─ 方案设计(选中)──────────┐ ┌─ 风险审查(选中)──────────┐ │
│ │ 一、整体架构…(流式渲染) │ │ 1. 合规风险 IDFA/OAID… │ │
│ │ … │ │ … │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
(左侧"参与角色"栏在并行时整体收起,腾出横向空间放两列消息)
```
### 5.2 关键交互(与需求逐条对齐)
1. **收起左侧**:并行执行时把左侧「参与角色 / 人工参与」栏整体折叠(一个 `isParallelRunning` 状态控制;非并行恢复)。
2. **头像条**:位置在**消息列表上方、模型选择器左侧**。展示**当前并行批次的所有进行中智能体头像**:
- **进行中** → 头像加 loading 动画(复用 `DispatchChainModal` 的 `animate-ping` 光环 + `Loader2Icon animate-spin` 角标)。
- **已完成** → 打勾(复用现有 `CheckIcon` 角标)。
- **选中** → 高亮描边(当前正在主区显示的那 2 个)。
3. **主区同时显示 2 个**:被选中的 2 个智能体的消息列表**并排两列**渲染。
4. **多于 2 个先隐藏**:未选中的智能体**不渲染 DOM**(见 §6 性能)。
5. **点头像切换**:点击未选中的头像 → 把它设为选中之一(替换当前 2 个里的一个,或按"最近点击保留 2 个"的 LRU 策略);被换下的智能体**停止渲染但继续接流**。
### 5.3 选中策略 — ✅ 固定 2 列,窄屏降 1 列
- 维护 `selectedParallelSeats: string[]`,**最多 2**(固定 2 列)。**不做 3+ 列**。
- **响应式**:宽屏 2 列并排;窄屏(建议断点 `< 1024px` / `lg`)降为**1 列**,头像条横向滚动切换;此时 `selectedParallelSeats` 仍可存 2 个,但主区只渲染当前 1 个(点头像切换哪个在前)。
- 默认选中本批次**最先开始**的(宽屏 2 个 / 窄屏 1 个)。
- 点头像:若已选中则取消;未选中则加入(超过上限时按 LRU 踢掉最早选中的)。被换下的席位**停止渲染但继续接流**(§6)。
### 5.4 验收(Phase 5)
- 并行时左侧收起、出现头像条、主区两列。
- 进行中头像有 loading;完成打勾;选中有高亮。
- 点头像能切换主区显示的智能体,且切换是**即时**的(数据已在内存,见 §6)。
---
## 6. 流式高性能策略(避免页面卡死)
> 核心矛盾:N 个并行席位同时高频 `onTextDelta`,若都 setState + Markdown 渲染会卡死。
### 6.1 全接收、惰性渲染
- **所有**并行 run 的流都正常订阅(不丢数据):每个席位的增量文本**累积进 store / ref**(`Map<agentId, { text, status, charCount }>`)。
- **只有被选中(主区显示)的席位**才把累积文本挂到 DOM 渲染(Markdown)。
- **未选中**席位:只更新内存 + 头像条上的轻量进度(字数 / loading),**不挂载消息 DOM**。
- 切换选中时:把该席位已累积的全文一次性渲染 + 继续接增量。
### 6.2 渲染节流
- 选中席位的流式渲染**节流/合帧**:用 `requestAnimationFrame` 或 ~50–100ms 批量 flush 累积的 token,而不是每个 token 一次 setState。
- Markdown 渲染对"流式中"可降级(纯文本 / 轻量渲染),**完成后**再做完整 Markdown(现有 `MessageBubble` 的 `streaming` 标志可复用:streaming 时简渲、done 后全渲)。
### 6.3 头像条进度
- 头像条只显示**廉价**信息:loading 动画 + 可选字数(`charCount`,从内存 Map 取,节流更新,例如每 500ms 刷一次)。不触发消息体渲染。
### 6.4 数据流(建议结构)
```
streamMultiAgent(seat).onTextDelta(text) →
parallelStore.append(seatId, text) // 纯内存累积,O(1)
if seatId ∈ selectedParallelSeats: // 仅选中的进 React state(节流)
scheduleFlush(seatId) // rAF / 节流 setState
else:
bumpCharCount(seatId) // 仅更新头像条进度(节流)
```
### 6.5 验收(Phase 6)
- 6+ 席位并行时页面不卡(输入 / 滚动 / 切换流畅)。
- 切到一个之前隐藏的席位,能立刻看到它**到目前为止的全部**输出(证明流没丢、只是没渲染)。
---
## 7. 流程图执行态可视化(DAG 版)
- 把现有 `DispatchChainModal.tsx`(横向**线性**流程图)扩展 / 复刻成 **DAG 流程图**:
- 按 stage 分**列**,stage 内席位**纵向并列**,stage 间用连接线。
- 节点状态复用 `ChainNodeState`(pending / active / done)+ 现有动画(`animate-ping` / `Loader2Icon` / `CheckIcon`)。
- 末尾接 `共识 → 结果绘制` 里程碑(沿用现有)。
- 用途:编辑器里的只读预览(§2.2 底部)+ 执行时的总览(可选小窗 / 顶部缩略)。
```
总控 ─▶ ┌ Stage1 ┐ ─▶ ┌ Stage2 ┐ ─▶ ┌ Stage3 ┐ ─▶ 🤝共识 ─▶ 📊报告
│ 情报● │ │ 方案✓ │ │ 执行○ │
└────────┘ │ 风险● │ └────────┘
└────────┘
```
### 7.1 验收(Phase 7)
- 编辑器底部 / 执行时能看到 DAG 流程图,节点状态随执行实时更新(active/done)。
### 7.2 实施结论(2026-06-07,✅ 已完成)
- **新组件** `components/DagFlowChart.tsx`:纯展示组件,复刻 `DispatchChainModal` 的节点 / 连线 / 里程碑视觉(`animate-ping` 光环 + `Loader2Icon` 转圈 + `CheckIcon` 打勾 + `rt-chain-connector` 连线动画),但按 **stage 分列**:总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ 🤝共识 ─▶ 📊结果绘制,stage 内席位**纵向并列**。`compact` 紧凑模式给缩略图用。所有状态由 props 传入(`stages` / `resolveSeat` / `seatStates` / `coordinatorState` / `consensusState` / `reportState`);不传 `seatStates` → 全 pending(只读预览)。`aggregateStageState` 把 stage 内席位聚合成列状态(任一 active→active、全 done→done)驱动 stage 间连线。
- **编辑器只读预览**(§2.2 底部):`BusinessChainEditorPage` 底部加「编排预览」section,把 `stageGroups`(去空 stage) 映射成 `DagFlowChart` 的 stages + `resolvePreviewSeat`(席位名/配色),`compact`、全 pending、随编辑实时变。
- **执行态总览**(Step 2 顶部缩略):`useStep2Orchestration` 新增 `dagNodeStates: Record<agentId, ChainNodeState>`,`runDagOrchestration` 跨 stage 维护(进入 stage→该批 active、席位终态→done、`resetStep2State` 清空);经 `RoundtablePlanningPage` 把 `orchestrationMode/orchestrationPlan/dagNodeStates` 传到 `Step2Panel`,dag 模式下会话区顶部 `compact` 渲染 DagFlowChart。总控/共识里程碑状态由 `Step2Panel` 从节点状态 + `isOrchestrating`/`hasConsensus` 派生(中转轮时无席位 active + isOrchestrating → 总控 active)。
- 改动文件:`components/DagFlowChart.tsx`(新)、`pages/BusinessChainEditorPage.tsx`、`hooks/useStep2Orchestration.ts`、`components/Step2Panel.tsx`、`pages/RoundtablePlanningPage.tsx`。typecheck 零新增(基线 65)。
- **下次实地验收**:①编辑器底部随分层编辑实时画出流程图;②Step2 dag 模式顶部出现缩略流程图,跑起来后当前 stage 列点亮(active 转圈)、完成打勾、中转时总控亮、最后共识亮。
---
## 8. 分步实现计划(每步可独立交付 + 验收)
| Phase | 内容 | 主要文件 | 依赖 | 可独立验收 |
|---|---|---|---|---|
| **1** | DAG 数据模型 + 校验 + 持久化字段 | `lib/dag-types.ts`(新)、draft step2 读写 | — | 纯函数单测:`validatePlan` / `chain→dag` 兼容转换 |
| **2** | 分层编排编辑器(先无拖拽:点选加入/移出) | `components/DagPlanEditor.tsx`(新)、Step2 模式切换 | P1 | 能编排并保存/恢复,非法编排有提示 |
| **2.5** | 编辑器拖拽 | 同上 + `@dnd-kit`(若引入) | P2 | 拖拽分配/重排 |
| **3** | `runDagOrchestration`:批内并行 + 批间串行 + 依赖摘要注入 | `hooks/useStep2Orchestration.ts`、`api/multi-agent.ts` | P1 | 后端日志并行 special run 时间重叠;stage 严格串行 |
| **4** | 并行禁写文件 run policy | `roundtable_run_policy.py`、`multi_agent.py`、`api/multi-agent.ts` | P3 | 并行 run 的 excluded_tools 含 write_file/bash/... |
| **5** | 并行显示骨架:收起左侧 + 头像条 + 两列 + 选中切换 | `components/Step2Panel.tsx`、`components/ParallelSeatStrip.tsx`(新) | P3 | 见 §5.4 |
| **6** | 流式高性能:全接收 / 惰性渲染 / 节流 | `hooks/useStep2Orchestration.ts`(parallelStore)、`Step2Panel` | P5 | 见 §6.5(6+ 并行不卡) |
| **7** | DAG 流程图执行态可视化 | `components/DagFlowChart.tsx`(复刻自 `DispatchChainModal`) | P2、P3 | 见 §7.1 |
| **8** | 边界 / 异常 | 各处 | 全部 | 见 §9 |
> 落地顺序建议:**P1 → P3 → P4 → P5 → P6**(先把"能并行跑 + 能看 + 不卡"打通),**P2 编辑器**可与 P3 并行开发(先用一份 hardcode/简单 UI 的 plan 驱动 P3 联调),**P7 / P2.5 / 2.3** 作为增强后置。
---
## 9. 边界与异常(Phase 8)
- **某并行节点失败 / 超时**:单个席位失败不拖垮整 stage;标该节点 error,stage 其余 ✅ 仍推进;收口时把"X 失败"如实告知总控(复用交付状态 ⬜ + 失败标记)。
- **依赖未满足**:调度器保证 stage 串行,理论不会发生;防御性校验 stage N 启动前 stage <N 必须全部终态(done/failed)。
- **人工干预 / 暂停 / 恢复**:在 stage 边界检查 `isStale()`(epoch);干预指令插入后,从"下一个未开始的 stage"继续(已完成 stage 不重跑)。
- **并行禁写文件 vs 席位本想写文件**:在席位 SOUL / 派活提示里说明"本轮为并行研讨,产出请直接写在回复正文,不要写文件",降低模型困惑。
- **窄屏**:主区两列降为一列 + 头像条横向滚动。
- **单 stage 全并行(一次性 N 个)**:给并行度 warning(远程模型并发限流 / 成本)。
---
## 10. 与现有代码的衔接点速查
- 编排循环:`hooks/useStep2Orchestration.ts`(`runOrchestration` / `runChainOrchestration` 旁新增 `runDagOrchestration`;复用 `isStale()` epoch、`abortAllInFlight`、`appendStreamingDialogue` / `appendToDialogue`)。
- 流式 API:`api/multi-agent.ts`(`streamMultiAgent`,新增 `parallel_no_file` 入参;`onTextDelta(text, messageId)` 已是按席位回调,天然支持并行分流)。
- 显示:`components/Step2Panel.tsx`(消息列表 `step2RoundtableDialogues.map`;新增头像条 `ParallelSeatStrip` + 两列容器 + 折叠左侧)。
- 流程图:`components/DispatchChainModal.tsx` / `lib/job-types.ts` / `lib/business-chain.ts`(复刻成 DAG 版)。
- 常量 / 元数据:`lib/roundtable-constants.ts`(`BUILTIN_AGENT_META` 头像/中文名、`buildAgentNameToRole`)。
- 后端 run policy:`app/gateway/roundtable_run_policy.py`(新增 `seat_parallel`)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `no_file` flag)。
- 交付摘要:`GET /api/threads/{id}/last-ai-message?max_chars=N`(依赖注入 stage N 输入用)。
---
## 11. 一句话总结
把「线性链条」升级为「分层 DAG」:**编辑器**用分层 stage(拖卡片)表达串/并;**调度**用 `Promise.all` 批内并行、批间 barrier 串行,依赖摘要在 stage 边界注入;**并行禁写文件**避免沙箱打架;**显示**收起左侧、头像条切换、主区两列、未选中只接流不渲染(rAF 节流)保证不卡。全部建立在现有 special/leader run + 轻量交付接口之上,后端几乎零新接口。
---
## 12. 已知问题与排查记录(2026-06-06)
### 12.1 ❗并行席位「步骤条」不显示 —— 根因:`seat_parallel` 工具面禁得过多
**现象**:并行两列里的席位气泡看不到步骤条(`MessageStepsCard`),而串行席位有。
**排查结论(已定位,待修)**:
1. **步骤条只渲染「工具调用」步骤**。`MessageStepsCard` 的 `isToolStep` 只认 `tool_calling:<toolName>` 的步骤进折叠卡;纯阶段步骤(`connecting:` / `streaming:` 这类 reasoning)只渲染成**底部一行小灯泡**,不出大卡(见 `MessageStepsCard.tsx` L80-100)。
2. **并行席位几乎调不动任何会产生步骤的工具**。后端 `app/gateway/roundtable_run_policy.py`:
- `_SEAT_EXCLUDED = [web_search, ask_clarification, present_files, view_image, agent_orchestration]` —— **注意 `web_search` 对所有席位(串行也)就禁了**(注释「圆桌研讨里联网搜索一律禁用」)。
- `_SEAT_PARALLEL_EXCLUDED = _SEAT_EXCLUDED + [write_file, bash, str_replace]`。
- 于是并行席位可用工具几乎只剩 `read_file / ls / task / write_todos / memory / hindsight_*`,研究/分析类席位基本一个都不会调 → **没有任何 `tool_calling` 步骤** → 步骤条(大卡)不出现。
3. **对比串行为何「有步骤条」**:串行席位(`seat` policy)**能 `write_file`**,会产生 `tool_calling:write_file` 步骤 → 大卡出现。并行被禁了 write_file + 又没有 search,所以一个工具步都没有。
**用户诉求**:并行时**只禁真正会冲突的写操作**,搜索信息 / 调用技能等**必要操作不要禁**。
**关键事实(影响方案选择)**:圆桌每个席位是**独立 thread = 独立 sandbox**(后端 per-thread 隔离 `.deer-flow/users/{uid}/threads/{tid}/`)。所以并行席位写文件**在后端并不会互相冲突**;当初 §4「并行禁写文件」的「打架」其实主要是**前端单沙箱预览**(`activeArtifactThreadId` 只能同时显示一个)+ 产物归集到正文的简化考量,并非后端真冲突。→ 这意味着「并行禁写」的必要性比原设想弱,可放宽。
**✅ 决策:选 A(2026-06-06 已实施)**:把 `seat_parallel` 放宽到**只禁 `write_file / bash / str_replace`**(防前端单沙箱预览串台 + 产物走正文)+ 仍禁 `ask_clarification / present_files / view_image / agent_orchestration`(打断/越权类),但**放开 `web_search` 与技能**(搜索不碰 sandbox;技能靠 `read_file`)。改在 `app/gateway/roundtable_run_policy.py` 的 `_SEAT_PARALLEL_EXCLUDED`(**不再继承** seat 的 web_search 禁用,显式列举),测试 `tests/test_roundtable_run_policy.py` 同步更新(`test_seat_parallel_allows_web_search_and_skills`)。
→ 并行席位现在能联网搜索/用技能 → 产生 `tool_calling` 步骤 → 步骤条显示。
> 备选 B(连写文件也放开,沙箱按 thread 隔离)/ C(重新评估「圆桌串行也一律禁 web_search」)**本期不做**,留档。串行席位目前仍禁搜索(`seat` policy 不变)。
### 12.2 其它已修(本轮反馈)
- 最左侧**全局工作区侧栏**并行时收起:改用与「打开沙箱」同一路径 `useSidebarSafe()?.setOpen(false)`(之前只收了圆桌「参与角色」栏 `step2SidebarExpanded`)。并行结束**不自动展开**(与沙箱收起不回弹一致)。
- 并行两列改回**完整 Markdown**(撤销 §6.2 流式纯文本降级 `plainWhileStreaming`),与串行同款 `Step2Message`。
- 头像条**不再显示字数**(去掉 charCount 徽标 + 字段)。
- **致命 bug 已修**:用户干预 / 并行跑完后**不再重跑整张 DAG**。`dagPlanExecutedRef` 闸门 + DAG 各阶段跑完后释放锁 `await runOrchestration(handoff)` 交回总控继续派活/收口;后续干预/恢复一律走 leader 自由派活。
- **并行两列宽表溢出已修(2026-06-07)**:模型输出的 Markdown **表格**(如「法律免责条款」表)没有宽度约束 / 横向滚动容器,在并行两列的窄列里会**超出左列、盖到相邻列**。修法:`MessageBubble.sharedMarkdownComponents.table`(+ `markdown-components.tsx`)把 `<table>` 套进 `<div class="max-w-full overflow-x-auto">`——表格保持自然宽度、超出时**列内横向滚动**而非撑破列宽。`min-w-0` 链(grid `minmax(0,1fr)` → 列 `min-w-0` → 气泡 `flex-1 min-w-0`)本就齐全,只差 table 这一层。
- **关于「并行席位无步骤条」**:步骤条(`MessageStepsCard` 大卡)只在席位产生 `tool_calling` 步骤时出现(§12.1)。纯分析型席位若本轮**直接写分析正文、没调用任何工具**(web_search 等),就只显示一行「正在输出」思考指示、不出大卡——这是设计使然,非解析失败。要确认是否真调了工具:浏览器 devtools 设 `window.__MULTI_AGENT_DEBUG__=true` 看 `frame# event=messages` 里有无 tool_calls,或查后端 special run 日志。
### 12.3 ✅需求变更:Stage 间「经总控中转 + 广播」(2026-06-06 提出,2026-06-07 已实施)
**当前实现(方案 B,§3.2)**:DAG 各 stage 由前端**直接**派活,stage 间不跑 leader,靠前端把 `buildDependencySummary` 拼进下一 stage 的 task(`priorSummary`)。各 stage 跑完后才 `await runOrchestration` 交回总控收口。
**新诉求**:业务链条调用时,**每一轮 stage 之间都要经总控中转 + 广播**,而不是前端直接串下去。具体:
1. 第 1 轮并行调用 stage1 的 A、B、C;
2. A/B/C **全部完成后**,把这一批的**输出广播出来**(广播给其它席位 + 总控 thread);
3. **总控(leader)接收**这批交付 → 再**往下继续派活**(派 stage2 的 D、E);
4. 派活时把(承上的)消息**广播给下一轮的 D、E**;
5. 以此类推,直到所有 stage 跑完再收口。
> 即把 §3.2 的「方案 B 前端直派」改成 **「方案 A' = 计划约束下的总控中转」**:stage 边界插入一轮 leader,由它综合上一批交付并驱动下一批(下一批席位仍由编排计划**钉死**为 D、E,不是 leader 自由乱派——leader 负责「承上启下 + 广播上下文」,计划负责「派给谁」)。
**实施要点(下次会话)**:
- **每个 stage 完成 → 广播该批输出**:后端 `_special_run` 已对每个 seat 做 detached 后广播(「子智能体X完成…交付内容为:…」→ 其它席位 + 总控)。但它是 **detached/best-effort + 截断**;要让总控**可靠**地在下一轮前看到,需在 stage barrier 处**等广播落地**(或前端在 stage 边界用 `GET /threads/{id}/last-ai-message` 主动收齐各席位交付再喂给 leader)。
- **插入 leader 中转轮**:`runDagOrchestration` 在进入下一 stage 前,跑一轮 leader(`synthesisMode=true` 让后端注入上一批**完整**交付),让总控产出一段「承上启下」说明;把这段说明(而非机械 `priorSummary`)作为下一 stage 席位 task 的上下文 / 或广播给下一批席位 thread(复用 `POST /threads/{id}/messages/append`)。
- **下一批仍由计划钉死**:leader 中转轮**不**自由决定派给谁(计划已定 D、E);可参考 `buildChainLeaderTask` 的「约束 leader 只派给指定席位」思路,或干脆 leader 只做综合、前端照计划派 D、E 并把 leader 综合 + 广播注入其 task。
- **与「并行禁写」「§12.1 放开搜索」并存**:中转/广播不影响工具面。
- **与致命 bug 修复并存**:最后一个 stage 跑完仍走「交回总控(`runOrchestration`)收口/继续派活」;用户干预仍走 leader、不重跑 DAG。
**影响文件(预估)**:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` 的 stage 循环里加 leader 中转 + 广播收齐)、可能复用后端 `_leader_run`(synthesisMode 注入全文)/ `/messages/append`(广播)/ `/last-ai-message`(收齐交付)。后端基本无需新接口。
**✅ 实施结论(2026-06-07)**:后端**零改动**,全部在前端 `runDagOrchestration` 内完成,复用既有「传摘要按需传全文」优化链路:
1. **中转轮位置**:每个**非最后**的 stage barrier 到达后(`Promise.all` 收齐本批交付),插入**一轮 leader**「中转轮」,展示为「总控协调(承上启下)」气泡。最后一个 stage 后**不**插中转,综合/收口仍交给末尾的 `runOrchestration` 接管(与致命 bug 修复一致)。
2. **可靠收齐交付(传摘要)**:中转轮跑 `streamMultiAgent({agentType:"leader", synthesisMode:false})`。后端 `_leader_run` 本就在每轮 leader 前用 `_collect_delivery_status`(走**轻量 `GET /last-ai-message?max_chars=600` 接口**)**客观核对**各席位真实交付并把**摘要**注入总控输入(`_build_seat_status_block`)——比 detached 广播可靠(直接实读各席位 thread,不依赖 best-effort 广播是否落地)。故中转轮传 `synthesisMode:false`:只综合本批、**不收口**,只注入摘要;**完整交付正文**留到末尾收口的 synthesis 轮(`_build_full_deliverables_block`,全 ✅ 才注入)按需读取——即「**传摘要按需传全文**」。
3. **承上启下驱动下一批**:中转轮产出的综合说明存进 `leaderBridge`,作为**下一 stage** 席位 task 的上下文(`buildStageTask({priorSummary: leaderBridge, priorIsBridge:true})`,措辞为「承上启下综合说明」而非机械「交付摘要」),替代原来的 `buildDependencySummary(deliveries)`。中转失败/无产出时回退机械依赖摘要,不阻断。
4. **下一批由计划钉死**:中转轮提示(`buildDagTransitTask`)明确告知总控「下一阶段席位已由编排计划钉死为 X、Y,你**不要**自行 `agent_orchestration` 派活、**不要**收口」,前端仍照计划直派下一批;即便弱模型仍派了活,前端忽略其 `dispatched`、只取 `content` 当 bridge。
5. **广播**:本批输出广播给其它席位 + 总控 = 后端 `_special_run` 既有 detached 后广播(截断摘要);承上消息给下一批 = 注入其 task `new_message`(席位 run 输入必见,比 `/messages/append` 更可靠,省一次写盘)。
6. **澄清/抢占/失败**:中转轮触发澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG,沿用 `dagPlanExecutedRef` 闸门);被 `isStale()`/abort 抢占 → 静默收尾;异常 → 该气泡标错误、清空 bridge 继续。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagTransitTask`(中转轮提示)+ `buildStageTask` 加 `priorIsBridge` 形参;`lib/dag-types.test.ts` 加 4 个单测(共 27 个全过)。
> ⚠️ **§12.3 的「单独中转轮 + 前端直派」已被 §12.4 取代**(2026-06-07 同日):见下。`buildDagTransitTask` / `buildStageTask` / `buildDependencySummary` 仍保留在 lib(带单测),但 `runDagOrchestration` 不再调用它们。
### 12.4 ✅ 需求再变更:DAG 改成「总控驱动每 stage 派活」(2026-06-07,§3.2 方案 A)
**新诉求(用户原话)**:把并行(DAG)逻辑改得和串行一样——串行时总控给多个智能体派活、它们完成后汇报给总控、总控再继续往下派活;并行也改成「由总控判断要执行哪些步骤,这些步骤完成后再汇报给总控,总控继续往下派活」。
**已确认的两个关键决策**:
1. **每批「派给谁」**:✅ **计划钉死成员,但走总控派活**(不是总控自由乱派)。即 stage 成员仍由编排计划定死,但改成总控每轮**真正** `agent_orchestration` 派活给这些席位(约束只派给它们、且每个都派到)。= §3.2「方案 A」+ 批内并行。
2. **适用范围**:✅ **只改 dag 模式**(recommend 仍串行逐个、chain 不动)。
**实现(后端零改动,全在 `runDagOrchestration`)**:每个 stage 两步——
- **(a) 总控派活轮**:跑一轮 `leader`,提示 `buildDagStageLeaderTask`(首 stage 带任务背景;后续 stage 靠后端注入的此前交付摘要承上;列出本 stage 钉死席位的 `agent_name`,要求逐个派到、不派清单外、不收口)。`synthesisMode:false` → 后端 `_build_seat_status_block` 走轻量 `last-ai-message` 注入各席位**摘要**(传摘要按需传全文)。
- **(b) 批内并行**:本 stage 席位并行跑总控派给各自的 task——`pickDagSeatTask(leaderRes.dispatched, content, agentId, name)` 命中其 `agent_name`;总控漏派/写偏则回退总控正文/模板,**不借用别席位 task**(多席位并行避免张冠李戴)。完成后交付经后端 `_special_run` detached 后广播回总控;下一 stage 的派活轮即可看到。
- barrier 后进入下一 stage,总控再派下一批。全部 stage 跑完 → 交回 `runOrchestration` 收口(synthesisMode 按需注入全文)。
**相比 §12.3 的变化**:§12.3 是「前端直派 + stage 间单独插一轮只综合的中转轮」;§12.4 把**每个 stage 本身**就改成「总控派活轮 + 批内并行」——总控既承上(注入的摘要)又派活,不再需要单独的中转轮。与 chain 模式同构,区别是一次钉死一批、批内并行。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagStageLeaderTask`(每 stage 派活提示)+ `pickDagSeatTask`(取某席位 task,多席位不张冠李戴);`lib/dag-types.test.ts` 加 5 个单测(共 32 个全过)。`runDagOrchestration` 不再用 `buildStageTask/buildDependencySummary/buildDagTransitTask`(保留在 lib)。
**Phase 7 流程图兼容**:总控派活轮无席位 active + `isOrchestrating` → 流程图「总控」节点显示 active(承上派活中),随后该 stage 列点亮,无缝衔接。
**下次实地验收**:①每个 stage 前出现「总控协调(派活中/承上派活中)」气泡,内容是总控对本 stage 席位的派活说明;②后端日志该轮 leader 的 `_partition_dispatch` 派给的正是本 stage 钉死席位;③这些席位并行跑(special 日志时间重叠);④完成后下一 stage 总控派活轮能看到上一批交付摘要(`[leader-roster]` 日志)。
### 12.5 ✅ 流程图弹窗化 + DAG「快速/沙箱」执行子模式(2026-06-07)
三个改动一起做:
**(1) 流程图改成弹窗**:Step2 顶部不再内联 `DagFlowChart`(挤占两列消息高度,截图 `样式优化-1.png`)。改成一条细栏「分层编排执行中 · 共 N 个阶段 + [查看编排流程图]」按钮,点开 `Dialog` 看**全尺寸**流程图(非 compact)。编辑器(`BusinessChainEditorPage`)底部预览保持内联不变。改 `Step2Panel.tsx`(加 `dagFlowOpen` state + Dialog)。
**(2) 推荐弹窗新增 DAG 执行子模式(快速/沙箱)**:`BusinessChainPicker` 在选中链条**有分层并行**(走 dag)时,「进入研讨」旁出现「执行方式:快速·双列禁写 / 沙箱·单列可写」二选一(只 dag 时出现,§已确认)。
- **快速(`fast`,默认)**:同 stage 席位**并行**、主区**双列**、`parallelNoFile=true`(seat_parallel **禁写文件**)。= 之前的行为。
- **沙箱(`sandbox`)**:同 stage 席位**仍并行**,但主区**单列**(`selectedParallelSeats` 最多 1)、`parallelNoFile=false`(seat policy **允许写文件**);点头像切换查看,沙箱跟随当前席位。
- 数据流:`SeatSelectionResult.dagExecMode` → `RoundtablePlanningPage` state(持久化进 `DraftStep2(Run)Snapshot.dagExecMode`,mirror orchestrationPlan)→ `useStep2Orchestration` option → `runDagOrchestration`。
**(3) 沙箱跟随当前显示席位(防 bug 重点,用户特别强调)**:`useStep2Orchestration`
- `sandboxFollowThreadIdRef` = 当前显示席位的 thread;`openSeatVirtualArtifact` 的 auto 守卫:sandbox 模式下,**后台并行席位**写文件时若不是当前显示席位,**不自动打开**(否则沙箱内容和主区列表错位)。
- 切换显示席位的 effect(keyed on `selectedParallelSeats` + `dagExecMode`):该席位**有产物**(stub 里有 write_file/str_replace)→ `openSeatLatestArtifact` 强制打开它最新的文件;**无产物**→ 关闭沙箱面板(`setActiveArtifactThreadId(null)` + `deselectArtifact` + `setArtifactsOpen(false)`)。
- 当前显示席位 live 写文件 → `handleArtifactToolPhase` auto-open,守卫放行(follow===该席位)。后台席位写 → 守卫拦掉、只缓存 stub;切到它时 effect 再打开。
- `toggleParallelSeat`:sandbox 单列 = 点头像切换(始终保留 1 个,不取消到空);fast 双列 = 原 LRU 2 个。
**改动文件**:`lib/business-chain.ts`(SeatSelectionResult.dagExecMode)、`components/BusinessChainPicker.tsx`(子模式 UI)、`api/drafts.ts`(快照 dagExecMode)、`pages/RoundtablePlanningPage.tsx`(state/snapshot/hydrate/confirm/reset 全链)、`hooks/useStep2Orchestration.ts`(option + parallelNoFile/单列 + 沙箱跟随 + toggle cap)、`components/Step2Panel.tsx`(流程图弹窗 + 单列 grid)。typecheck 零新增(基线 65),dag-types 32 单测全过。
**下次实地验收(沙箱跟随是重点)**:①推荐弹窗选有并行的链条 → 出现「快速/沙箱」二选一;②快速=双列禁写(无沙箱);③沙箱=单列、席位能写文件、点头像切换列表时沙箱内容**同步**切换:有产物显示、无产物关闭;④后台席位写文件不会把沙箱抢到非当前列表;⑤流程图点按钮弹窗、全尺寸、节点随执行点亮。
### 12.6 ✅ 修:并行席位「跟着派活」(2026-06-07)
**现象**(截图 `bug-1.png`):总控派活后,并行席位的气泡里显示的是**总控的整段派活叙述**(「我来调度各智能体…第1阶段任务清单:席位1…席位2…」),还去调 `skill_list` 找 `agent_orchestration`——席位在**模仿派活**。
**根因**:总控这轮**没真正调用 `agent_orchestration` 工具**(弱模型 deepseek 用文字罗列了派活计划)→ 后端解析的 `dispatched=[]` → 前端 `pickDagSeatTask` 回退把**总控整段叙述**喂给**每个**席位 → 席位照抄叙述、模仿派活。(席位本就被 run policy 禁了 `agent_orchestration`,**派不了活**,只是空转一轮叙述 + 调 `skill_list`。)
**修 v1(护栏,效果不足)**:`pickDagSeatTask` 加角色边界护栏 + 回退措辞改「挑出属于你的那份」;`buildDagStageLeaderTask` 加「必须真正调用工具」。→ **没拦住**:总控对 deepseek 仍只写文字不调工具(`dispatched` 还是空),席位拿到的**任务正文本身**就是那段「我来调度…席位1/2/3」,护栏被淹没;3/4 席位仍模仿派活,有的甚至绕道 `bash`+`python requests.get('/api/agents')` 调内部接口派活(bug-1~4)。
**✅ 修 v2(根治:回退绝不注入派活叙述)**:`pickDagSeatTask` 去掉 `leaderContent` 入参——
- **命中**总控结构化派活(真调了工具)→ `请你作为「X」,完成总控派给你的本阶段任务:<dispatched task>` + 护栏;
- **未命中 / `dispatched` 空**(总控只写文字)→ **绝不**把总控派活叙述喂给席位,改给**干净本职任务**:「请你作为「X」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。任务背景与此前各阶段交付已在你的会话上下文里(后端广播注入),据此承接」+ 护栏。护栏强化到「不要用 bash/python/脚本访问内部接口(/api/agents、agent_orchestration)派活、不要复述『我来调度』总控口吻」。
- `buildDagStageLeaderTask` 的「必须真正调用工具」保留(让命中率更高、尽量走 exact-match)。
- `dag-types.test.ts` 改 2 单测(共 33 过)。typecheck 65。
> ⚠️ **残留次要污染源(若仍复发再处理)**:DAG 每个 stage 的 leader 轮,后端 `_leader_run` 会把 leader 的输入(含「请你作为总控…派活」)**预广播**进所有席位线程(`总控智能体收到用户消息:…`)。席位的**当轮任务**已干净(修 v2),这条只是历史上下文,理论上被「最新的干净任务 + 护栏」压制。若实测仍模仿派活,下一步:后端给 DAG leader 轮加 `suppress_pre_broadcast` / 或广播一条**净化**过的背景(去掉派活措辞)。
### 12.7 ✅ 修 4 项(2026-06-07):席位仍派活(v3 后端)+ 沙箱关不掉 + 并行不自动下滑 + 流程图只到 Step2
实测 §12.6 仍有席位派活(视觉设计师 `skill_manage` 找 agent-orchestration、列 dispatch all 4)——**坐实**了「残留次要污染源」:后端 leader 预广播把派活 prompt 推进席位线程,弱模型从历史里捡到派活意图。一起修了 4 项:
1. **席位仍派活(根治·后端)**:`_leader_run` 加 `suppress_pre_broadcast` 参数;DAG「每 stage 派活轮」前端传 `suppressPreBroadcast: true`(`api/multi-agent.ts` → `suppress_pre_broadcast`,run/stream 读取)。为真时:**不**把总控派活 prompt 预广播给席位、**也不**把「总控给 X 派活」detached 广播给兄弟席位。recommend/chain 不传该 flag,行为不变。配套:`pickDagSeatTask` 加 `seedMessage` 入参——预广播抑制后席位线程没有用户原始消息了,把**任务背景**直接写进席位 task(此前各阶段交付仍由 special 完成后广播注入)。→ 席位线程里**任何地方都没有派活语句**,根治模仿。
2. **沙箱关闭按钮关不掉**:沙箱跟随 effect 之前每次 render(deps 身份变)都重跑 → 把用户刚关的沙箱强制重开。加 `lastReconciledSeatRef` 守卫:**只在显示席位真正变化时**才开/关沙箱;用户手动关闭(席位没变)时 effect 早退,不再回弹。
3. **并行时自动下滑失效**:平铺列表 ↔ 网格(两列/单列)切换时 `scrollHeight` 突变、`scrollTop` 被浏览器 clamp → `useAutoScroll` 误判「用户上滚」而停跟随。`Step2Panel` 加 effect:`isParallelRunning` / `selectedParallelSeats` 变化时 `scrollStep2ToBottom()` 重新拉到底 + 恢复跟随。
4. **流程图只展示 Step2**:`DagFlowChart` 加 `showReportMilestone`(默认 true);Step2 弹窗 + 编辑器预览传 `false` —— 只到「共识」(Step2 终点),去掉「结果绘制」(那是 Step3)。
**改动**:后端 `multi_agent.py`(`_leader_run` suppress 两处广播 + run/stream 读 flag);前端 `api/multi-agent.ts`、`lib/dag-types.ts`(pickDagSeatTask + seedMessage)、`hooks/useStep2Orchestration.ts`(传 flag + seed + 沙箱跟随守卫)、`components/Step2Panel.tsx`(自动下滑 effect + showReportMilestone)、`components/DagFlowChart.tsx`、`pages/BusinessChainEditorPage.tsx`。`dag-types.test.ts` 共 **34 单测过**;前端 tsc 65 基线零新增;后端 `py_compile` OK。
### 12.8 ✅ 席位产出 md 文件 + 禁写 .py(2026-06-07)
**现象**:某次 7 席位**全都没产出 md 文件**;且一直有的问题——席位**有时写 .py 脚本**(用户要求:不写 .py,其它格式可以)。
**根因(没 md)**:两层都堵了——(a)**快速模式** `seat_parallel` policy 禁了 `write_file`(原「产物走正文」设计);(b)DAG 席位**提示词**写的是「直接输出你的交付正文」,即使沙箱模式(允许写)也不会去写文件。
**修**:
1. **后端 policy 放开写文件、统一禁 bash**(`roundtable_run_policy.py`):
- `seat_parallel`:去掉 `write_file` / `str_replace` 禁用(**可写 md**),保留禁 `bash` + 打断/越权类;仍放开 `web_search`。
- `seat`:新增禁 `bash`(原来允许)——圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 再跑生成产物,乱且抓不到预览);改为**直接 write_file 写成品文档**。
- `report`:`bash` 已随 `seat` 禁,改为 `[*_SEAT_EXCLUDED, "str_replace"]`(集合不变)。
- 测试 `test_roundtable_run_policy.py`:`test_seats_can_write_files_but_not_run_bash`(seat / seat_parallel 都可写文件、都禁 bash),6 用例过。
2. **前端提示词**(`pickDagSeatTask`)加「产物要求」:**把完整交付写成一个 .md 文件**(write_file 到 outputs)+ 正文给摘要;**禁写 .py 或任何脚本文件**,.md/.html/.txt 等其它格式都可以,唯独不要 .py。
3. **快速模式(双列)不自动开沙箱**(`openSeatVirtualArtifact` 新增守卫):放开写文件后,双列里多席位并发写文件会让单沙箱预览反复串台——fast 模式 DAG 并行批次**完全不自动开沙箱**(文件仍写盘,Step3/文件卡可查看);沙箱模式(单列)仍按「沙箱跟随当前席位」预览。
**关于 .py 的强度**:`write_file` 是全局工具(所有 agent 共用),无法在网关层按扩展名硬拦,故 .py 禁止走**提示词**(强约束)+ **禁 bash**(写了也跑不了,脚本生成产物的动机被掐断)。若实测仍偶发写 .py 且需**硬拦**,下一步:给 lead_agent 的 `write_file` 工具加一个「按 run context 禁某些扩展名」的开关,网关 `_run_payload` 对圆桌席位置 `[".py"]`(只影响圆桌 run,不动其它 agent)。
**改动**:`roundtable_run_policy.py`、`test_roundtable_run_policy.py`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(35 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65、后端 py_compile OK + 6 policy 单测过。⚠️ **改了后端,需重启 Gateway**。
### 12.9 ✅ 席位「按需取前序全文」只读工具 read_peer_delivery(2026-06-07)
**诉求**:A,B,C → D,E 时,D,E 默认拿 A,B,C 的**摘要**,但识别到需要**全文**时也能取到。之前只有总控(leader)能「摘要默认+按需全文」(跨 thread 读),下游席位**不能**(席位间 thread 隔离、只有 1500 字截断广播)。
**实现**(真·按需,全文只在调用工具时才进 LLM 上下文):
1. **新 harness 只读工具** `read_peer_delivery(seat_name)`(`packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py`):从 `runtime.context["roundtable_peer_deliveries"]`(网关注入的 `[{name,content}]`)按席位名取全文;空名→列出可取席位;非圆桌场景→「无」。只读 context dict,**不跨 thread、不碰 sandbox、无网络**,纯 `deerflow.*`(不违反 harness→app 防火墙)。注册进 `tools/tools.py` 的 builtin(始终在,只读无副作用)。
2. **网关注入 context**:`_run_payload` 加 `extra_context`;`_special_run` 收 `peer_deliveries` → 注入 `context.roundtable_peer_deliveries`;run/stream 读 `peer_deliveries`(规整 `[{name,content}]`)。**不进 prompt → 不调工具就不耗 token**。
3. **run policy**:`read_peer_delivery` 从 `leader`(总控自己跨 thread 读,不需要)+ `report`(Step3 不读前序)排除;**席位(seat/seat_parallel)保留**。
4. **前端**:`runDagOrchestration` 跨 stage 累积**完整交付** `priorDeliveries`,stage>0 席位的 special run 带 `peerDeliveries`(`api/multi-agent.ts` → `peer_deliveries`);`pickDagSeatTask` 加 `hasPeers` 形参,有前序时在 task 里提示「需要某前序席位全文时调用 read_peer_delivery('<名>'),平时用摘要」。
**最终语义**:D、E 默认看 A、B、C 的**摘要**(1500 字广播);识别到不够 → 调 `read_peer_delivery('情报收集')` 取**全文**。总控仍是摘要默认+综合轮全文。**= 用户要的「摘要默认、按需全文」对席位也成立了。**
**改动**:后端 `roundtable_peers_tool.py`(新)、`tools/tools.py`、`roundtable_run_policy.py`、`multi_agent.py`;前端 `api/multi-agent.ts`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(36 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65;后端 py_compile OK + 6 policy 单测过 + 工具导入/行为 smoke 通过。⚠️ **改了后端 + 新增 harness 工具,需重启 Gateway**。
### 12.10 ✅ 修:沙箱模式第一个席位不自动开沙箱(2026-06-07,前端)
**现象**:沙箱(单列)模式并行时,**第一个**席位的消息列表不自动打开沙箱(要手动开);切换到其它席位时正常自动打开。
**根因**:沙箱跟随 effect(§12.5)在选中席位**还没写产物**时走「关闭沙箱」分支,里面同时调了 `deselectArtifact()` **和** `setArtifactsOpen(false)`。后者是 context 的 **wrapped setOpen**(`context.tsx:73`):`关闭时若 autoOpen 为真 → 把 autoOpen 置 false`。于是第一个席位刚选中(还没写文件)→ autoOpen 被关 → 该席位**随后 live 写文件**时,`openSeatVirtualArtifact` 的 `!flags.autoOpen` 守卫拦掉自动打开。切换到**已写过**文件的席位能开,是因为那条路走 `openSeatLatestArtifact`(force,auto=false,绕过 autoOpen 守卫)。
**修**(一行):关闭分支只保留 `deselectArtifact()`(它走 raw `setOpen(false)`,**不**动 autoOpen),删掉多余的 `setArtifactsOpen(false)`。这样面板关掉、但 autoOpen 仍为真 → 第一个席位 live 写文件时正常自动开沙箱。(若用户**手动**关过沙箱,autoOpen 本就为 false,不会自动重开 —— 尊重用户。)改 `hooks/useStep2Orchestration.ts` 沙箱跟随 effect。tsc 65。
### 12.11 ✅ 修:快速模式又生成文件了 + Fragment 警告(2026-06-07);记录无关 404
1. **快速模式不应生成文件(回退 §12.8 的过度放开)**:§12.8 为「让席位产 md」把 `seat_parallel`(快速模式)也放开了 write_file —— 但**快速模式(双列)的设计就是产物走正文、不写文件**,写文件归**沙箱模式**(单列 + 沙箱预览)。修:
- `roundtable_run_policy.py`:`seat_parallel` 重新**禁 write_file / str_replace**(保留禁 bash);`seat`(沙箱模式)仍可写。
- `useStep2Orchestration.ts`:`parallelNoFile = dagExecMode !== "sandbox"`(快速模式一律禁写,含单节点 stage;沙箱模式放开)。
- `pickDagSeatTask` 加 `allowFiles` 形参:沙箱(true)=写 .md 文件 + 禁 .py;快速(false)=产物走正文、不写文件。hook 按 `dagExecMode === "sandbox"` 传。
- 测试:`test_sandbox_seat_writes_files_fast_parallel_does_not`(6 过);`dag-types.test.ts` 拆成沙箱/快速两个产物要求用例(37 过)。
- **语义**:快速=双列、产物走正文、不写文件;沙箱=单列、写 .md、沙箱跟随预览。
2. **`Invalid prop data-lov-id supplied to React.Fragment`**:`DagFlowChart` 的 stage 循环用了具名 `<Fragment key>`,dev 代码标注插件(lovable-tagger)给它注入 `data-lov-id`,而 Fragment 只接受 key/children → React 警告。修:换成 `<div className="contents" key=...>`(display:contents 不生成盒子,Connector/StageColumn 仍是外层 flex 直接子项,布局不变,且能接收 data-*)。移除 `Fragment` import。
3. **(无关,非本期引入)`/api/getTokenByEmail?email=…%40local.deerflow` 404 ×60**:来自 `src/core/studio/api.ts` 的 `fetchStudioToken`(studio/expert-auth 引导),用户邮箱被映射到 legacy `@local.deerflow` 域、后端 `getTokenByEmail` 不认 → 404,React Query 跨多个 `useStudioToken` 调用点重试 → 重复多次。**与圆桌 DAG 改动无关**(圆桌头像是本地 SVG,本期未新增任何前端 GET)。需要的话单独排查 studio token 链路。
---
## 13. 实施进度(截至 2026-06-06,下次会话续)
| Phase | 内容 | 状态 |
|---|---|---|
| 1 | DAG 数据模型 + 校验 + 持久化字段(`lib/dag-types.ts`,24 单测) | ✅ 完成 |
| 2 | 分层编辑器(**做进业务链条页**):链条加 `stages` 字段(前后端 + alembic `20260607_03` + 11 后端单测);`BusinessChainEditorPage` 竖列分组 Stage(DnD + 按钮);纯逻辑 `lib/chain-stages.ts`(19 单测) | ✅ 完成 |
| 3 | `runDagOrchestration`:批内 `Promise.all` 并行 + 批间 barrier 串行 + 依赖摘要注入(方案 B 前端直派) | ✅ 完成 |
| 4 | 并行 run policy `seat_parallel`(`roundtable_run_policy.py` + `multi_agent.py` 读 `parallel_no_file`,6 后端单测);**§12.1 方案 A 已放开 web_search/技能** | ✅ 完成 |
| 5 | 并行显示:收起左侧 + 头像条 + 两列 + 选中切换 | ✅ 完成(本轮修了侧栏/Markdown/字数) |
| 6 | 流式高性能:ref 全量累积 + 节流 flush(仅选中渲染) | ✅ 完成 |
| 7 | DAG 流程图执行态可视化(`DispatchChainModal` 复刻成按 stage 分列;后改成**弹窗**见 §12.5) | ✅ 完成(2026-06-07) |
**端到端闭环已通**:链条页编排分层 → 存库 → 选链条进 Step2 → `runDagOrchestration` 并行直派 → 各阶段完交回总控派活/收口。
**✅ §12.3 已实施(2026-06-07)**:见 §12.3「实施结论」。`runDagOrchestration` 在每个非末尾 stage barrier 后插入 leader 中转轮(`synthesisMode:false` 走轻量 `last-ai-message` 注入摘要、收口轮才按需全文),承上启下驱动下一批;后端零改动;`dag-types.ts` 加 `buildDagTransitTask` + `buildStageTask.priorIsBridge`,27 单测全过。
**下次会话 TODO(优先级从高到低)**:
1. **运行时实地验收 §12.3**:①每个 stage 之间出现「总控协调(承上启下)」气泡,内容是对上一批的综合;②后端日志中转轮 `[leader-roster] ... 注入席位交付状态` 显示上一批 ✅、未跑的 ⬜;③下一 stage 席位 task 里带的是总控承上说明(非机械摘要);④中转轮不应真的派活(即使派了前端也忽略)。
2. 运行时实地验收(旧):①§12.1 后并行席位能搜索、步骤条显示;②并行 special 日志时间重叠 + `Excluded` 不含 web_search 含 write_file;③用户干预后是「总控派活」而非重跑 DAG;④最左全局侧栏 + 参与角色栏并行收起且不回弹。
3. **✅ Phase 7 DAG 流程图可视化已完成(2026-06-07)**:见 §7.2。`DagFlowChart` 组件 + 编辑器只读预览 + Step2 执行态缩略(`dagNodeStates` 实时驱动)。
4. (可选)串行也确定性注入上一轮交付摘要。
**关键文件速查**:
- 前端:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` / `dagPlanExecutedRef` / 并行 store+flush)、`components/Step2Panel.tsx`(两列+头像条)、`components/ParallelSeatStrip.tsx`、`lib/dag-types.ts`、`lib/chain-stages.ts`、`pages/BusinessChainEditorPage.tsx`、`pages/RoundtablePlanningPage.tsx`(侧栏收起 effect + handleConfirmAgents dag 分支)、`components/BusinessChainPicker.tsx`(链条→dag)。
- 后端:`app/gateway/roundtable_run_policy.py`(`seat_parallel`,**待放宽**)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `parallel_no_file`)、`persistence/roundtable_chains/`(`stages` 列)、`migrations/versions/20260607_03_chain_stages.py`。
- 测试:`frontend-web` 用 esbuild 转译 `.test.ts` 后 `node --test`(项目无 vitest);后端 `PYTHONPATH=. uv run pytest tests/test_roundtable_chain_stages.py tests/test_roundtable_run_policy.py`。
@@ -1,375 +0,0 @@
# 浅蓝色 UI 规范
> 本文档由设计稿截图(`1.1 全局样式-色彩`、`1.2 全局样式-色彩附录`)识别整理而成。
> 全部色值已与"色彩附录"编号表逐一核对,保证准确。
---
## 1.1 色彩体系总览
调色体系分为 **主色** 与 **辅色** 两大类。每个色相按用途分为若干阶梯:
| 阶梯 | 数量 | 用途 |
| --- | --- | --- |
| 浅色 | 3 | 用于大面积底色 |
| 中亮色 | 6 | 用于标签、图表、提示等 |
| 深色 | 4(+基准色,共 5 个深阶) | 用于小面积按钮、图标等 |
| 线性渐变色 | 1 | 用于特殊装饰性小面积底色 |
| 径向渐变色 | 1 | 用于特殊装饰性小面积底色 |
> 中性色(灰阶)略有不同:浅色用于背景 / 白色文字及分割线,中亮色用于备注文字及图标,深色用于正文,且**无渐变色**。
---
## 主色
### 主色 - 红(基准 `#CF1322`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FEFAFA` · `#FEF6F6` · `#FEEDED` |
| 中亮色 | `#FDDADC` · `#FCB5B9` · `#FA9196` · `#F86C73` · `#F74750` · `#F5222D` |
| 深色 | `#CF1322` · `#BC0D1E` · `#A8071A` · `#820014` · `#5C0011` |
| 线性渐变色 | `#F5222D` → `#A8071A` |
| 径向渐变色 | `#F86C73` → `#F5222D` |
### 主色 - 蓝(基准 `#0062D9`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#F9FCFF` · `#F4F9FF` · `#EAF4FF` |
| 中亮色 | `#D4E9FF` · `#AAD2FF` · `#80BCFF` · `#55A6FE` · `#2B8FFE` · `#0079FE` |
| 深色 | `#0062D9` · `#0056C6` · `#004AB3` · `#00368C` · `#002466` |
| 线性渐变色 | `#0079FE` → `#004AB3` |
| 径向渐变色 | `#55A6FE` → `#0079FE` |
---
## 辅色
### 辅色 - 橙(基准 `#D97B00`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FFFCF9` · `#FFFAF4` · `#FFF6EA` |
| 中亮色 | `#FFEED4` · `#FFDDAA` · `#FFCC80` · `#FFBB55` · `#FFAA2B` · `#FF9900` |
| 深色 | `#D97B00` · `#C66D00` · `#B35F00` · `#8C4600` · `#663000` |
| 线性渐变色 | `#FF9900` → `#B35F00` |
| 径向渐变色 | `#FFBB55` → `#FF9900` |
### 辅色 - 绿(基准 `#1EB07F`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FAFEFD` · `#F6FDFB` · `#EEFCF7` |
| 中亮色 | `#DCF8EE` · `#BAF1DD` · `#97EBCD` · `#74E4BC` · `#52DDAB` · `#2FD69A` |
| 深色 | `#1EB07F` · `#189D72` · `#118A65` · `#07634B` · `#043D30` |
| 线性渐变色 | `#2FD69A` → `#118A65` |
| 径向渐变色 | `#74E4BC` → `#2FD69A` |
### 辅色 - 青蓝(基准 `#2F9ACC`)
| 用途 | 色值 |
| --- | --- |
| 浅色 | `#FBFDFE` · `#F7FCFE` · `#EFFAFE` |
| 中亮色 | `#E0F5FD` · `#C1EBFB` · `#A2E1F9` · `#83D7F7` · `#64CDF5` · `#45C3F3` |
| 深色 | `#2F9ACC` · `#2788B9` · `#1E76A6` · `#115580` · `#0B3959` |
| 线性渐变色 | `#45C3F3` → `#1E76A6` |
| 径向渐变色 | `#83D7F7` → `#45C3F3` |
### 中性色 - 灰(基准 `#222222`)
| 用途 | 色值 |
| --- | --- |
| 浅色(背景、白色文字及分割线) | `#FFFFFF` · `#FAFAFA` · `#F5F5F5` |
| 中亮色(备注文字及图标) | `#E8E8E8` · `#DBDBDB` · `#C1C1C1` · `#A6A6A6` · `#7F7F7F` · `#575757` |
| 深色(正文) | `#3D3D3D` · `#222222` |
---
## 1.2 色彩附录(编号对照表)
编号规则:`ZS` = 主色(主色调),`FS` = 辅色 / 中性色。`-01~-03` 为浅色,`-04~-09` 为中亮色,`-10~-13` 为深色,`-14` 为线性渐变色,`-15` 为径向渐变色;不带后缀的为该色相基准深色。
### 主色 ZS
| 编号 | 红 (ZS-001) | 编号 | 蓝 (ZS-002) |
| --- | --- | --- | --- |
| ZS-001 | `#CF1322` | ZS-002 | `#0062D9` |
| ZS-001-01 | `#FEFAFA` | ZS-002-01 | `#F9FCFF` |
| ZS-001-02 | `#FEF6F6` | ZS-002-02 | `#F4F9FF` |
| ZS-001-03 | `#FEEDED` | ZS-002-03 | `#EAF4FF` |
| ZS-001-04 | `#FDDADC` | ZS-002-04 | `#D4E9FF` |
| ZS-001-05 | `#FCB5B9` | ZS-002-05 | `#AAD2FF` |
| ZS-001-06 | `#FA9196` | ZS-002-06 | `#80BCFF` |
| ZS-001-07 | `#F86C73` | ZS-002-07 | `#55A6FE` |
| ZS-001-08 | `#F74750` | ZS-002-08 | `#2B8FFE` |
| ZS-001-09 | `#F5222D` | ZS-002-09 | `#0079FE` |
| ZS-001-10 | `#BC0D1E` | ZS-002-10 | `#0056C6` |
| ZS-001-11 | `#A8071A` | ZS-002-11 | `#004AB3` |
| ZS-001-12 | `#820014` | ZS-002-12 | `#00368C` |
| ZS-001-13 | `#5C0011` | ZS-002-13 | `#002466` |
| ZS-001-14 | `#F5222D` → `#A8071A` | ZS-002-14 | `#0079FE` → `#004AB3` |
| ZS-001-15 | `#F86C73` → `#F5222D` | ZS-002-15 | `#55A6FE` → `#0079FE` |
### 辅色 / 中性色 FS
| 编号 | 橙 (FS-001) | 绿 (FS-002) | 青蓝 (FS-003) | 中性灰 (FS-004) |
| --- | --- | --- | --- | --- |
| FS-00n | `#D97B00` | `#1EB07F` | `#2F9ACC` | `#222222` |
| -01 | `#FFFCF9` | `#FAFEFD` | `#FBFDFE` | `#FFFFFF` |
| -02 | `#FFFAF4` | `#F6FDFB` | `#F7FCFE` | `#FAFAFA` |
| -03 | `#FFF6EA` | `#EEFCF7` | `#EFFAFE` | `#F5F5F5` |
| -04 | `#FFEED4` | `#DCF8EE` | `#E0F5FD` | `#E8E8E8` |
| -05 | `#FFDDAA` | `#BAF1DD` | `#C1EBFB` | `#DBDBDB` |
| -06 | `#FFCC80` | `#97EBCD` | `#A2E1F9` | `#C1C1C1` |
| -07 | `#FFBB55` | `#74E4BC` | `#83D7F7` | `#A6A6A6` |
| -08 | `#FFAA2B` | `#52DDAB` | `#64CDF5` | `#7F7F7F` |
| -09 | `#FF9900` | `#2FD69A` | `#45C3F3` | `#575757` |
| -10 | `#C66D00` | `#189D72` | `#2788B9` | `#3D3D3D` |
| -11 | `#B35F00` | `#118A65` | `#1E76A6` | — |
| -12 | `#8C4600` | `#07634B` | `#115580` | — |
| -13 | `#663000` | `#043D30` | `#0B3959` | — |
| -14 | `#FF9900` → `#B35F00` | `#2FD69A` → `#118A65` | `#45C3F3` → `#1E76A6` | — |
| -15 | `#FFBB55` → `#FF9900` | `#74E4BC` → `#2FD69A` | `#83D7F7` → `#45C3F3` | — |
> 注:中性灰(FS-004)无渐变阶,最深至 `#222222`(基准)/`#3D3D3D`(FS-004-10),用于正文文字。
---
# 组件规范
> 以下为各组件的视觉规范与实现方式。颜色统一引用上文色值,建议在项目中先抽取为 CSS 变量 / Tailwind token,组件只引用语义化变量。
## 语义化 Token 建议
把色彩体系映射成语义变量,组件层只用语义名,便于换肤与维护:
```css
:root {
/* 品牌 / 主色(蓝) */
--color-primary: #0062D9; /* 主按钮、实底选中:ZS-002 深色基准 */
--color-primary-hover: #0079FE; /* 悬浮态:ZS-002-09 */
--color-primary-active: #0056C6; /* 按下态:ZS-002-10 */
--color-primary-bg: #EAF4FF; /* 浅底选中底色:ZS-002-03 */
--color-primary-border: #0079FE; /* 描边选中边框 */
/* 中性 */
--color-text: #3D3D3D; /* 正文:FS-004-10 */
--color-text-secondary: #A6A6A6; /* 次要 / 占位符:FS-004-07 */
--color-border: #DBDBDB; /* 默认边框:FS-004-05 */
--color-bg-disabled: #F5F5F5; /* 失效底色:FS-004-03 */
--color-fill-disabled: #A6A6A6; /* 主按钮失效底色:FS-004-07 */
--color-surface: #FFFFFF; /* 卡片 / 控件底 */
}
```
> 项目使用 Tailwind v4 + shadcn,可在 `@theme` 中声明这些变量后用 `bg-primary` / `text-primary` 等使用;下文示例同时给出等价的 Tailwind 写法。
---
## 2.1 基础 - 按钮(Button)
### 按钮尺寸
| 规格 | 高度 | 圆角 | 用途 |
| --- | --- | --- | --- |
| 小 | **28px** | 4px | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | **32px** | 4px | 常用按钮(默认尺寸) |
水平内边距约 12–16px,字号 14px,图标与文字间距 4–8px。
### 常用按钮类型
| 类型 | 视觉 | 用途 |
| --- | --- | --- |
| 主按钮 | 实底 `--color-primary`,白字 | 一个操作区域**只能有一个**主按钮 |
| 主按钮·失效 | 实底灰 `--color-fill-disabled`,白字 | 主按钮禁用态 |
| 次要按钮 | 白底 + `--color-primary` 边框 + 蓝字 | 重要度低于主按钮,高于默认按钮 |
| 默认按钮 | 白底 + `--color-border` 边框 + `--color-text` | 默认动作(取消等) |
| 默认·失效 | `--color-bg-disabled` 底 + 浅边框 + `--color-text-secondary` | 次要/默认按钮禁用态 |
| 文本按钮 | 无边框无底,蓝字(可加下划线) | 表格操作、文件下载等 |
| 图标按钮 | 仅图标,可选 描边/实底/浅底 × 方形/圆形 | 增强辨识度或节省空间 |
实现(Tailwind 示意):
```tsx
// 主按钮
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9]
hover:bg-[#0079FE] active:bg-[#0056C6]
disabled:bg-[#A6A6A6] disabled:cursor-not-allowed">确定</button>
// 次要按钮(次按钮)
<button className="h-8 px-4 rounded text-sm text-[#0062D9] bg-white
border border-[#0062D9] hover:bg-[#EAF4FF]">点击</button>
// 默认按钮
<button className="h-8 px-4 rounded text-sm text-[#3D3D3D] bg-white
border border-[#DBDBDB] hover:border-[#0079FE] hover:text-[#0079FE]
disabled:bg-[#F5F5F5] disabled:text-[#A6A6A6] disabled:border-[#DBDBDB]">取消</button>
// 文本按钮
<button className="text-sm text-[#0062D9] hover:underline">文本按钮样式</button>
// 图标按钮(实底圆形)
<button className="size-7 grid place-items-center rounded-full text-white bg-[#0062D9]
hover:bg-[#0079FE]"><TrashIcon className="size-4" /></button>
```
图标按钮三种底 × 两种形状:
- **描边**:白底 + `--color-border` 边框,图标用 `--color-text`
- **实底**:`--color-primary` 底 + 白图标
- **浅底**:`--color-primary-bg`(`#EAF4FF`)底 + 蓝图标
- 形状:方形 `rounded`(4px)/ 圆形 `rounded-full`
### 按钮组合
| 组合 | 说明 |
| --- | --- |
| 文字 + 下拉按钮 | 按钮右侧带 `⌄` 箭头;有 默认 / 主按钮 两种变体 |
| 图标 + 文字按钮 | `+ 正常`,前置图标;有 描边 / 实底 两种变体 |
| 文本按钮 + 图标 | 蓝色文本链按钮:`更多 »` `展开 ⌄` `下一页 »|` `收起 ⌃` `|⌜ 上一页` |
| 主按钮 + 默认按钮 | `确定`(主) `取消`(默认) `返回`(默认) 并排,间距 8–12px |
### 其他状态
- **悬浮状态(hover)**:主按钮悬浮时底色变亮(`--color-primary-hover` `#0079FE`),用于 Banner / 登录 / 视觉冲击强的场景。
- **加载中(loading)**:按钮内前置旋转图标 `⟳ 加载中`,期间 `disabled` 并降低交互;用于异步操作等待反馈。
```tsx
<button className="h-8 px-4 rounded text-sm text-white bg-[#0062D9] inline-flex items-center gap-1.5" disabled>
<Spinner className="size-4 animate-spin" /> 加载中
</button>
```
---
## 5.9 输入 - 单选框(选择按钮 / Segmented Radio)
一组互斥选项,外观为按钮组。两种排布 × 三种选中样式。
### 排布方式
| 排布 | 说明 |
| --- | --- |
| 拼接式 | 选项首尾相连,仅最外侧两角圆角,相邻共用 1px 边框 |
| 分离式 | 选项之间留间距(约 8px),每个独立 4px 圆角,各自带完整边框 |
### 选中(已选)样式 × 三种
| 样式 | 已选 | 未选 |
| --- | --- | --- |
| 描边式 | 白底 + 蓝边框 `--color-primary` + 蓝字 | 白底 + `--color-border` 边框 + `--color-text` |
| 浅底式 | `--color-primary-bg`(`#EAF4FF`) 底 + 蓝字 + 蓝边框 | 同上 |
| 实底式 | `--color-primary` 实底 + 白字 | 同上 |
- **带统计**:选项文字后追加计数,如 `已选(230)` / `未选(230)`,计数用次要色 `--color-text-secondary`。
实现(拼接式 + 描边选中):
```tsx
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB] divide-x divide-[#DBDBDB]">
{options.map((o) => (
<button key={o.value}
className={cn(
"h-8 px-4 text-sm bg-white",
value === o.value
? "text-[#0062D9] ring-1 ring-inset ring-[#0079FE] z-10" // 描边选中
: "text-[#3D3D3D] hover:text-[#0079FE]"
)}>
{o.label}{o.count != null && <span className="text-[#A6A6A6]">({o.count})</span>}
</button>
))}
</div>
```
> 浅底式把选中态换成 `bg-[#EAF4FF] text-[#0062D9]`;实底式换成 `bg-[#0062D9] text-white`。
> 分离式去掉 `divide-x` / `overflow-hidden`,外层用 `gap-2`,每个按钮单独 `rounded border`。
### 按钮式页签(Tab as Button)
图标 + 文字的页签型按钮:
- **激活** 两种:描边(白底 + 蓝边框 + 蓝字)/ 实底(`--color-primary` 底 + 白字)
- **正常**:白底 + `--color-border` 边框 + `--color-text`,前置图标
- 结构与拼接式选择按钮一致,区别在每项前置一个功能图标。
---
## 5.11 输入 - 选择器(Select / Dropdown)
单项输入选择,下拉单选。
### 状态
| 状态 | 触发框 |
| --- | --- |
| 占位 | `请选择` 占位文字用 `--color-text-secondary`,灰边框 `--color-border`,右侧 `⌄` |
| 展开/聚焦 | 边框变 `--color-primary`,箭头翻转为 `⌃` |
| 已选 | 显示选中项文字(正文色),保持聚焦时蓝边框 |
### 下拉面板
- 白底、圆角 4px、投影(如 `shadow-lg`),最大高度后出现滚动条
- 选项默认 `--color-text`;**hover / 选中** 项:`--color-primary-bg`(`#EAF4FF`) 底 + `--color-primary` 文字
- **分组**:分组标题用小号灰字 `--color-text-secondary`,组之间用 1px 分割线 `--color-border`
实现示意:
```tsx
// 触发框
<button className="h-8 w-full px-3 inline-flex items-center justify-between rounded
border border-[#DBDBDB] bg-white text-sm
data-[state=open]:border-[#0062D9]">
<span className={selected ? "text-[#3D3D3D]" : "text-[#A6A6A6]"}>{selected ?? "请选择"}</span>
<ChevronDown className="size-4 text-[#A6A6A6] transition-transform data-[state=open]:rotate-180" />
</button>
// 下拉项
<li className={cn(
"h-9 px-3 flex items-center text-sm cursor-pointer rounded-sm",
active ? "bg-[#EAF4FF] text-[#0062D9]" : "text-[#3D3D3D] hover:bg-[#EAF4FF]"
)}>{label}</li>
// 分组标题
<div className="px-3 py-1.5 text-xs text-[#A6A6A6]">分组标题1</div>
```
> 推荐直接基于 Radix `Select`(项目已有 shadcn 封装)改这些类名落地。
---
## 5.14 输入 - 开关(Switch)
两种形态:
### 1. 滑块开关(多用于表单、表格中)
- **开**:胶囊底 `--color-primary`,白色圆形滑块在右,左侧白字「开」
- **关**:胶囊底灰 `--color-text-secondary`/`#C1C1C1`,滑块在左,右侧灰字「关」
- 切换时滑块带左右过渡动画
```tsx
<button role="switch" aria-checked={on}
className={cn(
"relative h-6 w-12 rounded-full transition-colors text-[11px] text-white",
on ? "bg-[#0062D9]" : "bg-[#C1C1C1]"
)}>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white transition-all",
on ? "left-[26px]" : "left-0.5")} />
<span className={cn("absolute top-1/2 -translate-y-1/2", on ? "left-2" : "right-2 text-[#7F7F7F]")}>
{on ? "开" : "关"}
</span>
</button>
```
### 2. 单选框作为开关(分段式)
- `开 | 关` 两段式:激活段 `--color-primary` 实底白字,未激活段白底灰字 + `--color-border` 边框
- 即 5.9 拼接式「实底选择按钮」的两选项特例
```tsx
<div className="inline-flex rounded overflow-hidden border border-[#DBDBDB]">
<button className={cn("h-7 px-4 text-sm", on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>开</button>
<button className={cn("h-7 px-4 text-sm border-l border-[#DBDBDB]", !on ? "bg-[#0062D9] text-white" : "bg-white text-[#3D3D3D]")}>关</button>
</div>
```
-161
View File
@@ -1,161 +0,0 @@
# 需求文档 · 问答中心左侧栏改造(0616)
> 整理自 2026-06-16 需求草稿。本批需求统一围绕「问答中心」菜单下 5 个页面入口的**左侧侧边栏**进行改造,目标是让每个业务页面的左栏从「通用导航」变为「贴合该业务场景的快捷入口 + 历史记录」。
>
> 菜单结构参考:`frontend-web/src/core/page-layout/sidebar-menu.ts` 中 `qa-center`(问答中心)分组,含 5 个子项,正好对应下文 5 条需求。
## 0. 背景与共性约定
| 需求 | 菜单项 | id | 现有路由 | 对应页面/组件 |
|------|--------|----|----------|--------------|
| 1 | 通用问答 | `general-qa` | `/page/workspace/chats/new` | Workspace Chat + 左栏(`recent-chat-list.tsx` 等) |
| 2 | 多智能体会商 | `multi-agent-roundtable` | `/page/strategy/qa/multi-agent-roundtable` | `roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 3 | zzJC支持 | `qa-zzjc` | `/page/strategy/agents/6bf/chats/new` | `pages/AgentChatPage.tsx` |
| 4 | zzXD应用 | `qa-zzxd` | `/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new` | `pages/AgentChatPage.tsx` |
| 5 | 课题研究 | `qa-research` | `/page/canvas/ai-writing` | `open-canvas/pages/AIWritingPage.tsx` |
**共性约定(适用于全部 5 条,除非单条另有说明):**
- 「更多」一律为可点击入口;点击后跳转到对应的完整列表/管理页(具体目标见各条「待确认」)。
- 列表为空时的兜底:参考各条「现状」中的级联/空态规则;无内容时该区块整体隐藏或显示占位文案(待 UI 设计确认,默认隐藏,与现有 `recent-chat-list.tsx` 一致)。
- 左栏宽度、折叠交互沿用现有侧边栏体系(`SidebarGroup` 系列组件)。
- 所有「智能体」相关数据复用 `core/agents`(`Agent` 类型已含 `is_pinned` / `is_favorite` / `featured_order`,scope 支持 `mine` / `square` / `builtin`)。
---
## 1. 通用问答 — 左侧栏改造
**页面**:`/page/workspace/chats/new`
### 1.1 现状
当前左栏主要展示「最近会话」列表(`recent-chat-list.tsx`,已过滤 `scheduler` / `bootstrap` / `roundtable` 类型线程,置顶会话优先)。
### 1.2 目标布局(自上而下)
左栏从上到下依次为三块:**新建回答按钮 → 常用智能体 → 聊天记录**。
#### ① 顶部:新建回答
- 左栏最顶部放置「**新建回答**」按钮(主操作)。
- 点击 = 开启一次全新的通用问答会话(等价于跳转 `/page/workspace/chats/new`)。
#### ② 常用智能体(最多 3 个 + 更多)
- 展示「常用智能体」卡片/列表项,**最多 3 个**。
- 数据来源采用**级联兜底**逻辑(取到即用,不足再降级):
1. 若用户有**置顶智能体**(`is_pinned = true`)→ 展示置顶的;
2. 否则展示**用户自己的智能体**(`scope = "mine"`);
3. 仍没有 → 展示**默认广场智能体**(`scope = "square"` / `builtin`,即广场默认推荐)。
- 三者按上述优先级取,最终最多取 3 个。
- 下方显示「**更多**」入口。
- 点击某个常用智能体 → 进入该智能体的对话(待确认:是否为该智能体新建会话)。
#### ③ 聊天记录(最多 10 条 + 更多)
- 展示用户的问答聊天记录,**最多 10 条**(沿用现有 `useThreads` + 过滤规则)。
- 下方显示「**更多**」入口。
- 点击「更多」→ **跳转到问答记录页面**(完整聊天记录列表页)。
### 1.3
- 「常用智能体」点击后「打开该智能体主页」
- 「常用智能体」的「更多」跳转目标页 智能体管理 `/page/strategy/agents`。
- 「聊天记录」的「问答记录页面」具体路由http://localhost:5173/#/page/workspace/chats。
- 级联兜底是(置顶组够 3 个就完全不展示我的/广场)如果不够「补足到 3 个
---
## 2. 多智能体会商 — 左侧栏重做
**页面**:`/page/strategy/qa/multi-agent-roundtable`(`RoundtablePlanningPage`)
### 2.1 现状
圆桌会商页已有业务链选择、分析草稿等能力(`roundtable-planning/api/chains.ts`、`hooks/useRoundtableDrafts.ts`)。需求要求**左栏整体重做**。
### 2.2 目标布局(自上而下)
#### ① 顶部:业务链(公共,前 3 个)
- 顶部展示**业务链**,仅取**公共**链条(`listChains("public")`,即所有已发布 `is_public = true` 的链条)。
- 仅展示**前 3 个**。
- 点击某条业务链 → 以该业务链启动一次会商(沿用现有"会商自动串联圆桌"流程,见记忆 `business-chain-mapping`)。
#### ② 下方:分析记录
- 业务链下方展示「**分析记录**」列表。
- 数据来源:圆桌分析草稿/历史(`useRoundtableDrafts` / `listDrafts`)。
- 点击某条分析记录 → 打开对应的历史会商分析。
### 2.3 待确认
- 「业务链」是否需要「更多」入口跳转到业务链条配置页(`/page/strategy/business-chains`)?需求未提及,默认仅展示前 3 个、无更多。 需要更多入口
- 「分析记录」是否需要分页/「更多」?需求未提及,默认展示全部或合理上限 分析记录默认全部吧。
- 公共业务链「前 3 个」的排序口径(创建时间倒序?已有 `listChains` 默认 newest first) 按照现在默认的显示来吧,然后要支持用户单选,如果用户选择后,点击进入多智能体研讨按钮时,直接按照选择的业务链进去执行,如果用户取消勾选,或没有勾选就还是打开弹窗。
---
## 3. zzJC支持 — 左侧栏改造(按类型聚合智能体)
**页面**:`/page/strategy/agents/6bf/chats/new`(当前为单一智能体 `6bf` 的对话页)
### 3.1 现状
当前 zzJC支持直接进入某个固定智能体(id=`6bf`)的对话页(`AgentChatPage`)。
### 3.2 目标布局
- **左栏按「类型」分组**,展示该类型下的智能体列表("类型"待确认,推测为智能体 `tags` / 分类)。
- 智能体分组**下方**展示**聊天记录**。
- 左栏**宽度与通用问答(需求 1)保持一致**("左侧是问答宽")。
### 3.3 Admin 设置入口
- 在 **admin 账号权限下**,页面**右侧顶部显示「设置」图标按钮**(齿轮图标)。
- 点击设置按钮 → 打开设置面板,可对该模块进行配置。
- 非 admin 账号不显示设置按钮(沿用 `sidebar-menu.ts` 中 `adminOnly` 同款角色判断)。
### 3.4 待确认
- 「类型」的确切含义:是智能体的 `tags`,还是 zzJC 业务自定义的一组分类?
- 现 zzJC 入口是单个智能体 `6bf`;改为「按类型展示多个智能体」后,**进入该菜单时默认落地页是什么**(智能体列表?还是仍默认进 `6bf` 对话)?是否需要新建一个 zzJC 聚合页。
- Admin「设置」具体可配置项:配置「该模块纳入哪些类型/哪些智能体」?还是其他(如默认智能体、欢迎语)?需明确设置项清单。
- 「聊天记录」过滤范围:仅 zzJC 相关会话,还是全部会话?
---
## 4. zzXD应用 — 左侧栏改造
**页面**:`/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new`
### 4.1 需求
**与需求 3(zzJC支持)完全一致**:
- 左栏按类型展示该类型下的智能体 + 下方聊天记录;
- 左栏宽度同通用问答;
- admin 账号下右侧顶部显示设置图标按钮,可设置。
### 4.2 待确认
- 同需求 3 全部待确认项。
- zzXD 与 zzJC 是否共用同一套「按类型 + 设置」组件(仅数据源/默认智能体不同),还是各自独立配置?建议**抽象为同一可复用组件**,通过参数区分。
---
## 5. 课题研究 — 左侧栏改造(研究模板 + 笔记本)
**页面**:`/page/canvas/ai-writing`(`AIWritingPage`)
### 5.1 目标布局(自上而下)
#### ① 上半部分:研究模板
- 左栏上半部分展示「**研究模板**」。
- 研究模板 = **原来的「文章类型」**(`open-canvas/hooks/useArticleTypes.ts` 的 `articleTypes`,即 AI 写作表单里的「文章类型」选择项)。
- 点击某个研究模板 → 以该模板/文章类型发起课题研究(沿用现有 `articleType` 设置逻辑)。
#### ② 下半部分:笔记本
- 左栏下半部分展示「**笔记本**」。
- 笔记本内容 = **「简洁模式」左侧菜单下方展示的笔记本内容**(`useStudioNotebooks`,即 `workspace-nav-studio.tsx` 中渲染的 studio notebooks 列表)。
- 点击某个笔记本 → 打开该笔记本(沿用现有 `/page/workspace/studio/notebooks/{id}` 行为,或作为课题研究的素材来源 `materialSource = 'notebook'`,待确认)。
> 备注:草稿原文写作「简介模式」,结合代码应为「**简洁模式**」(`appearance-settings-page.tsx` 中的全局界面模式:全量 / 简洁;简洁模式下笔记本在左栏菜单展示)。
### 5.2 待确认
- 「研究模板」点击行为:仅预填表单 `articleType`,还是直接开始生成?
- 「笔记本」点击行为:跳转笔记本详情页,还是将笔记本设为当前课题研究的素材来源(`materialSource='notebook'`)?
- 上/下两部分是否各自可折叠、是否需要「更多」入口与数量上限?需求未提及。
---
## 6. 整体待确认与建议
1. **复用性**:需求 3 / 4(zzJC / zzXD)布局完全相同,建议实现为同一套「按类型分组 + 聊天记录 + admin 设置」可复用组件,通过配置区分模块。
2. **左栏宽度统一**:需求 1 / 3 / 4 都要求"问答宽",建议统一左栏宽度常量,避免各页面各写一套。
3. **「更多」目标页**:需求 1 涉及「智能体更多」「聊天记录更多→问答记录页」两个跳转目标需先确定路由是否已存在。
4. **Admin 设置项清单**:需求 3 / 4 的设置面板具体能配置什么,需要产品给出字段清单后才能落地。
5. **入口语义变更**:需求 3 / 4 把原「单一智能体对话」入口改为「按类型聚合」,菜单 `path` 与默认落地页可能需要调整,需同步确认。
-148
View File
@@ -1,148 +0,0 @@
# 定时任务 — 模板填充「源数据抽取」手工测试清单
> 覆盖功能:定时任务「模板数据转换器」内置 agent —— 把用户传入的源数据转换成 HTML
> 模板的 `DATA` 结构并填充生成可预览页面。**重点测「源数据抽取」**:源可能是
> ① 纯 JSON、② 含 JSON 的文本、③ 纯文本,三种形态都要正确处理。
>
> 测试环境:后端 `http://localhost:8001`(`make dev`),前端 `http://localhost:5174`(`pnpm dev`)。
> 相关代码:`deerflow/runtime/scheduler/template_fill.py`(`extract_embedded_json`)、
> `service.py`(`_run_template_fill_attempt` / `_build_conversion_prompt`)、
> `pages/ScheduledTasksPage.tsx`。
---
## 前置 — 进入表单
| # | 步骤 | 预期结果 |
|---|------|----------|
| 0.1 | 任务管理 → 新建定时任务 | 弹出「新建定时任务」对话框 |
| 0.2 | 「执行智能体」下拉选择 **模板数据转换器** | 表单出现「HTML 模板」下拉;「必须生成 HTML/Markdown」两个开关隐藏 |
| 0.3 | 「HTML 模板」下拉选择 **no.1** | 下方出现「上传 .json」按钮 + 源数据文本框;说明含「未提供源 JSON 时,将直接展示该模板的默认数据」|
---
## T1 — 源形态①:纯 JSON
| # | 步骤 / 输入 | 预期结果 |
|---|------|----------|
| 1.1 | 在源数据框粘贴纯 JSON(示例见下「样例 A」) | 文本框正常接收,无报错 |
| 1.2 | 保存任务 → 「立即执行」 | 执行记录新增一条,状态最终 succeeded |
| 1.3 | 打开执行记录 → HTML 预览 | 渲染出 no.1 大屏,**总览指标/各列表数据来自粘贴的 JSON**(而非模板默认值)|
| 1.4 | 执行记录正文 | 文案为「已根据源数据填充模板「no.1」生成页面」|
## T2 — 源形态②:含 JSON 的文本(抽取重点)
| # | 步骤 / 输入 | 预期结果 |
|---|------|----------|
| 2.1 | 粘贴「样例 B」(前后是中文说明,中间夹一段 JSON) | 文本框正常接收 |
| 2.2 | 保存 → 立即执行 → 查看 HTML | 页面数据以**夹带的 JSON 为准**填充;正文里的补充说明(如「重点突出 X」)被一并参考 |
| 2.3 | 验证抽取正确性:JSON 字符串值里含 `}`(如 `"备注": "进度 80%}尚未完成"`) | 不会被误判为 JSON 块提前结束,整段 JSON 被完整抽取 |
## T3 — 源形态③:纯文本(无 JSON)
| # | 步骤 / 输入 | 预期结果 |
|---|------|----------|
| 3.1 | 粘贴「样例 C」(一段纯中文描述,无任何 JSON) | 文本框正常接收 |
| 3.2 | 保存 → 立即执行 → 查看 HTML | 智能体**理解文本语义**后据实填充模板,产出完整可预览页面(数据可能较稀疏但结构完整)|
| 3.3 | 不应报「未返回 JSON」类硬失败(除非模型彻底失败重试耗尽)| 状态 succeeded |
## T4 — 空源 → 模板默认数据
| # | 步骤 | 预期结果 |
|---|------|----------|
| 4.1 | 源数据框留空,保存 → 立即执行 | **不调用模型**,直接用 no.1 模板自带的默认数据生成 |
| 4.2 | 执行记录正文 | 「未提供源数据,已用模板「no.1」的默认数据生成页面」|
| 4.3 | HTML 预览 | 展示模板内置的默认大屏 |
## T5 — 上传 .json 文件
| # | 步骤 | 预期结果 |
|---|------|----------|
| 5.1 | 点击「上传 .json 文件」,选一个合法 `.json` | 文件内容被读入源数据文本框 |
| 5.2 | 上传一个非法 JSON 内容的文件 | toast 提示「文件不是合法 JSON,已填入文本框请检查」,内容仍填入(可手改)|
| 5.3 | 连续上传同一个文件两次 | 第二次仍能触发读取(input 已被重置 value)|
## T6 — no.2(只展示模板)
| # | 步骤 | 预期结果 |
|---|------|----------|
| 6.1 | 「HTML 模板」下拉选 **no.2** | **隐藏**上传按钮与源数据文本框;显示「该模板暂为直接展示已有数据,无需填写」|
| 6.2 | 即使之前填过源数据,保存 → 立即执行 | 忽略源数据,**原样输出** no.2 现有页面 |
| 6.3 | 执行记录正文 | 「已展示模板「no.2」的现有数据」|
## T7 — 编辑回显
| # | 步骤 | 预期结果 |
|---|------|----------|
| 7.1 | 对已保存的模板任务点「编辑」 | 「执行智能体」「HTML 模板」「源数据」均正确回显 |
| 7.2 | 把模板从 no.1 改成 no.2 再保存 | 切到 no.2 时源数据区即时隐藏;保存后执行走只展示分支 |
---
## 样例数据(可直接复制粘贴到源数据框)
### 样例 A — 纯 JSON
```json
{
"overview": {
"核心指标": [
{ "名称": "综合区域", "数值": 7, "单位": "区", "说明": "测试数据" },
{ "名称": "组织与单位", "数值": 188, "单位": "个", "说明": "测试数据" }
]
},
"regions": [
{ "name": "测试区域甲", "scope": "本岛西岸" }
],
"equipment": [
{ "name_zh": "测试装备X", "category": "无人系统", "status": "列装" }
]
}
```
> 预期:总览第一项「综合区域」显示 **7**,「组织与单位」显示 **188**,区域/装备出现「测试区域甲」「测试装备X」。
### 样例 B — 含 JSON 的文本
```
这是本月台海态势数据,请据此填充大屏,重点突出离岛防卫:
{"overview": {"核心指标": [{"名称": "综合区域", "数值": 9, "单位": "区", "说明": "含离岛"}]}, "regions": [{"name": "离岛防区", "scope": "外岛", "备注": "进度 80%}尚未完成"}]}
以上数据来自 6 月例会,务必准确,不要臆造未提供的字段。
```
> 预期:① 数据以中间 JSON 为准(综合区域=9、出现「离岛防区」);② 字符串里的 `}`(`进度 80%}`)不破坏抽取;③ 前后的「重点突出离岛防卫」「务必准确」作为补充说明被模型参考。
### 样例 C — 纯文本
```
请整理一份台湾滨海防卫概览:覆盖本岛沿岸 9 个综合区域,组织与单位约 280 个,
装备条目近百项(含无人系统、防空、海军平台等类别),并列出若干关键节点与近期演习。
数据以离线知识为准,没有的字段留空即可。
```
> 预期:智能体理解语义后据实填充,产出结构完整的页面(具体数值取决于模型,可较稀疏),不报硬失败。
---
## 后端单元测试(自动化补充)
```bash
# 在 offline-backend-20260512/backend/ 下运行
PYTHONPATH=. uv run --no-sync pytest tests/test_template_fill.py -v # 引擎:抽取/校验/填充/注册表
PYTHONPATH=. uv run --no-sync pytest tests/test_template_fill_attempt.py -v # 调度器执行分支:三形态/默认/只展示/重试
PYTHONPATH=. uv run --no-sync pytest tests/test_template_builder_seed.py -v # 内置 agent 种子
```
抽取相关关键用例:
- `test_extract_embedded_json_pure_object` / `_pure_array` —— 纯 JSON。
- `test_extract_embedded_json_text_with_json` / `_fenced` —— 含 JSON 的文本 / ```json 围栏。
- `test_extract_embedded_json_plain_text_returns_none` —— 纯文本返回 None。
- `test_extract_embedded_json_ignores_braces_in_strings` —— 引号内 `}` 不误判。
- `test_text_with_embedded_json_surfaced_in_prompt` —— 转换 prompt 单独标注「识别到的结构化数据」。
- `test_plain_text_source_converts` —— 纯文本仍照常转换。
- `test_empty_source_uses_template_default` —— 空源用模板默认数据。
- `test_display_only_template_renders_as_is` —— no.2 原样输出。
三个文件应全部通过(0 failed)。
+3 -5
View File
@@ -13,11 +13,9 @@
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
<script src="/runtime-config.js" data-runtime-config></script>
</head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CM 助手</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
+10140 -10492
View File
File diff suppressed because it is too large. Load diff
+2 -15
View File
@@ -7,13 +7,9 @@
"dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
"build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test",
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test:maf": "vitest run --config vitest.maf.config.ts",
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@@ -26,7 +22,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/noto-sans-sc": "^5.2.9",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
@@ -60,7 +55,6 @@
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"axios": "1.16.1",
"best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -69,7 +63,6 @@
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
@@ -78,7 +71,6 @@
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"mermaid": "^11.15.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
@@ -107,23 +99,18 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^30.0.1",
"lightningcss": "^1.30.2",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.2",
"vite": "^6.0.0",
"vite-plugin-source-identifier": "^1.1.2",
"vitest": "^4.1.10"
"vite-plugin-source-identifier": "^1.1.2"
},
"packageManager": "pnpm@10.26.2"
}
+5 -11
View File
@@ -1,11 +1,5 @@
import tailwindcss from "@tailwindcss/postcss";
import legacyColorFallback from "./scripts/postcss-legacy-color-fallback.mjs";
import lightningCssLegacyChrome from "./scripts/postcss-lightningcss-chrome109.mjs";
export default {
plugins: [
tailwindcss(),
legacyColorFallback(),
lightningCssLegacyChrome(),
],
};
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

-29
View File
@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="智能助手中心">
<defs>
<!-- 品牌玫红渐变填充的圆角磁贴 -->
<linearGradient id="tile" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
<!-- 气泡内 AI 闪光星:玫红渐变,与白色气泡形成对比 -->
<linearGradient id="spark" x1="24" y1="18" x2="40" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
</defs>
<!-- 磁贴背景 -->
<rect width="64" height="64" rx="15" fill="url(#tile)"/>
<!-- 辅助小闪光,点出「多种智能工具」 -->
<path fill="#FFFFFF" fill-opacity="0.85"
d="M47 43 Q47.6 46.4 50.5 47 Q47.6 47.6 47 51 Q46.4 47.6 43.5 47 Q46.4 46.4 47 43 Z"/>
<!-- 助手对话气泡(白色)+ 尾巴 -->
<rect x="12" y="13" width="40" height="28" rx="10" fill="#FFFFFF"/>
<path fill="#FFFFFF" d="M21 40 H31 L19 49 Z"/>
<!-- 气泡内的 AI 闪光星 -->
<path fill="url(#spark)"
d="M32 17.5 Q33.2 25.8 41.5 27 Q33.2 28.2 32 36.5 Q30.8 28.2 22.5 27 Q30.8 25.8 32 17.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-135
View File
@@ -1,135 +0,0 @@
// 前端运行时配置文件
//
// 说明:
// 1. 本文件会在打包时原样复制到 dist/runtime-config.js。
// 2. 应用启动时会优先读取 window.__CM_RUNTIME_CONFIG__ 里的配置。
// 3. 部署后如果需要切换接口地址,直接修改 dist/runtime-config.js 即可,不需要重新 pnpm build。
// 4. 这里的默认值来自 .env.network;如某个环境不用某项,可以留空字符串。
// 5. 布尔开关也用字符串形式填写:"true" / "false"。
window.__CM_RUNTIME_CONFIG__ = {
// 品牌图片相对路径会以 runtime-config.js 所在目录为基准解析。
// Nginx 以子路径部署时不需要修改前端图片路径。
// 打包后可直接替换 dist/brand-assets/ 内的同名 PNG。
// 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。
// 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。
VITE_WELCOME_LOGO_VISIBLE: "false",
// 深色主题顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。
VITE_DARK_LOGO_POSITION: "center",
// 红白主题的顶栏系统 Logo 图片地址。
VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png",
// 浅蓝主题的顶栏系统 Logo 图片地址。
VITE_BRAND_LOGO_LIGHTBLUE_URL: "brand-assets/brand-logo-lightblue.png",
// 绿色主题可单独替换 Logo;默认复用浅色 Logo。
VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png",
// 深色主题的顶栏系统 Logo 图片地址。
VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png",
// 红白主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
VITE_BRAND_WELCOME_LIGHT_URL: "",
// 浅蓝主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
VITE_BRAND_WELCOME_LIGHTBLUE_URL: "",
VITE_BRAND_WELCOME_LIGHTGREEN_URL: "",
// 深色主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
VITE_ROUTE_LOGIN_PASSWORD: "",
// 轻应用「新建笔记」系列接口地址。
VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000",
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
// 多轮检索/生成接口。
VITE_AIPI_GENERATE_CHAT_URL: "https://ch1.b.uat.4.cn/jialai_b1_chat",
// 任务问答接口。
VITE_AIPI_TASK_CHAT_URL: "https://ch1.b.uat.4.cn/qq_b1_chat",
// B 系统知识库前端地址:用于打开知识详情、原文详情等。
VITE_AIPI_KNOWLEDGE_URL: "https://chlib.b.uat.4.cn/knowlibrary",
// B1 COH 系统地址。
VITE_AIPI_B1COH_URL: "https://ch1.b.uat.4.cn/web",
// B2 行为体/网络资源系统地址。
VITE_AIPI_B2ACTOR_URL: "https://ty.b.uat.4.cn",
// B2 网络 XWT iframe 路径。
VITE_AIPI_B2ACTOR_XWT_PATH: "/fromB/toXWT?isFrame=1",
// 知识库 iframe 地址换取接口。
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL: "https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl",
// 知识库 iframe 皮肤参数。
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN: "red",
// MopeChat 接口地址。
VITE_AIPI_MOPECHAT_URL: "https://ch1.b.uat.4.cn/likun_b1_mope",
// 工作室/空间后端接口:空间列表、空间详情、素材上传等使用。
VITE_AIPI_BACKENP_URL: "http://47.88.25.99:8000",
// 工作室/空间后端别名:兼容旧配置命名,通常与 VITE_AIPI_BACKENP_URL 保持一致。
VITE_NOTEBOOK_API_BASE_URL: "http://47.88.25.99:8000",
// 工作室/空间前端地址:用于 auth/callback、新建对话等跳转。
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
// B 系统 knowes 根地址。
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
// MaxKey 鉴权地址:onlineTicket 换 token 使用。
VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn",
// PG 中任务 iframe 地址。
VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn",
// XK 中任务 iframe 地址。
VITE_AIPI_XKLINKTO_URL: "https://kz.b.uat.4.cn",
// 是否使用真实任务看板数据:true=真实接口;false=本地 mock。
VITE_USE_REAL_DATA: "true",
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
VITE_MOCK_COP_TASK_DETAIL: "false",
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
// 默认关闭,避免演示历史记录出现在正常生产账号中。
VITE_POSITION_ROUNDTABLE_DEMO_MODE: "false",
// 纯前端岗位分析演示:跳过登录,直接进入岗位分析页,并禁止所有后台请求。
// 仅用于离线展台/演示环境,生产环境必须保持 false。
VITE_POSITION_ROUNDTABLE_STANDALONE_MODE: "false",
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
// 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "false",
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
};
@@ -1,109 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import postcss from "postcss";
const unsupportedColor = /(?:color-mix|oklch|oklab|lab|lch)\(/i;
const assetsDir = path.resolve("dist/assets");
if (!fs.existsSync(assetsDir)) {
throw new Error("dist/assets 不存在,请先运行 pnpm build");
}
const cssFiles = fs
.readdirSync(assetsDir)
.filter((file) => file.endsWith(".css"));
if (cssFiles.length === 0) {
throw new Error("dist/assets 中没有 CSS 构建产物");
}
const failures = [];
let modernDeclarations = 0;
const requiredLegacyFallbacks = [
{
file: "src/components/ui/textarea.tsx",
pattern: /rows=\{rows\s*\?\?\s*3\}/,
description: "Textarea needs a native rows fallback",
},
{
file: "src/components/ai-elements/prompt-input.tsx",
pattern: /<InputGroup className="h-auto min-h-16">/,
description: "Prompt textarea must not rely on :has() for its parent height",
},
];
for (const file of cssFiles) {
const root = postcss.parse(
fs.readFileSync(path.join(assetsDir, file), "utf8"),
{ from: file },
);
root.walkDecls((declaration) => {
if (!unsupportedColor.test(declaration.value)) return;
modernDeclarations += 1;
let ancestor = declaration.parent;
while (ancestor) {
if (
ancestor.type === "atrule" &&
ancestor.name === "supports" &&
unsupportedColor.test(ancestor.params)
) {
return;
}
ancestor = ancestor.parent;
}
let previous = declaration.prev();
while (previous?.type === "comment") previous = previous.prev();
const hasSafeFallback =
!declaration.prop.startsWith("--") &&
previous?.type === "decl" &&
previous.prop === declaration.prop &&
!unsupportedColor.test(previous.value);
if (!hasSafeFallback) {
failures.push(
`${file}:${declaration.source?.start?.line ?? "?"} ${declaration.prop}: ${declaration.value}`,
);
}
});
}
function walk(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(directory, entry.name);
return entry.isDirectory() ? walk(fullPath) : [fullPath];
});
}
for (const file of walk(path.resolve("src"))) {
if (!/\.[jt]sx?$/.test(file)) continue;
const source = fs.readFileSync(file, "utf8");
if (unsupportedColor.test(source)) {
failures.push(
`${path.relative(process.cwd(), file)} contains a runtime-only modern color function`,
);
}
}
for (const fallback of requiredLegacyFallbacks) {
const source = fs.readFileSync(path.resolve(fallback.file), "utf8");
if (!fallback.pattern.test(source)) {
failures.push(`${fallback.file}: ${fallback.description}`);
}
}
if (failures.length > 0) {
console.error("Legacy Chrome CSS compatibility check failed:\n");
console.error(failures.slice(0, 50).join("\n"));
if (failures.length > 50) {
console.error(`... and ${failures.length - 50} more`);
}
process.exit(1);
}
console.log(
`Legacy Chrome (100+) CSS compatibility check passed (${modernDeclarations} guarded modern declarations).`,
);
@@ -1,70 +0,0 @@
/**
* Tailwind 4 gracefully falls back from color-mix() to the base semantic
* color, but that turns utilities such as bg-primary/5 into an opaque fill on
* Chrome 109. Replace that fallback with an sRGB-channel based alpha color.
* The modern color-mix() declaration remains in its @supports block.
*/
const LEGACY_RGB_TOKENS = new Set([
"accent",
"background",
"border",
"brand-200",
"card",
"destructive",
"foreground",
"input",
"muted",
"muted-foreground",
"primary",
"primary-foreground",
"ring",
"secondary",
"sidebar-accent",
"sidebar-border",
"sidebar-foreground",
"workspace-brand",
]);
const TRANSPARENT_MIX_RE =
/color-mix\(\s*in\s+[\w-]+\s*,\s*var\(--([\w-]+)\)\s+([\d.]+)%\s*,\s*transparent\s*\)/i;
function legacyAlphaColor(value) {
const match = value.match(TRANSPARENT_MIX_RE);
if (!match || !LEGACY_RGB_TOKENS.has(match[1])) return null;
const [, token, percentage] = match;
return `rgb(var(--legacy-${token}-rgb) / ${percentage}%)`;
}
export default function legacyColorFallback() {
return {
postcssPlugin: "postcss-legacy-color-fallback",
OnceExit(root) {
root.walkAtRules("supports", (atRule) => {
if (!atRule.params.includes("color-mix")) return;
atRule.walkDecls((modernDeclaration) => {
const fallbackValue = legacyAlphaColor(modernDeclaration.value);
if (!fallbackValue) return;
// Tailwind emits the old-browser declaration immediately before
// the nested @supports block, in the same utility/variant rule.
let fallbackDeclaration = atRule.prev();
while (fallbackDeclaration?.type === "comment") {
fallbackDeclaration = fallbackDeclaration.prev();
}
if (
fallbackDeclaration?.type === "decl" &&
fallbackDeclaration.prop === modernDeclaration.prop
) {
fallbackDeclaration.value = fallbackValue;
}
});
});
},
};
}
legacyColorFallback.postcss = true;
@@ -1,33 +0,0 @@
import postcss from "postcss";
import { transform } from "lightningcss";
const LEGACY_CHROME = 100 << 16;
/**
* Keep Vite's PostCSS pipeline (required by Tailwind 4), then compile the
* resulting CSS to Chrome 100-compatible syntax for both dev and production.
*/
export default function lightningCssLegacyChrome() {
return {
postcssPlugin: "postcss-lightningcss-legacy-chrome",
OnceExit(root, { result }) {
const filename = root.source?.input.file ?? result.opts.from ?? "style.css";
const output = transform({
filename,
code: Buffer.from(root.toString()),
targets: { chrome: LEGACY_CHROME },
minify: false,
});
const compiled = postcss.parse(output.code.toString(), { from: filename });
root.removeAll();
root.append(compiled.nodes);
for (const warning of output.warnings) {
result.warn(warning.message, { plugin: "lightningcss" });
}
},
};
}
lightningCssLegacyChrome.postcss = true;
+13 -128
View File
@@ -1,112 +1,47 @@
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
resolveEmbedRoundtableTheme,
resolveEmbedTheme,
} from "@/core/embed/params";
import { usePublicEmbedTheme } from "@/core/embed/background";
import { readWujieProps } from "@/core/embed/wujie";
import {
resolveForcedEmbedTheme,
syncEmbedSessionFromSearch,
} from "@/core/embed/embed-session";
import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import {
isPositionRoundtableStandaloneMode,
POSITION_ROUNDTABLE_ROUTE,
} from "@/position-roundtable/demo/position-roundtable-demo-config";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const publicEmbedTheme = usePublicEmbedTheme();
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
useEffect(() => {
syncEmbedSessionFromSearch(location.search);
}, [location.search]);
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
if (positionStandaloneMode) return;
void fetchYLogConfig();
}, [positionStandaloneMode]);
const forcedTheme = useMemo(() => {
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
const embedTheme = resolveForcedEmbedTheme(location.search);
if (embedTheme) return embedTheme;
const routeTheme = resolveForcedThemeForPath(
location.pathname,
location.search,
);
if (routeTheme) return routeTheme;
if (location.pathname === "/embed/roundtable") {
const wp = readWujieProps();
const raw =
(typeof wp.theme === "string" && wp.theme.trim()) ||
new URLSearchParams(location.search).get("theme")?.trim() ||
"dark";
return resolveEmbedRoundtableTheme(raw);
}
if (location.pathname.startsWith("/embed/chats")) {
const raw = new URLSearchParams(location.search).get("theme")?.trim();
return raw ? resolveEmbedTheme(raw) : undefined;
}
if (PUBLIC_EMBED_PREFIXES.some((p) => location.pathname.startsWith(p))) {
return publicEmbedTheme;
}
return undefined;
}, [location.pathname, location.search, publicEmbedTheme]);
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
return (
<NextThemesProvider
attribute="class"
storageKey="strategy-theme"
defaultTheme="light"
themes={["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"]}
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
>
{/* QueryClientProvider must wrap ThemeProvider because the custom
ThemeProvider reads the admin-configured default via useQuery. */}
<QueryClientProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
{!positionStandaloneMode && <AuthSyncWatcher />}
{!positionStandaloneMode && <DesensitizeWordsLoader />}
<AuthSyncWatcher />
<FontScaleApplier />
{children}
</ChatProvider>
@@ -117,68 +52,23 @@ function AppProviders({ children }: { children: React.ReactNode }) {
);
}
/**
* 根路由 `/` → `/login` 重定向,**保留 query string**。
* 外部系统的任务深链落在 `#/?taskId=...&goPath=...`,裸 `<Navigate to="/login">` 会丢掉
* query,导致 LoginPage 读不到参数;这里把 search 一并带过去。
*/
function RootRedirect() {
const { search } = useLocation();
if (isPositionRoundtableStandaloneMode()) {
return <Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />;
}
return <Navigate to={`/login${search}`} replace />;
}
export function App() {
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
const standaloneLoginRedirect = (
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
);
const pageElement = positionStandaloneMode ? (
<ChatRuntime>
<PositionRoundtablePage compactChrome />
</ChatRuntime>
) : (
<PageRoutes />
);
return (
<HashRouter>
<AppProviders>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route
path="/login/:username"
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
/>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
@@ -187,12 +77,7 @@ export function App() {
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={pageElement} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AppProviders>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="62" viewBox="0 0 1920 62"><defs><style>.a{fill:url(#a);}.b{fill:rgba(0,98,217,0.1);}.c{fill:#fff;}.d{fill:rgba(0,98,217,0.2);}.e{filter:url(#h);}.f{filter:url(#e);}.g{filter:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0b3776"/><stop offset="1" stop-color="#0c1935"/></linearGradient><filter id="b"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="c"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="d"/><feComposite operator="out" in="SourceGraphic" in2="c"/><feComposite operator="in" in="d"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="e"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="f"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="g"/><feComposite operator="out" in="SourceGraphic" in2="f"/><feComposite operator="in" in="g"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="h"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="i"/><feFlood flood-color="#0062d9" flood-opacity="0.2" result="j"/><feComposite operator="out" in="SourceGraphic" in2="i"/><feComposite operator="in" in="j"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(-2009 -3361)"><rect class="a" width="1920" height="62" transform="translate(2009 3361)"/><g data-type="innerShadowGroup"><path class="b" d="M0,0H318L268,62H0Z" transform="translate(2009 3361)"/><g class="g" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H318L268,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="b" d="M0,0H310L260,62H0Z" transform="translate(2009 3361)"/><g class="f" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H310L260,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="d" d="M0,0H302L252,62H0Z" transform="translate(2009 3361)"/><g class="e" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H302L252,62H0Z"/></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 1920 1080"><image width="1920" height="1080" xlink:href="data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMwaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA5LjEtYzAwMSA3OS4xNDYyODk5Nzc3LCAyMDIzLzA2LzI1LTIzOjU3OjE0ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgMjUuMCAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OUVERkMwNjI0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OUVERkMwNjM0Q0RCMTFGMDg5MTZCMkY2RDg1RkVEQkUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5RURGQzA2MDRDREIxMUYwODkxNkIyRjZEODVGRURCRSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5RURGQzA2MTRDREIxMUYwODkxNkIyRjZEODVGRURCRSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/uAA5BZG9iZQBkwAAAAAH/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwODxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx8BBwcHDQwNGBAQGBoVERUaHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fH//AABEIBaAKAAMBEQACEQEDEQH/xACVAAEBAQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgBAQEBAQEBAAAAAAAAAAAAAAABAgMEBRAAAQMDAwIEBAYCAgICAAENAQARITFBAlFhEnEDgZGhIrHBMhPw0eFCUgTxBWIjcjMUBoKSokOywtIkFeJTFmMHEQEBAQEAAwEBAAIDAQEAAAAAARECITEDEkFhBFFxEyIy/9oADAMBAAIRAxEAPwD/ADJ3O73PuZjnk3I1J1XqcWfu91/ry8ygfd7n88urlA+73W+vJ+pQT7nd/nlG5QX7vdE88tpKCDu9z+WXmUFPd7tOeUblA+73f5nzKB97ufyy8ygfe7teeXmVA+73J9+TdSgfe7n88m6lBPu92nLLzKov3e4R9eXVygn3e5/PLzKB93uH9+XmUD7vd/nl5lA+73a88o3KB93u/wA8vMoq/d7rtzy0qUD7vdvnkPEoh93ufzy8yig7vdrzPmUQ+73K88vNA+93P5ZeZRT7vcB+vLzKiH3e4w9+XmUU+73P55HxKIfd7tTkfMoH3e6/15eZQPu93+eXmUD7vdec8vMoH3e7/LLq5QT7nd/nl5lFX73dpyy8ygHu93+eW0lUPu90/vPmUQPd7v8APLzKB93u/wAz5lA+73a88vMop97uv9eXmUD7vd/nl5lED3e6z88tpKB93ufzy8ygfd7v88vMoL93ut9WUblBPudz+eXmUF+53f55eaB93ufzy6uUA93uv9eXmVQ+73P55eZQPu93+ZPiUAd3uP8AXl5lA+73f55eZQPvd1/rPmURfu93+eXmUE+73f55eZVF+73P55P1KB93u/yy8ygfd7rPzPmUA97u/wAz5lUPu9yvM+ZQPu9xvryHiUAd3ufzPiSgfd7leeXVygv3e7/M9HKCfd7v8svMoL93u/zy8yqIO73f5HzKC/e7rPzy8ygfd7ls8vMoh97ufzy2koH3O7/M+ZQPu9z+eXmUU+73P55dHKB93uU55eZQPu9wfvy8ygn3e7/PJupQX7vd/nk3UoH3e7/PLzKQfs/9f/tv6eX+qyJ7gxyxxIyxL1ZvVeueXyPp8+p2/HZ97uHPIjPJiYkrzd5vh9bn0n3e7/PLq5WVT7vd/nl5lA+73H+vLzKgfd7l88vMoH3e5/PLzKAO73bZZeZQD3O5bPI+JRT7vc/nl5lBPu9x/ry8yoL93uT7svMoIe73K88vMoH3e7/PLzKC/d7n88m1coH3e40Z5N1KIfd7n88vMoA7vc/nl5lFPu9yvPLzKgn3e5/PLzKoDu93+eXmUD7vdf68vMoH3e7/ADy8ygfd7v8AM+ZUD7vd/nk/UoIe73R+/J+pQX7vcpzy8ygfd7leeXmUD7vd/nkPEoB73c/nl5lA+73f55eZQPu92+eXmVA+73f55eZVD7vctmfMoJ93umeWXmUD7vdb68vMqB93u/zPmVRfu93+eQ8Sgfc7tOeXmUD7vc/mT4lA+73P55eZQT7nc/nl5lBfu93+Z8ygfd7p/efMoH3e7/LLzKgfd7lOeXmVQ+73f55eZQPu9z+eXmUD7vd/mfMoH3e7/PLzKB93ut9eT9Sgfd7v88vMoH3e431k+JQPu9z+eXmUF+73f55eZVD7nc/nl5lBPud3+eXmVBfu9z+R8yqh93u/zy8ygfd7t88vMoq/d7jkc8tpKCHu9z+eXmUD7vcb68vMoH3e6f35eZQPu9z+eXmUD7nddueRGjlA+53R+8t1KB93uu/PLzKC/e7o/fl5lUT7vdj35dHKAO53W+vLzKCnu9xvqy8ygv3e9/PLzKIfd7rfXk3UoH3O649x8ygfd7pP15eZQPu9yPefMwgfd7kjlkPEop93u/yy8yqh93uH9+XmUD7vcMc8urlAHd7n88vMpA+73f5ZeZVD7vdvlk53KAe73f5ZeZUD7nd/nk3UoJ93ufyy8ygfd7l88vMoOv8AV/uZdnv493PlmMX9pyIdwyJZsTu/2Mu538+5iTjjkTkMXJZMWMfe7teeU7lAHd7v88n6lA+73KjPLzKo32v7f9jtl+33csTsSkqWad3+3/Y7uXLudzLI6klW3STGD3e5/M+ZRU+73Qfry8yoB7vcvnl5lA+73THPLaSgfc7388o3QB3e4/15eZQPu9z+eXRygfd7n88vMoi/d7v8z5lFT7nc/nlvJQUd3uV5ZdHKIn3e40Z5byUF+73R+/LzKB97ufyy8ygn3O5/PLzKB93u/wAzTUoL9zufzPmUD7vc/mW6lA+73G+sv1KB93uU55RuVRR3u7fPKdygfd7grnltJQD3O4R9eXRygfd7k+8nxKofc7p/fltJQPu91/rPmYQD3e7/ADy8yiH3e6G9+T9Sgfd7n88vMqi/c7p/efMoJ9zu/wA8vMoL93uP9WXgSgv3e5fPLzKofc7v/wDcy8ymCDu9z+eT9Sgfc7v88tpKgp7ncf68urlBPu9w/uy8ygfd7n8st5KB93ufyy8ygfd7n88p3Kgp7vdf68n6lUPud3+eXVyiB7ndH78vMoJ93un9+QazlBfu9yvLLzKAO73p958ygfc7v88t5KYan3e7QZ5P1KC/d7ls8t5KCfd7k+8kdSgfd7jfXkfEqB93vWzy8SVRfvd1m5mdygn3O6I55eZUD73ceMy3UoH3e68ZZOdyqL9zu3yy2koIO73XnM+ZUFHe7xLcsnO5VD7vdEc8n6qAO53ZbPI+JTDUHe7gB9+XmUgDud2+WWgkpgfdz4znl5lBB3e7Tkd5KBj3O4x9xi7lSQqjvd3+eQ8Sgfd7teWTauUDn3WnPKNymGh7vcrzyO7mqofc7onmZ3Kgfd7wD8y4oHKB9zumueXmUU+73T+7JjdyiB7ndY+8ltygfc7rPyym7lFT7ndb68n6lTBfu9z+eR8SqiHu9wBxnlpUqKDud0//AKQ+ZQPu9wge7LzKoDu9z+eTdSoH3e7/ADy8ygfd7teR8ygn3e67c8iTSSin3u431FxUOVBD3e63/sy1qUF+73D+8v1KBj3e7Puy8yqH3O67cj1coJ93u2zLdSgp7ubfXl5lBPu93+eRrcqCnud2pyPmUEHd7g/fl5lA+73ZHPLq5hBfu9z+eQbcqCc+88ZF+pQU9zuGeeT9UE+93Kc8h0KKo73c/nl5lED3e63/ALMtalUPu93+eXRyoJ93u/zIOjlFX7vcYe8+ZQPu94zzy6uUEPc7gH15TuWUA93u/wAyB1KCfc7j/WX6lBfu93+WQaklAPd7tszO5QPu9y+WR8Sgg7vd/nl4koL93uuRzMblA+73Sfryncqh93uH9+U7lA+53P55eZQT7ndvnkT1KB93u055dXKgfd7v8yAayVQ+73Kcy2rlBfu9yTzy8ygn3e659+T3YlQPu912OeT6uUwPudz+WXmgfd7rxnk3UoIO93aDPLq5QPu9Line truncated

Before

Width:  |  Height:  |  Size: 431 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="152" height="42.359" viewBox="0 0 152 42.359"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 536 150"><image width="536" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAACWCAYAAACcofGRAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnVmSJLmSXTMiMiKzqt7A12S3cAcU4Sa4Cn7zj39cA4WbpEjvgMIm+/ENNWRmDNSrgMLV4BgUk7l5pHtJlnvYAIPBBj129QJ29+H2ubXAshZ4u1tW9F4F//cP++/D/7zANvdqz5Xb+Y8f3lYWnyz7f1xgm9N38m7/dpu+D7cCj9gC+988j9gKtzpNaoErBYo9IOIGDZPOsUnF7AEjVwsfN+CYdJZ998XcAOO7PwVGGuCKgGIVROwJDv/7pmzw2fpPO6oGq0DkquDjBhwjd8nved0bYHzPR79r368AKmbCxCqAuMFC19nXvdIqKJkJIFcBHTfY6D4Hv8MVb4DxHR70tl0+MFDMAomZEHEDh7bT62hLzwSRWfBxaPC4AcfRTuEj1ecGGEc6GoeqyzsHixlQcYOJQ52xyyozAzpmwMYNNJYd4lvBa1rgBhhr2vVKSz0gVIyqFCMg8R4A4j8c3Lfxzzv6KVZdlSMAMgoeh4SOm6qx6lS7tnJvgHFtR2xqfQ8EFL0gMQIQ0pZHBImjg8HU83BCYUcElRHwkCbpBZBDgccNOCac4VdZxA0wrvKwjVb6AGDRAxQjMHFpiNgbGP58cOUiPoX/tLOScWkgGYGPHug4BHDcQGP0zn1t698A49qOWFd9rxAoemHiEiAxGx6uDQ66zskFK82GlEtASC94tELHDTgWnIC3IuMWuAHGuz4nvhOw2BsqZgHFDST2ufpmgcfewNEDGzfQ2Oecum3F1AI3wDA107UtdEGwaE19tCgVe4JED0TcgOHaLpR0fXuAZE/4aAGPqwKOWwrlfVxAp724Aca7OKIXAoprhYkeeMB5cg0A8e8vdC7Muo7+1xUEmR4AQfushJB3CR1XcC7MOu/faTk3wLjqA3uBYNICFVZ1YqUy0QoTl4aIaweES19PlwaUVvg4AnS0qBwX8W7cQOPSl1Xv9m+A0dtyF1tvZ6i4FqBoAYm9IWI2NPz17vu8bv/wNvetn3vDSAt8rAAPq8pxaOC4wcbFQk/Hhr/PG1VHQx1jlR3h4hrA4ohQMQsmvleI6L3QZsHHXtBxSdi4etC4QUbvZbL3ejfA2LvFm7d3xVAxM/VhhYmV6sQIPNyAofnMX7rCCJCshBAreMxUOCzAcVhV4wYbS6+TwcJvgDHYgGtW3wkqrCrF3l4KC0ysAIlegLhWePh5p/PspysNAr0QsgJALOAxCzoswIEbnxU6dvNtXOl5tiaIHKLUG2Ac4jBIJXa44VugYk+guARMtIDEpeFhLwg41HXQUJlLw0sLhMwGj72gYyZw7AIbN9BouIKWLnoDjKXN21L4FcHFjNTH3mBxdKg4Akj8wyTz6L9ONmO2XEay7CXA41KwcSTQsKgaN8joOaOvcp0bYFz0sC2GCotagf2vKRZ7AMWslIcVJPZSJmaCwywAuOg5P2HjMwFmLxCxwscslaMGHTPSKTVlwwIbOB2WA8dN0Zhw1XUVcQOMrmYbXekAYLEaKmoKxQygsMDESpDohYfVoPDL4vNr9PTPrf/j4kDQCyYrIcQCHjOgYzVwzICNG2isurIuVu4NMHZr+sU3/ZpaUQMKtMOIUlECilGYuARI9MDDKDhcKxjsdg0ZNzQKKj0gMhtC9gCPEnSMKBw12MBhrKkbN9gwnuzHXuwGGMuPz0KwGIWKIwLFnjDRChE9AHGDhuVX2NAGemCkFUBmwcdq6LgUcFwUNharZkMn5/WvfAOMpcfwHcLFKqWiBhazUh0tUNEKFO8RJr5UjJ+fDmDonH0Nt0JHC3DsBRsjaZUVoFFTNW6QMfssPkR5N8CYfhjeGVRcAij2hIkWiDgCQNQC/vTz+WAFHgFoWgDEAh97QMeRgOMGGwe7qNZV5wYYU9r2BhXFZlypTlgUCStE7A0Qe8HC7xeenyPXz992kqf3hhIrgKyGj1pKpQc6ZqsbN9gYuYIOv+4NMIYO0aIb94i3otdXMVOpKAHFiDoxCyZWgsQMaDgqEAxdKzusPANYVsKIBTwuCR2twDEbNnCKlIDjYmmUnUB4h0tk703cAKOrxQ8GFrOhoqfXRw4qeoGiBhMWVWIFSPQCxA0auq60ZSv1wsgKAJkBHr1plpzK0QobOFA54OjtkZKDjRtoLLssZhd8A4zmFr0AXJS6mPbARU6taAWLFUpFCSwuARU9QHGDiear6hAr9EDHbOAYhY3ZoIED0wobM0GjV9FY1s31pma0XKs3wDC31gKw6E2FHBEqepSKEZiYqU60QsTRAOLLh/d5HX/68Ga+PPdYsBVAZsJHDTxKqZUe6JilbOwBGxdRNG6gYbnk3ueNybLn5mV2BouZasUMpWJm6qMXKGbARAtEXAog3isomK+1SQteCkxaAGQGfJSg44jAMQs2DqVq3ECjdNXeACPbOpPBYi+1YhVUzFQoaqmOEaCwgsReELEIGv7006QXk02K6auL+fPPi8bb2AtGrPAxAh69KscshaMllbIaNnZXNW6gkboH3ADjrFV2BItZakUKKmb4KVqhIqdQlIBiNUysAolBcDgSILwO7ksOLu73Ct4GuhkGlFX7YgGPVdCRUzlagSOVTmmBjZxBtMUcehhV4wYa+nK8Acbm5vQdwkUqBbIaLEagAserpFIcECj2golVoGCI31MX2QtMhqDjEsAxAho4QjmF4yigkVI1bpAx9drau7AbYHCLHwAsWoybo4rFKFTspVTU0h2zYaLzSX4UIN4LGOx986ptbxRUugFkNnzUVI4e8Ojxb7QoG6OqxirYKKVOpvc8uakZN8CYCRcln0UqHTIKFbjDWlMhK6BiZupjT5hoAIkReLiBQw0BjjF/BESaIGQmeMyGjlbg2As2Zng1UumT3UDj+4aM7xQwdlIsch6LUbDYAypaVIqelMfqNEcDRHxoMEz+ScXEG0AcAxBW10IDyJ9bNtZiTJ0BHyXomKVyjKZTRpSNvVWNm6LRcrYnl/0OAWMSXLSqFWh+K1iMpEBGlIoUVORUilaoWAkUVphoAIk/WsscvgQ7C/j2Tse+yDXH48HGxIjq+ZcWQLCCR0uZqXabCRwt/g2runEp2OgZIXQabHxfisZ3BhjfGVy0mDWtcHEUsLACgBEqDgkU3xtEdLLWhwPChxk49oCN1aCRUjWskIFjHoOGtQfKiKJxg4zeq61pve8IMCbAxZ6qxeo0iBUocDq1QEVOqRgxZFpgwggS2J2LwcTBgOHthwnXhOF2c/frwZ7aLgQkZuhAm1rAY0ThyEFHSypltqoxU9Gw9j65GGgc7JowXMc9i3wHgDHpJpqDixHzZm8qpDcNYoWKSwLFRJjYFSR2gIe9gKDnRrLnOrsAy44QYgaPldCxCjh61Y1e2JitauTMoLeUiemSf8eAMQEsWhWL1R6LGCwsKZDZUDFboagBRUWZWA4RC8DhBgqme9P0hZaAyWIQqcJHDTp6VY5R4EipG7Ngw5JC2Qs0cJZOgY33qWi8Q8CYABY4aWYrFrPUiqNARU/KowQThhTHUpgYBIkbNEzngYsWOAwjC8GjCh21FEsPdKSAw5pOuVbYaOneOgUycODeF2i8M8C4wQXlbrfHNNULxJoCmaVWHBUsBqDiBhQXjf+7b3wIOBbBxu6gsYeqYTGH9phCexWNG2QMXWvvCDAmwMVs1QKHJlYuLOZNnQqZpViMQMVMteISKY9OkPgeIOLt69rurndPx+5eOnT39Ct3w8cC8KhCRy6lcmlVY0b6pCd1MmIGXerPeB9KxjsBjEG4mA0WPemQUX/FbKWiBSoGFIppaY/vACJWw8CMYLtHGdcELZeGj27gwIFsgY6VKZQVqsZVKBrXDxlXDhiDYJHzWvT2DGkFi1GoQP1jsLAoFTNSHzmo2EOhaICJI6sQN2DYA0coq31gFaUJQCYoHt3AMQobDCxvb9UjHvs1YmVjNmysBo0p3ozrBY0rBoxBuEipFr1De7emQXrAwhm33PGaBRWLVYpp6oTclWpg8Ue34NvXwXOjehcsL3AEcHh7vruqa/vuoyH4DB6X2uqXBJG7JxVE/lKp6QTQ0FvIQkeph8oocIzAhgUysIOtXo0YNmrpk5YxNIZB4zoh46puQqeLYjCAWOHC0u10NVzUTJu9ioUVLjqUimGwqIGEujteWqG4BExcGzzUArt1/iUg5JLQgXbZU+Eoqhuj3o3eFMqoorEaMnCQrEbQ7xAyrhAwBuDC6rU4Oli8N6gwAsXeMLESHr5XSLDCxOzlVsLJ3hBiho4BpePwsNGaOrkEaCwxgV6XknFFgDEAFrhbzVItVioWo2pFyluxQKnYQ6HYEyZmgsTFwOHz4PUxO6KPlvfbZW6kM0FkT/AwQccAcOBwJqHjEsrGiKqxGjRuasbmyr8SwBi4eV4CLFo9FiWw6FErLFDRmProhooDqRMzQGI5QLw3UBgFjdb1F4PJDADZAzxMwIG27YSOZt+GxbMRp1FafRorVY0Z/oyUojGUNrkMhLdcklcAGO8YLi6lWKTgItP749rBYhQqlgHFDSRa7lPjyy4Cj1HgWA0bhwKNHsjAka+Bxl6Kxg0ymq/DgwPGheFiVTpkBCx60yAHgIpVaY9RiMBVMxUkbvDQfCM6xAoLine truncated

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}.c{clip-path:url(#c);}.d{fill:url(#d);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593"/></linearGradient><linearGradient id="b" x1="0.473" x2="0.473" y2="0.95" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#131f3d"/><stop offset="1" stop-color="#094593" stop-opacity="0"/></linearGradient><clipPath id="c"><rect class="a" width="1920" height="64" transform="translate(-2 5)"/></clipPath><pattern id="d" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 1917 104"><image width="1917" height="104" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB30AAABoCAYAAAD1oOqOAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7svQnUbMdRJli3qv717U+bpSdZsi1LtiUbtVdsQ7Nz2LpZvNADPdimWYfumW4Yz4Dpw+FwGs2hB4YZppsxDeOF5TCNMYYeNwM+mIPBm7zbki3JkmxL1tPytLz1X6vq3onIyMiMjBt5q/7//ZL+/ylL+l/VzZtL5BdLZkbkzVt986eaqlc+BYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILAnEahK0HdP8q0QXRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEHAIl6FsEoSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgI7GEEStB3DzOvkF4QKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBErQt8hAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYwwiUoO8eZl4hvSBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQECgIlKBvkYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgIFAQKAgUBAoCBYGCQEGgILCHEShB3z3MvEL6U4pA9ZS2bjfe7EKaCkkFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgsDsRKH7u3cmXQlVBYFsIlKDvtmArhQoCDoHdMiCWYG8RyIJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBDYDgLFz70d1EqZgsAuRKAEfXchUwpJewqB3TAglqDvnhKZQmxBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENhVCBQ/965iRyGmILA9BErQd3u4lVJ7G4HdMIDtVgRLAHm3cqbQVRAoCBQECgIFgYJAQaAgUBAoCBQECgIFgYJAQaAgUBAoCBQEIgLFz52XhuLnLprytESgBH2flmx/2ne6DIZlMHzaK0EBoCBQECgIFAQKAgWBgkBBoCBQECgIFAQKAgWBgkBBoCBQENjTCBQ/d/Fz72kBLsTvPAIY9O3vfLWlxoJAQaAgUBAoCBQECgK7GoGy43NXs6cQVxAoCBQECgIFgYJAQaAgUBDYNgIlCLJt6ErBgkBBoCBQECgIFAT2MgIl6LuXuVdoLwgUBAoCBYGCQEFgJxEogeCdRLPUVRAoCBQECgIFgYJAQaAgUBB44hEoAd4nHuPSQkGgIFAQKAgUBAoCewSBEvTdI4wqZO4qBGZdUGDwYNa8XR3U9XRdl4DFrhKVQkxBoCBwgSGwE3a92OkLTChKdwoCBYGCQEGgIFAQKAgUBEwELH/IrPPpWfMV6AsCBYGCQEFg+whsx2/9RNtnWX/xn2yft6Xk0xiBEvR9GjO/dH1LCGxnENxSA09A5jIwPgGglioLAgWBgsAOIFDs8w6AWKooCBQECgIFgYJAQaAgUBDY1QjsRT/Krga0EFcQKAgUBHYIgb1on4sfZYeYX6q58BGobvqPHxwcfeWrm8c+9KGq8m/3xevH4bqH13UE4eirp6djWS6BdbgyH4FvrBO+sb5QD2bkNwpjO/C7gW9ndbht/91A4kWvinXo+4FKyB/akvzz9Ye+wT2ss8LGNE3cPt7zveFyeHmR7xNXL+8xhlgn16/pcX0U2CZ4+EoZN3fpaeffrl5t5sSbmRu4F+5LHI26knb4PnfM4IHDiz8ZXubkJ9Cde4u0qi+Li5fNRNZQtuDDbVtyJ+UZ70N2V+bsbbdWJAjIbw8sXuvfPu3AjS9ssIz7/vxt1YEbbgzcCOlYJ3wwj2sD8rlryIt5HI8EliGfpMXTw+0wfQ0UPOjrcZVIOgV7Qn98Q9w2lpE0n/G0jk+fcbQGmfC6H3TP6zPmcfYBP17fXJrXb66DSXnswx+qpO5i/TpN1hXsBbQRdBQzeHpMO6RlSuhMS+c9t1rb0ry8a52Q5YO98vQktGTSWN81z009Ufoq87AdYRvZ0l0PuLQ3rCPBvmtcgGdsL6S+ef1wNTq+Mv4GzhYGQQzVGMLpWsaS/Hwhxp4ue8/20cIqjF+SDm0TNd+kDsnxyac78eGxA/VFjG0sx5Imrk6Of0GHrDHW65EcnzQ+SZtCByXpgW8STzk+qnGF9VaO/S3eajngOoTtTngrxm83Jmk+qPqkjPM4G8ZLySdh7wOWyAs/R5G6zbLM8xGNvewjt896E7CQdHJfUXf8WJ7kF0zQY5g1prnsYkA/+qqvQ/Kb43/27urYD7ymOf7ud5sLsWOviffwN1Zz/5++uyJzLyYIrEdaF0WbjBd+X/na1/m63uXma/i58vU+7U8gzX8w7X5xbaVznlxertuqR8ryLG1JOmXZvfq7mUx6zXjcw+8av/FvNMLfVTMaw/cIrq1vyof5XTlRz3axYP5vt/xeLCdlUsqx0zND7qUsS3mV2HXJ+Sw6YtExTTem3Z+FN5b+W7qby3e+eafRqPs4zV7NypNZbNA02vR9i9YuWcvJm8XXLvy77PY0mnJlu2TW6rfuS64PjMesdE0bk7bKo5K/ILATuq/nP9a1bEfL/Sz6pccariP4omQDuKjQn2RNl96Xc0xdjO+5bz9XxTyPf/iDsUZoz5p/u3zsH/S+BL0eb/kJ9fpfEJRdK4n1icsu1/jKhyF9Gi4v+z/wt/AVJmtRkU+ue1prCuFP5XvW2kquP+R6xeGlfFzsi0j8i3KuL2jTflbpJ2NeJPV5bALvlA8g8YlABdx3XA8hVs7fI/BNZEfQmPjrpL9HrE0Tf+YUs8S+JfbrYvYgV9baU9aX8zdwHuU7QR+W5T/WfhS5Dmdfe/AhC/mUdOZ4mkuXGGm/EPLEtaf0jX13vMZl/5TVhpbZRC6kH8HLnKkLjGPOJ235psT6WvoFuX7te0l8zJKfks+WDRC2QdsJ9lU6H5qQdeZliFfgPY6d6PaU3OqYQLA3yucubUTwH2qfi9BN5gvSPHfwYDDm7of0FXt62JeNl+G3dExDo4lvmvvBdUm/uUiTvmbnn5Y+aPSFC591yOtp4Cacz5zzyXb9bzleBL+6b8c5NRWtmHLwxhfWLO/a9rCtcLYeMksZb/l35Jjp/UFYXvuLEhsv5LHlX9W+OIyFQX0XSd+ib7MVI5N6JfWP7a+/nx2TfBmuJrEfYsx1/RM+frzW/iwZ8+L6ZBxQxhOt8YbLI/uSeB9UJmNvLb1H8ZY4aFuOtPrYpPaLmv59gx8hTsjxTBUjlfMcppXHCEmvNT6YsQDsD/tr1Rwp6LmP23LfJI8sPyzed0HfhBFRVZLAayuQyIJiOMWTwIgSKFd9LkAjGOUUGgVfBjCR8XryZdWv29BOT1YeVXdCG5SRAadE0TJttiYXVsBD4mUFIgwFZrwCDWiRkuirZBr8tgRW0txFF1dlDOStwItBPwVECIng5Jfk6XotupR8uH5jZcgQvgeC0cD13KE4sCkUOi+9Z7w3OnNajA693twBqM8PXhhcJf+5xxvb5DSrdj3Q6Dz+vquu3w7WNnWTstUKRCt6zE4ag3sY8HNyg33bCoAi7/gsBIvl4sqyCZZcs55aQQnUfeS7L8f2wHXfTwRlEFXqp95UoQO1VgDRrYWRL9yeH/hde06a1SfXR5EuaTbxsXQtg0UreNllR1intJ3VfRCL2rAJxehXa7PGlMl0blGsF56JnWI1k0Io7H/CA22/jEWEmV/3LTcOSfy0LZXyoHmgy0mZl/oh5V7TnrOHurxVt2rfXOxKGVAT1xZ/JC1WOSmrmb63+KD7Z2FtjRE+n9bF7CJvlrFX9smYJEqdNbHU+DFfc7hJW6Z1ka9bu7liobWHHu400Sk2Pqulq76NUFlOn6VBZPoyFCS22Jie6A0vVvdnyZODraQXBJ4MBLr21z0Z7W+nDUuvcrq21XRNzzQ7gPl3Ws/PtLine truncated

Before

Width:  |  Height:  |  Size: 98 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="30" viewBox="0 0 32 30"><defs><style>.a{fill:rgba(255,255,255,0.05);}.b{fill:url(#a);}</style><linearGradient id="a" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#a2e1f9"/><stop offset="1" stop-color="#45c3f3"/></linearGradient></defs><g transform="translate(1.411 -9.285)"><rect class="a" width="32" height="30" rx="4" transform="translate(-1.411 9.285)"/><path class="b" d="M4618.588,4844.6a1.257,1.257,0,1,1,1.258,1.256A1.258,1.258,0,0,1,4618.588,4844.6Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.867a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4839.569Zm4.609.838v-1.676h12.569v1.676Zm-4.609-5.865a1.257,1.257,0,1,1,1.258,1.257A1.257,1.257,0,0,1,4618.588,4834.542Zm4.609.838V4833.7h12.569v1.676Z" transform="translate(-4613 -4815)"/></g></svg>

Before

Width:  |  Height:  |  Size: 892 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M24.036,10l-.012,9.333" transform="translate(-16.016 -6.667)"/><path class="b" d="M10,24h9.333" transform="translate(-6.667 -16)"/></svg>

Before

Width:  |  Height:  |  Size: 401 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" viewBox="0 0 36 36"><defs><style>.a,.d{fill:#fff;}.a{opacity:0;}.b{fill:#da2331;}.c{fill:#0a54cf;}.e{fill:#1677ff;}.f{fill:#1e2433;}.g{fill:#10d6ff;}.h{fill:#ff8181;}.i{fill:none;stroke:#10d6ff;stroke-linecap:round;stroke-linejoin:round;}.j{filter:url(#d);}.k{filter:url(#a);}</style><filter id="a"><feOffset dx="4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="b"/><feFlood flood-opacity="0.161" result="c"/><feComposite operator="out" in="SourceGraphic" in2="b"/><feComposite operator="in" in="c"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="d"><feOffset dx="-4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="e"/><feFlood flood-opacity="0.161" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(0 -1)"><rect class="a" width="36" height="36" transform="translate(0 1)"/><path class="b" d="M23.5,14.75a1.5,1.5,0,0,1-1.5-1.5V9.5a1.5,1.5,0,1,1,3,0v3.75A1.5,1.5,0,0,1,23.5,14.75Z" transform="translate(-5.5 -1)"/><g data-type="innerShadowGroup"><path class="c" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 19.541)"/><g class="k" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 18.54)"/></g></g><g data-type="innerShadowGroup"><path class="c" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 19.541)"/><g class="j" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 18.54)"/></g></g><path class="e" d="M5.5-2H16a7.508,7.508,0,0,1,7.5,7.5V13A7.508,7.508,0,0,1,16,20.5H5.5A7.508,7.508,0,0,1-2,13V5.5A7.508,7.508,0,0,1,5.5-2Z" transform="translate(7.25 13.5)"/><path class="f" d="M5.25,0h9A5.25,5.25,0,0,1,19.5,5.25V7.5a5.25,5.25,0,0,1-5.25,5.25h-9A5.25,5.25,0,0,1,0,7.5V5.25A5.25,5.25,0,0,1,5.25,0Z" transform="translate(8.25 15.25)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(21 19)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(12 19)"/><path class="d" d="M24,10v3.75" transform="translate(-6 -1.5)"/><circle class="h" cx="3" cy="3" r="3" transform="translate(15 4)"/><path class="i" d="M44,28.667a3.367,3.367,0,0,0,1.922.412,2.841,2.841,0,0,0,1.828-.412" transform="translate(-27.875 -5.017)"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#006efe;stroke-width:6px;}.b{stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 1)"><rect class="a" width="96" height="96" transform="translate(0 -1)"/><path class="b" d="M47,67H38L18,77V67H4V7H84V29" transform="translate(4 8)"/><circle class="c" cx="12" cy="12" r="12" transform="translate(56 45)"/><path class="b" d="M39,32l10,8" transform="translate(39 33)"/><path class="b" d="M12,15H24" transform="translate(12 16)"/><path class="b" d="M12,21H36" transform="translate(12 22)"/></g></svg>

Before

Width:  |  Height:  |  Size: 670 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{stroke:rgba(0,0,0,0);stroke-miterlimit:10;fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M1,10A1,1,0,1,1,1,8H6a1,1,0,0,1,0,2Zm9.8-.482L7.848,7.361A.445.445,0,0,1,7.661,7a.46.46,0,0,1,.187-.366L10.8,4.481a.45.45,0,0,1,.716.365V9.152a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,10.8,9.518ZM1,6A1,1,0,1,1,1,4H6A1,1,0,0,1,6,6ZM1,2A1,1,0,1,1,1,0H11a1,1,0,1,1,0,2Z" transform="translate(0.5 0.5)"/></svg>

Before

Width:  |  Height:  |  Size: 753 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff" stop-opacity="0.302"/><stop offset="1" stop-color="#00c7ff" stop-opacity="0.302"/></linearGradient><linearGradient id="b" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M5.5,0,11,8H0Z" transform="translate(13) rotate(90)"/><path class="b" d="M5.5,0,11,8H0Z" transform="translate(8) rotate(90)"/></svg>

Before

Width:  |  Height:  |  Size: 735 B

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<defs>
<style>
.hit { fill: rgba(255, 255, 255, 0.8); opacity: 0; }
.mark { fill: #fff; }
</style>
</defs>
<g transform="translate(-2434.135 -67.941)">
<rect class="hit" width="12" height="12" transform="translate(2434.135 67.941)"/>
<path class="mark" d="M.885,0,0,.885,3.692,4.577,4.135,5l.442-.423L8.269.885,7.385,0,4.135,3.25Z" transform="translate(2436 71.942)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 501 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#fff;stroke-linejoin:round;}.b{stroke-linecap:round;}</style></defs><g transform="translate(-1)"><rect class="a" width="16" height="16" transform="translate(1)"/><path class="b" d="M7,42H19" transform="translate(-4 -28)"/><path class="b" d="M0,0H3" transform="translate(9.848 3.848) rotate(45)"/><path class="c" d="M11,12.907v2.427h2.439l6.894-6.9L17.9,6Z" transform="translate(-6.667 -4)"/></g></svg>

Before

Width:  |  Height:  |  Size: 545 B

@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<defs>
<style>
.background { fill: none; }
.mark { stroke: rgba(0, 0, 0, 0); stroke-miterlimit: 10; fill: url(#gradient); }
</style>
<linearGradient id="gradient" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#a2e1f9"/>
<stop offset="1" stop-color="#45c3f3"/>
</linearGradient>
</defs>
<rect class="background" width="16" height="16"/>
<path class="mark" d="M14554.5,10202.5a1,1,0,1,1,0-2h5a1,1,0,0,1,0,2Zm9.8-.482-2.95-2.157a.445.445,0,0,1-.187-.361.46.46,0,0,1,.187-.366l2.95-2.152a.45.45,0,0,1,.716.365v4.307a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,14564.3,10202.019Zm-9.8-3.521a1,1,0,1,1,0-2h5a1,1,0,1,1,0,2Zm0-4a1,1,0,1,1,0-2h10a1,1,0,1,1,0,2Z" transform="translate(-14551.5 -10189.5)"/>
</svg>

Before

Width:  |  Height:  |  Size: 913 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(255,255,255,0.8);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10,1.5A1.5,1.5,0,1,1,11.5,3,1.5,1.5,0,0,1,10,1.5Zm-5,0A1.5,1.5,0,1,1,6.5,3,1.5,1.5,0,0,1,5,1.5Zm-5,0A1.5,1.5,0,1,1,1.5,3,1.5,1.5,0,0,1,0,1.5Z" transform="translate(1.498 6.5)"/></svg>

Before

Width:  |  Height:  |  Size: 410 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(85,166,254,0.6);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M12.715,13.421,9.588,10.294a5.841,5.841,0,1,1,.707-.707l3.128,3.128a.5.5,0,0,1-.708.706ZM1,5.833A4.833,4.833,0,1,0,5.834,1,4.838,4.838,0,0,0,1,5.833Z" transform="translate(1.334 1.216)"/></svg>

Before

Width:  |  Height:  |  Size: 418 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;}</style></defs><path class="a" d="M14.488.053.824,4.256a1.167,1.167,0,0,0-.285,2.1L4.576,8.936a1.167,1.167,0,0,0,1.382-.092L11,4.593A.292.292,0,0,1,11.408,5L7.156,10.043a1.167,1.167,0,0,0-.091,1.382l2.58,4.036a1.168,1.168,0,0,0,2.1-.286l4.2-13.663A1.167,1.167,0,0,0,14.488.053Z" transform="translate(0)"/></svg>

Before

Width:  |  Height:  |  Size: 422 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16.131" height="16" viewBox="0 0 16.131 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#97edff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M6,30V34.12H18.361V30" transform="translate(-2.73 -19.994)"/><path class="b" d="M21.181,9.09,18.09,6,15,9.09" transform="translate(-8.64 -4.235)"/><path class="b" d="M23.992,14.241V6" transform="translate(-14.544 -4.235)"/></svg>

Before

Width:  |  Height:  |  Size: 504 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#346fb1;stroke-linejoin:round;}.c{stroke-linecap:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10.667,17.333A6.667,6.667,0,1,0,4,10.667,6.667,6.667,0,0,0,10.667,17.333Z" transform="translate(-2.667 -2.667)"/><path class="c" d="M24.008,12v4l2.826,2.826" transform="translate(-16.005 -8)"/></svg>

Before

Width:  |  Height:  |  Size: 473 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(212,120,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 320 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(207,19,34,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 320 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(29,171,123,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(132,85,230,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(201,181,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 320 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(69,195,243,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#346fb1;stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 -2)"><rect class="a" width="16" height="16" transform="translate(0 2)"/><path class="b" d="M17.333,8H4V18H9l1.667,1.667L12.333,18h5Z" transform="translate(-2.667 -3.667)"/><circle class="b" cx="1.667" cy="1.667" r="1.667" transform="translate(6.333 6.333)"/><path class="b" d="M21,26.667A2.848,2.848,0,0,0,18,24a2.848,2.848,0,0,0-3,2.667" transform="translate(-10 -14.333)"/></g></svg>

Before

Width:  |  Height:  |  Size: 618 B

@@ -1,8 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import { copyTextToClipboard } from "@/core/clipboard";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
type ComponentProps,
@@ -145,13 +144,18 @@ export const CodeBlockCopyButton = ({
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
try {
await copyTextToClipboard(code);
setIsCopied(true);
onCopy?.();
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
@@ -846,10 +846,7 @@ export const PromptInput = ({
ref={formRef}
{...props}
>
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
versions before 105 ignore :has(), retain h-9, and clip the whole
multi-line composer to a single row. */}
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
<InputGroup>{children}</InputGroup>
</form>
</PromptInputValidationContext.Provider>
);
@@ -876,48 +873,16 @@ export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
onInput,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
if (
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("field-sizing", "content")
) {
return;
}
// Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem
// autosize range used by min-h-16/max-h-48 instead of leaving the prompt
// stuck at its minimum height.
textarea.style.height = "auto";
const height = Math.max(64, Math.min(textarea.scrollHeight, 192));
textarea.style.height = `${height}px`;
textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden";
}, []);
// onInput does not run for a controlled value filled by a suggested question
// or restored draft. Resize once after every render in legacy Chrome too.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const frame = window.requestAnimationFrame(() => {
resizeForLegacyChrome(textarea);
});
return () => window.cancelAnimationFrame(frame);
}, [controller?.textInput.value, resizeForLegacyChrome]);
export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
@@ -997,18 +962,12 @@ export const PromptInputTextarea = ({
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
ref={textareaRef}
rows={3}
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onInput={(event) => {
resizeForLegacyChrome(event.currentTarget);
onInput?.(event);
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
File diff suppressed because it is too large. Load diff
Loaded 100 of 1865 files, more files were not shown because too many files have changed in this diff. Show more