Merge remote-tracking branch 'origin/master' into ji

This commit is contained in:
吉鑫升 committed 2026-05-25 18:59:32 +08:00
commit a800baf8ae
107 files changed
+13645 -16203

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,7 @@
# 复制为 .env.development.local,仅本机生效,不提交 Git
# 直连本机 Gateway(需后端 CORS_ORIGINS 包含 http://127.0.0.1:5173 等)
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
+28 -7
View File
@@ -1,9 +1,30 @@
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
#
# ┌─────────────────┬──────────────────────────────────────────────────┐
# │ 命令 │ 生效文件 │
# ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.local │
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘
#
# 推荐:
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.production 后再执行 pnpm build
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离)
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
# VITE_GITHUB_OAUTH_TOKEN=
+12
View File
@@ -0,0 +1,12 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,5 @@
# 复制为 .env.production.local,打包前临时覆盖生产地址(不提交 Git)
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
@@ -1,916 +0,0 @@
# AI 写作 — 后端开发文档
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
>
> **配套文档**:
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
---
## 一、产品定位与技术架构
### 产品定位
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
### 技术栈
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
- **业务数据**:MySQL `ai_writing_sessions` 表
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
### 与主 Agent 的关系
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
---
## 二、目录结构
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── routers/
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
│
└── packages/harness/deerflow/
├── agents/ai_writing/
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
│ ├── state.py # AIWritingState TypedDict(121 行)
│ │
│ ├── nodes/ # ── Graph 节点 ──
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
│ │ ├── intent_parser.py # 节点 1:意图解析
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
│ │ ├── writer_outline.py # 节点 3:大纲规划
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
│ │
│ ├── prompts/ # ── Prompt 模板 ──
│ │ ├── __init__.py
│ │ ├── researcher_prompts.py
│ │ ├── writer_prompts.py
│ │ └── editor_prompts.py
│ │
│ └── search/ # ── 素材检索 provider ──
│ ├── base.py # SearchResult / SearchProvider 接口
│ ├── duckduckgo_search.py
│ ├── internet_search.py # 互联网检索(默认)
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
│ └── demo_search.py # 离线 demo 数据
│
└── persistence/ai_writing_sessions/
├── __init__.py # AIWritingSessionRepository 工厂
├── model.py # ORM:AIWritingSessionRow
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
```
---
## 三、Graph 拓扑(最重要)
### StateGraph 节点 + 边
```
START
│
↓
┌─────────────────┐
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
└────────┬────────┘
↓
┌─────────────────┐
│ researcher │ ← (re_search 回到这里)
└────────┬────────┘
↓ (after_researcher: ⼟材 ready)
┌──────────────────────┐
│ pause_material │ ⚙️ PAUSE-1:素材确认
└─┬──────────┬─────────┘
│ confirm │ re_search
↓ └→ researcher
┌─────────────────┐
│ writer_outline │ ← (re_outline 回到这里)
└────────┬────────┘
↓
┌──────────────────────┐
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
└─┬──────────┬─────────┘
│ confirm │ re_outline
↓ └→ writer_outline
┌─────────────────┐
│ writer_draft │ ← (素材补充后回到这里)
└─┬───────────────┘
↓ (after_writer_draft)
┌──────┴────────────────┐
↓ ↓
┌──────────────────┐ ┌────────────────────┐
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
│ help(素材不足)│ │ │
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
│ supplement │ del/ │ │ │ to_editor
│↓ │ loose │ │ user_revise
research writer_ │ final ↓
draft │ ize writer_draft
│↓
done
│
(to_editor)│
↓
┌─────────────────┐
│ editor │ 3 维度并行流式审核
└────────┬────────┘
↓ (after_review)
┌──────────────────────┐
│ pause_review │ ⚙️ PAUSE-4:审核确认
└─┬──────────┬─────────┘
│ confirm │ revise
│↓ └→ writer_draft
done
```
### 节点定义位置一览
| 节点名 | 实现文件 | 类型 | 流式? |
|---|---|---|---|
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
### 条件路由函数(graph.py)
| 函数 | 在哪个节点之后 | 路由依据 |
|---|---|---|
| `after_researcher` | researcher | 是否找到素材 |
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
| `after_section_help_pause` | pause_section_help | status |
| `after_draft_pause` | pause_draft | done / revising / reviewing |
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
| `after_review_pause` | pause_review | done / writing_draft |
---
## 四、State 定义(state.py)
```python
class AIWritingState(TypedDict):
# —— 写作配置(启动时填充)——
user_intent: str
article_type: str
target_audience: str
word_count_target: int
keyword_count: int # 1-10,默认 6
writing_mode: str # "strict" | "loose"
# —— Agent 产出 ——
material_package: Optional[MaterialPackage] # 素材收集专家
current_outline: Optional[Outline] # 作家
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
current_draft: Optional[DraftArticle] # 最新草稿
review_result: Optional[ReviewResult] # 编辑
# —— 用户干预 ——
last_user_intervention: Optional[UserInterventionPayload]
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
# —— 流程控制 ——
revision_count: int
max_revisions: int # 默认 3
status: WritingStatus
# —— SSE 推送队列 ——
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
```
### State 字段的特殊语义
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
---
## 五、路由层(app/gateway/routers/ai_writing.py)
### 14 个接口一览
| HTTP | 路径 | 用途 | 鉴权 |
|---|---|---|---|
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
```python
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件(供重连重投)
_session_model_names: dict[str, str] # 用户选的模型
_session_interventions: dict[str, list] # 累计干预历史
_session_repos: dict[str, repo] # DB 仓库引用
_session_user_ids: dict[str, str] # 用户 id
_session_article_types: dict[str, list] # 动态文章类型列表
_session_error_flags: dict[str, bool]
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
```
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
### `_stream_graph_segment`(graph 推进函数)
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
5. 任何异常 → 推 `error` 事件 → 关闭 queue
6. finally 段:更新 DB 最终状态、清理所有模块字典
### SSE 生成器 `_sse_generator`
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
**关键逻辑**:
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
- 收到 None 哨兵 → 关闭流
### `_recover_session`(跨 worker 恢复)
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
1. 从 checkpointer 读出 state snapshot
2. 重建 `_event_queues[sid]` 队列
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
返回三种状态:
- `"interrupted"` — 图停在干预点,已重建队列
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
---
## 六、Graph 与持久化(graph.py)
### Lazy 单例 + 自动降级
```python
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
_async_checkpointer_singleton = None # checkpointer 单例
async def get_ai_writing_graph():
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
global AI_WRITING_GRAPH
if AI_WRITING_GRAPH is not None:
return AI_WRITING_GRAPH
checkpointer = await _get_async_checkpointer()
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
return AI_WRITING_GRAPH
```
### Checkpointer 优先级(_get_async_checkpointer)
```
1. config.yaml 的 checkpointer 段
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
└── type: memory → InMemorySaver
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
```
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
### Shutdown
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
调用顺序(详见 `app/gateway/app.py` lifespan):
1. 停清理任务
2. `wait_for_inflight_graph_tasks(30s)`
3. `shutdown_ai_writing_graph()`
---
## 七、节点详解(nodes/)
### `nodes/_utils.py`(公共工具)
| 函数 / 类 | 用途 |
|---|---|
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
| `count_words(text)` | 中文 + 英文混合计字数 |
### `nodes/intent_parser.py` — 意图解析
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
- 输出:更新 state 的对应字段,推 `step_done` 事件
### `nodes/researcher.py` — 素材检索
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
- 用 `search/*` 的 provider 并行检索
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
### `nodes/writer_outline.py` — 大纲规划
- 输入:`user_intent` + `material_package`
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
- 解析最终 JSON → `current_outline`,推 `outline_ready`
### `nodes/writer_draft.py` — 草稿生成(最复杂)
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
### `nodes/editor.py` — 编辑审核(3 维度并行)
- 3 个维度(**fact / logic / language**)并行调 LLM
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
- 三个都完成后聚合成 `review_result`,推 `review_ready`
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
---
## 八、持久化(persistence/ai_writing_sessions/)
### `model.py` — ORM
```python
class AIWritingSessionRow(Base):
__tablename__ = "ai_writing_sessions"
id: Mapped[str] # = session_id(uuid)
user_id: Mapped[str | None] # NULL = no-auth 模式
title: Mapped[str] # 从 user_intent 截取前 50 字
user_intent: Mapped[str | None]
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
draft_title: Mapped[str | None]
draft_markdown: Mapped[str | None]
completed_interventions: Mapped[str | None] # JSON 字符串
review_result: Mapped[str | None] # JSON 字符串
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
created_at / updated_at: BeijingDateTime
```
### `sql.py` — Repository
| 方法 | 用途 |
|---|---|
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
| `list(user_id, limit)` | 列出当前用户的会话 |
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
| `delete(session_id, user_id)` | 删除(带权限校验) |
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
### Alembic 迁移
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
---
## 九、定期清理(ai_writing_cleanup.py)
### 设计目标
- 删超过 `retention_days`(默认 7)的旧会话
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
- **绝对不删正在写的会话**(活跃保护)
### 关键函数
```python
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
```
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
执行流程:
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
5. 循环直到本批 < batch_size
### Cron 调度
`cleanup_scheduler_loop()` 常驻后台任务:
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
- `asyncio.sleep` 到下次触发时间
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
### Admin 手动触发
`POST /api/ai-writing/admin/cleanup`:
- 鉴权:`system_role == "admin"`
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
---
## 十、配置(config.yaml)
```yaml
# Checkpointer:graph 状态持久化
checkpointer:
type: sqlite # sqlite | postgres | memory
connection_string: .deer-flow/data/checkpoints.db
# AI 写作子系统
ai_writing:
# ── 清理调度 ─────────────────────────────────────────────
cleanup:
enabled: true
retention_days: 7
cron: "0 3 * * *" # 每天凌晨 3 点
delete_only_finished: false
batch_size: 500
# ── 素材收集专家(researcher)专用配置 ──────────────────
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
# 取值范围 [1, 100],非法值或缺省回退 20。
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
max_materials: 20
# ── 素材检索 provider ───────────────────────────────────
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
search_provider: internet
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
ddg_region: "cn-zh"
# intranet 模式:对接内网知识库 ES 检索接口
# 接口契约(POST):
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
```
### 配置项与代码的对应关系
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|---|---|---|---|
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
### 配置如何传到节点
```
config.yaml
└─ ai_writing: { ... }
│
▼ _ai_writing_config() 取出整个 dict
routers/ai_writing.py:_make_config()
│ 注入到 config.configurable.ai_writing_config
▼
nodes/researcher.py
cfg = config.get("configurable", {})
ai_writing_config = cfg.get("ai_writing_config", {})
keyword_model = ai_writing_config.get("keyword_model")
rank_model = ai_writing_config.get("rank_model")
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
```
### 切换内网检索的最小步骤
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
### 备注
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
---
## 十一、SSE 事件协议(后端 → 前端契约)
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
### 推出事件的位置
| 事件 | 推出位置 |
|---|---|
| `connected` | `_sse_generator` 入口 |
| `step_start` / `step_done` | 节点内部主动 emit |
| `research_progress` | `researcher_node` 内的 `_emit` |
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
| `resumed` | pause 节点 return 时塞进 `progress_events` |
| `notice` | 节点内手动推(非致命告警) |
| `done` | `_stream_graph_segment` 正常结束 |
| `error` | `_stream_graph_segment` except 分支 |
### 节点如何流式推 chunk
通过 `config.configurable.sse_queue`:
```python
async def writer_outline_node(state, config):
sse_queue = config.get("configurable", {}).get("sse_queue")
sse_session_id = config.get("configurable", {}).get("sse_session_id")
async for chunk in llm.astream(prompt):
if sse_queue:
await sse_queue.put(("outline_chunk", {
"session_id": sse_session_id,
"text": chunk.content,
"is_thinking": False,
}))
return {"current_outline": parsed, ...}
```
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
---
## 十二、扩展指南:常见开发任务
### 任务 1:新增一个 Graph 节点
**例**:在 `editor` 之后加一个「术语统一」节点。
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
```python
async def terminology_node(state, config):
# ... 读 current_draft 改术语
return {
"current_draft": new_draft,
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
}
```
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
```python
from .nodes.terminology import terminology_node
g.add_node("terminology", terminology_node)
# 改 editor 后的边
g.add_edge("editor", "terminology")
g.add_edge("terminology", "pause_review")
```
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
### 任务 2:新增一个用户干预点
1. **写 pause 节点**:在 `graph.py` 顶部加:
```python
async def pause_xxx(state):
user_input = interrupt({
"pause_point": "xxx_confirm",
"message": "...",
"context_field": state["..."],
})
action = user_input.get("action", "confirm")
if action == "confirm":
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
# ... 其他分支
```
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
```python
class ResumeRequest(BaseModel):
# ...
new_field: Optional[str] = None
```
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
```python
intervention = {
# ...
"newField": body.new_field,
}
```
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
5. **前端配合**:见前端文档
### 任务 3:新增一种素材检索 provider
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
```python
class MyProvider(SearchProvider):
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
...
```
2. 配置 `config.yaml`:
```yaml
ai_writing:
search:
provider: my_pkg.search:MyProvider
# provider 特定的配置...
```
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
### 任务 4:修改 LLM Prompt
所有 prompt 集中在 `agents/ai_writing/prompts/`:
- `researcher_prompts.py` — 检索专家
- `writer_prompts.py` — 大纲 + 草稿
- `editor_prompts.py` — 审核
改 prompt 后**强烈建议**:
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
2. 跑现有测试确认拓扑兼容
### 任务 5:扩展持久化字段
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
1. `model.py` 加列:
```python
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
```
2. **必须**新建 alembic 迁移:
```bash
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
```
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
---
## 十三、容错策略(关键设计原则)
### 「写作流程能完整继续」契约
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
### 落实位置
| 失败点 | 兜底位置 | 行为 |
|---|---|---|
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
| DB `update(review)` 失败 | 同上 | 同上 |
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
### 容器优雅关闭
lifespan shutdown 顺序:
1. 停 cleanup scheduler
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
3. 超时未结束的 task `cancel()` + 2s ack 等待
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
---
## 十四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|---|---|---|
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
跑全部:
```bash
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
```
期望 **51 个测试全过**。
---
## 十五、关键调试 / 排查命令
### 看 graph 当前状态
```python
# 进 python shell
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
print(snap.values, snap.next, snap.tasks)
```
### 直接读 sqlite checkpointer
```bash
sqlite3 /app/.deer-flow/data/checkpoints.db
.tables # 看表(checkpoints / checkpoint_writes 等)
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
WHERE thread_id = '<session_id>' LIMIT 5;
```
### 查 MySQL 业务行
```sql
SELECT id, status, draft_title, updated_at,
JSON_LENGTH(transcript) AS transcript_size
FROM ai_writing_sessions
WHERE id = '<session_id>';
```
### 查内存中的活跃会话(调试时手动加)
```python
# 临时在路由加一个 admin 接口
@router.get("/admin/inflight")
async def admin_inflight():
return {
"event_queues": list(_event_queues.keys()),
"inflight_tasks": len(_inflight_graph_tasks),
"pending_pause": list(_session_pending_pause.keys()),
}
```
### 日志关键字
```bash
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
```
期望看到(启动时):
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
警告:
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
---
## 十六、性能与限制
### 单 session 资源占用
- 内存:~50KB(state + progress_events 累计)
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
### 并发上限
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
### 长会话风险
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
### 优化方向
- progress_events 节流(聚合 chunk 事件)
- transcript 增量保存(diff 而非全量覆盖)
- 节点级 LLM 调用加 retry(tenacity)
---
## 十七、与主 Agent 的关系
| 维度 | 主 Agent | AI 写作 |
|---|---|---|
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
| Middleware | 18 层中间件 | 无(直接节点链) |
| State | `ThreadState` | `AIWritingState` |
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
---
## 十八、未来 RFC 方向
| 主题 | 描述 |
|---|---|
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
| 多模态素材 | 支持图片素材输入和图文混合输出 |
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
---
## 附录 A:相关文件速查
| 文件 | 行数 | 用途 |
|---|---|---|
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
---
## 附录 B:常见踩坑
| 现象 | 根因 | 解决 |
|---|---|---|
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -1,726 +0,0 @@
# AI 写作助手后端接口与 Prompt 协议文档
## 1. 文档目标
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
---
## 2. 本轮后端职责
后端负责:
- 接收当前线程信息
- 接收用户选区文本
- 接收改写动作
- 选取必要上下文
- 调用模型生成候选改写结果
- 返回纯净候选文本
后端本轮不负责:
- 直接编辑 Tiptap JSON
- 直接操作前端编辑器选区
- 直接替换前端文档内容
- 做复杂富文本 patch
- 做 tracked changes
---
## 3. 设计原则
### 3.1 纯文本协议优先
首版只让后端处理:
- 选区文本
- 前后文文本
- 动作类型
- 风格参数
不让后端直接理解完整富文本 JSON。
### 3.2 前端负责应用,后端负责生成
- 前端负责选区获取
- 前端负责候选结果展示
- 前端负责替换 / 插入
- 后端只负责“给出候选文本”
### 3.3 结果必须可直接落文档
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
- “当然可以,以下是润色版本:”
- “我帮你改写如下:”
- “下面是优化后的内容:”
这类内容必须禁止。
---
## 4. 推荐接口设计
## 4.1 主接口
推荐新增统一接口:
```text
POST /api/writing/rewrite
```
由 `action` 决定具体重写类型。
## 4.2 为什么推荐统一接口
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
- 前端判断分支增多
- 埋点与异常处理分散
- 后续新增动作不方便扩展
统一接口更适合本轮需求。
---
## 5. 请求体定义
建议请求体如下:
```json
{
"threadId": "string",
"documentId": "string",
"selection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"action": "polish",
"tone": "professional",
"surroundingContext": {
"before": "选区前文",
"after": "选区后文"
}
}
```
---
## 6. 字段说明
### 6.1 `threadId`
用途:
- 关联当前 DeerFlow 线程
- 必要时取线程上下文或会话元数据
### 6.2 `documentId`
用途:
- 标识当前右侧文档
- 首版可选
- 如果当前系统还没有独立文档 ID,可先允许为空
### 6.3 `selection.from` / `selection.to`
用途:
- 帮助前后端联调排查
- 首版后端通常不需要真正使用偏移量
- 建议保留,便于日志定位
### 6.4 `selection.text`
用途:
- 这是本轮最核心输入
- 后端实际重写的主文本
### 6.5 `action`
允许值:
- `polish`
- `expand`
- `shorten`
- `tone`
### 6.6 `tone`
仅当 `action = tone` 时使用。
建议允许值:
- `professional`
- `casual`
- `readable`
- `academic`
- `expressive`
### 6.7 `surroundingContext.before`
用途:
- 提供选区前文
- 帮助模型理解语气、主语、叙事连续性
建议:
- 控制长度
- 不要塞太多无关上下文
### 6.8 `surroundingContext.after`
用途:
- 提供选区后文
- 帮助模型在扩写、改语气时与后续内容衔接
---
## 7. 返回体定义
建议返回体如下:
```json
{
"output": "AI 改写后的候选文本",
"originalSelection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"metadata": {
"action": "polish",
"tone": null,
"model": "your-model-name"
}
}
```
---
## 8. 返回字段要求
## 8.1 `output`
必须:
- 只包含候选正文
- 尽量不夹带解释语
- 能直接被前端替换或插入
## 8.2 `originalSelection`
作用:
- 前端可以做校验
- 联调时方便确认是针对哪段文本返回的
## 8.3 `metadata`
作用:
- 便于埋点
- 便于日志记录
- 便于问题追踪
---
## 9. 错误响应规范
建议统一错误结构:
```json
{
"error": {
"code": "WRITING_REWRITE_FAILED",
"message": "rewrite failed"
}
}
```
建议错误码:
- `WRITING_REWRITE_FAILED`
- `WRITING_INVALID_ACTION`
- `WRITING_INVALID_TONE`
- `WRITING_EMPTY_SELECTION`
- `WRITING_MODEL_UNAVAILABLE`
- `WRITING_CONTEXT_TOO_LARGE`
---
## 10. 动作语义定义
## 10.1 `polish`
目标:
- 保持原意
- 改善表达
- 提升通顺度、自然度和可读性
不应:
- 引入新事实
- 改写成完全不同内容
## 10.2 `expand`
目标:
- 保持主旨
- 扩充细节
- 增强画面感、解释性或完整度
不应:
- 无限制发散
- 添加明显脱离主题的新内容
## 10.3 `shorten`
目标:
- 压缩篇幅
- 保留关键信息
- 减少重复表达
不应:
- 丢失关键论点
- 压缩到语义残缺
## 10.4 `tone`
目标:
- 不改变原意
- 调整语言风格
不应:
- 改写事实
- 改写逻辑结构到完全不同
---
## 11. Prompt 设计原则
## 11.1 通用系统约束
所有改写类 prompt 都应包含以下约束:
- 直接输出最终可用正文
- 不输出标题,除非原文本身就是标题改写
- 不输出解释
- 不输出分析过程
- 不输出“以下是结果”之类前缀
- 保持与上下文语气一致
## 11.2 上下文使用原则
前后文只用于理解,不要求模型重写前后文。
明确告诉模型:
- 只改写选中文本
- 前后文仅用于参考衔接
## 11.3 长度控制原则
- `polish`:长度与原文接近
- `expand`:长度可明显增加,但不要失控
- `shorten`:长度明显缩短,但要保留关键信息
- `tone`:长度尽量与原文接近
---
## 12. Prompt 模板建议
## 12.1 润色模板
```text
你是一名中文写作编辑助手。
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
要求:
1. 保留原文事实和核心意思。
2. 不要添加原文没有的新事实。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出润色后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.2 扩写模板
```text
你是一名中文写作编辑助手。
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
要求:
1. 保持原文主旨不变。
2. 增加内容时要自然,不要跑题。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出扩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.3 缩写模板
```text
你是一名中文写作编辑助手。
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
要求:
1. 不要丢掉核心信息。
2. 去掉重复、冗余和拖沓表达。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出缩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.4 改语气模板
```text
你是一名中文写作编辑助手。
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
要求:
1. 保持原文信息不变。
2. 只调整表达风格,不改变事实和逻辑。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出改写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
---
## 13. Tone 映射建议
建议前端传标准值,后端映射中文标签:
- `professional` -> 专业、正式
- `casual` -> 口语化、轻松自然
- `readable` -> 更易读、更清晰
- `academic` -> 学术、严谨
- `expressive` -> 更有文采、更有表现力
---
## 14. 上下文截取策略
## 14.1 推荐策略
前端传:
- 选区前固定长度文本
- 选区后固定长度文本
例如:
- 前文 200 到 500 字
- 后文 200 到 500 字
### 原因
- 足够帮助模型理解文气和衔接
- 不会让请求太重
## 14.2 不建议
- 把整篇长文全量塞进去
- 把整个线程消息都塞进改写接口
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
---
## 15. LangGraph 接入建议
## 15.1 推荐接入方式
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
建议二选一:
### 方案 A
新增专用 router:
- `POST /api/writing/rewrite`
内部调用现有模型层 / agent 层。
### 方案 B
新增专用 graph node 或 tool:
- `rewrite_selection`
再由 router 调用该能力。
## 15.2 更推荐方案
推荐:
- 对外暴露统一 router
- 内部走已有模型封装或 LangGraph 节点
这样前端接口更稳定,后续后端内部重构不会影响前端。
---
## 16. 后端模块拆解
## 16.1 模块 A:接口层
负责:
- 请求校验
- 错误响应
- 参数标准化
需要完成:
- 统一 rewrite API
- action / tone 校验
- 空选区校验
## 16.2 模块 B:上下文拼装层
负责:
- 拼装 before / selection / after
- 必要时提取线程相关信息
需要完成:
- 上下文长度控制
- 清洗空白和异常字符
## 16.3 模块 C:Prompt 层
负责:
- 选择模板
- 填充 action 和 tone
需要完成:
- 4 类动作模板
- tone 映射
## 16.4 模块 D:模型调用层
负责:
- 走现有模型配置体系
- 执行大模型请求
需要完成:
- 统一调用入口
- 模型不可用错误处理
## 16.5 模块 E:结果清洗层
负责:
- 去掉解释性前缀
- 去掉多余包裹
- 标准化返回
需要完成:
- 输出清洗
- metadata 填充
---
## 17. 返回结果清洗建议
即使 prompt 已经约束,模型仍可能输出:
- “以下是润色后的内容:”
- “当然,这里是改写结果:”
- “优化如下:”
建议后端加一层轻量清洗:
- 去掉常见说明性前缀
- 去掉包裹性 markdown 代码块
- 去掉多余首尾空白
注意:
- 清洗必须保守
- 不要误删正文
---
## 18. 日志与观测建议
建议记录:
- `threadId`
- `documentId`
- `action`
- `tone`
- `selection_length`
- `before_length`
- `after_length`
- `model_name`
- `latency_ms`
- `success / fail`
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
---
## 19. 后端测试清单
## 19.1 单元测试
- action 校验
- tone 校验
- 空选区校验
- prompt 选择逻辑
- 输出清洗逻辑
## 19.2 集成测试
- `polish`
- `expand`
- `shorten`
- `tone-professional`
- `tone-casual`
- `tone-readable`
- `tone-academic`
- `tone-expressive`
## 19.3 边界测试
- 选区过短
- 选区过长
- 前后文为空
- 多段文本带换行
- 模型超时
- 模型不可用
---
## 20. 联调约束
## 20.1 前后端必须统一
- `action` 枚举值
- `tone` 枚举值
- 错误码
- 返回体字段名
## 20.2 联调重点
- 多段落文本中换行保留是否正确
- AI 返回内容是否适合直接落文档
- 超时和失败时前端是否能优雅重试
## 20.3 联调建议顺序
1. 先 mock 返回固定文本
2. 再接真实模型
3. 先验证单段落
4. 再验证多段落
5. 最后验证大段文本
---
## 21. 优先级建议
### P0
- 统一 rewrite API
- 4 类动作支持
- 输出纯净正文
- 错误结构标准化
### P1
- 输出清洗增强
- 埋点与日志
- 上下文截断策略优化
### P2
- 更细粒度风格控制
- 结果质量二次校验
---
## 22. 完成定义
后端满足以下条件即可视为本轮完成:
- 前端能通过统一接口发起改写
- 4 类动作均可稳定返回候选文本
- 返回文本基本可直接用于替换或插入
- 多段落文本能稳定处理
- 错误结构规范统一
---
## 23. 结论
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
最稳妥的路线是:
- 统一接口
- 统一动作枚举
- 统一 prompt 模板
- 统一错误结构
- 统一输出约束
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
@@ -1,703 +0,0 @@
# AI 写作助手前端开发任务清单
## 1. 文档目标
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
目标是让前端团队按模块并行推进,最终完成:
- 保留左侧聊天区
- 右侧升级为 `Tiptap` 写作编辑器
- 支持选区浮层
- 支持润色 / 扩写 / 缩写 / 改语气
- 支持替换 / 插入
- 支持标题 / 列表 / 引用 / 代码块工具条
- UI 参考豆包 + Novel + open-canvas
---
## 2. 本轮前端改造范围
### 2.1 保留不动
- 左侧聊天主链路
- 现有线程消息流
- 现有模型选择器
- 现有输入框的 `writing_mode` 总入口
### 2.2 重点改造
- 右侧 Artifact 面板承载方式
- 新增独立写作工作区
- 富文本编辑器内核
- 选区浮层
- AI 改写结果预览
- 文档级工具条
---
## 3. 推荐目录结构
建议新增独立模块目录:
```text
frontend-web/src/open-canvas-writing/
routes/
WritingRoutes.tsx
pages/
WritingWorkspacePage.tsx
components/
WritingShell.tsx
WritingHeader.tsx
WritingToolbar.tsx
WritingEditorPane.tsx
WritingBubbleMenu.tsx
WritingAiMenu.tsx
WritingRewritePreview.tsx
WritingStatusBar.tsx
WritingEmptyState.tsx
editor/
TiptapEditor.tsx
TiptapExtensions.ts
commands.ts
selection.ts
context/
WritingEditorContext.tsx
hooks/
useWritingEditor.ts
useWritingSelection.ts
useWritingRewrite.ts
useWritingToolbar.ts
api/
rewrite.ts
documents.ts
types/
document.ts
rewrite.ts
utils/
apply-rewrite.ts
markdown.ts
editor-style.ts
```
---
## 4. 现有代码落点
## 4.1 需要接入的现有文件
- `frontend-web/src/pages/PageRoutes.tsx`
- 增加新写作路由入口
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
- 当前双栏布局参考来源
- 后续决定是否复用布局容器,或在新页面中重建双栏
- `frontend-web/src/components/workspace/input-box.tsx`
- 保留 `writing_mode`
- 后续可在切换写作模式时跳转到新写作页面
- `frontend-web/src/components/workspace/artifacts/context.tsx`
- 仅保留给旧 Artifact 逻辑
- 不要继续承载写作编辑器状态
## 4.2 不建议继续沿用的方式
- 不要把编辑器主状态存成 `string[] artifacts`
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
- 不要让 AI 改写结果先回聊天,再由用户手动复制
---
## 5. 任务分阶段拆解
## 阶段 A:路由与页面壳
### 目标
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
### 任务
- 新增 `WritingRoutes.tsx`
- 新增 `WritingWorkspacePage.tsx`
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
- 确认新页面进入方式
- 确定是:
- 通过左侧聊天上下文进入
- 通过 `writing_mode` 入口进入
- 或二者都支持
### 交付物
- 新写作页面能被路由访问
- 页面有基础左右布局和空态
### 验收
- 打开 `page/writing` 能进入工作区
- 页面不报错
---
## 阶段 B:编辑器底座
### 目标
把右侧正式替换成富文本编辑器。
### 任务
- 安装 Tiptap 依赖
- 建立 `TiptapEditor.tsx`
- 建立 `TiptapExtensions.ts`
- 配置节点:
- paragraph
- heading
- bulletList
- orderedList
- listItem
- blockquote
- codeBlock
- hardBreak
- 配置 marks:
- bold
- italic
- underline
- strike
- code
- 建立编辑器初始化逻辑
- 建立编辑器销毁逻辑
- 定义最小文档状态
### 交付物
- 可编辑正文区域
- 初始文档可加载
- 内容可读可写
### 验收
- 可以直接输入正文
- 可以换行、加标题、切换列表
- 不出现内容错位和明显闪烁
---
## 阶段 C:主工具栏
### 目标
右侧具备正式写作工具栏。
### 任务
- 新增 `WritingToolbar.tsx`
- 新增 `useWritingToolbar.ts`
- 实现按钮:
- 正文
- H1
- H2
- H3
- 有序列表
- 无序列表
- 引用
- 代码块
- 加粗
- 斜体
- 下划线
- 删除线
- 处理 active 状态
- 处理不可用状态
- 处理光标块状态联动
### 交付物
- 主工具栏可用
### 验收
- 工具条按钮能正确反映当前块状态
- 操作后编辑器内容正确变化
---
## 阶段 D:选区状态与浮层
### 目标
实现豆包式选区浮层基础能力。
### 任务
- 新增 `useWritingSelection.ts`
- 新增 `selection.ts`
- 新增 `WritingBubbleMenu.tsx`
- 基于 Tiptap 监听 selection 变化
- 过滤掉空选区
- 处理多段连续选区
- 浮层按钮:
- AI 改写
- 加粗
- 斜体
- 标题
- 复制
- 处理滚动定位
- 处理窗口尺寸变化
### 交付物
- 选中内容后出现浮层
### 验收
- 单段可触发
- 多段可触发
- 浮层位置稳定
---
## 阶段 E:AI 改写菜单
### 目标
把选区浮层接到 AI 改写工作流。
### 任务
- 新增 `WritingAiMenu.tsx`
- 新增 `useWritingRewrite.ts`
- 新增 `api/rewrite.ts`
- 实现一级动作:
- 润色
- 扩写
- 缩写
- 改语气
- 实现改语气二级菜单:
- 更专业
- 更口语化
- 更易读
- 更学术
- 更有文采
- 封装请求入参
- 增加 loading 态
- 增加 error 态
- 增加 retry 入口
### 交付物
- AI 改写菜单和请求链路
### 验收
- 4 个动作都能发请求
- 改语气的二级菜单可正常展开
- 请求失败时有反馈
---
## 阶段 F:候选结果预览
### 目标
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
### 任务
- 新增 `WritingRewritePreview.tsx`
- 新增 `apply-rewrite.ts`
- 显示原文与候选内容
- 提供按钮:
- 替换
- 插入
- 重试
- 取消
- 用 transaction 应用结果
- 处理应用成功后的焦点与滚动
### 交付物
- 改写结果预览卡片或面板
### 验收
- 替换准确
- 插入准确
- 取消后文档保持原样
---
## 阶段 G:文档状态与容器体验
### 目标
补齐正式产品的壳层体验。
### 任务
- 新增 `WritingShell.tsx`
- 新增 `WritingHeader.tsx`
- 新增 `WritingStatusBar.tsx`
- 新增 `WritingEmptyState.tsx`
- 补充文档标题区域
- 补充文档状态提示
- 补充空态和加载态
### 交付物
- 页面完整壳层
### 验收
- 页面不是裸编辑器
- 有完整的文档工作区感
---
## 阶段 H:视觉打磨
### 目标
让整体观感接近成熟产品。
### 任务
- 调整右侧阅读宽度
- 调整字体大小和行高
- 调整段距和标题层级
- 调整工具栏阴影、边框、圆角
- 调整浮层动效
- 调整 AI 预览卡片质感
- 优化深浅色模式兼容
### 交付物
- 完整视觉优化
### 验收
- 明显优于当前版本
- 有豆包 / Novel 风格的编辑器质感
---
## 6. 组件级开发清单
## 6.1 `WritingWorkspacePage.tsx`
职责:
- 页面级容器
- 获取线程 / 文档上下文
- 组织左右布局
必须完成:
- 页面初始化
- 与左侧聊天协同
- 右侧编辑区挂载
## 6.2 `WritingShell.tsx`
职责:
- 写作工作区总壳层
- 布局标题区、工具栏、正文区、状态区
必须完成:
- 自适应布局
- 桌面端优先
- 窄屏降级
## 6.3 `TiptapEditor.tsx`
职责:
- 包装 Tiptap EditorContent
- 负责 editor 实例初始化
必须完成:
- `onUpdate`
- `onSelectionUpdate`
- 内容同步
## 6.4 `WritingToolbar.tsx`
职责:
- 顶部主工具栏
必须完成:
- 所有基础格式化命令
- 当前状态高亮
## 6.5 `WritingBubbleMenu.tsx`
职责:
- 选区浮层
必须完成:
- 跟随选区
- 基础快捷操作
- AI 改写入口
## 6.6 `WritingAiMenu.tsx`
职责:
- AI 改写动作菜单
必须完成:
- 一级菜单
- 改语气二级菜单
- 动作触发
## 6.7 `WritingRewritePreview.tsx`
职责:
- 展示候选内容
必须完成:
- 替换
- 插入
- 重试
- 取消
---
## 7. Hook 级开发清单
## 7.1 `useWritingEditor.ts`
负责:
- editor 生命周期
- 初始内容装载
- 内容变更同步
## 7.2 `useWritingSelection.ts`
负责:
- 当前选区抽取
- 选区文本缓存
- 多段落纯文本拼接
## 7.3 `useWritingRewrite.ts`
负责:
- 改写请求
- loading / success / error
- 结果缓存
- 重试
## 7.4 `useWritingToolbar.ts`
负责:
- toolbar active 状态
- editor command 映射
---
## 8. API 对接清单
前端需向后端约定以下接口能力:
- `POST /api/writing/rewrite`
前端请求内容至少包括:
- `threadId`
- `documentId`
- `selection.from`
- `selection.to`
- `selection.text`
- `action`
- `tone`
- `surroundingContext.before`
- `surroundingContext.after`
前端需处理的返回字段:
- `output`
- `originalSelection`
- `metadata`
---
## 9. 状态管理建议
建议新增独立 Context:
- `WritingEditorContext.tsx`
建议持有:
- 当前文档内容
- 当前 editor 实例
- 当前选区
- 当前改写请求状态
- 当前候选结果
- 当前 UI 面板状态
不建议继续复用旧 Artifact Context 的原因:
- 旧 Context 关注文件列表和文件路径
- 新编辑器关注选区、结构、候选结果、应用动作
- 关注点完全不同
---
## 10. 前端联调检查点
## 10.1 与后端联调前
- 先用 mock 数据打通浮层和预览
- 先验证替换 / 插入逻辑
## 10.2 与后端联调时
- 检查多段落选区拼接结果
- 检查 AI 返回内容是否带说明性前缀
- 检查替换后位置是否准确
## 10.3 联调后
- 验证大段文本性能
- 验证撤销 / 重做
- 验证快速连续触发是否会状态错乱
---
## 11. 前端测试清单
## 11.1 功能测试
- 输入正文
- 插入标题
- 切换列表
- 插入引用
- 插入代码块
- 加粗 / 斜体 / 下划线 / 删除线
- 选区浮层显示
- 选区浮层隐藏
- AI 改写菜单打开
- 改语气二级菜单打开
- 改写结果替换
- 改写结果插入
- 改写结果取消
## 11.2 场景测试
- 单段短文本润色
- 多段文本润色
- 多段文本扩写
- 多段文本缩写
- 多段文本改语气
- 连续两次改写同一段
- 快速切换线程后重新进入
## 11.3 回归测试
- 左侧聊天是否正常
- 模型切换是否正常
- `writing_mode` 是否正常
- 原有 artifact 模块是否仍可使用
---
## 12. 任务优先级
### P0
- 路由与页面壳
- Tiptap 接入
- 主工具栏
- 选区浮层
- 改写菜单
- 替换 / 插入
### P1
- 页面头部与状态
- 优雅 loading / error / retry
- 多段落稳定性
- UI 打磨
### P2
- 空态优化
- 动效优化
- 快捷键支持
---
## 13. 建议分工
### 前端 1 号
负责:
- 路由与页面壳
- Tiptap 接入
- 主工具栏
### 前端 2 号
负责:
- 选区状态
- BubbleMenu
- AI 菜单
### 前端 3 号
负责:
- 候选结果预览
- 替换 / 插入
- 状态栏与页面体验
### 联调负责人
负责:
- 前后端请求协议
- 错误态规范
- 多段落测试和回归
---
## 14. 前端完成定义
满足以下条件可视为前端完成:
- 右侧能作为正式编辑器独立使用
- 选区后可直接发起 AI 改写
- 改写后可替换或插入
- 视觉上明显优于当前版本
- 左侧聊天和现有系统不被破坏
---
## 15. 下一步建议
前端团队可以按以下顺序开工:
1. 先起页面和路由
2. 再接 Tiptap 与主工具栏
3. 再做选区浮层
4. 再接 AI 菜单和结果预览
5. 最后做视觉打磨
这个顺序风险最小,也最容易在中途验收。
@@ -1,457 +0,0 @@
# AI 写作助手源码与实现映射表
## 1. 文档目标
本文档用于把以下几类信息对应起来:
- 豆包 AI 写作助手里要借鉴的交互
- Novel / open-canvas / Tiptap 中可参考的实现思路
- DeerFlow 当前项目里已经存在的落点
- 本次新建模块应该落到哪里
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
---
## 2. 使用方式
开发时建议按以下顺序看:
1. 先看“目标能力”
2. 再看“参考来源”
3. 再看“DeerFlow 当前落点”
4. 最后看“本次目标文件”
这样可以避免出现两种常见问题:
- 直接照搬外部项目结构,结果和你们现有架构冲突
- 只看自己项目,不知道成熟产品交互应该怎么落
---
## 3. 总体映射
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
| --- | --- | --- | --- | --- |
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
---
## 4. 豆包功能到 DeerFlow 的映射
## 4.1 滑词 / 选区浮层
### 豆包里的体验
- 选中文本后出现轻量浮层
- 浮层里有:
- AI 改写
- 文本样式
- 复制
### DeerFlow 当前状态
- 右侧还没有正式的选区级工具层
### 本次实现方式
- 使用 Tiptap 的 `BubbleMenu`
- 再叠加自定义菜单逻辑
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
---
## 4.2 多段落改写
### 豆包里的体验
- 不是只改单句
- 能对连续几段一起润色、扩写、缩写、改语气
### DeerFlow 当前状态
- 现有右侧编辑区没有正式的多段落选区改写模型
### 本次实现方式
- 前端提取连续选区纯文本
- 前端附带前后文
- 后端返回候选纯文本
- 前端决定替换或插入
### 本次落点
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
---
## 4.3 替换 / 插入
### 豆包里的体验
- 改写结果不是直接覆盖原文
- 用户可选择:
- 替换
- 插入
### DeerFlow 当前状态
- 当前没有候选文本应用层
### 本次实现方式
- 建立候选结果预览区
- 基于 Tiptap transaction 应用文本
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
---
## 4.4 标题 / 列表 / 引用 / 代码块工具条
### 豆包里的体验
- 右侧本身就是正式文档编辑器
- 可以快速切换文档结构
### DeerFlow 当前状态
- 当前右侧偏预览,不是正式写作编辑器
### 本次实现方式
- 使用 Tiptap 节点和命令
- 在主工具栏中集中承载
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
---
## 5. Novel 参考映射
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
- 编辑器主容器的产品感
- 工具栏和空态的组织方式
- 正文阅读宽度、排版、留白
## 5.2 对应到 DeerFlow 的实现位置
- 页面壳:`WritingShell.tsx`
- 顶部文档区:`WritingHeader.tsx`
- 编辑容器:`TiptapEditor.tsx`
- 工具栏:`WritingToolbar.tsx`
- 空态:`WritingEmptyState.tsx`
## 5.3 不建议直接照搬的部分
- Next.js 页面结构
- 依赖其服务端 AI 调用方式
- 与 Vercel AI SDK 深绑定的部分
---
## 6. open-canvas 参考映射
## 6.1 主要借鉴对象
open-canvas 这次最值得借鉴的不是页面外观,而是:
- AI 工作流组织方式
- 改写结果应用节奏
- 编辑器和聊天协作的模式
## 6.2 借鉴重点
### 借鉴 1:独立工作区思想
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
### 借鉴 2:画布状态独立维护
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
### 借鉴 3:AI 输出先预览,再应用
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
---
## 7. Tiptap 参考映射
## 7.1 Tiptap 在本项目中的职责
- 作为右侧正式富文本编辑器内核
- 提供结构节点与 marks
- 提供 BubbleMenu 能力
- 提供 transaction 应用能力
## 7.2 建议直接参考的实现点
- `EditorContent` 的封装模式
- `BubbleMenu` 的接入方式
- heading / list / blockquote / codeBlock 扩展配置
- `editor.chain().focus()` 命令调用模式
- selection 变化监听
## 7.3 DeerFlow 本次落点
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
---
## 8. DeerFlow 现有文件到新模块的映射
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
### 当前职责
- 汇总页面路由
### 本次作用
- 挂入新的写作助手路由
### 后续动作
- 引入 `WritingRoutes`
- 增加 `page/writing/*`
---
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
### 当前职责
- 左聊天 + 右 Artifact 双栏布局
### 本次作用
- 提供双栏工作区组织思路
- 可复用布局比例和 panel 交互经验
### 不建议直接复用的部分
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
### 本次目标替代
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
---
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
### 当前职责
- 管理文件型 artifact 列表、选中项和开关状态
### 本次作用
- 仅作为旧 Artifact 模块保留
### 本次目标替代
- `WritingEditorContext.tsx`
### 原因
- 写作编辑器需要的不是文件路径状态
- 而是文档状态、选区状态、候选结果状态、工具层状态
---
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
### 当前职责
- 聊天输入区
- 已有 `writing_mode` 入口
### 本次作用
- 保留为用户进入写作模式的现有入口
### 本次改造建议
- 写作模式打开时,跳转或唤起新的写作工作区
- 不再只停留在“生成 Markdown 文件”这个层面
---
## 9. 新模块间的依赖关系
## 9.1 页面层
- `WritingRoutes.tsx`
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
职责:
- 页面路由
- 双栏或工作区布局
- 上下文装配
## 9.2 编辑器层
- `TiptapEditor.tsx`
- `TiptapExtensions.ts`
- `commands.ts`
- `selection.ts`
职责:
- 编辑器内核
- 节点和 marks
- 基础命令
- 选区抽取
## 9.3 交互层
- `WritingToolbar.tsx`
- `WritingBubbleMenu.tsx`
- `WritingAiMenu.tsx`
- `WritingRewritePreview.tsx`
职责:
- 工具栏
- 浮层
- AI 菜单
- 候选内容应用
## 9.4 状态层
- `WritingEditorContext.tsx`
- `useWritingEditor.ts`
- `useWritingSelection.ts`
- `useWritingRewrite.ts`
- `useWritingToolbar.ts`
职责:
- 主状态
- 选区状态
- 改写状态
- 工具栏状态
## 9.5 API 层
- `api/rewrite.ts`
- `api/documents.ts`
职责:
- 对接后端写作改写接口
- 对接文档加载与保存接口
---
## 10. 能力到文件的精确映射
| 能力 | 新文件 | 说明 |
| --- | --- | --- |
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
---
## 11. 开发时的参考顺序
建议开发顺序如下:
1. 先看 `PageRoutes.tsx`,确定路由接法
2. 再看 `chat-box.tsx`,理解当前左右布局
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
4. 再接 `TiptapEditor.tsx`
5. 再做 `WritingToolbar.tsx`
6. 再做 `WritingBubbleMenu.tsx`
7. 再做 `WritingAiMenu.tsx`
8. 最后做 `WritingRewritePreview.tsx`
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
---
## 12. 明确哪些是“参考”,哪些是“目标”
### 12.1 参考
- 豆包:交互体验参考
- Novel:页面和编辑器质感参考
- open-canvas:AI 工作流参考
- Tiptap:编辑器技术实现参考
### 12.2 目标
目标不是复刻任何一个外部项目,而是:
- 在 DeerFlow 现有架构中
- 用 React + Vite + 现有线程体系
- 做出一个更成熟的 AI 写作编辑器模式
---
## 13. 结论
这份映射表的核心作用,是避免团队在开发时迷路。
真正推荐的姿势是:
- 用豆包定义目标体验
- 用 Novel 定义页面质感
- 用 open-canvas 定义 AI 工作流
- 用 Tiptap 作为编辑器内核
- 用 DeerFlow 现有路由、线程、模型体系做宿主
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
File diff suppressed because it is too large. Load diff
@@ -1,645 +0,0 @@
# AI 写作后端新路线设计草案
## 1. 目标
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
- 不影响现有同事正在开发的功能
- 不强耦合现有 DeerFlow 通用对话主链路
- 可独立部署、独立调试、独立回滚
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
---
## 2. 推荐建设方式
优先建议:
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
建议目录:
```text
offline-backend-20260512/
apps/
ai_writing_orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
如果当前后端没有 `apps/` 结构,也可以采用:
```text
offline-backend-20260512/
ai-writing-orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
---
## 3. 技术路线建议
推荐组合:
- 编排框架:`LangGraph`
- 模型调用:接你们内网模型网关
- 检索层:接内网知识库 / 文档库 / 搜索服务
- 状态存储:先用数据库或 Redis + DB,后续再演进
- 事件输出:SSE 或流式 HTTP
推荐原因:
- `LangGraph` 适合状态机和多节点回退
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
- 后续加人工确认节点和 checkpoint 会更自然
---
## 4. 分层设计
建议按四层拆分。
## 4.1 API 层
职责:
- 接收前端请求
- 鉴权
- 参数校验
- 返回 workflow 信息
- 提供事件流出口
建议文件:
```text
api/
routes/
workflows.py
workflow_events.py
schemas/
workflow_request.py
workflow_response.py
```
## 4.2 Application 层
职责:
- 组织用例
- 调度 workflow service
- 管理人工确认动作
- 处理重试和终止逻辑
建议文件:
```text
application/
services/
workflow_service.py
workflow_event_service.py
human_action_service.py
use_cases/
create_workflow.py
start_workflow.py
confirm_outline.py
rewrite_section.py
approve_final.py
```
## 4.3 Domain 层
职责:
- 定义工作流状态机
- 定义 Agent 输入输出协议
- 定义 artifact 与评分模型
建议文件:
```text
domain/
models/
workflow.py
workflow_stage.py
workflow_event.py
artifact.py
review_result.py
agents/
researcher.py
outliner.py
writer.py
editor.py
supervisor.py
graphs/
writing_graph.py
policies/
retry_policy.py
quality_policy.py
```
## 4.4 Infrastructure 层
职责:
- 访问模型网关
- 访问检索服务
- 访问数据库
- 发送事件
- 缓存与日志
建议文件:
```text
infrastructure/
llm/
model_client.py
retrieval/
intranet_search_client.py
knowledge_base_client.py
persistence/
workflow_repository.py
artifact_repository.py
streaming/
sse_publisher.py
logging/
workflow_logger.py
```
---
## 5. 工作流最小闭环
第一版建议只做如下状态机:
1. `collect_materials`
2. `generate_outline`
3. `await_outline_confirmation`
4. `write_draft`
5. `review_draft`
6. `rewrite_if_needed`
7. `complete`
终止规则:
- 编辑评分达标则完成
- 否则进入重写
- 最多重写 2 次
- 超过次数则标记 `human_intervention_required`
---
## 6. Agent 模块设计
## 6.1 Researcher
输入:
- 主题
- 用户要求
- 检索范围
- 可用资料源
输出:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
程序职责:
- 先做检索
- 再把检索结果裁剪后送给模型
- 输出必须结构化
## 6.2 Outliner
输入:
- 用户要求
- 研究结果
输出:
- `title_candidates`
- `outline_sections`
- `key_points_per_section`
约束:
- 提纲优先,不写正文
## 6.3 Writer
输入:
- 已确认提纲
- 素材卡片
- 风格要求
输出:
- `draft_sections`
- `section_source_refs`
- `full_draft_markdown`
建议:
- 按章节分段生成,不要一次长文直出
## 6.4 Editor
输入:
- 初稿
- 提纲
- 素材
- 用户要求
输出:
- `issues`
- `severity`
- `score`
- `rewrite_instructions`
- `decision`
规则:
- 先查事实,再查结构,再查文风
## 6.5 Supervisor
输入:
- 各阶段结果
- 重试计数
- 评分结果
输出:
- 下一跳节点
- 是否需要人工确认
- 是否结束
---
## 7. Prompt 组织建议
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
建议目录:
```text
prompts/
researcher/
system.txt
output_schema.json
outliner/
system.txt
output_schema.json
writer/
system.txt
output_schema.json
editor/
system.txt
output_schema.json
supervisor/
system.txt
```
原则:
- 每个 Agent 一套职责
- 每个 Agent 一套输出 schema
- Prompt 版本可管理
- 后续支持 A/B 调参
---
## 8. 弱模型适配策略
## 8.1 先检索后生成
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
## 8.2 短上下文任务化
每个节点只给弱模型一小段明确任务:
- 研究归纳
- 提纲整理
- 单章节写作
- 单章节审稿
## 8.3 结构化输出
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
## 8.4 章节级重写
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
## 8.5 程序接管质量门槛
例如:
- 资料引用数不足,不进入下一步
- 提纲章节数过少,直接重生
- 编辑评分低于阈值,自动重写
---
## 9. 数据模型建议
## 9.1 Workflow
建议字段:
- `id`
- `status`
- `current_stage`
- `user_id`
- `thread_id`
- `input_payload`
- `current_artifact_id`
- `retry_count`
- `created_at`
- `updated_at`
## 9.2 Workflow Artifact
建议字段:
- `id`
- `workflow_id`
- `artifact_type`
- `stage`
- `version`
- `title`
- `content`
- `metadata`
## 9.3 Workflow Event
建议字段:
- `id`
- `workflow_id`
- `event_type`
- `stage`
- `agent_name`
- `payload`
- `created_at`
## 9.4 Human Action
建议字段:
- `workflow_id`
- `action_type`
- `payload`
- `operator`
- `created_at`
---
## 10. API 草案
## 10.1 创建任务
`POST /api/ai-writing/workflows`
请求:
```json
{
"topic": "主题",
"articleType": "行业分析",
"audience": "管理层",
"tone": "专业克制",
"targetLength": 3000,
"constraints": ["避免口语化"],
"referenceScope": ["内网知识库", "项目复盘文档"]
}
```
返回:
```json
{
"workflowId": "wf_xxx",
"status": "idle"
}
```
## 10.2 启动任务
`POST /api/ai-writing/workflows/{id}/start`
## 10.3 查询任务
`GET /api/ai-writing/workflows/{id}`
## 10.4 订阅事件
`GET /api/ai-writing/workflows/{id}/events`
返回事件流示例:
```json
{
"type": "stage_changed",
"stage": "generating_outline",
"message": "正在生成提纲"
}
```
## 10.5 确认提纲
`POST /api/ai-writing/workflows/{id}/confirm-outline`
## 10.6 指定章节重写
`POST /api/ai-writing/workflows/{id}/rewrite-section`
## 10.7 确认终稿
`POST /api/ai-writing/workflows/{id}/approve-final`
---
## 11. LangGraph 实现建议
## 11.1 推荐 graph 节点
- `load_input`
- `retrieve_materials`
- `normalize_materials`
- `generate_outline`
- `wait_for_outline_confirmation`
- `write_sections`
- `merge_sections`
- `review_draft`
- `rewrite_sections`
- `publish_final_artifact`
## 11.2 推荐 state
建议 state 字段:
- `workflow_id`
- `input_payload`
- `materials`
- `outline`
- `outline_confirmed`
- `draft_sections`
- `merged_draft`
- `review_result`
- `rewrite_targets`
- `final_article`
- `retry_count`
- `events`
## 11.3 条件边
建议条件边逻辑:
- 提纲未确认 -> 等待用户
- 编辑通过 -> 完成
- 编辑不通过且可重试 -> 重写
- 编辑不通过且超限 -> 人工介入
---
## 12. 可观测性要求
第一版就建议带上以下能力:
- 每个 workflow 的事件日志
- 每个 Agent 的输入输出摘要
- 每次重试原因
- 每个阶段耗时
- 每次模型调用 token 与错误日志
后续排问题会非常依赖这些数据。
---
## 13. 测试建议
## 13.1 单元测试
覆盖:
- stage 流转
- retry policy
- quality policy
- 事件构造
## 13.2 集成测试
覆盖:
- 创建任务到完成的主链路
- 提纲确认链路
- 审稿失败后重写链路
- 人工介入链路
## 13.3 Prompt 回归集
建议建立 10-20 个固定写作样例,做回归:
- 短文
- 长文
- 事实密集型文章
- 宣传型文章
- 分析型文章
---
## 14. 开发顺序建议
## Step 1:先做 domain 与 API 契约
先定:
- stage 枚举
- event 枚举
- artifact 结构
- human action payload
## Step 2:先做 fake orchestrator
先不接真实模型,只模拟事件流,把前后端联调跑通。
## Step 3:接 Researcher + Outliner
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
## Step 4:接 Writer + Editor
再接初稿和审稿重写。
## Step 5:接可观测性和缓存
最后再补生产化能力。
---
## 15. 风险控制建议
### 风险 1:直接复用现有线程流,导致协议污染
建议:
- workflow 用独立 route 和独立 event schema
### 风险 2:写作结果与右侧 artifact 模型不兼容
建议:
- 后端统一输出标准 markdown artifact
### 风险 3:弱模型频繁输出不合法 JSON
建议:
- 每个节点加 schema repair
- 加轻量重试
- 尽量减少复杂嵌套输出
### 风险 4:研究资料太多导致上下文爆炸
建议:
- 检索后先裁剪、摘要、分桶
---
## 16. 最终建议
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
建议 MVP 只保留:
1. Researcher
2. Outliner
3. Writer
4. Editor
5. Supervisor
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
@@ -1,806 +0,0 @@
# AI 写作执行方案
## 1. 总体原则
本项目的最终落地原则:
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
3. 编排框架采用 `LangGraph`
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
6. 前后端通过 `SSE` 传递工作流事件
7. 第一版只做最小闭环,不做过多花哨扩展
一句话说明:
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
---
## 2. 项目边界
## 2.1 前端边界
继续使用当前页面:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
前端做的事情:
- 左侧增加“写作工作流模式”
- 展示多 Agent 执行进度与中间产物
- 提供提纲确认和指定章节重写
- 将初稿、终稿同步到右侧 artifact
前端不做的事情:
- 不重写右侧编辑器
- 不重写现有普通 chat 流程
- 不把 workflow 状态散落在多个旧 context 里
## 2.2 后端边界
后端采用独立新路线:
推荐目录:
```text
offline-backend-20260512/
apps/
ai_writing_orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
后端做的事情:
- 实现写作 workflow 状态机
- 实现 A/B/C Agent 与 Supervisor
- 接内网模型与内网检索
- 输出标准事件流和文章 artifact
后端不做的事情:
- 不直接改现有 DeerFlow 主流程
- 不把工作流逻辑塞到现有通用 thread 协议里
## 2.3 资料搜集边界
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
第一版要求:
- 研究员可以调用互联网搜索能力收集公开资料
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
第二版预留:
- 后续替换或并行接入内网搜索接口
- 后续接入内部知识库、文档库、FAQ、项目复盘库
- 前端和工作流层不因检索源变化而改协议
统一设计要求:
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
- `writing_graph` 只读取标准化后的素材结果
- API、事件流、artifact 结构不区分互联网资料和内网资料
推荐检索抽象:
```text
infrastructure/
retrieval/
base.py
internet_search_client.py
intranet_search_client.py
retriever_router.py
```
建议接口:
```python
class RetrievalProvider(Protocol):
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
class RetrievedDocument(BaseModel):
title: str
url: str | None
source_type: str
snippet: str
published_at: str | None = None
fetched_at: str
reliability: str | None = None
```
第一版路由策略:
- 默认 `internet`
- 配置项预留 `intranet`、`hybrid`
例如:
```yaml
retrieval:
provider: internet
top_k: 8
timeout_seconds: 15
```
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
---
## 3. 第一版产品目标
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
1. 输入主题、体裁、读者、风格、篇幅
2. 研究员搜集资料并输出素材卡片
3. 提纲 Agent 生成提纲
4. 用户确认提纲
5. Writer 生成初稿
6. Editor 给出问题清单与评分
7. 不达标则重写一次或两次
8. 终稿写入右侧 artifact
第一版成功标准:
- 不是“生成得像最强模型一样好”
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
## 3.1 第一版研究员目标
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
第一版输入:
- 用户主题
- 用户写作要求
- 检索源配置,第一版默认 `internet`
第一版输出至少包含:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
每条素材最少要带:
- 来源标题
- 来源 URL
- 摘要
- 可信度标签
- 被引用的原始片段或整理后的事实点
第一版 Researcher 的程序流程建议:
1. 根据主题自动生成 3-5 个检索 query
2. 调用互联网检索接口拿回候选文档
3. 做去重、裁剪、初筛
4. 让模型将结果整理成结构化素材卡片
5. 将结构化结果返回给 Outliner 和 Writer
第一版先不做:
- 深度网页 crawling
- 多轮自动追问式深研究
- 内网知识库接入
- 复杂引用格式生成
---
## 4. 最终工作流
## 4.1 Agent 角色
### `Researcher`
职责:
- 从配置的检索源检索信息
- 产出结构化素材卡片
- 标注来源、可信度、风险点
第一版说明:
- 默认走互联网公开资料检索
- 必须预留后续切换到内网检索源的接口
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
第一版检索源字段建议:
- `source_type = "internet"`
- 后续预留 `source_type = "intranet" | "hybrid"`
输出:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
### `Outliner`
职责:
- 根据用户要求和研究结果生成提纲
输出:
- `title_candidates`
- `outline_sections`
- `key_points_per_section`
### `Writer`
职责:
- 基于提纲和素材写初稿
- 按章节写,不一次生成整篇长文
输出:
- `draft_sections`
- `section_source_refs`
- `full_draft_markdown`
### `Editor`
职责:
- 检查事实、结构、文风
- 给出评分与重写建议
输出:
- `issues`
- `severity`
- `score`
- `rewrite_instructions`
- `decision`
### `Supervisor`
职责:
- 负责流程调度
- 负责通过 / 重写 / 人工介入判断
## 4.2 状态机
第一版统一采用:
- `idle`
- `collecting_materials`
- `generating_outline`
- `await_outline_confirmation`
- `writing_draft`
- `reviewing_draft`
- `rewriting_draft`
- `completed`
- `failed`
- `human_intervention_required`
## 4.3 流程闭环
最小闭环固定为:
1. `Research`
2. `Outline`
3. `HumanConfirmOutline`
4. `Draft`
5. `Review`
6. `RewriteIfNeeded`
7. `Complete`
重试规则:
- 最多重写 2 次
- 达标则完成
- 超限则进入人工介入
## 4.4 LangGraph 推荐节点
第一版推荐节点:
- `load_input`
- `build_search_queries`
- `retrieve_materials`
- `normalize_materials`
- `generate_outline`
- `wait_for_outline_confirmation`
- `write_sections`
- `merge_sections`
- `review_draft`
- `rewrite_sections`
- `publish_final_artifact`
其中和资料搜集直接相关的节点说明:
### `build_search_queries`
职责:
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
### `retrieve_materials`
职责:
- 调用检索 provider 获取候选文档
- 第一版默认走 `internet_search_client`
- 后续可切到 `intranet_search_client`
### `normalize_materials`
职责:
- 做去重
- 做摘要裁剪
- 做来源字段统一
- 输出标准 `RetrievedDocument[]`
---
## 5. 前端最终做法
## 5.1 目录建议
在现有 `open-canvas` 下新增:
```text
frontend-web/src/open-canvas/
workflow/
api/
writing-workflow.ts
components/
WritingWorkflowEntryForm.tsx
WritingWorkflowTimeline.tsx
WritingWorkflowEventList.tsx
WritingWorkflowArtifactDrawer.tsx
WritingWorkflowConfirmCard.tsx
WritingWorkflowHeader.tsx
CanvasWorkflowPanel.tsx
contexts/
WritingWorkflowContext.tsx
hooks/
useWritingWorkflow.ts
useWritingWorkflowEvents.ts
types/
workflow.ts
```
## 5.2 左侧改造策略
`CanvasChatPanel` 只承担模式切换与容器职责:
- `chat` 模式:沿用现有普通聊天
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
新面板负责:
- 写作任务表单
- 进度时间线
- 事件列表
- 中间产物抽屉
- 提纲确认卡片
## 5.3 右侧衔接策略
继续复用现有 `CanvasArtifactPanel`。
同步规则:
- 提纲:可选同步到右侧预览
- 初稿:同步到右侧
- 重写稿:覆盖当前草稿
- 终稿:作为最终 artifact
## 5.4 前端状态设计
新增:
- `workflowMode`
- `workflowRunId`
- `workflowStage`
- `workflowStatus`
- `workflowEvents`
- `workflowArtifacts`
- `workflowPendingAction`
注意:
- workflow 状态放进新的 `WritingWorkflowContext`
- 不塞进现有 `CanvasGraphContext`
## 5.5 前端显示的资料信息
第一版左侧至少要能展示研究员产出的资料摘要:
- 来源标题
- 来源域名或 URL
- 资料摘要
- 可信度标签
- 是否被提纲或正文引用
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
注意:
- 第一版显示公开互联网来源即可
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
---
## 6. 后端最终做法
## 6.1 分层
推荐分层:
```text
api/
application/
domain/
infrastructure/
prompts/
tests/
```
## 6.2 核心模块
建议核心文件:
```text
domain/
models/
workflow.py
workflow_stage.py
workflow_event.py
artifact.py
review_result.py
agents/
researcher.py
outliner.py
writer.py
editor.py
supervisor.py
graphs/
writing_graph.py
policies/
retry_policy.py
quality_policy.py
```
## 6.3 检索层实现要求
第一版检索层必须独立成基础设施模块,不允许:
- 在 Agent prompt 中直接拼接“去网上找资料”
- 在 graph 节点中散落请求第三方搜索的代码
- 把互联网检索和内网检索混在一个无抽象的函数里
建议结构:
```text
infrastructure/
retrieval/
base.py
internet_search_client.py
intranet_search_client.py
retriever_router.py
material_normalizer.py
```
建议职责:
- `internet_search_client.py`
- 第一版实际使用
- 对接互联网搜索 API 或爬取网关
- `intranet_search_client.py`
- 第一版只保留接口和空实现或 mock
- 后续接企业内部接口
- `retriever_router.py`
- 根据配置选择 provider
- `material_normalizer.py`
- 统一清洗和标准化结果
## 6.4 Prompt 管理
统一独立存放:
```text
prompts/
researcher/
system.txt
output_schema.json
outliner/
system.txt
output_schema.json
writer/
system.txt
output_schema.json
editor/
system.txt
output_schema.json
supervisor/
system.txt
```
## 6.5 输出约束
每个 Agent 都必须:
- 优先输出 JSON / Pydantic schema
- 失败时允许轻量 repair 和一次重试
- 避免自由散文式输出
## 6.6 Researcher 的实现细则
Researcher 推荐由两部分组成:
1. 程序化检索
2. 模型化整理
推荐伪流程:
```python
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
queries = build_queries(topic, article_type)
raw_docs = []
for query in queries:
raw_docs.extend(provider.search(query=query, top_k=5))
docs = normalize_materials(raw_docs)
docs = dedupe_materials(docs)
docs = rank_materials(docs)
return llm_extract_material_cards(docs)
```
第一版建议:
- 先把 query 生成逻辑做简单
- `top_k` 先控制在 5-10
- 先做轻量去重和摘要裁剪
- 不做很重的网页全文抓取
---
## 7. 前后端协议
## 7.1 API
第一版统一提供:
- `POST /api/ai-writing/workflows`
- `POST /api/ai-writing/workflows/{id}/start`
- `GET /api/ai-writing/workflows/{id}`
- `GET /api/ai-writing/workflows/{id}/events`
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
- `POST /api/ai-writing/workflows/{id}/approve-final`
## 7.2 SSE 事件
统一事件类型:
- `workflow_started`
- `stage_changed`
- `agent_started`
- `agent_output_chunk`
- `artifact_generated`
- `human_confirmation_required`
- `retry_scheduled`
- `workflow_completed`
- `workflow_failed`
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
## 7.3 配置项补充
建议增加以下配置项:
```yaml
retrieval:
provider: internet
top_k: 8
timeout_seconds: 15
enable_content_fetch: false
workflow:
max_revisions: 2
models:
researcher: your-default-model
outliner: your-default-model
writer: your-default-model
editor: your-default-model
```
说明:
- 第一版 `provider=internet`
- 第二版可切成 `intranet` 或 `hybrid`
---
## 8. 必须做 / 延后做 / 不建议现在做
## 8.1 必须做
### 架构
- 独立后端路线 `ai-writing-orchestrator`
- 前端独立 `workflow/` 目录
- LangGraph 状态机
- `Researcher / Outliner / Writer / Editor / Supervisor`
- 独立检索抽象层,第一版先落互联网 provider
### 协议
- 标准 workflow 状态枚举
- 标准 SSE 事件枚举
- 标准 artifact 结构
- JSON / Pydantic 输出约束
### 功能
- 启动写作任务
- 研究阶段
- 提纲阶段
- 提纲确认
- 初稿生成
- 审稿与最多 2 次重写
- 终稿同步到右侧
- 素材卡片展示
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
### UI
- 模式切换
- 时间线
- 事件列表
- 提纲确认卡片
- 右侧终稿显示
- 左侧素材卡片视图
## 8.2 延后做
- 章节级精准重写
- 初稿多版本树
- FactChecker
- StyleEditor
- ComplianceReviewer
- 自动评分看板
- 素材引用可视化高亮
- 更复杂的人机协作节点
- 内网知识库正式接入
- 混合检索 `hybrid`
- 网页全文抓取与深摘要
## 8.3 不建议现在做
- 直接接入现有 DeerFlow 主 agent 包
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
- 一开始就做 5 个以上 Agent
- 一开始就做很复杂的文章版本管理
- 一开始就把评分阈值和 prompt 写死得非常细
## 9. 最终开发顺序
## Step 1:先定契约
先一起拍板:
- stage 枚举
- event 枚举
- artifact 结构
- confirm / rewrite 的 payload
- retrieval provider 接口
- RetrievedDocument 结构
## Step 2:先做 fake workflow
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
## Step 3:先接互联网版 Researcher + Outliner
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
这一阶段要求:
- `Researcher` 先走互联网检索
- `retriever_router` 可配置
- `intranet_search_client` 先留接口空壳
## Step 4:接 Writer + Editor
再补初稿与审稿重写闭环。
## Step 5:接右侧 artifact
把初稿和终稿稳定写入右侧。
## Step 6:补生产化
- 缓存
- 日志
- 错误回放
- 配置化 prompt
## 10. 推荐任务拆分
## 前端
- `CanvasChatPanel` 模式切换
- `workflow/` 目录与类型
- 时间线 / 事件列表 / 确认卡片
- SSE 订阅 hook
- 右侧 artifact 映射
- 素材卡片列表展示
- 来源字段展示规范
## 后端
- workflow API
- LangGraph graph
- 5 个 Agent 模块
- Prompt 目录
- SSE 输出
- 仓储与日志
- `RetrievalProvider` 抽象
- `internet_search_client`
- `intranet_search_client` 预留接口
- `material_normalizer`
## Prompt / 算法
- Researcher prompt
- Outliner prompt
- Writer prompt
- Editor prompt
- JSON schema
- 失败样本回归
## 联调
- mock SSE
- 真模型接入
- artifact 同步校验
- 提纲确认链路验证
- 互联网资料来源字段联调
- 素材卡片到提纲引用链路联调
---
## 11. 开发过程中的约束
开发过程中统一采用下面这套口径:
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
- 技术底座:`LangGraph`
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
- 后端方式:独立 `ai-writing-orchestrator`
- 检索方式:第一版 `internet`,接口预留 `intranet`
- 通信方式:`SSE`
- 输出方式:`JSON Schema / Pydantic`
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
给开发团队的统一口径:
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
@@ -1,538 +0,0 @@
# AI 写作 — 前端开发文档
> **本文档目的**:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
>
> **配套文档**:
> - `ai-writing-说明.md` — 故障排查与运维向解决方案
> - `ai-writing-assistant-backend-api.md` — 后端 API 详细字段定义
> - `ai-writing-assistant-source-map.md` — 旧版前端结构索引(已合并到本文档)
---
## 一、产品定位
AI 写作是 DeerFlow 的「**杂志社协作写作**」子产品,由 LangGraph 后端驱动 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章。用户通过 **4 个干预点**(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
### UI 入口
- 路由:`/canvas/ai-writing`(在 `WorkspaceRoutes.tsx` 注册)
- 入口页:`src/open-canvas/pages/AIWritingPage.tsx`
- 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
---
## 二、目录结构
```
frontend-web/src/open-canvas/
├── pages/
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
│
├── api/ # ── 后端 API 适配层 ──
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
│ └── ai-writing-article-types.ts # 文章类型 CRUD
│
├── hooks/ # ── 状态管理 ──
│ ├── useAIWriting.ts # 写作主状态机(最重要)
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
│ └── useArticleTypes.ts # 文章类型选项 hook
│
├── components/ai-writing/ # ── UI 组件 ──
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
│ │
│ ├── AIWritingTimeline.tsx # 时间线主容器
│ ├── TimelineStep.tsx # 单条进度事件
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
│ │
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
│ ├── MaterialsCard.tsx # 素材确认
│ ├── MaterialItem.tsx # 单个素材项
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
│ ├── OutlineSection.tsx # 大纲单章节
│ ├── ReviewCard.tsx # 审核确认
│ ├── IssueList.tsx # 审核意见列表
│ ├── ScoreBar.tsx # 评分进度条
│ │
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
│ ├── DraftStreamingView.tsx # 草稿流式打字机
│ ├── ReviewStreamingView.tsx # 审核流式打字机
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
│ │
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
│
└── types/
└── ai-writing.ts # 全部类型定义(重要参考)
```
---
## 三、数据流总览
```
┌─────────────────────────────────────────────────────────────────┐
│ AIWritingPanel │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ useAIWriting() 主状态 hook │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
│ │ │ ↓ 返回 session_id │ │ │
│ │ │ │ │ │
│ │ │ subscribeToProgress(sessionId) │ │ │
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
│ │ │ ─── handleEvent reducer → setState │ │ │
│ │ │ │ │ │
│ │ │ submitIntervention() │ │ │
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
│ │ │ │ │ │
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 渲染: │
│ ├ AIWritingForm(idle 状态) │
│ ├ AIWritingTimeline(运行中) │
│ │ ├ TimelineStep × N(progressEvents) │
│ │ └ CompletedInterventionCard × N(已完成的干预) │
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 四、状态机(useAIWriting.ts)
### 状态字段(完整定义见 `types/ai-writing.ts`)
```typescript
interface AIWritingState {
// —— 核心标识 ——
sessionId: string | null; // 后端返回的会话 id
status: WritingStatus; // 当前阶段(13 种状态之一)
isPaused: boolean; // 是否在干预点等待用户
error: string | null;
// —— 配置 ——
request: AIWritingRequest | null; // 用户提交的写作请求
// —— 累计数据 ——
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
outline: AIOutline | null; // 大纲(大纲就绪后填充)
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
// —— 干预 ——
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
// —— 流式视图状态 ——
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
outlineStreamingText: string;
outlineThinkingText: string;
researchProgress: string;
streamingReviewItems: StreamingReviewItem[];
revisionSummary: RevisionSummary | null;
pendingRevisionContext: { /* ... */ } | null;
}
```
### 状态转移图(写作状态机)
```
┌──────┐
│ idle │ ←─────────────────────────────────────┐
└──┬───┘ │
│ startWriting() │
↓ │
┌─────────────────┐ │
│ researching │ │
└──┬──────────────┘ │
↓ materials_ready │
┌─────────────────────────────┐ │
│ awaiting_material_confirm │ ← await_user │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_outline │ ← (re_search 路径) │
└──┬──────────────┘ │
↓ outline_ready │
┌─────────────────────────────┐ │
│ awaiting_outline_confirm │ │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_draft │ ← (re_outline 路径) │
└──┬──────────────┘ │
│ │
┌───────┴────────┐ │
↓ ↓ │
awaiting_section_ awaiting_draft_confirm │
help (素材不足) └─┬───┬───┬──────────────┐ │
│ (supplement) │ │ │ to_editor │ │
│ ↑回到 research │ │ ↓ │ │
│ │ │ reviewing │ │
│ │ │ └─┬──────┐ │ │
│ │ │ ↓ ↓ │ │
│ │ │ awaiting_review_confirm │
│ │ │ └─┬────────┐ │
│ │ │ ↓ submit ↓ │
│ │ │ revising done ───────────────┘
│ │ │ │
│ │ │ └─→ writing_draft (循环修订)
│ │ │
│ │ │ user_revise
│ │ └─→ revising
│ │
│ │ finalize
│ └─→ done
│
└──→ (delete/loose) writing_draft 继续
```
### handleEvent reducer(核心)
`useAIWriting.ts` 里的 `handleEvent` 是个**纯 reducer**,根据 SSE 事件类型更新状态:
| 事件类型 | 主要影响的状态字段 |
|---|---|
| `connected` | 仅记录到 progressEvents |
| `step_start` | 切到对应 `status`(如「编辑」→ reviewing) |
| `step_done` | 仅记录 |
| `research_progress` | `researchProgress`(实时检索进度文案) |
| `materials_ready` | `materials`、清空 researchProgress |
| `outline_chunk` | `outlineStreamingText` / `outlineThinkingText`(打字机) |
| `outline_ready` | `outline`、清空流式文本 |
| `draft_chunk` | `streamingDraftSections`(按章节拼接打字机) |
| `draft_ready` | `currentDraftMarkdown` / `currentDraftTitle` |
| `review_chunk` | `streamingReviewItems[i].streamingText`(逐条打字机) |
| `review_item_done` | `streamingReviewItems[i]` 标记 isDone + 结构化结果 |
| `review_ready` | `reviewResult` |
| `await_user` | `currentPause = event`,`isPaused = true`,**弹出 InterventionCard** |
| `resumed` | `currentPause = null` |
| `notice` | 仅记录(非致命告警) |
| `done` | `status = 'done'` |
| `error` | `status = 'error'`,`error = event.message` |
---
## 五、API 层(api/)
### 1. `api/ai-writing.ts` — 核心写作 API
```typescript
// 启动写作 → 返回 session_id
export async function startWritingSession(req: AIWritingRequest): Promise<string>
// 订阅 SSE 进度流 → 返回取消订阅函数
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
): () => void
// 提交用户干预 → 让 graph 续推
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void>
```
**关键实现细节**:
- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换
- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略
- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream
- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
- 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
### 2. `api/ai-writing-sessions.ts` — 历史会话
```typescript
// CRUD
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
deleteAIWritingSession(id) : Promise<void>
// 时间线持久化(fire-and-forget,永不抛错)
saveAIWritingTranscript(id, transcript) : Promise<boolean>
```
**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。
### 3. `api/ai-writing-article-types.ts` — 文章类型管理
| 函数 | 用途 |
|---|---|
| `listArticleTypes()` | 表单下拉框选项 |
| `createArticleType()` | 管理员新增 |
| `updateArticleType()` | 管理员编辑 |
| `deleteArticleType()` | 管理员删除 |
---
## 六、组件树详解
### 顶层:`AIWritingPanel`(457 行,最大)
负责:
1. 调用 `useAIWriting()` 拿主状态
2. 调用 `useAIWritingSessions()` 拿历史列表
3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容
5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧
6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板
### 干预卡:`InterventionCard`(663 行,第二大)
按 `pausePoint` 渲染**不同的卡片**:
| pausePoint | 卡片 | 用户能做的 |
|---|---|---|
| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 |
| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 |
| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 |
| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
### 时间线:`AIWritingTimeline`
迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。
### 流式视图:`{Outline,Draft,Review}StreamingView`
- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `<think>...</think>` 折叠显示
- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机
- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
### 历史回显:`AIWritingHistoryView`
只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。
---
## 七、SSE 事件协议(前后端契约)
后端通过 SSE 推 `event: <type>\ndata: <json>\n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。
### 解析流程
```
ReadableStream → TextDecoder
→ 按 "\n\n" 分帧
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
→ JSON.parse(data)
→ normalize(parsed)(snake → camel)
→ onEvent(normalized)
```
### 事件载荷字段(不完全列表)
| 字段 | 出现在哪些事件 | 用途 |
|---|---|---|
| `type` | 全部 | 事件类别 |
| `sessionId` | 全部 | 标识 |
| `message` | 大多数 | 显示给用户的文案 |
| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
| `materials` | materials_ready | 素材数组 |
| `outline` | outline_ready | 大纲对象 |
| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 |
| `reviewResult` | review_ready | 完整审核结果 |
| `pausePoint` | await_user | 哪个干预点 |
| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) |
| `blockedSections` | await_user (section_help) | 素材不足的章节列表 |
| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 |
---
## 八、扩展指南:常见开发任务
### 新增一个干预点
1. **前端类型扩展**:
- 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case
- 在 `WritingStatus` 加对应的 `awaiting_xxx`
- 在 `CompletedIntervention.pausePoint` 加 case
2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑
3. **后端配合**(详见后端开发文档):
- `graph.py` 加 `pause_xxx_confirm` 节点
- graph 拓扑加这个节点的边
4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到
### 新增一个 SSE 事件类型
1. `types/ai-writing.ts`:
- `ProgressEventType` 联合类型加新值
- `ProgressEvent` 接口加新字段(如果有载荷)
2. `api/ai-writing.ts`:
- `SSE_EVENT_TYPES` 数组加新值(不加会被忽略)
- `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize)
3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新
4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
### 修改一个流式打字机的行为
每个 streaming view 内部都是独立的:
- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段
- 渲染在 `*StreamingView.tsx`
- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支
### 新增草稿到右侧画布的逻辑
`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。
---
## 九、容错与边界处理
### 已落实的容错(不会阻断写作)
- ✅ `saveAIWritingTranscript` 失败仅 `console.warn`
- ✅ 历史会话列表加载失败仅 `console.warn`
- ✅ 重命名/删除会话失败静默吞掉
- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断
- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
### 已知边界(可能影响 UI 体验)
- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新
- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制
- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理
---
## 十、调试技巧
### 看 SSE 原始事件
浏览器 DevTools → Network → 找 `/api/ai-writing/<id>/stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
### 查当前 hook 状态
在浏览器 console 里:
```js
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
// 或在 useAIWriting 里临时加:
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
// 然后在 console 里 __aiwriting 看
```
### 模拟事件
开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。
### 看后端 transcript
```
GET /api/ai-writing/sessions/<id>
```
返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。
---
## 十一、性能注意点
### 大数组渲染
- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过)
- `streamingDraftSections` 章节多时也是潜在瓶颈
### transcript PUT 频率
- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听)
- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大
- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存
### 浏览器内存
- progressEvents 不主动清理 → 长会话可能占用几十 MB
- 完成一篇文章后建议 `reset()` 清空状态
---
## 十二、相关后端接口(速查)
| 接口 | 用途 | 前端调用方 |
|---|---|---|
| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` |
| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` |
| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` |
| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 |
| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` |
| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` |
| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` |
| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` |
| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` |
| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 |
---
## 十三、未来优化方向
| 项目 | 说明 | 工作量 |
|---|---|---|
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
---
## 附录 A:所有类型一览
完整定义在 `types/ai-writing.ts`,主要类型:
```typescript
// 业务实体
AIMaterial // 单个素材
AIOutline // 大纲
OutlineSection // 大纲单章节
ReviewIssue // 审核问题
ReviewResult // 完整审核结果
BlockedSection // 素材不足的章节
SectionDecision // 章节求助决策
// 流程
WritingStatus // 13 种状态
ProgressEventType // 17 种事件类型
ProgressEvent // SSE 事件载荷
UserInterventionPayload // 干预提交体
AIWritingRequest // 启动写作的请求
CompletedIntervention // 已完成的干预记录
// 流式视图
StreamingDraftSection
StreamingReviewItem
RevisionSummary
// Hook 状态
AIWritingState // useAIWriting 的完整状态
```
---
## 附录 B:常见踩坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 |
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 |
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` |
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -1,472 +0,0 @@
# AI 写作前端改造清单
## 1. 目标
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
当前核心文件:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
---
## 2. 改造原则
- 不改右侧主编辑器交互心智
- 不复制新页面
- 不破坏现有普通 chat 模式
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
- 所有 workflow 能力都应允许逐步灰度
---
## 3. 推荐前端结构
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
```text
frontend-web/src/open-canvas/
workflow/
api/
writing-workflow.ts
components/
WritingWorkflowEntryForm.tsx
WritingWorkflowTimeline.tsx
WritingWorkflowEventList.tsx
WritingWorkflowArtifactDrawer.tsx
WritingWorkflowConfirmCard.tsx
WritingWorkflowHeader.tsx
contexts/
WritingWorkflowContext.tsx
hooks/
useWritingWorkflow.ts
useWritingWorkflowEvents.ts
types/
workflow.ts
utils/
workflow-event.ts
```
说明:
- `open-canvas` 原有聊天、artifact、editor 目录不重构
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
---
## 4. 状态设计
## 4.1 新增的核心状态
建议新增:
- `workflowMode: "chat" | "writing_pipeline"`
- `workflowRunId: string | null`
- `workflowStage`
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
- `workflowEvents: WritingWorkflowEvent[]`
- `workflowArtifacts: WritingWorkflowArtifact[]`
- `workflowPendingAction: WritingPendingAction | null`
## 4.2 推荐类型定义
建议在 `workflow/types/workflow.ts` 中先定义:
```ts
export type WritingWorkflowStage =
| "idle"
| "collecting_materials"
| "generating_outline"
| "await_outline_confirmation"
| "writing_draft"
| "reviewing_draft"
| "rewriting_draft"
| "completed"
| "failed";
export type WritingWorkflowEventType =
| "workflow_started"
| "stage_changed"
| "agent_started"
| "agent_output_chunk"
| "artifact_generated"
| "human_confirmation_required"
| "retry_scheduled"
| "workflow_completed"
| "workflow_failed";
```
第一版把类型先收紧,后续再扩展。
---
## 5. 组件拆分建议
## 5.1 左侧主面板改造
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
建议改造为:
- `chat` 模式继续渲染现有消息列表 + 输入框
- `writing_pipeline` 模式渲染新的工作流视图
建议拆分出:
- `CanvasChatConversation.tsx`
- `CanvasChatInputBox.tsx`
- `CanvasWorkflowPanel.tsx`
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
## 5.2 新增组件职责
### `WritingWorkflowEntryForm.tsx`
负责采集:
- 主题
- 文章类型
- 目标读者
- 语气风格
- 目标篇幅
- 是否需要引用资料
- 禁写项
- 补充说明
### `WritingWorkflowTimeline.tsx`
负责显示阶段:
- 研究中
- 提纲生成中
- 等待确认提纲
- 正在写初稿
- 正在审稿
- 正在重写
- 已完成
### `WritingWorkflowEventList.tsx`
负责显示细粒度事件:
- 哪个 Agent 开始执行
- 当前在处理哪一步
- 当前产生了什么中间结果
- 是否重试
### `WritingWorkflowArtifactDrawer.tsx`
负责展示中间产物:
- 素材卡片
- 提纲
- 初稿
- 编辑意见
- 重写稿
### `WritingWorkflowConfirmCard.tsx`
负责展示用户确认点:
- 确认提纲
- 驳回提纲并附带修改意见
- 继续写稿
- 指定重写章节
---
## 6. Context 与 Hook 改造建议
## 6.1 `CanvasGraphContext`
当前它只维护:
- `messages`
- `artifact`
- `streamingArtifact`
- `isStreaming`
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
推荐做法:
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
- 新增 `WritingWorkflowContext`
## 6.2 `WritingWorkflowContext`
建议承载:
- 当前 workflow 运行状态
- 中间产物列表
- 待确认操作
- workflow 事件缓存
- 启动 / 取消 / 确认 / 重写等 action
## 6.3 `useCanvasThreadStream`
当前这个 hook 已经负责:
- 创建 thread
- 提交消息
- 接收 thread stream
- 将 `update_artifact` 同步到右侧
建议不要把“多 Agent workflow 状态机”也硬塞进去。
推荐方案:
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
- 新增 `useWritingWorkflow`
- 新增 `useWritingWorkflowEvents`
分工如下:
- `useCanvasThreadStream`:普通对话流
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
- `useWritingWorkflowEvents`:订阅 workflow 事件流
---
## 7. API 接入建议
## 7.1 前端 API 文件
新增:
- `workflow/api/writing-workflow.ts`
建议暴露方法:
- `createWorkflow(payload)`
- `startWorkflow(id)`
- `getWorkflow(id)`
- `subscribeWorkflowEvents(id, handlers)`
- `confirmOutline(id, payload)`
- `rewriteSection(id, payload)`
- `approveFinal(id)`
## 7.2 事件流形式
优先级建议:
1. `SSE`
2. 流式 HTTP chunk
3. 轮询兜底
理由:
- 事件驱动比整段消息流更适合 workflow UI
- 可以更方便映射到时间线和事件列表
---
## 8. 与右侧 Artifact 的衔接
## 8.1 现有优势
当前 `CanvasArtifactPanel` 已具备:
- 流式展示内容
- 最终 artifact 渲染
- 右侧编辑体验成熟
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
## 8.2 建议接法
工作流各阶段结果按两类处理:
- 中间产物:进入左侧 `workflowArtifacts`
- 可阅读正文:同步一份到右侧 `artifact`
推荐映射:
- 提纲:可选同步到右侧,供用户预览
- 初稿:同步到右侧
- 重写稿:覆盖或新增版本到右侧
- 终稿:作为最终右侧主版本
## 8.3 版本策略
建议右侧版本保留:
- `outline`
- `draft_v1`
- `draft_v2`
- `final`
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
---
## 9. 页面交互流程
## 9.1 用户首次进入
- 默认仍是现有 chat 模式
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
## 9.2 启动写作工作流
- 用户填写写作任务表单
- 点击“开始生成”
- 左侧切换到时间线 + 事件流视图
- 右侧进入 loading 或素材 / 提纲预览态
## 9.3 等待提纲确认
- 时间线停在 `await_outline_confirmation`
- 左侧出现确认卡片
- 用户可执行:
- 通过提纲
- 要求调整提纲
- 追加要求
## 9.4 生成初稿与审稿
- 左侧显示 Agent 事件
- 右侧展示初稿内容
- 审稿意见在左侧展示,同时高亮需要重写的章节
## 9.5 完成
- 左侧显示“终稿已完成”
- 右侧进入可继续局部 AI 编辑状态
---
## 10. 分步开发清单
## Step 1:类型与假数据
目标:
- 先把 workflow 的类型、mock 数据、组件骨架搭好
任务:
- 新建 `workflow/types/workflow.ts`
- 新建 `workflow/components/*`
- 用本地 mock 事件驱动时间线和事件流
验收:
- 不接后端也能在页面中演示完整流程
## Step 2:模式切换
目标:
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
任务:
- 拆出 `CanvasWorkflowPanel`
- 增加模式切换 UI
- 保证旧 chat 不回归
验收:
- 现有功能不受影响
- 新模式可进入表单页
## Step 3:接入 workflow API
目标:
- 从 mock 切到真实 API
任务:
- 接 `create/start/get/events`
- 实现基础错误态和重试态
验收:
- 前后端可联调跑一条主链路
## Step 4:接入确认节点
目标:
- 支持提纲确认和指定章节重写
任务:
- 做确认卡片
- 做动作回传
验收:
- 用户能在流程中介入
## Step 5:打通右侧版本
目标:
- 让 workflow 产物稳定进入右侧编辑器
任务:
- 约定 artifact 映射
- 接入版本切换或当前版本覆盖
验收:
- 终稿稳定显示在右侧
---
## 11. 建议修改点位
优先涉及文件:
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
建议新增,不建议重写原有:
- `open-canvas/workflow/api/writing-workflow.ts`
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
- `open-canvas/workflow/components/*`
---
## 12. 联调约定
前后端需要尽早统一:
- workflow id 命名
- stage 枚举
- event type 枚举
- artifact type 枚举
- user action payload
- 错误码与可恢复动作
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
---
## 13. 最终建议
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
第一版建议先保证三件事:
1. 左侧能看见阶段进展
2. 用户能确认提纲
3. 右侧能稳定收到初稿和终稿
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
@@ -1,718 +0,0 @@
# AI 写作弱模型多 Agent 编排需求文档
## 1. 背景与目标
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
- 结构松散,长文容易跑题
- 素材不足,内容空泛
- 事实依据弱,容易幻觉
- 润色能力有限,语言风格不统一
- 一次生成失败后缺少可控的纠偏机制
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
建议的角色分工:
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
- `Agent B 作家`:基于选题、素材和结构写初稿
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
---
## 2. 本次建设边界
### 2.1 前端边界
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
当前相关入口:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
前端本期只做以下增强:
- 在左侧聊天区增加“写作工作流模式”
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
### 2.2 后端边界
后端必须单开新路线,不影响其他同事当前功能开发。
建议原则:
- 不直接改现有 DeerFlow 主流程
- 不把多 Agent 逻辑硬塞进当前通用对话链路
- 以独立服务或独立模块形式建设
- 通过清晰 API 与当前前端页面对接
推荐落地位置:
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
---
## 3. 为什么多 Agent 对弱模型更合适
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
- 选题理解
- 信息检索
- 信息筛选
- 结构规划
- 长文写作
- 自我校验
- 风格润色
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
- 降低单次上下文复杂度
- 降低一次性长输出失败率
- 把“写得不好”变成“某一步做得不好”,便于重试
- 可以在关键节点插入规则校验与人工确认
- 可以用程序补足模型短板,而不是完全依赖模型智力
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
---
## 4. 可借鉴的开源项目
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
### 4.1 第一优先级:LangGraph
推荐原因:
- 天然适合把写作流程建模成状态图
- 支持多步骤、条件分支、重试、人工介入
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
- 对“弱模型 + 强流程控制”很友好
可借鉴点:
- `StateGraph` 的状态驱动编排
- 每一步单独定义输入、输出、失败处理
- 可视化地表达“通过 / 打回 / 重试 / 终止”
参考:
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
### 4.2 第二优先级:STORM
推荐原因:
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
- 对“先研究后写作”的思路非常契合
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
这和我们的目标高度一致,尤其适合借鉴:
- 提纲先行
- 素材引用先行
- 分模块写作
- 后置润色
参考:
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
### 4.3 第三优先级:Deep Research Agent
推荐原因:
- 明确支持本地模型与云模型混用
- 已经包含研究、写作、质量校验、缓存、重试等能力
- 很适合借鉴“研究阶段如何约束弱模型”
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
特别适合借鉴:
- 素材缓存
- 分阶段质量评分
- 节点失败重试
- 研究结果结构化存储
参考:
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
### 4.4 可选借鉴:CrewAI
推荐原因:
- 角色扮演表达自然
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
- Flow + Crew 的组织方式适合做 demo 和快速验证
不建议作为首选底座的原因:
- 更偏“Agent 角色编排体验”
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
适合借鉴:
- 角色定义写法
- Task 模板
- 多角色协作 prompt 结构
参考:
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
### 4.5 不建议新项目优先采用:AutoGen
原因:
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
- 官方建议新项目优先采用 `Microsoft Agent Framework`
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
参考:
- [microsoft/autogen](https://github.com/microsoft/autogen)
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
### 4.6 结论
综合本项目需求,建议技术选型优先级如下:
1. `LangGraph` 作为主编排框架
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
---
## 5. 目标产品形态
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
理想交互流程:
1. 用户输入主题、用途、风格、读者、篇幅要求
2. 系统先进入“研究阶段”
3. 系统给出资料卡片与候选提纲
4. 用户确认提纲或调整
5. 系统生成初稿
6. 严苛编辑给出问题清单
7. 系统自动重写问题段落
8. 系统输出终稿到右侧编辑区
9. 用户可继续局部改写、扩写、精简
核心体验目标:
- 左侧像“编辑部工作台”
- 右侧像“成文编辑器”
- 用户知道系统现在进行到哪一步
- 用户可以在关键节点接管,而不是只能等待最终结果
---
## 6. 多 Agent 流程设计
## 6.1 建议的最小可用流程 MVP
第一版不要一上来做很复杂,建议先做 4 步闭环:
1. `Research`
- 输入:主题、用户要求、内网资料源
- 输出:素材卡片列表、可引用事实列表、风险点列表
2. `Outline`
- 输入:用户要求 + 素材卡片
- 输出:结构化提纲
3. `Draft`
- 输入:提纲 + 素材卡片
- 输出:初稿
4. `ReviewAndRewrite`
- 输入:初稿 + 用户要求 + 素材卡片
- 输出:问题清单、改写稿、终稿
MVP 终止条件:
- 编辑评分达标则结束
- 不达标最多重写 2 次
- 超过次数则返回“需要人工介入”
## 6.2 第二阶段增强流程
在 MVP 跑通后,再增加:
- `Planner`:根据文章类型动态规划流程
- `SectionWriter`:按章节分别写作,避免长文崩坏
- `FactChecker`:逐段核对素材来源
- `StyleEditor`:统一语气、标题风格、句式节奏
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
## 6.3 建议的状态机
建议将流程状态固定为:
- `idle`
- `collecting_materials`
- `generating_outline`
- `await_outline_confirmation`
- `writing_draft`
- `reviewing_draft`
- `rewriting_draft`
- `completed`
- `failed`
- `human_intervention_required`
这样前端和后端都可以围绕统一状态通信。
---
## 7. Agent 角色定义建议
## 7.1 Agent A 研究员
职责:
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
- 输出结构化素材,而不是长篇自然语言
- 对每条素材标注来源、可信度、适用角度
输出格式建议:
- `facts[]`
- `quotes[]`
- `examples[]`
- `risks[]`
- `source_refs[]`
约束建议:
- 禁止直接生成整篇文章
- 不做风格化表达
- 每条结论必须可回溯到来源
## 7.2 Agent B 作家
职责:
- 严格基于提纲和素材写稿
- 按章节写,而不是一次写完整篇
- 对每段附带素材引用 ID,便于后续审核
约束建议:
- 无素材不发挥
- 每段只服务一个小节目标
- 段落长度控制
- 输出结构化草稿数据,而不仅是纯文本
## 7.3 Agent C 严苛编辑
职责:
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
- 输出明确的“问题清单”和“改写指令”
- 必要时打回指定章节,而不是整篇推翻
输出格式建议:
- `issues[]`
- `severity`
- `rewrite_instructions[]`
- `accept/reject`
编辑原则建议:
- 优先挑事实问题,再挑结构问题,最后才是文风问题
- 问题描述必须可执行,不能只说“写得不够好”
## 7.4 Supervisor
职责:
- 负责流程启动、状态流转、失败重试、记录日志
- 判断是否需要回到上一步
- 控制最大重试次数
- 对外提供统一 API 与流式事件
---
## 8. 前端需求
## 8.1 保持当前页面结构不变
继续沿用当前左右布局:
- 左侧:聊天 + 流程进度 + 中间产物
- 右侧:最终文章编辑与局部 AI 改写
不建议再复制一套新页面,以免造成维护分叉。
## 8.2 左侧新增 UI 模块
建议在 `CanvasChatPanel` 基础上新增:
- 写作任务表单
- 工作流阶段时间线
- Agent 执行日志面板
- 中间产物抽屉
- 人工确认卡片
建议新增的前端概念:
- `workflowMode`: `chat | writing_pipeline`
- `workflowStage`
- `workflowRunId`
- `workflowEvents[]`
- `workflowArtifacts[]`
## 8.3 需要展示的中间产物
至少支持展示:
- 素材卡片
- 提纲
- 初稿
- 编辑问题单
- 重写结果
- 最终定稿
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
## 8.4 人工介入节点
至少提供两个确认点:
- 提纲确认
- 初稿确认或编辑后重跑
后续可扩展:
- 指定重写某个章节
- 锁定某段不允许再改
- 替换某条素材后重新生成
---
## 9. 后端需求
## 9.1 架构原则
后端新路线必须满足:
- 与现有通用对话链路解耦
- 与现有 Agent 运行时解耦
- 独立维护 Prompt、状态机、数据结构
- 可以单独部署、单独回滚、单独压测
## 9.2 推荐模块划分
建议后端拆分为以下模块:
- `workflow_orchestrator`
- `agent_registry`
- `material_retriever`
- `outline_generator`
- `draft_writer`
- `editor_reviewer`
- `rewrite_engine`
- `workflow_store`
- `stream_event_adapter`
## 9.3 API 建议
建议最小 API:
- `POST /api/ai-writing/workflows`
- 创建写作任务
- `POST /api/ai-writing/workflows/{id}/start`
- 启动流程
- `GET /api/ai-writing/workflows/{id}`
- 查询当前状态
- `GET /api/ai-writing/workflows/{id}/events`
- 获取事件流或轮询事件
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
- 用户确认提纲
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
- 指定章节重写
- `POST /api/ai-writing/workflows/{id}/approve-final`
- 确认终稿
## 9.4 流式事件建议
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
- `workflow_started`
- `stage_changed`
- `agent_started`
- `agent_output_chunk`
- `artifact_generated`
- `human_confirmation_required`
- `retry_scheduled`
- `workflow_completed`
- `workflow_failed`
前端只要订阅事件,就能把左侧工作台做出来。
---
## 10. 弱模型优化策略
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
## 10.1 强约束输出格式
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
原因:
- 便于程序消费
- 便于后续 Agent 接续
- 便于失败重试
- 便于质量评估
## 10.2 长文拆成章节生成
不要让弱模型一次生成整篇长文,建议:
- 先出提纲
- 再按章节逐段写
- 最后统一润色
## 10.3 把检索和写作分离
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
## 10.4 程序兜底而不是模型兜底
程序需要承担:
- 去重
- 引用格式化
- 章节拼接
- 长度控制
- 评分阈值判断
- 重试路由
## 10.5 小步快跑、失败可回退
每步都要能独立失败、独立重试、独立回放。
## 10.6 引入可量化评分
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
- 相关性
- 事实性
- 结构完整度
- 语言流畅度
- 风格一致性
系统据此决定是否进入下一步。
---
## 11. 分阶段实施计划
## Phase 1:需求冻结与技术验证
目标:
- 确认文章类型范围
- 确认内网资料来源
- 选定主框架
- 跑通最小多 Agent demo
交付物:
- 技术选型说明
- 角色与状态机设计
- 1 个主题的端到端 demo
验收标准:
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
## Phase 2:后端新路线搭建
目标:
- 建立独立 orchestrator
- 建立 workflow state store
- 建立事件流输出
- 建立可配置 prompt 模板
交付物:
- 独立服务目录
- 基础 API
- 状态机运行骨架
验收标准:
- 不依赖现有主流程改造即可独立跑通
## Phase 3:前端接入当前页面
目标:
- 在 `CanvasChatPanel` 接入工作流模式
- 增加阶段展示与人工确认
- 中间产物可视化
交付物:
- 左侧工作流时间线
- 中间产物展示面板
- 流程控制按钮
验收标准:
- 用户可以在当前页面完成一次全流程写作
## Phase 4:质量优化
目标:
- 强化素材检索质量
- 提高提纲质量
- 引入章节级重写
- 引入评分与回退机制
交付物:
- 质量评估规则
- 重试策略
- Prompt 调优包
验收标准:
- 弱模型效果明显优于单轮直出
## Phase 5:生产化
目标:
- 加缓存
- 加日志
- 加监控
- 加失败告警
- 加审计记录
交付物:
- 可观测性方案
- 运行日志与回放
- 配置中心或 prompt 管理机制
验收标准:
- 可长期维护并可定位问题
---
## 12. 团队协作与隔离方案
为了不影响其他同事,建议采用以下协作原则:
- 新后端目录独立开发
- 新接口使用独立路由前缀
- 前端只在 `open-canvas` 内增量开发
- 不修改现有公共线程协议,必要时增加新事件类型
- Prompt、Agent 配置、评分规则独立存放
推荐分工:
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
---
## 13. 风险与规避
### 风险 1:Agent 太多,成本和耗时暴涨
规避:
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
- 长文按章节写,失败只重写局部
### 风险 2:弱模型在自由对话场景中失控
规避:
- 全流程结构化
- 每步只做一件事
- 强约束输入输出格式
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
规避:
- 先把资料源治理好
- 对素材做来源、可信度、去重处理
### 风险 4:前后端协议频繁变化影响联调
规避:
- 先定义事件模型和 artifact 模型
- 先 mock 数据,再并行开发
### 风险 5:影响现有功能稳定性
规避:
- 新路线、新接口、新状态机
- 不侵入现有主链路
---
## 14. 推荐结论
本项目推荐的总体策略:
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
3. 主框架优先采用 `LangGraph`
4. 流程设计借鉴 `STORM`
5. 质量控制借鉴 `Deep Research Agent`
6. 角色设计与任务模板可参考 `CrewAI`
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
一句话总结:
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
---
## 15. 参考链接
- [LangGraph](https://github.com/langchain-ai/langgraph)
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
- [STORM](https://github.com/stanford-oval/storm)
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
- [CrewAI](https://github.com/crewAIInc/crewAI)
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
- [AutoGen](https://github.com/microsoft/autogen)
-501
View File
@@ -1,501 +0,0 @@
# AI 写作 — 迁移到 LangGraph Server 的分步实现方案
> **文档目的**:彻底解决 AI 写作在多 worker 部署下"SSE 时断时续 / resume 偶发 404"的根因,参照主聊天(`/workspace/chats/new`)的实现,把 AI 写作的 graph 注册成 LangGraph Server 上的一个 assistant,让 Server 接管线程、checkpoint、SSE、interrupt/resume 的全部协议。
>
> **配套文档**:[ai-writing-说明.md](./ai-writing-说明.md) 里描述了为什么会出问题、当前已落地的兜底方案,以及方案 B/C/F 三条"绕过"路径。本文档是其中"长期方案"的具体实现路线 —— 不是绕过多 worker,而是从架构层面让它**天然不受 worker 数影响**。
>
> **更新日期**:2026-05-20
---
## 一、为什么走这条路
主聊天和 AI 写作功能形态几乎一致(多步流式、可中断/恢复、有持久化),但**主聊天完全不受多 worker 影响**。差别在于:
| 维度 | 主聊天(现状) | AI 写作(现状) | AI 写作(迁移后) |
|---|---|---|---|
| 前端流接入 | `useStream` (SDK) | `fetch` + 自写 `EventSource` | `useStream` (SDK) |
| 后端运行时 | **LangGraph Server**(独立进程,端口 2024 / 经 nginx 走 `/api/langgraph`) | 嵌在 FastAPI Gateway 路由里直接跑 | **LangGraph Server**(同主聊天) |
| 线程状态 | Server 自带持久化 checkpointer | 自管 sqlite + 一堆模块级 dict | Server 自带持久化 checkpointer |
| SSE 通道 | Server ↔ SDK 协议层 | 自写 `asyncio.Queue` per session_id | Server ↔ SDK 协议层 |
| 多 worker | 任何 worker 都能查 Server | per-worker 内存 dict 隔离 | 任何 worker 都能查 Server |
| Interrupt/Resume | Server 协议(`Command(resume=...)`)| 自写 `_session_pending_pause` + `/resume` 路由 | Server 协议 |
**核心收益**:迁完后 `_event_queues`、`_session_pending_pause`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_article_types`、`_session_error_flags`、`_session_pending_pause`、`_inflight_graph_tasks`、`_recover_session()`、自管 sqlite checkpointer 这一整套"per-process 内存状态"全部可以删掉 —— 不是"绕过"问题,是**让问题消失**。
**代价**:节点要改造(流式 chunk 改走 LangGraph 原生 `StreamWriter`/state update)、前端干预卡的交互要重写、业务表写入路径要从"路由里写"挪到"节点里写"。约 **6 人日**核心改造 + 50% buffer,建议按 **9 人日**排期。
---
## 二、现状盘点:要删 / 要留 / 要改
### 2.1 要删的(迁完即可移除)
| 位置 | 内容 | 备注 |
|---|---|---|
| `app/gateway/routers/ai_writing.py` | `POST /start`、`GET /{id}/stream`、`POST /{id}/resume`、`GET /{id}/state` | 由 LangGraph Server 的 `runs.stream / threads.create / runs.wait` 替代 |
| 同上 | `_event_queues`、`_session_pending_pause`、`_session_model_names`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_error_flags`、`_session_article_types` 这 8 个模块级 dict | 状态全部交给 LangGraph Server |
| 同上 | `_recover_session()`、`_inflight_graph_tasks`、`_spawn_graph_task()`、`wait_for_inflight_graph_tasks()` | 跨 worker 恢复 / drain 都由 Server 内建机制处理 |
| 同上 | `_make_config()` 里注入 `sse_queue` / `sse_session_id` 的逻辑 | 流式输出改走 LangGraph 原生通道 |
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()`、`AI_WRITING_GRAPH` 单例、`_async_checkpointer_singleton` | checkpoint 由 langgraph.json 的全局 checkpointer 接管 |
| `app/gateway/ai_writing_cleanup.py` | "联动清理 sqlite checkpointer thread" 那一段 | 改成调 `langgraph_sdk` 的 `threads.delete()` |
| `offline-backend-20260512/config.yaml` | `checkpointer:` 段里 AI 写作专用的 sqlite 路径(如果跟主 checkpointer 不同) | 共用主配置 |
### 2.2 要保留的
| 位置 | 内容 | 理由 |
|---|---|---|
| `packages/harness/deerflow/persistence/ai_writing_sessions/` | MySQL 业务表(标题、最终 draft、completed_interventions、transcript、article_type、status…) | 列表页 / 历史回显 / 全文搜索都依赖业务表,**不能让 LangGraph checkpoint 取代它** —— checkpoint 是流程状态,业务表是产品形态 |
| `GET/PATCH/DELETE /api/ai-writing/sessions[/{id}]` | 列表 / 重命名 / 删除 | 只查业务表,不碰 graph |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 时间线持久化 | 维持锦上添花的回显方案 |
| `GET/POST/PATCH/DELETE /api/ai-writing/article-types[/{id}]` | 文章类型 CRUD | 纯业务路由,跟 graph 无关 |
| `POST /api/ai-writing/admin/cleanup` | admin 清理入口 | 路由保留,内部实现改 |
| 4 个 interrupt 暂停点 + 5 个节点 + 全部 state 字段 | 业务逻辑零改动 | `pause_material_confirm` / `pause_outline_confirm` / `pause_section_help` / `pause_draft_confirm` / `pause_review_confirm` 用的就是 LangGraph 原生 `interrupt()`,直接吃迁移红利 |
### 2.3 要改的
| 位置 | 改造点 |
|---|---|
| `graph.py` | 删自管 checkpointer,导出 `make_ai_writing_graph(config) → CompiledStateGraph`,由 Server 注入 checkpointer |
| `langgraph.json` | `graphs` 里增加 `"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"` |
| 各 node(`researcher.py` / `writer_outline.py` / `writer_draft.py` / `editor.py`) | 流式输出从 `sse_queue.put_nowait(...)` 改成 LangGraph 原生 `get_stream_writer()` 推 custom 事件 |
| 各 node | DB 写入(草稿、审核结果、status)从「路由里调 repo」改成「节点里通过 `RunnableConfig` 拿 `thread_id` 调 repo」 |
| 各 node | "创建 session 行"从 `/start` 路由里挪到 `intent_parser_node`(或前端 `onCreated` 回调里调一个轻量级 upsert API) |
| `langgraph_auth.py` | 给 `ai_writing` assistant 加授权规则(保证只能访问自己的 thread) |
| 前端 `frontend-web/src/open-canvas/` | 完全弃用现有 `api/ai-writing.ts` 的 `fetch + EventSource`,新写 `useAIWritingStream` hook(基于 `@langchain/langgraph-sdk/react` 的 `useStream`) |
| 前端 4 个干预卡 | 提交回调从 `POST /resume` 改为 `thread.submit(new Command({ resume: payload }))` |
| 前端"启动写作"按钮 | 从 `POST /start` + 拿 session_id + 建 EventSource 改成 `thread.submit({...args})` 触发 `onCreated` 回调 |
| `tests/test_ai_writing_session_recovery.py` | 整体删除 —— 跨 worker 恢复由 LangGraph Server 接管,不再是 AI 写作自己的事 |
| 新增 `tests/test_ai_writing_assistant.py` | 通过 LangGraph SDK in-process 跑一遍 graph,覆盖 interrupt / resume / custom stream / state 持久化 |
---
## 三、分步实施
### 阶段 0:可行性预演(0.5 d)
**目标**:在不动现有代码的前提下,验证"把 ai_writing graph 注册到 LangGraph Server 并通过 SDK 跑通"这条路完全可行。
1. 临时在 `langgraph.json` 的 `graphs` 里加一条 `"ai_writing_probe": "deerflow.agents.ai_writing.graph:build_ai_writing_graph"`(直接复用现有 `build_ai_writing_graph`,不传 checkpointer 让 Server 注入)。
2. 起 `make dev`,确认 LangGraph Server 把 `ai_writing_probe` 列入 assistant 列表。
3. 用 Python SDK 客户端(`langgraph_sdk.get_client()`):
```python
from langgraph_sdk import get_client
client = get_client(url="http://localhost:8001/api/langgraph")
thread = await client.threads.create()
async for chunk in client.runs.stream(
thread["thread_id"], "ai_writing_probe",
input={"user_intent": "测试", "article_type": "analysis", ...},
stream_mode=["values", "messages-tuple", "custom"],
): print(chunk)
# 跑到 pause_material_confirm 应该收到 interrupt 事件
await client.runs.stream(thread["thread_id"], "ai_writing_probe",
command={"resume": {"action": "confirm", "approvedMaterialIds": [...]}})
```
4. 验证:interrupt 能正确暂停、`Command(resume=...)` 能续跑、state 能持久化到 Server checkpoint。
5. 跑通后**回滚 langgraph.json** —— 这一步只是"摸路"。
**通过条件**:4 个 pause 点全部能用 SDK 跑通 interrupt + resume。如果某个 pause 点的 payload 结构跟 SDK 协议不兼容(例如用了非 JSON 可序列化的对象),阶段 0 就要解决,否则会在阶段 2 翻车。
---
### 阶段 1:graph 注册为 assistant(0.5 d)
**改动文件**:`packages/harness/deerflow/agents/ai_writing/graph.py`、`offline-backend-20260512/backend/langgraph.json`
1. `graph.py` 顶部新增:
```python
from langchain_core.runnables import RunnableConfig
def make_ai_writing_graph(config: RunnableConfig | None = None):
"""LangGraph Server 入口:不接收 checkpointer,由 Server 注入。"""
g = StateGraph(AIWritingState)
# ... 跟现在 build_ai_writing_graph 完全一样的节点 / 边定义 ...
return g.compile() # 注意:不传 checkpointer
```
2. 旧的 `build_ai_writing_graph(checkpointer=None)`、`get_ai_writing_graph()`、`_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()` 全部**先标 `@deprecated` 暂留** —— 阶段 7 再删,避免阶段 2/3 出错时无法快速回退。
3. `langgraph.json`:
```json
{
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
}
}
```
4. `checkpointer.path` 已经指向 `make_checkpointer`(看 `langgraph.json:15-17`),AI 写作直接共享主 checkpointer 即可,**不要**重新搞一套。
**通过条件**:启动 LangGraph Server 后 `GET /api/langgraph/assistants` 能列出 `ai_writing`,state schema 字段齐全。
---
### 阶段 2:节点改造 —— 流式输出走原生通道(1.5 d)
**核心问题**:现在的 `writer_draft_node` / `writer_outline_node` / `researcher_node` 通过 `config["configurable"]["sse_queue"]` 把 LLM token chunk 推给前端。LangGraph Server 不会再注入 `sse_queue`,必须改用原生流通道。
**选型**:用 `langgraph.config.get_stream_writer()` 推 `custom` 事件(保留现有"非聊天消息、自定义协议"的形态)。
> 不选 `messages-tuple` 模式的原因:草稿生成不是聊天消息流,而是大段 markdown;硬塞成 `AIMessage` 会让前端渲染逻辑变得很别扭。custom 通道改动最小、契合度最高。
**改造模板**(每个节点都按这个模板改):
```python
# 旧:
from .nodes._utils import push_sse_event
async def writer_draft_node(state, config):
sse_queue = config["configurable"].get("sse_queue")
sse_queue.put_nowait({"event": "progress", "data": {...}})
async for chunk in llm.astream(...):
sse_queue.put_nowait({"event": "draft_chunk", "data": {"text": chunk}})
# 新:
from langgraph.config import get_stream_writer
async def writer_draft_node(state, config):
writer = get_stream_writer() # 注意:必须在 async 函数体内调,不能模块级缓存
writer({"type": "progress", "agent": "writer", "message": "开始写作"})
async for chunk in llm.astream(...):
writer({"type": "draft_chunk", "text": chunk})
```
**业务表写入改造**:现在 `writer_draft_node` 收尾时通过 `config["configurable"]["sse_session_id"]` + `_session_repos[sid]` 调 `repo.save_draft()`。新方案:
```python
async def writer_draft_node(state, config):
thread_id = config["configurable"]["thread_id"] # LangGraph Server 注入
# 从 thread metadata / state 里拿到 user_id(在阶段 4 写入)
user_id = state.get("_user_id") or config["configurable"].get("user_id")
repo = get_ai_writing_session_store() # 进程内全局,无 per-session 字典
try:
await repo.save_draft(thread_id, user_id, state["current_draft"])
except Exception:
logger.exception("save_draft failed (non-fatal)")
```
**注意事项**:
- `get_stream_writer()` 在子图 / 工具函数里会拿到当前 graph 的 writer,**不要**在模块 import 时缓存。
- 节点要兼容"测试场景下没有 LangGraph runtime":包一层 `try: writer = get_stream_writer() except ...`,单测里 writer 是 no-op。
- progress_events 字段保留写入 state(现在节点都已经 return `{"progress_events": [...]}`),这样 history / 回放仍然能在 state 里看到完整轨迹;custom stream 只是为了**实时推送**。
**通过条件**:手动跑一次 `make dev`,用 SDK 客户端从前一阶段验证脚本流式拿到 `draft_chunk` 事件。
---
### 阶段 3:前端接入 LangGraph SDK(1 d)
**核心改动**:`frontend-web/src/open-canvas/api/ai-writing.ts` 整文件作废,改写 `frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`。
**新 hook 骨架**(参考 `frontend-web/src/core/threads/hooks.ts:207` 现有主聊天写法):
```ts
import { useStream } from "@langchain/langgraph-sdk/react";
import { Command } from "@langchain/langgraph-sdk";
import { getAPIClient } from "@/core/api/api-client";
interface AIWritingValues {
user_intent: string;
article_type: string;
status: string;
material_package?: MaterialPackage;
current_outline?: Outline;
current_draft?: DraftArticle;
review_result?: ReviewResult;
progress_events: any[];
}
export function useAIWritingStream(sessionId: string | "new") {
const stream = useStream<AIWritingValues, { CustomEventType: AIWritingCustomEvent }>({
client: getAPIClient(),
assistantId: "ai_writing",
threadId: sessionId === "new" ? undefined : sessionId,
reconnectOnMount: true,
onCreated(meta) {
// 后端会用同一个 thread_id 做 ai_writing_sessions.id;通知 UI 写入 URL
navigateTo(`/workspace/ai-writing/${meta.thread_id}`);
},
onCustomEvent(event) {
// 节点 push 来的 draft_chunk / progress / notice
pushUIEvent(event);
},
});
return stream;
}
```
**4 个干预卡的提交逻辑**(旧 → 新):
```ts
// 旧(自写 fetch):
await fetch(`/api/ai-writing/${sessionId}/resume`, {
method: "POST",
body: JSON.stringify({ action: "confirm", approvedMaterialIds: [...] }),
});
// 新(SDK 原生 Command):
stream.submit(undefined, {
command: { resume: { action: "confirm", approvedMaterialIds: [...] } },
});
```
**启动写作**:
```ts
// 旧:POST /start → 返回 session_id → 建 EventSource
// 新:
stream.submit({
user_intent, article_type, target_audience, word_count_target, keyword_count, writing_mode,
}, {
config: { configurable: { model_name } },
metadata: { feature: "ai_writing", article_type }, // 走业务表筛选
});
```
**列表页 / 历史回显**:仍然走 `GET /api/ai-writing/sessions` 业务 API,**无改动**。点进某条历史记录时:
- 拿到 `sessionId` (== LangGraph `thread_id`)
- `useAIWritingStream(sessionId)` 自动调 `client.threads.getState()` 拉最新 state,渲染最后一次草稿 / 干预点 / interrupt 等待
- 已结束的会话 (`status == "done"`) 直接读业务表的 `draft_markdown` 字段,**不挂 SDK stream**(省 Server 资源)
**通过条件**:手动跑完一个完整流程(启动 → 4 次干预 → 拿到草稿),过程中刷新页面 / 切 worker,进度都不丢。
---
### 阶段 4:业务表写入解耦(0.5 d)
**问题**:现在 `/start` 路由先 INSERT 一行 `ai_writing_sessions(id=session_id, ...)`,再启动 graph。迁移后前端用 `stream.submit(...)` 直接触发 LangGraph 建 thread + 跑 graph,业务表行没人建 → 列表页查不到。
**两个方案二选一**:
**方案 4A(推荐)**:节点里建业务表行
- `intent_parser_node` 开头:`await repo.upsert_session(thread_id, user_id, user_intent, article_type, status="researching")`
- 优点:单一数据源(节点),不依赖前端做"双写"。
- 缺点:用户从前端点"启动" → 看到列表里出现这条 → 之间有 200~500ms 延迟(intent_parser 跑完才入库),需要前端 onCreated 后乐观插入一条临时行。
**方案 4B**:前端 onCreated 时调一个轻量 upsert API
- 新增 `POST /api/ai-writing/sessions/{thread_id}/init`:只插业务表行,不碰 graph。
- 前端 `onCreated(meta)` 回调里立刻 fire-and-forget 调它。
- 优点:列表瞬间出现新行。
- 缺点:前端要多调一次 API,且失败要兜底;多了一条"双方都能建行"的路径。
**推荐 4A** —— 单一数据源比 UI 响应更重要,200ms 延迟用 loading skeleton 即可。
**用户身份传递**:节点要拿到 `user_id` 写入业务表。两条路:
- LangGraph Server 已有 `langgraph_auth.py` 注入 auth context,在节点里 `config["configurable"].get("langgraph_auth_user")` 取(具体字段名以现有实现为准)。
- 或者前端 `submit()` 时 metadata 里带上 `user_id`,节点从 `config["configurable"]["metadata"]` 拿。
**通过条件**:从启动到列表显示新行,延迟 < 1s;多用户场景下不会出现"A 用户的 thread 落到 B 的业务表行"。
---
### 阶段 5:transcript 持久化(0.2 d)
**当前**:前端 `PUT /api/ai-writing/sessions/{id}/transcript` 全量覆盖 `ai_writing_sessions.transcript` 列。
**改造选择**:
- **维持现状**(推荐):transcript 是前端组装的时间线 blob,让前端继续 fire-and-forget PUT 即可,跟迁移正交。
- 或者完全弃用 transcript 列,直接从 `client.threads.getHistory()` + `getState()` 还原 —— 工作量大且需要重写回显逻辑,**不推荐第一版做**。
**通过条件**:原 PUT 接口照常工作;新建会话也能落库。
---
### 阶段 6:admin 清理改造(0.3 d)
**改动文件**:`app/gateway/ai_writing_cleanup.py`
```python
# 旧:
saver = get_ai_writing_checkpointer()
await saver.adelete_thread(sid) # 自管 sqlite
# 新:
from langgraph_sdk import get_client
lg_client = get_client(url=internal_langgraph_url())
await lg_client.threads.delete(sid)
```
**活跃保护**:旧逻辑是看 `sid in _event_queues`。新逻辑:
```python
state = await lg_client.threads.get_state(sid)
is_active = (
state.get("next") # graph 还有未执行的节点
or state.get("tasks") # 当前有 pending task
or state.get("metadata", {}).get("interrupted") # 卡在 interrupt 上
)
if is_active:
skipped_active += 1
continue
```
**通过条件**:admin cleanup 接口照常返回 `deleted_db / deleted_checkpoints / skipped_active`,且能正确跳过仍在 interrupt 等待用户的会话。
---
### 阶段 7:删冗余代码(0.3 d)
按 §2.1 列表逐项删除。建议**单独开一个 PR**,跟阶段 1~6 解耦:
- 前 6 个 PR 全部上线、灰度验证至少 1 周
- 确认旧代码路径再无流量后,再走清理 PR
- 这样万一灰度阶段需要回滚,旧路径还在
**通过条件**:`make test` 全绿,`grep -r "_event_queues\|_session_pending_pause\|_recover_session" app/ packages/` 零命中。
---
### 阶段 8:测试 + 灰度(1 d)
**新增测试**:
- `tests/test_ai_writing_assistant.py` —— 用 `langgraph_sdk` 客户端打 LangGraph Server,覆盖:
- 完整流程:start → 4 个 interrupt → resume → done
- state 持久化:跑到 pause_outline 后重建客户端,能 `getState` 看到 outline
- custom stream:能收到 `draft_chunk` 等事件
- 错误兜底:节点抛错时 status 落 error、业务表 status 同步
- 保留 `tests/test_portable_long_text.py` / `tests/test_ai_writing_transcript_endpoint.py` / `tests/test_ai_writing_session_repo.py` —— 这几个跟业务表强相关,不受迁移影响。
**删除测试**:
- `tests/test_ai_writing_session_recovery.py` —— 跨 worker 恢复改由 LangGraph Server 提供,不再是 AI 写作自己的能力。
- `tests/test_ai_writing_cleanup.py` 里关于"drain + checkpointer 降级 + SSE 序列化"的几个用例 —— 这些机制已删,对应用例失效。
**灰度策略**:
- **前端开关**:`VITE_AI_WRITING_BACKEND` = `legacy | langgraph`,默认 `legacy`
- `legacy` → 走老的 `fetch + EventSource + /resume`
- `langgraph` → 走新 `useAIWritingStream`
- **后端**:旧路由(`/start /stream /resume /state`)保留两个发版周期,逐步下线
- **数据兼容**:业务表 schema 完全不变,两条路径都用同一张表
- **回滚成本**:改回 `VITE_AI_WRITING_BACKEND=legacy` 重新部署即可,无 DB migration
**通过条件**:
- 灰度环境跑 100+ 写作流程,对比新旧两条路径的成功率、SSE 中断率、resume 成功率
- 新路径在 worker=4 部署下 SSE 中断率 = 0(旧路径基线约 5~15%)
---
## 四、关键设计决策与权衡
### 4.1 为什么不让 LangGraph checkpoint 替代业务表?
LangGraph checkpoint 存的是"图执行状态",业务表存的是"产品形态"。两者职责不同:
- **会话列表筛选/排序**:业务表 `WHERE user_id = ? AND status = ? ORDER BY updated_at` —— LangGraph checkpoint 没法这样查(它是 `(thread_id, checkpoint_ns)` 主键)
- **全文搜索 draft**:业务表 `LIKE '%关键词%'` —— checkpoint 是序列化 blob
- **transcript 自定义 UI 协议**:前端组装的时间线 JSON,跟 graph state 不一一对应
**结论**:两者**并存**,`thread_id` 作为外键。这是主聊天的做法(`threads_meta` + LangGraph checkpoint),AI 写作迁完后也是同样的形态。
### 4.2 为什么 graph 节点里直接调 repo,不发到外面消费?
考虑过"节点只 emit custom event,路由层订阅事件后写业务表" —— 但这又把状态分散回路由层了。
直接在节点里调 `await repo.save_draft(...)`:
- 节点拿得到 `thread_id`(== session_id == business id),主键一致
- `repo` 是单例(DB 连接池),不依赖 per-session 字典
- 写入失败包 try/except 即可(草稿落 state 比落 DB 更关键,DB 失败不阻断流程)
### 4.3 为什么用 custom stream 而不是 messages-tuple?
writer_draft 输出是大段 markdown,强行包成 `AIMessage` 后:
- 前端要把它从消息列表里拎出来当草稿渲染(混杂)
- 大纲 / 审核结果是结构化 JSON,更不像聊天消息
- progress_events / notice / draft_chunk / outline_chunk 几种事件类型,全塞 message 通道得发明 metadata convention
custom stream 是为这种"非聊天但需要流式 push"场景设计的,匹配度更高。
### 4.4 在 Server 进程里跑 graph,资源够用吗?
LangGraph Server 是独立 Python 进程,跟 Gateway 共享同一台机器但不共享内存。新增一个 assistant 的常驻开销 ~10 MB(graph 编译后的 schema 缓存),每个并发会话峰值 ~50 MB(LLM client + state)。
实测建议:在阶段 0 之后跑一次 10 个并发的压测,观察 Server RSS 增量。若一台机器 4 GB 内存,理论支撑 60~80 并发 AI 写作,足够 50 人规模团队使用。
---
## 五、风险清单
| # | 风险 | 缓解 |
|---|---|---|
| R1 | LangGraph Server 升级版本时 `interrupt()` / `Command(resume=...)` 协议有 breaking change | 在 `pyproject.toml` 里 pin `langgraph` minor 版本;升级前先在阶段 0 那个 probe 跑一遍冒烟 |
| R2 | 前端 `useStream` 的 `reconnectOnMount` 行为跟自写 EventSource 不完全一致,刷新页面后某些情况下不会自动续接 SSE | 阶段 3 手动测刷新场景;如不达预期,配合 `client.threads.getState()` 拉一次 state 兜底 |
| R3 | 节点里 `get_stream_writer()` 在单测里没有 LangGraph runtime → 抛错 | 节点 wrap `try: writer = get_stream_writer() except: writer = lambda x: None` |
| R4 | 多用户场景下 `langgraph_auth.py` 没对 ai_writing assistant 做隔离,可能跨用户拿到别人的 thread state | 阶段 4 一并配置 auth;新增专项测试 |
| R5 | `client.threads.delete()` 是异步删,admin 清理脚本调完后立即查 state 还能短暂查到 → `skipped_active` 误判 | 删之前先 lock thread 或调用 `client.runs.cancel()` 强制收敛 |
| R6 | LangGraph Server 自带 checkpoint 仍然是 sqlite(`langgraph.json` 里配的),在容器重启时跟 AI 写作原有的 `.deer-flow/data/checkpoints.db` 是同一个文件 → schema 共用 | 阶段 1 之前先确认主 checkpointer 配置路径,避免双开 sqlite 文件锁竞争 |
| R7 | 现有用户在迁移窗口期发起的会话,灰度切到新路径后无法续跑(state 在旧 sqlite,新路径查不到) | 迁移前 1 小时禁止新会话;或写一个一次性脚本把旧 checkpointer 的 thread 复制到主 checkpointer |
---
## 六、工时与里程碑
| 阶段 | 工时 | 可独立交付 |
|---|---|---|
| 阶段 0:可行性预演 | 0.5 d | ✅ |
| 阶段 1:graph 注册 | 0.5 d | ❌(后端只生效,前端没接) |
| 阶段 2:节点改造 | 1.5 d | ❌ |
| 阶段 3:前端接入 | 1 d | ✅(带开关上灰度) |
| 阶段 4:业务表写入解耦 | 0.5 d | ❌(跟阶段 3 合并发) |
| 阶段 5:transcript | 0.2 d | ✅ |
| 阶段 6:admin 清理改造 | 0.3 d | ✅ |
| 阶段 7:删冗余 | 0.3 d | ✅(独立 PR,灰度后再发) |
| 阶段 8:测试 + 灰度 | 1 d | ✅ |
| **核心工时** | **5.8 d** | |
| **+ 50% buffer** | **8.7 d ≈ 9 d** | |
**建议排期**:
- 第 1 周:阶段 0 + 1 + 2(后端完整、本地能跑通)
- 第 2 周:阶段 3 + 4 + 5 + 6(前端 + 业务集成)
- 第 3 周:阶段 8(测试 + 灰度)
- 灰度 1~2 周后:阶段 7(删冗余)
---
## 七、回滚预案
每个阶段都设计为可独立回滚:
| 场景 | 回滚动作 |
|---|---|
| 阶段 3 上线后发现 SSE 不稳 | 前端开关 `VITE_AI_WRITING_BACKEND=legacy`,重新部署,0 dataloss |
| 阶段 7 删冗余代码后发现新路径有问题 | 从 git revert 删除 commit,恢复旧路由代码;业务表/checkpoint 不受影响 |
| 阶段 1 注册 assistant 后 LangGraph Server 起不来 | 从 `langgraph.json` 移除 `ai_writing` 那一行,重启 Server,回到迁移前状态 |
**数据兼容承诺**:
- 业务表 schema 完全不变
- `thread_id` (== `ai_writing_sessions.id`) 含义保持一致
- transcript 列含义不变
**最坏回滚**:把 `VITE_AI_WRITING_BACKEND` 改回 `legacy`、`langgraph.json` 删 `ai_writing` 那行,回到迁移前的"方案 C/F/B 路径"。已经走完的会话留在新 checkpoint 里成"孤儿",但业务表里的草稿和审核结果**完整无损**。
---
## 八、迁移成功后能享受的"免费红利"
1. **完整 history API**:`client.threads.getHistory(thread_id)` 拿到每一步 checkpoint,做"回到某一步重试"非常便宜
2. **time travel**:用户能从任意 checkpoint 分叉重写
3. **跟主聊天共享基础设施**:升级 LangGraph 版本时一并升级,不用维护两套 SSE / checkpoint 协议
4. **天然支持水平扩容**:哪天上 K8s 多副本部署,AI 写作跟主聊天一样开箱即用
5. **天然支持 IM 渠道接入**:`app/channels/` 那套 Feishu / Slack 实现是 `langgraph-sdk` 的,迁完后 AI 写作也能在 IM 里用,零额外代码
---
## 九、相关文件索引(迁移前后参照)
### 现状(迁前)
- `app/gateway/routers/ai_writing.py:148-176` —— 8 个模块级 dict + drain task
- `packages/harness/deerflow/agents/ai_writing/graph.py:370-538` —— 自管 checkpointer
- `frontend-web/src/open-canvas/api/ai-writing.ts` —— 自写 fetch + EventSource
### 参照(主聊天迁后形态)
- `frontend-web/src/core/threads/hooks.ts:207` —— `useStream` 用法
- `frontend-web/src/core/api/api-client.ts` —— LangGraph SDK Client 初始化
- `offline-backend-20260512/backend/langgraph.json` —— assistant 注册位置
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/` —— graph 工厂参考写法
- `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py` —— 共享 checkpointer 入口
### 目标(迁后)
- 新增:`packages/harness/deerflow/agents/ai_writing/graph.py:make_ai_writing_graph`
- 新增:`frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`
- 删除:`app/gateway/routers/ai_writing.py` 中的 `/start /stream /resume /state` 路由 + 8 个 dict
- 删除:`frontend-web/src/open-canvas/api/ai-writing.ts` 中的 `startWriting / streamSession / resume` 全部 fetch 调用
- 简化:`app/gateway/ai_writing_cleanup.py`(改用 SDK client 删 thread)
---
## 十、决策摘要(给 PM / 维护者)
> **TL;DR**
> 1. 主聊天不受多 worker 影响,是因为它把状态托管给了独立的 LangGraph Server 进程,前端直接通过 SDK 跟 Server 通信。AI 写作要做同样的事。
> 2. 改造范围:后端 graph 注册 + 节点流通道改造 + 前端 hook 换 SDK + 业务表写入解耦。**核心约 6 人日,含 buffer 9 人日**。
> 3. 业务表、节点逻辑、4 个干预点、UI 形态全部保留,**用户感知层面零变化**。
> 4. 改造完一次性获得"刷新不丢进度、多 worker 不裂、跟主聊天共享基础设施、IM 渠道免费支持"等长期红利。
> 5. 建议跟"方案 C"并行:先按 [ai-writing-说明.md](./ai-writing-说明.md) 上方案 C 应急止血,本方案作为长期改造按周排期。两者不冲突,方案 C 上线即受益,本方案完工后可下线方案 C 的双进程配置。
-787
View File
@@ -1,787 +0,0 @@
# AI 写作 — 故障排查与解决方案说明
> **本文档目的**:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。
>
> **适用读者**:维护 AI 写作功能的前端/后端开发者、内网部署运维。
>
> **更新日期**:2026-05-20
---
## 一、问题背景
### 起因
内网 Docker 部署后,AI 写作出现两类报错:
| 接口 | 报错 | 频率 |
|---|---|---|
| `PUT /api/ai-writing/sessions/{id}/transcript` | HTTP 500 | 经常 |
| `POST /api/ai-writing/{id}/resume` | HTTP 4xx(用户记忆为 400,实际可能是 404) | 经常,且没有 Docker 重启时也发生 |
### 这两个接口的作用
#### 1. `PUT /sessions/{id}/transcript` — 对话时间线持久化
- **职责**:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
- **调用时机**:前端在写作过程中**反复 PUT**(事件累计、干预完成、草稿生成等节点全量覆盖)
- **存到哪**:MySQL 的 `ai_writing_sessions.transcript` 列(LONGTEXT)
- **用途**:用户**关闭页面后再回来 / 刷新 / 看历史会话**时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
- **定位**:**纯锦上添花** — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整
#### 2. `POST /{id}/resume` — 写作流程的核心控制接口
- **职责**:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
- **调用时机**:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
- **传什么**:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
- **定位**:**流程主干** — 它失败,用户就**真的卡死**在干预点
---
## 二、根因分析
### 根因 1:`transcript` 列容量不足 + 路由层无兜底
- MySQL `TEXT` 列上限 64 KB
- 用户写作过程中累计 `progressEvents`(含每个流式 chunk)+ `completedInterventions` 后,JSON blob 经常超过 64 KB
- `session.commit()` 抛 `DataError: Data too long for column 'transcript'`
- 原路由没有 try/except,异常直接冒泡成 HTTP 500
### 根因 2:内存中的会话状态在多 worker 部署下隔离
后端用了大量模块级 dict 维护会话状态:
```python
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件
_session_model_names: dict[str, str] # 用户选的模型
_session_interventions: dict[str, list] # 累计干预历史
_session_repos: dict[str, repo] # DB 仓库引用
_session_user_ids: dict[str, str] # 用户 id
_session_article_types: dict[str, list] # 文章类型列表
```
**uvicorn / gunicorn 多 worker 是多进程共享 socket**,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:
```
T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
graph 推进任务跑在 worker A
T1: GET /stream → 落 worker B,没有 QueueA 的引用
→ 触发 _recover_session,从 sqlite 重建 QueueB
→ SSE 推 QueueB 但事件都在 QueueA 里
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态
```
**结果**:SSE 时断时续、resume 偶发 404「会话状态已丢失」。
### 根因 3:Graph 用了内存 checkpointer
原代码:
```python
# ai_writing/graph.py
checkpointer = MemorySaver()
```
进程内的 `MemorySaver` 重启即丢,**容器重启或单 worker 进程被回收**后所有进行中会话都拿不到。
### 根因 4:路径配错
`config.yaml` 里 sqlite 路径写成了 Windows 绝对路径 `D:\cursor-work\...`,Linux 容器里根本无法解析。
---
## 三、已落地的修复(已合入 dhk-1 分支)
### 修复 1:`transcript` 列改 LONGTEXT + 路由兜底
| 文件 | 改动 |
|---|---|
| `packages/harness/deerflow/persistence/types.py` | 新增 `PortableLongText` 类型(MySQL→LONGTEXT,SQLite/PG→TEXT) |
| `packages/harness/deerflow/persistence/ai_writing_sessions/model.py` | `transcript` 列改成 `PortableLongText` |
| `packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py` | Alembic 迁移:MySQL 上 `ALTER ... type_=LONGTEXT()` |
| `app/gateway/routers/ai_writing.py` | `save_transcript` 整段 try/except 包裹,**任何异常都返回 `{success: false, reason}`,永远不抛 5xx** |
**结果**:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 `console.warn`,写作流程不阻断。
### 修复 2:Checkpointer 改为持久化 SQLite
| 文件 | 改动 |
|---|---|
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `MemorySaver()` → 懒加载 `AsyncSqliteSaver` |
| 同上 | 新增 `get_ai_writing_graph()` / `get_ai_writing_checkpointer()` / `shutdown_ai_writing_graph()` |
| `offline-backend-20260512/config.yaml` | sqlite 路径改成相对路径 `.deer-flow/data/checkpoints.db` |
**关键容错**:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会**自动降级 InMemorySaver**,写作流程能跑完,但跨重启不可恢复。
### 修复 3:跨 worker / 进程重启会话恢复
| 文件 | 改动 |
|---|---|
| `app/gateway/routers/ai_writing.py` | 新增 `_recover_session(session_id, request)` |
当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 `_event_queues`、重新注入 `await_user` 事件。
**效果**:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,**resume 接口本身能跑通**(但 SSE 事件流仍隔离 — 见下文剩余风险)。
### 修复 4:多层失败兜底,保证写作流程能继续
| 改动 | 作用 |
|---|---|
| `_sse(event, data)` 加 `default=str` + notice 帧降级 | JSON 序列化失败不会让 SSE 断开 |
| 所有 DB 写入(草稿/审核/干预/status)已包 try/except | DB 异常吞掉,graph 继续推 |
| 文章类型加载失败 → 退回 `[]` | intent_parser 仍能跑 |
| `/start` 加 503 兜底 | graph 构建失败时给前端可读错误而不是 500 |
### 修复 5:定期清理 + admin 触发接口
| 文件 | 改动 |
|---|---|
| `app/gateway/ai_writing_cleanup.py` | 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread) |
| `packages/harness/deerflow/persistence/ai_writing_sessions/sql.py` | 新增 `list_older_than()` / `delete_by_ids()` |
| `app/gateway/app.py` | lifespan 启动/停止清理任务 |
| `app/gateway/routers/ai_writing.py` | 新增 `POST /api/ai-writing/admin/cleanup` 手动触发接口(admin only) |
| `config.yaml` | 新增 `ai_writing.cleanup` 段(默认每天凌晨 3 点跑,保留 7 天) |
**关键安全机制**:清理永远跳过 `_event_queues` 里仍在跑的会话(**活跃保护**),即使 admin 手动传 `retention_days: 0` 也不会秒杀正在写作的用户。
### 修复 6:优雅关闭 — In-flight Drain
| 文件 | 改动 |
|---|---|
| `app/gateway/routers/ai_writing.py` | 新增 `_inflight_graph_tasks` + `_spawn_graph_task()` + `wait_for_inflight_graph_tasks(timeout)` |
| `app/gateway/app.py` | lifespan shutdown 顺序:取消清理 → **等 in-flight 写作 task 收尾(30s 超时)** → 关 checkpointer connection |
容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。
### 修复 7:多 worker 配置陷阱检测
| 文件 | 改动 |
|---|---|
| `app/gateway/app.py` | lifespan startup 检测 `WEB_CONCURRENCY > 1` 或 `/proc` 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志 |
下次启动容器看 docker logs 第一屏就能看到强提醒。
---
## 四、当前状态评估
### transcript 500
✅ **彻底修复**,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。
### resume 报错
⚠️ **大幅缓解**:
- 接口本身(在 worker > 1 下)能跑通
- sqlite 共享让 worker B 也能恢复 worker A 创建的 session
❌ **未彻底解决**:
- SSE 事件流仍 per-worker 隔离
- 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
- 但**草稿、审核结果**都落到 MySQL,刷新 `/sessions/{id}` 总能看到最终成品
### 「写作流程能不能跑完」
✅ **能**。最坏情况下:
- 用户感受:进度断断续续,需要刷新几次
- 数据保证:草稿和审核都落 MySQL,**写出来的内容不会丢**
---
## 五、剩余风险
### 风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要
**症状**:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。
**为什么不能用 `--workers 1` 解决**:业务团队反馈其他业务功能需要多 worker。
**解决方案见第六章**。
### 风险 B:容器优雅关闭时 in-flight 写作进度
**当前缓解**:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。
**完美方案**(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。
### 风险 C:节点级别 LLM 抽风
某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。
**当前缓解**:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。
**改进方向**(不在本次修复范围):
- 给节点加 `tenacity.retry` 重试装饰器
- 节点抛错时把已生成的部分塞进 progress_events
### 风险 D:跨 worker 恢复时降级丢失
worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:
| 数据 | 默认值 | 影响 |
|---|---|---|
| `_session_model_names` | `""` | 退回默认模型 |
| `_session_interventions` | `[]` | 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全) |
| `_session_article_types` | `[]` | 没影响(graph 早过了 intent_parser) |
**改进方向**:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。
---
## 六、Worker > 1 限制下的解决方案路径
### 三条路径对比
| 方案 | 是否引入新依赖 | 代码改动 | 运维改动 | 适用场景 |
|---|---|---|---|---|
| **B. Redis Pub/Sub 共享状态** | ⚠️ 需 Redis | ~150 行 | docker-compose 加 redis | 已有 Redis 或愿意上 |
| **C. AI 写作独立单 worker 进程** | 无 | ~30 行 | docker-compose 加一个容器 / 进程 + nginx 转发 | 不想上 Redis |
| **F. 多容器单 worker + nginx sticky** | 无 | ~5 行(nginx config) | 把 1 个多 worker 容器拆成 N 个单 worker 容器 | 已经在做容器编排 |
### 方案 B:Redis Pub/Sub(最彻底)
**改造点**:
```
之前(per-worker memory dict) 之后(Redis 共享)
───────────────────────── ─────────────────────
_event_queues[sid] = asyncio.Queue() → Redis Pub/Sub channel "ai_writing:events:{sid}"
_session_pending_pause → Redis Hash "ai_writing:pause"
_session_model_names → Redis Hash "ai_writing:meta:model"
_session_interventions → Redis Hash + JSON
_session_repos / _session_user_ids → Redis Hash
```
**关键设计**:
1. `/start` 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
2. `/stream` 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
3. `/resume` 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」
**优点**:根治。任何 worker 数、任何部署形态都能跑。
**缺点**:
- 引入 Redis 依赖(运维负担)
- 代码改动较大(约 150 行)
- 需要处理 Redis 断连 / 分布式锁释放等边缘情况
### 方案 C:AI 写作独立服务(次推荐)
**做法**:在同一个容器里多起一个 uvicorn 进程,专门处理 `/api/ai-writing/*`,**显式 `--workers 1`**。
```bash
# 主 gateway(多 worker,处理其他业务)
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4
# AI 写作专用单 worker
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1
```
nginx 配置:
```nginx
location /api/ai-writing/ {
proxy_pass http://localhost:8002;
proxy_buffering off;
proxy_read_timeout 3600s;
}
location / {
proxy_pass http://localhost:8001;
}
```
**优点**:
- 零代码改动
- 不引入新依赖
- AI 写作彻底单 worker,问题根除
**缺点**:
- 容器内多一个进程
- 内存占用 +1 份 Python runtime(约 200 MB)
### 方案 F:多容器单 worker + nginx sticky
**做法**:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 `ip_hash` 做 sticky。
```yaml
# docker-compose.yml
services:
gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }
nginx:
# upstream 配 ip_hash,让同一个客户端粘到同一个容器
```
```nginx
upstream gateway_pool {
ip_hash;
server gateway-1:8001;
server gateway-2:8001;
server gateway-3:8001;
server gateway-4:8001;
}
```
**优点**:零代码改动,标准做法
**缺点**:
- 改动 docker-compose
- 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)
### 推荐顺序
1. **方案 C(AI 写作独立单 worker 进程)** — 改动最小、风险最低、立刻可上
2. **方案 F(多容器 sticky)** — 如果你们已经在做多容器编排
3. **方案 B(Redis)** — 长期方案,等真正需要水平扩容时再上
---
## 六-A、方案 C 在 Docker 容器内的详细实施指南
> **目标**:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
>
> **基础设施**:本项目镜像基于 `python:3.12-slim-bookworm`,用 `uv` 管理依赖,Dockerfile 位于 `offline-backend-20260512/backend/Dockerfile`,默认入口是
>
> ```
> CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
> ```
### 总览:单容器双 uvicorn 进程
```
┌─────────────── nginx (容器外或前置) ───────────────┐
│ │
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
└────────────────────────────────────────────────────┘
▲
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
```
两进程都用 **同一个 `app.gateway.app:app`**,区别只在:
- `8002` 单 worker,专门接 `/api/ai-writing/*`
- `8001` 多 worker,接其他所有路由
- 容器内用 `supervisord` 同时托管这两个 uvicorn
### 步骤 1:在镜像里加 supervisord
编辑 `offline-backend-20260512/backend/Dockerfile`,给 **runtime stage**(第 67 行 `FROM python:3.12-slim-bookworm` 之后)补一段:
```dockerfile
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
RUN apt-get update \
&& apt-get install -y --no-install-recommends supervisor \
&& rm -rf /var/lib/apt/lists/*
```
如果内网无法访问 debian 源,可用现有的 `APT_MIRROR` build-arg:
```bash
docker build \
--build-arg APT_MIRROR=mirrors.aliyun.com \
-f offline-backend-20260512/backend/Dockerfile \
-t deerflow-gateway:dual .
```
### 步骤 2:写 supervisord 配置
新建 `offline-backend-20260512/backend/deploy/supervisord.conf`:
```ini
[supervisord]
nodaemon=true ; 容器主进程,必须前台
user=root
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
[program:gateway-main]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8001
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
[program:gateway-aiwriting]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8002
--workers 1
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
**几个关键点**:
- `nodaemon=true` 让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程
- `stopasgroup=true` + `killasgroup=true` 确保 uvicorn 多 worker 模式下所有子进程一起退
- `stopwaitsecs=35` 必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL
- `%(ENV_GATEWAY_MAIN_WORKERS)s` 让主进程 worker 数走环境变量,下面会传
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage `COPY --from=builder /app/backend ./backend` 之后加):
```dockerfile
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
```
### 步骤 3:把 Dockerfile 入口换成 supervisord
把 Dockerfile **runtime stage 最后一行** 改成:
```dockerfile
# 之前
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
# 之后
EXPOSE 8001 8002 2024
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
```
> **`dev` stage 不用改**:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
### 步骤 4:暴露端口 / 挂卷 / 环境变量
启动容器时:
```bash
docker run -d --name deerflow-gateway \
-p 8001:8001 \
-p 8002:8002 \
-e GATEWAY_MAIN_WORKERS=4 \
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
-v /your/host/path/.deer-flow:/app/.deer-flow \
-v /your/host/path/config.yaml:/app/config.yaml:ro \
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
deerflow-gateway:dual
```
**两个进程必须共享 `.deer-flow` 卷** —— 因为 `checkpoints.db` 在里面(`offline-backend-20260512/config.yaml` 里配的是相对路径 `.deer-flow/data/checkpoints.db`)。两个进程跑在同一个容器内,天然共享文件系统,**这一步不用做任何额外配置**,只要确认数据卷挂出去就行。
> ⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
### 步骤 5:nginx 路由
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
```nginx
upstream gateway_main { server gateway-host:8001; }
upstream gateway_aiwriting { server gateway-host:8002; }
server {
listen 80;
server_name deerflow.example.com;
# SSE 长连接必须关 buffering
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
# AI 写作 → 单 worker 后端
location /api/ai-writing/ {
proxy_pass http://gateway_aiwriting;
proxy_read_timeout 3600s; # graph 跑得久,给足时间
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 其它一切 API → 多 worker 后端
location /api/ {
proxy_pass http://gateway_main;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端静态资源不变
location / {
proxy_pass http://frontend:5174;
}
}
```
**`proxy_buffering off` 是必须的** —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 `frontend-web` 里 AI 写作相关的 `fetch`/`EventSource` 改成 `:8002`,但更推荐 **始终走 nginx**,对前端透明。
### 步骤 6:如果你用 docker-compose
完整 compose 示例:
```yaml
services:
gateway:
image: deerflow-gateway:dual
container_name: deerflow-gateway
restart: unless-stopped
environment:
GATEWAY_MAIN_WORKERS: "4"
DEER_FLOW_CONFIG_PATH: /app/config.yaml
ports:
- "8001:8001"
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
volumes:
- ./data/deer-flow:/app/.deer-flow
- ./config.yaml:/app/config.yaml:ro
- ./extensions_config.json:/app/extensions_config.json:ro
healthcheck:
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
nginx:
image: nginx:1.27-alpine
depends_on: [gateway]
ports: ["80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
```
`stop_grace_period: 45s` 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
### 步骤 7:验证(按这个清单逐项过)
```bash
# 1) 两个进程都起来了
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
# 期望看到:
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
# 3) 8001 不再警告多 worker
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
# 4) 8002 进程能看到 ai_writing 持久化日志
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
# 5) /api/ai-writing/* 走 8002,其他走 8001
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
```
### 步骤 8:监控两个进程的健康
supervisord 自带 `unix_http_server` + `supervisorctl`,但对容器化场景更直接的是看 docker logs:
```bash
# 实时看两个进程的日志
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
# 进程级状态
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
# 期望:
# gateway-main RUNNING pid 7, uptime 0:05:12
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
```
### 资源开销(实测预估)
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|---|---|---|---|
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
**结论**:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
### 常见坑位
1. **`stop_grace_period` 没改大 → 重启时 in-flight 写作被秒杀**
表现:每次 `docker compose restart` 都有用户报告「写到一半没了」。修复见步骤 6。
2. **8002 没暴露 / nginx 没改 → 所有请求还是落 8001**
表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
3. **两路用了不同的 `config.yaml`**
表现:8001 跟 8002 行为不一致。务必让两个进程**只读同一个文件**(在 compose 里只挂一次卷)。
4. **`.deer-flow` 卷没挂 → 8002 重启后会话全丢**
表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 `-v` 必须有。
5. **AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸**
表现:`gateway-aiwriting` 状态在 RUNNING/EXITED/BACKOFF 间反复。
修复:先 `supervisorctl tail gateway-aiwriting stderr` 看真正原因(多半是 sqlite 权限或配置文件路径),再修,**别盲目调 `autorestart=false`**。
6. **想做 K8s 部署但还想用方案 C**
不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
### 回滚方案
如果上线后发现问题,回滚只需要 1 步:
```bash
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
```
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),**不会丢任何用户数据**,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
---
## 七、运维操作清单
### 部署前验证
```bash
# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
docker exec <gateway> ls -la /app/.deer-flow/data/
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)
# 2) 确认 alembic 迁移跑完了
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
# 应该看到包含 20260519_02 的 head
# 3) 确认 transcript 列已扩容
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
"SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
# Type 列应该是 longtext
# 4) 查 worker 数
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告
```
### 启动时关注的日志
```bash
docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'
```
期望看到:
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理任务起来了
警告:
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 配置警告
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 初始化失败,需立刻排查
### 用户报告「会话丢失」时的排查
```bash
# 1) 查 sqlite 里这个 session 的快照
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
\"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""
# 2) 查 MySQL 里这条会话
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
"SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"
# 3) 看 gateway 日志里这个 session 的活动轨迹
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50
# 4) 看是否有 recover_session 调用
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20
# 5) 看是否有节点异常
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20
```
### 手动触发清理(admin only)
```bash
# 用 admin 账号登录后,取 cookie,调清理接口
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
-H 'Content-Type: application/json' \
-H 'Cookie: <admin session cookie>' \
-d '{"retention_days": 7}'
```
返回:
```json
{
"deleted_db": 12,
"deleted_checkpoints": 12,
"skipped_active": 1,
"elapsed_seconds": 0.234,
"params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
}
```
`skipped_active` > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。
---
## 八、相关文件索引
### 后端
- `offline-backend-20260512/backend/app/gateway/routers/ai_writing.py` — 路由 + 内存状态字典 + drain
- `offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py` — 定期清理
- `offline-backend-20260512/backend/app/gateway/app.py` — lifespan / 多 worker 检测
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py` — Graph 定义 + 持久化 checkpointer
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/` — MySQL repo
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py` — `PortableLongText`
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py` — LONGTEXT 迁移
- `offline-backend-20260512/config.yaml` — `checkpointer:` + `ai_writing.cleanup:` 配置
### 前端
- `frontend-web/src/open-canvas/pages/AIWritingPage.tsx` — 页面顶部有快速排查注释
- `frontend-web/src/open-canvas/api/ai-writing-sessions.ts` — transcript 保存(已做 fire-and-forget 容错)
- `frontend-web/src/open-canvas/api/ai-writing.ts` — resume / stream
### 测试
- `tests/test_ai_writing_cleanup.py` — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
- `tests/test_ai_writing_session_recovery.py` — 跨 worker 会话恢复(8 个测试)
- `tests/test_ai_writing_session_repo.py` — Repo(7 个测试)
- `tests/test_ai_writing_transcript_endpoint.py` — transcript 兜底(5 个测试)
- `tests/test_portable_long_text.py` — LONGTEXT 列类型(5 个测试)
总测试:**51 个,全过**。
---
## 九、变更历史
| 日期 | Commit | 内容 |
|---|---|---|
| 2026-05-19 | c3d0c82 | 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复) |
| 2026-05-20 | (本次) | checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测 |
---
## 十、决策摘要(给未来的你/同事)
> **如果你现在第一次接手这个问题**,按这个清单往下走:
>
> 1. ✅ **transcript 500 不用管** — 已彻底修复
> 2. ⚠️ **resume 报错的根因是多 worker** — sqlite 持久化已减轻,但 SSE 事件流仍隔离
> 3. ⚠️ **写作能跑完,但用户体验断断续续** — 草稿数据安全,刷新就能看到
> 4. 🎯 **要彻底解决,从方案 C / F / B 选一个**(建议从 C 开始)
> 5. 🚨 **务必确认 `.deer-flow` 数据卷挂出来了** — 否则 sqlite 持久化白做
> 6. 📊 **关注的日志关键字**:`ai_writing:`、`checkpointer`、`AsyncSqliteSaver`、`WEB_CONCURRENCY`
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
-140
View File
@@ -1,140 +0,0 @@
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
## 一、需求背景
当前 `frontend-web/src/App.tsx` 同时承载了:
1. 登录/首页相关路由(如 `/login`、`/landing`)
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
---
## 二、目标(你提出的核心诉求)
你希望:
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
---
## 三、重构范围
### 1)保留在 `App.tsx` 的内容
- 全局 Provider:`HashRouter`、`AppProviders`
- 登录/首页路由:
- `/`
- `/login`
- `/login/:username`
- `/landing`
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
### 2)迁移出 `App.tsx` 的内容
将以下与工作台有关的路由从 `App.tsx` 抽离:
- `/workspace`(重定向)
- `/workspace/chats`
- `/workspace/chats/:thread_id`
- `/workspace/agents`
- `/workspace/agents/new`
- `/workspace/agents/:agent_id/chats`(重定向)
- `/workspace/agents/:agent_id/chats/:thread_id`
- `/workspace/scheduled`
- `/workspace/scheduled/tasks`
- `/workspace/scheduled/runs/:run_id`
以及配套的包装组件职责:
- `WorkspaceLayout`
- `ChatRuntime`
---
## 四、建议的新结构(方案)
## 方案 A(推荐):新增“业务路由容器组件”
在 `pages` 下新增一个路由容器组件,例如:
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
职责:
- 专门维护 `/workspace/*` 相关路由
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
- 输出一个可被 `App.tsx` 挂载的路由节点
同时在 `App.tsx` 里只保留:
- 登录/首页/404
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
---
## 五、“再建立一个页面放置导航”的落地解释
你说的“建立一个页面放置这些导航”,可以有两种理解:
1. **路由容器页(推荐)**:
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
- 即上面的 `WorkspaceRoutes.tsx`。
2. **可见的导航页(可选)**:
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
---
## 六、实施步骤(开发任务拆解)
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
5. 校验以下访问路径行为一致:
- `/workspace` 重定向
- chats/agents/scheduled 各页面可正常进入
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
- 404 不受影响
6. 运行前端并做手工回归。
---
## 七、验收标准
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
2. `/workspace/*` 全部由新建组件统一管理。
3. 功能行为与改造前一致(或符合新定义)。
4. 代码可读性提升:登录入口与业务入口职责分离。
---
## 八、注意事项
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
---
## 九、建议的命名(可直接采用)
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
---
## 十、后续可选优化
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
+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 的实际执行 |
@@ -0,0 +1,437 @@
# 多智能体圆桌研讨 · 后端开发文档
本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`、`multi-agent-step1-api.md`、`multi-agent-step3-api.md`;推荐弹窗见 `recommend-agents-progress.md`;前端实现见 `multi-agent-frontend-dev.md`。本文关注**实现细节、协议帧、设计取舍**。
源码内另有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py` 及 `clarification_utils.py`),与本文互为补充。
---
## 1. 功能定位与三步数据流
对应前端页面:`/page/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。
```
Step 1 任务意图理解
POST /api/intent/init → 建 thread,绑定内置 roundtable-intent
POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary
↓ intentReady { objective, constraints, assumptions }
推荐弹窗(Step 1 → Step 2 之间,可选)
POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks
↓ selectedAgents[]
Step 2 多智能体圆桌研讨
POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(席位列表由前端传入)
POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清
↓ lastLeaderContent、各席位交付、hasConsensus
Step 3 最终方案展示
GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件
GET /api/threads/{tid}/artifacts/{path} → 预览或下载
(总控结论、席位文本主要来自 Step 2 前端内存,一般不再调 run/stream)
```
| 步骤 | 前端 `currentStep` | 专属 Router | 内置 Agent |
|------|-------------------|-------------|------------|
| 1 | 1 | `intent.py` | `roundtable-intent`(固定 id,复用) |
| 1→2 | 推荐弹窗 | `recommend.py` | `roundtable-recommender`(每请求新建 thread) |
| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 |
| 3 | 3 | `artifacts.py`(通用) | 无新 agent |
所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:单次请求卡住 40s+)。
---
## 2. 涉及的后端文件
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── app.py # include_router: multi_agent, intent, recommend
│ └── routers/
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream
│ ├── intent.py # Step 1:init + stream
│ ├── recommend.py # Step 1→2:推荐 stream
│ └── artifacts.py # Step 3:列出/下载 thread 产出
└── .deer-flow/agents/
├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill)
├── roundtable-recommender/ # 推荐智能体
├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换)
├── roundtable-environment/
├── roundtable-solution-design/
├── roundtable-risk-review/
├── roundtable-execution-plan/
└── roundtable-summary/
```
**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。
**共享工具逻辑**:`packages/harness/deerflow/tools/builtins/clarification_utils.py` 中的 `resolve_allow_multiple()` 被 `intent.py`、`multi_agent.py` 与 `ClarificationMiddleware` 共用,决定选项卡片默认单选还是多选。
---
## 3. 接口总览
| 方法 | 路径 | 步骤 | 用途 |
|------|------|------|------|
| POST | `/api/intent/init` | 1 | 创建意图澄清 thread |
| POST | `/api/intent/stream` | 1 | SSE,一轮用户对话 |
| POST | `/api/recommend/stream` | 1→2 | SSE,一次性推荐席位 |
| POST | `/api/multi-agent/init` | 2 | SSE,并行拉起席位 + 协调智能体 |
| POST | `/api/multi-agent/run/stream` | 2 | SSE,leader 或 special 一轮 |
| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 |
| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出 |
字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。
---
## 4. Step 1:任务理解(`intent.py`)
### 4.1 定位
轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。
### 4.2 `POST /api/intent/init`
1. `_create_thread()` → LangGraph thread
2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent)
与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。
### 4.3 `POST /api/intent/stream`
**上游**:`_run_payload(agent_name=roundtable-intent, excluded_tools=[...])`,硬排除 `agent_orchestration`、`write_file`、`web_search` 等,**仅保留** `ask_clarification` 等澄清能力,防止模型写方案文档。
**流式**:透传 LangGraph SSE → 解析 `_parse_last_messages(captured)`。
**终态帧判定顺序(重要)**:
1. **先**扫描全部 AI 消息中的 `[INTENT_READY]\n\`\`\`json {...}\`\`\``(正则 `_INTENT_READY_PATTERN`)
- 命中 → `status: "done"`,`summary` 为解析后的 JSON,`content` 为含标记的整段文本
- **优先于** `ask_clarification`,避免「已汇总仍被当成待澄清」
2. 否则查最后一则带 `tool_calls` 的 AIMessage 是否含 `ask_clarification`
- 命中 → `status: "clarification"`,附带结构化字段供前端选项卡片:
```json
{
"status": "clarification",
"content": "<引导语/前言>",
"question": "...",
"clarification_type": "missing_info",
"clarification_context": "...",
"options": ["..."],
"allow_custom": true,
"allow_multiple": false,
"summary": null
}
```
`allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定:**默认单选**(点击即发送);仅模型显式传 `allow_multiple: true` 时为多选。
3. 否则 → `status: "asking"`(继续对话,无结构化澄清)
**`summary` 形状**(`done` 时):
```json
{
"objective": "一句话目标",
"constraints": ["硬约束 1", "..."],
"assumptions": ["可选假设"]
}
```
### 4.4 内置 Agent:`roundtable-intent`
- 配置:`.deer-flow/agents/roundtable-intent/config.yaml` + `SOUL.md`
- SOUL 内写清:工作流、`ask_clarification` 用法、`[INTENT_READY]` 格式、禁止派活/写文件
- **可不配置 `skills`**;若配置 Skill,SOUL 要求不得偏离澄清与结束协议
---
## 5. 推荐智能体(`recommend.py`,Step 1 → 2)
### 5.1 定位
无状态单次调用:每次请求内部 `_create_thread()`,不暴露 `/init`。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(排除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。
### 5.2 `POST /api/recommend/stream`
**入参校验**:
- `intent.objective` 必填
- `candidates` 非空,且至少一个有效 `agent_id`
**Prompt 拼装**(`_build_prompt`):把 objective/constraints/assumptions 与候选池格式化为一条 user message。
**上游**:`roundtable-recommender`,`excluded_tools` 含 `ask_clarification`、`agent_orchestration` 等(纯判断,不调工具)。
**终态**:
- 解析 `[RECOMMEND_READY]\n\`\`\`json {"picks":[{"agent_id","reason"},...]}\`\`\``
- `_validate_picks()`:只保留候选池内的 id、去重、数量 clamp 到 2–6
- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值
- 否则 → `status: "asking"`(前端回退默认席位列表)
```json
{
"status": "done",
"content": "<自然语言推荐理由>",
"picks": [{"agent_id": "roundtable-risk-review", "reason": "..."}]
}
```
**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤,避免污染推荐理由区。
---
## 6. Step 2:多智能体研讨(`multi_agent.py`)
### 6.1 `/init` 内部流程(SSE,非一次性 JSON)
当前实现为 **流式 init**,便于前端按席位点亮 UI:
```
asyncio.as_completed(各席位 _prepare_sub_agent)
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
协调智能体:
POST /api/threads → coord_thread
POST /api/agents(skills=[agent_orchestration],SOUL 动态拼入可调度列表)
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
失败 → data: {"event":"error","detail":"..."}
```
**席位列表**:由前端 `agent_names` 传入(推荐弹窗确认后的 2–6 个 id),不再写死 6 个。
**协调智能体 SOUL**(动态拼接)包含:只负责协调汇总、通过 `agent_orchestration` 派活、`agent_name` 必须用英文 id(不要用中文显示名)。
### 6.2 `/run/stream` — `agent_type = "leader"`
```
1. (可选)pre_broadcast:总控收到用户消息 → 广播到非自己的 thread
2. POST /api/threads/{coordinator}/runs/stream
- excluded_tools: ["web_search"]
3. 透传 LangGraph 事件
4. 解析 messages → tool_calls
分支 A — ask_clarification:
status: "clarification"
+ question / options / allow_multiple / clarification_type / clarification_context
(空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环)
分支 B — 有 agent_orchestration 派活:
并发 _broadcast 派活消息到相关 thread
status: [["sub_agent_id", "task_text"], ...]
分支 C — 无 tool_calls:
status: [] // 共识达成,content 为总控最终文本
```
### 6.3 `/run/stream` — `agent_type = "special"`
```
1. POST /api/threads/{sub}/runs/stream
- excluded_tools: web_search, ask_clarification, present_files, view_image, agent_orchestration
2. 透传 + 解析最后 AI 文本
3. post_broadcast:子智能体完成交付 → 广播到非自己的 thread
4. status: "<完成提示字符串>" 或业务自定义;content 为交付正文;agent_name 为子 agent id
```
### 6.4 `_broadcast` 机制
```python
async def _broadcast(client, base, headers, message, agent_threads, skip):
targets = [tid for name, tid in agent_threads.items() if name not in skip]
# 对每个 target: GET state → append human message → POST state
```
目的:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文。`skip` 通常含发起方与接收方,避免回声。
### 6.5 终态帧协议(leader / special / clarification)
前端 `streamMultiAgent` 用 `isFinalStatusFrame()` 识别:**同时存在 `status` 与 `content`**。
| `status` 类型 | 含义 |
|---------------|------|
| `[["agent","task"],...]` | 派活列表;`[]` = 共识 |
| `"clarification"` | 总控追问,需用户回复后再 leader |
| `"子智能体 X 完成..."` | special 完成(字符串) |
| `"error"` | 异常 |
与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。
---
## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API)
Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力。
### 7.1 `GET /api/threads/{thread_id}/artifacts`
- 鉴权:`threads:read`,owner 校验
- 扫描:`users/{user_id}/threads/{thread_id}/user-data/outputs/`(`collect_outputs_listing`)
- 目录不存在 → `files: []`(非 404)
```json
{
"thread_id": "...",
"files": [
{
"path": "mnt/user-data/outputs/report.md",
"name": "report.md",
"size_bytes": 12480,
"mime_type": "text/markdown",
"modified_at": "2026-05-23T14:32:30.123Z"
}
]
}
```
前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 可对协调 thread 与各席位 thread 分别拉取,合并为下载清单。
### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}`
- 虚拟路径前缀 `mnt/user-data/outputs/...`
- 路径穿越防护;`download=true` 强制附件下载
- HTML/XHTML/SVG 等 active content 始终按下载处理
**总控结论、各席位最终段落**:主要来自 Step 2 已缓存的 `lastLeaderContent`、`step2RoundtableDialogues`;无需为 Step 3 新增聚合接口(可选优化见 `multi-agent-step3-api.md` 中的 `sessions/summary` 草案)。
---
## 8. 内置席位 SOUL 设计要点
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
| 席位 | agent_id | emoji | 核心职责 |
|------|----------|-------|----------|
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险 |
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 承载力 |
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源 |
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 |
子智能体**不**安装 `agent_orchestration`;仅协调智能体安装,保证单调度源。
---
## 9. 关键设计决策
### 9.1 HTTP loopback
三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。
### 9.2 协调智能体每次新建
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录,需后续 GC 策略。
### 9.3 澄清协议统一
Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification` 帧驱动选项卡片;`[INTENT_READY]` / 派活列表 / `[]` 各自表示阶段结束语义,互不混用。
### 9.4 选项单选默认
`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。
### 9.5 SSE 末尾状态帧
LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端与 LangGraph 帧区分解析。
---
## 10. 性能特征
| 阶段 | 已做优化 | 典型耗时 |
|------|----------|----------|
| Step 2 `/init` | 席位 `as_completed` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) |
| LLM `/run/stream` | — | 数百 ms~数十秒(主因) |
| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 |
仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用(见原 B/D 优化项)。
---
## 11. 依赖与扩展点
### 11.1 `agent_orchestration` 技能
协调智能体派活依赖此技能;正则 `_TOOL_CALL_PATTERN` 从 tool 输出提取 `agent_name` 与 task。技能缺失时 leader 易变成「无 tool_calls → 空 status → 前端误判共识」。
### 11.2 模型
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型。
### 11.3 新增席位
1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md`
2. 前端候选池 / 推荐逻辑纳入该 id
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
### 11.4 新增 Step 1 工具
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 白名单逻辑(当前为黑名单,仅 `ask_clarification` 实际可用)。
---
## 12. 故障排查
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY |
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple` |
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 | Network + 后端 `multi_agent` 日志 |
| leader 立即 `status: []` | 未调用 orchestration 技能 | 换模型或加强 SOUL |
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧 |
| Step 3 无文件 | 子 agent 未写 outputs | `GET .../artifacts` 对子 thread_id 逐个查 |
### 日志关键字
```bash
grep "intent stream" .runtime/logs/*.log
grep "multi_agent\|multi-agent" .runtime/logs/*.log
grep "recommend stream" .runtime/logs/*.log
grep "dispatch failed" .runtime/logs/*.log
```
---
## 13. 测试建议
```
backend/tests/test_intent.py # init、INTENT_READY、clarification 优先级
backend/tests/test_recommend.py # picks 校验、候选池过滤
backend/tests/test_multi_agent.py # init SSE 事件、leader/special 终态、clarification
```
可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway。
---
## 14. 路径速查
| 资源 | 路径 |
|------|------|
| Step 1 路由 | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` |
| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| Step 3 列出/下载 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 澄清单选/多选 | `packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 任务理解 Agent | `.deer-flow/agents/roundtable-intent/` |
| 推荐 Agent | `.deer-flow/agents/roundtable-recommender/` |
| 圆桌席位 | `.deer-flow/agents/roundtable-*/` |
| 前端 Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 前端推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 前端 Step 2 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
| Step 1 API 说明 | `frontend-web/docs/multi-agent-step1-api.md` |
| Step 3 API 说明 | `frontend-web/docs/multi-agent-step3-api.md` |
@@ -0,0 +1,659 @@
# 多智能体圆桌研讨 · 前端开发文档
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
---
## 0. 速读:你需要先知道的事
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
---
## 1. 功能定位
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
**核心能力**:
| 步骤 | 用户目标 | 真实后端对接 |
|------|----------|--------------|
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
**步骤门禁**:
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
---
## 2. 目录结构
```
frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/
│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行)
├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
├── hooks/
│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
├── lib/
│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
├── utils/
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
```
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
```tsx
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
```
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
**环境变量**(由 `scripts/start-frontend.sh` 注入):
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
---
## 3. 三步数据流(前端视角)
```
[进入 Step 1]
useEffect → runStep1Init()
initIntent(model) → { agent_id, thread_id }
setIntentThreadId、setTimelineEntries
interactiveMessages seed 欢迎语(本地 seed,非 API)
[用户发送 / 选项卡片提交]
sendIntentMessage(text)
追加 user 气泡 + agent 占位(streaming + phase)
streamIntent({ onTextDelta, onPhaseChange, signal })
终态:
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
asking → 继续对话
error → toast + 红色 (错误) 文案
[点击「进入多智能体研讨」且 intentReady]
setIsRecommendDialogOpen(true)
RecommendAgentsDialog
listAgents() → filterRecommendCandidates()
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
用户勾选 2–8 个 → onConfirm(agentIds)
handleConfirmAgents
setSelectedAgents(agentIds.map(...))
step2InitRef.current = false // 复位 Step 2 init 闸门
setCurrentStep(2)
[进入 Step 2]
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
onSeatReady:右侧席位面板逐个「在线」
onCoordinatorReady / init_done → threadIds + coordinatorName
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
while !cancelled && cycles<8:
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
dispatched.length === 0 → hasConsensus = true,可进 Step 3
[进入 Step 3]
HighFidelityReport
consensusText ← lastLeaderContent
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
```
---
## 4. API 层
### 4.1 `api/intent.ts` — Step 1
| 函数 | 说明 |
|------|------|
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
**`StreamIntentRequest` 重要字段**:
- `threadId`:必填,跨多轮对话保持
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
- `onTextDelta(text, messageId)`:每个 AI 文本增量
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
**`StreamIntentResult`**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 |
**SSE 解析要点**:
- `event: metadata` → phase `connected`
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`)
**Abort 行为**:
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
### 4.2 `api/recommend.ts` — 推荐弹窗
| 函数 | 说明 |
|------|------|
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
- `roundtable-intent`
- `roundtable-recommender`
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
**终态语义**:
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
| 函数 | 说明 |
|------|------|
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
| `streamMultiAgent(req)` | leader / special 一轮 |
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
```ts
{ event: "seat_ready", agent_name, thread_id, display_name, description }
{ event: "coordinator_ready", main_agent_name, thread_id }
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
{ event: "error", detail }
```
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
| `status` | 含义 | 前端处理 |
|----------|------|----------|
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
| `"error"` | 失败 | toast + 红色气泡 |
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
**调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
```
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
---
## 5. 主页面状态(`RoundtablePlanningPage`)
### 5.1 步骤与门禁
- `currentStep`: `1 | 2 | 3`
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
### 5.2 Step 1 关键 state
| 状态 | 用途 |
|------|------|
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
| `intentAborterRef` | 当前 stream 的 AbortController |
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
| `step1InitRef` | 闸门:true 即认为已 init 过 |
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
**`step1InitGenRef` 的存在原因**:
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
### 5.3 推荐与 Step 2 席位
| 状态 | 用途 |
|------|------|
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
伪代码:
```
isOrchestrating = true
orchestrationCancelRef = false
nextLeaderTask = seedMessage
cycles = 0
MAX_CYCLES = 8
while !cancelled && cycles < MAX_CYCLES:
cycles += 1
highlight(speakerId=2) // 总控
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
if res.clarification:
finalize(leaderBubble, content, "等待用户澄清")
setPendingClarification(res.clarification)
break // 等用户回复后由 submitClarification 重启循环
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
if res.content: setLastLeaderContent(res.content)
if res.dispatched.length === 0:
setHasConsensus(true); setConsensusPercentage(100); break
for [subName, task] of res.dispatched: // **顺序**,不是并行
meta = agentNameToRole[subName.toLowerCase()]
markSpeakerStreaming(meta.speakerId)
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
finalize(subBubble, specRes.content, "子智能体交付")
markSpeakerIdle(meta.speakerId)
if cancelled: break
setConsensusPercentage(prev => min(95, prev + 8))
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
if cycles >= MAX_CYCLES:
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
clearAllStreamingSpeakers()
isOrchestrating = false
```
要点:
- **`buildInitialIntent()`**:
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
- **挂起 / 恢复**:
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
- 抽屉控件:
- **System Prompt**:仅前端记录(后端覆写未开放)
- **Temp**:仅前端记录
- **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL`
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
- 把新的 `(seatId, model)` 写入 `agentConfigs`
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数
### 5.6 草稿持久化(`utils/drafts.ts`)
- Key:`roundtable-planning:drafts:v1`(`localStorage`)
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
- **加载草稿**:`handleLoadDraft`
- 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
- `step1InitGenRef` 自增,淘汰可能晚归的旧 init
- `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
- 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
---
## 6. 组件说明
### 6.1 `IntentClarificationCard`
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
- 仅选项 → `我选择:A、B`
- 仅自定义 → 直接发自定义文本
- 同时存在 → `我选择:A、B;补充:…`
- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片)
### 6.2 `RecommendAgentsDialog`
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
- 阶段 UI(`InternalState.phase`):
- `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
### 6.3 `HighFidelityReport`(Step 3)
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
- **文件预览策略**(`shouldPreviewInModal`):
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
- 命中规则:
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
### 6.4 `ArtifactPreviewModal`
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
- 其它文本类 → `<pre>` 直出
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
### 6.5 共享 UI 模式与辅助
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
- `connecting` → 「调度中」
- `connected` → 「已连接,等待响应」
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
- `tool_result` → 「处理工具结果」
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
---
## 7. 澄清交互(与主聊天对齐)
| 能力 | 实现位置 |
|------|----------|
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
**关键差异**:
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
---
## 8. 与主聊天的差异
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|------|-----------------------------------|----------|
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
---
## 9. 关键交互清单
| 交互 | 行为 |
|------|------|
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
---
## 10. 类型与常量速查
```ts
// Step 1 输出 → Step 2 输入
interface IntentSummary {
objective: string;
constraints: string[];
assumptions?: string[];
}
// Step 2 线程映射(init_done.thread_ids)
type ThreadIdMap = Record<string, string>;
// 派活
type LeaderDispatch = [string, string]; // [agent_name, task]
// 当前选中的研讨席位
type SelectedAgent = { agent_id: string; name: string };
// Step 1 单条澄清问题
interface ClarificationPayload {
question: string;
clarificationType: string;
context: string;
options: unknown[];
allowCustom: boolean;
allowMultiple?: boolean;
}
// Step 2 stream 终态
interface StreamMultiAgentResult {
dispatched: LeaderDispatch[];
message: string | null;
content: string;
agentName: string | null;
clarification: ClarificationPayload | null;
}
```
**核心常量**:
| 常量 | 值 | 位置 |
|------|----|------|
| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage |
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
---
## 11. 故障排查
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
**本地调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
```
---
## 12. 扩展指南
### 12.1 新增默认研讨席位
1. 后端 `.deer-flow/agents/roundtable-xxx/`
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
3. 无需改 API 层(`agent_names` 由推荐结果传入)
### 12.2 调整 Step 1 澄清轮次上限
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
### 12.3 调整 Step 2 编排上限
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
### 12.4 把 special 改回并行
不推荐;若一定要做:
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
### 12.5 接入真实 Persona 覆盖
当前 prompt / temp 只在前端记录;要落地需:
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面
当前刻意单文件维护三步。若拆分建议:
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
- 保持 `api/*` 不变
- 保持 `roundtable-planning.css` 主题变量
### 12.7 文档协同
`@/roundtable-planning/` 内一切公共 API 都应当:
1. 在 `api/*.ts` 文件级注释里说明 endpoint
2. 在本页第 4 节 / 第 10 节同步对照
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
---
## 13. 路径速查
| 资源 | 路径 |
|------|------|
| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-312
View File
@@ -1,312 +0,0 @@
# Open Canvas 集成进度文档
> 最后更新:2026-05-16
> 当前分支:dhk-1
---
## 一、整体进度概览
### Canvas 系统(双栏写作/编码工作区)
| 阶段 | 名称 | 状态 |
|------|------|------|
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
| 阶段七 | 版本历史导航 | ✅ 已完成 |
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
| 步骤 | 名称 | 状态 |
|------|------|------|
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
**当前状态:全部功能已完成 🎉**
---
## 二、已完成工作详情
### 2.1 前端核心文件结构
```
frontend-web/src/open-canvas/
├── api/
│ ├── client.ts # LangGraph SDK 客户端实例
│ ├── models.ts # 模型列表 API
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
│ └── threads.ts # createCanvasThread、getCanvasThread
├── components/
│ ├── artifact/
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
│ ├── chat/
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
│ ├── layout/
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
│ └── selectors/
│ └── CanvasModelSelector.tsx # 模型下拉选择器
├── contexts/
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
│ ├── CanvasThreadContext.tsx # threadId、modelName
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
├── editor/
│ ├── TiptapEditor.tsx # EditorContent 包装器
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
├── hooks/
│ ├── useCanvasArtifact.ts # artifact 初始化工具
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
│ ├── useCanvasSelection.ts # 文字/代码选区状态
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
│ ├── useCanvasThread.ts # 线程加载工具
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
├── pages/
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
│ └── CanvasThreadPage.tsx # Provider 装配层
├── routes/
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
├── types/
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
│ ├── model.ts # CanvasModel
│ ├── quick-action.ts # QuickAction、QuickActionId
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
│ └── thread.ts # CanvasMessage、CanvasThreadValues
└── utils/
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
├── selection.ts # buildMessageWithSelection
├── stream.ts # 流式工具
└── thread.ts # 线程工具
```
### 2.2 后端核心文件结构
```
offline-backend-20260512/backend/
├── packages/harness/deerflow/agents/
│ ├── canvas_agent/
│ │ ├── __init__.py
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
└── app/gateway/
├── app.py # 注册 writing router
└── routers/
└── writing.py # POST /api/writing/rewrite(SSE 流式)
```
**langgraph.json** 注册了两个 graph:
- `lead_agent` → 主聊天 Agent
- `canvas_agent` → Canvas 写作 Agent
### 2.3 关键 Context 层级
```
CanvasThreadPage
├── CanvasThreadProvider # threadId、modelName
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
│ │ └── CanvasShell
│ │ └── CanvasResizableLayout
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
│ │ └── CanvasArtifactPanel
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
```
---
## 三、关键架构决策与注意事项
### 3.1 流式架构:单例流(重要)
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
```
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
```
### 3.2 artifact 内容注入机制(重要)
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
```typescript
// utils/quick-actions.ts
export function buildQuickActionMessage(id, content) {
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
}
// core/messages/utils.ts
export function stripArtifactTag(content: string): string {
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
}
```
### 3.3 ThinkTagParser(AI 改写思考过程解析)
后端支持两种思考格式:
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
### 3.4 版本历史:本地切换
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
### 3.5 Writing Editor 流程(三阶段状态机)
```
idle → menu → preview → idle
↑ ↓
openAiMenu closeAll
```
- **idle**:正常编辑
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
---
## 四、已解决的技术难题
| 问题 | 解法 |
|------|------|
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
---
## 五、当前已知限制(非 bug,设计取舍)
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
---
## 六、快速开始下一次工作
### 启动服务
```bash
# 后端(从 offline-backend-20260512/backend/)
make dev # 启动 Gateway API on port 8001
# 前端(从 frontend-web/)
pnpm dev # 启动 Vite on port 5174
```
### 访问路径
- 主页:`http://localhost:5174`
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
### 类型检查
```bash
cd frontend-web && pnpm typecheck
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
```
---
## 七、如需继续开发的潜在方向
| 方向 | 描述 | 优先级 |
|------|------|------|
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
---
## 八、关键文件速查
| 文件 | 说明 |
|------|------|
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
---
## 九、开始新对话的推荐提示词
```
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
详细进度见:frontend-web/docs/openCanvas-progress.md
当前全部功能已完成,包括:
- Canvas 双栏布局、canvas_agent、版本历史
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
- 快捷动作(通过 CanvasStreamContext 统一流)
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
```
-950
View File
@@ -1,950 +0,0 @@
# Open Canvas 模式需求拆解文档
## 1. 文档目标
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
- 前端:基于 `React 19 + Vite + react-router-dom`
- 后端:基于 `Python + FastAPI + LangGraph`
- 模型层:支持云模型与本地模型自由配置
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
---
## 2. 最终目标
### 2.1 用户视角目标
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
- 左侧是聊天区,右侧是 Canvas 编辑区
- 可以从空白文本或代码模板快速开始
- 可以通过聊天让 AI 生成一份结构化 artifact
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
- artifact 支持版本历史切换
- artifact 支持文本和代码两种主要类型
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
- 支持启用 Web 搜索增强
- 支持切换模型
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
### 2.2 工程视角目标
新增的 Canvas 模式需要满足以下工程原则:
- 不破坏现有 DeerFlow 工作区
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
- 可逐阶段上线
- 前后端状态协议清晰
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
---
## 3. 范围定义
### 3.1 本次需求包含
- 前端新增 `open-canvas` 专用目录
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
- 前端新增 Canvas 页面、上下文、组件、状态管理
- 后端新增 Canvas 专用线程状态字段
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
- 模型配置统一为 DeerFlow 后端配置驱动
- 支持本地大模型配置与模型列表动态下发
- 支持结构化 artifact 的保存、读取、更新、版本切换
### 3.2 本次需求不包含
- 一次性复刻 open-canvas 全部 UI 细节
- 完整照搬 Supabase 鉴权体系
- 直接复用 open-canvas 的 Next.js API Route
- 直接运行 open-canvas 的 LangGraph.js 后端
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
### 3.3 迁移原则
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
- 前端尽量复用其交互设计和数据结构
- 后端以 Python 实现同等能力
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
---
## 4. 总体设计思路
### 4.1 前端设计思路
在现有 `frontend-web/src` 下新增一个专用目录,例如:
```text
src/
open-canvas/
pages/
routes/
components/
contexts/
hooks/
types/
api/
utils/
constants/
```
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
建议路由形式:
- `/canvas`
- `/canvas/:threadId`
也可以挂在现有页面布局下,例如:
- `/workspace/canvas`
- `/workspace/canvas/:threadId`
推荐优先采用独立一级功能路由:
- 逻辑更清晰
- 状态隔离更容易
- 不容易污染现有 `workspace` 功能
### 4.2 后端设计思路
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
#### 方案 A:新增独立 Canvas Assistant
- 新增 `canvas_agent`
- 使用单独的 LangGraph 状态定义
- 前端调用时通过 `assistant_id=canvas_agent`
优点:
- 与现有 `lead_agent` 隔离
- 风险更低
- 更容易对齐 open-canvas 的图结构
缺点:
- 会产生一套新的 Agent 图维护成本
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
- Agent 根据模式进入 Canvas 路径
优点:
- 统一入口
缺点:
- 会显著增加现有 Agent 复杂度
- 容易影响现有聊天和文件产物模式
推荐先做方案 A。
### 4.3 模型配置设计思路
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
- `name`
- `display_name`
- `provider`
- `supports_thinking`
- `supports_reasoning_effort`
- `supports_vision`
- `supports_tool_calling`
- `supports_artifact_editing`
- `supports_code_editing`
- `temperature_range`
- `max_tokens_range`
- `base_url_source` 或 provider 标识
- `is_local`
---
## 5. 与当前项目的关键差异
### 5.1 当前 DeerFlow 现状
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
- `artifacts: string[]`
这更接近“生成文件产物”的工作模式。
前端当前 artifact 面板也主要是:
- 文件列表
- 文件详情
- 文件下载/预览
### 5.2 open-canvas 的关键能力
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
- 一个 conversation 对应一个主 artifact
- artifact 具有类型、标题、内容、版本
- 用户可以选中部分内容让 AI 改写
- 每次改写都会生成新版本
### 5.3 需要新增的核心能力
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
- 当前 artifact
- 当前版本索引
- 历史版本列表
- 选区信息
- 当前编辑模式
- 快速动作状态
- Web 搜索结果状态
---
## 6. 分阶段实施方案
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
---
## 7. 阶段一:前端路由与目录骨架
### 7.1 目标
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
### 7.2 需求
- 新增 `src/open-canvas/` 目录
- 新增 Canvas 页面路由
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
- 提供 Canvas 首页和 Canvas Thread 页
- 支持空状态展示
### 7.3 建议目录
```text
src/open-canvas/
api/
threads.ts
runs.ts
models.ts
components/
layout/
chat/
canvas/
artifacts/
quick-actions/
selectors/
contexts/
CanvasThreadContext.tsx
CanvasGraphContext.tsx
CanvasUIContext.tsx
hooks/
useCanvasThread.ts
useCanvasStream.ts
useCanvasArtifact.ts
pages/
CanvasHomePage.tsx
CanvasThreadPage.tsx
routes/
OpenCanvasRoutes.tsx
types/
artifact.ts
thread.ts
model.ts
utils/
artifact.ts
selection.ts
stream.ts
constants/
index.ts
```
### 7.4 路由建议
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
建议形式:
```text
/canvas
/canvas/:threadId
```
### 7.5 验收标准
- 能访问 Canvas 首页
- 能访问指定 thread 的 Canvas 页面
- 不影响现有 `workspace/*` 与 `strategy/*`
- 页面有独立布局骨架
---
## 8. 阶段二:前端 Canvas 布局与交互容器
### 8.1 目标
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
### 8.2 需求
- 左侧聊天区
- 右侧画布区
- 支持面板拖拽或折叠
- 初始空状态
- 支持“快速开始”:
- 新建空白文本 artifact
- 新建代码 artifact
### 8.3 组件拆分建议
- `CanvasShell`
- `CanvasSidebar`
- `CanvasChatPanel`
- `CanvasArtifactPanel`
- `CanvasEmptyState`
- `QuickStartActions`
### 8.4 快速开始能力
用户进入空线程时可以选择:
- 开始写文章
- 开始写代码
- 从模板开始
首版模板可只做最小集:
- 空白文档
- HTML 模板
- React 组件模板
- Python 脚本模板
### 8.5 验收标准
- 页面可稳定展示双栏布局
- 快速开始能创建前端本地 artifact 状态
- 代码和文本可分别进入不同渲染器
---
## 9. 阶段三:结构化 Artifact 数据模型
### 9.1 目标
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
### 9.2 核心要求
需要支持以下能力:
- 文本 artifact
- 代码 artifact
- 标题
- 当前版本索引
- 版本历史
- 当前选中版本切换
- 后续支持局部高亮编辑
### 9.3 建议数据结构
```ts
type CanvasArtifact = {
currentIndex: number;
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
};
type CanvasArtifactText = {
index: number;
type: "text";
title: string;
fullMarkdown: string;
};
type CanvasArtifactCode = {
index: number;
type: "code";
title: string;
language: string;
code: string;
};
```
### 9.4 线程状态建议新增字段
前端与后端需共同支持:
- `artifact`
- `highlighted_text`
- `highlighted_code`
- `canvas_mode`
- `web_search_enabled`
- `web_search_results`
- `quick_action_id`
### 9.5 与现有 `artifacts: string[]` 的关系
建议保留现有 `artifacts: string[]` 不动。
新增字段:
- `artifact` 用于 Canvas 主文档
- `artifacts` 继续用于文件产物
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
### 9.6 验收标准
- 前端类型定义完成
- 后端状态字段设计完成
- 确认与现有线程状态兼容
---
## 10. 阶段四:后端线程状态与 API 扩展
### 10.1 目标
让后端线程状态可以安全存储和返回 Canvas Artifact。
### 10.2 需求
- 扩展 Python `ThreadState`
- 扩展状态序列化逻辑
- 确保 `/api/threads/*` 返回结构化 `artifact`
- 确保 LangGraph 流中可带上 `artifact` 更新
### 10.3 后端改造点
建议关注以下模块:
- `packages/harness/deerflow/agents/thread_state.py`
- 线程 state serializer
- `app/gateway/routers/threads.py`
- 流事件适配层
### 10.4 注意事项
- 旧线程没有 `artifact` 字段时必须兼容
- 状态更新不能破坏现有消息结构
- 必须确保 JSON 可序列化
### 10.5 验收标准
- 新建线程可写入 `artifact`
- 读取线程时可返回 `artifact`
- 不影响现有线程读取
- 前端能从线程 state 中恢复 Canvas 内容
---
## 11. 阶段五:Canvas 专用 Agent 图
### 11.1 目标
实现与 open-canvas 类似的 AI 路由能力。
### 11.2 推荐方案
新增 `canvas_agent`,单独维护图逻辑。
### 11.3 节点拆分建议
首版建议至少拆成这些节点:
- `route_user_intent`
- `reply_general_input`
- `generate_artifact`
- `rewrite_artifact`
- `update_highlighted_text`
- `update_highlighted_code`
- `generate_followup`
二期可加:
- `rewrite_artifact_theme`
- `rewrite_code_theme`
- `change_length`
- `change_reading_level`
- `translate_artifact`
- `add_comments`
- `add_logs`
- `fix_bugs`
- `port_language`
- `web_search_enrich`
### 11.4 路由逻辑要求
系统需要能判断:
- 用户是在闲聊
- 用户要新生成 artifact
- 用户要整体重写当前 artifact
- 用户要局部修改当前 artifact
- 用户要执行某个快捷动作
### 11.5 状态输入要求
节点需要读取:
- 当前消息历史
- 当前 artifact
- 当前选区
- 当前模型配置
- Web 搜索开关
### 11.6 输出要求
节点输出必须能更新:
- `messages`
- `artifact`
- `web_search_results`
- `followup`
### 11.7 验收标准
- 能根据输入创建新 artifact
- 能根据输入整体改写 artifact
- 能保留版本历史
- 能在改写后返回自然语言 followup
---
## 12. 阶段六:前端流式同步与版本管理
### 12.1 目标
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
### 12.2 需求
- 复用现有 LangGraph stream client
- 在 Canvas 模式中解析 `artifact` 更新
- 支持流式文本更新
- 支持版本切换
- 支持断线后恢复线程状态
### 12.3 前端状态层建议
- `CanvasThreadContext`:线程基础信息
- `CanvasGraphContext`:消息、artifact、streaming 状态
- `CanvasUIContext`:面板、编辑态、选中态
### 12.4 关键能力
- 当前版本切换
- 新版本创建后自动定位到最新版本
- 手动编辑内容后保存
- 流结束后线程状态持久化
### 12.5 验收标准
- 发送消息时聊天和画布可同步更新
- 版本数会增加
- 切换历史版本稳定
- 刷新页面后线程状态能恢复
---
## 13. 阶段七:局部选区改写
### 13.1 目标
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
### 13.2 文本选区需求
- 支持在 Markdown 文本中选择一段文本
- 记录选区位置与原始文本
- 用户发出修改请求后,仅重写该选区
- 将选区结果合并为新版本 artifact
### 13.3 代码选区需求
- 支持在代码编辑器中选择一段代码
- 记录 start/end index 或行列信息
- AI 只返回替换片段
- 前端或后端负责将新片段拼接回完整代码
### 13.4 实现建议
首版建议:
- 文本:前端记录纯文本字符位置
- 代码:使用 CodeMirror selection 信息
不建议首版就做复杂块级富文本局部编辑。
### 13.5 风险
- Markdown 渲染态与源码态位置映射复杂
- 富文本块级位置难维护
- 流式更新中选区同步容易错位
### 13.6 首版折中方案
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
### 13.7 验收标准
- 文本局部改写成功
- 代码局部改写成功
- 原内容未改动部分保持不变
- 新版本正确入历史
---
## 14. 阶段八:快捷动作系统
### 14.1 目标
实现 open-canvas 风格的快捷动作体系。
### 14.2 首批推荐动作
文本类:
- 扩写
- 精简
- 改正式语气
- 改口语语气
- 改阅读难度
- 翻译
代码类:
- 加注释
- 加日志
- 修复 bug
- 优化代码
- 转换语言
### 14.3 设计方式
快捷动作不要直接写死在 UI,建议配置化:
- `id`
- `label`
- `type`
- `applicable_to`
- `prompt_template`
### 14.4 后端设计
快捷动作可通过:
- 前端传 `quick_action_id`
- 后端路由节点根据动作 id 选择 prompt
### 14.5 验收标准
- 文本动作可执行
- 代码动作可执行
- 结果进入新版本
- 不同动作提示词可扩展
---
## 15. 阶段九:模型配置统一与本地模型支持
### 15.1 目标
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
### 15.2 原则
- 后端 `config.yaml` 为真源
- 前端只展示后端暴露的模型信息
- 不维护一份脱离后端的前端常量模型表
### 15.3 本地模型支持范围
建议优先支持两类:
- `Ollama`
- OpenAI-compatible 本地服务
- LM Studio
- vLLM
- LocalAI
- one-api 风格代理
### 15.4 后端配置建议
后端模型配置应至少支持:
- `name`
- `display_name`
- `use`
- `model`
- `api_key`
- `base_url`
- `supports_vision`
- `supports_thinking`
- `supports_tool_calling`
- `is_local`
- `canvas_capabilities`
### 15.5 Canvas 模式额外要求
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
- `supports_canvas_generation`
- `supports_canvas_rewrite`
- `supports_canvas_code_edit`
- `supports_streaming_text`
- `supports_streaming_tool_calls`
### 15.6 与 open-canvas 的关系
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
### 15.7 验收标准
- 前端模型选择器可显示后端返回模型
- 可切换本地模型
- Canvas 模式能用本地模型生成和改写
---
## 16. 阶段十:Web 搜索增强
### 16.1 目标
实现类似 open-canvas 的可选 Web 搜索增强。
### 16.2 需求
- UI 提供 Web 搜索开关
- 后端在需要时走搜索节点
- 搜索结果写入线程状态
- 结果可在画布旁侧栏展示
### 16.3 首版建议
只做:
- 开关
- 搜索结果列表
- 搜索结果用于生成 artifact
暂不做复杂网页抓取编辑链路。
### 16.4 验收标准
- 打开搜索后可返回搜索结果
- 搜索结果可参与 artifact 生成
- 结果展示不阻塞主画布
---
## 17. 阶段十一:持久化、导出与文件产物联动
### 17.1 目标
让 Canvas 文档与 DeerFlow 原有文件能力协同。
### 17.2 需求
- artifact 持久化在线程 state 中
- 可手动导出为文件
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
- Canvas artifact 与 `artifacts: string[]` 可双向关联
### 17.3 推荐能力
- 导出 Markdown
- 导出代码文件
- 另存为 artifact file
- 从 artifact file 回填到 Canvas
### 17.4 验收标准
- 当前版本可导出
- 导出文件可在现有文件面板查看
- 文件型 artifact 和 Canvas artifact 能共存
---
## 18. 推荐开发顺序
建议严格按以下顺序推进:
1. 路由和目录骨架
2. Canvas 页面布局
3. 前端 artifact 数据模型
4. 后端线程状态扩展
5. 新建 Canvas Agent 图
6. 前后端流式同步
7. 整体生成与整体改写
8. 局部选区改写
9. 快捷动作
10. 本地模型支持完善
11. Web 搜索增强
12. 导出与文件联动
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
---
## 19. 里程碑定义
### M1:页面可进入
- 路由接通
- 双栏布局完成
- 本地 mock artifact 可渲染
### M2:后端状态打通
- 线程可保存结构化 artifact
- 页面刷新后能恢复
### M3:AI 生成可用
- 可新建文本/代码 artifact
- 可整体改写
- 可产生版本历史
### M4:核心体验可用
- 选区改写可用
- 快捷动作可用
- 模型切换可用
### M5:增强能力完整
- 本地模型支持完善
- Web 搜索增强
- 导出和文件联动
---
## 20. 风险与难点
### 20.1 最大难点
- 结构化 artifact 状态从无到有
- 局部选区改写的精确性
- 流式状态与版本管理的一致性
- 不破坏现有 DeerFlow 文件型 artifact 体系
### 20.2 技术风险
- 线程 state JSON 结构过大
- 大模型对局部改写指令遵循不稳定
- 本地模型 tool calling 与流式能力差异大
- 文本渲染态与源码态位置映射复杂
### 20.3 规避建议
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
- 首版只支持文本和代码两种 artifact 类型
- 首版先做整体生成和整体改写,再做局部改写
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
---
## 21. 验收总清单
### 21.1 前端
- 有独立 Canvas 路由
- 有专用目录和模块边界
- 双栏布局可用
- 文本和代码两类 artifact 可渲染
- 历史版本可切换
- 选区改写可交互
- 快捷动作可触发
- 模型可切换
### 21.2 后端
- 线程状态支持 `artifact`
- 新增 `canvas_agent` 或等价模式
- 生成、重写、局部改写可用
- 流式返回稳定
- 模型列表接口支持 Canvas 能力元数据
### 21.3 本地模型
- 可配置 Ollama
- 可配置 OpenAI-compatible 本地服务
- 前端可见并可切换
- 至少能完成文本生成和整体改写
---
## 22. 推荐首版最小可交付范围
如果要控制风险,建议首版只交付以下内容:
- 新增 `canvas/*` 路由
- 双栏布局
- 结构化 `artifact`
- `canvas_agent`
- 文本/代码 artifact 新建
- 整体生成
- 整体改写
- 历史版本切换
- 后端模型动态加载
- Ollama 或本地 OpenAI-compatible 至少一种可用
先不要把以下内容放进首版强制目标:
- 富文本块级精确编辑
- 全量快捷动作
- 复杂 Web 搜索增强
- 多 artifact 并行管理
---
## 23. 后续实施建议
建议下一步基于本文档继续输出两份落地文档:
### 23.1 前端实施清单
逐文件列出:
- 要新建哪些文件
- 哪些组件可复用
- 哪些页面要接入路由
- 哪些状态要从现有 hooks 抽离
### 23.2 后端实施清单
逐模块列出:
- 要改哪些 Python 文件
- 新增哪些状态字段
- 新增哪些 Agent 节点
- 哪些 API 需要扩展
---
## 24. 结论
这个需求很大,但完全可拆。
正确做法不是“先抄 UI”,而是按下面的主线推进:
- 先建前端 Canvas 子系统
- 再建结构化 artifact 状态
- 再建 Canvas Agent 图
- 再补局部改写和快捷动作
- 最后统一本地模型能力
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
+310
View File
@@ -0,0 +1,310 @@
# 浅色红色 UI 规范对齐 — 实施计划
> 依据:`H:\页面设计\UI设计规范\浅色红色设计规范-前端用.pdf`(共 104 页)
> 范围:**仅对齐 Light 模式(红色主题)**,**不改动图表部分**
---
## ⚠️ 重要约束(必读)
### 1. 主题切换必须保留
项目使用 `next-themes` 实现 Light/Dark 切换:
- `:root` → Light 模式(红色品牌,对应本规范)
- `.dark` → Dark 模式(蓝色品牌,**不在本次范围内**)
- 切换组件入口:`src/components/theme-provider.tsx`
**所有改动只能动 `:root` 块和 light-only 选择器,禁止动 `.dark` 块和 `dark:` 前缀样式。**
### 2. 不动图表
本次跳过 `chart-1` ~ `chart-5` 颜色变量和所有图表组件。
### 3. 同一 token 双模式都用时如何改
若变量同时被 light/dark 使用(如 `--radius`、按钮 `h-*`、`--primary-foreground`),改动后需在 Dark 模式下回归检查,确保没有破坏。
---
## 改动总目标对照
| 类别 | UI 规范 | 现状 | 差异 |
|------|--------|------|------|
| 主品牌红 | `#F5222D` | `#d92121` (`oklch(0.52 0.19 27)`) | 偏差 |
| 深色品牌 | `#CF1322` / `#A8071A` | 未使用 | 缺失 |
| 圆角-按钮 | 2px | 12px (`--radius: 0.75rem`) | 严重偏大 |
| 圆角-卡片 | 4px | 12px | 严重偏大 |
| 按钮高度(正常)| 32px | 36px (`h-9`) | 偏大 4px |
| 按钮高度(小)| 24px | 32px (`h-8`) | 偏大 8px |
| 输入框高度 | 32px | 36px (`h-9`) | 偏大 4px |
| 中文字体 | NotoSansSChinese | 系统字体 | 未引入 |
| 数字字体 | D-DIN | 系统字体 | 未引入 |
| 间距体系 | 8px 基准(4/8/12/16/24/32/40/56)| Tailwind 默认 | 基本一致 |
---
## 分步实施计划
### Step 1 — 颜色变量对齐(Light 模式)
**文件**:`src/styles/globals.css`
**只动 `:root` 块(第 242-277 行),保持 `.dark` 块原样**
#### 1.1 主色映射
| 角色 | 规范值 | 用途 |
|------|--------|------|
| 主色 | `#F5222D` | `--primary`、`--ring`、`--sidebar-primary` |
| 主色 hover | `#CF1322` | 按钮 hover 状态(新增辅助变量) |
| 主色 active | `#A8071A` | 按钮按下状态 |
| 主色淡 1 | `#FEEDED` | `--accent`(极淡红,背景) |
| 主色淡 2 | `#FDDADC` | hover bg、tag 背景 |
| 主色淡 3 | `#FCB5B9` | 浅边框、selected 态 |
| 危险色 | `#F5222D` | `--destructive` |
#### 1.2 新增工程变量(建议加在 `:root` 内)
```css
/* 红色主题阶梯色(规范色板)*/
--brand-50: #FEFAFA;
--brand-100: #FEF6F6;
--brand-200: #FEEDED;
--brand-300: #FDDADC;
--brand-400: #FCB5B9;
--brand-500: #FA9196;
--brand-600: #F86C73;
--brand-700: #F74750;
--brand-800: #F5222D; /* ← 主色 */
--brand-900: #CF1322; /* ← hover */
--brand-950: #A8071A; /* ← active */
/* 辅色(规范)*/
--accent-orange: #FF9900;
--accent-green: #2FD69A;
--accent-cyan: #45C3F3;
```
#### 1.3 `--workspace-brand` 同步
`:root` 内:`--workspace-brand: #F5222D;`(从 `#d92121` 改)
`.dark` 内:**不动**(保持 `#3b82f6` 蓝色)
#### 1.4 验收
- 切到 Light 模式:所有按钮、链接、选中态使用规范红
- 切到 Dark 模式:依然是原蓝色品牌(无变化)
---
### Step 2 — 圆角规范化
**文件**:`src/styles/globals.css`(`--radius` 是全局变量,light/dark 共用)
#### 2.1 改动方案(推荐)
按规范:按钮 2px、卡片/页面框 4px。但当前许多组件依赖 `rounded-md` / `rounded-lg`,全局把 `--radius` 改成 4px 会引起视觉跳变。
**采用细粒度策略:**
```css
:root {
--radius: 0.25rem; /* 4px → 卡片/页面框 */
}
@theme inline {
--radius-sm: 2px; /* 按钮、tag */
--radius-md: calc(var(--radius) - 1px); /* 3px → 输入框 */
--radius-lg: var(--radius); /* 4px → 卡片 */
--radius-xl: calc(var(--radius) + 4px); /* 8px → 弹窗 */
--radius-2xl: calc(var(--radius) + 8px); /* 12px → 大卡片 */
}
```
#### 2.2 例外保留(设计感样式)
以下组件**圆角不改**,保持设计稿原貌:
- `.workspace-user-bubble`(聊天气泡,圆角 `1.6rem 1.6rem 0.5rem 1.6rem`)
- `.workspace-assistant-card`(圆角 `1.75rem`)
- `.gemini-gradient-border`(圆角 `1.5rem`)
#### 2.3 验收
- 普通按钮变为近直角(2px)
- 卡片维持温和圆角(4px)
- 聊天气泡、AI 卡片不变形
---
### Step 3 — 按钮组件尺寸对齐
**文件**:`src/components/ui/button.tsx`
#### 3.1 改 size 变体(规范:小 24px / 正常 32px)
| size | 规范 | 当前 | 改后 |
|------|------|------|------|
| `sm` | 24px | h-8 (32px) | `h-6 px-2 text-xs` |
| `default` | 32px | h-9 (36px) | `h-8 px-3 text-sm` |
| `lg` | — | h-10 (40px) | 保留 `h-10`(用于 Banner/登录场景)|
#### 3.2 圆角
- 默认按钮:`rounded-sm`(2px)
#### 3.3 主按钮配色
- bg:`bg-primary`
- hover:`hover:bg-[var(--brand-900)]`(深红 `#CF1322`)
- active:`active:bg-[var(--brand-950)]`(更深 `#A8071A`)
- disabled:`disabled:opacity-50`(保留)
#### 3.4 次要按钮 / 文本按钮
- 次要按钮(`outline` variant):边框 `border-primary`,文字 `text-primary`
- 文本按钮(`ghost` variant):仅文字色 `text-primary`,hover 背景 `--brand-200`
#### 3.5 验收
检查所有调用 `<Button>` 的页面:
- 工具栏按钮(之前 h-9 → 现在 h-8)
- 弹窗确认/取消(之前 h-9 → 现在 h-8)
- Landing 主 CTA 按钮(保留 `lg`)
---
### Step 4 — 输入框尺寸对齐
#### 4.1 `src/components/ui/input.tsx`
- `h-9` → `h-8`(32px)
- `rounded-md`(沿用 3px)
- 边框:`border-input`(变量已是浅灰,不变)
- focus:`focus-visible:ring-[2px] focus-visible:ring-primary/35`
#### 4.2 `src/components/ui/textarea.tsx`
- 最小高度建议 `min-h-16`
- 圆角 `rounded-md`
#### 4.3 `src/components/ui/select.tsx`
- `size: default` → `h-8`
- `size: sm` → `h-6`
#### 4.4 验收
- 搜索框、表单输入、下拉框高度一致(32px)
- 与按钮对齐时高度一致
---
### Step 5 — 字体引入
#### 5.1 引入字体文件
在 `frontend-web/public/fonts/` 下放置:
- `NotoSansSC-Regular.woff2` / `Medium.woff2` / `Bold.woff2`
- `D-DIN-Regular.woff2` / `Bold.woff2`
> 若需在线方式,可用 Google Fonts CDN:
> `https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap`
#### 5.2 `globals.css` 顶部添加 `@font-face`
```css
@font-face {
font-family: 'Noto Sans SC';
src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* Medium / Bold 同上 */
@font-face {
font-family: 'D-DIN';
src: url('/fonts/D-DIN-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
```
#### 5.3 修改 `--font-sans`(globals.css 第 74 行)
```css
--font-sans:
'Noto Sans SC', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-numeric: 'D-DIN', 'Noto Sans SC', sans-serif;
```
#### 5.4 数字字体工具类
新增 `.font-numeric { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }`
仪表盘、表格中重要数字加 `className="font-numeric"`。
#### 5.5 验收
- 中文文本回退到 Noto Sans SC
- Dark 模式同样生效(字体是全局变量)
---
### Step 6 — Badge / 状态标签色对齐
**文件**:`src/components/ui/badge.tsx`
规范状态色(参考 PDF 第 67 页):
| 状态 | 颜色定位 | 推荐 token |
|------|---------|-----------|
| 未开始 | 中性灰 | `bg-muted text-muted-foreground` |
| 等待中 | 蓝/青 | `bg-blue-50 text-blue-600`(保留) |
| 进行中 | 主红 | `bg-[var(--brand-200)] text-primary` |
| 已完成 | 绿 | `bg-emerald-50 text-emerald-600` |
| 已失败 | 红/橙 | `bg-red-50 text-red-600` |
| 待更新 | 黄/橙 | `bg-amber-50 text-amber-700` |
需要新增 `variant`:`waiting`、`processing`、`success`、`failed`、`pending`。
圆角统一 `rounded-sm`,padding `px-2 py-0.5`,字号 `text-xs`。
---
### Step 7 — 全局间距 & 阴影规范化
#### 7.1 间距
规范基于 8px 网格(4 / 8 / 12 / 16 / 24 / 32 / 40 / 56)。Tailwind 默认 4px 步进(`p-1`=4, `p-2`=8...)已兼容。
**只需 review**:检查页面是否有 `p-2.5`、`gap-1.5` 等非 8 的倍数,统一改成 8 的倍数。
#### 7.2 阴影
规范阴影仅用于"标题+操作"和"卡片新增"两类。其余卡片保持平。
新增工具类(可选):
```css
.shadow-card { box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); }
.shadow-card-hover { box-shadow: 0 4px 12px 0 rgba(0,0,0,0.08); }
```
---
### Step 8 — 验收清单
每步完成后检查:
- [ ] **Light 模式**视觉对齐规范(参考 PDF 关键页:5、7、10、36、66)
- [ ] **Dark 模式**完全无变化(切换主题验证)
- [ ] 首页 / Workspace / 聊天 / 设置弹窗 → 按钮和输入框高度统一
- [ ] 主题切换按钮点击 → 颜色平滑切换,无错位/闪烁
- [ ] `/landing` 强制 Dark 模式仍正常
- [ ] 控制台无 CSS 变量未定义警告
---
## 执行顺序建议
```
Step 1 (颜色变量) ─┐
├→ Step 3 (按钮) ─┐
Step 2 (圆角) ─┘ ├→ Step 6 (Badge) ─→ Step 7 (间距/阴影) ─→ Step 8 (验收)
│
Step 4 (输入框) ──┤
Step 5 (字体) ────┘
```
并行可行:Step 1 + Step 2 完成后,Step 3/4/5 可同时推进。
---
## 风险提示
1. **圆角从 12px → 4px 是大幅度视觉变化**,建议先在分支上做、配截图比对再合并
2. 字体文件体积较大(NotoSansSC 全字符集 ~10MB),建议子集化或仅引入需要的字重
3. `--radius-sm` 改后可能影响 dropdown menu / popover 等小组件外观,需逐个检查
4. Dark 模式下的红色 `--workspace-brand` 已是蓝色,**不要把蓝色也改成 #F5222D**
-243
View File
@@ -1,243 +0,0 @@
# 素材专家检索 —— 内网接口对接说明
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
涉及的代码文件,以及常见错误的排查方式。
---
## 1. 检索来源切换
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
配置位置:`offline-backend-20260512/backend/config.yaml`
```yaml
ai_writing:
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
search_provider: internet
# 以下两项仅在 search_provider=intranet 时生效
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples
```
| `search_provider` 取值 | 含义 |
|---|---|
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
| `tavily` | Tavily 互联网检索 |
| `demo` | 内置演示素材,仅用于本地调试 |
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
---
## 1.5 检索关键词数量
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
规整到 `[1, 10]`,越界回退默认 `6`。
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
---
## 1.6 素材总量上限
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
```yaml
ai_writing:
# 素材总量上限,范围 1~100,非法值回退 20
max_materials: 20
```
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
| `max_materials` | 检索词数 | 每词检索条数 |
|---|---|---|
| 20 | 1 | 20 |
| 20 | 2 | 10 |
| 20 | 3 | 7(共 21,合并后截断到 20) |
| 20 | 5 | 4 |
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
> `config.yaml` 支持热加载,改完一般无需重启后端。
---
## 2. 内网接口规格
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
- **方法**:`POST`
- **请求体**:
```json
{
"query": "检索关键词",
"knowledge_base_name": "samples",
"ts": "yes"
}
```
- **响应体**:一个数组,每个元素结构如下:
```json
[
{
"page_content": "正文片段",
"metadata": {
"m_title": "标题",
"content": "内容",
"recUuid": "原文id",
"m_publish":"发布时间",
"source1": "来源"
}
}
]
```
我们需要的字段都在 `metadata` 里。
### 字段映射
后端把内网响应映射为统一的 `SearchResult` 结构:
| 内网响应字段 | SearchResult 字段 | 说明 |
|--------------------------|--------------------|------|
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
| `metadata.content` | `content` | 内容,截断到 500 字 |
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
| `metadata.m_publish` | `published_at` | 发布时间 |
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
| — | `source_type` | 固定为 `"intranet"` |
---
## 3. 对接逻辑与涉及文件
### 后端
| 文件 | 作用 |
|------|------|
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
调用链:
```
ai_writing.py(_make_config 注入 ai_writing_config)
→ researcher_node
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
→ IntranetSearchProvider.search() # POST search_docs_es
→ SearchResult[] # 字段映射
```
### 前端
| 文件 | 作用 |
|------|------|
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
「查看原文」链接拼接规则:
```
INTRANET_SOURCE_URL_BASE + recUuid
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
```
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
---
## 4. 错误处理与排查
### 4.1 内网检索返回空 / 调不通
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
**接口报错不影响写作流程继续**。
> 容错相关代码:
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
排查步骤:
1. **确认网络可达**:在后端所在机器执行
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
用户可以选择「重新搜索」或「无数据继续写作」。
### 4.2 字段缺失 / 响应结构变化
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
单条数据字段缺失不会导致整批失败。
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
### 4.3 「查看原文」按钮不显示
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
### 4.4 原文链接打不开 / 地址不对
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
若内网原文详情页地址有变,只改这一个常量即可。
### 4.5 检索到的素材全是无关内容
**现象**:素材卡片内容与写作意图完全不沾边。
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
---
## 5. 联调上线步骤
1. 确认后端机器能访问 `10.106.57.180:7863`。
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
3. 后端配置热加载生效(或重启后端)。
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
+12 -1
View File
@@ -1,7 +1,18 @@
<!doctype html>
<html lang="en-US">
<html lang="en-US" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<script>
(function () {
try {
var t = localStorage.getItem("strategy-theme");
var root = document.documentElement;
root.classList.remove("dark", "light");
if (t === "light") return;
root.classList.add("dark");
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CM 助手</title>
</head>
+3 -1
View File
@@ -25,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
enableSystem
storageKey="strategy-theme"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
>
@@ -72,6 +72,12 @@ const menuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "qa-roundtable",
label: "圆桌规划",
icon: Bot,
path: "/page/roundtable/planning",
},
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -99,6 +105,12 @@ const menuItems: MenuItem[] = [
icon: List,
path: "/page/strategy/light-app/notes",
},
{
id: "light-app-note-qa",
label: "笔记问答",
icon: MessageSquareCode,
path: "/page/strategy/light-app/note-qa",
},
],
},
{ id: "light-app-square", label: "应用广场", icon: Layout },
+169 -169
View File
@@ -397,53 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
return (
<Comp
ref={ref}
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
function SidebarGroupContent({
className,
@@ -503,99 +503,99 @@ const sidebarMenuButtonVariants = cva(
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const button = (
<Comp
ref={ref}
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
function SidebarMenuBadge({
className,
@@ -686,37 +686,37 @@ function SidebarMenuSubItem({
);
}
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
@@ -526,7 +526,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
result?: string | Record<string, unknown>;
}
type CoTStep = CoTReasoningStep | CoTToolCallStep;
@@ -558,12 +558,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages);
if (toolCallResult) {
try {
const json = JSON.parse(toolCallResult);
step.result = json;
} catch {
const toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
);
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
step.result = JSON.parse(toolCallResult) as Record<
string,
unknown
>;
} catch {
step.result = toolCallResult;
}
} else {
step.result = toolCallResult;
}
}
@@ -31,6 +31,7 @@ import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator";
@@ -115,7 +116,9 @@ export function MessageList({
[],
);
const handleSubmitAllClarifications = useCallback(async () => {
const handleSubmitAllClarifications = useCallback(async (
selectionOverrides?: Map<string, ClarificationSelection>,
) => {
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return;
}
@@ -124,7 +127,7 @@ export function MessageList({
const id = pendingClarificationIds[index]!;
const message = messages.find((msg) => msg.id === id);
const payload = message ? getClarificationPayload(message) : null;
const selection = clarificationSelections.get(id) ?? {
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
selectedIds: [],
custom: "",
};
@@ -244,7 +247,12 @@ export function MessageList({
}
submitting={submittingClarification}
canSubmit={hasAnyClarificationAnswer}
onSubmitAll={handleSubmitAllClarifications}
onSubmitAll={(override) => {
const overrides = override
? new Map([[messageId, override]])
: undefined;
void handleSubmitAllClarifications(overrides);
}}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -435,6 +443,7 @@ type ClarificationPayload = {
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
allow_multiple?: boolean;
};
type ClarificationSelection = {
@@ -641,7 +650,7 @@ function ClarificationCard({
onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean;
canSubmit: boolean;
onSubmitAll: () => void | Promise<void>;
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
}) {
const payload = getClarificationPayload(message);
@@ -665,11 +674,21 @@ function ClarificationCard({
const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1;
const allowMultiple = resolveAllowMultiple(
payload.allow_multiple,
payload.clarification_type,
);
function toggleOption(optionId: string) {
function handleOptionClick(optionId: string) {
if (disabled) {
return;
}
if (!allowMultiple) {
const next = { selectedIds: [optionId], custom: "" };
onSelectionChange(next);
void onSubmitAll(next);
return;
}
const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId];
@@ -707,7 +726,7 @@ function ClarificationCard({
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs">
可多选(再次点击取消选择)
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const optionId = option.id || option.label;
@@ -716,10 +735,10 @@ function ClarificationCard({
<Button
key={optionId}
type="button"
variant={isSelected ? "default" : "outline"}
variant={isSelected && allowMultiple ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => toggleOption(optionId)}
onClick={() => handleOptionClick(optionId)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span>
@@ -736,7 +755,7 @@ function ClarificationCard({
</span>
) : null}
</span>
{isSelected ? (
{isSelected && allowMultiple ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</Button>
@@ -761,10 +780,10 @@ function ClarificationCard({
</div>
) : null}
{isPending ? (
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? (
{multiPending && allowMultiple ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
@@ -776,14 +795,14 @@ function ClarificationCard({
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
</Button>
</div>
) : (
) : allowMultiple ? (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
)
) : null
) : null}
</div>
);
@@ -1,6 +1,7 @@
"use client";
import {
ChevronDown,
Download,
FileJson,
FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
};
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
useParams<{
thread_id: string;
@@ -161,12 +171,24 @@ export function RecentChatList() {
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase">
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,9 +1,10 @@
"use client";
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import {
Select,
SelectContent,
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
const { theme, setTheme } = useTheme();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
{
id: "system",
label: t.settings.appearance.system,
description: t.settings.appearance.systemDescription,
icon: MonitorSmartphoneIcon,
},
{
id: "light",
label: t.settings.appearance.light,
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
t.settings.appearance.system,
t.settings.appearance.systemDescription,
],
);
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
title={t.settings.appearance.themeTitle}
description={t.settings.appearance.themeDescription}
>
<div className="grid gap-3 lg:grid-cols-3">
<div className="grid gap-3 lg:grid-cols-2">
{themeOptions.map((option) => (
<ThemePreviewCard
key={option.id}
@@ -76,8 +69,7 @@ export function AppearanceSettingsPage() {
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "system" | "light" | "dark"}
systemTheme={systemTheme}
mode={option.id as "light" | "dark"}
onSelect={(value) => setTheme(value)}
/>
))}
@@ -159,19 +151,16 @@ function ThemePreviewCard({
description,
active,
mode,
systemTheme,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "system" | "light" | "dark";
systemTheme?: string;
onSelect: (mode: "system" | "light" | "dark") => void;
mode: "light" | "dark";
onSelect: (mode: "light" | "dark") => void;
}) {
const previewMode =
mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
const previewMode = mode;
return (
<button
type="button"
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { cn } from "@/lib/utils";
@@ -119,6 +119,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/planning">
<Users />
<span>多智能体规划</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
+60 -23
View File
@@ -1,4 +1,11 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
import { useTheme as useNextTheme } from 'next-themes';
// 主题类型
export type ThemeType = 'dark' | 'light';
@@ -75,12 +82,24 @@ const themes: Record<ThemeType, ThemeConfig> = {
},
};
const STORAGE_KEY = 'strategy-theme';
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'dark';
}
const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'light' ? 'light' : 'dark';
}
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
themeConfig: ThemeConfig;
toggleTheme: () => void;
setTheme: (theme: ThemeType) => void;
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
mounted: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeType>(() => {
// 从 localStorage 读取保存的主题
const saved = localStorage.getItem('strategy-theme');
return (saved as ThemeType) || 'dark';
});
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false);
// 切换主题
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
// 设置特定主题
const setTheme = (newTheme: ThemeType) => {
setThemeState(newTheme);
};
// 保存到 localStorage
useEffect(() => {
localStorage.setItem('strategy-theme', theme);
// 更新文档类名
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}, [theme]);
setMounted(true);
}, []);
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
useEffect(() => {
if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (current !== 'light' && current !== 'dark') {
const fallback =
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
setNextTheme(fallback);
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy);
}
if (legacy) {
localStorage.removeItem('theme');
}
}, [mounted, setNextTheme]);
const theme: ThemeType = mounted
? resolvedTheme === 'light'
? 'light'
: 'dark'
: readPersistedTheme();
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
};
const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme);
};
const themeConfig = themes[theme];
return (
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
<ThemeContext.Provider
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
>
{children}
</ThemeContext.Provider>
);
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const layers = collectNestedRecords(record);
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
let title =
pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
if (!title && indexLabel) {
title = `结果 ${indexLabel}`;
}
let content =
pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
title = pickShortTitleFallback(layers, reserved);
}
if (!title && !content && !url) {
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
if (!title && !content && !url && !indexLabel) {
return null;
}
+81 -15
View File
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
interface GenericMessageGroup<T = string> {
type: T;
id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages<T>(
}
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return (
splitInlineReasoningFromAIMessage(message)?.content ??
message.content.trim()
);
const payload = extractToolResultPayload(message);
if (typeof payload === "string") {
return payload;
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => (content.type === "text" ? content.text : ""))
.join("\n")
.trim();
if (payload && typeof payload === "object") {
try {
return JSON.stringify(payload);
} catch {
return "";
}
}
return "";
}
/**
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
*/
export function extractToolResultPayload(
message: Message,
): string | Record<string, unknown> | undefined {
const { content } = message;
if (typeof content === "string") {
const trimmed = content.trim();
return trimmed || undefined;
}
if (content && typeof content === "object" && !Array.isArray(content)) {
return content as Record<string, unknown>;
}
if (Array.isArray(content)) {
const textParts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") {
continue;
}
const block = part as Record<string, unknown>;
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
continue;
}
if (block.type === "json" && block.json && typeof block.json === "object") {
return block.json as Record<string, unknown>;
}
if (hasSearchResultPayload(block)) {
return block;
}
}
const joined = textParts.join("\n").trim();
return joined || undefined;
}
return undefined;
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
return false;
}
export function findToolCallResult(toolCallId: string, messages: Message[]) {
export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
): string | Record<string, unknown> | undefined {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
const content = extractTextFromMessage(message);
if (content) {
return content;
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
continue;
}
const payload = extractToolResultPayload(message);
if (payload !== undefined) {
return payload;
}
}
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName) {
for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) {
continue;
}
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
continue;
}
const payload = extractToolResultPayload(message);
if (payload !== undefined && hasSearchResultPayload(payload)) {
return payload;
}
}
}
return undefined;
}
@@ -0,0 +1,69 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
export async function downloadAIWritingDocx(options: {
title?: string;
draftMarkdown: string;
}): Promise<void> {
const draftMarkdown = options.draftMarkdown.trim();
if (!draftMarkdown) {
throw new Error("文档内容为空");
}
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
draft_markdown: draftMarkdown,
}),
});
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
}
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
hideAdvancedFields?: boolean;
}
interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const DEFAULT_KEYWORD_COUNT = 6;
const DEFAULT_AUDIENCE = '普通读者';
export function AIWritingForm({
onSubmit,
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount,
keywordCount,
writingMode: strictMode ? 'strict' : 'loose',
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined,
});
}
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
{!hideAdvancedFields && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
</>
)}
{models.length > 0 && (
<div className="space-y-1.5">
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
error: { label: '错误', color: 'text-destructive' },
};
function formatRelative(isoStr: string): string {
export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
interface SessionItemProps {
export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
onRename: (title: string) => void;
}
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
error: '出错了',
};
export function AIWritingPanel() {
export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
reset();
}, [reset]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
useEffect(() => {
handleSelectHistoryRef.current = handleSelectHistory;
}, [handleSelectHistory]);
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
useEffect(() => {
const raw = sessionStorage.getItem('ai-writing:pending-session');
if (!raw) return;
sessionStorage.removeItem('ai-writing:pending-session');
try {
const session = JSON.parse(raw) as AIWritingSessionSummary;
handleSelectHistoryRef.current(session);
} catch { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
useEffect(() => {
function onOpenSession(e: Event) {
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
if (session) handleSelectHistoryRef.current(session);
}
window.addEventListener('ai-writing:open-session', onOpenSession);
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)}
{/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} />
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)}
{/* 运行中或完成后显示时间线 */}
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
</span>
</ReqRow>
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
{!hideAdvancedFields && (
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
{!hideAdvancedFields && (
<>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && (
<ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span>
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
hideTargetAudience?: boolean;
}
/**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
</div>
<div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} />
{!hideTargetAudience && (
<Row label="目标读者" value={intent.targetAudience} />
)}
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline />
@@ -25,7 +25,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
import { exportMarkdownToDocx } from "../../utils/export-docx";
import { downloadAIWritingDocx } from "../../api/writing-export";
interface ToolbarButtonProps {
onClick: () => void;
@@ -82,9 +82,12 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
if (!canExport) return;
setExporting(true);
try {
await exportMarkdownToDocx(markdown ?? "", title);
await downloadAIWritingDocx({
title,
draftMarkdown: markdown ?? "",
});
} catch (err) {
console.error("[export-docx] failed", err);
console.error("[writing-export] failed", err);
toast.error("导出 Word 失败,请稍后重试");
} finally {
setExporting(false);
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() {
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
</ResizablePanel>
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BookOpen, Plus, Send } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (!question.trim()) return;
setQuestion("");
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
isDark ? "bg-slate-950" : "bg-white",
)}
>
{/* 背景光晕 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background: isDark
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
}}
/>
{/* 主内容 */}
<div className="relative flex h-full flex-col items-center justify-center -translate-y-10">
<div className="flex w-full max-w-[520px] flex-col items-center gap-4 px-6">
{/* 笔记图标 */}
<div
className={cn(
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-6 w-6" />
</div>
{/* 笔记标题 */}
<h1
className={cn(
"text-xl font-semibold tracking-tight",
isDark ? "text-slate-100" : "text-slate-900",
)}
>
笔记01
</h1>
{/* 新增来源按钮 */}
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
isDark
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
)}
>
<Plus className="h-3.5 w-3.5" />
新增来源
</button>
{/* 问答输入框 */}
<div
className={cn(
"mt-3 flex w-full items-center gap-3 rounded-full px-5 py-3.5",
isDark
? "bg-slate-800 ring-1 ring-white/10"
: "bg-white shadow-sm ring-1 ring-black/5",
)}
>
<Plus
className={cn(
"h-4 w-4 shrink-0",
isDark ? "text-slate-500" : "text-slate-400",
)}
/>
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
placeholder="开始问答"
className={cn(
"min-w-0 flex-1 bg-transparent text-sm outline-none",
isDark
? "text-slate-200 placeholder:text-slate-500"
: "text-slate-700 placeholder:text-slate-400",
)}
/>
{question.trim() && (
<button
type="button"
onClick={handleSubmit}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
aria-label="发送"
>
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -8,6 +8,7 @@ import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
/>
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
</Routes>
</main>
</div>
@@ -0,0 +1,16 @@
import { Navigate, Route, Routes } from "react-router-dom";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export default function RoundtableRoutes() {
return (
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="planning" replace />} />
<Route path="planning" element={<RoundtablePlanningPage />} />
<Route path="*" element={<Navigate to="planning" replace />} />
</Routes>
</div>
);
}
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() {
return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
+15 -1
View File
@@ -33,6 +33,8 @@ import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export function WorkspaceLayout({
children,
@@ -183,7 +185,19 @@ export default function WorkspaceRoutes({
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage />
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
/>
<Route
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<RoundtablePlanningPage />
</WorkspaceLayout>
}
/>
@@ -0,0 +1,292 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { ClarificationPayload } from "./multi-agent";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type { ClarificationPayload };
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
* built-in agent and no per-call coordinator: one /init creates a thread
* tied to the built-in `roundtable-intent` agent, and each /stream POST is
* one user-turn of the clarification dialogue. The terminal status frame
* carries either `asking` (more turns needed) or `done` (structured summary
* available — hand off to Step 2).
*/
export interface IntentInitResponse {
status: "success";
agent_id: string;
thread_id: string;
}
/**
* Create a fresh thread for an intent-clarification session.
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
*/
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init intent failed (${res.status}): ${text}`);
}
return (await res.json()) as IntentInitResponse;
}
/**
* Structured task summary the intent agent emits when it judges the user's
* task is fully specified. The frontend feeds this into Step 2's initial
* leader prompt.
*/
export interface IntentSummary {
/** One-line statement of what the user wants accomplished. */
objective: string;
/** Hard constraints (budget / time / compliance / scope). At least 1 entry. */
constraints: string[];
/** Optional assumptions the agent is making on the user's behalf. */
assumptions?: string[];
}
/**
* Same phases as streamMultiAgent so the UI can reuse one phase-label mapper.
* `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`;
* `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or
* similar tool call (which usually means the run is about to interrupt and
* the user-visible text won't grow further this turn).
*/
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
signal?: AbortSignal;
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
onTextDelta?: (text: string, messageId: string) => void;
/**
* Fires whenever the inferred SSE phase changes. The UI uses this to flip
* the placeholder bubble's caption from "调度中" → "已连接,等待响应" →
* "正在输出" so users see what's happening during the inevitable LLM
* first-token wait.
*/
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
}
export interface StreamIntentResult {
/** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
status: "asking" | "clarification" | "done" | "error";
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
content: string;
/** Populated only when status === "done". */
summary: IntentSummary | null;
/**
* Populated when the intent agent called ask_clarification — render option
* cards + custom input instead of treating the turn as plain text only.
*/
clarification: ClarificationPayload | null;
}
/**
* Recognise the terminal status frame the backend appends after the upstream
* LangGraph stream finishes. Must carry `status` and `content`; `summary`
* is optional.
*/
function isFinalIntentFrame(value: unknown): value is {
status: "asking" | "clarification" | "done" | "error";
content?: string;
summary?: IntentSummary | null;
question?: string;
clarification_type?: string;
clarification_context?: string;
options?: unknown[];
allow_custom?: boolean;
allow_multiple?: boolean;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status;
return (
status === "asking" ||
status === "clarification" ||
status === "done" ||
status === "error"
);
}
/**
* Reuse the same delta-extraction shape as multi-agent.ts so AI text streams
* smoothly into the frontend bubble. Returns null for tool / value / tool
* result frames.
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to /api/intent/stream and resolve when the upstream
* stream completes. Each call is one user turn; reuse the same threadId
* across calls to keep the dialogue history.
*/
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: req.threadId,
message: req.message,
model: req.model,
}),
signal: req.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`intent/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let lastEvent = "";
// Single source of truth for the inferred SSE phase. The helper only fires
// the callback when the phase actually changes (or when a tool name is
// provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
if (next === currentPhase && !toolName) return;
currentPhase = next;
req.onPhaseChange?.(next, toolName);
};
const result: StreamIntentResult = {
status: "asking",
content: "",
summary: null,
clarification: null,
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
// First `event: metadata` = the upstream LangGraph run was accepted.
// Flip out of "connecting" so the bubble label changes from
// "调度中" → "已连接,等待响应".
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
if (isFinalIntentFrame(parsed)) {
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
}
if (parsed.status === "clarification") {
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType,
context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
clarificationType,
),
};
}
continue;
}
if (lastEvent === "messages") {
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
if (Array.isArray(parsed) && parsed.length > 0) {
const chunk = parsed[0];
if (chunk && typeof chunk === "object") {
const chunkObj = chunk as Record<string, unknown>;
const chunkType = String(chunkObj.type ?? "").toLowerCase();
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const hasToolCall =
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
if (hasToolCall) {
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
}
}
const delta = extractAiTextDelta(parsed);
if (delta) req.onTextDelta?.(delta.text, delta.id);
}
}
}
return result;
}
@@ -0,0 +1,568 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type ThreadIdMap = Record<string, string>;
export interface SeatReadyEvent {
agentName: string;
threadId: string;
displayName: string;
description: string;
}
export interface CoordinatorReadyEvent {
mainAgentName: string;
threadId: string;
}
export interface InitMultiAgentRequest {
agentNames: string[];
mainAgentName: string;
model?: string;
/** Fires the moment each sub-agent's thread is ready (real completion order). */
onSeatReady?: (event: SeatReadyEvent) => void;
/** Fires when the coordinator's thread + agent have been created. */
onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
signal?: AbortSignal;
}
export interface InitMultiAgentResponse {
status: "success";
main_agent_name: string;
thread_ids: ThreadIdMap;
}
/**
* Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
* callbacks as events arrive so the UI can light up roles individually, and
* resolves with the final {main_agent_name, thread_ids} once init_done lands.
* If the server emits an "error" frame mid-stream, the promise rejects.
*/
export async function initMultiAgent(
payload: InitMultiAgentRequest,
): Promise<InitMultiAgentResponse> {
// Timing telemetry for /api/multi-agent/init — enabled by default so we can
// see where time goes when users report "Step 2 init feels slow". Disable
// with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools.
const timingEnabled =
typeof window === "undefined"
? false
: (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false;
const t0 = performance.now();
let tFirstByte = 0;
let tCoord = 0;
let seatCount = 0;
const tlog = (label: string) => {
if (!timingEnabled) return;
const elapsed = (performance.now() - t0).toFixed(0);
console.debug(`[init-timing] +${elapsed}ms ${label}`);
};
tlog("fetch.start");
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agent_names: payload.agentNames,
main_agent_name: payload.mainAgentName,
model: payload.model,
}),
signal: payload.signal,
});
tlog(`fetch.headers status=${res.status}`);
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let mainAgentName = "";
let threadIds: ThreadIdMap | null = null;
let aborted = false;
let errorDetail: string | null = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (tFirstByte === 0) {
tFirstByte = performance.now();
tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`);
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data) continue;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
if (!parsed || typeof parsed !== "object") continue;
const obj = parsed as Record<string, unknown>;
const event = typeof obj.event === "string" ? obj.event : "";
switch (event) {
case "seat_ready": {
seatCount += 1;
tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`);
payload.onSeatReady?.({
agentName: String(obj.agent_name ?? ""),
threadId: String(obj.thread_id ?? ""),
displayName: String(obj.display_name ?? ""),
description: String(obj.description ?? ""),
});
break;
}
case "coordinator_ready": {
tCoord = performance.now();
tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`);
payload.onCoordinatorReady?.({
mainAgentName: String(obj.main_agent_name ?? ""),
threadId: String(obj.thread_id ?? ""),
});
break;
}
case "init_done": {
tlog(`init_done seats=${seatCount}`);
mainAgentName = String(obj.main_agent_name ?? "");
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
break;
}
case "error": {
errorDetail = String(obj.detail ?? "unknown init error");
break;
}
default:
// Unknown event — ignore for forward-compat.
break;
}
}
}
} catch (err) {
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
if (!aborted) throw err;
}
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
if (errorDetail) {
throw new Error(`init multi-agent failed: ${errorDetail}`);
}
if (aborted) {
throw new DOMException("init aborted", "AbortError");
}
if (!threadIds || !mainAgentName) {
throw new Error("init multi-agent stream ended without init_done");
}
return {
status: "success",
main_agent_name: mainAgentName,
thread_ids: threadIds,
};
}
export type StreamAgentType = "leader" | "special";
export type LeaderDispatch = [string, string];
/**
* High-level phases the SSE stream goes through. Used by the UI to show
* "what the backend is doing" while the user waits — most of the time before
* a first token is upstream/LLM latency, which feels like a stall otherwise.
*
* - `connecting` — fetch sent, no frames yet
* - `connected` — got the `event: metadata` frame (run_id assigned)
* - `streaming` — first AI text chunk arrived; tokens flowing
* - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
* present_files); usually means the run is about to interrupt
* - `tool_result` — a tool result message came back; brief, may go back to streaming
*/
export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
export interface StreamMultiAgentRequest {
agentType: StreamAgentType;
dAgentThreadId: ThreadIdMap;
agentName: string;
newMessage: string;
model?: string;
skillStopNames?: string[];
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
/**
* Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
* `text` is the incremental delta (concatenate to rebuild the message);
* `messageId` is stable per AI message so callers can group deltas.
*/
onTextDelta?: (text: string, messageId: string) => void;
/**
* Fires whenever the inferred SSE phase changes. The UI can use this to
* show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
*/
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
}
export interface ClarificationPayload {
/** Raw question text from the model's ask_clarification args. */
question: string;
/** Optional category hint from the model (e.g. "missing_info"). */
clarificationType: string;
/** Free-text reason the model needs clarification. */
context: string;
/** Suggested answer chips (may be empty). */
options: unknown[];
/** Whether the user is allowed to type a custom answer. */
allowCustom: boolean;
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
allowMultiple?: boolean;
}
export interface StreamMultiAgentResult {
/** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
dispatched: LeaderDispatch[];
/** For special runs: the completion status string. */
message: string | null;
/** Final assistant text emitted by the underlying agent. */
content: string;
/** Echoed agent_name of the agent that produced this run. */
agentName: string | null;
/**
* Populated when the leader called ask_clarification — the run ended without
* dispatching, and the model is asking the user a question instead. When
* present, callers should render `content` (or `clarification.question` as
* a fallback) as a question and collect a user reply rather than treating
* an empty `dispatched` as consensus.
*/
clarification: ClarificationPayload | null;
}
/**
* Identify the final status frame our backend appends after the LangGraph
* stream. Required keys are `status` (array for leader, string for special)
* and `content` (final assistant text); `agent_name` is also expected.
*/
function isFinalStatusFrame(value: unknown): value is {
status: LeaderDispatch[] | string;
content?: string;
agent_name?: string;
question?: string;
clarification_type?: string;
clarification_context?: string;
options?: unknown[];
allow_custom?: boolean;
allow_multiple?: boolean;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status;
if (typeof status === "string") return true;
if (Array.isArray(status)) {
return status.every(
(item) =>
Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
);
}
return false;
}
/**
* Extract an AI text delta from a LangGraph `messages-tuple` data frame.
* Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
* {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
* structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots).
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
// Accept any AI-shaped message chunk LangChain might emit across versions:
// "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
* stream completes. The final frame (`{status: ...}`) is interpreted; every
* other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
* its own rendering (we don't try to mimic LangGraph SDK semantics here).
*/
export async function streamMultiAgent(
req: StreamMultiAgentRequest,
): Promise<StreamMultiAgentResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agent_type: req.agentType,
d_agent_thread_id: req.dAgentThreadId,
agent_name: req.agentName,
new_message: req.newMessage,
model: req.model,
skill_stop_names: req.skillStopNames ?? [],
}),
signal: req.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// Track the last `event:` line so logging can show which event a `data:`
// belongs to. The frontend doesn't actually dispatch by event name — every
// data frame goes through the same parser — but the label is invaluable
// for diagnosing why a sub-agent stream "looks empty".
let lastEvent = "";
let frameSeq = 0;
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
const debugEnabled =
typeof window !== "undefined" &&
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
const result: StreamMultiAgentResult = {
dispatched: [],
message: null,
content: "",
agentName: null,
clarification: null,
};
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
// value actually changes (the reader loop sees many frames per phase).
let currentPhase: StreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: StreamPhase, name?: string) => {
if (next === currentPhase && !name) return;
currentPhase = next;
req.onPhaseChange?.(next, name);
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
// First metadata frame = run accepted by upstream. Move phase out
// of "connecting" so the UI can switch from "调度中" to "已连接".
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
continue;
}
frameSeq += 1;
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
// Phase inference from LangGraph messages-tuple frames. We only flip
// to tool_calling/tool_result on chunks that clearly indicate them;
// everything else with non-empty AI text means we're streaming.
if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
const chunk = parsed[0];
if (chunk && typeof chunk === "object") {
const chunkObj = chunk as Record<string, unknown>;
const chunkType = String(chunkObj.type ?? "").toLowerCase();
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const hasToolCall =
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
if (chunkType === "tool") {
setPhase("tool_result");
} else if (hasToolCall) {
// Pick the most informative name we can find in either field.
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
}
}
if (isFinalStatusFrame(parsed)) {
if (Array.isArray(parsed.status)) {
result.dispatched = parsed.status;
} else {
result.message = parsed.status;
// "clarification" status carries the leader's ask_clarification
// payload. Extract the structured fields so the page can render
// a question (or chip choices) instead of mistaking the run for
// a consensus.
if (parsed.status === "clarification") {
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType,
context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
clarificationType,
),
};
}
}
if (typeof parsed.content === "string") {
result.content = parsed.content;
}
if (typeof parsed.agent_name === "string") {
result.agentName = parsed.agent_name;
}
continue;
}
const delta = extractAiTextDelta(parsed);
if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
req.onTextDelta?.(delta.text, delta.id);
}
req.onUpstreamFrame?.(parsed);
}
}
if (debugEnabled) {
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
}
return result;
}
export interface SessionArtifactFile {
/** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */
path: string;
/** Bare filename (basename of `path`). */
name: string;
sizeBytes: number;
/** MIME type guessed from the extension, or null when unknown. */
mimeType: string | null;
/** ISO 8601 UTC timestamp of last modification. */
modifiedAt: string;
}
export interface SessionArtifactsResponse {
threadId: string;
files: SessionArtifactFile[];
}
/**
* List every file written to `/mnt/user-data/outputs/` for a thread. Used by
* the Step 3 final-report view to surface a thread's produced artifacts so the
* user can preview/download them via {@link buildArtifactDownloadUrl}.
*
* Returns an empty `files` array when the thread produced no artifacts (the
* backend treats a missing outputs directory as empty, not 404).
*/
export async function listSessionArtifacts(
threadId: string,
options: { signal?: AbortSignal } = {},
): Promise<SessionArtifactsResponse> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
{ method: "GET", signal: options.signal },
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`list artifacts failed (${res.status}): ${text}`);
}
const json = (await res.json()) as {
thread_id: string;
files: Array<{
path: string;
name: string;
size_bytes: number;
mime_type: string | null;
modified_at: string;
}>;
};
return {
threadId: json.thread_id,
files: (json.files ?? []).map((f) => ({
path: f.path,
name: f.name,
sizeBytes: f.size_bytes,
mimeType: f.mime_type,
modifiedAt: f.modified_at,
})),
};
}
/**
* Build a URL that previews (inline) or downloads (attachment) an artifact
* file. The `path` must be a virtual path returned by
* {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`).
*/
export function buildArtifactDownloadUrl(
threadId: string,
virtualPath: string,
options: { download?: boolean } = {},
): string {
const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/");
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
return options.download ? `${base}?download=true` : base;
}
@@ -0,0 +1,303 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { Agent } from "@/core/agents";
import type { IntentSummary } from "./intent";
/**
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
* Step 2 (multi-agent roundtable): given the IntentSummary and a candidate
* agent pool, the built-in `roundtable-recommender` picks 2-6 agents most
* relevant to the task and explains why.
*
* One-shot, stateless: no /init, no thread_id, each call spins up a fresh
* backend-managed thread. Use {@link filterRecommendCandidates} to clean the
* `/api/agents` list before passing it as `candidates`.
*/
/** Single agent pick returned by the recommender. */
export interface RecommendPick {
agent_id: string;
reason: string;
}
/** Final status frame the backend appends after the upstream stream finishes. */
export interface StreamRecommendResult {
/**
* `done` → picks parsed and validated, ready to populate the dialog as
* default-checked selections.
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
* caller should apply its fallback list (typically the 6 roundtable-* seats).
* `error` → upstream / network failure.
*/
status: "done" | "asking" | "error";
/** Full final AI text (includes the natural-language rationale). */
content: string;
/** Populated only when status === "done"; null otherwise. */
picks: RecommendPick[] | null;
}
/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */
export type RecommendStreamPhase = "connecting" | "connected" | "streaming";
export interface StreamRecommendRequest {
/** Step 1 output. `objective` is required by the backend. */
intent: IntentSummary;
/** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */
candidates: RecommendCandidate[];
model?: string;
signal?: AbortSignal;
/** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */
onRationaleUpdate?: (rationale: string) => void;
/** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */
onPicksUpdate?: (picks: RecommendPick[]) => void;
/** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */
onTextDelta?: (text: string, messageId: string) => void;
/** Fires whenever the inferred SSE phase changes. */
onPhaseChange?: (phase: RecommendStreamPhase) => void;
}
/** Shape the backend expects in `candidates[]`. */
export interface RecommendCandidate {
agent_id: string;
name: string;
description: string;
}
/**
* Agent ids that must never enter the candidate pool. Centralized here so
* every caller (recommend dialog, future tools) applies the same rule.
*
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
* accumulates one per session); meaningless as roundtable participants.
*/
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
/**
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
* to the slim shape the backend expects. Returns a fresh array; original
* objects are not mutated.
*/
export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] {
const out: RecommendCandidate[] = [];
for (const a of agents) {
if (!a || !a.id) continue;
if (EXCLUDED_AGENT_IDS.has(a.id)) continue;
if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue;
out.push({
agent_id: a.id,
name: a.name ?? a.id,
description: a.description ?? "",
});
}
return out;
}
function isFinalRecommendFrame(value: unknown): value is {
status: "done" | "asking" | "error";
content?: string;
picks?: RecommendPick[] | null;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status;
return status === "done" || status === "asking" || status === "error";
}
const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]";
/** Match one complete pick object inside a partially streamed JSON block. */
const PICK_OBJECT_PATTERN =
/\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g;
function unescapeJsonString(raw: string): string {
try {
return JSON.parse(`"${raw}"`) as string;
} catch {
return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
}
/**
* Split accumulated recommender output into visible rationale text and any
* pick objects that have fully arrived in the JSON section.
*/
export function parseRecommendStreamContent(raw: string): {
rationale: string;
picks: RecommendPick[];
} {
const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER);
let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw;
rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd();
const jsonSection =
markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : "";
const picks: RecommendPick[] = [];
const seen = new Set<string>();
PICK_OBJECT_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) {
const agent_id = unescapeJsonString(match[1]).trim();
const reason = unescapeJsonString(match[2]).trim();
if (!agent_id || seen.has(agent_id)) continue;
seen.add(agent_id);
picks.push({ agent_id, reason });
}
return { rationale, picks };
}
/**
* Extract an AI text delta from one LangGraph `messages-tuple` frame. Same
* shape as the helper in intent.ts; duplicated rather than imported to keep
* each helper independently readable.
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") {
const meta = parsed[1] as Record<string, unknown>;
const node = String(meta.langgraph_node ?? "");
if (node.includes("TitleMiddleware")) return null;
const tags = meta.tags;
if (Array.isArray(tags) && tags.includes("middleware:title")) return null;
}
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to `/api/recommend/stream` and resolve once the
* upstream stream finishes. Each call is one recommendation round — the
* recommender is stateless, so calling twice in a row produces two
* independent runs.
*/
export async function streamRecommend(req: StreamRecommendRequest): Promise<StreamRecommendResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent: req.intent,
candidates: req.candidates,
model: req.model,
}),
signal: req.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let lastEvent = "";
let currentPhase: RecommendStreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: RecommendStreamPhase) => {
if (next === currentPhase) return;
currentPhase = next;
req.onPhaseChange?.(next);
};
const result: StreamRecommendResult = { status: "asking", content: "", picks: null };
let rawContent = "";
let lastRationale = "";
let lastPickCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
if (isFinalRecommendFrame(parsed)) {
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (Array.isArray(parsed.picks)) {
result.picks = parsed.picks as RecommendPick[];
}
continue;
}
if (lastEvent === "messages") {
const delta = extractAiTextDelta(parsed);
if (delta) {
setPhase("streaming");
req.onTextDelta?.(delta.text, delta.id);
rawContent += delta.text;
const { rationale, picks } = parseRecommendStreamContent(rawContent);
if (rationale !== lastRationale) {
lastRationale = rationale;
req.onRationaleUpdate?.(rationale);
}
if (picks.length > lastPickCount) {
lastPickCount = picks.length;
req.onPicksUpdate?.(picks);
}
}
}
}
}
if (result.content) {
const { rationale, picks } = parseRecommendStreamContent(result.content);
if (rationale !== lastRationale) {
req.onRationaleUpdate?.(rationale);
}
if (picks.length > lastPickCount) {
req.onPicksUpdate?.(picks);
}
}
return result;
}
@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
/** The thread that owns the artifact. Both fetch and download URLs use it. */
threadId: string;
/** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */
path: string;
/** Bare filename — shown in the modal header. */
name: string;
/** Mime type from the listing; drives the renderer choice. */
mimeType: string | null;
onClose: () => void;
}
const MARKDOWN_COMPONENTS = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-3 my-2 overflow-x-auto text-[12px] font-mono">
<code>{children}</code>
</pre>
);
}
return <code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[12px] font-mono">{children}</code>;
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
hr: () => <hr className="my-3 border-slate-200" />,
};
function isMarkdown(mimeType: string | null, name: string): boolean {
if (mimeType === "text/markdown") return true;
return /\.(md|markdown)$/i.test(name);
}
export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
threadId,
path,
name,
mimeType,
onClose,
}) => {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Esc closes the modal. Attached once per mount.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Fetch the artifact body via the same URL the original download link used.
// The GET endpoint already returns text/* as decoded UTF-8, so res.text()
// gives us a renderable string directly.
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
setLoading(true);
setError(null);
setContent(null);
(async () => {
try {
const url = buildArtifactDownloadUrl(threadId, path);
const res = await apiFetch(url, { method: "GET", signal: ctrl.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const text = await res.text();
if (!cancelled) setContent(text);
} catch (err) {
if (cancelled || (err as Error).name === "AbortError") return;
setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
ctrl.abort();
};
}, [threadId, path]);
const handleDownload = useCallback(() => {
if (typeof window === "undefined") return;
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
window.open(url, "_blank", "noopener,noreferrer");
}, [threadId, path]);
const renderAsMarkdown = isMarkdown(mimeType, name);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-6"
onMouseDown={(e) => {
// Only close on outer-overlay click — clicks inside the modal panel
// bubble up here too, so check the target.
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 px-5 py-3.5 border-b border-slate-150 flex items-center justify-between gap-3 bg-gradient-to-r from-slate-50 to-white">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-primary shrink-0" />
<span className="text-sm font-bold text-slate-800 truncate" title={name}>{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={handleDownload}
className="bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 text-[11px] font-bold px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition"
title="下载原文件"
>
<Download className="w-3 h-3" />
下载
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 text-slate-500 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs">正在加载文件内容…</span>
</div>
) : error ? (
<div className="text-rose-600 text-xs py-6 text-center">
加载失败:{error}
</div>
) : content === null ? null : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
) : (
<pre className="whitespace-pre-wrap break-words text-xs text-slate-700 font-mono">{content}</pre>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,477 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
CheckCircle,
ChevronDown,
ChevronRight,
Download,
Eye,
FileText,
Loader2,
Printer,
RefreshCw,
} from "lucide-react";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
* images, html) keep the original window.open behaviour so the browser's
* native viewer handles them.
*/
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
return true;
}
if (mime.startsWith("text/")) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
}
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
sender: string;
/** Avatar color hint inherited from the dialogue card. Unused for layout right now. */
avatarType: string;
/** The seat's final markdown delivery from Step 2. */
content: string;
}
interface HighFidelityReportProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */
consensusText: string;
/** True once leader.dispatched === [] was observed (natural convergence). */
hasConsensus: boolean;
/** Each sub-agent's final delivery, filtered out of step2 dialogues. */
seatDeliveries: SeatDelivery[];
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
seatNameMap: Record<string, string>;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
}
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatGeneratedAt(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadIds,
coordinatorName,
consensusText,
hasConsensus,
seatDeliveries,
seatNameMap,
onReset,
}) => {
// Build the per-seat list (excludes the coordinator). useMemo so the file
// fetch effect doesn't re-fire on every parent render.
const seatThreadEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
// Map sender display name → its delivery content for the accordion rows.
// Built once per seatDeliveries change; later entries with the same sender
// win so the *latest* delivery wins.
const deliveryByDisplay = useMemo(() => {
const m = new Map<string, string>();
for (const d of seatDeliveries) m.set(d.sender, d.content);
return m;
}, [seatDeliveries]);
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
const [reloadKey, setReloadKey] = useState(0);
// Active preview target — non-null means the modal is open. We carry the
// owning threadId so the modal can fetch + build download URLs without
// needing a second prop drill.
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
// Fetch artifact listings for every seat thread in parallel. Re-runs when
// the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set
// changes (rare — only on a true re-init).
useEffect(() => {
if (seatThreadEntries.length === 0) {
setArtifactsBySeat([]);
return;
}
const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({
...s,
files: [],
loading: true,
error: null,
}));
setArtifactsBySeat(initial);
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatThreadEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setArtifactsBySeat(
results.map(({ seat, files, error }) => ({
...seat,
files,
loading: false,
error,
})),
);
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatThreadEntries, reloadKey]);
const totalFileCount = useMemo(
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
[artifactsBySeat],
);
const toggleSeat = useCallback((key: string) => {
setExpandedSeats((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const handlePreview = useCallback(
(threadId: string, file: SessionArtifactFile) => {
// Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
// in our in-app modal so the user can read the content without leaving
// the planning page. Binary or active content (pdf / png / html) still
// hands off to the browser's native viewer via the original tab.
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
const url = buildArtifactDownloadUrl(threadId, file.path);
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
<div className="flex flex-wrap items-center gap-2.5">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-emerald-500" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{hasConsensus && (
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm">
● 已达成共识
</span>
)}
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg">
生成时间:{generatedAt}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (typeof window !== "undefined") window.print();
}}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
>
<Printer className="w-3.5 h-3.5 text-slate-500" />
打印 / 另存 PDF
</button>
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
刷新文件清单
</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
总控最终结论
</h4>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
)}
</section>
{/* Section 2: Per-seat deliveries (collapsible) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{seatDeliveries.length}
</span>
</h4>
{seatDeliveries.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
</p>
) : (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const key = `${seat.sender}-${idx}`;
const expanded = expandedSeats.has(key);
return (
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggleSeat(key)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{expanded && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Section 3: Generated files per seat */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{totalFileCount}
</span>
</h4>
{artifactsBySeat.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">
读取失败:{seat.error}
</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
该席位未产生输出文件
</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li
key={f.path}
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">
{formatBytes(f.sizeBytes)}
</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</section>
{/* Footer */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
<span className="text-xs text-slate-400 font-medium">
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
</span>
<button
type="button"
onClick={onReset}
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
>
重新起草新规划
</button>
</div>
</div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</div>
);
};
@@ -0,0 +1,217 @@
import { Check } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type IntentClarificationSelection = {
selectedIds: string[];
custom: string;
};
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
function normalizeOptions(raw: unknown[]): ClarificationOption[] {
return raw
.map((opt, index) => {
if (typeof opt === "string") {
const label = opt.trim();
return label ? { id: `option-${index}`, label } : null;
}
if (opt && typeof opt === "object" && "label" in opt) {
const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
const label = String(obj.label ?? "").trim();
if (!label) return null;
const id = String(obj.id ?? label).trim() || `option-${index}`;
const description =
typeof obj.description === "string" && obj.description.trim()
? obj.description.trim()
: undefined;
return { id, label, description };
}
return null;
})
.filter((opt): opt is ClarificationOption => opt !== null);
}
export function formatIntentClarificationAnswer(
payload: ClarificationPayload,
selection: IntentClarificationSelection,
): string | null {
const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
const selectedLabels = selection.selectedIds
.map((sid) => options.find((option) => option.id === sid)?.label)
.filter((label): label is string => Boolean(label && label.trim()));
const trimmedCustom = selection.custom.trim();
if (selectedLabels.length > 0 && trimmedCustom) {
return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
}
if (selectedLabels.length > 0) {
return `我选择:${selectedLabels.join("、")}`;
}
if (trimmedCustom) {
return trimmedCustom;
}
return null;
}
interface IntentClarificationCardProps {
payload: ClarificationPayload;
fallbackContent?: string;
selection: IntentClarificationSelection;
onSelectionChange: (next: IntentClarificationSelection) => void;
disabled?: boolean;
submitting?: boolean;
/** Multi-select: submit current selection. Single-select: also used for custom text. */
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
}
export function IntentClarificationCard({
payload,
fallbackContent,
selection,
onSelectionChange,
disabled = false,
submitting = false,
onSubmit,
}: IntentClarificationCardProps) {
const options = useMemo(
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
[payload.options],
);
const allowMultiple = resolveAllowMultiple(
payload.allowMultiple,
payload.clarificationType,
);
const canSubmit = useMemo(() => {
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
}, [selection.custom, selection.selectedIds]);
const showSubmitButton = allowMultiple || payload.allowCustom !== false;
function handleOptionClick(optionId: string) {
if (disabled || submitting) return;
if (!allowMultiple) {
onSubmit({ selectedIds: [optionId], custom: "" });
return;
}
const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId];
onSelectionChange({ ...selection, selectedIds: next });
}
function updateCustom(value: string) {
if (disabled || submitting) return;
onSelectionChange({ ...selection, custom: value });
}
return (
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
需要你的确认
</span>
{payload.clarificationType ? (
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
{payload.clarificationType}
</span>
) : null}
</div>
{payload.context ? (
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
) : null}
<div className="text-xs font-medium leading-relaxed text-slate-800">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-3 grid gap-2">
<div className="text-[10px] text-slate-500">
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const isSelected = selection.selectedIds.includes(option.id);
return (
<button
key={option.id}
type="button"
disabled={disabled || submitting}
onClick={() => handleOptionClick(option.id)}
className={cn(
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
isSelected && allowMultiple
? "border-primary bg-primary text-white shadow-sm"
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
(disabled || submitting) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span className="text-[11px] font-bold">{option.label}</span>
{option.description ? (
<span
className={cn(
"text-[10px] leading-snug",
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected && allowMultiple ? (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
) : null}
</button>
);
})}
</div>
) : null}
{payload.allowCustom !== false ? (
<div className="mt-3 flex flex-col gap-2">
<Textarea
value={selection.custom}
onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder={
options.length > 0
? allowMultiple
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled || submitting}
className="min-h-16 resize-none border-slate-200 text-xs"
/>
</div>
) : null}
{showSubmitButton ? (
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || submitting || !canSubmit}
onClick={() => onSubmit()}
className="h-8 rounded-xl px-4 text-xs font-bold"
>
{submitting ? "发送中…" : "发送回答"}
</Button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,614 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { listAgents, type Agent } from "@/core/agents";
import { cn } from "@/lib/utils";
import type { IntentSummary } from "../api/intent";
import {
filterRecommendCandidates,
parseRecommendStreamContent,
streamRecommend,
type RecommendCandidate,
type RecommendPick,
type RecommendStreamPhase,
} from "../api/recommend";
/**
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
* (roundtable). Opens with a streaming recommendation from
* `roundtable-recommender`, defaults its picks to checked, and lets the user
* trim or extend the selection from the rest of the agent pool before
* confirming.
*
* Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the
* backend also clamps but UI feedback is faster here.
*/
export interface RecommendAgentsDialogProps {
open: boolean;
intent: IntentSummary | null;
/** Falls back to the recommender's own default if omitted. */
model?: string;
onCancel: () => void;
/** Called with the user's final selection of agent ids (in display order). */
onConfirm: (agentIds: string[]) => void;
}
const MIN_SELECTED = 2;
const MAX_SELECTED = 8;
/**
* Default selection when the recommender fails or returns < 2 valid picks.
* Intersected with the live candidate pool at runtime so missing built-ins
* don't break the dialog.
*/
const FALLBACK_AGENT_IDS = [
"roundtable-intelligence",
"roundtable-environment",
"roundtable-solution-design",
"roundtable-risk-review",
"roundtable-execution-plan",
"roundtable-summary",
];
type DialogPhase =
| "idle"
| "loading_agents"
| "recommending"
| "ready"
| "fallback"
| "error";
interface InternalState {
phase: DialogPhase;
/** Streaming + final rationale text from the recommender. */
rationale: string;
/** Streaming SSE phase from the recommender helper. */
streamPhase: RecommendStreamPhase | null;
/** Pre-filtered candidate pool fetched from `/api/agents`. */
candidates: RecommendCandidate[];
/** Reason-by-agent_id, only for picks returned by the recommender. */
reasons: Record<string, string>;
/** Agent ids the recommender returned (in order). */
recommendedIds: string[];
/** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */
warning: string | null;
/** Latest error message; only shown when `phase === "error"`. */
errorMessage: string | null;
}
const INITIAL_STATE: InternalState = {
phase: "idle",
rationale: "",
streamPhase: null,
candidates: [],
reasons: {},
recommendedIds: [],
warning: null,
errorMessage: null,
};
/**
* Resolve which candidate ids should be checked by default given a set of
* recommended ids. The result preserves recommendation order and silently
* drops ids that aren't in the candidate pool.
*/
function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] {
const poolIds = new Set(pool.map((c) => c.agent_id));
const seen = new Set<string>();
const out: string[] = [];
for (const id of preferred) {
if (!poolIds.has(id) || seen.has(id)) continue;
out.push(id);
seen.add(id);
}
return out;
}
export function RecommendAgentsDialog({
open,
intent,
model,
onCancel,
onConfirm,
}: RecommendAgentsDialogProps) {
const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Latest stream abort controller, so closing or re-running cancels the
// in-flight request instead of leaking it.
const abortRef = useRef<AbortController | null>(null);
// Generation token guards against stale completion handlers writing into
// state after a re-run (e.g. user clicks "重新推荐" mid-stream).
const generationRef = useRef(0);
const cancelInflight = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
}, []);
/**
* The full pipeline: fetch agents → run recommendation → seed selection.
* Wrapped so the user can re-trigger it via the "重新推荐" button.
*/
const runRecommendation = useCallback(async () => {
if (!intent || !intent.objective) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "缺少任务目标,无法启动推荐",
});
return;
}
cancelInflight();
const myGen = ++generationRef.current;
const controller = new AbortController();
abortRef.current = controller;
setState({ ...INITIAL_STATE, phase: "loading_agents" });
setSelectedIds(new Set());
let agents: Agent[];
try {
agents = await listAgents();
} catch (err) {
if (myGen !== generationRef.current) return;
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
});
return;
}
if (myGen !== generationRef.current) return;
const candidates = filterRecommendCandidates(agents);
if (candidates.length === 0) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
});
return;
}
setState((prev) => ({
...prev,
phase: "recommending",
candidates,
rationale: "",
streamPhase: "connecting",
}));
try {
const result = await streamRecommend({
intent,
candidates,
model,
signal: controller.signal,
onRationaleUpdate: (rationale) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale }));
},
onPicksUpdate: (picks) => {
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of seed) next.add(id);
return next;
});
setState((prev) => ({
...prev,
recommendedIds,
reasons,
}));
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, streamPhase: phase }));
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
if (result.picks) {
for (const p of result.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
}
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
...prev,
phase: "ready",
rationale: rationale || prev.rationale,
recommendedIds,
reasons,
streamPhase: null,
warning: null,
}));
return;
}
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
const seed = fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
...prev,
phase: "fallback",
rationale: rationale || prev.rationale,
recommendedIds: seed,
reasons,
streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
}));
} catch (err) {
if (myGen !== generationRef.current) return;
// AbortError fires when the user closes the dialog mid-stream — no
// state update needed because the dialog is going away.
if (err instanceof DOMException && err.name === "AbortError") return;
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
}));
}
}, [intent, model, cancelInflight]);
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
// in-flight stream so the next open starts clean.
useEffect(() => {
if (!open) {
cancelInflight();
return;
}
void runRecommendation();
return () => {
cancelInflight();
};
}, [open, runRecommendation, cancelInflight]);
const handleToggle = useCallback((agentId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(agentId)) next.delete(agentId);
else next.add(agentId);
return next;
});
}, []);
// Split candidates into "recommended" and "other" — order within each
// group matches the recommender's pick order / the API's list order so the
// dialog is deterministic between renders.
const { recommendedCards, otherCards } = useMemo(() => {
const recSet = new Set(state.recommendedIds);
const byId = new Map(state.candidates.map((c) => [c.agent_id, c]));
const recommended: RecommendCandidate[] = [];
for (const id of state.recommendedIds) {
const c = byId.get(id);
if (c) recommended.push(c);
}
const others = state.candidates.filter((c) => !recSet.has(c.agent_id));
return { recommendedCards: recommended, otherCards: others };
}, [state.candidates, state.recommendedIds]);
const selectedCount = selectedIds.size;
const canConfirm =
(state.phase === "ready" || state.phase === "fallback") &&
selectedCount >= MIN_SELECTED &&
selectedCount <= MAX_SELECTED;
const selectionHint = (() => {
if (selectedCount < MIN_SELECTED) {
return `至少再选 ${MIN_SELECTED - selectedCount} 个`;
}
if (selectedCount > MAX_SELECTED) {
return `已超出上限 ${selectedCount - MAX_SELECTED} 个`;
}
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
})();
const handleConfirm = useCallback(() => {
if (!canConfirm) return;
// Order: recommended-and-selected first (in recommender order), then
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
// list visually stable.
const ordered: string[] = [];
for (const c of recommendedCards) {
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
}
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
}
onConfirm(ordered);
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) onCancel();
},
[onCancel],
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
选择参会智能体
</DialogTitle>
<DialogDescription>
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
</DialogDescription>
</DialogHeader>
{/* Rationale + status block */}
<div className="border rounded-md bg-muted/30 p-3 text-sm">
<RationaleBlock state={state} />
</div>
{/* Selection area: scrollable, takes remaining height */}
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
{state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
</div>
) : state.phase === "recommending" ? (
<>
{recommendedCards.length > 0 ? (
<Section title={`推荐参会 (${recommendedCards.length})`}>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={state.reasons[c.agent_id]}
recommended
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
streaming
/>
))}
</Section>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
<Loader2Icon className="size-3 animate-spin" />
等待推荐结果...
</div>
)}
</>
) : (state.phase === "ready" || state.phase === "fallback") ? (
<>
{state.warning && (
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
{state.warning}
</div>
)}
{recommendedCards.length > 0 && (
<Section title={`推荐参会 (${recommendedCards.length})`}>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={state.reasons[c.agent_id]}
recommended
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
/>
))}
</Section>
)}
{otherCards.length > 0 && (
<Section title={`其他候选 (${otherCards.length})`}>
{otherCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={undefined}
recommended={false}
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
/>
))}
</Section>
)}
</>
) : null}
</div>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 text-xs">
<span
className={cn(
"tabular-nums",
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
? "text-destructive"
: "text-muted-foreground",
)}
>
{selectionHint}
</span>
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
<Button
variant="ghost"
size="sm"
onClick={() => void runRecommendation()}
className="h-7 px-2 text-xs"
>
<RefreshCwIcon className="size-3 mr-1" />
重新推荐
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
进入研讨
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RationaleBlock({ state }: { state: InternalState }) {
if (state.phase === "loading_agents") {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
</div>
);
}
if (state.phase === "recommending") {
const label =
state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐...";
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
{label}
</div>
{state.rationale ? (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
{state.rationale}
</p>
) : (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
</div>
);
}
if (state.phase === "ready" || state.phase === "fallback") {
if (!state.rationale) {
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
}
return (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
{state.rationale}
</p>
);
}
if (state.phase === "error") {
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
}
return null;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</div>
<div className="grid gap-2">{children}</div>
</div>
);
}
interface AgentCardProps {
candidate: RecommendCandidate;
reason: string | undefined;
recommended: boolean;
checked: boolean;
onToggle: () => void;
/** Card is still being filled in from the SSE stream. */
streaming?: boolean;
}
function AgentCard({
candidate,
reason,
recommended,
checked,
onToggle,
streaming = false,
}: AgentCardProps) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
checked
? "border-primary bg-primary/5 hover:bg-primary/10"
: "border-border hover:bg-muted/40",
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
<span
className={cn(
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
)}
>
{checked && <CheckIcon className="size-3" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
{recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
推荐
</span>
)}
</div>
{candidate.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{candidate.description}
</div>
)}
{reason ? (
<div className="text-xs text-primary/80 mt-1 italic">
<span className="font-medium not-italic">推荐理由:</span> {reason}
</div>
) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
) : null}
</div>
</button>
);
}
@@ -0,0 +1,35 @@
import React from "react";
import { ChevronDown } from "lucide-react";
interface ScrollToBottomButtonProps {
onClick: () => void;
/** Label shown next to the icon. Defaults to "回到底部". */
label?: string;
/** Tailwind classes appended to the default positioning. */
className?: string;
}
/**
* Pill-shaped floating button shown when the user has scrolled up away from
* the bottom of a streamed conversation. Click → smooth-scroll back to the
* bottom and resume auto-follow.
*
* Positioning is `absolute` — render inside a `relative` wrapper so it
* floats over the scroll container's lower-center without scrolling along
* with content.
*/
export const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({
onClick,
label = "回到底部",
className = "",
}) => (
<button
type="button"
onClick={onClick}
className={`absolute left-1/2 -translate-x-1/2 bottom-3 z-10 bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-[11px] font-bold px-3 py-1.5 rounded-full shadow-lg shadow-slate-900/10 flex items-center gap-1 transition active:scale-95 ${className}`}
aria-label={label}
>
<ChevronDown className="w-3.5 h-3.5" />
{label}
</button>
);
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, useState, type DependencyList, type RefObject } from "react";
/**
* Auto-scroll a scrollable container while the user is at (or near) the
* bottom; stop following as soon as the user scrolls up, and resume when
* the user clicks the floating "回到底部" button (which calls `scrollToBottom`).
*
* Why a custom hook: the page has two streamed-message lists (Step 1 chat,
* Step 2 roundtable) with identical scroll-follow semantics, and reading
* `scrollHeight` after every state change inside the component would mean
* duplicating the scroll listener / auto-scroll / pause logic per list.
*/
export function useAutoScroll<T extends HTMLElement>(
ref: RefObject<T | null>,
deps: DependencyList,
options: {
/** How close to the bottom (px) still counts as "at bottom". */
threshold?: number;
} = {},
): {
/** True when the user is at the bottom — drives the button's visibility. */
atBottom: boolean;
/** Force-scroll to the bottom and resume auto-follow. */
scrollToBottom: () => void;
} {
const threshold = options.threshold ?? 32;
// Ref mirrors state so the deps-effect can read the latest value without
// re-subscribing when atBottom flips. State is for the button's render.
const atBottomRef = useRef(true);
const [atBottom, setAtBottom] = useState(true);
// Track scroll position. The handler runs for BOTH user-initiated scrolls
// and programmatic `scrollTo` calls — after a programmatic scroll to the
// bottom, the resulting onScroll naturally leaves atBottom=true, so the
// auto-follow effect can keep firing on subsequent deps changes.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
if (atBottomRef.current !== isAtBottom) {
atBottomRef.current = isAtBottom;
setAtBottom(isAtBottom);
}
};
el.addEventListener("scroll", onScroll, { passive: true });
// Initial measurement so we don't start in a stale state if the container
// mounted with content already overflowing.
onScroll();
return () => el.removeEventListener("scroll", onScroll);
}, [ref, threshold]);
// Auto-scroll on deps change — but only if the user hasn't scrolled away.
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!atBottomRef.current) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// Optimistically mark as at-bottom so the next deps change resumes
// auto-follow without waiting for the smooth-scroll's onScroll to land.
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
return { atBottom, scrollToBottom };
}
@@ -0,0 +1,10 @@
/**
* 圆桌规划(Roundtable Planning)模块入口。
*
* 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。
* 这里只暴露两个外部使用的组件:
* - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。
* - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。
*/
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
export { HighFidelityReport } from "./components/HighFidelityReport";
@@ -0,0 +1,13 @@
/**
* Resolve whether option cards should allow multi-select.
* Default is single-select (click to send); only multi when explicitly requested.
*/
export function resolveAllowMultiple(
allowMultiple: boolean | undefined,
_clarificationType?: string | undefined,
): boolean {
if (typeof allowMultiple === "boolean") {
return allowMultiple;
}
return false;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,217 @@
@import "tailwindcss";
/* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */
.roundtable-planning {
--rt-primary: var(--workspace-brand, #f5222d);
--rt-primary-hover: var(--brand-900, #cf1322);
--rt-primary-active: var(--brand-950, #a8071a);
--rt-primary-soft: var(--brand-200, #feeded);
--rt-page-bg: #fef6f6;
}
.roundtable-planning ::-webkit-scrollbar-thumb {
background: #fcb5b9;
}
.roundtable-planning ::-webkit-scrollbar-thumb:hover {
background: #fa9196;
}
.roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: #fddadc;
}
/* 深色模式:跟随全局 ThemeContext(html.dark)或组件 .roundtable-planning--dark */
.dark .roundtable-planning,
.roundtable-planning--dark {
--rt-page-bg: var(--background);
--rt-primary: var(--primary);
--rt-primary-hover: var(--primary);
--rt-primary-soft: var(--accent);
color: var(--foreground);
background-color: var(--background);
}
.dark .roundtable-planning ::-webkit-scrollbar-thumb,
.roundtable-planning--dark ::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 45%, var(--border));
}
.dark .roundtable-planning ::-webkit-scrollbar-thumb:hover,
.roundtable-planning--dark ::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--primary) 65%, var(--border));
}
.dark .roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb,
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 35%, transparent);
}
/* 页面底与浅色专用背景 */
.dark .roundtable-planning.bg-\[\#fef6f6\],
.roundtable-planning--dark.bg-\[\#fef6f6\] {
background-color: var(--background) !important;
}
/* 表面:白底卡片 → card */
.dark .roundtable-planning .bg-white,
.roundtable-planning--dark .bg-white {
background-color: var(--card) !important;
}
.dark .roundtable-planning .bg-slate-50,
.dark .roundtable-planning .bg-slate-50\/20,
.dark .roundtable-planning .bg-slate-50\/30,
.roundtable-planning--dark .bg-slate-50,
.roundtable-planning--dark .bg-slate-50\/20,
.roundtable-planning--dark .bg-slate-50\/30 {
background-color: color-mix(in oklch, var(--muted) 80%, transparent) !important;
}
.dark .roundtable-planning .from-slate-50,
.roundtable-planning--dark .from-slate-50 {
--tw-gradient-from: var(--muted) !important;
}
.dark .roundtable-planning .to-white,
.roundtable-planning--dark .to-white {
--tw-gradient-to: var(--card) !important;
}
/* 品牌浅红底 → accent */
.dark .roundtable-planning [class*="brand-200"],
.roundtable-planning--dark [class*="brand-200"] {
background-color: color-mix(in oklch, var(--accent) 55%, transparent) !important;
}
.dark .roundtable-planning .border-red-100,
.dark .roundtable-planning .border-red-100\/50,
.dark .roundtable-planning .border-red-200,
.dark .roundtable-planning .border-red-200\/50,
.dark .roundtable-planning .border-red-200\/85,
.roundtable-planning--dark .border-red-100,
.roundtable-planning--dark .border-red-100\/50,
.roundtable-planning--dark .border-red-200,
.roundtable-planning--dark .border-red-200\/50,
.roundtable-planning--dark .border-red-200\/85 {
border-color: var(--border) !important;
}
.dark .roundtable-planning .border-slate-100,
.dark .roundtable-planning .border-slate-200,
.dark .roundtable-planning .border-slate-200\/50,
.dark .roundtable-planning .border-\[\#e2e8f0\],
.roundtable-planning--dark .border-slate-100,
.roundtable-planning--dark .border-slate-200,
.roundtable-planning--dark .border-slate-200\/50,
.roundtable-planning--dark .border-\[\#e2e8f0\] {
border-color: var(--border) !important;
}
/* 文字 */
.dark .roundtable-planning .text-slate-900,
.dark .roundtable-planning .text-slate-800,
.dark .roundtable-planning .text-slate-700,
.dark .roundtable-planning .text-\[\#334155\],
.roundtable-planning--dark .text-slate-900,
.roundtable-planning--dark .text-slate-800,
.roundtable-planning--dark .text-slate-700,
.roundtable-planning--dark .text-\[\#334155\] {
color: var(--foreground) !important;
}
.dark .roundtable-planning .text-slate-600,
.dark .roundtable-planning .text-slate-500,
.dark .roundtable-planning .text-slate-400,
.roundtable-planning--dark .text-slate-600,
.roundtable-planning--dark .text-slate-500,
.roundtable-planning--dark .text-slate-400 {
color: var(--muted-foreground) !important;
}
.dark .roundtable-planning .hover\:text-slate-800:hover,
.roundtable-planning--dark .hover\:text-slate-800:hover {
color: var(--foreground) !important;
}
.dark .roundtable-planning .hover\:bg-slate-50:hover,
.roundtable-planning--dark .hover\:bg-slate-50:hover {
background-color: var(--accent) !important;
}
/* 顶栏 */
.dark .roundtable-planning > header,
.roundtable-planning--dark > header {
background-color: var(--card) !important;
border-color: var(--border) !important;
box-shadow: none !important;
}
/* 步骤条容器 */
.dark .roundtable-planning header .rounded-2xl.border,
.roundtable-planning--dark header .rounded-2xl.border {
background-color: var(--muted) !important;
border-color: var(--border) !important;
}
/* 次要按钮(白底) */
.dark .roundtable-planning header button.bg-white,
.roundtable-planning--dark header button.bg-white {
background-color: var(--secondary) !important;
color: var(--foreground) !important;
}
/* 输入区 focus ring */
.dark .roundtable-planning .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within,
.roundtable-planning--dark .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within {
--tw-ring-color: color-mix(in oklch, var(--primary) 25%, transparent) !important;
}
/* 渐变横幅 */
.dark .roundtable-planning .from-\[var\(--brand-200\)\],
.roundtable-planning--dark .from-\[var\(--brand-200\)\] {
--tw-gradient-from: color-mix(in oklch, var(--accent) 40%, transparent) !important;
}
.dark .roundtable-planning .border-primary,
.roundtable-planning--dark .border-2.border-primary {
border-color: color-mix(in oklch, var(--primary) 50%, var(--border)) !important;
}
/* 分隔线 */
.dark .roundtable-planning .bg-slate-200,
.roundtable-planning--dark .bg-slate-200 {
background-color: var(--border) !important;
}
/* Custom elegant scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e2e8f0;
}
.custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: #fddadc;
}
.dark .custom-scrollbar-inner::-webkit-scrollbar-thumb,
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 35%, var(--border));
}
@@ -0,0 +1,195 @@
/**
* Local-only draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
* it; cross-device sync is out of scope (would need a backend). The Step 2
* back-end LangGraph threads are reused on load, so list/preview/download of
* artifacts in Step 3 continue to work as long as the user hasn't deleted the
* threads server-side.
*/
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
const STORAGE_KEY = "roundtable-planning:drafts:v1";
/**
* Snapshot of every piece of UI state needed to resume a session. We capture
* shapes loosely (`unknown[]`) for the chat / role lists because their inline
* types in the page component are wide and would force a refactor we don't
* want as part of the persistence feature.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
}
export interface DraftRecord {
id: string;
/** Auto-derived task summary (objective → first user message → timestamp). */
name: string;
createdAt: string;
updatedAt: string;
/** Furthest step the user had reached when this snapshot was taken. */
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
}
function isBrowser(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readAll(): DraftRecord[] {
if (!isBrowser()) return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as DraftRecord[];
} catch (err) {
console.warn("[drafts] failed to parse localStorage payload:", err);
return [];
}
}
function writeAll(records: DraftRecord[]): void {
if (!isBrowser()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
} catch (err) {
// Quota errors are the realistic failure mode — surface but don't throw,
// since the user can keep working with their in-memory state.
console.warn("[drafts] failed to write localStorage payload:", err);
}
}
function newId(): string {
if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** List drafts newest first. */
export function listDrafts(): DraftRecord[] {
return readAll()
.slice()
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
}
export function getDraft(id: string): DraftRecord | null {
return readAll().find((d) => d.id === id) ?? null;
}
/**
* Create-or-update a draft. If `id` is given and exists, that record is
* overwritten (updatedAt refreshed). Otherwise a new record is inserted.
* Returns the (re-)saved record so callers can read back the new id.
*/
export function upsertDraft(
payload: Omit<DraftRecord, "createdAt" | "updatedAt" | "id"> & { id?: string },
): DraftRecord {
const records = readAll();
const now = new Date().toISOString();
if (payload.id) {
const idx = records.findIndex((d) => d.id === payload.id);
if (idx >= 0) {
const existing = records[idx];
const updated: DraftRecord = {
...existing,
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
updatedAt: now,
};
records[idx] = updated;
writeAll(records);
return updated;
}
}
const created: DraftRecord = {
id: payload.id ?? newId(),
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
createdAt: now,
updatedAt: now,
};
records.push(created);
writeAll(records);
return created;
}
export function deleteDraft(id: string): void {
const records = readAll();
const next = records.filter((d) => d.id !== id);
if (next.length !== records.length) writeAll(next);
}
/**
* Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
* updated record so callers can refresh local UI state.
*/
export function renameDraft(id: string, name: string): DraftRecord | null {
const trimmed = name.trim();
if (!trimmed) return null;
const records = readAll();
const idx = records.findIndex((d) => d.id === id);
if (idx < 0) return null;
const updated: DraftRecord = {
...records[idx],
name: trimmed,
updatedAt: new Date().toISOString(),
};
records[idx] = updated;
writeAll(records);
return updated;
}
/**
* Derive a short task-summary name from whatever the user has supplied so far.
* Preference order: intent agent's structured objective → first user message
* in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
* the dropdown rows stay scannable.
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}
+119 -69
View File
@@ -1,12 +1,24 @@
import React, { useState } from 'react';
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
import { NotificationDialog } from '@/components/workspace/notification-dialog';
import { useTheme } from '../contexts/ThemeContext';
import { useUnreadCount } from '@/core/notifications';
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
import { useState } from "react";
const Header: React.FC = () => {
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { Button } from "@/components/ui/button";
import { useUnreadCount } from "@/core/notifications";
import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
const iconButtonClass = (isDark: boolean) =>
cn(
"size-8 shrink-0 rounded-sm",
isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
const Header = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
@@ -16,72 +28,110 @@ const Header: React.FC = () => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
isDark
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
: 'bg-red-600 border-b border-red-700'
}`}>
{/* 左侧 - 搜索框 */}
<div className="flex items-center gap-4">
<header
className={cn(
"theme-transition flex h-16 items-center justify-between border-b px-4",
isDark
? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggleTheme}
className={iconButtonClass(isDark)}
title={isDark ? "切换到红白主题" : "切换到深色主题"}
>
{isDark ? (
<Palette className="size-4" />
) : (
<Moon className="size-4" />
)}
</Button>
{/* 右侧 - 用户信息和通知 */}
<div className="flex items-center gap-4">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className={`p-2 rounded-lg transition-all duration-200 ${
isDark
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
: 'hover:bg-red-500 text-white'
}`}
title={isDark ? '切换到红白主题' : '切换到深色主题'}
>
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setNotificationOpen(true)}
className={cn(iconButtonClass(isDark), "relative")}
title="通知"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
isDark
? "bg-cyan-500 text-white"
: "bg-white tabular-nums text-[var(--brand-900)]",
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
{/* 通知 */}
<button
type="button"
onClick={() => setNotificationOpen(true)}
className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}
title="通知"
>
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
{unreadCount > 0 && (
<span
className={`absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? 'bg-cyan-500' : 'bg-white text-red-600'
}`}
<div
className={cn(
"mx-1 h-8 w-px",
isDark ? "bg-blue-800/30" : "bg-white/30",
)}
aria-hidden
/>
<Button
type="button"
variant="ghost"
className={cn(
"h-8 gap-2 rounded-sm px-2",
isDark
? "text-slate-200 hover:bg-slate-800/50"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-sm",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white",
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* 分隔线 */}
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
{/* 用户信息 */}
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white'
}`}>
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">张策略</p>
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>策略分析师</p>
</div>
<ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} />
</button>
</div>
</header>
<User
className={cn(
"size-4",
isDark ? "text-white" : "text-[var(--brand-900)]",
)}
/>
</div>
<div className="hidden text-left sm:block">
<p className="text-sm leading-tight font-medium text-white">
张策略
</p>
<p
className={cn(
"text-xs leading-tight",
isDark ? "text-blue-400/60" : "text-white/75",
)}
>
策略分析师
</p>
</div>
<ChevronDown
className={cn(
"size-4 shrink-0",
isDark ? "text-slate-500" : "text-white/80",
)}
/>
</Button>
</div>
</header>
</>
);
};
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -3,11 +3,12 @@ import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
plugins: [react()],
plugins: [sourceIdentifierPlugin(), react()],
resolve: {
alias: [
{