提交代码
This commit is contained in:
1 parent
d21dbeb214
commit
54190d7a83
8 files changed
+1205
-11
No files matched your search
@@ -0,0 +1,507 @@
|
||||
# AI 写作「对话驱动写作台」实现方案(可落地版)
|
||||
|
||||
> 需求:**复制**现有 AI 写作代码,新增一个独立页面(**原页面/原路由零改动**)。在新页面里
|
||||
> **大改交互流程**——去掉「素材收集 / 大纲规划 / 作家写作 / 编辑审核」四个阶段干预卡里的**输入框**,
|
||||
> 只**保留原来的确认按钮**;在页面**底部新增一个统一的多行智能输入框**,具备**强意图理解**能力:
|
||||
> 重新搜索、新增检索词、添加检索素材、重新规划大纲、按要求改大纲、提修改意见、审核意见、
|
||||
> 素材不足继续搜集、对写作内容提问……**覆盖现有全部交互甚至更多**。
|
||||
>
|
||||
> 交互节奏不变:**一步一确认**——进行到某个专家阶段时,交互就针对该专家(阶段作用域);
|
||||
> 用户可以**点原来的确认按钮**,也可以**在底部输入框里发「继续下一步」的意图**推进。
|
||||
> 意图**拿不准时**主动向用户**发起协助/澄清**,绝不盲目触发高代价动作(定稿 / 重搜等)。
|
||||
|
||||
本文是**实现指引**,落地时按第 9 节路线图分阶段推进。文中所有文件路径、符号名均对齐当前代码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状梳理(改造地基)
|
||||
|
||||
现有 AI 写作是一套**独立 LangGraph 子系统**(不是 lead_agent),前后端都已成型、可复用。
|
||||
|
||||
### 1.1 后端 `ai_writing` graph
|
||||
|
||||
- 图工厂:`offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py`
|
||||
(`make_ai_writing_graph`,registered as `assistant_id="ai_writing"`)
|
||||
- 状态:`.../ai_writing/state.py`(`AIWritingState`)
|
||||
- 四个「阶段大脑」节点:
|
||||
|
||||
| 阶段 | 节点 | 文件 |
|
||||
|------|------|------|
|
||||
| 素材收集 | `researcher` | `nodes/researcher.py` |
|
||||
| 大纲规划 | `writer_outline` | `nodes/writer_outline.py` |
|
||||
| 作家写作 | `writer_draft` | `nodes/writer_draft.py` |
|
||||
| 编辑审核 | `editor` | `nodes/editor.py` |
|
||||
|
||||
- **五个暂停点**(`interrupt()`,`Command(resume=payload)` 续跑)——这是对话改造的核心可复用资产:
|
||||
|
||||
| pause_point | 语义 | 现有 action |
|
||||
|-------------|------|-------------|
|
||||
| `material_confirm` | 素材确认 | `confirm` / `re_search` / `enable_skill_search` |
|
||||
| `outline_confirm` | 大纲确认 | `confirm` / `re_outline` |
|
||||
| `draft_confirm` | 草稿确认 | `to_editor` / `finalize` / `user_revise` |
|
||||
| `review_confirm` | 编辑打回确认 | `accept_review` / `force_finalize` |
|
||||
| `section_help` | 素材不足求助 | `section_help`(+`sectionDecisions`) |
|
||||
|
||||
- resume 载荷(`UserInterventionPayload`,`types/ai-writing.ts`)字段:`action`、`approvedMaterialIds`、
|
||||
`userQuery`(自然语言补充检索)、`editedOutline`、`outlineFeedback`、`userRevisionNotes`、
|
||||
`overrideVerdict`、`selectedIssueIndices`、`sectionDecisions`。
|
||||
**协议已同时支持「自然语言 + 结构化 action」**——这是对话层能复用的关键。
|
||||
|
||||
> 结论:**状态机 / 流式 / 暂停·续跑协议 / 持久化都可复用**,无需重写引擎。改造本质是
|
||||
> 「把分散在干预卡里的输入框,换成一个底部智能输入框 + 意图路由层」,再把意图翻译回**现有
|
||||
> `action + payload`**;仅少量新意图(对内容提问、素材不足自动回退重搜、追加审核意见)需新增能力。
|
||||
|
||||
### 1.2 前端 `open-canvas` 子系统
|
||||
|
||||
- 主页面:`frontend-web/src/open-canvas/pages/AIWritingPage.tsx`
|
||||
- 路由注册:`open-canvas/routes/OpenCanvasRoutes.tsx`(`/page/canvas/ai-writing/*`)、
|
||||
`pages/WorkspaceRoutes.tsx`(`/page/workspace/ai-writing/*`)、`pages/NotebookAIWritingPage.tsx`
|
||||
- 状态机 + 流式:`contexts/AIWritingContext.tsx`(`useReducer` + LangGraph `useStream`,`assistantId:'ai_writing'`)
|
||||
- 事件翻译 / reducer:`hooks/useAIWritingStream.ts`
|
||||
- 类型:`types/ai-writing.ts`
|
||||
- **左栏面板**:`components/ai-writing/AIWritingPanel.tsx`(时间线 + 干预卡 + 状态栏;idle 时挂启动表单)
|
||||
- **要改造的输入 UI**(本次重点):
|
||||
- 干预卡 `components/ai-writing/InterventionCard.tsx`:内含
|
||||
`MaterialConfirmCard`(素材:有 `MaterialsToolbar` 输入框)、
|
||||
`OutlineConfirmCard`(大纲:有 feedback textarea)、
|
||||
`DraftConfirmCard`(草稿:有「修改意见」textarea)、
|
||||
`ReviewConfirmCard`(审核:有「补充修改指令」textarea)、
|
||||
`SectionHelpCard`(素材不足:按钮组)。
|
||||
**每张卡里都既有输入框又有确认/动作按钮**——本次要**去掉输入框、保留按钮**。
|
||||
- 启动表单 `components/ai-writing/AIWritingForm.tsx`(idle 阶段的配置表单)——本次由底部输入框接管启动。
|
||||
- **已存在的对话原型**(可借鉴):`components/ai-writing/WritingSetupChat.tsx`
|
||||
(`useThreadStream` 起 lead_agent 线程 + `setup_writing` 审批卡回填表单;仅管启动前配置,不直接复用)。
|
||||
- 底部输入框可直接复用 UI 组件:`@/components/ai-elements/prompt-input`
|
||||
(`PromptInput` / `PromptInputTextarea` / `PromptInputSubmit` / `PromptInputFooter`,见 `WritingSetupChat`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与范围
|
||||
|
||||
### 2.1 必须满足
|
||||
|
||||
1. **复制式新增**:新目录 / 新路由 / 新侧边栏入口,**旧页面、旧路由、旧组件零改动**(「初始的不用动」)。
|
||||
2. 页面**底部单一多行智能输入框**接管全流程交互;四个阶段干预卡里的**输入框全部移除**。
|
||||
3. **保留确认按钮**:每个阶段到达暂停点时,消息流里仍渲染**产物卡 + 原来的确认/动作按钮**
|
||||
(素材「确认素材」、大纲「确认大纲」、草稿「让编辑审核 / 确认定稿」、审核「按编辑意见修改 / 强制定稿」、
|
||||
素材不足的三选一按钮)。用户**点按钮**或**在底部输入框发自然语言意图**二选一均可推进。
|
||||
4. **强意图理解**:把用户自由文本准确路由到正确动作,**覆盖现有全部交互 + 更多**(见 3.2)。
|
||||
5. **阶段作用域**:意图解析必须知道「当前停在哪个专家阶段」,把同一句话按当前阶段翻译成对应 action。
|
||||
6. **素材不足自动回退重搜**:修改意见需要的信息现有素材没有时,先重搜补素材、补完自动继续原意图。
|
||||
7. **拿不准就协助**:低置信意图**不猜着执行**,走澄清追问(clarify)或用户协助。
|
||||
8. 保留右侧草稿实时预览(可编辑)与时间线 / 进度展示。
|
||||
|
||||
### 2.2 明确不做(本期)
|
||||
|
||||
- 不改旧 `AIWritingPage` 及其 `AIWritingForm` / `InterventionCard`(这两个是要在**副本**里改的)。
|
||||
- 不替换 `ai_writing` graph 四个阶段大脑逻辑,不动五个暂停点 / checkpoint / 持久化。
|
||||
- 不引新状态管理库(继续 `useReducer` + `useStream`)。
|
||||
|
||||
### 2.3 复制策略(关键决策)
|
||||
|
||||
「复制一份」= **复制会被大改的表现层组件**(页面 + 干预卡 + 面板),**复用不改的引擎层**:
|
||||
|
||||
| 复制(新建副本,大改) | 复用(原样,不改;仅对 `types` 做**追加式**扩展) |
|
||||
|------------------------|--------------------------------------------------|
|
||||
| 新页面 `AIWritingChatPage.tsx` | `contexts/AIWritingContext.tsx`(状态机 + 流式) |
|
||||
| 新面板 `WritingChatPanel.tsx`(消息流 + 底部输入框) | `hooks/useAIWritingStream.ts`(事件 → state) |
|
||||
| 新消息卡 `chat-cards/*`(产物卡+确认按钮,**无输入框**) | `components/ai-writing/AIWritingDraftPanel.tsx`(右侧草稿预览) |
|
||||
| 新意图客户端 `api/ai-writing-intent.ts` | 各流式展示子组件(`MaterialsCard`/`OutlineCard`/`ReviewCard`/`SkillStepsCard`…) |
|
||||
| (可选)新 `contexts/WritingChatContext.tsx` 包一层「pending 意图 / 澄清态」 | `types/ai-writing.ts`(**只加字段不改旧字段**,保证旧页面不受影响) |
|
||||
|
||||
> **不复用、也不改** 的三个「输入框组件」:`AIWritingForm` / `InterventionCard` / `WritingFormContext`——
|
||||
> 旧页面继续用它们,新页面完全不引用它们。这样「初始的不用动」得到硬保证。
|
||||
>
|
||||
> 为什么复用 `AIWritingContext` 而不复制它?它**不含任何输入框 UI**,只是「状态机 + 与后端 graph 的
|
||||
> 流式桥」,复用它**不会改动旧页面的任何呈现**(旧页面照常在自己的 Provider 实例里跑)。复用能显著
|
||||
> 降低维护成本、避免两套引擎漂移。若要绝对物理隔离,也可整目录复制 `AIWritingContext` +
|
||||
> `useAIWritingStream` 到新目录——**推荐先复用**,仅当后续需要给对话态加大量图内新协议时再分叉。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心设计
|
||||
|
||||
### 3.1 三层结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 对话 UI 层(新) WritingChatPanel │
|
||||
│ - 消息流:用户消息 + AI 阶段产物卡(含【保留的确认按钮】)+ 进度 │
|
||||
│ - 底部:统一多行智能输入框(唯一自由输入入口) │
|
||||
│ - 右侧:草稿实时预览(复用 AIWritingDraftPanel) │
|
||||
└───────────────┬──────────────────────────────────────────────┘
|
||||
│ 用户自然语言(+ 当前 status/pausePoint 上下文)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 意图解析层(新,核心) POST /api/ai-writing/sessions/{id}/intent │
|
||||
│ 输出:{ intent, pausePoint, action, payload, │
|
||||
│ needResearch, researchQuery, answer, confidence, clarify }│
|
||||
└───────────────┬──────────────────────────────────────────────┘
|
||||
│ 结构化 action + payload
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 执行层(复用现有 AIWritingContext) │
|
||||
│ idle → startWriting(AIWritingRequest) │
|
||||
│ 暂停 → submitIntervention(UserInterventionPayload) │
|
||||
│ 问答 → 旁路回答(不改写作状态) │
|
||||
│ 素材不足 → 先重搜、缓存 pending、补完自动续跑 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**设计原则**:意图解析层是「翻译官」——把自由文本翻译成**执行层已经懂的指令**。能复用
|
||||
`action + payload` 的绝不新造协议;确认按钮点击**直接走执行层**(不经意图层,零延迟、零误判)。
|
||||
|
||||
### 3.2 意图分类体系(覆盖全流程 + 更多)
|
||||
|
||||
按「意图 → 触发时机(当前 status/pausePoint)→ 映射执行」组织,即意图解析引擎的目标 schema。
|
||||
**「甚至更多」** = G(问答)、H(控制)、以及 B/C/D/E 里的「素材不足自动回退重搜」。
|
||||
|
||||
#### A. 启动类(status = `idle`)
|
||||
| 意图 | 用户说法示例 | 映射 |
|
||||
|------|-------------|------|
|
||||
| `start_writing` | 「写一篇关于新能源出口的深度分析,2000 字」 | 解析成 `AIWritingRequest` → `startWriting()` |
|
||||
| `start_with_outline` | 「按这个大纲写:…」 | `AIWritingRequest.userOutline` |
|
||||
| `start_imitate` | 「仿照这篇样文写…」 | `writingModeType='imitate'` + `sampleText` |
|
||||
|
||||
#### B. 素材类(pausePoint = `material_confirm`)
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `confirm_materials` | 「素材可以,继续 / 下一步」 | `{action:'confirm', approvedMaterialIds}` |
|
||||
| `research_again` | 「重新搜索素材」 | `{action:'re_search'}`(无 userQuery=覆盖式重检) |
|
||||
| `add_material`/`add_keyword` | 「再补点关于 X 的素材」「加个关键词 Y」 | `{action:'re_search', userQuery:'X/Y'}`(追加) |
|
||||
| `select_materials` | 「只用第 1、3 条」 | `{action:'confirm', approvedMaterialIds:[…]}` |
|
||||
| `drop_materials` | 「去掉第 2 条」 | 同上(反选后的子集) |
|
||||
|
||||
#### C. 大纲类(pausePoint = `outline_confirm`)
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `confirm_outline` | 「大纲 OK,开始写」 | `{action:'confirm'}` |
|
||||
| `reoutline` | 「重写大纲」 | `{action:'re_outline'}`(feedback 可空) |
|
||||
| `revise_outline` | 「第二章拆成两节」「调整顺序」 | `{action:'re_outline', outlineFeedback:自然语言}` |
|
||||
|
||||
#### D. 写作 / 草稿类(pausePoint = `draft_confirm`)
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `to_editor` | 「交给编辑审核」 | `{action:'to_editor'}` |
|
||||
| `finalize` | 「就这样定稿」 | `{action:'finalize'}` **(高代价:低置信必澄清)** |
|
||||
| `revise_draft` | 「第三段太啰嗦,精简」「补个 2024 案例」 | `{action:'user_revise', userRevisionNotes:意见}` |
|
||||
| `rewrite_section` | 「重写第二章」 | `user_revise` + 章节定位写进 notes |
|
||||
| `change_tone/style` | 「更口语一点 / 更正式」 | `user_revise` + notes |
|
||||
| `apply_review`(若已有审核结果) | 「按编辑意见改」「只接受第 1、2 条」 | `{action:'user_revise', selectedIssueIndices:[…]}` |
|
||||
|
||||
#### E. 编辑审核类(pausePoint = `review_confirm`)
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `accept_review` | 「按编辑意见改」 | `{action:'accept_review', selectedIssueIndices}` |
|
||||
| `partial_accept_review` | 「只接受第 1、2 条」 | `accept_review` + `selectedIssueIndices` 子集 |
|
||||
| `add_review_opinion` | 「再补一条:核查数据准确性」 | `accept_review` + `userRevisionNotes`(并入补充指令,现协议已支持) |
|
||||
| `force_finalize` | 「不改了,强制定稿」 | `{action:'force_finalize', overrideVerdict:true}` **(高代价:必澄清)** |
|
||||
|
||||
#### F. 素材不足求助类(pausePoint = `section_help`)
|
||||
| 意图 | 映射 |
|
||||
|------|------|
|
||||
| `section_supplement` | `{action:'section_help', sectionDecisions:[…'supplement']}` |
|
||||
| `section_loose` | `sectionDecisions:[…'loose']` |
|
||||
| `section_delete` | `sectionDecisions:[…'delete']` |
|
||||
|
||||
#### G. 问答类(**新**;任意 status;旁路,不改写作状态)
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `ask_about_content` | 「第二章讲了啥?」「为什么用这个论点?」 | 意图节点直接带回 `answer`,对话流渲染(见 5.4) |
|
||||
| `ask_meta` | 「现在到哪步了?」「用了哪些素材?」 | 读当前 state 直接回答,不走图 |
|
||||
|
||||
#### H. 控制类(**新 / 半新**)
|
||||
| 意图 | 映射 |
|
||||
|------|------|
|
||||
| `next_step`(通用「继续/下一步」) | 按**当前 pausePoint** 映射到该阶段的默认确认(material/outline→`confirm`,draft→`to_editor`…;等价点确认按钮) |
|
||||
| `restart_stage` | 重跑当前阶段(复用 `resumeWriting()` 空 command 续跑) |
|
||||
| `run_background` | 转后台自动跑:`suspendAIWritingToBackground()` |
|
||||
| `cancel` | `pauseWriting()`(`stream.stop()`) |
|
||||
| `unknown/clarify` | 无法判定 → 反问澄清 / 用户协助(**防误触发硬指标**) |
|
||||
|
||||
### 3.3 「素材不足自动重搜」判定与回退(用户强调点)
|
||||
|
||||
触发:用户在**大纲/草稿/审核**阶段提出修改意见,但意见需要的信息现有素材里没有。
|
||||
|
||||
判定来源(组合):
|
||||
1. **意图节点直接判**:把「素材摘要(keywords + 每条 title/source)」喂给意图 prompt,让它判断
|
||||
「这条意见能否用现有素材满足」;不能 → `needResearch=true` + `researchQuery`。
|
||||
2. **执行阶段兜底**:`writer_draft` 严格模式已有 `blocked_sections` → `section_help` 暂停点,
|
||||
对话层把它翻译成「素材不足,是否重搜?」的追问。
|
||||
|
||||
回退动作(**首期纯前端实现,不动后端协议**):`needResearch=true` 时,前端把用户的修改
|
||||
`payload` 缓存为 `pendingIntent`,先 `submitIntervention({action:'re_search', userQuery:researchQuery})`
|
||||
补素材;监听到新一轮 `material_confirm`(素材追加完成)后,**自动提交** `pendingIntent`。
|
||||
需处理「重搜期间用户又改主意」→ 允许覆盖 pending。
|
||||
|
||||
---
|
||||
|
||||
## 4. 意图解析引擎:方案与契约
|
||||
|
||||
**采用方案 A:后端 LLM 意图节点(独立 REST)**,叠加高频短句**规则快路径**优化。
|
||||
|
||||
理由:`needResearch` 判定需要**完整写作上下文**(素材/大纲/草稿),后端天然持有 checkpoint state;
|
||||
与 `setup_writing` 一脉相承,离线模型选择策略统一;输出 schema 稳定、前端只消费。
|
||||
|
||||
### 4.1 后端入口
|
||||
|
||||
`app/gateway/routers/ai_writing.py` 新增:
|
||||
|
||||
```
|
||||
POST /api/ai-writing/sessions/{session_id}/intent
|
||||
Body: { text: string, status: string, pausePoint?: string }
|
||||
Resp: IntentResult(见 4.3)
|
||||
```
|
||||
|
||||
实现:读该 session 的 checkpoint state(素材摘要 / 大纲纲要 / 草稿字数·章节 / 审核意见)→
|
||||
用 `ai_writing` 配置模型(复用 `intent_parser.py` 同套 `create_chat_model` + `parse_json` +
|
||||
模型容错 `_model_fallback`)解析 → 返回结构化契约。**不改图拓扑**。
|
||||
|
||||
新增 prompt:`agents/ai_writing/prompts/intent_router_prompts.py`
|
||||
- 输入:用户文本、`current_status`/`pause_point`、素材摘要、大纲纲要、草稿字数+章节标题、审核意见列表。
|
||||
- 输出:严格 JSON(4.3 契约)。硬要求:**只在证据充分时给高 confidence,否则给 `clarify`**;
|
||||
`finalize`/`force_finalize`/`re_search` 等高代价意图**必须**高置信才不澄清。
|
||||
|
||||
### 4.2 前端快路径(先跑规则,未命中再调后端)
|
||||
|
||||
`api/ai-writing-intent.ts` 里对**当前 pausePoint** 做少量确定性短语命中,直接产出 payload,省一次往返:
|
||||
- 「继续/下一步/确认/可以/ok/好的」→ 当前阶段默认 `confirm`(draft 阶段=`to_editor`)。
|
||||
- 「重新搜索/重搜」(material)→ `re_search`。「重写大纲」(outline)→ `re_outline`。
|
||||
- 「定稿/强制定稿」→ **不进快路径**(高代价,一律走后端 + 可能澄清)。
|
||||
- 命中即执行;未命中 → 调后端 `intent` 接口。
|
||||
|
||||
### 4.3 意图解析输出契约
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"intent": "revise_draft", // 见 3.2 分类
|
||||
"pausePoint": "draft_confirm", // 需要 resume 时;否则 null
|
||||
"action": "user_revise", // 映射到现有 action
|
||||
"payload": { // 直接可喂 submitIntervention 的字段(camelCase)
|
||||
"userRevisionNotes": "第三段精简,并补一个 2024 年的案例"
|
||||
},
|
||||
"needResearch": true, // 素材是否不足
|
||||
"researchQuery": "2024 行业案例", // needResearch 时的检索意图
|
||||
"answer": null, // 问答类意图的直接回答(旁路)
|
||||
"confidence": 0.86,
|
||||
"clarify": null // 低置信时的反问话术(非空则不执行、只追问)
|
||||
}
|
||||
```
|
||||
|
||||
前端消费顺序:
|
||||
1. `answer` 非空 → 对话流渲染回答(问答旁路,不动写作状态)。
|
||||
2. `clarify` 非空 **或** `confidence < 阈值(建议 0.6)` → 渲染反问气泡,等用户澄清(**防误触发**)。
|
||||
3. `needResearch=true` → 缓存 `pendingIntent=payload`,先重搜,收到新素材后自动提交。
|
||||
4. 否则 → `idle` 走 `startWriting(...)`;暂停态走 `submitIntervention({action, ...payload})`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端改造方案
|
||||
|
||||
### 5.1 新页面与路由(复制式)
|
||||
|
||||
- 新页面:`open-canvas/pages/AIWritingChatPage.tsx`
|
||||
(骨架照抄 `AIWritingPage.tsx` 的 Provider 包裹与左右分栏;左栏换成 `WritingChatPanel`,右栏仍
|
||||
`AIWritingDraftPanel`;idle 不再挂 `AIWritingForm`,改由底部输入框启动)。
|
||||
- 路由注册(**新增,不动旧行**):
|
||||
- `open-canvas/routes/OpenCanvasRoutes.tsx` 加 `path="ai-writing-chat/*"`。
|
||||
- 侧边栏 `core/page-layout/sidebar-menu.ts`:在 `qa-research` 旁新增
|
||||
`{ id:"qa-research-chat", label:"AI写作(对话版)", icon:FileText, path:"/page/canvas/ai-writing-chat" }`。
|
||||
(或先不加、内部灰度直链验证。)
|
||||
|
||||
### 5.2 新面板 `WritingChatPanel.tsx`(左栏主体)
|
||||
|
||||
职责 = 消息流容器 + 底部输入框,替代 `AIWritingPanel` 里的「时间线 + InterventionCard + 状态栏」。
|
||||
复用 `useAIWriting()` 读 state / 调 `startWriting` / `submitIntervention` / `pauseWriting` /
|
||||
`suspendAIWritingToBackground`。
|
||||
|
||||
消息流渲染(把 `ProgressEvent` / 阶段产物适配成消息卡,复用现有展示子组件):
|
||||
- 运行中:`research_progress`/`outline_chunk`/`draft_chunk`/`review_chunk` → 进度 / 流式卡
|
||||
(复用 `SkillStepsCard`/`OutlineStreamingView`/`DraftGeneratingView`/`ReviewStreamingView` 的展示部分)。
|
||||
- 到达暂停点:渲染 **产物卡 + 保留的确认按钮**(见 5.3),不再有输入框。
|
||||
- 用户消息、AI 澄清追问、问答回答:普通气泡。
|
||||
|
||||
底部输入框:直接复用 `@/components/ai-elements/prompt-input`(`PromptInput`+`PromptInputTextarea`+
|
||||
`PromptInputSubmit`),Enter 发送、Shift+Enter 换行;发送时读当前 `state.status`/`currentPause?.pausePoint`
|
||||
交给意图路由(见 5.5)。运行中(非暂停点)可禁用发送或转「排队/提示当前在生成」。
|
||||
|
||||
### 5.3 新消息卡 `chat-cards/*`(**保留按钮、去掉输入框**)
|
||||
|
||||
把 `InterventionCard.tsx` 的五张卡各复制一份到 `open-canvas/components/ai-writing-chat/chat-cards/`,
|
||||
**删掉其中的 textarea / MaterialsToolbar 输入部分,保留产物展示 + 确认/动作按钮**:
|
||||
|
||||
| 新卡 | 展示(复用) | 保留的按钮 | 删除的输入 |
|
||||
|------|-------------|-----------|-----------|
|
||||
| `MaterialConfirmChatCard` | `MaterialsCard`(勾选保留)+ `SkillStepsCard` | 「确认素材,规划大纲」「重新搜索」 | `MaterialsToolbar` 的 userQuery 输入框 |
|
||||
| `OutlineConfirmChatCard` | `OutlineCard`(可点标题微调保留) | 「确认大纲,开始写作」 | feedback textarea + 发送 |
|
||||
| `DraftConfirmChatCard` | `ReviewCard`(若有审核,勾选保留) | 「按编辑审核意见修改」「让编辑继续审核」「确认定稿」 | 「修改意见」textarea |
|
||||
| `ReviewConfirmChatCard` | `ReviewCard`(勾选保留) | 「按编辑意见修改」「强制定稿」 | 「补充修改指令」textarea |
|
||||
| `SectionHelpChatCard` | 章节 + 三选一按钮组 | 三选一 + 「提交并继续」 | (本就无自由输入,原样保留) |
|
||||
|
||||
> 勾选态(素材/审核意见)**保留**——它是「点按钮」路径的一部分,不算自由输入框。用户想用自然语言
|
||||
> 「只用第 1、3 条」也能走底部输入框(意图层产出 `approvedMaterialIds`/`selectedIssueIndices`)。
|
||||
> 按钮点击 **直接调 `submitIntervention`**,与旧卡完全一致,零改动风险。
|
||||
|
||||
### 5.4 问答旁路(新能力)
|
||||
|
||||
「对写作内容提问」不改写作 state。首期**轻量实现**:意图节点检出 `ask_*` 时读 state 直接带回 `answer`,
|
||||
前端在消息流渲染为普通 AI 气泡即可(一次调用出答案)。完整版(后续)可另起只读 lead_agent 线程注入
|
||||
草稿/大纲作背景,类似 `WritingSetupChat` 的 `useThreadStream`。
|
||||
|
||||
### 5.5 交互流程(对话版)串起来
|
||||
|
||||
1. **空闲**:底部输入「写一篇关于 X 的深度分析,2000 字」→ 意图 `start_writing` → 组 `AIWritingRequest`
|
||||
→ `startWriting()`。(缺关键字段时先 `clarify` 追问字数/读者/类型。)
|
||||
2. **运行中**:进度/流式卡渲染。
|
||||
3. **到暂停点**:渲染产物卡 + 按钮 + 一句提示「你可以点上面的按钮,或直接说:确认继续 / 补充素材 / 改大纲…」。
|
||||
- **点按钮** → 直接 `submitIntervention`(旧逻辑)。
|
||||
- **发文字** → `resolveIntent(text, status, pausePoint)`(先快路径、后后端)→ 按 4.3 消费。
|
||||
4. **问答**:任意时刻提问 → 旁路 `answer`,不打断写作。
|
||||
5. **素材不足**:`needResearch` 或 `section_help` → 追问/执行「重搜 → 自动续跑 pending 意图」。
|
||||
6. **拿不准**:`clarify`/低置信 → 反问气泡,不执行。
|
||||
|
||||
### 5.6 pending 意图 & 澄清态(新增本地状态)
|
||||
|
||||
在 `WritingChatPanel`(或新 `WritingChatContext`)维护:
|
||||
- `pendingIntent: UserInterventionPayload | null` —— 素材不足重搜完成后自动提交。
|
||||
- `awaitingClarify: { originalText: string } | null` —— 澄清态,用户下一句与上一句拼接再解析。
|
||||
- pending 生命周期:设置 → 提交 `re_search` → 监听 reducer 里新一轮 `awaiting_material_confirm`
|
||||
(`state.status` 从 `researching` 回到 `awaiting_material_confirm`)→ 自动 `submitIntervention(pending)` → 清空。
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端改造方案
|
||||
|
||||
### 6.1 意图解析入口(新增,唯一后端改动主体)
|
||||
|
||||
- `app/gateway/routers/ai_writing.py`:新增 `POST /sessions/{id}/intent`(见 4.1)。
|
||||
读 checkpoint state 用 `app.state.checkpointer` + `make_ai_writing_graph().aget_state(...)`
|
||||
(参照 `ai_writing_job_executor.py` 的读法)取素材/大纲/草稿/审核摘要。
|
||||
- `agents/ai_writing/prompts/intent_router_prompts.py`:新增意图 prompt(见 4.1)。
|
||||
- 复用 `nodes/intent_parser.py` 的模型创建 / `parse_json` / `_model_fallback` 容错。
|
||||
|
||||
### 6.2 不需要改的部分
|
||||
|
||||
- 四个阶段大脑节点、五个暂停点、checkpoint、StreamBridge、持久化、sessions/transcript REST——**全不动**。
|
||||
- 「素材不足自动续跑」「问答旁路」首期都在**前端 / 意图节点**实现,**不扩 resume 协议**。
|
||||
|
||||
### 6.3 可选增强(阶段 3,按需)
|
||||
|
||||
- `add_review_opinion` 完整版:`review_confirm` resume 增字段 `extra_review_notes`(首期已用现有
|
||||
`userRevisionNotes` 降级承载,够用)。
|
||||
- `resume_intent` 后端化(把「重搜完自动续跑」搬到后端,减少前端状态机复杂度)。
|
||||
- undo / 回退到上一暂停点(依赖 checkpoint 回滚)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据流时序(改稿 + 素材不足自动重搜)
|
||||
|
||||
```
|
||||
用户(draft_confirm 暂停): "第三段补个2024年的行业案例"
|
||||
│ 底部输入框 → resolveIntent(text, status='awaiting_draft_confirm', pausePoint='draft_confirm')
|
||||
▼ POST /api/ai-writing/sessions/{id}/intent { text, status, pausePoint }
|
||||
后端: 读 state(素材摘要) + LLM 解析
|
||||
│ 返回 { intent:revise_draft, action:user_revise,
|
||||
│ payload:{userRevisionNotes:"第三段…补2024案例"},
|
||||
│ needResearch:true, researchQuery:"2024 行业案例", confidence:0.9 }
|
||||
▼
|
||||
前端: needResearch=true → pendingIntent = payload
|
||||
│ submitIntervention({action:'re_search', userQuery:"2024 行业案例"})
|
||||
▼ LangGraph resume → researcher 追加素材 → status 回到 awaiting_material_confirm
|
||||
前端: 监听到新一轮素材确认 → 自动提交 pendingIntent
|
||||
│ submitIntervention({action:'user_revise', userRevisionNotes:"第三段…补2024案例"})
|
||||
▼ writer_draft 用新素材改稿 → draft_chunk… → draft_ready → draft_confirm
|
||||
前端: 消息流展示新草稿卡 + "已按你的意见改写并补充了 2024 案例"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键实现细节与坑
|
||||
|
||||
1. **阶段作用域**:`resolveIntent` 必须带 `state.currentPause?.pausePoint` + `state.status`——
|
||||
同一句「重新来」在 material 是重搜、在 outline 是重写大纲。`AIWritingContext` 已有这两个字段,直接读。
|
||||
2. **确认按钮零改动**:新卡的按钮 `onClick` 原样调 `submitIntervention(payload)`,与旧卡逐字一致,
|
||||
风险最低;**只删输入框**。
|
||||
3. **防误触发**:低置信(<0.6)或 `clarify` 非空一律追问;`finalize`/`force_finalize`/`re_search`
|
||||
即使命中也要求高置信,否则澄清。绝不盲目定稿/覆盖重搜。
|
||||
4. **pending 意图队列**:素材不足是「先插重搜、再回原意图」,用 5.6 的小状态机;重搜完成前允许覆盖 pending。
|
||||
5. **问答不污染 transcript**:问答/澄清消息在前端标记 `kind:'qa'|'clarify'`,与写作进度事件区分(
|
||||
`AIWritingContext` 的 transcript 只存 progressEvents/completedInterventions,问答气泡是纯前端消息,
|
||||
默认不入 transcript,避免历史回看错乱)。
|
||||
6. **流式产物→消息卡**:复用 `OutlineStreamingView`/`DraftGeneratingView`/`ReviewStreamingView`/
|
||||
`SkillStepsCard` 的**展示部分**,套进消息容器,不重写渲染。
|
||||
7. **右侧草稿预览**:`AIWritingDraftPanel` + `DraftSync` 原样复用(`DraftSync` 依据 status/markdown
|
||||
同步右侧画布)。
|
||||
8. **离线内网**:意图模型走 `config.yaml → ai_writing` 配置模型;检索仍走技能/内网 ES,不联网。
|
||||
9. **模型选择透传**:`submitIntervention` 的 resume 已把 `state.request?.modelName` 一并回传(见
|
||||
`AIWritingContext`),新页面沿用即可。
|
||||
10. **旧页面隔离验证**:改完后回归旧 `/page/canvas/ai-writing` 与 `/page/workspace/ai-writing`、
|
||||
笔记本嵌入页,确认零变化(新代码不 import 旧的 `AIWritingForm`/`InterventionCard`/`WritingFormContext`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 分阶段实施路线图
|
||||
|
||||
**阶段 0 — 骨架(不含意图)**
|
||||
- 复制 `AIWritingChatPage` + 注册新路由 + 侧边栏入口;复用 `AIWritingContext`/`useStream`/`DraftPanel`。
|
||||
- 复制五张 `chat-cards`(删输入框、留按钮);底部输入框先只做 idle 直发 `start_writing`
|
||||
(文本原样当 `userIntent`,其余字段取默认)+ 暂停点靠按钮推进。端到端跑通。
|
||||
|
||||
**阶段 1 — 意图解析(核心)**
|
||||
- 后端 `POST /sessions/{id}/intent` + `intent_router_prompts.py`,返回 4.3 契约。
|
||||
- 前端 `api/ai-writing-intent.ts`(快路径 + 后端)接入 `WritingChatPanel`,覆盖 A~F 类意图。
|
||||
- 低置信 `clarify` 澄清态(5.6)。
|
||||
|
||||
**阶段 2 — 问答旁路 + 素材不足自动重搜**
|
||||
- G 类问答(意图节点直接带 `answer`)。
|
||||
- `needResearch` + pending 意图机制(前端实现重搜后自动续跑,5.6)。
|
||||
|
||||
**阶段 3 — 增强(按需)**
|
||||
- `add_review_opinion` 扩协议、`resume_intent` 后端化、undo/回退、完整问答只读线程。
|
||||
|
||||
**阶段 4 — 打磨**
|
||||
- 消息卡视觉、历史回看(transcript)、后台挂起入口、灰度到侧边栏。
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键文件清单(落地对照)
|
||||
|
||||
### 新增(前端)
|
||||
- `open-canvas/pages/AIWritingChatPage.tsx` — 新页面(复制 `AIWritingPage` 骨架改造)
|
||||
- `open-canvas/components/ai-writing-chat/WritingChatPanel.tsx` — 消息流 + 底部输入框
|
||||
- `open-canvas/components/ai-writing-chat/chat-cards/{MaterialConfirmChatCard,OutlineConfirmChatCard,DraftConfirmChatCard,ReviewConfirmChatCard,SectionHelpChatCard}.tsx` — 保留按钮、去掉输入框
|
||||
- `open-canvas/components/ai-writing-chat/progressToMessage.ts` — `ProgressEvent` → 消息卡适配
|
||||
- `open-canvas/api/ai-writing-intent.ts` — 意图解析客户端(快路径 + `POST …/intent`)
|
||||
- (可选)`open-canvas/contexts/WritingChatContext.tsx` — pending 意图 / 澄清态
|
||||
|
||||
### 修改(前端,仅追加,不动旧逻辑)
|
||||
- `open-canvas/routes/OpenCanvasRoutes.tsx` — 加一条 `ai-writing-chat/*` 路由
|
||||
- `core/page-layout/sidebar-menu.ts` — 加一个侧边栏入口(可选)
|
||||
- `open-canvas/types/ai-writing.ts` — **仅追加** `IntentResult` 等新类型,不改旧字段
|
||||
|
||||
### 新增(后端)
|
||||
- `app/gateway/routers/ai_writing.py` — 加 `POST /sessions/{id}/intent`
|
||||
- `agents/ai_writing/prompts/intent_router_prompts.py` — 意图 prompt
|
||||
- `tests/test_ai_writing_intent.py` — 意图接口单测(TDD 强制)
|
||||
|
||||
### 复用(不改)
|
||||
- `open-canvas/contexts/AIWritingContext.tsx`、`hooks/useAIWritingStream.ts`
|
||||
- `open-canvas/components/ai-writing/{AIWritingDraftPanel,DraftSync,MaterialsCard,OutlineCard,ReviewCard,SkillStepsCard,OutlineStreamingView,DraftGeneratingView,ReviewStreamingView}.tsx`
|
||||
- `agents/ai_writing/graph.py`、四个阶段节点、五个暂停点、`nodes/intent_parser.py`
|
||||
|
||||
### 绝不动(保证「初始的不用动」)
|
||||
- `open-canvas/pages/AIWritingPage.tsx` 及其引用的
|
||||
`components/ai-writing/{AIWritingForm,InterventionCard}.tsx`、`contexts/WritingFormContext.tsx`
|
||||
- 旧路由 `/page/canvas/ai-writing`、`/page/workspace/ai-writing`、笔记本嵌入页
|
||||
|
||||
---
|
||||
|
||||
## 11. 待确认(默认取值已给出,可直接推进)
|
||||
|
||||
1. 意图引擎:**后端 LLM 节点(方案 A)+ 前端短句快路径** —— 默认采用。
|
||||
2. `AIWritingContext` / `useAIWritingStream`:**复用**(不物理复制)——默认采用;需绝对隔离可改为复制。
|
||||
3. 新页面入口:**侧边栏新增「AI写作(对话版)」** —— 默认加;若要先灰度可只留直链。
|
||||
4. 「素材不足自动重搜」:**首期前端 pending 续跑**(不动后端 resume 协议)——默认采用。
|
||||
5. 问答旁路:**首期意图节点直接带 `answer`** —— 默认采用。
|
||||
|
||||
> 以上默认按「A + 复用引擎 + 侧边栏入口 + 前端 pending + 意图节点带答案」推进,如需调整请指出。
|
||||
@@ -0,0 +1,454 @@
|
||||
# AI 写作「对话驱动 + 意图解析」改造方案
|
||||
|
||||
> 目标:在**不动现有 AI 写作页面**的前提下,新建一个「对话驱动」的写作工作台。
|
||||
> 去掉素材收集 / 大纲规划 / 作家写作 / 编辑审核各阶段**分散的输入框/表单**,
|
||||
> 用**底部一个统一的智能输入框**接管全流程交互;该输入框具备**强意图解析**能力,
|
||||
> 能准确识别用户意图(重新搜索、加素材、加关键词、重写大纲、按要求改大纲、改写作、
|
||||
> 改编辑、提编辑意见、对写作内容提问……),并在**素材不足**时自动回退去重新检索素材。
|
||||
|
||||
本文只做**方案与实现指引**,不含代码改动。落地时按第 9 节的路线图分阶段推进。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状梳理(改造的地基)
|
||||
|
||||
现有 AI 写作是一套**独立的 LangGraph 子系统**(不是 lead_agent),前后端都已成型。
|
||||
|
||||
### 1.1 后端:`ai_writing` graph
|
||||
|
||||
- 图工厂:`offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py`
|
||||
- 状态:`.../agents/ai_writing/state.py`(`AIWritingState` + `UserInterventionPayload`)
|
||||
- 注册:`offline-backend-20260512/backend/langgraph.json` → `assistant_id = "ai_writing"`
|
||||
- 执行:与 lead agent **共用** RunManager + StreamBridge + checkpointer(`app/gateway/services.py` 里 `assistant_id=="ai_writing"` 分发到 `make_ai_writing_graph`)
|
||||
|
||||
节点拓扑(实际):
|
||||
|
||||
```
|
||||
sample_analyzer → intent_parser → researcher
|
||||
→ [cond] pause_material | writer_draft
|
||||
→ writer_outline → pause_outline
|
||||
→ writer_draft → [cond] pause_section_help | pause_draft | END
|
||||
→ editor → [cond] pause_draft | pause_review
|
||||
→ pause_review → END | writer_draft
|
||||
```
|
||||
|
||||
四个「阶段大脑」节点:
|
||||
|
||||
| 阶段 | 节点 | 文件 |
|
||||
|------|------|------|
|
||||
| 素材收集 | `researcher` | `nodes/researcher.py` |
|
||||
| 大纲规划 | `writer_outline` | `nodes/writer_outline.py` |
|
||||
| 作家写作 | `writer_draft` | `nodes/writer_draft.py` |
|
||||
| 编辑审核 | `editor` | `nodes/editor.py` |
|
||||
|
||||
五个**暂停点**(`interrupt()`,靠 `Command(resume=payload)` 续跑):
|
||||
|
||||
| pause_point | 语义 | 可用 action(现有) |
|
||||
|-------------|------|---------------------|
|
||||
| `material_confirm` | 素材确认 | `confirm` / `re_search` / `supplement_search` / `enable_skill_search` |
|
||||
| `outline_confirm` | 大纲确认 | `confirm` / `re_outline` |
|
||||
| `draft_confirm` | 草稿确认 | `to_editor` / `finalize` / `user_revise` / `custom_revise` |
|
||||
| `review_confirm` | 编辑打回确认 | `accept_review` / `force_finalize` |
|
||||
| `section_help` | 素材不足求助 | `supplement` / `loose` / `delete` |
|
||||
|
||||
> resume 载荷字段(`UserInterventionPayload`):`action`、`approved_material_ids`、
|
||||
> `extra_keywords`、`user_query`(自然语言补充检索)、`edited_outline`、`outline_feedback`、
|
||||
> `user_revision_notes`、`override_verdict`、`selected_issue_indices`、`section_decisions`。
|
||||
> **关键点**:现有协议已经支持「自然语言 + 结构化 action」,这是对话改造的核心可复用资产。
|
||||
|
||||
REST(业务元数据,非图执行):`app/gateway/routers/ai_writing.py`,前缀 `/api/ai-writing`
|
||||
(sessions CRUD、transcript 保存、article-types、后台挂起 `/background` + `/progress` 等)。
|
||||
|
||||
持久化:`ai_writing_sessions` 表(`transcript` 为 `PortableLongText`)
|
||||
+ `ai_writing_article_types`(`deerflow/persistence/...`)。
|
||||
|
||||
配置:`config.yaml → ai_writing`(`keyword_model`、`rank_model`、`max_materials`、
|
||||
`use_builtin_agents.{researcher,outliner,writer,editor}` 等)。
|
||||
|
||||
### 1.2 前端:`open-canvas` 子系统
|
||||
|
||||
- 主页面:`frontend-web/src/open-canvas/pages/AIWritingPage.tsx`
|
||||
- 路由:`/page/canvas/ai-writing`、`/page/workspace/ai-writing`
|
||||
- 状态机:`contexts/AIWritingContext.tsx`(`useReducer` + LangGraph `useStream`)
|
||||
- 事件翻译/reducer:`hooks/useAIWritingStream.ts`
|
||||
- 类型:`types/ai-writing.ts`(`WritingStatus`、`ProgressEvent`、`UserInterventionPayload`、`AIWritingRequest`…)
|
||||
- 流式:LangGraph `useStream`,`assistantId: 'ai_writing'`,`streamMode: ['values','updates','custom','messages-tuple']`
|
||||
- **要去掉的输入 UI**(改造重点):
|
||||
- 启动表单 `components/ai-writing/AIWritingForm.tsx` + `WritingFormContext`
|
||||
- 阶段暂停输入 `components/ai-writing/InterventionCard.tsx`(素材/大纲/草稿/审核四套内嵌表单)
|
||||
- **已存在的对话原型**:`components/ai-writing/WritingSetupChat.tsx`
|
||||
(用 `useThreadStream` 起一条 lead_agent 线程 + `setup_writing` 工具审批卡回填表单)——
|
||||
这是「对话 → 结构化配置」的现成范式,可借鉴但**不直接复用**(它只管启动前配置)。
|
||||
|
||||
### 1.3 结论
|
||||
|
||||
- **状态机、流式、暂停/续跑协议、持久化都可复用**,无需重写引擎。
|
||||
- 改造本质 = **把「分散表单」换成「一个对话输入框 + 意图路由层」**,
|
||||
再把意图映射回**现有的 `action + payload`**(大部分场景),
|
||||
少量新意图(如「对内容提问」「素材不足自动重搜」)需要**新增协议/节点**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与范围
|
||||
|
||||
### 2.1 必须满足
|
||||
|
||||
1. 新页面/新路由,**旧页面零改动**(用户要求「初始的不用动」)。
|
||||
2. 底部**单一对话输入框**控制所有阶段交互,移除各阶段独立输入框。
|
||||
3. **强意图解析**:准确识别用户自然语言意图并路由到正确动作,覆盖全流程 + 更多。
|
||||
4. 用户给修改意见后,**若现有素材不足以满足要求 → 自动/建议重新检索素材**再继续。
|
||||
5. 保留右侧草稿实时预览(可编辑)与时间线/进度展示。
|
||||
|
||||
### 2.2 明确不做(本期)
|
||||
|
||||
- 不改旧 `AIWritingPage` 及其表单/干预卡组件。
|
||||
- 不替换底层 `ai_writing` graph 的四个阶段大脑逻辑(只加意图层与少量新协议)。
|
||||
- 不引入新的状态管理库(继续 `useReducer` + `useStream`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心设计
|
||||
|
||||
### 3.1 总体思路:三层结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 对话 UI 层(新) │
|
||||
│ - 左:消息流(用户消息 + AI 阶段产物卡片 + 进度) │
|
||||
│ - 底:统一智能输入框(唯一交互入口) │
|
||||
│ - 右:草稿实时预览(复用 AIWritingDraftPanel) │
|
||||
└───────────────┬─────────────────────────────────────────┘
|
||||
│ 用户自然语言
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 意图解析层(新,核心) │
|
||||
│ - 输入:用户文本 + 当前 WritingStatus + 上下文快照 │
|
||||
│ - 输出:{ intent, action, payload, needResearch, ... } │
|
||||
└───────────────┬─────────────────────────────────────────┘
|
||||
│ 结构化 action + payload
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 执行层(复用现有) │
|
||||
│ - 空闲态:startWriting(AIWritingRequest) │
|
||||
│ - 暂停态:submitIntervention(UserInterventionPayload) │
|
||||
│ - 运行中:stream.stop() → 续跑 / 新命令 │
|
||||
│ - 提问态:旁路问答(不改写作状态) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**设计原则**:意图解析层是「翻译官」——把自由文本翻译成**现有执行层已经懂的指令**。
|
||||
能复用 `action + payload` 的绝不新造协议;只有现有协议表达不了的才扩展。
|
||||
|
||||
### 3.2 意图分类体系(覆盖用户全流程 + 更多)
|
||||
|
||||
按「意图类别 → 触发时机(当前 status)→ 映射到的执行」组织。这是**意图解析引擎的目标 schema**。
|
||||
|
||||
#### A. 启动类(status = idle)
|
||||
|
||||
| 意图 | 说明 | 映射 |
|
||||
|------|------|------|
|
||||
| `start_writing` | 用户描述要写什么 | 解析成 `AIWritingRequest` → `startWriting()` |
|
||||
| `start_with_outline` | 用户自带大纲 | `AIWritingRequest.userOutline` |
|
||||
| `start_imitate` | 样文仿写 | `writingModeType=imitate` + 样文 |
|
||||
|
||||
#### B. 素材类(status ≈ awaiting_material_confirm / 运行中)
|
||||
|
||||
| 意图 | 用户说法示例 | 映射 |
|
||||
|------|-------------|------|
|
||||
| `confirm_materials` | 「素材可以,继续」 | `material_confirm` + `confirm`(可带勾选 ids) |
|
||||
| `research_again` | 「重新搜索素材」 | `material_confirm` + `re_search` |
|
||||
| `add_material` / `add_keyword` | 「补充关于 X 的素材」「加个关键词 Y」 | `material_confirm` + `supplement_search`,`user_query`=X/Y |
|
||||
| `use_skill_search` | 「用 XX 技能查」 | `material_confirm` + `enable_skill_search`,`user_query`=技能/来源 |
|
||||
| `select_materials` | 「只用第 1、3 条」 | `confirm` + `approved_material_ids` 子集 |
|
||||
| `drop_materials` | 「去掉第 2 条」 | 同上,反选后 `approved_material_ids` |
|
||||
|
||||
#### C. 大纲类(status ≈ awaiting_outline_confirm)
|
||||
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `confirm_outline` | 「大纲 OK」 | `outline_confirm` + `confirm` |
|
||||
| `reoutline` | 「重写大纲」 | `outline_confirm` + `re_outline`(`outline_feedback` 可空) |
|
||||
| `revise_outline` | 「第二章拆成两节」「调整顺序」 | `re_outline` + `outline_feedback`=自然语言 |
|
||||
| `edit_outline_manually` | 用户直接给出新大纲文本 | 解析成 `edited_outline` |
|
||||
|
||||
#### D. 写作/草稿类(status ≈ awaiting_draft_confirm / writing_draft)
|
||||
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `to_editor` | 「交给编辑审核」 | `draft_confirm` + `to_editor` |
|
||||
| `finalize` | 「就这样定稿」 | `draft_confirm` + `finalize` |
|
||||
| `revise_draft` | 「第三段太啰嗦,精简」「补个案例」 | `draft_confirm` + `user_revise`,`user_revision_notes`=意见 |
|
||||
| `rewrite_section` | 「重写第二章」 | `user_revise` + 定位信息(章节)写进 notes |
|
||||
| `change_tone/style` | 「更口语一点」 | `user_revise` + notes |
|
||||
|
||||
#### E. 编辑审核类(status ≈ awaiting_review_confirm)
|
||||
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `accept_review` | 「按编辑意见改」 | `review_confirm` + `accept_review`(可带 `selected_issue_indices`) |
|
||||
| `partial_accept_review` | 「只接受第 1、2 条意见」 | `accept_review` + `selected_issue_indices` |
|
||||
| `force_finalize` | 「不改了,强制定稿」 | `review_confirm` + `force_finalize` |
|
||||
| `add_review_opinion` | 「你再补一条:检查数据准确性」 | 追加自定义审核意见(见 5.3 扩展) |
|
||||
|
||||
#### F. 素材不足求助类(status = awaiting_section_help)
|
||||
|
||||
| 意图 | 映射 |
|
||||
|------|------|
|
||||
| `section_supplement` | `section_help` + `supplement`(去重搜) |
|
||||
| `section_loose` | `section_help` + `loose`(放宽续写) |
|
||||
| `section_delete` | `section_help` + `delete` |
|
||||
|
||||
#### G. 问答类(**新**,任意 status,旁路,不改写作状态)
|
||||
|
||||
| 意图 | 示例 | 映射 |
|
||||
|------|------|------|
|
||||
| `ask_about_content` | 「第二章讲了啥?」「为什么用这个论点?」 | 旁路问答(见 5.4) |
|
||||
| `ask_meta` | 「现在进行到哪一步?」「用了哪些素材?」 | 读当前 state 直接回答,不走图 |
|
||||
|
||||
#### H. 控制类(**新/半新**)
|
||||
|
||||
| 意图 | 映射 |
|
||||
|------|------|
|
||||
| `undo/back` | 回退到上一暂停点(依赖 checkpoint 回滚,见 8.4) |
|
||||
| `restart_stage` | 重跑当前阶段 |
|
||||
| `run_background` | 挂起后台自动跑 → `POST /sessions/{id}/background` |
|
||||
| `cancel` | `stream.stop()` |
|
||||
| `unknown/clarify` | 无法判定 → 反问澄清(对话追问,不误触发) |
|
||||
|
||||
> **覆盖「甚至更多」**:G(问答)、H(控制)、以及 D/E 里「素材不足自动回退重搜」都是超出原表单能力的新增。
|
||||
|
||||
### 3.3 「素材不足自动重搜」的判定与回退(用户强调的重点)
|
||||
|
||||
触发场景:用户在**大纲/草稿/审核**阶段提出修改意见,但意见需要的**信息现有素材里没有**。
|
||||
|
||||
判定放在**意图解析层**输出一个 `needResearch: boolean` + `researchQuery`,判定来源二选一(推荐组合):
|
||||
|
||||
1. **意图解析 LLM 直接判断**:把「当前素材摘要(keywords + 每条 title/来源)」喂给意图解析
|
||||
prompt,让它判断「用户这条意见能否用现有素材满足」。不能 → `needResearch=true` 并给出检索词。
|
||||
2. **执行阶段兜底**:`writer_draft` 严格模式本就有 `blocked_sections` → `pause_section_help`,
|
||||
这是**已有的**素材不足闸门;对话层把它翻译成「素材不足,是否重新检索?」的对话追问。
|
||||
|
||||
回退动作:`needResearch=true` 时,先走 `material_confirm + supplement_search`(或新的
|
||||
「带目标的重搜」协议,见 8.3)补素材,**补完自动继续**用户原本的修改意图(需要在对话层
|
||||
记住「pending 修改意图」,重搜结束后再提交)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 意图解析引擎:方案对比与推荐
|
||||
|
||||
| 方案 | 做法 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **A. 后端 LLM 意图节点**(推荐) | 新增 `/api/ai-writing/intent/parse`(或图内新节点),后端用 LLM 把 `{text, status, contextSnapshot}` → 结构化意图 JSON | 能吃到完整 state(素材/大纲/草稿);可判 `needResearch`;prompt/模型集中可控;离线内网可用配置模型 | 多一次网络往返(可接受,交互本就等待) |
|
||||
| B. 前端调模型接口分类 | 前端直接调现有 `/api/models` 聊天做分类 | 少一层后端改动 | 前端难拿全后端 state 快照;prompt 分散;鉴权/模型选择复杂 |
|
||||
| C. 规则 + LLM 混合 | 常见短句走关键词规则快路径,兜底 LLM | 快、省 token | 规则易漏、维护成本高;中文表达多样命中率低 |
|
||||
|
||||
**推荐 A(后端 LLM 意图节点)**,并在其上**叠加 C 的快路径**做优化(如「确认/继续/定稿」等
|
||||
高频短句先走轻量规则,未命中再调 LLM)。理由:
|
||||
|
||||
- 意图判定需要**当前写作上下文**(尤其 `needResearch` 判定),后端天然持有 state/checkpoint。
|
||||
- 与现有「配置助手 `setup_writing`」一脉相承,模型选择/离线部署策略统一。
|
||||
- 输出 schema 稳定,前端只消费结构化结果,UI 简单。
|
||||
|
||||
### 4.1 意图解析输出契约(建议)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"intent": "revise_draft", // 见第 3.2 分类
|
||||
"pausePoint": "draft_confirm", // 若需 resume;否则 null
|
||||
"action": "user_revise", // 映射到现有 action;新意图可为新值
|
||||
"payload": { // 直接可喂 submitIntervention 的字段
|
||||
"userRevisionNotes": "第三段精简,并补一个 2024 年的案例"
|
||||
},
|
||||
"needResearch": true, // 素材是否不足
|
||||
"researchQuery": "2024 行业案例", // needResearch 时的检索意图
|
||||
"answer": null, // 问答类意图的直接回答(旁路)
|
||||
"confidence": 0.86,
|
||||
"clarify": null // 低置信时的反问话术
|
||||
}
|
||||
```
|
||||
|
||||
前端拿到后:
|
||||
- `answer` 非空 → 直接在对话流渲染回答(问答旁路,不动写作状态)。
|
||||
- `clarify` 非空或 `confidence` 低 → 渲染反问,等用户澄清(**防误触发**)。
|
||||
- `needResearch=true` → 先重搜,缓存 `payload` 为 pending,重搜完再提交。
|
||||
- 否则 → `submitIntervention({action, ...payload})` 或 `startWriting(...)`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端改造方案
|
||||
|
||||
### 5.1 新页面与路由
|
||||
|
||||
- 新目录:`frontend-web/src/open-canvas/pages/AIWritingChatPage.tsx`(或 `conversational/` 子目录)。
|
||||
- 新路由:`/page/canvas/ai-writing-chat`(在 `OpenCanvasRoutes.tsx` 注册;旧路由保留)。
|
||||
- 侧边栏:`sidebar-menu.ts` 加一个入口(如「AI 写作(对话版)」),或先不加、内部灰度。
|
||||
|
||||
### 5.2 复用 vs 新建
|
||||
|
||||
| 复用(尽量不改) | 新建 |
|
||||
|------------------|------|
|
||||
| `AIWritingContext` / `useAIWritingStream`(状态机 + 流式) | 统一对话输入框组件 `WritingChatComposer` |
|
||||
| `AIWritingDraftPanel`(右侧草稿预览) | 消息流容器 `WritingChatThread`(渲染阶段产物为消息卡片) |
|
||||
| `types/ai-writing.ts`(按需扩展) | 意图解析客户端 `api/ai-writing-intent.ts` |
|
||||
| `ProgressEvent` 事件模型 | 阶段产物 → 消息卡片的适配器 `progressToMessage.ts` |
|
||||
| 会话持久化 REST | 「pending 意图」暂存逻辑(重搜后续跑) |
|
||||
|
||||
**不复用**:`AIWritingForm` / `WritingFormContext` / `InterventionCard`(这三个就是要去掉的表单)。
|
||||
|
||||
### 5.3 交互流程(对话版)
|
||||
|
||||
1. 空闲:用户在底部输入「写一篇关于 X 的深度分析,2000 字」→ 意图解析 `start_writing`
|
||||
→ 组装 `AIWritingRequest` → `startWriting()`。
|
||||
2. 运行中:`ProgressEvent`(`research_progress`/`outline_chunk`/`draft_chunk`…)
|
||||
→ 适配成消息流里的**进度卡片/流式卡片**(复用现有流式视图组件的展示部分)。
|
||||
3. 到达暂停点:不再弹 `InterventionCard`,而是在消息流里展示**阶段产物卡片**
|
||||
(素材列表 / 大纲 / 草稿 / 审核结果)+ 一句提示「你可以说:确认继续 / 补充素材 / 改大纲…」。
|
||||
用户下一句自然语言 → 意图解析 → `submitIntervention`。
|
||||
4. 问答:任意时刻用户提问 → 旁路回答,不打断写作状态。
|
||||
5. 素材不足:意图解析 `needResearch` 或后端 `section_help` → 对话追问「素材不足,帮你重搜?」
|
||||
→ 确认后重搜 → 自动续跑 pending 意图。
|
||||
|
||||
### 5.4 问答旁路(新能力)
|
||||
|
||||
「对写作内容提问」不应改写作 state。两种实现:
|
||||
|
||||
- **轻量(推荐先做)**:意图解析节点直接带回 `answer`(后端能读 state,一次调用出答案)。
|
||||
- **完整**:新起一条**只读**问答线程(lead_agent,context 注入当前草稿/大纲作为背景),
|
||||
类似 `WritingSetupChat` 的 `useThreadStream` 模式,但只读不回填。
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端改造方案
|
||||
|
||||
### 6.1 意图解析入口(二选一)
|
||||
|
||||
- **方案 A1(推荐,独立 REST)**:`app/gateway/routers/ai_writing.py` 新增
|
||||
`POST /api/ai-writing/sessions/{id}/intent`,入参 `{text}`,后端读该 session 的
|
||||
checkpoint state(素材/大纲/草稿摘要)+ 用 `ai_writing` 配置模型解析,返回第 4.1 的契约。
|
||||
- 好处:不改图拓扑;前端拿到结果后再决定调 `startWriting`/`submitIntervention`。
|
||||
- 方案 A2(图内节点):在图里加 `conversation_router` 节点。改动大、状态耦合高,**不推荐**首期。
|
||||
|
||||
### 6.2 意图解析 prompt
|
||||
|
||||
新增 `agents/ai_writing/prompts/intent_router_prompts.py`(或复用/扩展 `intent_parser.py`):
|
||||
- 输入:用户文本、`current_status`、素材摘要(keywords + 每条 title/source)、大纲纲要、
|
||||
草稿字数/章节标题、审核意见列表。
|
||||
- 输出:严格 JSON(第 4.1 契约)。要求模型**只在证据充分时给高 confidence**,
|
||||
否则给 `clarify`(防误触发是硬指标)。
|
||||
|
||||
### 6.3 「带目标的重搜」协议扩展(可选增强)
|
||||
|
||||
现有 `supplement_search` 用 `user_query` 追加检索。为支持「改稿意见 → 缺素材 → 重搜 → 自动续跑」,
|
||||
建议在 `material_confirm` 的 resume 里新增可选字段 `resume_intent`(重搜完成后要自动执行的原始意图)。
|
||||
- 前端也可**纯前端**实现(重搜完成事件回来后再自动提交缓存的 pending payload),**首期优先前端实现**,避免动后端协议。
|
||||
|
||||
### 6.4 追加自定义审核意见(`add_review_opinion`)
|
||||
|
||||
`editor`/`pause_review` 目前只接受「接受/强制定稿」。要支持「你再补一条审核意见」,
|
||||
需在 `review_confirm` resume 里接受 `extra_review_notes`,`editor` 或 `writer_draft`
|
||||
修订时并入。首期可先降级为 `user_revise` + notes(把用户的审核诉求当作改稿意见),
|
||||
**完整版**再扩协议。
|
||||
|
||||
### 6.5 不需要改的部分
|
||||
|
||||
- 四个阶段大脑节点(researcher/writer_outline/writer_draft/editor)逻辑不动。
|
||||
- 五个暂停点、checkpoint、StreamBridge、持久化不动。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据流时序(改稿 + 素材不足自动重搜)
|
||||
|
||||
```
|
||||
用户: "第三段补个2024年的行业案例"
|
||||
│
|
||||
▼ POST /api/ai-writing/sessions/{id}/intent { text }
|
||||
后端: 读 state(素材摘要) + LLM 解析
|
||||
│ 返回 { intent:revise_draft, action:user_revise,
|
||||
│ payload:{userRevisionNotes:...},
|
||||
│ needResearch:true, researchQuery:"2024 行业案例" }
|
||||
▼
|
||||
前端: needResearch=true → 缓存 pending=payload
|
||||
│ 对话追问/直接执行: submitIntervention(material_confirm, supplement_search, user_query="2024 行业案例")
|
||||
▼ LangGraph resume → researcher 补素材 → materials_ready
|
||||
前端: 收到新素材 → 自动提交 pending
|
||||
│ submitIntervention(draft_confirm, user_revise, userRevisionNotes=...)
|
||||
▼ writer_draft 用新素材改稿 → draft_chunk... → draft_ready → pause_draft
|
||||
前端: 消息流展示新草稿 + "已按你的意见改写并补充了案例"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键实现细节与坑
|
||||
|
||||
1. **暂停态识别**:对话层必须知道「现在停在哪个 pause_point」才能正确映射 action。
|
||||
`AIWritingContext` 已有 `currentPause`/`status`,直接读。
|
||||
2. **防误触发**:低置信意图一律走 `clarify` 反问,绝不猜着执行(尤其 `finalize`/`re_search` 这种代价大的)。
|
||||
3. **pending 意图队列**:素材不足重搜是「先插一段、再回原意图」,需要一个小状态机记住 pending,
|
||||
并处理「重搜后用户又改主意」的情况(重搜完成前允许覆盖 pending)。
|
||||
4. **回退/undo(H 类)**:依赖 checkpoint 回滚,`runtime/runs/worker.py` 有 `rollback` 概念,
|
||||
首期可**不做**或只做「重跑当前阶段」。
|
||||
5. **问答旁路不污染 transcript**:问答消息可标记 `kind:'qa'`,与写作进度事件区分持久化。
|
||||
6. **流式产物→消息卡片**:复用现有 `OutlineStreamingView`/`DraftStreamingPreview` 等的**展示部分**,
|
||||
套进消息气泡即可,不必重写渲染。
|
||||
7. **离线内网**:意图解析模型走 `config.yaml → ai_writing` 里的配置模型;检索仍走技能/内网 ES,
|
||||
不联网(与现有并行席位「禁 web_search」的离线策略一致)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 分阶段实施路线图
|
||||
|
||||
**阶段 0 — 骨架(不含意图)**
|
||||
- 新路由 + 新页面 `AIWritingChatPage`,复用 `AIWritingContext`/`useStream`/`DraftPanel`。
|
||||
- 底部对话框先只做「空闲 `start_writing` + 暂停点固定按钮」跑通端到端(相当于把干预卡换成消息)。
|
||||
|
||||
**阶段 1 — 意图解析(核心)**
|
||||
- 后端 `POST /sessions/{id}/intent` + intent prompt,返回第 4.1 契约。
|
||||
- 前端接入:自然语言 → 意图 → `startWriting`/`submitIntervention`,覆盖 A~F 类意图。
|
||||
- 加高频短句规则快路径 + 低置信 `clarify` 反问。
|
||||
|
||||
**阶段 2 — 问答旁路 + 素材不足自动重搜**
|
||||
- G 类问答(先用 intent 节点直接带 `answer`)。
|
||||
- `needResearch` + pending 意图机制(前端实现重搜后自动续跑)。
|
||||
|
||||
**阶段 3 — 增强协议(按需)**
|
||||
- `add_review_opinion` 扩 `review_confirm` 协议、`resume_intent` 后端化、undo/回退。
|
||||
|
||||
**阶段 4 — 打磨**
|
||||
- 消息卡片视觉、历史回看(复用 transcript)、后台挂起入口、灰度到侧边栏。
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键文件清单(落地时对照)
|
||||
|
||||
### 新增(前端)
|
||||
- `open-canvas/pages/AIWritingChatPage.tsx` — 新页面
|
||||
- `open-canvas/components/ai-writing-chat/WritingChatComposer.tsx` — 统一输入框
|
||||
- `open-canvas/components/ai-writing-chat/WritingChatThread.tsx` — 消息流
|
||||
- `open-canvas/components/ai-writing-chat/progressToMessage.ts` — 事件→消息适配
|
||||
- `open-canvas/api/ai-writing-intent.ts` — 意图解析客户端
|
||||
- 路由注册:`open-canvas/OpenCanvasRoutes.tsx`(+ 可选 `sidebar-menu.ts`)
|
||||
|
||||
### 新增(后端)
|
||||
- `app/gateway/routers/ai_writing.py` — 加 `POST /sessions/{id}/intent`
|
||||
- `agents/ai_writing/prompts/intent_router_prompts.py` — 意图 prompt
|
||||
- (阶段 3)`state.py` / `nodes/*` — 扩 `review_confirm`、`resume_intent`
|
||||
|
||||
### 复用(尽量不改)
|
||||
- `open-canvas/contexts/AIWritingContext.tsx`、`hooks/useAIWritingStream.ts`
|
||||
- `open-canvas/components/ai-writing/AIWritingDraftPanel.tsx` 及各流式展示组件
|
||||
- `open-canvas/types/ai-writing.ts`(按需扩字段)
|
||||
- `agents/ai_writing/graph.py`、四个阶段节点、五个暂停点
|
||||
|
||||
### 不动
|
||||
- 旧 `open-canvas/pages/AIWritingPage.tsx` 及 `AIWritingForm`/`WritingFormContext`/`InterventionCard`
|
||||
|
||||
---
|
||||
|
||||
## 11. 待确认
|
||||
|
||||
1. 意图解析引擎是否采用**推荐的方案 A(后端 LLM 节点)**?
|
||||
2. 新页面是**独立入口**(侧边栏新增)还是先**内部灰度**(仅直链)?
|
||||
3. 「素材不足自动重搜」首期是否接受**前端实现 pending 续跑**(不动后端 resume 协议)?
|
||||
4. 问答旁路首期是否接受**意图节点直接带答案**(不新起只读线程)?
|
||||
|
||||
> 以上默认按「A / 内部灰度 / 前端 pending / 意图节点带答案」推进,如需调整请指出。
|
||||
@@ -39,6 +39,7 @@ import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
import { SkillStepsCard } from './SkillStepsCard';
|
||||
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
|
||||
import { KeywordStreamingView } from './KeywordStreamingView';
|
||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||
import { DraftGeneratingView } from './DraftGeneratingView';
|
||||
import { DraftSync } from './DraftSync';
|
||||
@@ -283,6 +284,8 @@ export function AIWritingPanel({
|
||||
state.intentStreamingText,
|
||||
state.intentThinkingText,
|
||||
state.parsedIntent,
|
||||
state.keywordStreamingText,
|
||||
state.keywordThinkingText,
|
||||
state.skillSearchSteps.length,
|
||||
state.skillSelectStreamingText,
|
||||
state.skillSelectThinkingText,
|
||||
@@ -642,6 +645,16 @@ export function AIWritingPanel({
|
||||
<DraftGeneratingView sections={state.streamingDraftSections} />
|
||||
)}
|
||||
|
||||
{/* 检索词生成过程:先展示模型如何从写作意图提炼搜索方向,再进入技能匹配 / 检索。 */}
|
||||
{state.status === 'researching' && (
|
||||
state.keywordStreamingText || state.keywordThinkingText
|
||||
) && (
|
||||
<KeywordStreamingView
|
||||
text={state.keywordStreamingText}
|
||||
thinkingText={state.keywordThinkingText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
|
||||
挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载,
|
||||
让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */}
|
||||
@@ -675,11 +688,13 @@ export function AIWritingPanel({
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||||
/* 技能检索占位卡 / 选择打字机 / 已流式的技能步骤 已经在展示,不再叠加 skeleton。
|
||||
/* 检索词生成卡 / 技能检索占位卡 / 选择打字机 / 已流式的技能步骤
|
||||
已经在展示,不再叠加 skeleton。
|
||||
(研究阶段一旦有 skill_search_step,就改由上面的 SkillStepsCard 实时呈现进度,
|
||||
研究词生成阶段 steps 还为空时仍走 skeleton 透传 researchProgress 文案。) */
|
||||
关键词 chunk 还没到之前仍走 skeleton 透传 researchProgress 文案。) */
|
||||
!state.skillSearchPending &&
|
||||
!(state.status === 'researching' && state.skillSearchSteps.length > 0) &&
|
||||
!(state.status === 'researching' && (state.keywordStreamingText || state.keywordThinkingText)) &&
|
||||
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 样文分析已有流式过程卡时,不再叠加 skeleton */
|
||||
!state.sampleAnalysisStreamingText && !state.sampleAnalysisThinkingText &&
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight, Hash, Search } from 'lucide-react';
|
||||
|
||||
interface KeywordStreamingViewProps {
|
||||
text: string;
|
||||
thinkingText?: string;
|
||||
}
|
||||
|
||||
function extractStreamingKeywords(text: string): string[] {
|
||||
if (!text) return [];
|
||||
const keyMatch = text.match(/["']?keywords["']?\s*:\s*\[/);
|
||||
if (!keyMatch) return [];
|
||||
const start = keyMatch.index! + keyMatch[0].length;
|
||||
const rest = text.slice(start);
|
||||
const endIdx = rest.indexOf(']');
|
||||
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
|
||||
const keywords: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(arrBody)) !== null) {
|
||||
const keyword = m[1]
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.trim();
|
||||
if (keyword) keywords.push(keyword);
|
||||
}
|
||||
return Array.from(new Set(keywords));
|
||||
}
|
||||
|
||||
export function KeywordStreamingView({ text, thinkingText }: KeywordStreamingViewProps) {
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
const keywords = useMemo(() => extractStreamingKeywords(text), [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
思考过程
|
||||
</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{thinkOpen
|
||||
? <ChevronDown className="size-3.5" />
|
||||
: <ChevronRight className="size-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{thinkOpen && (
|
||||
<pre
|
||||
ref={thinkRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{!text && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[keyword-blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-sky-200 dark:border-sky-900 bg-sky-50/60 dark:bg-sky-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-200/70 dark:border-sky-900/60 bg-sky-100/45 dark:bg-sky-900/30">
|
||||
<Search className="size-3.5 text-sky-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-sky-700 dark:text-sky-300">
|
||||
生成检索词
|
||||
{keywords.length > 0 && (
|
||||
<span className="ml-1 text-sky-600/80 dark:text-sky-300/80 font-normal">
|
||||
· 已识别 {keywords.length} 个方向
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-sky-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5">
|
||||
{keywords.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((keyword, idx) => (
|
||||
<span
|
||||
key={`${keyword}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] bg-sky-100 dark:bg-sky-900/60 text-sky-700 dark:text-sky-200 animate-[keyword-fade-in_0.25s_ease-out]"
|
||||
style={{ animationFillMode: 'both' }}
|
||||
>
|
||||
<Hash className="size-2.5 shrink-0" />
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground italic flex items-center gap-1">
|
||||
等待模型提炼检索方向
|
||||
<span className="inline-block w-0.5 h-3 bg-sky-400 align-text-bottom animate-[keyword-blink_1s_step-end_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes keyword-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
@keyframes keyword-fade-in {
|
||||
from { opacity: 0; transform: translateY(-2px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
|
||||
'draft_chunk',
|
||||
'outline_chunk',
|
||||
'intent_chunk',
|
||||
'keyword_chunk',
|
||||
'skill_select_chunk',
|
||||
'review_chunk',
|
||||
]);
|
||||
|
||||
@@ -66,6 +66,8 @@ const INITIAL_STATE: AIWritingState = {
|
||||
intentThinkingText: '',
|
||||
parsedIntent: null,
|
||||
researchProgress: '',
|
||||
keywordStreamingText: '',
|
||||
keywordThinkingText: '',
|
||||
skillSearchSteps: [],
|
||||
skillSelectStreamingText: '',
|
||||
skillSelectThinkingText: '',
|
||||
@@ -126,7 +128,14 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
currentPause: null,
|
||||
status: ns,
|
||||
skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending,
|
||||
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
|
||||
...(ns === 'researching' ? {
|
||||
researchProgress: '',
|
||||
keywordStreamingText: '',
|
||||
keywordThinkingText: '',
|
||||
skillSearchSteps: [],
|
||||
skillSelectStreamingText: '',
|
||||
skillSelectThinkingText: '',
|
||||
} : {}),
|
||||
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
|
||||
@@ -160,6 +169,7 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
outlineStreamingText: '', outlineThinkingText: '',
|
||||
streamingDraftSections: [], streamingDraftTitle: '',
|
||||
streamingReviewItems: [],
|
||||
keywordStreamingText: '', keywordThinkingText: '',
|
||||
skillSelectStreamingText: '', skillSelectThinkingText: '',
|
||||
};
|
||||
|
||||
@@ -260,6 +270,18 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
case 'research_progress':
|
||||
return { ...base, researchProgress: event.message ?? '' };
|
||||
|
||||
case 'keyword_chunk': {
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
const visibleText = text || (prev.keywordStreamingText ? '' : ' ');
|
||||
return {
|
||||
...prev,
|
||||
status: 'researching',
|
||||
keywordStreamingText: isThinking ? prev.keywordStreamingText : prev.keywordStreamingText + visibleText,
|
||||
keywordThinkingText: isThinking ? prev.keywordThinkingText + text : prev.keywordThinkingText,
|
||||
};
|
||||
}
|
||||
|
||||
case 'sample_profile_ready':
|
||||
return {
|
||||
...base,
|
||||
@@ -299,6 +321,8 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
...base,
|
||||
materials: (event.materials ?? []) as AIMaterial[],
|
||||
researchProgress: '',
|
||||
keywordStreamingText: '',
|
||||
keywordThinkingText: '',
|
||||
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
|
||||
};
|
||||
}
|
||||
@@ -852,7 +876,7 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
'intent_chunk', 'intent_ready',
|
||||
'sample_chunk',
|
||||
'sample_profile_ready',
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'research_progress', 'keyword_chunk', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
'skill_search_step', 'skill_select_chunk',
|
||||
'model_switch',
|
||||
|
||||
@@ -179,6 +179,7 @@ export type ProgressEventType =
|
||||
| "sample_profile_ready"
|
||||
| "intent_chunk"
|
||||
| "intent_ready"
|
||||
| "keyword_chunk"
|
||||
| "research_progress"
|
||||
| "materials_ready"
|
||||
| "outline_chunk"
|
||||
@@ -408,6 +409,9 @@ export interface AIWritingState {
|
||||
parsedIntent: ParsedIntent | null;
|
||||
// 素材收集实时进度(最新一条提示文案)
|
||||
researchProgress: string;
|
||||
// 检索词生成 LLM 流式打字内容(正文 / 思考分开)
|
||||
keywordStreamingText: string;
|
||||
keywordThinkingText: string;
|
||||
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
|
||||
skillSearchSteps: SkillSearchStep[];
|
||||
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
|
||||
|
||||
+65
-7
@@ -25,15 +25,18 @@ import re
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..search import get_search_provider, SearchResult
|
||||
from ..prompts.researcher_prompts import (
|
||||
EXTRACT_KEYWORDS_SYSTEM,
|
||||
EXTRACT_KEYWORDS_USER,
|
||||
RANK_MATERIALS_SYSTEM,
|
||||
RANK_MATERIALS_USER,
|
||||
)
|
||||
from ..search import SearchResult, get_search_provider
|
||||
from ..search.skill_search import SkillPicker
|
||||
from ..state import AIWritingState, Material, MaterialPackage
|
||||
from ..prompts.researcher_prompts import (
|
||||
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
|
||||
RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER,
|
||||
)
|
||||
from ._agent_runner import (
|
||||
KEYWORDS_READY_MARKER,
|
||||
MATERIALS_RANKED_MARKER,
|
||||
@@ -45,7 +48,16 @@ from ._agent_runner import (
|
||||
use_builtin_agent,
|
||||
)
|
||||
from ._skill_agent import run_skill_via_real_agent, use_skill_search_agent
|
||||
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
|
||||
from ._utils import (
|
||||
ThinkTagParser,
|
||||
_content_to_text,
|
||||
get_ai_writing_config,
|
||||
get_model,
|
||||
llm_json,
|
||||
parse_json,
|
||||
reasoning_delta,
|
||||
safe_get_stream_writer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -227,6 +239,40 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
|
||||
return (max_materials + total_keywords - 1) // total_keywords
|
||||
|
||||
|
||||
async def _stream_llm_json(
|
||||
system: str,
|
||||
user: str,
|
||||
*,
|
||||
fallback: dict,
|
||||
config: RunnableConfig,
|
||||
model_name: str | None,
|
||||
disable_thinking: bool,
|
||||
on_text,
|
||||
) -> dict:
|
||||
"""流式生成一段 JSON,并把正文 / 思考片段透传给前端过程卡。"""
|
||||
messages = [SystemMessage(content=system), HumanMessage(content=user)]
|
||||
full_text = ""
|
||||
parser = ThinkTagParser()
|
||||
try:
|
||||
model = get_model(config, model_name=model_name, disable_thinking=disable_thinking)
|
||||
async for chunk in model.astream(messages):
|
||||
reasoning = reasoning_delta(chunk)
|
||||
if reasoning:
|
||||
on_text(reasoning, True)
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
full_text += text
|
||||
if text:
|
||||
on_text(text, is_thinking)
|
||||
return parse_json(full_text, fallback)
|
||||
except Exception as e:
|
||||
logger.error("stream_llm_json failed: %s", e, exc_info=True)
|
||||
return {**fallback, "_llm_error": str(e)}
|
||||
|
||||
|
||||
# 「再追加 N 条素材」之类的数量指令:动词 + 数字(+ 量词 + 名词)。
|
||||
_APPEND_COUNT_VERBS = "追加|新增|增加|补充|再来|多来|多给|多找|多搜|多检索|再给|再要|再找|再搜|再加"
|
||||
_APPEND_COUNT_RE = re.compile(
|
||||
@@ -467,6 +513,15 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
def _emit(message: str) -> None:
|
||||
writer({"type": "research_progress", "message": message})
|
||||
|
||||
def _emit_keyword_chunk(text: str = "", is_thinking: bool = False) -> None:
|
||||
writer({
|
||||
"type": "keyword_chunk",
|
||||
"agent_name": researcher_label,
|
||||
"message": "生成检索词",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
})
|
||||
|
||||
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」。
|
||||
# 仅在**首次**进入(无任何 pause 干预 = intervention 为空)时短路,避免后续
|
||||
# 「补充检索 / 按输入追加」等再入研究节点时被误跳过。直接产出空素材包、强制
|
||||
@@ -501,6 +556,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
_emit(f"追加检索 {len(keywords)} 个关键词…")
|
||||
else:
|
||||
_emit("正在生成检索词…")
|
||||
_emit_keyword_chunk()
|
||||
if use_agent:
|
||||
kw_result = await run_writing_agent(
|
||||
agent_id=RESEARCHER_AGENT_ID,
|
||||
@@ -514,12 +570,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
fallback={"keywords": [intent]},
|
||||
state=state,
|
||||
config=config,
|
||||
on_text=_emit_keyword_chunk,
|
||||
explicit_model_fallback=keyword_model,
|
||||
strict_model=False,
|
||||
disable_thinking=True,
|
||||
)
|
||||
else:
|
||||
kw_result = await llm_json(
|
||||
kw_result = await _stream_llm_json(
|
||||
EXTRACT_KEYWORDS_SYSTEM,
|
||||
EXTRACT_KEYWORDS_USER.format(
|
||||
user_intent=intent,
|
||||
@@ -531,6 +588,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
config=config,
|
||||
model_name=keyword_model,
|
||||
disable_thinking=True,
|
||||
on_text=_emit_keyword_chunk,
|
||||
)
|
||||
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
|
||||
keywords = _clean_keywords(kw_result.get("keywords"), intent)
|
||||
|
||||
Reference in new issue
Block a user