Compare commits

..
Author SHA1 Message Date
liuq_1233 9afcacc223 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-06-02 11:43:23 +08:00
liuq_1233 57289251a0 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-29 15:16:20 +08:00
liuq_1233 0c7d15fe59 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-28 19:05:58 +08:00
liuq_1233 f9baa2f8b9 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-28 18:43:34 +08:00
liuq_1233 9e4195db36 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-28 18:38:24 +08:00
liuq_1233 17257b79a7 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-28 13:26:29 +08:00
liuq_1233 43e73df066 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-27 11:20:11 +08:00
liuq_1233 843a9a3a7b Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-27 09:47:13 +08:00
liuq_1233 9ffe28f049 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-24 10:22:01 +08:00
liuq_1233 b90c71d0ce Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-20 10:33:47 +08:00
liuq_1233 9f8b9f7ae5 Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-19 10:29:56 +08:00
liuq_1233 f5a47201ba Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq 2026-05-19 10:28:56 +08:00
1723 changed files with 151766 additions and 254464 deletions

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "md-viewer-frontend",
"runtimeExecutable": "npx",
"runtimeArgs": ["ng", "serve", "--port", "4200"],
"port": 4200,
"cwd": "F:\\angular\\md-viewer\\frontend"
}
]
}
+1 -2
View File
@@ -4,8 +4,7 @@
"PowerShell(uv --version)", "PowerShell(uv --version)",
"PowerShell(netstat -ano)", "PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)", "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: $_\" })", "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)"
] ]
} }
} }
-32
View File
@@ -1,32 +0,0 @@
---
name: deerflow-ui-guard
description: Improve or review UI in the DeerFlow Vite/React frontend while preserving its light, dark-blue, and light-blue themes, Radix/shadcn-style primitives, Chinese enterprise density, embedded routes, and Chrome 109 compatibility. Use for visual refinements, new frontend pages/components, UI reviews, token cleanup, or design-system work under frontend-web/.
---
# DeerFlow UI Guard
Use this skill only in this repository. Preserve the product's existing visual system; do not replace it with a generic SaaS template or a new component library.
## Read the relevant sources first
1. Read `references/project-sources.md`, then the relevant theme specification.
2. Inspect the page and the nearest existing components before proposing or changing UI.
3. Read `frontend-web/src/styles/globals.css` and the affected primitives under `frontend-web/src/components/ui/` before changing tokens, shared styles, or variants.
## Design and implementation rules
- Reuse the existing Radix/shadcn-style primitives, `lucide-react`, Tailwind v4, and established CSS variables. Do not add Ant Design, another component system, or a visual-effects package unless the user explicitly asks.
- Keep all three themes correct: light/red, dark/blue, and light-blue. Prefer semantic classes and existing variables over raw color values. When a new token is necessary, define matching values for every theme.
- Preserve Chinese enterprise-product density and task completion speed. Make hierarchy, primary action, status, empty states, error recovery, and keyboard focus clearer before adding decoration.
- Keep the shell, deep links, authentication, embed/isolation behavior, sidebar menu semantics, and responsive layout unchanged unless the request explicitly targets them.
- Avoid unscoped global overrides and `!important`; place page-specific styling beside the feature and make selectors local.
- Support the Chrome 109 constraint. Do not introduce unguarded `oklch()`, `oklab()`, `lab()`, `lch()`, or `color-mix()` values.
- Preserve accessible names, keyboard interaction, visible `:focus-visible` states, sufficient contrast, and reduced-motion behavior for new or changed controls.
## Delivery workflow
1. State which existing theme and UI primitives the work will reuse.
2. Make the smallest coherent change; do not perform an unrelated visual sweep.
3. Validate with `pnpm typecheck`; when CSS or built UI changes, also run `pnpm build` and `pnpm check:chrome109-css` from `frontend-web/` when practical.
4. If a browser is available, inspect screenshots in light, dark, and light-blue modes. Otherwise say that visual verification remains outstanding.
5. For a review-only request, use `web-design-guidelines` as an additional quality gate when it is available, then distinguish confirmed issues from visual checks that still need a browser.
@@ -1,4 +0,0 @@
interface:
display_name: "DeerFlow UI Guard"
short_description: "Apply DeerFlow themes and UI safety checks."
default_prompt: "Use $deerflow-ui-guard to improve this DeerFlow UI without breaking its themes or legacy-browser support."
@@ -1,23 +0,0 @@
# DeerFlow UI sources
Read only the files relevant to the requested surface.
## Canonical themes
- Light/red: `frontend-web/docs/浅红色UI规范/README.md` and its focused section files.
- Dark/blue: `frontend-web/docs/ui规范-深蓝/README.md` and its focused section files.
- Light-blue: `frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md`.
- Runtime tokens and browser fallbacks: `frontend-web/src/styles/globals.css`.
## Shared implementation
- UI primitives: `frontend-web/src/components/ui/`.
- Theme selection: `frontend-web/src/contexts/ThemeContext.tsx` and `frontend-web/src/App.tsx`.
- Application shell and embed behavior: `frontend-web/src/pages/PageRoutes.tsx`.
- Dynamic navigation: `frontend-web/src/core/page-layout/use-sidebar-menu.ts`.
## Required constraints
- Use `pnpm` in `frontend-web/`.
- Retain Chrome 109 compatibility; validate CSS with `pnpm check:chrome109-css` after a build.
- Do not move or replace public assets, route behavior, or authentication as part of a visual-only change.
+1 -34
View File
@@ -1,7 +1,7 @@
# Workspace/editor # Workspace/editor
.vscode/ .vscode/
frontend-web/.vscode/ frontend-web/.vscode/
node_modules/
# Claude Code local-only settings (per-user permissions, not shared) # Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json .claude/settings.local.json
@@ -78,44 +78,11 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API # Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json offline-backend-20260512/backend/skill_evolution.json
skills/ skills/
# Keep the project-local Codex UI guard under version control. The broad
# skills/ rule above still excludes runtime skill directories elsewhere.
!.codex/
!.codex/skills/
!.codex/skills/deerflow-ui-guard/
!.codex/skills/deerflow-ui-guard/**
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
deploy deploy
.history .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/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
# 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
View File
Whitespace-only changes.
-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: 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` - **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) - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks)
- **定时任务「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.
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **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 - **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/` - **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) - **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 ### 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;
}
-48
View File
@@ -1,48 +0,0 @@
# Design QA — skills card list
## Scope
Only the skill-card list presentation was changed. The page header, summary cards, filters, top actions, sidebar, and existing interactions were left intact.
## Visual evidence
- Source visual truth: `C:\Users\admin\AppData\Local\Temp\codex-clipboard-8ca03817-ebd4-43e1-9d7c-2119e87d331c.png`
- Implementation URL: `http://localhost:5173/#/page/workspace/skills`
- Implementation capture: `F:\react01\deeflow-code\deerflow-server\skills-card-list-implementation.png`
- Focused source/implementation comparison: `F:\react01\deeflow-code\deerflow-server\skills-card-list-comparison.png`
- Viewport and state: Edge, desktop light theme, default list mode. The supplied reference contains three sample skills; the live implementation currently exposes one skill, so the comparison focuses on the first card row rather than fabricated data.
## Findings
No actionable P0, P1, or P2 visual issue remains within the requested list-card scope.
- **Hierarchy and layout:** the card now begins with a large semantic skill icon, then groups name, existing status badges, description, and publisher before the existing tag, use-count, state, activity, and action areas. The five-column grid keeps the important skill identity visible while preserving the original live fields.
- **Typography and spacing:** the title, description, metadata, and tags use the existing product type scale with a compact 12px card radius and consistent horizontal rhythm. The row remains responsive and collapses cleanly on narrower widths.
- **Color and surfaces:** category icons use restrained solid blue/green/violet/amber surfaces, tags remain subdued, and enabled status uses a quiet green pill. Borders and hover surfaces follow the existing neutral card treatment; no gradients were introduced.
- **Image and icon fidelity:** the source uses symbolic skill artwork. The implementation uses the closest existing Lucide workflow, globe, file, and upload icons in colored circular surfaces rather than adding screenshot-derived artwork or a new custom asset.
- **Feature fidelity:** all pre-existing action controls, view modes, and list metadata stay visible. The reference's toggle was not copied as a fake interactive control because this page had no enable/disable mutation; its existing `enabled` data is shown as a read-only status pill instead.
## Comparison history
1. **[P1, fixed] The original live skill row had little identity or information hierarchy.**
- Earlier state: title, description, metadata, and the action cluster were visually detached, with no category icon or clear scanning order.
- Fix: rebuilt only the list/card row layout around existing data and handlers, added semantic colored icon surfaces, compact state/use/activity columns, and a unified action cluster.
- Evidence: `skills-card-list-comparison.png` contains the reference card row and the live implementation card row in one focused comparison.
## Interaction and engineering checks
- Edge interaction check: passed. The live row retains 7 existing action buttons; opening the details action displays its dialog.
- View-mode behavior: passed. The existing list/card view control still switches to card mode (`aria-pressed=true`); the card view uses the same restyled visual language.
- Browser runtime: two guest-bootstrap resource requests returned `401 Unauthorized` after reload. They are unrelated to the skill-card styling and did not prevent the list, details dialog, or view-mode control from working.
- `pnpm build`: passed. The project still emits the pre-existing `/images/deer.svg` runtime-resolution warning.
- `git diff --check`: passed; only existing line-ending notices were emitted for unrelated files.
## Implementation checklist
- [x] Skill-card list restyled without changing page chrome or filtering controls
- [x] Existing skill actions, metadata, and view modes retained
- [x] Live `enabled` data displayed without inventing a new mutation control
- [x] Source and implementation reviewed in a single focused comparison image
- [x] Edge interaction and production build verified
final result: passed
@@ -1,139 +0,0 @@
# Iframe Chat Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a standalone `/page/workspace/chats_iframe/*` chat page that reuses the existing thread system and history, while showing only `你好` and no input box before the first user message.
**Architecture:** Keep the existing workspace shell and chat runtime intact, then add a new route pair and a copied page component dedicated to the iframe path. The new page keeps the original chat behavior once a thread already has messages, but it owns its empty-state rendering and URL transition to `/page/workspace/chats_iframe/:thread_id`.
**Tech Stack:** React, TypeScript, React Router, Next navigation shim, existing LangGraph thread hooks
---
### Task 1: Add the iframe chat routes
**Files:**
- Modify: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Add `chats_iframe/new` and `chats_iframe/:thread_id` routes that render the new page inside the existing workspace layout and chat runtime**
```tsx
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
```
- [ ] **Step 2: Ensure the new route imports compile cleanly**
Run: `rg -n "IframeChatPage|chats_iframe" frontend-web/src/pages/WorkspaceRoutes.tsx`
Expected: `WorkspaceRoutes.tsx` contains one import for `IframeChatPage` and both `chats_iframe` route entries.
### Task 2: Add a standalone iframe chat page
**Files:**
- Create: `frontend-web/src/pages/IframeChatPage.tsx`
- Reference: `frontend-web/src/pages/ChatPage.tsx`
- [ ] **Step 1: Copy the current `ChatPage` implementation into `IframeChatPage` and preserve the existing chat runtime integration**
```tsx
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// keep the rest of the existing chat hooks and handlers aligned with ChatPage
}
```
- [ ] **Step 2: Change the thread creation URL replacement to stay on the iframe route family**
```tsx
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
);
},
```
- [ ] **Step 3: Replace the default landing state with a simple `你好` view and do not render the input box while the iframe page is still a brand-new untouched chat**
```tsx
{isLandingState && mounted ? (
<div className="flex min-h-[240px] items-center justify-center">
<div className="text-2xl font-medium">你好</div>
</div>
) : null}
{!isLandingState &&
(mounted ? (
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
autoFocus={false}
status={thread.error ? "error" : thread.isLoading ? "streaming" : "ready"}
context={settings.context}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
onContextChange={(context) => setSettings("context", context)}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
) : (
<div aria-hidden="true" className="bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border" />
))}
```
- [ ] **Step 4: Keep message rendering, thread title, export, token usage, todos, artifact triggers, and all post-first-message behavior identical to the original page**
Run: `rg -n "MessageList|ThreadTitle|ArtifactTrigger|TokenUsageIndicator|TodoList" frontend-web/src/pages/IframeChatPage.tsx`
Expected: The new page still renders the same core chat primitives used by `ChatPage`.
### Task 3: Verify the new page compiles
**Files:**
- Test: `frontend-web/src/pages/IframeChatPage.tsx`
- Test: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Run the frontend build**
Run: `cmd /c npm run build`
Expected: Build succeeds without TypeScript or bundling errors related to `IframeChatPage` or `chats_iframe`.
- [ ] **Step 2: Spot-check the changed routes in the built source**
Run: `rg -n "chats_iframe" frontend-web/src/pages`
Expected: Matches appear in `WorkspaceRoutes.tsx` and `IframeChatPage.tsx`.
- [ ] **Step 3: Commit after verification**
```bash
git add docs/superpowers/plans/2026-06-01-iframe-chat-page.md frontend-web/src/pages/WorkspaceRoutes.tsx frontend-web/src/pages/IframeChatPage.tsx
git commit -m "feat: add iframe chat page routes"
```
+3 -25
View File
@@ -1,18 +1,6 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致 # 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高) # 个人覆盖请新建 .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 # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
@@ -36,21 +24,11 @@ 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=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope 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=https://knowledgetoolserver.b.uat.4.cn
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 我的笔记前端
# 工作室/空间前端地址(用于 auth/callback 等跳转) VITE_AIPI_FRONTEND_URL= xxx
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_FRONTEND_URL=http://localhost:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer 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 VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL) # 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -15,16 +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_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope 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_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 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_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
+3 -21
View File
@@ -5,43 +5,25 @@
# ├─────────────────┼──────────────────────────────────────────────────┤ # ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │ # │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.local │ # │ pnpm build │ .env.production + .env.production.local │
# │ pnpm build:network │ .env.network + .env.network.local(内网地址) │
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │ # │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘ # └─────────────────┴──────────────────────────────────────────────────┘
# #
# 推荐: # 推荐:
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址) # - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local # - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build) # - 内网打包部署:改 .env.production 后再执行 pnpm build
# #
# 若浏览器直连后端报 CORS,请在 Gateway 配置 GATEWAY_CORS_ORIGINS,包含前端源站;无界嵌入时还要加宿主源站,例如: # 若浏览器直连后端报 CORS,请在 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_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
# --- 开发环境(见 .env.development)--- # --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow # VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api # VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # 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)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build # 部署前修改 .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
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn # VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1 # 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=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
-57
View File
@@ -1,57 +0,0 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# 任务看板数据源: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
# 轻应用「新建笔记」系列接口
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
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_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_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_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=
-22
View File
@@ -1,23 +1,12 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址 # 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改 # 打包后变量会写入 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 # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-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 VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口 # 多轮检索接口
@@ -30,21 +19,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_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope 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_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的空间前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer 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_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.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
# 可选:Canvas 独立 LangGraph 地址 # 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,5 +3,3 @@
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow # VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api # VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-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
-56
View File
@@ -1,56 +0,0 @@
# 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
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.sit.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.sit.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.sit.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.sit.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.sit.4.cn
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=
@@ -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 键就地补丁、显式 `[]` 不动。
@@ -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 / 意图节点带答案」推进,如需调整请指出。
-436
View File
@@ -1,436 +0,0 @@
# 工作台问答 · iframe 嵌入开发说明
本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。
后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py`
数据表:`embed_sessions`(迁移 `20260602_01`)
---
## 0. 速读
| 角色 | 要做的事 |
|------|----------|
| **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` |
| **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` |
| **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie |
**完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。
---
## 1. 背景与约束
### 1.1 业务目标
- 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。
- 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。
- 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。
- 进入后 **自动发送** 首条用户消息并走流式问答。
### 1.2 为何需要 `sessionId`
iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。
因此约定:
1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`;
2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`;
3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。
---
## 2. 端到端流程
```mermaid
sequenceDiagram
participant Parent as 父页面
participant Iframe as DeerFlow iframe
participant API as Gateway /api/public/embed
Parent->>Parent: sessionId = crypto.randomUUID()
Parent->>Iframe: iframe src 含 sessionId、message、username、theme
Iframe->>Iframe: loginByUsername + 自动 sendMessage
Iframe->>API: POST /sessions { session_id, thread_id }
loop 每 1–3s
Parent->>API: GET /sessions/{sessionId}/status
API-->>Parent: registered, finished, thread_id
end
```
---
## 3. 前端路由与页面
### 3.1 路由(独立于 `/page/*`)
在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。
| 路由 | 组件 | 说明 |
|------|------|------|
| `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID |
示例(HashRouter,注意 `#`):
```
http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red
```
开发环境端口以本地 Vite 为准(如 5173 / 5174)。
### 3.2 URL 查询参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` |
| `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 |
| `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` |
| `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) |
缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。
### 3.3 相关源码
| 路径 | 职责 |
|------|------|
| [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox |
| [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 |
| [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 |
| [src/App.tsx](../src/App.tsx) | 路由注册 |
### 3.4 UI 行为说明
- **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。
- **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。
- **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。
---
## 4. 公开 HTTP API(无需鉴权)
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
### 4.1 登记映射(iframe 内自动调用)
```http
POST /api/public/embed/sessions
Content-Type: application/json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b"
}
```
**响应:**
```json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"registered": true
}
```
同一 `session_id` 再次 POST 会 **更新** `thread_id`(例如 `onStart` 后 id 变化)。
### 4.2 轮询状态(父页面使用)
```http
GET /api/public/embed/sessions/{session_id}/status
```
**尚未登记**(iframe 仍在加载或未 POST):
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null
}
```
**已登记且问答进行中:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": false,
"thread_status": "idle",
"has_active_run": true,
"latest_run_status": "running"
}
```
**已登记且已结束:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success"
}
```
#### 字段说明
| 字段 | 含义 |
|------|------|
| `registered` | 是否已收到 iframe 的 POST 登记 |
| `finished` | **父页面应以此为准**:无 inflight run,且线程非 running/busy,且非 `interrupted`(澄清等待用户) |
| `thread_status` | 从 checkpoint 推导:`idle` / `interrupted` / `error` 等 |
| `has_active_run` | 是否存在 pending/running 的 LangGraph run |
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
### 4.3 获取生成结果(父页面使用)
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
```http
GET /api/public/embed/sessions/{session_id}/result
```
**尚未登记:**
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null,
"messages": [],
"generated_text": "",
"generated_files": []
}
```
**已登记且已有内容:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success",
"messages": [
{ "type": "human", "content": "请总结要点", "id": "..." },
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
],
"generated_text": "根据分析,要点如下:...",
"generated_files": [
"/mnt/user-data/outputs/summary.md",
"/mnt/user-data/outputs/chart.png"
]
}
```
#### 结果字段说明
| 字段 | 含义 |
|------|------|
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
### 4.4 按 thread_id 查询(可选)
调试或父页面已持有 thread_id 时:
```http
GET /api/public/embed/threads/{thread_id}/status
GET /api/public/embed/threads/{thread_id}/result
```
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
### 4.5 前端封装
```ts
import {
getEmbedSessionResult,
getEmbedSessionStatus,
registerEmbedSession,
} from "@/core/embed/status-api";
// 父页面(或你们自己的脚本)
const status = await getEmbedSessionStatus(sessionId);
if (status.registered && status.finished) {
const result = await getEmbedSessionResult(sessionId);
console.log(result.generated_text, result.generated_files);
}
// 仅 iframe 内需要
await registerEmbedSession(sessionId, threadId);
```
`fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。
---
## 5. 父页面集成示例
### 5.1 拼接 iframe
```html
<iframe
id="deerflow-embed"
title="DeerFlow 问答"
style="width:100%;height:600px;border:0"
></iframe>
```
```javascript
const sessionId = crypto.randomUUID();
const base = "http://localhost:5173"; // 换成你们部署的前端 origin
const params = new URLSearchParams({
sessionId,
message: "请根据附件总结要点",
username: "guest",
theme: "red",
});
document.getElementById("deerflow-embed").src =
`${base}/#/embed/chats/new?${params.toString()}`;
```
### 5.2 轮询直到结束
```javascript
const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL
async function pollUntilFinished(sessionId) {
const res = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!res.ok) throw new Error(`status HTTP ${res.status}`);
const data = await res.json();
if (!data.registered) return { done: false, reason: "pending" };
if (data.finished) return { done: true, data };
return { done: false, reason: "running" };
}
const timer = setInterval(async () => {
try {
const poll = await pollUntilFinished(sessionId);
if (poll.done) {
clearInterval(timer);
const output = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
).then((r) => r.json());
console.log("问答已结束", output.generated_text, output.generated_files);
}
} catch (e) {
console.error(e);
}
}, 2000);
```
### 5.3 跨域注意
父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
```bash
# offline-backend-20260512/.env 或启动脚本注入
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173
```
修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。
临时调试(勿用于公网生产):
```bash
GATEWAY_CORS_ALLOW_ALL=1
```
常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。
### 5.4 其它部署注意
---
## 6. 部署与数据库
1. **跑迁移**(SQL 持久化后端):
```bash
cd offline-backend-20260512/backend
# 按项目惯例执行 alembic upgrade head
```
表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。
2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。
3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。
无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。
---
## 7. 安全说明
- `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。
- 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。
- 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。
---
## 8. 与主聊天 `/page/workspace/chats` 的差异
| 项目 | 主聊天 | iframe 嵌入 |
|------|--------|-------------|
| 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` |
| 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 |
| 登录 | 常规登录流 | URL `username` 自动 `login/username` |
| 首条消息 | 用户输入 | URL `message` 自动发送 |
| 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` |
能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。
---
## 9. 常见问题
**Q:`registered` 一直为 false?**
A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。
**Q:`finished` 一直 false?**
A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。
**Q:父页面能否不用轮询?**
A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。
**Q:能否继续用已有 thread_id 嵌入?**
A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。
---
## 10. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
@@ -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:` 样式 |
+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 的实际执行 |
@@ -649,31 +649,6 @@ JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `a
--- ---
## 7B. 业务链条持久化(`roundtable_chains.py`,2026-06)
「业务链条」是用户在配置页编排的**可复用、命名、有序**的智能体编排模板;选中后 Step 2 由总控**按链条顺序派活**(前端编排,后端零改动)。链条持久化仿 §7A,新增一张表 + 一组 CRUD 路由。完整前后端设计见 `frontend-web/docs/multi-agent-business-chain-dev.md`。
### 7B.1 数据模型(`deerflow.persistence.roundtable_chains`)
- 表 **`roundtable_chains`**:`id`(PK str64) / `user_id`(idx) / `title`(512) / `description`(512, nullable) / `seats`(**`PortableLongText`** JSON:有序 `[{agent_id,name,description}, ...]`,`seats[0]` 先发言) / `created_at` / `updated_at`(**`BeijingDateTime`**)。
- ORM `RoundtableChainRow` 登记进 `persistence/models/__init__.py`,启动 `Base.metadata.create_all` 自动建表;store `make_roundtable_chain_store(sf)` 接到 `app.state.roundtable_chain_store`(`deps.py`)。
### 7B.2 路由(`app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `` | 当前用户全部链条(含 seats,按 `updated_at` 倒序) |
| POST | `` | 新建(可带客户端 `id`);校验席位 `2 ≤ N ≤ 8` + 去重 |
| GET | `/{id}` | 取单条;非属主 → 404 |
| PUT | `/{id}` | partial 更新(`exclude_unset`);带 seats 时同样校验 |
| DELETE | `/{id}` | 204;非属主 → 404 |
- 鉴权/属主用 `_current_user_id(request)`,跨用户一律 404,与 `roundtable_drafts` 一致。
- 席位约束 `MIN_SEATS=2 / MAX_SEATS=8` 与前端 `CHAIN_MIN/MAX_SEATS`、推荐弹窗 `MIN/MAX_SELECTED` 对齐;前端配置页保存前先校验,后端 `_validate_seats` 兜底(400)。
- 前端 `api/chains.ts` 直接打这组路由(snake↔camel 边界映射);测试 `tests/test_roundtable_chains.py`(CRUD + 部分更新 + 跨用户隔离)。
---
## 8. 内置席位 SOUL 设计要点 ## 8. 内置席位 SOUL 设计要点
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。 每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
@@ -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,400 +0,0 @@
# 业务链条模式 · 需求与分步实现文档
> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。
>
> 配套阅读:
> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7)
> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A)
>
> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。
---
## 0. 需求原文拆解
用户原始诉求逐条拆成可实现条目:
| # | 原始诉求 | 拆解 |
|---|----------|------|
| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` |
| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 |
| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 |
| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 |
| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) |
| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) |
| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 |
---
## 1. 目标与非目标
### 1.1 目标
- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。
- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。
- Step 2 支持两种编排:
- `recommend` 模式 → 现有总控**自由派活**循环(不动)。
- `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。
### 1.2 非目标(本期不做,留扩展点)
- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。
- 链条的分支 / 条件流转(只做线性顺序链)。
- 链条跨用户共享 / 市场(先做「本人私有链条」)。
- 链条版本管理 / 协作编辑。
---
## 2. 关键设计决策
### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅
**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。**
理由:
| 维度 | 弹窗 | 独立页面 ✅ |
|------|------|------------|
| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 |
| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 |
| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 |
| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 |
**分工**:
- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。
- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。
> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。
### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅
**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。
理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。
落地:
- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。
- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。
- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。
### 2.3 决策 C:拖拽库选型 —— `@dnd-kit`
仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。
| 方案 | 取舍 |
|------|------|
| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 |
| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug |
**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。
### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅
**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。
为什么后端无需改动:
- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。
- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。
- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。**
→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。
> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。
---
## 3. 数据模型与类型
新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`:
```ts
/** 业务链条里的一个有序席位。 */
export interface ChainSeat {
agent_id: string;
/** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */
name: string;
description?: string;
}
/** 一条可复用的业务链条。 */
export interface BusinessChain {
id: string;
title: string;
description?: string;
/** 有序席位列表;seats[0] 是第一个发言者。 */
seats: ChainSeat[];
createdAt?: string;
updatedAt?: string;
}
/** 推荐弹窗确认后回传给主页面的统一结果。 */
export interface SeatSelectionResult {
/** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */
agents: Array<{ agent_id: string; name: string }>;
/** 决定 Step 2 走哪种编排。 */
mode: "recommend" | "chain";
/** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
chain?: Pick<BusinessChain, "id" | "title">;
}
```
**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。
**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。
---
## 4. 现有代码改动点速查
| 位置 | 改动 | 说明 |
|------|------|------|
| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) |
| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` |
| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` |
| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 |
| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 |
| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` |
| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 |
| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` |
| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** |
---
## 5. 链条持久化 API 设计
### 5.1 前端客户端 `api/chains.ts`(直接打后端)
```ts
listChains(): Promise<BusinessChain[]> // 当前用户全部链条
getChain(id: string): Promise<BusinessChain>
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
deleteChain(id: string): Promise<void>
```
- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。
- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。
### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`)
- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。
- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。
- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。
- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。
> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。
---
## 6. Step 2 链条模式编排设计(`runChainOrchestration`)
### 6.1 与现有 `runOrchestration` 的差异
| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) |
|------|--------------------------------|----------------------------------------|
| 谁派活 | 总控 leader | 总控 leader(**不变**) |
| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** |
| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** |
| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) |
| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` |
> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。
### 6.2 伪代码
```
runChainOrchestration(seedMessage, ids, coord, chainSeats):
isOrchestrating = true
cancel = false
N = chainSeats.length
for (let i = 0; i < N; i++): // 链条游标,逐位推进
if cancel: break
seat = chainSeats[i]
seatMeta = agentNameToRole[seat.agent_id]
// ── (a) 总控派活给「链条下一席位」──────────────────────────
highlightSpeaker(null) // leader 阶段不高亮任何 sub
leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中")
leaderTask = buildChainLeaderTask({
seedMessage, // 任务目标/约束(首轮)
position: i, total: N,
nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」
isFirst: i === 0,
})
leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel)
// 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task;
// 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底:
// - 命中就用它给出的 task 文本(保留总控的承上启下措辞);
// - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。
task = pickChainTask(leaderRes, seat)
// leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3)
finalizeDialogue(leaderBubble, leaderRes.content, "派活说明")
// ── (b) 被派席位交付(special,顺序、不并行)──────────────────
markSpeakerStreaming(seatMeta.speakerId)
seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中")
perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel
specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel)
finalizeDialogue(seatBubble, specRes.content, "子智能体交付")
markSpeakerIdle(seatMeta.speakerId)
// 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见
if cancel: return
// ── (c) 总控收口:综合全链交付,产出最终结论 ──────────────────
leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论")
leaderRes = await streamMultiAgent(leader, ids, coord,
"各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。")
finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印")
setLastLeaderContent(leaderRes.content)
setHasConsensus(true); setConsensusPercentage(100)
isOrchestrating = false
```
要点:
- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。
- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。
- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。
- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。
- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。
### 6.3 澄清 / 挂起 / 恢复 / 干预
- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。
- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。
- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。
### 6.4 多轮链条(扩展点,非 MVP)
单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。
### 6.5 init 复用
chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。
---
## 7. 分阶段实现计划
> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。
### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成
**目标**:打地基(含后端持久化),不含前端 UI。
- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2):
- [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。
- [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。
- [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。
- [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。
- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。
- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。
- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。
### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成
**目标**:能创建/编辑/保存/删除链条。
- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。
- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`:
- [x] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
- **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。
- **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。
- **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。
- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。
### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成
**目标**:弹窗顶部模式切换,智能推荐行为不变。
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
**目标**:草稿记住模式,回归不破。
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
### 工期粗估
| 阶段 | 估时 |
|------|------|
| 0 后端链条表 + 脚手架 | 1.5d |
| 1 配置页 | 2–3d |
| 2 弹窗模式壳 | 1d |
| 3 链条选择卡片 | 1.5d |
| 4 Step2 链编排 | 2–3d |
| 5 持久化兼容收尾 | 1d |
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
---
## 8. 验收清单(端到端)
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
---
## 9. 风险与开放问题
| # | 项 | 说明 / 建议 |
|---|----|-------------|
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
---
## 10. 路径速查(新增/改动)
| 资源 | 路径 | 状态 |
|------|------|------|
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
</content>
</invoke>
@@ -439,9 +439,8 @@ isOrchestrating = false
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。 > **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。 - **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。 - **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。 - **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。 - `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。 - **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。 - **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
@@ -482,8 +481,6 @@ isOrchestrating = false
### 6.2 `RecommendAgentsDialog` ### 6.2 `RecommendAgentsDialog`
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。 - **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。 - 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
- 阶段 UI(`InternalState.phase`): - 阶段 UI(`InternalState.phase`):
@@ -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
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` 与默认落地页可能需要调整,需同步确认。
-4
View File
@@ -49,7 +49,6 @@ frontend-web/
│ ├─ ScheduledTaskRunDetailPage.tsx │ ├─ ScheduledTaskRunDetailPage.tsx
│ ├─ PublicSharePage.tsx 免登录分享只读 │ ├─ PublicSharePage.tsx 免登录分享只读
│ ├─ PublicScheduledTaskPage.tsx │ ├─ PublicScheduledTaskPage.tsx
│ ├─ EmbedChatPage.tsx iframe 嵌入问答(见 docs/embed-iframe-chat.md)
│ ├─ LoginPage.tsx 用户名直登(开发/内网用) │ ├─ LoginPage.tsx 用户名直登(开发/内网用)
│ └─ ... │ └─ ...
├─ core/ 「领域核心」: API 客户端、领域 hooks、类型 ├─ core/ 「领域核心」: API 客户端、领域 hooks、类型
@@ -187,7 +186,6 @@ ReactDOM.createRoot(#root)
/login/:username LoginPage(按用户名直登) /login/:username LoginPage(按用户名直登)
/landing LandingPage /landing LandingPage
/share/:shareCode PublicSharePage(免登录只读) /share/:shareCode PublicSharePage(免登录只读)
/embed/chats/:thread_id EmbedChatPage(iframe 嵌入问答,见 docs/embed-iframe-chat.md)
/public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录) /public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录)
/page/* PageRoutes(含侧边栏与 Header) /page/* PageRoutes(含侧边栏与 Header)
* NotFoundPage * NotFoundPage
@@ -666,7 +664,6 @@ const { models, tokenUsageEnabled, isLoading } = useModels();
- 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/` - 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/`
- 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录 - 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录
- iframe 嵌入问答 `EmbedChatPage`(`/embed/chats/:thread_id`):第三方用普通 iframe 嵌套,父页面用 `sessionId` 轮询公开 API 判断问答是否结束,详见 [embed-iframe-chat.md](./embed-iframe-chat.md)
- 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带 - 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带
--- ---
@@ -1141,7 +1138,6 @@ setSettings("context", { model_name: "gpt-5" }); // 仅当前线程
| `/landing` | App.tsx | LandingPage.tsx | | `/landing` | App.tsx | LandingPage.tsx |
| `/share/:shareCode` | App.tsx | PublicSharePage.tsx | | `/share/:shareCode` | App.tsx | PublicSharePage.tsx |
| `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx | | `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx |
| `/embed/chats/:thread_id` | App.tsx | EmbedChatPage.tsx([embed-iframe-chat.md](./embed-iframe-chat.md)) |
| `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage | | `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage |
| `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 | | `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 |
| `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage | | `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage |
-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)。
-2
View File
@@ -14,9 +14,7 @@
})(); })();
</script> </script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title> <title>CM 助手</title>
<script src="/runtime-config.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -403
View File
@@ -15,10 +15,6 @@
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@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/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3", "@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -52,7 +48,6 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.33", "ai": "^6.0.33",
"axios": "1.16.1",
"best-effort-json-parser": "^1.2.1", "best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -60,12 +55,9 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1", "docx": "^9.5.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
@@ -98,7 +90,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0", "@types/gsap": "^3.0.0",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@@ -867,59 +858,6 @@
"w3c-keyname": "^2.2.4" "w3c-keyname": "^2.2.4"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1396,15 +1334,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
}, },
"node_modules/@fontsource/noto-sans-sc": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-sc/-/noto-sans-sc-5.2.9.tgz",
"integrity": "sha512-bTUIWGBgJDpwi5qAr+x0/lcgv80IHTB9vl6s2f6EymZEa7qYV99yNRBZuKFT+SYDKVunZrjCEhWtpxqmbXWl5Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@iconify/types": { "node_modules/@iconify/types": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
@@ -5066,13 +4995,6 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -5403,18 +5325,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ai": { "node_modules/ai": {
"version": "6.0.78", "version": "6.0.78",
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -5458,24 +5368,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/bail": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -5553,19 +5445,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -5730,18 +5609,6 @@
"@codemirror/view": "^6.0.0" "@codemirror/view": "^6.0.0"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -6279,10 +6146,9 @@
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.21", "version": "1.11.20",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="
"license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
@@ -6328,15 +6194,6 @@
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -6371,13 +6228,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/dnd-kit": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/dnd-kit/-/dnd-kit-0.0.2.tgz",
"integrity": "sha512-d8AYd6I7D2b5u882+QNVGw0slBAt851/LWZ2j/pU+onf5/TGEKXeb47sCyhPYKEAUXp4oLfvWfNCqfkU03R1lw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/docx": { "node_modules/docx": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
@@ -6434,20 +6284,6 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.349", "version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -6502,51 +6338,6 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
@@ -6679,48 +6470,6 @@
} }
} }
}, },
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/format": { "node_modules/format": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -6769,15 +6518,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6787,30 +6527,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": { "node_modules/get-nonce": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -6819,31 +6535,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -6859,33 +6550,6 @@
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==" "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": { "node_modules/hash.js": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
@@ -6895,18 +6559,6 @@
"minimalistic-assert": "^1.0.1" "minimalistic-assert": "^1.0.1"
} }
}, },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hast": { "node_modules/hast": {
"version": "1.0.0", "version": "1.0.0",
"license": "MIT" "license": "MIT"
@@ -7177,19 +6829,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -7812,15 +7451,6 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": { "node_modules/mdast-util-find-and-replace": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -8692,27 +8322,6 @@
} }
] ]
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": { "node_modules/minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -9223,15 +8832,6 @@
"prosemirror-transform": "^1.1.0" "prosemirror-transform": "^1.1.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode.js": { "node_modules/punycode.js": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+1 -14
View File
@@ -6,13 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "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", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -22,10 +17,6 @@
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@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/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3", "@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -59,7 +50,6 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.33", "ai": "^6.0.33",
"axios": "1.16.1",
"best-effort-json-parser": "^1.2.1", "best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -68,7 +58,6 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1", "docx": "^9.5.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
@@ -77,7 +66,6 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "^11.15.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -112,7 +100,6 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"lightningcss": "^1.30.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.0.15", "tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
+3 -9
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 { export default {
plugins: [ plugins: {
tailwindcss(), "@tailwindcss/postcss": {},
legacyColorFallback(), },
lightningCssLegacyChrome(),
],
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

-28
View File
@@ -1,28 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
<title id="title">CM 助手</title>
<defs>
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#5DB5FF"/>
<stop offset="1" stop-color="#328AF2"/>
</linearGradient>
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
<stop stop-color="#62B2FF"/>
<stop offset="1" stop-color="#4B98F4"/>
</linearGradient>
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#2C7CF0"/>
<stop offset="1" stop-color="#1F5EDA"/>
</linearGradient>
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#276DE3"/>
<stop offset="1" stop-color="#4E9DF5"/>
</linearGradient>
</defs>
<!-- The mark is drawn as four independent folded faces. Together they form
the open C-shaped cube used in the product reference. -->
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 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

-96
View File
@@ -1,96 +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__ = {
// 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: "",
// 轻应用「新建笔记」系列接口地址。
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.sit.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",
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
// 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "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;
+8 -105
View File
@@ -1,103 +1,44 @@
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes"; 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 { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync"; import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context"; import { I18nProvider } from "@/core/i18n/context";
import { fetchYLogConfig } from "@/core/log/y-log-config";
import { detectLocale } from "@/core/i18n/locale"; import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { ChatProvider } from "@/contexts/ChatContext"; import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext"; 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 { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage"; import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes"; import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage, { import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage"; 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 { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage"; import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
function AppProviders({ children }: { children: React.ReactNode }) { function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []); const locale = useMemo(() => detectLocale(), []);
const location = useLocation(); const location = useLocation();
const publicEmbedTheme = usePublicEmbedTheme(); const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
useEffect(() => {
syncEmbedSessionFromSearch(location.search);
}, [location.search]);
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
useEffect(() => {
void fetchYLogConfig();
}, []);
const forcedTheme = useMemo(() => {
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
const embedTheme = resolveForcedEmbedTheme(location.search);
if (embedTheme) return embedTheme;
const routeTheme = resolveForcedThemeForPath(location.pathname);
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]);
return ( return (
<NextThemesProvider <NextThemesProvider
attribute="class" attribute="class"
storageKey="strategy-theme" storageKey="strategy-theme"
defaultTheme="light" defaultTheme="light"
themes={["light", "dark", "lightblue"]}
enableSystem={false} enableSystem={false}
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
{/* QueryClientProvider must wrap ThemeProvider because the custom
ThemeProvider reads the admin-configured default via useQuery. */}
<QueryClientProvider> <QueryClientProvider>
<I18nProvider initialLocale={locale}> <I18nProvider initialLocale={locale}>
<ThemeProvider> <ThemeProvider>
<ChatProvider> <ChatProvider>
<AuthSyncWatcher /> <AuthSyncWatcher />
<DesensitizeWordsLoader />
<FontScaleApplier />
{children} {children}
</ChatProvider> </ChatProvider>
</ThemeProvider> </ThemeProvider>
@@ -107,60 +48,22 @@ function AppProviders({ children }: { children: React.ReactNode }) {
); );
} }
/**
* 根路由 `/` → `/login` 重定向,**保留 query string**。
* 外部系统的任务深链落在 `#/?taskId=...&goPath=...`,裸 `<Navigate to="/login">` 会丢掉
* query,导致 LoginPage 读不到参数;这里把 search 一并带过去。
*/
function RootRedirect() {
const { search } = useLocation();
return <Navigate to={`/login${search}`} replace />;
}
export function App() { export function App() {
return ( return (
<HashRouter> <HashRouter>
<AppProviders> <AppProviders>
<Routes> <Routes>
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} /> <Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} /> <Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} /> {/* Public read-only scheduled-task runs viewer — no authentication. */}
<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" />}
/>
<Route <Route
path="/public/scheduled-tasks/:taskId" path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />} element={<PublicScheduledTaskPage />}
/> />
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route
path="/roundtable-dashboard"
element={<DashboardStandalonePage />}
/>
<Route path="/page/*" element={<PageRoutes />} /> <Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 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,7 +1,6 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { copyTextToClipboard } from "@/core/clipboard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { import {
@@ -148,8 +147,13 @@ export const CodeBlockCopyButton = ({
const { code } = useContext(CodeBlockContext); const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try { try {
await copyTextToClipboard(code); await navigator.clipboard.writeText(code);
setIsCopied(true); setIsCopied(true);
onCopy?.(); onCopy?.();
setTimeout(() => setIsCopied(false), timeout); setTimeout(() => setIsCopied(false), timeout);
@@ -846,10 +846,7 @@ export const PromptInput = ({
ref={formRef} ref={formRef}
{...props} {...props}
> >
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome <InputGroup>{children}</InputGroup>
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>
</form> </form>
</PromptInputValidationContext.Provider> </PromptInputValidationContext.Provider>
); );
@@ -878,7 +875,6 @@ export type PromptInputTextareaProps = ComponentProps<
export const PromptInputTextarea = ({ export const PromptInputTextarea = ({
onChange, onChange,
onInput,
className, className,
placeholder = "What would you like to know?", placeholder = "What would you like to know?",
...props ...props
@@ -887,37 +883,6 @@ export const PromptInputTextarea = ({
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation(); const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false); 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]);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@@ -1000,15 +965,9 @@ export const PromptInputTextarea = ({
<InputGroupTextarea <InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)} className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message" name="message"
ref={textareaRef}
rows={3}
onCompositionEnd={() => setIsComposing(false)} onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)} onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onInput={(event) => {
resizeForLegacyChrome(event.currentTarget);
onInput?.(event);
}}
onPaste={handlePaste} onPaste={handlePaste}
placeholder={placeholder} placeholder={placeholder}
{...props} {...props}
File diff suppressed because it is too large. Load diff
@@ -1,20 +0,0 @@
import React from 'react';
const Empty = ({
description = '暂无数据',
size = 'default'
}: {
description?: string;
size?: 'default' | 'small';
}) => {
return (
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
<div className="empty-image">
<img src="/icon/empty-list.png" alt="暂无数据" />
</div>
{description && <p className="empty-description">{description}</p>}
</div>
);
};
export default Empty;
@@ -1,627 +0,0 @@
"use client";
import {
useEffect,
useRef,
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { authenticatedFetch } from "@/components/assistant-ui/document-upload-popup";
import { readRuntimeEnv } from "@/env";
interface NetworkActorProps {
inputValue: string;
searchSpaceId: string;
onSuccess?: () => void;
}
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
interface languageItem {
title: string;
checked: boolean;
}
interface PoliticsDirItem {
地区: string;
语言: string;
年龄段: string;
性别: string;
教育程度: string;
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
类型: string;
对HZZLC: string;
对MZZLC: string;
对TZZLC: string[];
账号描述: string;
发文频率: string;
活跃时间: string;
}
interface 用户画像 {
地区: string;
教育程度: string;
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
语言: string;
年龄段: string;
账号描述: string;
对TZZLC: string[];
对HZZLC: string;
对MZZLC: string[];
性别: string;
类型: string;
发文频率: string;
活跃时间: string;
}
interface hx_info {
语言: string;
用户画像: 用户画像;
}
interface UserDataItem {
comment_count: number;
country: string | null;
agent_task_id: string | null;
education: string | null;
groupId: string | null;
forward_count: number;
political_stand: string;
language: string;
type: string;
concern: string | null;
profile_image: string | null;
concern_topic: string | null;
screen_name: string;
school: string | null;
is_alive: number;
createdTime: string;
data_provider: string | null;
id: string;
text: string | null;
group: string | null;
read_count: number;
group_name: string;
area: string | null;
image: string | null;
updatedTime: string;
like_count: number;
m_politics_dir: string;
camp: string | null;
tags: string[];
army: string | null;
is_sync_a: number;
tweets_count: number;
groupName: string | null;
history_key: string;
user_id: string;
real_id: string | null;
h_politics_dir: string | null;
domain: string | null;
name: string | null;
position: string | null;
job: string | null;
age_round: string | null;
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info: hx_info;
gender: string | null;
agentInfo: PoliticsDirItem;
profile_url: string | null;
tagId: string[];
user_name: string;
a_update_time: string | null;
values: string | null;
follows_count: number;
title: string | null;
is_protected: number;
platform: string;
importType: string;
update_time: string;
key: string;
introduction: string | null;
brief: string | null;
creator: string;
history_title: string;
tweets_ratio: string | null;
is_verified: number;
t_politics_dir: string[];
eventMap: string | null;
fans_count: number;
a_create_time: string | null;
hobbies: string[];
location: string | null;
average_count: number;
ratio: string | null;
classification?: string | null;
groupDiscussTags?: string[];
checked?: boolean;
content?: string;
url?: string;
}
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
{ title: '繁体中文', checked: false },
{ title: '简体中文', checked: false },
{ title: '英文', checked: false },
{ title: '日文', checked: false },
{ title: '其他', checked: false },
]);
const languageList = useRef<string[]>([]);
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
const [allLanguage, setAllLanguage] = useState<boolean>(true);
const [sortType, setSortType] = useState<string>('fans_count');
const sortTypeValue = useRef('fans_count');
const [fansSort, setFansSort] = useState<string>('desc');
const sortValue = useRef('desc');
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
const [userList, setUserList] = useState<UserDataItem[]>([]);
const [searchText, setSearchText] = useState<string>(inputValue);
const [isSearching, setIsSearching] = useState<boolean>(false);
const [searchType, setSearchType] = useState<string>('network');
const searchValue = useRef<string>('network');
const [selNetCount, setSelNetCount] = useState<number>(0);
const [actorCount, setActorCount] = useState<number>(0);
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer';
const backendUrl = typeof window !== 'undefined'
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
: 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const searchChange = async () => {
setIsSearching(true);
if (searchValue.current === 'network') {
await agentUserSearch();
} else {
await agentGroupSearch();
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange();
};
const searchTypeChange = async (value: string) => {
setSearchType(value);
searchValue.current = value;
await searchChange();
};
const allPlatformChange = async () => {
setAllPlatforms(true);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
platformList.current = [];
await searchChange();
};
const handleTogglePlatform = async (index: number) => {
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
};
const allLanguageChange = async () => {
setAllLanguage(true);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
languageList.current = [];
await searchChange();
};
const handleToggleLanguage = async (index: number) => {
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
};
const sortTypeChange = async (type: string) => {
setSortType(type);
sortTypeValue.current = type;
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
};
const agentUserSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const agentGroupSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const listDataCheckedChange = (index: number, checked: boolean) => {
setUserList((prevSources) => {
const newSources = prevSources.map((item, aIndex) =>
aIndex === index ? { ...item, checked } : item
);
setSelectedSearchSources(newSources.filter((item) => item.checked));
return newSources;
});
};
const linkToB2 = (data: UserDataItem) => {
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
window.open(url, '_blank');
};
const handleBatchDelete = () => {
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
setSelectedSearchSources([]);
};
const handleImport = async () => {
if (selectedSearchSources.length === 0) {
toast.warning('没有可导入的来源');
return;
}
setIsImporting(true);
const dataList = selectedSearchSources.map((item) => ({
title: item.title,
content: item.content,
url: item.url,
}));
try {
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_space_id: Number(searchSpaceId),
source_list: dataList,
}),
}, true);
if (response.ok) {
const data = await response.json();
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
setSearchText('');
setUserList([]);
handleBatchDelete();
onSuccess?.();
} else {
const errorData = await response.json();
toast.error(errorData.detail || '导入失败,请重试');
}
} catch {
toast.error('导入失败,请重试');
} finally {
setIsImporting(false);
}
};
useEffect(() => {
void searchChange();
}, []);
return (
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
onClick={() => void allPlatformChange()}
>全部</span>
{platforms.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleTogglePlatform(index)}
>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
onClick={() => void allLanguageChange()}
>全部</span>
{languages.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
</span>
</span>
</div>
</div>
</div>
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
{searchType === 'network' && (
<>
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
导入选中
</Button>
</div>
</div>
)}
<div className="p-[10px]">
{isSearching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : null}
<div className={`grid grid-cols-4 gap-[10px] user-list${isSearching ? ' hidden' : ''}`}>
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
checked={item.checked ?? false}
onChange={() => listDataCheckedChange(index, !item.checked)}
className="w-4 h-4 cursor-pointer accent-primary"
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<span>实体人:{item.name}</span>
</div>
</div>
</div>
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
<div className="line-clamp-3 text-ellipsis overflow-hidden">
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -1,27 +0,0 @@
import { useEffect } from "react";
import { replaceDesensitizeMap } from "@/lib/desensitize";
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
/**
* 启动时把后台「敏感词管理」配置加载进运行时脱敏映射表。
*
* - 后台返回**非空**时:用其中「启用」的词条整体替换运行时映射
* ({@link replaceDesensitizeMap}),后台即唯一真源,被删除的词不再生效。
* - 后台为空(全新部署还没配置)或拉取失败:什么都不做,保持使用
* `src/lib/desensitize.ts` 内置的种子映射。
*
* 渲染 `null`;挂在 `AppProviders` 里(QueryClientProvider 之内)。与「敏感词管理」
* 页共享同一份 React Query 缓存,保存后会自动刷新并回灌运行时映射。
*/
export function DesensitizeWordsLoader() {
const { data } = useSensitiveWords();
useEffect(() => {
if (data && data.length > 0) {
replaceDesensitizeMap(wordsToMap(data));
}
}, [data]);
return null;
}
@@ -1,45 +0,0 @@
"use client";
import { useEffect } from "react";
import { useLocalSettings } from "@/core/settings";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[] =
[
{ value: 0.875, label: "小", px: 14 },
{ value: 1, label: "标准", px: 16 },
{ value: 1.125, label: "大", px: 18 },
{ value: 1.25, label: "特大", px: 20 },
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
return null;
}
@@ -1,45 +0,0 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
@@ -1,956 +0,0 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ReactNode,
} from "react";
import type {
GraphEdge,
GraphNode,
LayoutType,
SimEdge,
SimNode,
TooltipState,
Transform,
} from "./graphTypes";
import {
applyCircularLayout,
ForceSimulation,
getEdgeId,
getEdgeInfo,
getEdgeLabel,
getLinkDistance,
getNodeKind,
getNodeRadius,
getNodeTitle,
NODE_KIND_META,
seedPositions,
truncateLabel,
} from "./graphUtils";
const MIN_ZOOM = 0.15;
const MAX_ZOOM = 4;
/** Canvas-drawn colors per app theme (CSS variables don't reach the 2D context). */
const CANVAS_PALETTE = {
dark: {
exportBg: "#0b1020",
labelHalo: "rgba(2, 6, 23, 0.9)",
nodeRim: "rgba(2, 6, 23, 0.55)",
rootRim: "#e0f2fe",
expandedRim: "#7dd3fc",
rootRing: "rgba(125, 211, 252, 0.9)",
expandedDot: "#7dd3fc",
expandedDotRim: "rgba(2, 6, 23, 0.8)",
selectionRing: "#ffffff",
hoverRing: "rgba(255, 255, 255, 0.85)",
pin: "rgba(255, 255, 255, 0.8)",
labelActive: "#ffffff",
label: "#e2e8f0",
minimapViewport: "rgba(56, 189, 248, 0.8)",
},
light: {
exportBg: "#f8fafc",
labelHalo: "rgba(255, 255, 255, 0.92)",
nodeRim: "rgba(255, 255, 255, 0.9)",
rootRim: "#075985",
expandedRim: "#0284c7",
rootRing: "rgba(2, 132, 199, 0.85)",
expandedDot: "#0284c7",
expandedDotRim: "rgba(255, 255, 255, 0.9)",
selectionRing: "#0f172a",
hoverRing: "rgba(15, 23, 42, 0.7)",
pin: "rgba(15, 23, 42, 0.6)",
labelActive: "#0f172a",
label: "#334155",
minimapViewport: "rgba(2, 132, 199, 0.8)",
},
} as const;
export interface GraphCanvasHandle {
zoomIn: () => void;
zoomOut: () => void;
fitView: () => void;
relayout: () => void;
focusNode: (id: string) => void;
exportImage: () => void;
}
interface GraphCanvasProps {
nodes: GraphNode[];
edges: GraphEdge[];
/** Degrees computed on the FULL graph so node sizes stay stable under filters. */
degrees: Map<string, number>;
selectedNodeId: string | null;
/** null = no active search; otherwise the set of matching node ids. */
searchMatchIds: Set<string> | null;
layoutType: LayoutType;
showLabels: boolean;
/** App theme; drives the canvas-drawn colors (labels, rims, rings). */
theme: "light" | "dark";
onSelectNode: (id: string | null) => void;
onTooltip: (tooltip: TooltipState | null) => void;
onZoomChange: (k: number) => void;
/**
* Explore-mode rendering context; null/undefined renders the normal full view.
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
*/
explore?: {
preview: boolean;
rootId: string | null;
nodeDepthMap: Map<string, number>;
expandedNodeIds: Set<string>;
} | null;
/** Floating pills rendered top-left over the canvas. */
toolbar?: ReactNode;
/** Small floating hint rendered bottom-center over the canvas. */
hint?: ReactNode;
/** Centered overlay (empty / loading states). */
overlay?: ReactNode;
}
interface DragState {
pointerId: number;
node: SimNode | null;
startX: number;
startY: number;
originX: number;
originY: number;
moved: boolean;
}
const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3);
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const minimapRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<ForceSimulation | null>(null);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
const labelThresholdRef = useRef(3);
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
const sizeRef = useRef({ w: 0, h: 0 });
const hoverRef = useRef<SimNode | null>(null);
const dragRef = useRef<DragState | null>(null);
const animRef = useRef<{
from: Transform;
to: Transform;
start: number;
duration: number;
} | null>(null);
const needsDrawRef = useRef(true);
const autoFitRef = useRef(false);
// While the initial layout is still settling we keep the whole graph in
// view every frame; any pan/zoom/drag from the user takes over.
const interactedRef = useRef(false);
const rafRef = useRef(0);
// Latest props readable from the rAF loop / event handlers.
const propsRef = useRef(props);
propsRef.current = props;
const requestDraw = useCallback(() => {
needsDrawRef.current = true;
}, []);
// ---------------------------------------------------------------- build
useEffect(() => {
const previous = nodeMapRef.current;
const simNodes: SimNode[] = props.nodes.map((n) => {
const degree = props.degrees.get(n.id) ?? 0;
const kind = getNodeKind(n);
const old = previous.get(n.id);
return {
id: n.id,
node: n,
kind,
degree,
radius: getNodeRadius(degree),
color: NODE_KIND_META[kind].color,
x: old?.x ?? Number.NaN,
y: old?.y ?? Number.NaN,
vx: 0,
vy: 0,
fx: old?.fx ?? null,
fy: old?.fy ?? null,
};
});
const fresh = simNodes.filter((n) => Number.isNaN(n.x));
if (fresh.length === simNodes.length) {
seedPositions(simNodes);
} else {
// Filters re-enabled some nodes: spiral the newcomers in near the center.
fresh.forEach((n, i) => {
const r = 60 + 16 * Math.sqrt(i + 1);
const angle = i * 2.3999632297;
n.x = r * Math.cos(angle);
n.y = r * Math.sin(angle);
});
}
const byId = new Map(simNodes.map((n) => [n.id, n]));
const simEdges: SimEdge[] = [];
const adjacency = new Map<string, Set<string>>();
for (const e of props.edges) {
const source = byId.get(e.source);
const target = byId.get(e.target);
if (!source || !target) continue;
const label = getEdgeLabel(e);
simEdges.push({
id: getEdgeId(e),
source,
target,
label,
info: getEdgeInfo(label),
weight: e.weight ?? 1,
distance: getLinkDistance(label) + source.radius + target.radius,
});
if (!adjacency.has(source.id)) adjacency.set(source.id, new Set());
if (!adjacency.has(target.id)) adjacency.set(target.id, new Set());
adjacency.get(source.id)!.add(target.id);
adjacency.get(target.id)!.add(source.id);
}
// Label budget: always label hubs; ~20 labels max by default.
const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a);
labelThresholdRef.current = Math.max(
2,
sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2,
);
nodeMapRef.current = byId;
adjacencyRef.current = adjacency;
const sim = new ForceSimulation(simNodes, simEdges);
if (props.layoutType === "circular") {
applyCircularLayout(simNodes);
sim.alpha = 0;
} else {
sim.alpha = previous.size === 0 ? 1 : 0.5;
}
simRef.current = sim;
if (previous.size === 0) {
autoFitRef.current = false;
interactedRef.current = false;
// Pre-settle a fresh force layout synchronously (time-bounded) so the
// first paint is already roughly laid out instead of an exploding
// cloud that drifts out of the viewport.
if (props.layoutType === "force") {
const budget = simNodes.length > 350 ? 280 : 120;
const deadline = performance.now() + budget;
let guard = 0;
while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) {
sim.tick();
guard += 1;
}
}
}
requestDraw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.nodes, props.edges, props.degrees]);
// Layout switching.
const layoutRef = useRef(props.layoutType);
useEffect(() => {
if (layoutRef.current === props.layoutType) return;
layoutRef.current = props.layoutType;
const sim = simRef.current;
if (!sim) return;
if (props.layoutType === "circular") {
applyCircularLayout(sim.nodes);
sim.alpha = 0;
} else {
for (const n of sim.nodes) {
n.fx = null;
n.fy = null;
}
sim.reheat(0.9);
}
autoFitRef.current = false;
interactedRef.current = false;
requestDraw();
}, [props.layoutType, requestDraw]);
// Redraw on highlight-affecting prop changes.
useEffect(() => {
requestDraw();
}, [
props.selectedNodeId,
props.searchMatchIds,
props.showLabels,
props.explore,
props.theme,
requestDraw,
]);
// ------------------------------------------------------------ transform
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
// Notify React of zoom changes only when k actually moved — a state
// update per animation frame re-renders the whole page while settling.
const lastNotifiedZoomRef = useRef(0);
const setTransform = useCallback((t: Transform) => {
transformRef.current = t;
if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) {
lastNotifiedZoomRef.current = t.k;
propsRef.current.onZoomChange(t.k);
}
needsDrawRef.current = true;
}, []);
const animateTo = useCallback((to: Transform, duration = 350) => {
animRef.current = {
from: { ...transformRef.current },
to,
start: performance.now(),
duration,
};
needsDrawRef.current = true;
}, []);
const zoomBy = useCallback(
(factor: number, cx?: number, cy?: number) => {
interactedRef.current = true;
const t = transformRef.current;
const { w, h } = sizeRef.current;
const px = cx ?? w / 2;
const py = cy ?? h / 2;
const k = clampZoom(t.k * factor);
const wx = (px - t.x) / t.k;
const wy = (py - t.y) / t.k;
setTransform({ x: px - wx * k, y: py - wy * k, k });
},
[setTransform],
);
const computeFitTransform = useCallback((): Transform | null => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
// A single bad coordinate must not poison the viewport transform.
if (!Number.isFinite(minX + minY + maxX + maxY)) return null;
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
}, []);
const fitView = useCallback(
(animated = true) => {
const target = computeFitTransform();
if (!target) return;
if (animated) animateTo(target);
else setTransform(target);
},
[animateTo, computeFitTransform, setTransform],
);
const focusNode = useCallback(
(id: string) => {
const node = nodeMapRef.current.get(id);
const { w, h } = sizeRef.current;
if (!node || !w || !h) return;
interactedRef.current = true;
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
},
[animateTo],
);
const relayout = useCallback(() => {
const sim = simRef.current;
if (!sim) return;
if (layoutRef.current === "circular") {
applyCircularLayout(sim.nodes);
sim.alpha = 0;
fitView(true);
} else {
seedPositions(sim.nodes);
sim.alpha = 1;
autoFitRef.current = false;
interactedRef.current = false;
}
requestDraw();
}, [fitView, requestDraw]);
const exportImage = useCallback(() => {
const main = canvasRef.current;
if (!main) return;
const out = document.createElement("canvas");
out.width = main.width;
out.height = main.height;
const ctx = out.getContext("2d");
if (!ctx) return;
ctx.fillStyle = CANVAS_PALETTE[propsRef.current.theme].exportBg;
ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(main, 0, 0);
out.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "knowledge-graph.png";
a.click();
URL.revokeObjectURL(url);
}, "image/png");
}, []);
useImperativeHandle(
ref,
() => ({
zoomIn: () => zoomBy(1.25),
zoomOut: () => zoomBy(1 / 1.25),
fitView: () => fitView(true),
relayout,
focusNode,
exportImage,
}),
[zoomBy, fitView, relayout, focusNode, exportImage],
);
// ----------------------------------------------------------------- draw
const draw = useCallback(() => {
const canvas = canvasRef.current;
const sim = simRef.current;
if (!canvas || !sim) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const { w, h } = sizeRef.current;
const t = transformRef.current;
const p = propsRef.current;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.translate(t.x, t.y);
ctx.scale(t.k, t.k);
const hover = hoverRef.current;
const activeId = hover?.id ?? p.selectedNodeId;
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
const search = p.searchMatchIds;
const k = t.k || 1;
const explore = p.explore;
const palette = CANVAS_PALETTE[p.theme];
// ---- edges
for (const e of sim.edges) {
const touchesActive =
activeId !== null &&
(e.source.id === activeId || e.target.id === activeId);
let alpha: number;
if (activeId) alpha = touchesActive ? 0.95 : 0.07;
else if (search)
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
else alpha = 0.38;
if (explore && !explore.preview) {
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
const edgeDepth = Math.max(sourceDepth, targetDepth);
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
}
ctx.globalAlpha = alpha;
ctx.strokeStyle = e.info.color;
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
ctx.setLineDash(e.info.dashed ? [6 / k, 5 / k] : []);
ctx.beginPath();
ctx.moveTo(e.source.x, e.source.y);
ctx.lineTo(e.target.x, e.target.y);
ctx.stroke();
// Relation label at the midpoint while a node is focused.
if (touchesActive && k >= 0.55) {
const mx = (e.source.x + e.target.x) / 2;
const my = (e.source.y + e.target.y) / 2;
ctx.setLineDash([]);
ctx.font = `${10.5 / k}px "Noto Sans SC", system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = 3 / k;
ctx.strokeStyle = palette.labelHalo;
ctx.strokeText(e.info.zh, mx, my);
ctx.fillStyle = e.info.color;
ctx.fillText(e.info.zh, mx, my);
}
}
ctx.setLineDash([]);
// ---- nodes
const labelThreshold = labelThresholdRef.current;
for (const n of sim.nodes) {
const isActive = n.id === activeId;
const isSelected = n.id === p.selectedNodeId;
const isNeighbor = neighbors?.has(n.id) ?? false;
const depth = explore?.nodeDepthMap.get(n.id);
const isExploreRoot = explore?.rootId === n.id;
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
let alpha: number;
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
else alpha = 1;
let baseRadius = n.radius;
let labelByExplore = false;
let strokeWidth = 1.4 / k;
let strokeColor: string = palette.nodeRim;
let fillColor = n.color;
if (explore?.preview) {
alpha *= 0.42;
baseRadius = Math.max(8, n.radius * 0.86);
labelByExplore = isActive || isSelected;
} else if (explore && depth != null) {
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
alpha *= Math.max(0.45, 1 - depth * 0.12);
labelByExplore = depth <= 2 || isActive || isSelected;
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
strokeColor =
depth === 0
? palette.rootRim
: isExpanded
? palette.expandedRim
: palette.nodeRim;
if (isExploreRoot) fillColor = "#38bdf8";
}
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
ctx.globalAlpha = alpha;
// Glow for hubs and the active node. Canvas shadows are expensive,
// so on large graphs only the focused nodes keep theirs.
const glowEligible =
sim.nodes.length <= 450
? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold
: isActive || isSelected || isExploreRoot;
if (alpha > 0.5 && glowEligible) {
ctx.shadowColor = fillColor;
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
} else {
ctx.shadowBlur = 0;
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = fillColor;
ctx.fill();
ctx.shadowBlur = 0;
// Rim.
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.stroke();
if (isExploreRoot) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.4 / k;
ctx.strokeStyle = palette.rootRing;
ctx.stroke();
} else if (isExpanded) {
ctx.beginPath();
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
ctx.fillStyle = palette.expandedDot;
ctx.fill();
ctx.lineWidth = 1 / k;
ctx.strokeStyle = palette.expandedDotRim;
ctx.stroke();
}
// Selection: double ring.
if (isSelected) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 4 / k, 0, Math.PI * 2);
ctx.lineWidth = 2 / k;
ctx.strokeStyle = palette.selectionRing;
ctx.stroke();
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.2 / k;
ctx.strokeStyle = n.color;
ctx.stroke();
} else if (n.id === hover?.id) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 3.5 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.6 / k;
ctx.strokeStyle = palette.hoverRing;
ctx.stroke();
}
// Pin marker for dragged-and-fixed nodes.
if (n.fx != null && !isSelected) {
ctx.beginPath();
ctx.arc(n.x, n.y, Math.max(r * 0.28, 2 / k), 0, Math.PI * 2);
ctx.fillStyle = palette.pin;
ctx.fill();
}
// Label.
const wantsLabel =
isActive ||
isSelected ||
isNeighbor ||
labelByExplore ||
(search?.has(n.id) ?? false) ||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
if (wantsLabel && alpha > 0.5) {
const text = truncateLabel(getNodeTitle(n.node), isActive ? 26 : 14);
const fontSize = (isActive || isSelected ? 13 : 12) / k;
ctx.font = `600 ${fontSize}px "Noto Sans SC", system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.lineWidth = 3.5 / k;
ctx.strokeStyle = palette.labelHalo;
ctx.strokeText(text, n.x, n.y + r + 5 / k);
ctx.fillStyle = isActive || isSelected ? palette.labelActive : palette.label;
ctx.fillText(text, n.x, n.y + r + 5 / k);
}
}
ctx.globalAlpha = 1;
drawMinimap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const drawMinimap = useCallback(() => {
const minimap = minimapRef.current;
const sim = simRef.current;
if (!minimap || !sim || !sim.nodes.length) return;
const ctx = minimap.getContext("2d");
if (!ctx) return;
const mw = minimap.width;
const mh = minimap.height;
ctx.clearRect(0, 0, mw, mh);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x);
maxY = Math.max(maxY, n.y);
}
const pad = 10;
const scale = Math.min(
(mw - pad * 2) / Math.max(maxX - minX, 1),
(mh - pad * 2) / Math.max(maxY - minY, 1),
);
const ox = (mw - (maxX - minX) * scale) / 2 - minX * scale;
const oy = (mh - (maxY - minY) * scale) / 2 - minY * scale;
for (const n of sim.nodes) {
ctx.beginPath();
ctx.arc(n.x * scale + ox, n.y * scale + oy, 1.6, 0, Math.PI * 2);
ctx.fillStyle = n.color;
ctx.globalAlpha = 0.9;
ctx.fill();
}
ctx.globalAlpha = 1;
// Viewport rectangle.
const t = transformRef.current;
const { w, h } = sizeRef.current;
if (t.k > 0 && w && h) {
const x0 = ((0 - t.x) / t.k) * scale + ox;
const y0 = ((0 - t.y) / t.k) * scale + oy;
const x1 = ((w - t.x) / t.k) * scale + ox;
const y1 = ((h - t.y) / t.k) * scale + oy;
ctx.strokeStyle = CANVAS_PALETTE[propsRef.current.theme].minimapViewport;
ctx.lineWidth = 1;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
}
}, []);
// ----------------------------------------------------------------- loop
useEffect(() => {
const loop = () => {
const sim = simRef.current;
let active = false;
const anim = animRef.current;
if (anim) {
const progress = Math.min(
(performance.now() - anim.start) / anim.duration,
1,
);
const e = easeCubicOut(progress);
transformRef.current = {
x: anim.from.x + (anim.to.x - anim.from.x) * e,
y: anim.from.y + (anim.to.y - anim.from.y) * e,
k: anim.from.k + (anim.to.k - anim.from.k) * e,
};
if (progress >= 1) {
animRef.current = null;
lastNotifiedZoomRef.current = transformRef.current.k;
propsRef.current.onZoomChange(transformRef.current.k);
}
active = true;
}
if (sim && layoutRef.current === "force" && sim.tick()) {
active = true;
// Until the user pans/zooms/drags, gently track the settling layout
// so it stays in view without the viewport snapping every frame.
if (!interactedRef.current && sizeRef.current.w) {
const target = computeFitTransform();
if (target) {
const t = transformRef.current;
const ease = t.k === 0 ? 1 : 0.16;
setTransform({
x: t.x + (target.x - t.x) * ease,
y: t.y + (target.y - t.y) * ease,
k: t.k + (target.k - t.k) * ease,
});
}
autoFitRef.current = true;
} else if (!autoFitRef.current && sim.alpha < 0.3) {
autoFitRef.current = true;
fitView(true);
}
} else if (sim && !autoFitRef.current && sim.nodes.length && sizeRef.current.w) {
autoFitRef.current = true;
fitView(false);
}
if (active || needsDrawRef.current) {
needsDrawRef.current = false;
draw();
}
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [computeFitTransform, draw, fitView, setTransform]);
// --------------------------------------------------------------- resize
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
let timer: number | undefined;
const apply = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current = { w: rect.width, h: rect.height };
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
if (transformRef.current.k === 0) {
setTransform({ x: rect.width / 2, y: rect.height / 2, k: 0.9 });
}
requestDraw();
};
apply();
const observer = new ResizeObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(apply, 120);
});
observer.observe(container);
return () => {
window.clearTimeout(timer);
observer.disconnect();
};
}, [requestDraw, setTransform]);
// --------------------------------------------------------------- events
const screenToWorld = (sx: number, sy: number) => {
const t = transformRef.current;
return { x: (sx - t.x) / t.k, y: (sy - t.y) / t.k };
};
const hitTest = (wx: number, wy: number): SimNode | null => {
const sim = simRef.current;
if (!sim) return null;
// Topmost (drawn last) first.
for (let i = sim.nodes.length - 1; i >= 0; i--) {
const n = sim.nodes[i];
const dx = wx - n.x;
const dy = wy - n.y;
const r = n.radius + 3 / (transformRef.current.k || 1);
if (dx * dx + dy * dy <= r * r) return n;
}
return null;
};
const localPoint = (e: { clientX: number; clientY: number }) => {
const rect = canvasRef.current!.getBoundingClientRect();
return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
};
const setHover = (node: SimNode | null, e?: React.PointerEvent) => {
if (hoverRef.current?.id !== node?.id) {
hoverRef.current = node;
requestDraw();
}
const canvas = canvasRef.current;
if (canvas) canvas.style.cursor = node ? "pointer" : "grab";
if (node && e) {
propsRef.current.onTooltip({
x: e.clientX,
y: e.clientY,
node: node.node,
kind: node.kind,
degree: node.degree,
});
} else {
propsRef.current.onTooltip(null);
}
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
const { sx, sy } = localPoint(e);
const { x: wx, y: wy } = screenToWorld(sx, sy);
const node = hitTest(wx, wy);
animRef.current = null;
dragRef.current = {
pointerId: e.pointerId,
node,
startX: sx,
startY: sy,
originX: transformRef.current.x,
originY: transformRef.current.y,
moved: false,
};
if (node) {
node.fx = node.x;
node.fy = node.y;
}
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const drag = dragRef.current;
if (drag && drag.pointerId === e.pointerId) {
const dx = sx - drag.startX;
const dy = sy - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) {
drag.moved = true;
// Only a real pan/drag takes over the camera; a plain click keeps
// the auto-follow so the settling graph can't drift out of view.
interactedRef.current = true;
}
if (drag.node) {
const world = screenToWorld(sx, sy);
drag.node.fx = world.x;
drag.node.fy = world.y;
if (layoutRef.current === "force") simRef.current?.reheat(0.3);
propsRef.current.onTooltip(null);
} else {
transformRef.current = {
...transformRef.current,
x: drag.originX + dx,
y: drag.originY + dy,
};
}
requestDraw();
return;
}
const world = screenToWorld(sx, sy);
setHover(hitTest(world.x, world.y), e);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
dragRef.current = null;
if (!drag.moved) {
// Plain click: select node / clear selection on empty space.
if (drag.node) {
drag.node.fx = null;
drag.node.fy = null;
propsRef.current.onSelectNode(drag.node.id);
} else {
propsRef.current.onSelectNode(null);
}
requestDraw();
}
// A real drag leaves the node pinned at its drop position.
};
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const world = screenToWorld(sx, sy);
const node = hitTest(world.x, world.y);
if (node) {
propsRef.current.onSelectNode(node.id);
focusNode(node.id);
}
};
const handlePointerLeave = () => {
setHover(null);
};
// Wheel zoom needs a non-passive listener to preventDefault.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
interactedRef.current = true;
const rect = canvas.getBoundingClientRect();
zoomBy(
Math.exp(-e.deltaY * 0.0012),
e.clientX - rect.left,
e.clientY - rect.top,
);
};
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener("wheel", onWheel);
}, [zoomBy]);
const minimapVisible = useMemo(() => props.nodes.length > 0, [props.nodes]);
return (
<div className="kg-canvas-wrap" ref={containerRef}>
<canvas
ref={canvasRef}
className="kg-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onDoubleClick={handleDoubleClick}
/>
{props.toolbar ? (
<div className="kg-canvas-toolbar">{props.toolbar}</div>
) : null}
{props.hint}
{minimapVisible ? (
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
) : null}
{props.overlay}
</div>
);
},
);
@@ -1,287 +0,0 @@
import {
Ban,
Compass,
Crosshair,
EyeOff,
FileText,
Loader2,
MousePointerClick,
Waypoints,
X,
} from "lucide-react";
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { GraphNode } from "./graphTypes";
import {
getEdgeInfo,
getNodeKind,
getNodeTitle,
NODE_KIND_META,
} from "./graphUtils";
const SOURCE_TYPE_ZH: Record<string, string> = {
thread: "对话沉淀",
search: "搜索沉淀",
tool: "工具结果",
file: "文件导入",
manual: "手动创建",
report: "报告",
};
const STATUS_ZH: Record<string, string> = {
draft: "草稿",
approved: "已审核",
archived: "已归档",
};
export interface NeighborItem {
node: GraphNode;
relation: string;
}
interface GraphDetailPanelProps {
node: GraphNode | null;
degree: number;
neighbors: NeighborItem[];
/** Note detail (only for note-type nodes; undefined while absent/loading). */
note: KnowledgeNote | undefined;
noteLoading: boolean;
/** Explore-mode context; null when in full mode. */
explore: {
/** Depth of this node in the explore graph; null = not yet visible. */
depth: number | null;
expanded: boolean;
isRoot: boolean;
} | null;
onViewSource: (noteId: string) => void;
onLocate: (id: string) => void;
onHide: (id: string) => void;
onBlacklist: (id: string) => void;
onPickNeighbor: (id: string) => void;
onExpandNode: (id: string) => void;
onSetExploreRoot: (id: string) => void;
onClose: () => void;
}
function formatTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
export function GraphDetailPanel(props: GraphDetailPanelProps) {
const { node } = props;
return (
<aside className="kg-panel kg-detail">
<div className="kg-panel-header">
<span className="kg-panel-title">节点详情</span>
<button
type="button"
className="kg-panel-close-btn"
title="收起详情面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
{!node ? (
<div className="kg-detail-empty">
<MousePointerClick size={28} />
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
<div>
点击图谱中的文档、主题或实体节点,
<br />
查看摘要、标签、关联关系和相邻节点。
</div>
</div>
) : (
<DetailBody {...props} node={node} />
)}
</aside>
);
}
function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
const { node, note } = props;
const kind = getNodeKind(node);
const kindMeta = NODE_KIND_META[kind];
const isNote = (node.type || "").toLowerCase() === "note";
return (
<div className="kg-panel-content">
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
<div>
<span
className="kg-detail-type"
style={{
background: `${kindMeta.color}26`,
color: kindMeta.color,
}}
>
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
{kindMeta.label}
{node.category && node.category !== kindMeta.label
? ` · ${node.category}`
: ""}
</span>
<span className="kg-detail-type kg-detail-type-neutral">
度数 {props.degree}
</span>
{props.explore && props.explore.depth !== null ? (
<>
<span className="kg-detail-type kg-detail-type-primary">
{props.explore.isRoot
? "探索起点"
: `第 ${props.explore.depth} 层`}
</span>
<span
className={`kg-detail-type ${
props.explore.expanded
? "kg-detail-type-success"
: "kg-detail-type-warn"
}`}
>
{props.explore.expanded ? "已展开" : "可展开"}
</span>
</>
) : null}
</div>
{props.noteLoading ? (
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
</div>
) : null}
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
{note?.tags?.length ? (
<div className="kg-tag-list">
{note.tags.map((tag) => (
<span key={tag} className="kg-tag">
#{tag}
</span>
))}
</div>
) : null}
{note ? (
<div className="kg-detail-meta">
<span className="kg-detail-meta-label">来源</span>
<span className="kg-detail-meta-value">
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
</span>
<span className="kg-detail-meta-label">状态</span>
<span className="kg-detail-meta-value">
{STATUS_ZH[note.status] ?? note.status}
</span>
<span className="kg-detail-meta-label">创建时间</span>
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
<span className="kg-detail-meta-label">更新时间</span>
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
{note.created_by_name ? (
<>
<span className="kg-detail-meta-label">创建人</span>
<span className="kg-detail-meta-value">{note.created_by_name}</span>
</>
) : null}
</div>
) : null}
<div className="kg-detail-actions">
{isNote ? (
<button
type="button"
className="kg-icon-button"
onClick={() => props.onViewSource(node.id)}
>
<FileText size={14} /> 查看原文
</button>
) : null}
<button
type="button"
className="kg-icon-button"
onClick={() => props.onLocate(node.id)}
>
<Crosshair size={14} /> 定位节点
</button>
<button
type="button"
className="kg-icon-button"
onClick={() => props.onHide(node.id)}
>
<EyeOff size={14} /> 隐藏节点
</button>
<button
type="button"
className="kg-icon-button"
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
onClick={() => props.onBlacklist(node.id)}
>
<Ban size={14} /> 加入黑名单
</button>
{props.explore ? (
<>
<button
type="button"
className="kg-icon-button"
disabled={props.explore.depth === null || props.explore.expanded}
title={
props.explore.expanded
? "该节点的一跳邻居已全部展开"
: "展开该节点的一跳邻居"
}
onClick={() => props.onExpandNode(node.id)}
>
<Waypoints size={14} /> 展开一跳邻居
</button>
<button
type="button"
className="kg-icon-button"
title="清空当前探索路径,以此节点为新起点"
onClick={() => props.onSetExploreRoot(node.id)}
>
<Compass size={14} /> 以此为新起点
</button>
</>
) : null}
</div>
<h3 className="kg-detail-section-title">
关联节点({props.neighbors.length})
</h3>
{props.neighbors.length === 0 ? (
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
) : (
<div className="kg-neighbor-list">
{props.neighbors.map(({ node: neighbor, relation }) => {
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
const relationInfo = getEdgeInfo(relation);
return (
<div
key={`${neighbor.id}:${relation}`}
className="kg-neighbor-item"
onClick={() => props.onPickNeighbor(neighbor.id)}
>
<span
className="kg-legend-dot"
style={{ background: neighborKind.color, flexShrink: 0 }}
/>
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
<span
className="kg-neighbor-relation"
style={{ color: relationInfo.color }}
>
{relationInfo.zh}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -1,57 +0,0 @@
import type { NodeKind } from "./graphTypes";
import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils";
interface GraphFooterProps {
nodeKindCounts: Map<NodeKind, number>;
edgeLabels: string[];
zoomLevel: number;
selectedTitle: string | null;
visibleNodes: number;
visibleEdges: number;
}
export function GraphFooter(props: GraphFooterProps) {
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
return (
<footer className="kg-footer">
<div className="kg-legend">
{kinds.map((kind) => (
<span key={kind} className="kg-legend-item">
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
{NODE_KIND_META[kind].label}
</span>
))}
{props.edgeLabels.slice(0, 6).map((label) => {
const info = getEdgeInfo(label);
return (
<span key={label} className="kg-legend-item">
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
{info.zh}
</span>
);
})}
</div>
<div className="kg-footer-status">
{props.selectedTitle ? (
<span className="kg-footer-selected" title={props.selectedTitle}>
选中:{props.selectedTitle}
</span>
) : null}
<span>
显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系
</span>
<span>缩放 {Math.round(props.zoomLevel * 100)}%</span>
</div>
</footer>
);
}
@@ -1,124 +0,0 @@
import {
ArrowLeft,
Download,
Loader2,
Maximize2,
RefreshCw,
Scan,
Shuffle,
ZoomIn,
ZoomOut,
} from "lucide-react";
interface GraphHeaderProps {
nodeCount: number;
edgeCount: number;
totalNodes: number;
totalEdges: number;
refreshing: boolean;
onBack: () => void;
onRelayout: () => void;
onFitView: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onExport: () => void;
onFullscreen: () => void;
onRefresh: () => void;
}
export function GraphHeader(props: GraphHeaderProps) {
const filteredNodes =
props.nodeCount === props.totalNodes
? `${props.totalNodes}`
: `${props.nodeCount}/${props.totalNodes}`;
const filteredEdges =
props.edgeCount === props.totalEdges
? `${props.totalEdges}`
: `${props.edgeCount}/${props.totalEdges}`;
return (
<header className="kg-header">
<div className="kg-header-left">
<button type="button" className="kg-back" onClick={props.onBack}>
<ArrowLeft size={15} />
<span className="kg-button-text">返回</span>
</button>
<div className="kg-title-block">
<h1 className="kg-title">知识图谱</h1>
<span className="kg-subtitle">知识关系可视化</span>
</div>
<div className="kg-stat-pills">
<span className="kg-stat-pill">{filteredNodes} 个节点</span>
<span className="kg-stat-pill kg-stat-pill-violet">
{filteredEdges} 条关系
</span>
</div>
</div>
<div className="kg-header-actions">
<button
type="button"
className="kg-icon-button"
title="重新布局"
onClick={props.onRelayout}
>
<Shuffle size={15} />
<span className="kg-button-text">重新布局</span>
</button>
<button
type="button"
className="kg-icon-button"
title="适应视图"
onClick={props.onFitView}
>
<Scan size={15} />
<span className="kg-button-text">适应视图</span>
</button>
<button
type="button"
className="kg-icon-button"
title="放大"
onClick={props.onZoomIn}
>
<ZoomIn size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="缩小"
onClick={props.onZoomOut}
>
<ZoomOut size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="导出图片"
onClick={props.onExport}
>
<Download size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="全屏"
onClick={props.onFullscreen}
>
<Maximize2 size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="刷新图谱数据"
onClick={props.onRefresh}
disabled={props.refreshing}
>
{props.refreshing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<RefreshCw size={15} />
)}
</button>
</div>
</header>
);
}
@@ -1,292 +0,0 @@
import { ChevronDown, ChevronRight, Eraser, Search, X } from "lucide-react";
import { useState } from "react";
import type { GraphBlacklistItem } from "@/core/knowledge/api";
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
import {
getEdgeInfo,
getNodeTitle,
NODE_KIND_META,
NODE_KIND_ORDER,
} from "./graphUtils";
interface GraphSidebarProps {
searchKeyword: string;
searchMatchCount: number | null;
nodeKindCounts: Map<NodeKind, number>;
edgeLabelCounts: Map<string, number>;
disabledNodeKinds: Set<NodeKind>;
disabledEdgeLabels: Set<string>;
stats: GraphStats;
topNodes: Array<{ node: GraphNode; degree: number }>;
hiddenCount: number;
blacklistItems: GraphBlacklistItem[];
blacklistPending: boolean;
junkCandidateCount: number;
onSearchChange: (keyword: string) => void;
onSearchEnter: () => void;
onToggleNodeKind: (kind: NodeKind) => void;
onToggleEdgeLabel: (label: string) => void;
onRestoreHidden: () => void;
onRemoveBlacklist: (entryId: string) => void;
onCleanJunk: () => void;
onPickNode: (id: string) => void;
onClose: () => void;
}
type SectionKey =
| "nodeTypes"
| "edgeTypes"
| "graphStats"
| "coreNodes"
| "blacklist";
interface FilterSectionProps {
title: string;
extra?: string;
collapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function FilterSection(props: FilterSectionProps) {
return (
<section className="kg-filter-section">
<button
type="button"
className="kg-filter-section-header"
onClick={props.onToggle}
aria-expanded={!props.collapsed}
>
<span>{props.title}</span>
<span className="kg-filter-section-extra">
{props.extra}
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{!props.collapsed ? (
<div className="kg-filter-section-body">{props.children}</div>
) : null}
</section>
);
}
export function GraphSidebar(props: GraphSidebarProps) {
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
const [collapsedSections, setCollapsedSections] = useState<
Record<SectionKey, boolean>
>({
nodeTypes: true,
edgeTypes: true,
graphStats: false,
coreNodes: false,
blacklist: true,
});
const toggleFilterSection = (key: SectionKey) => {
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
};
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
return (
<aside className="kg-panel kg-sidebar">
<div className="kg-panel-header">
<span className="kg-panel-title">搜索与筛选</span>
<div className="kg-panel-header-actions">
{props.hiddenCount > 0 ? (
<button
type="button"
className="kg-text-button"
onClick={props.onRestoreHidden}
>
恢复 {props.hiddenCount} 个隐藏节点
</button>
) : null}
<button
type="button"
className="kg-panel-close-btn"
title="收起筛选面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
</div>
<div className="kg-panel-content">
<div className="kg-search-wrap">
<Search size={14} />
<input
className="kg-search"
placeholder="搜索节点标题..."
value={props.searchKeyword}
onChange={(e) => props.onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSearchEnter();
}}
/>
</div>
{props.searchMatchCount !== null ? (
<div
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
>
{props.searchMatchCount === 0
? "没有匹配的节点,换个关键词试试"
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
</div>
) : null}
<FilterSection
title="节点类型"
extra={`${kinds.length} 类`}
collapsed={collapsedSections.nodeTypes}
onToggle={() => toggleFilterSection("nodeTypes")}
>
{kinds.map((kind) => (
<label key={kind} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledNodeKinds.has(kind)}
onChange={() => props.onToggleNodeKind(kind)}
/>
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
</span>
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
</label>
))}
</FilterSection>
{edgeLabels.length > 0 ? (
<FilterSection
title="关系类型"
extra={`${edgeLabels.length} 类`}
collapsed={collapsedSections.edgeTypes}
onToggle={() => toggleFilterSection("edgeTypes")}
>
{edgeLabels.map(([label, count]) => {
const info = getEdgeInfo(label);
return (
<label key={label} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledEdgeLabels.has(label)}
onChange={() => props.onToggleEdgeLabel(label)}
/>
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
<span className="kg-check-label">{info.zh}</span>
</span>
<span className="kg-count">{count}</span>
</label>
);
})}
</FilterSection>
) : null}
<FilterSection
title="图谱统计"
collapsed={collapsedSections.graphStats}
onToggle={() => toggleFilterSection("graphStats")}
>
<div className="kg-stats-grid">
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.nodes}</div>
<div className="kg-stat-card-label">节点</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.edges}</div>
<div className="kg-stat-card-label">关系</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">
{props.stats.avgDegree.toFixed(2)}
</div>
<div className="kg-stat-card-label">平均度</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
<div className="kg-stat-card-label">最大连通分量</div>
</div>
</div>
</FilterSection>
{props.topNodes.length > 0 ? (
<FilterSection
title="核心节点"
extra={`${props.topNodes.length} 个`}
collapsed={collapsedSections.coreNodes}
onToggle={() => toggleFilterSection("coreNodes")}
>
{props.topNodes.map(({ node, degree }, i) => (
<div
key={node.id}
className="kg-rank-item"
onClick={() => props.onPickNode(node.id)}
>
<span className="kg-rank-index">{i + 1}</span>
<span className="kg-rank-title">{getNodeTitle(node)}</span>
<span className="kg-rank-degree">{degree} 度</span>
</div>
))}
</FilterSection>
) : null}
<FilterSection
title="图谱黑名单"
extra={`${props.blacklistItems.length} 个`}
collapsed={collapsedSections.blacklist}
onToggle={() => toggleFilterSection("blacklist")}
>
<div className="kg-search-hint">
黑名单中的主题 / 节点不会出现在图谱中。可在节点详情里加入黑名单。
</div>
<button
type="button"
className="kg-icon-button"
disabled={props.blacklistPending || props.junkCandidateCount === 0}
title="将单字符、纯数字符号、通用泛词等低价值主题批量加入黑名单"
onClick={props.onCleanJunk}
>
<Eraser size={13} /> 清理低价值主题
{props.junkCandidateCount > 0 ? `(${props.junkCandidateCount})` : ""}
</button>
{props.blacklistItems.length === 0 ? (
<div className="kg-search-hint">暂无黑名单条目。</div>
) : (
props.blacklistItems.map((item) => (
<div key={item.id} className="kg-check-row">
<span className="kg-check-row-main">
<span className="kg-check-label" title={item.label}>
{item.label}
</span>
</span>
<button
type="button"
className="kg-text-button"
disabled={props.blacklistPending}
title="移出黑名单,重新显示该节点"
onClick={() => props.onRemoveBlacklist(item.id)}
>
移除
</button>
</div>
))
)}
</FilterSection>
</div>
</aside>
);
}
@@ -1,29 +0,0 @@
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
interface GraphTooltipProps {
tooltip: TooltipState | null;
}
export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
<div className="kg-tooltip-title">{getNodeTitle(tooltip.node)}</div>
<div className="kg-tooltip-desc">
<span className="kg-legend-item">
<span className="kg-legend-dot" style={{ background: meta.color }} />
{meta.label}
</span>
{tooltip.node.category ? <span>· {tooltip.node.category}</span> : null}
<span>· 度数 {tooltip.degree}</span>
</div>
<div className="kg-tooltip-hint">单击查看详情 · 双击聚焦 · 拖拽固定位置</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 1723 files, more files were not shown because too many files have changed in this diff. Show more