merge: integrate dhk-dev into dhk-1

This commit is contained in:
董宏凯 committed 2026-07-23 10:38:48 +08:00
commit 08ce3dda73
106 files changed
+11921 -910

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=flase
VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true
@@ -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 / 意图节点带答案」推进,如需调整请指出。
+2 -2
View File
@@ -109,8 +109,8 @@ window.__CM_RUNTIME_CONFIG__ = {
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
VITE_MOCK_COP_TASK_DETAIL: "false",
// 大屏绘制 selectSqReport 是否使用本地 mock:false=真实接口;true=本地 mock。
VITE_MOCK_SQ_REPORT: "false",
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
// 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "false",
@@ -11,6 +11,7 @@ import {
LoaderIcon,
PackageIcon,
SaveIcon,
SparklesIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
@@ -37,6 +38,7 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
@@ -216,6 +218,10 @@ export function ArtifactFileDetail({
!useWriteFileStreaming &&
!isSkillFile &&
!isMock;
// 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个
// 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间
// 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。
const canUseAiEditor = canEditArtifact;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
@@ -234,7 +240,7 @@ export function ArtifactFileDetail({
return "FILE";
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
@@ -274,13 +280,18 @@ export function ArtifactFileDetail({
return;
}
if (isSupportPreview) {
setViewMode("preview");
return;
}
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
return;
}
if (isSupportPreview) {
setViewMode("preview");
return;
}
setViewMode("code");
}, [isPreviewStreaming, isSupportPreview]);
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
useEffect(() => {
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
@@ -583,28 +594,30 @@ export function ArtifactFileDetail({
{fileTypeLabel}
</span>
{!hideFileName && (
<ArtifactTitle className="min-w-0">
{useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((artifactPath) => (
<SelectItem key={artifactPath} value={artifactPath}>
{getFileName(artifactPath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<div className="truncate px-3">{getFileName(filepath)}</div>
)}
</ArtifactTitle>
<ArtifactTitle className="min-w-0">
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="text-foreground h-8 min-w-0 max-w-[16rem] border-none bg-transparent! px-3 shadow-none select-none focus-visible:ring-0 focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((artifactPath) => (
<SelectItem key={artifactPath} value={artifactPath}>
{getFileName(artifactPath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<div className="truncate px-3">{getFileName(filepath)}</div>
)}
</ArtifactTitle>
)}
</div>
</div>
@@ -625,10 +638,19 @@ export function ArtifactFileDetail({
onValueChange={(value) => {
if (value) {
setAllowAutoViewSwitch(false);
setViewMode(value as "code" | "preview");
setViewMode(value as "code" | "preview" | "edit");
}
}}
>
{canUseAiEditor && (
<ToggleGroupItem
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
value="edit"
aria-label="AI 写作编辑"
>
<SparklesIcon />
</ToggleGroupItem>
)}
<ToggleGroupItem
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
value="code"
@@ -789,6 +811,17 @@ export function ArtifactFileDetail({
滑动到底部并继续自动下滑
</button>
)}
{viewMode === "edit" && canUseAiEditor && (
<MarkdownAiEditor
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
markdown={displayContent ?? ""}
onChange={(value) => {
setDraftContent(value);
setHasLocalEdits(true);
}}
threadId={threadId}
/>
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" ||
@@ -423,6 +423,7 @@ const ChatBox: React.FC<{
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
@@ -437,6 +438,7 @@ const ChatBox: React.FC<{
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : hasCitationReferences ? (
<div className="relative size-full overflow-hidden">
@@ -1,4 +1,4 @@
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -9,10 +9,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useEffect, useState } from "react";
export function SkillDisplaySources({
highlightedReferenceId,
@@ -51,6 +53,9 @@ export function SourceCard({
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const [contextError, setContextError] = useState<string | null>(null);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const cleanupWords =
@@ -73,6 +78,44 @@ export function SourceCard({
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
const previewTarget = parseWeKnoraPreviewUrl(source.url);
useEffect(() => {
if (!open || !previewTarget) {
return;
}
let cancelled = false;
setContext(null);
setContextError(null);
setContextLoading(true);
const params = new URLSearchParams({
knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId,
});
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
.then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
throw new Error(readPreviewError(payload, response.status));
}
if (!cancelled) {
setContext(payload.data);
}
})
.catch((error: unknown) => {
if (!cancelled) {
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
}
})
.finally(() => {
if (!cancelled) {
setContextLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
return (
<>
@@ -114,7 +157,14 @@ export function SourceCard({
)}
</div>
{source.url && (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
previewTarget ? (
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
<FileTextIcon className="size-3.5" />
查看原文
</span>
) : (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)
)}
</div>
</button>
@@ -141,7 +191,7 @@ export function SourceCard({
</DialogDescription>
)}
</div>
{source.url && (
{source.url && !previewTarget && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
@@ -155,7 +205,13 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{paragraphs.length > 0 && (
{previewTarget ? (
<WeKnoraSourceContextPanel
context={context}
loading={contextLoading}
error={contextError}
/>
) : paragraphs.length > 0 ? (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
@@ -163,8 +219,8 @@ export function SourceCard({
</p>
))}
</div>
)}
{source.url && (
) : null}
{source.url && !previewTarget && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
@@ -175,6 +231,118 @@ export function SourceCard({
);
}
type WeKnoraPreviewTarget = {
knowledgeBaseId: string;
chunkId: string;
};
type WeKnoraSourceContext = {
knowledge_base_name?: string;
document_title?: string;
document_file_name?: string;
focus_chunk_id: string;
chunks: Array<{
id: string;
chunk_index?: number;
content: string;
is_focus: boolean;
}>;
};
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
}
function isWeKnoraSourceContextResponse(
value: unknown,
): value is { success: true; data: WeKnoraSourceContext } {
if (!value || typeof value !== "object") {
return false;
}
const data = (value as { data?: unknown }).data;
return (
(value as { success?: unknown }).success === true &&
!!data &&
typeof data === "object" &&
Array.isArray((data as WeKnoraSourceContext).chunks)
);
}
function readPreviewError(payload: unknown, status: number): string {
if (payload && typeof payload === "object") {
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === "string" && detail) {
return detail;
}
}
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
}
function WeKnoraSourceContextPanel({
context,
loading,
error,
}: {
context: WeKnoraSourceContext | null;
loading: boolean;
error: string | null;
}) {
if (loading) {
return (
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
<LoaderCircle className="size-4 animate-spin" />
正在加载原文上下文…
</div>
);
}
if (error) {
return (
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
{error}
</div>
);
}
if (!context) {
return null;
}
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
{context.document_title && <span> · {context.document_title}</span>}
</div>
{context.chunks.map((chunk) => (
<section
key={chunk.id}
className={cn(
"rounded-lg border p-3 text-sm leading-relaxed",
chunk.is_focus
? "border-primary/50 bg-primary/5 shadow-sm"
: "bg-muted/20",
)}
>
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
{chunk.is_focus && (
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
检索命中
</span>
)}
</div>
<p className="whitespace-pre-wrap">{chunk.content}</p>
</section>
))}
</div>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],
@@ -1,277 +1,277 @@
"use client";
import {
BellIcon,
BotIcon,
BrainIcon,
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
PlugIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
SparklesIcon,
TagsIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
loadLogSystemSettings,
type LogSystemSettings,
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
function NavMenuButtonContent({
isSidebarOpen,
t,
}: {
isSidebarOpen: boolean;
t: ReturnType<typeof useI18n>["t"];
}) {
return isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<SettingsIcon className="size-4" />
<span>{t.workspace.settingsAndMore}</span>
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
</div>
) : (
<div className="flex size-full items-center justify-center">
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
</div>
);
}
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
loadLogSystemSettings()
.then((settings) => {
if (!cancelled) setLogSystem(settings);
})
.catch(() => {
if (!cancelled) setLogSystem(null);
});
return () => {
cancelled = true;
};
}, [isAdmin]);
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
setSettingsOpen(true);
}}
>
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/scheduled-tasks")
}
>
<CalendarClockIcon />
定时任务管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/agents")}
>
<BotIcon />
智能体管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills")}
>
<SparklesIcon />
技能管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
>
<BriefcaseIcon />
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
window.open(
logSystem.url,
"_blank",
"noopener,noreferrer",
)
}
>
<ScrollTextIcon />
{logSystem.label || "日志系统"}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton size="lg" className="pointer-events-none">
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
</>
);
}
"use client";
import {
BellIcon,
BotIcon,
BrainIcon,
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
PlugIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
SparklesIcon,
TagsIcon,
TrophyIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
loadLogSystemSettings,
type LogSystemSettings,
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
function NavMenuButtonContent({
isSidebarOpen,
t,
}: {
isSidebarOpen: boolean;
t: ReturnType<typeof useI18n>["t"];
}) {
return isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<SettingsIcon className="size-4" />
<span>{t.workspace.settingsAndMore}</span>
<ChevronsUpDown className="text-sidebar-foreground/55 ml-auto size-4" />
</div>
) : (
<div className="flex size-full items-center justify-center">
<SettingsIcon className="text-sidebar-foreground/80 size-4" />
</div>
);
}
export function WorkspaceNavMenu() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
loadLogSystemSettings()
.then((settings) => {
if (!cancelled) setLogSystem(settings);
})
.catch(() => {
if (!cancelled) setLogSystem(null);
});
return () => {
cancelled = true;
};
}, [isAdmin]);
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
setSettingsOpen(true);
}}
>
<Settings2Icon />
{t.common.settings}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/scheduled-tasks")
}
>
<CalendarClockIcon />
定时任务管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/agents")}
>
<BotIcon />
智能体管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills")}
>
<SparklesIcon />
技能管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
>
<BriefcaseIcon />
岗位管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate(
"/page/workspace/settings/recommended-questions",
)
}
>
<MessageCircleQuestionIcon />
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
window.open(
logSystem.url,
"_blank",
"noopener,noreferrer",
)
}
>
<ScrollTextIcon />
{logSystem.label || "日志系统"}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton size="lg" className="pointer-events-none">
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
</>
);
}
+38 -207
View File
@@ -1,101 +1,19 @@
/**
* 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。
* 大屏绘制智能体的任务输入适配层。
*
* 设计意图:深链 `?taskId=` 模式下,后端提供「研判情报报告」接口
* GET `{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=`
* 返回该任务的多类情报内容;本页把它**提取成干净的结构化 JSON** 交给智能体分析
* → 产出 report-json → 绘制大屏(react / html)。
*
* 接口返回结构:`{ state, msg, data: [{ id, taskId, ..., contentJson, categoryType }] }`,
* 其中 `contentJson` 是**被转义的 JSON 字符串**,`categoryType` 形如「敌情关键事件 JSON」。
* 本模块 `extractSqReport` 把每条的 `contentJson` 反序列化、按 `categoryType` 归类,
* 拼成 `{ taskId, categories: { 敌情关键事件: [...], 决心建议: {...}, ... } }` 喂给智能体。
*
* ⚠️ 真实接口现在还**请求不到**:通过 `VITE_MOCK_SQ_REPORT=true`(见 `utils/request.ts`)
* 走后端给的样例假数据(`sq-report-mock.json`),机制与 cop-task-detail 假数据一致。
* 联调通过后把该 env 置 false 即走真实接口;接口/数据若有变只需改 `extractSqReport`。
* 报告详情由 Gateway 的 `/api/task-reports/by-task/{taskId}` 提供,返回格式
* 与原 `selectSqReport` 一致:`{state, msg, data}`。每条 `contentJson` 是
* JSON 字符串,本模块将其按 `categoryType` 解析为智能体和大屏页面可直接使用的
* 结构化输入。
*/
import { requestCrudService } from "@/service/request-axios-service";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { getTaskReport } from "@/strategy-components/api/task-reports";
import { readRuntimeFlag } from "@/env";
import { SQ_REPORT_MOCK_RESPONSE } from "@/strategy-components/components/task-components/sq-report-mock-data";
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
/** 保留给既有调用方的 sessionStorage key;报告本身不再使用 mock 缓存。 */
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
/**
* 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。
* 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。
* ⚠️ 接口就绪后删除本常量及其兜底引用。
*/
export const DEMO_DASHBOARD_INPUT = {
taskId: "DEMO-涉台舆情",
taskName: "2026 第一季度涉台舆情态势研判",
taskContent:
"汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。",
overview:
"本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。",
period: "2026-01-01 ~ 2026-03-31",
kpis: {
总信息量: 1284000,
日均增速: "12%",
正向占比: "58%",
负向占比: "19%",
识别风险点: 7,
覆盖平台: 12,
},
channels: [
{ platform: "微博", share: 34 },
{ platform: "境外社媒(X/FB)", share: 27 },
{ platform: "短视频", share: 18 },
{ platform: "新闻门户", share: 13 },
{ platform: "论坛/贴吧", share: 8 },
],
narratives: [
{ theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" },
{ theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" },
{ theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" },
{ theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" },
],
actors: [
{ name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" },
{ name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" },
{ name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" },
{ name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" },
],
risks: [
{ label: "军事议题被恶意剪辑放大", level: "high" },
{ label: "境外智库联动制造紧张框架", level: "high" },
{ label: "个别大V情绪化带节奏", level: "mid" },
{ label: "短视频谣言二次传播", level: "mid" },
{ label: "节假日期间信息量骤增", level: "low" },
],
timeline: [
{ phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" },
{ phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" },
{ phase: "行为体画像", desc: "关键账号立场与影响力建模" },
{ phase: "风险研判", desc: "识别高危议题与传播链路" },
{ phase: "处置建议", desc: "分级响应与正向引导预案" },
],
weeklyVolume: [
{ week: "W1", value: 86 },
{ week: "W2", value: 92 },
{ week: "W3", value: 110 },
{ week: "W4", value: 134 },
{ week: "W5", value: 121 },
{ week: "W6", value: 158 },
],
};
/** 「研判情报报告」接口完整地址:`{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport`。 */
function sqReportUrl(): string {
const value = readRuntimeEnv("VITE_AIPI_CONSUMER_URL");
const base = typeof value === "string" ? value.replace(/\/+$/, "") : "";
return base ? `${base}/cop/selectSqReport` : "";
}
/** 把单条 `contentJson` 反序列化为对象/数组/原值(已是对象则原样返回)。 */
function parseContentJson(raw: unknown): unknown {
if (typeof raw !== "string") return raw ?? null;
const text = raw.trim();
@@ -103,11 +21,11 @@ function parseContentJson(raw: unknown): unknown {
try {
return JSON.parse(text);
} catch {
return text; // 解析失败就把原始字符串透传给智能体,让它自行理解
// 非 JSON 字符串仍透传给智能体,避免因单条异常让整份报告不可用。
return text;
}
}
/** 去掉 categoryType 末尾的「 JSON」后缀,作为归类键(如「敌情关键事件 JSON」→「敌情关键事件」)。 */
function categoryKey(categoryType: unknown, index: number): string {
const raw = typeof categoryType === "string" ? categoryType.trim() : "";
const cleaned = raw.replace(/\s*JSON\s*$/i, "").trim();
@@ -115,157 +33,70 @@ function categoryKey(categoryType: unknown, index: number): string {
}
/**
* 把 selectSqReport 的整包响应**提取**成喂给智能体的干净结构化输入:
* `{ taskId, source, categories: { [类目]: 反序列化后的内容 } }`
* 返回 null 表示无可用内容(调用方据此回退/不开跑)。
* 将接口 envelope 提取为 `{ taskId, source, categories }`。
* 返回 null 表示该任务尚未保存可用的报告内容。
*/
export function extractSqReport(body: unknown, taskId: string): unknown | null {
const data = (body && typeof body === "object" ? (body as { data?: unknown }).data : null) as
| Array<Record<string, unknown>>
| undefined;
if (!Array.isArray(data) || data.length === 0) return null;
const categories: Record<string, unknown> = {};
data.forEach((item, index) => {
const content = parseContentJson(item?.contentJson);
if (content == null) return;
let key = categoryKey(item?.categoryType, index);
if (key in categories) key = `${key}(${index + 1})`; // 同名类目去重保留
if (key in categories) key = `${key}(${index + 1})`;
categories[key] = content;
});
if (Object.keys(categories).length === 0) return null;
return { taskId, source: "cop/selectSqReport", categories };
return { taskId, source: "api/task-reports", categories };
}
/**
* 输入 JSON 缓存**仅供开发(mock)使用**:`VITE_MOCK_SQ_REPORT==='true'` 时才读/写缓存,
* 避免开发反复请求样例假数据。真实接口模式(false)下不读不写缓存,**始终拉取真实数据**。
*/
function inputCacheEnabled(): boolean {
return readRuntimeFlag("VITE_MOCK_SQ_REPORT");
}
/** 读取暂存的输入 JSON(仅 mock 开发模式生效,真实模式恒返回 null 以强制重新拉取)。 */
/** 兼容旧调用方:报告由接口实时读取,不再在浏览器中缓存。 */
export function readDashboardInputJson(): unknown | null {
if (!inputCacheEnabled()) return null;
try {
const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
return null;
}
/** 暂存输入 JSON(仅 mock 开发模式生效;隐私模式/quota 异常静默失败)。 */
export function persistDashboardInputJson(data: unknown): void {
if (!inputCacheEnabled()) return;
try {
sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
} catch {
/* noop */
}
/** 兼容旧调用方:报告数据只由后端接口维护。 */
export function persistDashboardInputJson(_data: unknown): void {
// noop
}
/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */
function placeholderFromCopTask(taskId: string): unknown | null {
const detail = readCopTaskDetail();
if (!detail) return taskId ? { taskId } : null;
return {
taskId,
taskName: detail.taskName,
taskContent: detail.taskContent,
overview: detail.overview,
taskDirection: detail.taskDirection,
_note: "占位输入(真实业务 JSON 接口未就绪)",
};
}
/**
* 取本页的输入 JSON:**先请求 selectSqReport,待其返回后再交给模型抽取**。
*
* - 真实接口模式(`VITE_MOCK_SQ_REPORT=false`):不读缓存、不兜底假数据——
* **只用接口真实返回的内容**;请求失败或返回空一律返回 `null`(页面据此提示「请求失败/无数据」,
* 绝不拿占位/演示数据去喂模型,避免「没等接口就抽取假数据」)。
* - mock 开发模式(true):走本地样例假数据(见 utils/request.ts)并缓存避免反复请求;
* 仅此模式在接口不可用时回退 copTaskDetail 占位 / DEMO 演示数据,保证开发能跑通。
*/
/** 查询并提取当前任务的报告详情;失败或空数据均返回 null。 */
export async function fetchDashboardInputJson(
taskId: string,
opts?: { forceRefresh?: boolean },
_opts?: { forceRefresh?: boolean },
): Promise<unknown | null> {
const mock = inputCacheEnabled(); // true=mock/开发;false=真实接口
// forceRefresh(重新分析/修改时):跳过缓存,强制重新请求接口拿**最新数据**(数据可能已变化)。
const cached = opts?.forceRefresh ? null : readDashboardInputJson(); // 真实模式恒 null(缓存仅开发用)
if (cached) return cached;
const url = sqReportUrl();
if (url && taskId) {
try {
// service 拦截器统一注入鉴权并返回 response.data(整包 body)。
// await 在此:必须**等接口返回**后才有 body 可 extract → 再由上层启动模型抽取。
const body = await requestCrudService.get(url, { taskId });
const extracted = extractSqReport(body, taskId);
if (extracted) {
persistDashboardInputJson(extracted); // 真实模式 no-op
return extracted;
}
} catch {
/* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */
}
if (!taskId) return null;
// 开发演示模式使用与 Gateway 接口同形的本地样例;这样不依赖报告库是否已为
// 当前深链 taskId 写入数据,同时真实/生产环境仍始终从 Gateway 读取。
if (readRuntimeFlag("VITE_MOCK_SQ_REPORT")) {
return extractSqReport(
{
...SQ_REPORT_MOCK_RESPONSE,
data: SQ_REPORT_MOCK_RESPONSE.data.map((item) => ({ ...item, taskId })),
},
taskId,
);
}
// 真实接口模式:**不兜底假数据**。失败/空 → null,让页面提示而不是拿假数据喂模型。
if (!mock) return null;
// 以下兜底**仅 mock/开发模式**:copTaskDetail 占位 / DEMO 演示数据,保证开发能看效果。
const placeholder = placeholderFromCopTask(taskId);
const detail = readCopTaskDetail();
const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
if (resolved) persistDashboardInputJson(resolved);
return resolved;
const body = await getTaskReport(taskId);
return extractSqReport(body, taskId);
}
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
/**
* 「3q(研判情报报告)是否完成」状态:
* - ``complete`` = selectSqReport 数组里有有效内容;
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
*/
export type SqReportStatus = "complete" | "incomplete" | "unknown";
export interface SqReportCheckResult {
status: SqReportStatus;
/** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */
report?: unknown;
}
/**
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
* - 有可用内容 → ``complete``(正常);
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
*
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
*/
/** rwfx 进入前检查:任务已有可解析报告则 complete,否则 incomplete。 */
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
if (!taskId) return { status: "unknown" };
const cfg = await fetchTaskDeeplinkConfig();
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
if (cfg.test) {
const report = extractSqReport(cfg.mock.sqReport, taskId);
return report ? { status: "complete", report } : { status: "incomplete" };
}
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
const url = sqReportUrl();
if (!url) return { status: "unknown" };
try {
const body = await requestCrudService.get(url, { taskId });
const report = extractSqReport(body, taskId);
const report = await fetchDashboardInputJson(taskId);
return report ? { status: "complete", report } : { status: "incomplete" };
} catch {
return { status: "unknown" };
@@ -86,6 +86,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
{
id: "qa-research-chat",
label: "课题研究(对话版)",
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
],
},
{
@@ -0,0 +1,445 @@
/**
* 对话驱动写作台的「意图解析」客户端。
*
* 两级解析:
* 1. `fastPathIntent`:高频确定性短句的前端规则快路径(确认/继续/重新搜索/重写大纲…),
* 零延迟、零误判;**高代价动作(定稿/强制定稿/覆盖式重搜的模糊说法)绝不走快路径**。
* 2. `streamWritingIntent`:POST /api/ai-writing/sessions/{id}/intent/stream —— 后端读该会话的
* graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 流式解析,实时返回 thinking/text,
* 最后一帧返回结构化意图契约。`parseWritingIntent` 保留为一次性兼容接口。
*
* 返回的 payload 字段与 `UserInterventionPayload` 对齐,可直接喂 `submitIntervention`。
*/
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { BlockedSection, UserInterventionPayload } from '../types/ai-writing';
export type WritingIntentSource = 'rule' | 'llm';
export interface WritingIntentResult {
/** 意图标识:confirm_materials / research_again / revise_draft / ask_content / clarify / run_background… */
intent: string;
/** 需 resume 的暂停点(后端以 checkpoint 里真实 interrupt 为准回传);无动作时为 null */
pausePoint: string | null;
/** 映射到现有协议的 action;null = 不执行图动作(问答/澄清/控制类) */
action: string | null;
/** action 非空时可直接喂 submitIntervention 的载荷 */
payload: UserInterventionPayload | null;
/** 用户的要求需要现有素材之外的信息(素材确认阶段可自动重搜续跑,其余阶段仅提示) */
needResearch: boolean;
researchQuery: string;
/** 问答类意图(ask_content / ask_meta)的直接回答 */
answer: string;
confidence: number;
/** 非空 = 需要向用户澄清,不执行任何动作 */
clarify: string;
source: WritingIntentSource;
}
/** 前端判定「执行动作」的置信度下限(后端 normalize 已兜底一层,这里再保守一档)。 */
export const INTENT_CONFIDENCE_THRESHOLD = 0.55;
// ── 规则快路径 ────────────────────────────────────────────────────────────────
const CONFIRM_RE = /^(继续|下一步|确认|好的|好|可以|行|嗯|没问题|OK|ok|Ok|开始吧|通过)$/;
const RESEARCH_AGAIN_RE = /^(重新搜索|重新检索|重搜|再搜一次|重新搜)$/;
const REOUTLINE_RE = /^(重新规划大纲|重写大纲|大纲重来|重新规划)$/;
const TO_EDITOR_RE = /^((交给|让)?编辑(继续)?审核|送审|提交(编辑)?审核)$/;
const ACCEPT_REVIEW_RE = /^(按编辑(的)?意见(修改|改)|采纳编辑意见)$/;
const SECTION_ALL_SUPPLEMENT_RE = /^(都|全部|全都)(补充|补)检索(素材)?$/;
const SECTION_ALL_LOOSE_RE = /^(都|全部|全都)(按通用知识|用通用知识)?(续写|写|继续写)$/;
const WRITING_ACTION_VERB_RE = /(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)/;
const WRITING_ACTION_TARGET_RE = /(第[一二三四五六七八九十\d]+[章节段]|章节|章|小节|段落|段|结尾|开头|标题|大纲|内容|案例|表格|总结章|总结章节|结论|摘要|观点|部分)/;
const INFO_QUESTION_RE = /(哪些|什么|为什么|是什么|有哪些|多少|是否)/;
function stripPunct(text: string): string {
return text.trim().replace(/[。..!!~~\s]+$/g, '');
}
function looksLikeWritingChangeRequest(text: string): boolean {
if (INFO_QUESTION_RE.test(text)) return false;
return WRITING_ACTION_VERB_RE.test(text) && WRITING_ACTION_TARGET_RE.test(text);
}
function ruleResult(
intent: string,
pausePoint: string | null,
action: string | null,
payload: UserInterventionPayload | null,
): WritingIntentResult {
return {
intent,
pausePoint,
action,
payload,
needResearch: false,
researchQuery: '',
answer: '',
confidence: 1,
clarify: '',
source: 'rule',
};
}
/**
* 高频短句规则快路径。命中返回结果,未命中返回 null(走后端 LLM)。
*
* 只做「当前暂停点语义完全无歧义」的映射;一切带自由文本的要求(补充检索内容、
* 修改意见等)与高代价动作(定稿/强制定稿)一律交给后端 LLM + 澄清机制。
*/
export function fastPathIntent(
rawText: string,
ctx: {
pausePoint: string | null;
isPaused: boolean;
blockedSections?: BlockedSection[];
},
): WritingIntentResult | null {
const text = stripPunct(rawText);
if (!text) return null;
// 「暂停中 + 继续」= 恢复运行(控制类,优先于阶段确认语义)
if (ctx.isPaused && CONFIRM_RE.test(text)) {
return ruleResult('resume_writing', null, null, null);
}
const pp = ctx.pausePoint;
if (!pp) return null;
if (pp === 'outline_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('revise_outline', pp, 're_outline', {
action: 're_outline',
outlineFeedback: rawText.trim(),
});
}
if (pp === 'draft_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('revise_draft', pp, 'user_revise', {
action: 'user_revise',
userRevisionNotes: rawText.trim(),
});
}
if (pp === 'review_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('accept_review_with_user_notes', pp, 'accept_review', {
action: 'accept_review',
userRevisionNotes: rawText.trim(),
});
}
if (text.length > 12) return null;
if (pp === 'material_confirm') {
if (CONFIRM_RE.test(text)) {
// 不带 approvedMaterialIds → 后端默认采用全部素材
return ruleResult('confirm_materials', pp, 'confirm', { action: 'confirm' });
}
if (RESEARCH_AGAIN_RE.test(text)) {
return ruleResult('research_again', pp, 're_search', { action: 're_search', approvedMaterialIds: [] });
}
return null;
}
if (pp === 'outline_confirm') {
if (CONFIRM_RE.test(text)) {
return ruleResult('confirm_outline', pp, 'confirm', { action: 'confirm' });
}
if (REOUTLINE_RE.test(text)) {
return ruleResult('reoutline', pp, 're_outline', { action: 're_outline' });
}
return null;
}
if (pp === 'draft_confirm') {
// 「继续/下一步」在草稿阶段的安全缺省是送审(绝不缺省成定稿)
if (CONFIRM_RE.test(text) || TO_EDITOR_RE.test(text)) {
return ruleResult('to_editor', pp, 'to_editor', { action: 'to_editor' });
}
return null;
}
if (pp === 'review_confirm') {
if (CONFIRM_RE.test(text) || ACCEPT_REVIEW_RE.test(text)) {
// 不带 selectedIssueIndices → 后端按全部意见修改(与后台挂起的默认放行一致)
return ruleResult('accept_review', pp, 'accept_review', { action: 'accept_review' });
}
return null;
}
if (pp === 'section_help') {
const blocked = ctx.blockedSections ?? [];
if (blocked.length === 0) return null;
const buildDecisions = (decision: 'supplement' | 'loose') => ({
action: 'section_help',
sectionDecisions: blocked.map((b) => ({ sectionTitle: b.sectionTitle, decision })),
});
if (SECTION_ALL_SUPPLEMENT_RE.test(text)) {
return ruleResult('section_supplement_all', pp, 'section_help', buildDecisions('supplement'));
}
if (SECTION_ALL_LOOSE_RE.test(text)) {
return ruleResult('section_loose_all', pp, 'section_help', buildDecisions('loose'));
}
// 「继续/确认」在求助点语义含糊(补检索 or 续写?),交给 LLM 澄清
return null;
}
return null;
}
// ── 后端 LLM 解析 ─────────────────────────────────────────────────────────────
interface RawIntentResponse {
intent?: string;
pause_point?: string | null;
action?: string | null;
payload?: Record<string, unknown> | null;
need_research?: boolean;
research_query?: string | null;
answer?: string | null;
confidence?: number;
clarify?: string | null;
}
export type WritingIntentStreamEvent =
| { type: 'thinking'; chunk: string }
| { type: 'text'; chunk: string }
| { type: 'result'; result: WritingIntentResult }
| { type: 'done' }
| { type: 'error'; message: string };
export type WritingAnswerStreamEvent =
| { type: 'start' }
| { type: 'thinking'; chunk: string }
| { type: 'text'; chunk: string }
| { type: 'done' }
| { type: 'error'; message: string };
export interface WritingSuggestionMessage {
role: 'user' | 'assistant';
content: string;
}
function normalizePayload(raw: Record<string, unknown> | null | undefined, action: string | null): UserInterventionPayload | null {
if (!action) return null;
const p = raw ?? {};
const payload: UserInterventionPayload = { action };
if (Array.isArray(p.approvedMaterialIds)) payload.approvedMaterialIds = p.approvedMaterialIds.map(String);
if (typeof p.userQuery === 'string' && p.userQuery.trim()) payload.userQuery = p.userQuery.trim();
if (typeof p.outlineFeedback === 'string' && p.outlineFeedback.trim()) payload.outlineFeedback = p.outlineFeedback.trim();
if (typeof p.userRevisionNotes === 'string' && p.userRevisionNotes.trim()) payload.userRevisionNotes = p.userRevisionNotes.trim();
if (typeof p.overrideVerdict === 'boolean') payload.overrideVerdict = p.overrideVerdict;
if (Array.isArray(p.selectedIssueIndices)) {
payload.selectedIssueIndices = p.selectedIssueIndices
.map((n) => Number(n))
.filter((n) => Number.isInteger(n) && n >= 0);
}
if (Array.isArray(p.sectionDecisions)) {
payload.sectionDecisions = (p.sectionDecisions as Array<Record<string, unknown>>)
.map((d) => ({
sectionTitle: String(d.sectionTitle ?? d.section_title ?? ''),
decision: (['supplement', 'loose', 'delete'].includes(String(d.decision))
? String(d.decision)
: 'loose') as 'supplement' | 'loose' | 'delete',
}))
.filter((d) => d.sectionTitle);
}
return payload;
}
function normalizeIntentResponse(raw: RawIntentResponse): WritingIntentResult {
const action = raw.action || null;
return {
intent: raw.intent || 'unknown',
pausePoint: raw.pause_point || null,
action,
payload: normalizePayload(raw.payload, action),
needResearch: Boolean(raw.need_research),
researchQuery: (raw.research_query || '').trim(),
answer: (raw.answer || '').trim(),
confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0,
clarify: (raw.clarify || '').trim(),
source: 'llm',
};
}
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
try {
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 line of lines) {
const trimmed = line.trimEnd();
if (trimmed.startsWith('event: ')) {
currentEvent = trimmed.slice(7);
} else if (trimmed.startsWith('data: ')) {
yield { event: currentEvent, data: trimmed.slice(6) };
currentEvent = '';
} else if (trimmed === '') {
currentEvent = '';
}
}
}
} finally {
reader.releaseLock();
}
}
/** 后端 LLM 意图解析。会话必须已存在(sessionId = graph thread_id)。 */
export async function parseWritingIntent(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null },
): Promise<WritingIntentResult> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
}),
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const raw = (await resp.json()) as RawIntentResponse;
return normalizeIntentResponse(raw);
}
/** 后端 LLM 意图解析的 SSE 流式版本,包含 `<think>`/reasoning 思考增量。 */
export async function* streamWritingIntent(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null },
signal?: AbortSignal,
): AsyncGenerator<WritingIntentStreamEvent> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
}),
signal,
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
if (!resp.body) {
throw new Error('意图解析失败:响应体为空');
}
for await (const { event, data } of parseSse(resp.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === 'thinking') {
yield { type: 'thinking', chunk: String(parsed.chunk ?? '') };
} else if (event === 'text') {
yield { type: 'text', chunk: String(parsed.chunk ?? '') };
} else if (event === 'result') {
yield { type: 'result', result: normalizeIntentResponse(parsed as RawIntentResponse) };
} else if (event === 'done') {
yield { type: 'done' };
return;
} else if (event === 'error') {
yield { type: 'error', message: String(parsed.message ?? '意图解析流式接口异常') };
return;
}
} catch {
// 忽略畸形帧,等待下一帧或 done。
}
}
}
export async function fetchWritingAnswerSuggestions(
sessionId: string,
body: { messages: WritingSuggestionMessage[]; n?: number; modelName?: string | null },
): Promise<string[]> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/suggestions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: body.messages,
n: body.n ?? 3,
model_name: body.modelName || null,
}),
},
);
if (!resp.ok) return [];
const raw = (await resp.json().catch(() => ({}))) as { suggestions?: unknown[] };
return (raw.suggestions ?? [])
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, body.n ?? 3);
}
export async function* streamWritingAnswer(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null; modelName?: string | null },
signal?: AbortSignal,
): AsyncGenerator<WritingAnswerStreamEvent> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/answer/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
model_name: body.modelName || null,
}),
signal,
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`回答生成失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
if (!resp.body) {
throw new Error('回答生成失败:响应体为空');
}
for await (const { event, data } of parseSse(resp.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === 'start') {
yield { type: 'start' };
} else if (event === 'thinking') {
yield { type: 'thinking', chunk: String(parsed.chunk ?? '') };
} else if (event === 'text') {
yield { type: 'text', chunk: String(parsed.chunk ?? '') };
} else if (event === 'done') {
yield { type: 'done' };
return;
} else if (event === 'error') {
yield { type: 'error', message: String(parsed.message ?? '回答生成失败') };
return;
}
} catch {
// 忽略畸形帧,等待下一帧或 done。
}
}
}
@@ -0,0 +1,29 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
export interface AIWritingSampleExtractResult {
filename: string;
text: string;
char_count: number;
truncated: boolean;
}
export async function extractAIWritingSample(file: File): Promise<AIWritingSampleExtractResult> {
const form = new FormData();
form.append('file', file);
const resp = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/sample/extract`, {
method: 'POST',
body: form,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
let detail = '';
try {
const parsed = text ? JSON.parse(text) as { detail?: unknown } : null;
detail = parsed?.detail ? String(parsed.detail) : '';
} catch {}
if (detail) throw new Error(detail);
throw new Error(text || `样文解析失败:${resp.status}`);
}
return resp.json();
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,920 @@
/**
* 对话版干预卡(AI 写作·对话驱动写作台专用)。
*
* 与旧版 `../ai-writing/InterventionCard.tsx` 的关系:**复制后减法**——
* - 保留:阶段产物展示(素材列表勾选 / 大纲可点选编辑 / 审核意见勾选)+ 全部确认/动作按钮;
* - 移除:每张卡内嵌的自由文本输入框(补充检索输入 / 大纲修改意见 textarea /
* 草稿修改意见 textarea / 审核补充指令 textarea)——这些自由输入统一改由页面
* 底部的智能输入框(WritingChatComposer)承接,经意图解析层翻译成 action。
*
* 按钮点击路径与旧卡完全一致:直接调 `onSubmit(UserInterventionPayload)`,
* 不经意图解析层,零延迟零误判。旧页面的 InterventionCard 不受本文件影响。
*/
import { useState, type ReactNode } from 'react';
import {
ArrowRight, BookOpen, CheckCircle2, ClipboardEdit, FileCheck2, FileText,
ListTree, MessageSquareText, RotateCcw, Search, ShieldAlert, ShieldCheck, Trash2,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, BlockedSection, ProgressEvent, ReviewIssue,
ReviewResult, SectionDecisionKind, SkillSearchStep, UserInterventionPayload,
} from '../../types/ai-writing';
import { normalizeKeyPoint } from '../../types/ai-writing';
import { MaterialsCard } from '../ai-writing/MaterialsCard';
import { OutlineCard } from '../ai-writing/OutlineCard';
import { SkillStepsCard } from '../ai-writing/SkillStepsCard';
interface ChatInterventionCardProps {
pauseEvent: ProgressEvent;
materials: AIMaterial[];
outline: AIOutline | null;
reviewResult: ReviewResult | null;
draftTitle: string;
draftWordCount?: number;
wordCountTarget?: number;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function ChatInterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, draftWordCount, wordCountTarget, revisionCount,
skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: ChatInterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return (
<MaterialConfirmChatCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'outline_confirm') {
return (
<OutlineConfirmChatCard
outline={outline ?? pauseEvent.outline ?? null}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'draft_confirm') {
return (
<DraftConfirmChatCard
draftTitle={draftTitle}
draftWordCount={draftWordCount}
wordCountTarget={wordCountTarget}
revisionCount={revisionCount}
reviewResult={reviewResult}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'section_help') {
return (
<SectionHelpChatCard
blockedSections={pauseEvent.blockedSections ?? []}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmChatCard
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
revisionCount={revisionCount}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
return (
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
<ShieldAlert className="size-3.5 text-yellow-500 shrink-0" />
等待用户确认({pausePoint})…
</div>
);
}
// ── 工具 ──────────────────────────────────────────────────────────────────────
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
return new Set(issues.map((_, i) => i));
}
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => {
const location = iss.location ? `【位置】${iss.location};` : '';
const suggestion = iss.suggestion ? `;【建议】${iss.suggestion}` : '';
return `${n + 1}. [${iss.category}] ${location}${iss.issue}${suggestion}`;
})
.join('\n');
}
type DecisionTone = 'blue' | 'amber' | 'emerald';
interface DecisionMetric {
label: string;
value: string;
tone?: DecisionTone | 'muted';
}
const PANEL_TONE: Record<DecisionTone, {
shell: string;
icon: string;
badge: string;
}> = {
blue: {
shell: 'border-blue-200/80 bg-blue-50/35 dark:border-blue-900/40 dark:bg-blue-950/15',
icon: 'border-blue-200 bg-background text-blue-600 dark:border-blue-900/50 dark:text-blue-300',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300',
},
amber: {
shell: 'border-amber-200/90 bg-amber-50/45 dark:border-amber-900/40 dark:bg-amber-950/20',
icon: 'border-amber-200 bg-background text-amber-600 dark:border-amber-900/50 dark:text-amber-300',
badge: 'bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300',
},
emerald: {
shell: 'border-emerald-200/80 bg-emerald-50/35 dark:border-emerald-900/40 dark:bg-emerald-950/15',
icon: 'border-emerald-200 bg-background text-emerald-600 dark:border-emerald-900/50 dark:text-emerald-300',
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300',
},
};
function metricClass(tone: DecisionMetric['tone']) {
if (tone === 'blue') return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
if (tone === 'amber') return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
if (tone === 'emerald') return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300';
return 'border-border bg-background/80 text-muted-foreground';
}
function DecisionMetricPill({ metric }: { metric: DecisionMetric }) {
return (
<span className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px]', metricClass(metric.tone))}>
<span className="text-muted-foreground">{metric.label}</span>
<span className="font-medium text-foreground/85 dark:text-foreground/80">{metric.value}</span>
</span>
);
}
function DecisionPanel({
tone = 'blue',
icon: Icon,
badge,
title,
description,
metrics,
children,
actions,
hint,
}: {
tone?: DecisionTone;
icon: LucideIcon;
badge: string;
title: string;
description: ReactNode;
metrics?: DecisionMetric[];
children?: ReactNode;
actions?: ReactNode;
hint?: ReactNode;
}) {
const classes = PANEL_TONE[tone];
return (
<section className={cn('overflow-hidden rounded-lg border shadow-sm', classes.shell)}>
<div className="p-3">
<div className="flex items-start gap-2.5">
<div className={cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border', classes.icon)}>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-sm font-semibold leading-5">{title}</h3>
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', classes.badge)}>
{badge}
</span>
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
{description}
</p>
</div>
</div>
{metrics && metrics.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{metrics.map((metric) => (
<DecisionMetricPill key={`${metric.label}-${metric.value}`} metric={metric} />
))}
</div>
)}
</div>
{children && (
<div className="border-y border-background/80 bg-background/72 p-3 dark:bg-background/35">
{children}
</div>
)}
{(actions || hint) && (
<div className="space-y-2 bg-background/55 p-3 dark:bg-background/25">
{actions}
{hint}
</div>
)}
</section>
);
}
/** 卡片底部的「用对话继续」提示行:告诉用户自由输入去底部输入框。 */
function ComposerHint({ examples }: { examples: string }) {
return (
<div className="flex items-start gap-1.5 rounded-md border bg-muted/35 px-2.5 py-1.5 text-[11px] leading-relaxed text-muted-foreground">
<MessageSquareText className="mt-0.5 size-3 shrink-0 text-primary/70" />
<span>也可以直接在底部输入框用一句话操作,例如:{examples}</span>
</div>
);
}
function getOutlineStats(outline: AIOutline | null): DecisionMetric[] {
if (!outline) return [];
let keyPointCount = 0;
let subPointCount = 0;
for (const section of outline.sections) {
const keyPoints = section.keyPoints ?? [];
keyPointCount += keyPoints.length;
subPointCount += keyPoints.reduce((sum, keyPoint) => sum + normalizeKeyPoint(keyPoint).subPoints.length, 0);
}
return [
{ label: '章节', value: `${outline.sections.length}`, tone: 'blue' },
{ label: '小节', value: `${keyPointCount}`, tone: 'emerald' },
{ label: '子要点', value: `${subPointCount}`, tone: 'muted' },
];
}
function reviewMetrics(review: ReviewResult | null): DecisionMetric[] {
if (!review) return [];
return [
{ label: '综合', value: `${review.overallScore}`, tone: review.verdict === 'pass' ? 'emerald' : 'amber' },
{ label: '事实', value: `${review.factScore}` },
{ label: '逻辑', value: `${review.logicScore}` },
{ label: '语言', value: `${review.languageScore}` },
{ label: '意见', value: `${review.revisionNotes.length}` },
];
}
function issueCategoryLabel(category: ReviewIssue['category']): string {
if (category === 'fact') return '事实';
if (category === 'logic') return '逻辑';
return '语言';
}
function issueTone(issue: ReviewIssue) {
if (issue.severity === 'major') return 'border-red-200 bg-red-50/65 text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300';
if (issue.category === 'logic') return 'border-amber-200 bg-amber-50/65 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300';
if (issue.category === 'fact') return 'border-blue-200 bg-blue-50/65 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-blue-300';
return 'border-violet-200 bg-violet-50/65 text-violet-700 dark:border-violet-900/40 dark:bg-violet-950/20 dark:text-violet-300';
}
function ChatReviewDecisionView({
review,
revisionCount,
selectedIndices,
onToggleIssue,
}: {
review: ReviewResult;
revisionCount?: number;
selectedIndices?: Set<number>;
onToggleIssue?: (index: number) => void;
}) {
const selectable = selectedIndices !== undefined && !!onToggleIssue;
return (
<div className="overflow-hidden rounded-lg border bg-background text-xs">
<div className="border-b px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold">编辑审核结果</p>
{revisionCount !== undefined && revisionCount > 0 && (
<p className="mt-0.5 text-[11px] text-muted-foreground">第 {revisionCount} 次修改稿</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
'rounded-md px-2 py-1 text-[11px] font-medium',
review.verdict === 'pass'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
)}
>
{review.verdict === 'pass' ? '通过' : '打回'}
</span>
<span className="text-sm font-semibold tabular-nums">
{review.overallScore}
<span className="text-xs font-normal text-muted-foreground"> / 100</span>
</span>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
{[
['事实', review.factScore],
['逻辑', review.logicScore],
['语言', review.languageScore],
].map(([label, score]) => (
<div key={label} className="rounded-md border bg-muted/30 px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{score}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full',
Number(score) >= review.passThreshold ? 'bg-emerald-500' : 'bg-amber-500',
)}
style={{ width: `${Math.max(0, Math.min(100, Number(score)))}%` }}
/>
</div>
</div>
))}
</div>
</div>
{review.revisionNotes.length > 0 ? (
<div className="space-y-2 px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-muted-foreground">修改意见</p>
{selectable && (
<p className="text-[10px] text-muted-foreground">
已选 {selectedIndices.size} / {review.revisionNotes.length}
</p>
)}
</div>
{review.revisionNotes.map((issue, index) => {
const selected = selectedIndices?.has(index) ?? false;
return (
<button
key={`${issue.category}-${issue.location}-${index}`}
type="button"
disabled={!selectable}
onClick={() => onToggleIssue?.(index)}
className={cn(
'w-full rounded-md border p-2 text-left transition-colors',
selectable ? 'hover:border-primary/40 hover:bg-primary/5' : 'cursor-default',
selected && 'border-primary/50 bg-primary/5',
)}
>
<div className="flex items-start gap-2">
{selectable && (
<span
className={cn(
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-background',
)}
>
{selected && <CheckCircle2 className="size-3" />}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className={cn('rounded border px-1.5 py-0.5 text-[10px] font-medium', issueTone(issue))}>
{issue.severity === 'major' ? '严重' : '轻微'} · {issueCategoryLabel(issue.category)}
</span>
{issue.location && (
<span className="truncate text-[10px] text-muted-foreground">位置:{issue.location}</span>
)}
</div>
<p className="mt-1 leading-relaxed text-foreground/90">{issue.issue}</p>
{issue.suggestion && (
<p className="mt-1 rounded bg-muted/45 px-2 py-1 leading-relaxed text-muted-foreground">
建议:{issue.suggestion}
</p>
)}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="px-3 py-2.5 text-xs text-muted-foreground">没有需要修改的审核意见。</div>
)}
</div>
);
}
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
function MaterialConfirmChatCard({
materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [submitting, setSubmitting] = useState(false);
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds] });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
// 无输入 = 重新生成检索词、覆盖式重检;带自然语言的补充检索走底部输入框。
await onSubmit({ action: 're_search', approvedMaterialIds: [] });
setSubmitting(false);
}
if (materials.length === 0) {
return (
<DecisionPanel
tone="amber"
icon={ShieldAlert}
badge="素材确认"
title="未检索到可用素材"
description="素材收集专家本次没有检索到相关数据。可以重新搜索、用一句话补充检索方向,或在无素材情况下继续写作。"
metrics={[{ label: '素材', value: '0 条', tone: 'amber' }]}
actions={(
<div className="flex flex-wrap gap-2">
<Button
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })}
disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
</div>
)}
hint={<ComposerHint examples="「补充关于××的素材」「换个方向重新搜」" />}
>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
</DecisionPanel>
);
}
return (
<DecisionPanel
tone="blue"
icon={FileText}
badge="素材确认"
title="确认素材范围"
description="已搜集到候选素材,默认全选。可取消不相关素材,确认后进入三层大纲规划。"
metrics={[
{ label: '已检索', value: `${materials.length} 条`, tone: 'blue' },
{ label: '已选择', value: `${selectedIds.size} 条`, tone: selectedIds.size > 0 ? 'emerald' : 'amber' },
]}
actions={(
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
已选 {selectedIds.size} / {materials.length} 条素材
</span>
<div className="flex-1" />
<button
type="button"
onClick={() => setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))}
className="rounded-md px-2 py-1 text-xs text-primary hover:bg-primary/5"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
<RotateCcw className="size-3.5" />重新搜索
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「补充关于××的素材」「只用第 1、3 条」「素材可以,继续」" />}
>
<div className="space-y-3">
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard
materials={materials}
selectedIds={selectedIds}
onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
/>
</div>
</DecisionPanel>
);
}
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
function OutlineConfirmChatCard({
outline: initialOutline, onSubmit, disabled,
}: {
outline: AIOutline | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({
action: 'confirm',
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
});
setSubmitting(false);
}
return (
<DecisionPanel
tone="blue"
icon={ListTree}
badge="三层大纲"
title="确认三层大纲结构"
description="大纲结构为「章节 → 小节 → 子要点」。可直接在大纲里微调节点,也可以通过底部输入框要求 AI 重新规划。"
metrics={getOutlineStats(editedOutline)}
actions={(
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
>
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
</Button>
)}
hint={<ComposerHint examples="「第二章拆成两节」「重新规划大纲」「加一章讲发展趋势」" />}
>
{editedOutline
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
</DecisionPanel>
);
}
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
function DraftConfirmChatCard({
draftTitle, draftWordCount, wordCountTarget, revisionCount, reviewResult, onSubmit, disabled,
}: {
draftTitle: string;
draftWordCount?: number;
wordCountTarget?: number;
revisionCount: number;
reviewResult: ReviewResult | null;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function submit(action: string) {
setSubmitting(true);
await onSubmit({ action });
setSubmitting(false);
}
async function handleApplyReview() {
if (!reviewResult) return;
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
setSubmitting(true);
await onSubmit({
action: 'user_revise',
userRevisionNotes: formattedNotes,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
const showWordCmp = wordCountTarget != null && wordCountTarget > 0 && draftWordCount != null && draftWordCount > 0;
const wordDiff = showWordCmp ? draftWordCount! - wordCountTarget! : 0;
const wordWithin = Math.abs(wordDiff) <= 100;
return (
<DecisionPanel
tone="blue"
icon={FileCheck2}
badge="草稿确认"
title="草稿已完成,请确认下一步"
description={(
<>
{draftTitle ? `《${draftTitle}》` : '草稿'}
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
· 右侧可预览完整内容。
</>
)}
metrics={[
...(draftWordCount ? [{ label: '当前字数', value: `${draftWordCount}`, tone: wordWithin ? 'emerald' as const : 'amber' as const }] : []),
...(wordCountTarget ? [{ label: '目标字数', value: `${wordCountTarget}` }] : []),
...(reviewResult ? reviewMetrics(reviewResult).slice(0, 2) : []),
]}
actions={(
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>下一步处理</span>
{showWordCmp && wordDiff !== 0 && (
<span className={wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400'}>
{wordDiff > 0 ? '超出' : '不足'} {Math.abs(wordDiff)} 字{wordWithin ? ',在误差范围内' : ''}
</span>
)}
</div>
{hasReview && (
<Button
type="button" variant="outline" size="sm"
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
onClick={handleApplyReview}
disabled={disabled || submitting || selectedIssueIndices.size === 0}
>
<ClipboardEdit className="size-3.5" />
按编辑审核意见修改
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
)}
<div className="flex gap-1.5">
<Button
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('to_editor')} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「第三段太啰嗦,精简一下」「结尾补个案例」「更口语一点」" />}
>
{reviewResult ? (
<ChatReviewDecisionView
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasReview ? selectedIssueIndices : undefined}
onToggleIssue={hasReview ? toggleIssue : undefined}
/>
) : (
<div className="rounded-md border bg-background/80 px-3 py-2 text-xs text-muted-foreground">
当前草稿暂无编辑审核结果,可直接送审或定稿。
</div>
)}
</DecisionPanel>
);
}
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
function ReviewConfirmChatCard({
reviewResult, revisionCount, onSubmit, disabled,
}: {
reviewResult: ReviewResult | null;
revisionCount: number;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [submitting, setSubmitting] = useState(false);
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
);
function toggleIssue(index: number) {
setSelectedIssueIndices((prev) => {
const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index);
return next;
});
}
async function handleAccept() {
setSubmitting(true);
const issueNotes = reviewResult
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
: '';
await onSubmit({
action: 'accept_review',
userRevisionNotes: issueNotes || undefined,
selectedIssueIndices: [...selectedIssueIndices],
});
setSubmitting(false);
}
async function handleForce() {
setSubmitting(true);
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
setSubmitting(false);
}
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
return (
<DecisionPanel
tone="amber"
icon={ShieldCheck}
badge="审核确认"
title="编辑认为文章需要修改"
description="勾选希望采纳的意见,再让作家重写;也可以忽略编辑意见并强制定稿。"
metrics={reviewMetrics(reviewResult)}
actions={(
<div className="space-y-2">
<div className="text-[11px] text-muted-foreground">请选择审核意见处理方式</div>
<div className="flex gap-2">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleAccept}
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0)}
>
<ClipboardEdit className="size-3.5" />按编辑意见修改
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
<span className="text-[10px] opacity-70">
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
</span>
)}
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
onClick={handleForce} disabled={disabled || submitting}
>
<ShieldAlert className="size-3.5" />强制定稿
</Button>
</div>
</div>
)}
hint={<ComposerHint examples="「再补一条意见:核查数据来源」「只按第 1、2 条改」「不改了,定稿」" />}
>
{reviewResult ? (
<ChatReviewDecisionView
review={reviewResult}
revisionCount={revisionCount}
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
onToggleIssue={hasIssues ? toggleIssue : undefined}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
)}
</DecisionPanel>
);
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [
{ value: 'supplement', label: '补充检索素材', icon: Search },
{ value: 'loose', label: '按通用知识续写', icon: BookOpen },
{ value: 'delete', label: '删除该章节', icon: Trash2 },
];
function SectionHelpChatCard({
blockedSections, onSubmit, disabled,
}: {
blockedSections: BlockedSection[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}) {
const [decisions, setDecisions] = useState<Record<string, SectionDecisionKind>>(
() => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])),
);
const [submitting, setSubmitting] = useState(false);
const hasSupplement = Object.values(decisions).some((d) => d === 'supplement');
async function handleSubmit() {
setSubmitting(true);
await onSubmit({
action: 'section_help',
sectionDecisions: blockedSections.map((b) => ({
sectionTitle: b.sectionTitle,
decision: decisions[b.sectionTitle] ?? 'supplement',
})),
});
setSubmitting(false);
}
return (
<DecisionPanel
tone="amber"
icon={ShieldAlert}
badge="素材不足"
title="部分章节素材不足,需要你决定"
description={`严格依据素材模式下,以下 ${blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。`}
metrics={[
{ label: '受影响章节', value: `${blockedSections.length}`, tone: 'amber' },
{ label: '需补检索', value: `${Object.values(decisions).filter((d) => d === 'supplement').length}` },
]}
actions={(
<div className="space-y-2">
{hasSupplement && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
</p>
)}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleSubmit} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />提交并继续
</Button>
</div>
)}
hint={<ComposerHint examples="「都补充检索」「都按通用知识写」「第一个删掉,其他补检索」" />}
>
<div className="space-y-2">
{blockedSections.map((b) => (
<div key={b.sectionTitle} className="rounded-md border bg-background/85 p-2.5 space-y-2">
<div>
<p className="text-xs font-medium">{b.sectionTitle}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
</div>
<div className="flex gap-1">
{DECISION_OPTIONS.map((opt) => {
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
disabled={disabled || submitting}
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
className={cn(
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
active
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-input text-muted-foreground hover:bg-muted/50',
)}
>
<Icon className="size-3 shrink-0" />
{opt.label}
</button>
);
})}
</div>
</div>
))}
</div>
</DecisionPanel>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react';
import { PenLine, SlidersHorizontal } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { FileText, Loader2, PenLine, SlidersHorizontal, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@@ -17,6 +17,7 @@ import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
import { extractAIWritingSample } from '../../api/ai-writing-sample';
import { ResearchTemplatesPanel } from './ResearchTemplatesPanel';
interface AIWritingFormProps {
@@ -42,14 +43,26 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'general' | 'knowledge_base' | 'notebook';
type WritingModeType = 'normal' | 'imitate';
const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口,不走技能编排' },
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
const SHOW_NOTEBOOK_MATERIAL_SOURCE = false;
const WRITING_MODE_OPTIONS: Array<{ value: WritingModeType; label: string }> = [
{ value: 'normal', label: '普通写作' },
{ value: 'imitate', label: '样文仿写' },
];
const IMITATE_STRENGTH_OPTIONS: Array<{ value: 'light' | 'medium' | 'strong'; label: string }> = [
{ value: 'light', label: '轻度' },
{ value: 'medium', label: '中度' },
{ value: 'strong', label: '高度' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]';
const FIELD_TRIGGER_CLASS =
@@ -63,6 +76,11 @@ export function AIWritingForm({
}: AIWritingFormProps) {
const {
intent, setIntent,
writingModeType, setWritingModeType,
sampleText, setSampleText,
sampleFilename, setSampleFilename,
imitateStrength, setImitateStrength,
preserveSampleStructure, setPreserveSampleStructure,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
@@ -79,6 +97,10 @@ export function AIWritingForm({
modelName, setModelName,
} = useWritingForm();
const [sampleUploading, setSampleUploading] = useState(false);
const [sampleMessage, setSampleMessage] = useState('');
const [sampleError, setSampleError] = useState('');
const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
@@ -98,17 +120,6 @@ export function AIWritingForm({
() => models.find((m) => m.name === modelName) ?? models[0],
[models, modelName],
);
const materialSourceOptions = useMemo(
() =>
SHOW_NOTEBOOK_MATERIAL_SOURCE || studioToken
? MATERIAL_SOURCE_OPTIONS
: MATERIAL_SOURCE_OPTIONS.filter((opt) => opt.value !== 'notebook'),
[studioToken],
);
const effectiveMaterialSource =
!SHOW_NOTEBOOK_MATERIAL_SOURCE && !studioToken && materialSource === 'notebook'
? 'general'
: materialSource;
// 初始化时加载文章类型
@@ -152,7 +163,7 @@ export function AIWritingForm({
function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val);
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空。
if (val !== 'notebook' && !studioToken) {
setSelectedNotebookId(null);
setSelectedDocumentIds([]);
@@ -167,13 +178,41 @@ export function AIWritingForm({
);
}
async function handleSampleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setSampleUploading(true);
setSampleError('');
setSampleMessage('');
try {
const result = await extractAIWritingSample(file);
setSampleFilename(result.filename);
setSampleText(result.text);
setSampleMessage(result.truncated ? '已提取样文文本,内容较长已截断。' : '已提取样文文本。');
} catch (err) {
setSampleError(err instanceof Error ? err.message : '样文解析失败');
} finally {
setSampleUploading(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
if (writingModeType === 'imitate' && !sampleText.trim()) {
setSampleError('请上传样文或粘贴样文文本');
return;
}
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
userOutline: outlineText.trim() || undefined,
writingModeType,
sampleText: writingModeType === 'imitate' ? sampleText.trim() : undefined,
sampleFilename: writingModeType === 'imitate' ? sampleFilename || '粘贴文本' : undefined,
imitateStrength,
preserveSampleStructure,
articleType,
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
@@ -184,12 +223,10 @@ export function AIWritingForm({
writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
addReferences,
skipResearch,
// 跳过素材收集时检索类型无意义,归一为 general
materialSource: skipResearch ? 'general' : effectiveMaterialSource,
materialSource: skipResearch ? 'general' : materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的空间"且未跳过素材收集才传笔记参数
noteSearchSpaceId: !skipResearch && effectiveMaterialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && effectiveMaterialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
noteSearchSpaceId: !skipResearch ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -203,6 +240,106 @@ export function AIWritingForm({
</h3>
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模式</label>
<div className="grid grid-cols-2 gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-1 dark:border-[#1B477E] dark:bg-[#022248]/40">
{WRITING_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => setWritingModeType(opt.value)}
className={cn(
'h-10 rounded-lg text-sm font-medium transition-colors',
writingModeType === opt.value
? 'bg-white text-primary shadow-sm dark:bg-[#0B376A] dark:text-white'
: 'text-muted-foreground hover:bg-white/70 dark:text-[#AACEFF]/70 dark:hover:bg-[#0B376A]/60',
)}
>
{opt.label}
</button>
))}
</div>
</div>
{writingModeType === 'imitate' && (
<div className="mt-4 space-y-3 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
<label className={FIELD_LABEL_CLASS}>样文</label>
<label
className={cn(
'inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg border border-[#e8dedd] bg-white px-3 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-[#fff8f8]',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
disabled || sampleUploading ? 'pointer-events-none opacity-60' : '',
)}
>
{sampleUploading ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
上传文件
<input
type="file"
accept=".doc,.docx,.pdf,.md,.markdown,.txt"
className="sr-only"
disabled={disabled || sampleUploading}
onChange={handleSampleFileChange}
/>
</label>
</div>
{sampleFilename && (
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-xs text-muted-foreground dark:bg-[#022248] dark:text-[#AACEFF]/80">
<FileText className="size-3.5" />
<span className="truncate">{sampleFilename}</span>
</div>
)}
<Textarea
placeholder="上传 Word/PDF/Markdown/TXT,或直接粘贴一篇样文。AI 会先分析文风,再按你的写作意图仿写一篇新文章。"
value={sampleText}
onChange={(e) => {
setSampleText(e.target.value.slice(0, MAX_SAMPLE_TEXT_LENGTH));
setSampleError('');
if (!sampleFilename) setSampleFilename('粘贴文本');
}}
disabled={disabled || sampleUploading}
rows={8}
maxLength={MAX_SAMPLE_TEXT_LENGTH}
className="max-h-[13.5rem] resize-none overflow-y-auto rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm [field-sizing:fixed]"
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground/80">
<span>{sampleText.length} / {MAX_SAMPLE_TEXT_LENGTH} 字</span>
{sampleMessage && <span className="text-emerald-600">{sampleMessage}</span>}
{sampleError && <span className="text-destructive">{sampleError}</span>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">仿写强度</label>
<Select
value={imitateStrength}
onValueChange={(v) => setImitateStrength(v as 'light' | 'medium' | 'strong')}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IMITATE_STRENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 pt-7 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={preserveSampleStructure}
onChange={(e) => setPreserveSampleStructure(e.target.checked)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
沿用样文结构
</label>
</div>
</div>
)}
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>
@@ -316,82 +453,80 @@ export function AIWritingForm({
</div>
{!skipResearch && (
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
value={effectiveMaterialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索类型" />
</SelectTrigger>
<SelectContent>
{materialSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索方式</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索方式" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 选了"我的空间"才展示空间 / 文档选择器 */}
{effectiveMaterialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{/* studioToken 模式:空间已锁定,不展示选择器 */}
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 space-y-2">
@@ -441,7 +576,7 @@ export function AIWritingForm({
<Button
type="submit"
disabled={disabled || !intent.trim()}
disabled={disabled || !intent.trim() || (writingModeType === 'imitate' && !sampleText.trim())}
className="mt-5 h-12 w-full gap-2 rounded-2xl text-base font-semibold shadow-lg shadow-primary/20 transition-all hover:shadow-primary/30 active:scale-[0.98]"
>
<PenLine className="size-4" />
@@ -10,6 +10,7 @@ export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '未完成', color: 'text-slate-400' },
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
background: { label: '挂起中', color: 'text-sky-500' },
analyzing_sample: { label: '样文分析', color: 'text-cyan-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, CloudUpload, PanelRightClose, Pause, Play, Plus, RotateCcw, Settings2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -39,11 +39,14 @@ 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';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { SampleAnalysisStreamingView } from './SampleAnalysisStreamingView';
import { SampleStyleProfileCard } from './SampleStyleProfileCard';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
@@ -54,6 +57,7 @@ import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABEL: Record<string, string> = {
idle: '就绪',
analyzing_sample: '样文分析师分析文风中…',
researching: '素材收集专家搜集素材中…',
awaiting_material_confirm: '等待确认素材',
writing_outline: '作家规划大纲中…',
@@ -99,93 +103,206 @@ export function AIWritingPanel({
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const shouldAutoFollow = !isIdle && !isHistoryMode;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
const intentSlot = (state.intentStreamingText || state.intentThinkingText) ? (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
) : state.parsedIntent ? (
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
embedded
/>
) : null;
const sampleAnalysisSlot = (state.sampleAnalysisStreamingText || state.sampleAnalysisThinkingText) ? (
<SampleAnalysisStreamingView
text={state.sampleAnalysisStreamingText}
thinkingText={state.sampleAnalysisThinkingText}
/>
) : null;
const sampleProfileSlot = state.sampleStyleProfile ? (
<SampleStyleProfileCard profile={state.sampleStyleProfile} />
) : null;
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
const isAutoScrollingRef = useRef(false);
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
const autoScrollRef = useRef(true);
const shouldAutoFollowRef = useRef(shouldAutoFollow);
shouldAutoFollowRef.current = shouldAutoFollow;
const scrollbarDragRef = useRef(false);
const touchYRef = useRef<number | null>(null);
const [autoScroll, setAutoScroll] = useState(true);
const enableAutoScroll = useCallback(() => {
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
const scrollToBottomNow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
const jump = () => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ block: 'end' });
} else {
el.scrollTop = el.scrollHeight;
}
};
jump();
requestAnimationFrame(() => {
jump();
requestAnimationFrame(() => {
isAutoScrollingRef.current = false;
});
});
}, []);
const maybeAutoScrollToBottom = useCallback(() => {
if (!autoScrollRef.current || !shouldAutoFollowRef.current) return;
scrollToBottomNow();
}, [scrollToBottomNow]);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
autoScrollRef.current = true;
setAutoScroll(true);
enableAutoScroll();
}
}, [isIdle]);
}, [enableAutoScroll, isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
if (shouldAutoFollow && !state.isPaused) {
enableAutoScroll();
requestAnimationFrame(scrollToBottomNow);
}
}, [isRunning, state.isPaused]);
}, [enableAutoScroll, scrollToBottomNow, shouldAutoFollow, state.isPaused]);
// 监听用户手动滚动(挂载一次)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 24;
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
};
// scroll 事件:兼容拖动滚动条的场景
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
const onTouchStart = (e: TouchEvent) => {
touchYRef.current = e.touches[0]?.clientY ?? null;
};
const onTouchMove = (e: TouchEvent) => {
const prevY = touchYRef.current;
const nextY = e.touches[0]?.clientY ?? null;
if (prevY !== null && nextY !== null && nextY > prevY + 4) {
disableAutoScroll();
}
touchYRef.current = nextY;
};
const onPointerDown = (e: PointerEvent) => {
if (e.target === el) {
scrollbarDragRef.current = true;
}
};
const onPointerDone = () => {
scrollbarDragRef.current = false;
};
// scroll 事件只处理「用户拖动滚动条」和「手动回到底部」。
// 普通布局变化也会触发 scroll,不能再把所有 scrollTop 下降都当成用户上划。
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
if (isAutoScrollingRef.current) {
lastTop = cur;
return;
}
if (nearBottom()) {
enableAutoScroll();
} else if (scrollbarDragRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: true });
el.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerDone);
window.addEventListener('pointercancel', onPointerDone);
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerDone);
window.removeEventListener('pointercancel', onPointerDone);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
}, [disableAutoScroll, enableAutoScroll]);
// 内容高度变化兜底:同一 timeline 节点内卡片展开、文风画像替换、流式文本变长,
// 都可能不改变 progressEvents.length。监听 DOM 变化可以把这些情况统一托底。
useEffect(() => {
const el = scrollRef.current;
if (!el || typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(() => {
maybeAutoScrollToBottom();
});
observer.observe(el, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [maybeAutoScrollToBottom]);
// 内容变化时若自动滚底开启则滚到底部
useEffect(() => {
// 检查同步 ref,避免 state 异步导致的时序竞争
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
useLayoutEffect(() => {
maybeAutoScrollToBottom();
}, [
maybeAutoScrollToBottom,
autoScroll, // 点击按钮后重新触发一次
isRunning,
state.status,
state.progressEvents.length,
state.sampleAnalysisStreamingText,
state.sampleAnalysisThinkingText,
state.sampleStyleProfile,
state.intentStreamingText,
state.intentThinkingText,
state.parsedIntent,
state.keywordStreamingText,
state.keywordThinkingText,
state.skillSearchSteps.length,
state.skillSelectStreamingText,
state.skillSelectThinkingText,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.outlineThinkingText,
state.revisionSummary,
state.currentPause,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
enableAutoScroll();
scrollToBottomNow();
}, [enableAutoScroll, scrollToBottomNow]);
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
void openHistory(session);
@@ -449,7 +566,13 @@ export function AIWritingPanel({
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
<div
ref={scrollRef}
className={cn(
'absolute inset-0 overflow-y-auto',
autoScroll && '[overflow-anchor:none]',
)}
>
{/* 历史回看:左侧时间线(与实时写作同一套 AIWritingTimeline) */}
{isHistoryMode && historySession && (
<AIWritingHistoryView session={historySession} />
@@ -466,27 +589,16 @@ export function AIWritingPanel({
/>
)}
{/* 意图解析:流式 → 结果卡片承接。
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
{(state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && !isHistoryMode && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
currentPause={state.currentPause}
sampleAnalysisSlot={sampleAnalysisSlot}
sampleProfileSlot={sampleProfileSlot}
intentSlot={intentSlot}
intentSlotDone={!!state.parsedIntent && !state.intentStreamingText && !state.intentThinkingText}
currentInterventionSlot={
state.currentPause ? (
<InterventionCard
@@ -499,6 +611,8 @@ export function AIWritingPanel({
wordCountTarget={state.request?.wordCountTarget}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
materialSource={state.request?.materialSource ?? 'general'}
canUseNotebookSearch={state.request?.noteSearchSpaceId != null}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention}
disabled={false}
@@ -534,6 +648,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 期间静默。 */}
@@ -567,12 +691,16 @@ 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 &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
@@ -589,6 +717,7 @@ export function AIWritingPanel({
{state.error}
</div>
)}
<div ref={bottomRef} className="h-px" aria-hidden="true" />
</div>{/* 滚动内容区结束 */}
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
@@ -1,3 +1,5 @@
import type { ReactNode } from 'react';
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
@@ -7,12 +9,17 @@ interface AIWritingTimelineProps {
events: ProgressEvent[];
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: React.ReactNode;
currentInterventionSlot?: ReactNode;
sampleAnalysisSlot?: ReactNode;
sampleProfileSlot?: ReactNode;
intentSlot?: ReactNode;
intentSlotDone?: boolean;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
'sample_profile_ready',
'materials_ready',
'outline_ready',
'draft_ready',
@@ -30,44 +37,162 @@ export function AIWritingTimeline({
completedInterventions,
currentPause,
currentInterventionSlot,
sampleAnalysisSlot,
sampleProfileSlot,
intentSlot,
intentSlotDone = false,
}: AIWritingTimelineProps) {
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
if (visible.length === 0) return null;
if (visible.length === 0 && !intentSlot && !sampleAnalysisSlot && !sampleProfileSlot) return null;
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
let awaitUserIdx = 0;
let intentInserted = false;
const lastSampleEventIndex = visible.reduce(
(last, event, index) => (isSampleEvent(event) ? index : last),
-1,
);
const rows: Array<{
key: string;
event: ProgressEvent;
slot: ReactNode;
extendLineThroughSlot?: boolean;
}> = [];
const makeIntentEvent = (): ProgressEvent => ({
type: intentSlotDone ? 'step_done' : 'step_start',
sessionId: 'intent-slot',
agentName: '系统',
message: intentSlotDone ? '意图解析完成' : '正在解析写作意图',
});
const pushIntentRow = (key: string) => {
if (!intentSlot || intentInserted) return;
rows.push({
key,
event: makeIntentEvent(),
slot: intentSlot,
extendLineThroughSlot: false,
});
intentInserted = true;
};
const makeSampleAnalysisEvent = (): ProgressEvent => ({
type: 'step_start',
sessionId: 'sample-analysis-slot',
agentName: '样文分析师',
message: '正在分析样文风格',
});
if (sampleAnalysisSlot && lastSampleEventIndex < 0) {
rows.push({
key: 'sample-analysis-live',
event: makeSampleAnalysisEvent(),
slot: sampleAnalysisSlot,
extendLineThroughSlot: false,
});
}
visible.forEach((event, idx) => {
let slot: ReactNode = null;
let extendLineThroughSlot = true;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
slot = (
<div className="space-y-2">
<UserRequirementCard record={completedRecord} />
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && idx === visible.length - 1) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
if (intentSlot && isIntentDoneEvent(event)) {
slot = slot ? (
<div className="space-y-2">
{slot}
{intentSlot}
</div>
) : intentSlot;
extendLineThroughSlot = false;
intentInserted = true;
}
if (sampleAnalysisSlot && idx === lastSampleEventIndex) {
slot = slot ? (
<div className="space-y-2">
{slot}
{sampleAnalysisSlot}
</div>
) : sampleAnalysisSlot;
extendLineThroughSlot = false;
} else if (sampleProfileSlot && event.type === 'sample_profile_ready') {
slot = slot ? (
<div className="space-y-2">
{slot}
{sampleProfileSlot}
</div>
) : sampleProfileSlot;
extendLineThroughSlot = false;
}
rows.push({
key: `event-${idx}`,
event,
slot,
extendLineThroughSlot,
});
if (
intentSlot &&
!intentSlotDone &&
!intentInserted &&
lastSampleEventIndex >= 0 &&
idx === lastSampleEventIndex
) {
pushIntentRow(`intent-after-sample-${idx}`);
}
});
if (!intentInserted) {
pushIntentRow('intent-fallback');
}
return (
<div className="px-4 py-3 space-y-0">
{visible.map((event, idx) => {
const isLast = idx === visible.length - 1;
let slot: React.ReactNode = null;
if (event.type === 'await_user') {
const completedRecord = completedInterventions[awaitUserIdx];
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
slot = (
<div className="space-y-2">
<UserRequirementCard record={completedRecord} />
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
}
}
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
return (
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
{slot}
<TimelineStep
key={row.key}
event={row.event}
isLast={isLast && (!row.slot || !row.extendLineThroughSlot)}
>
{row.slot}
</TimelineStep>
);
})}
</div>
);
}
function isSampleEvent(event: ProgressEvent): boolean {
return event.agentName === '样文分析师' || event.type === 'sample_profile_ready';
}
function isIntentDoneEvent(event: ProgressEvent): boolean {
return (
event.type === 'step_done' &&
event.agentName === '系统' &&
(event.message ?? '').includes('意图解析完成')
);
}
@@ -26,6 +26,7 @@ export function DraftSync({ status, markdown, title }: DraftSyncProps) {
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
// writing_draft / revising 才只读。
const generating =
status === 'analyzing_sample' ||
status === 'researching' ||
status === 'writing_outline' ||
streaming;
@@ -7,6 +7,7 @@ interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
hideTargetAudience?: boolean;
embedded?: boolean;
}
/**
@@ -17,13 +18,14 @@ export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
embedded = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
return (
<div className="px-4 pb-2">
<div className={embedded ? 'mt-2' : 'px-4 pb-2'}>
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/40 dark:bg-blue-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
@@ -5,6 +5,13 @@ import {
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
@@ -30,6 +37,8 @@ interface InterventionCardProps {
wordCountTarget?: number;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
materialSource?: MaterialSource;
canUseNotebookSearch?: boolean;
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
@@ -38,7 +47,8 @@ interface InterventionCardProps {
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps,
materialSource, canUseNotebookSearch, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
@@ -47,6 +57,8 @@ export function InterventionCard({
<MaterialConfirmCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
initialMaterialSource={materialSource ?? 'general'}
canUseNotebookSearch={canUseNotebookSearch}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
@@ -117,7 +129,11 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
const selected = issues.filter((_, i) => selectedIndices.has(i));
if (selected.length === 0) return '';
return selected
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
.map((iss, n) => {
const location = iss.location ? `【位置】${iss.location};` : '';
const suggestion = iss.suggestion ? `;【建议】${iss.suggestion}` : '';
return `${n + 1}. [${iss.category}] ${location}${iss.issue}${suggestion}`;
})
.join('\n');
}
@@ -126,17 +142,73 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
interface MaterialConfirmCardProps {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
initialMaterialSource: MaterialSource;
canUseNotebookSearch?: boolean;
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
];
function MaterialSourceSwitcher({
value,
onChange,
options,
disabled,
}: {
value: MaterialSource;
onChange: (value: MaterialSource) => void;
options: Array<{ value: MaterialSource; label: string; desc: string }>;
disabled?: boolean;
}) {
const current = options.find((opt) => opt.value === value) ?? options[0];
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-input bg-muted/30 px-2.5 py-2">
<span className="text-xs font-medium text-muted-foreground">检索方式</span>
<Select value={current.value} onValueChange={(v) => onChange(v as MaterialSource)} disabled={disabled}>
<SelectTrigger className="h-7 w-[9rem] bg-background px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
{current.desc}
</span>
</div>
);
}
function MaterialConfirmCard({
materials,
skillSearchSteps,
initialMaterialSource,
canUseNotebookSearch,
skillSearchInProgress,
onSubmit,
disabled,
}: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [userQuery, setUserQuery] = useState('');
const [materialSource, setMaterialSource] = useState<MaterialSource>(
initialMaterialSource === 'notebook' && !canUseNotebookSearch ? 'general' : initialMaterialSource,
);
const [submitting, setSubmitting] = useState(false);
const sourceOptions = MATERIAL_SOURCE_OPTIONS.filter((opt) => opt.value !== 'notebook' || canUseNotebookSearch);
async function handleConfirm() {
setSubmitting(true);
@@ -148,7 +220,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
await onSubmit({
action: 're_search',
approvedMaterialIds: [],
userQuery: userQuery.trim() || undefined,
materialSource,
});
setSubmitting(false);
}
@@ -163,7 +240,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
素材收集专家本次没有检索到相关数据。你可以切换检索方式并描述想检索的内容,或在无素材的情况下直接继续写作。
</p>
</div>
</div>
@@ -172,6 +249,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialSourceSwitcher
value={materialSource}
onChange={setMaterialSource}
options={sourceOptions}
disabled={disabled || submitting}
/>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
@@ -211,7 +295,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
或在下方切换检索方式并描述想补充检索的内容,系统会把新素材追加进列表。
</p>
</div>
</div>
@@ -226,6 +310,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
});
}} />
<MaterialSourceSwitcher
value={materialSource}
onChange={setMaterialSource}
options={sourceOptions}
disabled={disabled || submitting}
/>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
@@ -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>
);
}
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
补充检索(可选)— 选择检索方式后描述想检索的内容,新素材追加到列表
</label>
<textarea
ref={textareaRef}
@@ -34,16 +34,23 @@ function ScoreBar({ score }: { score: number }) {
function IssueRow({ issue }: { issue: ReviewIssue }) {
return (
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
<span className="text-muted-foreground">{issue.issue}</span>
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5 space-y-0.5">
<div>
<span className={cn(
'font-medium mr-1',
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
)}>
{issue.severity === 'major' ? '严重' : '轻微'}
</span>
{issue.location && (
<span className="text-muted-foreground/70 mr-1">【{issue.location}】</span>
)}
<span className="text-muted-foreground">{issue.issue}</span>
</div>
{issue.suggestion && (
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
<div className="text-orange-600 dark:text-orange-400">
建议:{issue.suggestion}
</div>
)}
</div>
);
@@ -1,6 +1,6 @@
import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { RevisionSummary } from '../../types/ai-writing';
import type { ReviewIssue, RevisionSummary } from '../../types/ai-writing';
interface RevisionSummaryCardProps {
summary: RevisionSummary;
@@ -12,6 +12,45 @@ const CATEGORY_LABEL: Record<string, string> = {
language: '语言',
};
function IssueSummaryRow({ issue, skipped = false }: { issue: ReviewIssue; skipped?: boolean }) {
return (
<div
className={cn(
'flex items-start gap-1.5 pl-5 leading-snug',
skipped ? 'text-muted-foreground/60' : 'text-muted-foreground',
)}
>
<span
className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
issue.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
issue.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
skipped && 'opacity-70',
)}
>
{CATEGORY_LABEL[issue.category] ?? issue.category}
</span>
<div className={cn('min-w-0 flex-1 space-y-0.5', skipped && 'line-through decoration-muted-foreground/50')}>
{issue.location && (
<div className="text-[11px] text-muted-foreground/70">
位置:{issue.location}
</div>
)}
<div className={skipped ? 'text-muted-foreground/70' : 'text-foreground/85 dark:text-foreground/80'}>
{issue.issue}
</div>
{issue.suggestion && (
<div className={skipped ? 'text-muted-foreground/60' : 'text-muted-foreground'}>
<span className="text-primary mr-1">建议:</span>
{issue.suggestion}
</div>
)}
</div>
</div>
);
}
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
const wordDiff = newWordCount - oldWordCount;
@@ -39,19 +78,7 @@ export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
<CheckCircle2 className="size-3.5" />
已修改 {revisedIssues.length} 条意见
</p>
{revisedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
<span className={cn(
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
)}>
{CATEGORY_LABEL[iss.category] ?? iss.category}
</span>
<span>{iss.issue}</span>
</div>
))}
{revisedIssues.map((iss, i) => <IssueSummaryRow key={i} issue={iss} />)}
</div>
)}
@@ -62,11 +89,7 @@ export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
<XCircle className="size-3.5" />
跳过 {skippedIssues.length} 条意见
</p>
{skippedIssues.map((iss, i) => (
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
<span>{iss.issue}</span>
</div>
))}
{skippedIssues.map((iss, i) => <IssueSummaryRow key={i} issue={iss} skipped />)}
</div>
)}
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
interface SampleAnalysisStreamingViewProps {
text: string;
thinkingText?: string;
}
export function SampleAnalysisStreamingView({ text, thinkingText }: SampleAnalysisStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="mt-2 space-y-2">
{thinkingText && (
<div className="overflow-hidden rounded-xl border border-amber-200 bg-amber-50/50 shadow-sm dark:border-amber-900 dark:bg-amber-950/20">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="flex w-full items-center gap-2 border-b border-amber-200/60 bg-amber-100/40 px-3 py-2 transition-colors hover:bg-amber-100/70 dark:border-amber-900/60 dark:bg-amber-900/30 dark:hover:bg-amber-900/50"
>
<Brain className="size-3.5 shrink-0 text-amber-500" />
<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="max-h-36 overflow-y-auto whitespace-pre-wrap p-3 font-mono text-[11px] italic leading-relaxed text-amber-700/70 dark:text-amber-300/60"
>
{thinkingText}
</pre>
)}
</div>
)}
{text && (
<div className="overflow-hidden rounded-xl border border-cyan-200 bg-cyan-50/50 shadow-sm dark:border-cyan-900 dark:bg-cyan-950/20">
<div className="flex items-center gap-2 border-b border-cyan-200/60 bg-cyan-100/40 px-3 py-2 dark:border-cyan-900/60 dark:bg-cyan-900/30">
<Sparkles className="size-3.5 shrink-0 text-cyan-500" />
<span className="text-xs font-medium text-cyan-700 dark:text-cyan-300">样文分析中…</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 animate-bounce rounded-full bg-cyan-400"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="max-h-52 overflow-y-auto whitespace-pre-wrap p-3 font-mono text-[11px] leading-relaxed text-muted-foreground"
>
{text}
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-[blink_1s_step-end_infinite] align-text-bottom bg-cyan-400" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { FileText, ListTree, MessageSquareText, ShieldCheck, WandSparkles } from 'lucide-react';
import type { SampleStyleProfile } from '../../types/ai-writing';
interface SampleStyleProfileCardProps {
profile: SampleStyleProfile;
}
export function SampleStyleProfileCard({ profile }: SampleStyleProfileCardProps) {
return (
<div className="mt-2 overflow-hidden rounded-xl border border-cyan-200 bg-cyan-50/40 shadow-sm dark:border-cyan-900 dark:bg-cyan-950/20">
<div className="flex items-center gap-2 border-b border-cyan-200/60 bg-cyan-100/40 px-3 py-2 dark:border-cyan-900/60 dark:bg-cyan-900/30">
<WandSparkles className="size-3.5 shrink-0 text-cyan-500" />
<span className="text-xs font-medium text-cyan-700 dark:text-cyan-300">文风画像</span>
</div>
<div className="space-y-3 px-3 py-2.5 text-xs">
{profile.summary && (
<p className="leading-relaxed text-foreground">{profile.summary}</p>
)}
<div className="grid gap-2 md:grid-cols-2">
<Info icon={<FileText className="size-3.5" />} label="文章类型" value={profile.articleTypeGuess} />
<Info icon={<MessageSquareText className="size-3.5" />} label="语气特征" value={profile.tone} />
<Info icon={<ListTree className="size-3.5" />} label="句式节奏" value={profile.sentenceStyle} />
<Info icon={<ShieldCheck className="size-3.5" />} label="避抄边界" value={profile.avoidCopying?.[0]} />
</div>
<ListBlock title="结构骨架" items={profile.structure} />
<ListBlock title="语言特征" items={profile.languageFeatures} />
<ListBlock title="仿写建议" items={profile.imitationGuidance} />
</div>
</div>
);
}
function Info({ icon, label, value }: { icon: ReactNode; label: string; value?: string }) {
if (!value) return null;
return (
<div className="rounded-lg bg-white/70 px-2.5 py-2 dark:bg-[#022248]/70">
<div className="flex items-center gap-1.5 text-cyan-700 dark:text-cyan-300">
{icon}
<span className="font-medium">{label}</span>
</div>
<p className="mt-1 leading-relaxed text-muted-foreground">{value}</p>
</div>
);
}
function ListBlock({ title, items }: { title: string; items?: string[] }) {
const visible = (items ?? []).filter(Boolean).slice(0, 4);
if (visible.length === 0) return null;
return (
<div>
<p className="mb-1 font-medium text-foreground">{title}</p>
<ul className="space-y-1 text-muted-foreground">
{visible.map((item, idx) => (
<li key={`${title}-${idx}`} className="flex gap-1.5 leading-relaxed">
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-cyan-400" />
<span>{item}</span>
</li>
))}
</ul>
</div>
);
}
@@ -21,6 +21,7 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
}
const AGENT_COLOR: Record<string, string> = {
样文分析师: 'text-cyan-500',
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
import type { Message } from '@langchain/langgraph-sdk';
import { FileText, Loader2, Sparkles, Upload, Zap } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -16,7 +17,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Zap } from 'lucide-react';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
@@ -33,9 +33,44 @@ import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
import { extractAIWritingSample } from '../../api/ai-writing-sample';
import type { AIWritingRequest } from '../../types/ai-writing';
import { WritingWelcome } from './WritingWelcome';
const MAX_SAMPLE_TEXT_LENGTH = 120000;
const SETUP_SAMPLE_CONTEXT_TEXT_LENGTH = 12000;
const IMITATE_STRENGTH_OPTIONS: Array<{ value: 'light' | 'medium' | 'strong'; label: string }> = [
{ value: 'light', label: '轻度' },
{ value: 'medium', label: '中度' },
{ value: 'strong', label: '高度' },
];
type WritingFormOptional = ReturnType<typeof useWritingFormOptional>;
function buildWritingSampleContext(formCtx: WritingFormOptional): Record<string, unknown> | undefined {
if (!formCtx || formCtx.writingModeType !== 'imitate') return undefined;
const text = formCtx.sampleText.trim();
if (!text) return undefined;
return {
writing_sample_context: {
filename: formCtx.sampleFilename || '粘贴文本',
text: text.slice(0, SETUP_SAMPLE_CONTEXT_TEXT_LENGTH),
original_length: text.length,
imitate_strength: formCtx.imitateStrength,
preserve_sample_structure: formCtx.preserveSampleStructure,
},
};
}
function isOptimisticMessage(message: Message): boolean {
return typeof message.id === 'string' && message.id.startsWith('opt-');
}
function buildSampleDisplayText(filename: string, text: string): string {
const label = filename.trim() || '粘贴文本';
return `已识别样文(${label}):\n\n${text.trim()}`;
}
/**
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
*
@@ -63,6 +98,10 @@ export function WritingSetupChat({
const [chatModel, setChatModel] = useState('');
// 快捷模式:开启后跳过逐项选项澄清(文章类型/字数/读者用默认),给标题直接生成大纲。
const [quickMode, setQuickMode] = useState(false);
const [sampleUploading, setSampleUploading] = useState(false);
const [sampleMessage, setSampleMessage] = useState('');
const [sampleError, setSampleError] = useState('');
const [sampleDisplay, setSampleDisplay] = useState<{ message: Message; insertAfter: number } | null>(null);
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
@@ -71,6 +110,16 @@ export function WritingSetupChat({
// 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。
const aiWriting = useAIWritingOptional();
const canStartWriting = !onApproveProp && !!aiWriting;
const currentWritingModeType = formCtx?.writingModeType ?? 'normal';
const isImitateMode = currentWritingModeType === 'imitate';
const recognizedSampleText = formCtx?.sampleText.trim() ?? '';
const sampleReady = isImitateMode && recognizedSampleText.length > 0;
const awaitingSample = isImitateMode && !sampleReady;
const inputPlaceholder = isImitateMode
? sampleReady
? '样文已识别,请输入你的仿写要求,例如:按这个风格写一篇 1500 字周报'
: '上传样文,或直接粘贴样文正文后点击「识别样文」'
: '例如:帮我写一篇 1500 字、面向行业专家关于人工智能商业趋势的分析报道';
// 首个模型可用时默认选中
useEffect(() => {
@@ -111,9 +160,77 @@ export function WritingSetupChat({
const trimmed = text.trim();
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx));
},
[thread, ensureThreadExists, sendMessage, threadId],
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const publishSampleMessage = useCallback(
(filename: string, text: string) => {
const normalized = text.trim();
if (!normalized) return;
const insertAfter = thread.messages.filter((message) => !isOptimisticMessage(message)).length;
setSampleDisplay({
insertAfter,
message: {
type: 'human',
id: `local-ai-writing-sample-${Date.now()}`,
content: buildSampleDisplayText(filename, normalized),
additional_kwargs: {
ai_writing_sample: true,
client_ts: new Date().toISOString(),
},
},
});
},
[thread.messages],
);
const handleSampleFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !formCtx) return;
setSampleUploading(true);
setSampleError('');
setSampleMessage('');
try {
const result = await extractAIWritingSample(file);
formCtx.setSampleFilename(result.filename);
formCtx.setSampleText(result.text);
publishSampleMessage(result.filename, result.text);
setSampleMessage(result.truncated ? '已识别样文,内容较长已截断' : '已识别样文');
} catch (err) {
setSampleError(err instanceof Error ? err.message : '样文解析失败');
} finally {
setSampleUploading(false);
}
},
[formCtx, publishSampleMessage],
);
const handleComposerSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
await handleChatSubmit(text);
return;
}
const trimmed = text.trim();
if (currentWritingModeType === 'imitate' && formCtx && !formCtx.sampleText.trim()) {
if (!trimmed) {
setSampleError('请先上传或粘贴样文');
return;
}
formCtx.setSampleText(trimmed.slice(0, MAX_SAMPLE_TEXT_LENGTH));
formCtx.setSampleFilename('粘贴文本');
publishSampleMessage('粘贴文本', trimmed.slice(0, MAX_SAMPLE_TEXT_LENGTH));
setSampleError('');
setSampleMessage('已识别样文,请继续输入仿写要求');
return;
}
await handleChatSubmit(text);
},
[currentWritingModeType, formCtx, handleChatSubmit, publishSampleMessage, thread.isLoading],
);
// 提交按钮状态:生成中显示「停止」方块,可点击暂停对话
@@ -140,11 +257,19 @@ export function WritingSetupChat({
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
const skipResearch = formCtx?.skipResearch ?? false;
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
const writingModeType = formCtx?.writingModeType === 'imitate' ? 'imitate' : 'normal';
const sampleText = formCtx?.sampleText?.trim() ?? '';
const request: AIWritingRequest = {
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
writingModeType,
sampleText: writingModeType === 'imitate' ? sampleText : undefined,
sampleFilename: writingModeType === 'imitate' ? formCtx?.sampleFilename || '粘贴文本' : undefined,
imitateStrength: writingModeType === 'imitate' ? formCtx?.imitateStrength ?? 'medium' : undefined,
preserveSampleStructure: writingModeType === 'imitate' ? formCtx?.preserveSampleStructure ?? true : undefined,
skipIntentRecognition: true,
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
@@ -159,9 +284,9 @@ export function WritingSetupChat({
materialSource,
modelName: chatModel || formCtx?.modelName || undefined,
noteSearchSpaceId:
materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
!skipResearch ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
noteSourceIds:
materialSource === 'notebook' && formCtx?.selectedNotebookId != null
!skipResearch && formCtx?.selectedNotebookId != null
? formCtx.selectedDocumentIds
: undefined,
};
@@ -170,13 +295,33 @@ export function WritingSetupChat({
toast.error('写作意图为空,请先补充意图');
return;
}
if (writingModeType === 'imitate' && !sampleText) {
toast.error('仿写模式需要先上传或粘贴样文');
return;
}
await aiWriting.startWriting(request);
},
[aiWriting, applyProposal, formCtx, chatModel],
);
const displayThread = useMemo(() => {
if (!sampleDisplay || !isImitateMode) return thread;
const messages = thread.messages;
const optimisticIndex = messages.findIndex(isOptimisticMessage);
const insertLimit = optimisticIndex >= 0 ? optimisticIndex : messages.length;
const insertIndex = Math.min(sampleDisplay.insertAfter, insertLimit);
return {
...thread,
messages: [
...messages.slice(0, insertIndex),
sampleDisplay.message,
...messages.slice(insertIndex),
],
} as typeof thread;
}, [isImitateMode, sampleDisplay, thread]);
// 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致)
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
const showWelcome = displayThread.messages.length === 0 && !thread.isLoading;
// 首 token 到达前的「正在整理…」占位:模型在跑(isLoading),但当前助手回合还没有任何
// 可见输出(无答案文本、无 <think> 思考、无工具调用)—— 这段纯网络/排队延迟下答案区空白,
@@ -197,7 +342,7 @@ export function WritingSetupChat({
}, [handleChatSubmit]);
return (
<ThreadContext.Provider value={{ thread }}>
<ThreadContext.Provider value={{ thread: displayThread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background dark:bg-transparent">
{/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
@@ -216,7 +361,7 @@ export function WritingSetupChat({
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
thread={displayThread}
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
@@ -238,7 +383,7 @@ export function WritingSetupChat({
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0">当前对话模型</span>
@@ -266,6 +411,36 @@ export function WritingSetupChat({
</Select>
)}
</div>
{formCtx && (
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span className="shrink-0">写作模式</span>
<div className="grid h-8 w-[10.5rem] grid-cols-2 rounded-md border border-input bg-background p-0.5 dark:border-[#1B477E] dark:bg-[#022248]">
{([
['normal', '普通写作'],
['imitate', '样文仿写'],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
disabled={thread.isLoading}
onClick={() => {
formCtx.setWritingModeType(value);
setSampleError('');
setSampleMessage('');
}}
className={cn(
'rounded-[5px] px-2 text-xs font-medium transition-colors',
currentWritingModeType === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted',
)}
>
{label}
</button>
))}
</div>
</div>
)}
{/* 快捷模式开关:放在模型选择右侧。开启后跳过逐项选项确认,给标题直接出大纲。 */}
<label
className={cn(
@@ -286,12 +461,101 @@ export function WritingSetupChat({
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInput onSubmit={({ text }) => void handleComposerSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 1500 字、面向行业专家关于人工智能商业趋势的分析报道"
placeholder={inputPlaceholder}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit status={submitStatus} />
<PromptInputFooter className="flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{formCtx && isImitateMode && (
<>
<label
className={cn(
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-input bg-background px-2 font-medium shadow-xs transition-colors hover:bg-muted',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
thread.isLoading || sampleUploading ? 'pointer-events-none opacity-60' : '',
)}
>
{sampleUploading ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
上传样文
<input
type="file"
accept=".doc,.docx,.pdf,.md,.markdown,.txt"
className="sr-only"
disabled={thread.isLoading || sampleUploading}
onChange={handleSampleFileChange}
/>
</label>
{sampleReady ? (
<div className="flex min-w-0 items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-foreground dark:bg-[#0B376A]/60 dark:text-[#AACEFF]">
<FileText className="size-3.5 shrink-0 text-primary" />
<span className="truncate">
{formCtx.sampleFilename || '样文'} · {formCtx.sampleText.length} 字
</span>
<button
type="button"
disabled={thread.isLoading}
onClick={() => {
formCtx.setSampleText('');
formCtx.setSampleFilename('');
setSampleMessage('');
setSampleError('');
setSampleDisplay(null);
}}
className="ml-1 rounded px-1 text-muted-foreground hover:bg-background hover:text-foreground disabled:opacity-50"
>
清除
</button>
</div>
) : (
<span>先上传样文,或把样文正文粘贴到输入框</span>
)}
{sampleMessage && <span className="text-emerald-600">{sampleMessage}</span>}
{sampleError && <span className="text-destructive">{sampleError}</span>}
</>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{formCtx && isImitateMode && (
<>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>仿写强度</span>
<Select
value={formCtx.imitateStrength}
onValueChange={(v) => formCtx.setImitateStrength(v as 'light' | 'medium' | 'strong')}
disabled={thread.isLoading}
>
<SelectTrigger className="h-7 w-[5.5rem] rounded-md border-input bg-background px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IMITATE_STRENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex cursor-pointer select-none items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={formCtx.preserveSampleStructure}
onChange={(e) => formCtx.setPreserveSampleStructure(e.target.checked)}
disabled={thread.isLoading}
className="size-3.5 shrink-0 accent-primary"
/>
沿用结构
</label>
</>
)}
<PromptInputSubmit
status={submitStatus}
disabled={sampleUploading}
size={awaitingSample && submitStatus !== 'streaming' ? 'sm' : 'icon-sm'}
className={awaitingSample && submitStatus !== 'streaming' ? 'rounded-lg px-3' : undefined}
>
{awaitingSample && submitStatus !== 'streaming' ? '识别样文' : undefined}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
@@ -217,10 +217,10 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
if (status === 'analyzing_sample' || status === 'researching' || status === 'awaiting_material_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<MaterialsSkeleton researchProgress={researchProgress} />
<MaterialsSkeleton researchProgress={status === 'analyzing_sample' ? '正在分析样文风格…' : researchProgress} />
</div>
);
}
@@ -0,0 +1,193 @@
import { useCallback, useRef, useState } from "react";
import { CanvasThreadProvider } from "../../contexts/CanvasThreadContext";
import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import { applyInsert, applyReplace } from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
import { WritingParagraphMenu } from "./WritingParagraphMenu";
import { WritingRewritePreview } from "./WritingRewritePreview";
interface MarkdownAiEditorProps {
/** 原始 markdown(编辑缓冲),由父级持有;编辑时通过 onChange 回写。 */
markdown: string;
/** 编辑回调:返回最新 markdown,父级据此 setDraftContent 并标记有改动。 */
onChange?: (markdown: string) => void;
/** 当前线程 id:透传给 AI 改写接口,便于后端结合上下文。 */
threadId?: string | null;
/** 只读:禁用编辑、AI 改写。 */
readOnly?: boolean;
className?: string;
}
interface PanelPos {
x: number;
y: number;
}
type FlowPhase = "idle" | "menu" | "preview";
interface LastAction {
action: RewriteAction;
tone?: RewriteTone;
customInstruction?: string;
referenceText?: string;
}
/**
* 沙箱 md 的「AI 写作」编辑器:复用 AI 写作 那套 TipTap 编辑器 + 划词「AI 改写」气泡/段落
* 菜单 + 改写流式预览(润色/扩写/缩写/改语气/仿写/自定义指令)。自带 CanvasThread/CanvasUI
* Provider,故可直接嵌入任意页面,无需在外层包裹整页 Provider。
*
* 与 AI 写作 整页编辑器(TextArtifactRenderer)的差异:去掉对 CanvasGraphContext 的耦合(不
* 走全局 artifact 状态),去掉自带的工具栏/目录栏(保存/导出由宿主 ArtifactFileDetail 头部按钮
* 负责),更贴合侧边栏内的紧凑展示。
*/
export function MarkdownAiEditor(props: MarkdownAiEditorProps) {
return (
<CanvasThreadProvider initialThreadId={props.threadId ?? null}>
<CanvasUIProvider>
<MarkdownAiEditorInner {...props} />
</CanvasUIProvider>
</CanvasThreadProvider>
);
}
function MarkdownAiEditorInner({
markdown,
onChange,
readOnly = false,
className,
}: MarkdownAiEditorProps) {
const { writingSelection } = useCanvasUIContext();
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const editor = useWritingEditor({
markdown,
onChange,
editable: !readOnly,
placeholder: "空白文档…",
});
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } =
useWritingRewrite(editor);
const computePos = useCallback((): PanelPos => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (rect.width || rect.height) {
return { x: rect.left, y: rect.bottom + 6 };
}
}
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
}, []);
const openAiMenu = useCallback(() => {
reset();
setPanelPos(computePos());
setPhase("menu");
}, [reset, computePos]);
const closeAll = useCallback(() => {
editor?.commands.clearAiHighlight();
setPhase("idle");
reset();
}, [reset, editor]);
const handleAction = useCallback(
(
action: RewriteAction,
tone?: RewriteTone,
customInstruction?: string,
referenceText?: string,
) => {
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp],
);
const handleReplace = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyReplace(editor, writingSelection, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
const handleInsert = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyInsert(editor, writingSelection, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
return (
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
{editor && !readOnly && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div>
</div>
{!readOnly && phase === "menu" && (
<WritingAiMenu
pos={panelPos}
status={status}
error={error}
onAction={handleAction}
onAbort={reset}
onRetry={handleRetry}
onClose={closeAll}
/>
)}
{!readOnly && phase === "preview" && (
<WritingRewritePreview
pos={panelPos}
status={status}
originalText={writingSelection?.text ?? ""}
result={result}
thinking={thinking}
error={error}
action={lastActionRef.current?.action ?? "polish"}
tone={lastActionRef.current?.tone}
onReplace={handleReplace}
onInsert={handleInsert}
onRetry={handleRetry}
onAbort={reset}
onClose={closeAll}
onFollowUp={handleFollowUp}
/>
)}
</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',
]);
@@ -426,16 +427,19 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
const stream = streamRef.current;
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
// 默认是 general(通用检索),但用户可以在开写前或素材确认后手动切换检索方式。
await stream.submit(
{
user_intent: request.userIntent,
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
skip_intent_parser: request.skipIntentRecognition ?? false,
writing_mode_type: request.writingModeType === 'imitate' ? 'imitate' : 'normal',
sample_text: request.sampleText?.trim() || '',
sample_filename: request.sampleFilename?.trim() || '',
sample_style_profile: null,
imitate_strength: request.imitateStrength ?? 'medium',
preserve_sample_structure: request.preserveSampleStructure ?? true,
article_type: request.articleType,
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
article_type_detail: request.articleTypeDetail?.trim() || '',
@@ -452,7 +456,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0,
max_revisions: 3,
status: 'researching',
status: request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
drafts: [],
progress_events: [],
material_package: null,
@@ -509,10 +513,17 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
}
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
const isApplyingReview =
state.reviewResult != null &&
(
(pausePoint === 'review_confirm' && payload.action === 'accept_review') ||
payload.selectedIssueIndices !== undefined
);
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const selectedSet = payload.selectedIssueIndices !== undefined
? new Set(payload.selectedIssueIndices)
: new Set(allIssues.map((_, i) => i));
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
@@ -541,6 +552,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.materialSource !== undefined) resumePayload.materialSource = payload.materialSource;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
@@ -556,7 +568,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext, materialSource: payload.materialSource });
try {
// resume 是新 invocation,其 config 默认不带最初的 model_name;而写作/审核节点
@@ -26,6 +26,16 @@ export interface WritingFormValue {
setShowOutline: (v: boolean) => void;
outlineText: string;
setOutlineText: (v: string) => void;
writingModeType: 'normal' | 'imitate';
setWritingModeType: (v: 'normal' | 'imitate') => void;
sampleText: string;
setSampleText: (v: string) => void;
sampleFilename: string;
setSampleFilename: (v: string) => void;
imitateStrength: 'light' | 'medium' | 'strong';
setImitateStrength: (v: 'light' | 'medium' | 'strong') => void;
preserveSampleStructure: boolean;
setPreserveSampleStructure: (v: boolean) => void;
articleType: string;
setArticleType: (v: string) => void;
wordCountInput: string;
@@ -83,6 +93,11 @@ export function WritingFormProvider({
const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(true);
const [outlineText, setOutlineText] = useState('');
const [writingModeType, setWritingModeType] = useState<'normal' | 'imitate'>('normal');
const [sampleText, setSampleText] = useState('');
const [sampleFilename, setSampleFilename] = useState('');
const [imitateStrength, setImitateStrength] = useState<'light' | 'medium' | 'strong'>('medium');
const [preserveSampleStructure, setPreserveSampleStructure] = useState(true);
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
@@ -105,12 +120,6 @@ export function WritingFormProvider({
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
// knowledge_base=知识库检索(直连内网 ES)保留;旧值 skill 归一为通用检索 general
if (payload.material_source === 'knowledge_base') {
setMaterialSource('knowledge_base');
} else if (payload.material_source === 'skill' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
setShowOutline(true);
@@ -123,6 +132,11 @@ export function WritingFormProvider({
formExpanded, setFormExpanded,
showOutline, setShowOutline,
outlineText, setOutlineText,
writingModeType, setWritingModeType,
sampleText, setSampleText,
sampleFilename, setSampleFilename,
imitateStrength, setImitateStrength,
preserveSampleStructure, setPreserveSampleStructure,
articleType, setArticleType,
wordCountInput, setWordCountInput,
audience, setAudience,
@@ -135,7 +149,7 @@ export function WritingFormProvider({
modelName, setModelName,
applyProposal,
}),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
[intent, formExpanded, showOutline, outlineText, writingModeType, sampleText, sampleFilename, imitateStrength, preserveSampleStructure, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -51,6 +51,7 @@ const INITIAL_STATE: AIWritingState = {
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
sampleStyleProfile: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
@@ -59,10 +60,14 @@ const INITIAL_STATE: AIWritingState = {
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
sampleAnalysisStreamingText: '',
sampleAnalysisThinkingText: '',
intentStreamingText: '',
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
@@ -82,7 +87,7 @@ export type Action =
| { type: 'handle_batch'; events: ProgressEvent[] }
| { type: 'start'; request: AIWritingRequest }
| { type: 'set_session'; sessionId: string }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext']; materialSource?: AIWritingRequest['materialSource'] }
| { type: 'pause' }
| { type: 'resume_ui' }
// Restore history: rebuild state from the graph checkpoint values snapshot.
@@ -101,7 +106,11 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
return action.events.reduce(handleEvent, prev);
case 'start':
return { ...INITIAL_STATE, status: 'researching', request: action.request };
return {
...INITIAL_STATE,
status: action.request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
request: action.request,
};
case 'set_session':
return { ...prev, sessionId: action.sessionId };
@@ -119,14 +128,24 @@ 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: [] } : {}),
// 进入「修改」:右侧画布恢复初始空占位(isRevising),不再显示旧稿/流式重写
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '', isRevising: true } : {}),
...(action.materialSource && prev.request
? { request: { ...prev.request, materialSource: action.materialSource } }
: {}),
completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
pendingRevisionContext: action.pendingRevisionContext,
};
}
@@ -145,10 +164,12 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
return {
...prev,
isPaused: false,
sampleAnalysisStreamingText: '', sampleAnalysisThinkingText: '',
intentStreamingText: '', intentThinkingText: '',
outlineStreamingText: '', outlineThinkingText: '',
streamingDraftSections: [], streamingDraftTitle: '',
streamingReviewItems: [],
keywordStreamingText: '', keywordThinkingText: '',
skillSelectStreamingText: '', skillSelectThinkingText: '',
};
@@ -201,6 +222,16 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'step_done':
return base;
case 'sample_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
sampleAnalysisStreamingText: isThinking ? prev.sampleAnalysisStreamingText : prev.sampleAnalysisStreamingText + text,
sampleAnalysisThinkingText: isThinking ? prev.sampleAnalysisThinkingText + text : prev.sampleAnalysisThinkingText,
};
}
case 'intent_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
@@ -239,6 +270,27 @@ 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,
sampleStyleProfile: event.sampleStyleProfile ?? null,
sampleAnalysisStreamingText: '',
sampleAnalysisThinkingText: '',
status: 'researching',
};
case 'resumed':
return { ...base, currentPause: null };
@@ -269,6 +321,8 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
};
}
@@ -712,6 +766,23 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
if (out.sample_style_profile && !out.sampleStyleProfile) {
const p = out.sample_style_profile as Record<string, unknown>;
out.sampleStyleProfile = {
summary: p.summary as string | undefined,
articleTypeGuess: (p.article_type_guess ?? p.articleTypeGuess) as string | undefined,
tone: p.tone as string | undefined,
structure: (p.structure as string[] | undefined) ?? [],
languageFeatures: (p.language_features ?? p.languageFeatures ?? []) as string[],
sentenceStyle: (p.sentence_style ?? p.sentenceStyle) as string | undefined,
openingStyle: (p.opening_style ?? p.openingStyle) as string | undefined,
endingStyle: (p.ending_style ?? p.endingStyle) as string | undefined,
imitationGuidance: (p.imitation_guidance ?? p.imitationGuidance ?? []) as string[],
avoidCopying: (p.avoid_copying ?? p.avoidCopying ?? []) as string[],
sampleOutline: (p.sample_outline ?? p.sampleOutline ?? []) as string[],
};
}
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
@@ -753,6 +824,9 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
const draft = (values.current_draft && typeof values.current_draft === 'object')
? (values.current_draft as Record<string, unknown>) : null;
const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
const sampleProfileEvent = values.sample_style_profile
? normalizeProgressDict({ type: 'sample_profile_ready', sample_style_profile: values.sample_style_profile }, sessionId)
: null;
return {
sessionId,
@@ -764,6 +838,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
currentDraftTitle: String(draft?.title ?? ''),
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
reviewResult: normalizeReviewResult(values.review_result),
sampleStyleProfile: sampleProfileEvent?.sampleStyleProfile ?? null,
progressEvents,
completedInterventions,
};
@@ -799,12 +874,15 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
const allowed: ProgressEventType[] = [
'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'sample_chunk',
'sample_profile_ready',
'research_progress', 'keyword_chunk', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
'skill_search_step', 'skill_select_chunk',
'model_switch',
];
if (!allowed.includes(type as ProgressEventType)) return null;
if (type === 'sample_profile_ready') return normalizeProgressDict(o, sessionId);
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
@@ -933,6 +1011,12 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
export type AIWritingValues = {
user_intent?: string;
user_outline_raw?: string;
writing_mode_type?: string;
sample_text?: string;
sample_filename?: string;
sample_style_profile?: unknown;
imitate_strength?: string;
preserve_sample_structure?: boolean;
article_type?: string;
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
article_type_detail?: string;
@@ -0,0 +1,155 @@
/**
* AI 写作 · 对话版(对话驱动写作台)。
*
* 与经典版 `AIWritingPage` 的关系:**并存的新入口,旧页面零改动**。
*
* 布局分两态(与用户预期对齐:「初始和原来一样,进入写作才进入新页面」):
* - idle(未开始写作、非历史回看):沿用经典版的初始双栏——左「写作助手 Q&A」
* (WritingSetupChat)+ 右「写作配置选项」表单(AIWritingPanel,可整块收起),
* 由 WritingFormProvider 承载表单状态;点表单「开始写作」或助手「开始写作」
* 后 status 离开 idle;
* - 写作中 / 历史回看:切到对话驱动工作台——左 WritingChatPanel(统一信息流 +
* 按钮版干预卡 + 底部智能输入框),右 AIWritingDraftPanel(草稿实时预览)。
*
* 引擎层完全复用:AIWritingProvider(状态机 + LangGraph useStream)、
* AIWritingHistoryProvider(历史回看)。路由:/page/canvas/ai-writing-chat。
*/
import { useRef } from 'react';
import { PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { cn } from '@/lib/utils';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { WritingChatPanel } from '../components/ai-writing-chat/WritingChatPanel';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
const CHAT_ID = 'awc-chat';
const DRAFT_ID = 'awc-draft';
// 对话区略宽于经典版左栏,以容纳信息流 + 干预卡
const DEFAULT_LAYOUT = { [CHAT_ID]: 40, [DRAFT_ID]: 60 };
/**
* idle 初始布局 —— 与经典版 AIWritingPage 的 idle 态一致:
* 左「写作助手 Q&A」恒为第一个子节点(位置稳定不重挂,避免 WritingSetupChat
* 内部线程丢失),右「写作配置表单」整块可收起(formExpanded 联动)。
*/
function SetupLayout() {
const { formExpanded, setFormExpanded } = useWritingForm();
return (
<div className="relative flex h-full">
<div className="min-h-0 flex-1 overflow-hidden">
<WritingSetupChat />
</div>
<div
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width,min-width] duration-300 ease-in-out',
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px] border-l' : 'w-0 min-w-0',
)}
>
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields />
</div>
</div>
{!formExpanded && (
<button
type="button"
onClick={() => setFormExpanded(true)}
title="展开写作配置"
className="absolute right-3 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
>
<PanelRightOpen className="size-4" />
</button>
)}
</div>
);
}
/** 写作中 / 历史回看:对话驱动工作台(左信息流 + 右草稿预览)。 */
function ChatWorkbenchLayout() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
<ResizablePanel
id={CHAT_ID}
defaultSize={40}
minSize={25}
className="overflow-hidden flex flex-col"
>
<WritingChatPanel />
</ResizablePanel>
<ResizableHandle
withHandle
className="after:left-0 after:w-3 after:translate-x-0 [&>div]:translate-x-1.5"
/>
<ResizablePanel
id={DRAFT_ID}
defaultSize={60}
minSize={35}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
);
}
function AIWritingChatBody() {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
// idle 且非历史回看 → 经典初始布局;开始写作 / 查看历史 → 对话驱动工作台。
// 两套布局都保持挂载态简单切换:SetupLayout 内的 WritingSetupChat 在开始写作后
// 卸载没有代价(它只负责启动前配置,线程与写作 graph 解耦)。
if (state.status === 'idle' && !isHistoryMode) {
return <SetupLayout />;
}
return <ChatWorkbenchLayout />;
}
export default function AIWritingChatPage() {
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingChatBody />
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -635,7 +635,6 @@ export default function AIWritingPage({
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
@@ -3,6 +3,7 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import AIWritingChatPage from "../pages/AIWritingChatPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
import ResearchTemplateEditorPage from "../pages/ResearchTemplateEditorPage";
@@ -12,6 +13,7 @@ export default function OpenCanvasRoutes() {
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing/*" element={<AIWritingPage hideAdvancedFields />} />
<Route path="ai-writing-chat/*" element={<AIWritingChatPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
@@ -135,10 +135,27 @@ export interface ReviewResult {
revisionNotes: ReviewIssue[];
}
// ── 样文仿写 ─────────────────────────────────────────────────────────────────
export interface SampleStyleProfile {
summary?: string;
articleTypeGuess?: string;
tone?: string;
structure?: string[];
languageFeatures?: string[];
sentenceStyle?: string;
openingStyle?: string;
endingStyle?: string;
imitationGuidance?: string[];
avoidCopying?: string[];
sampleOutline?: string[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| "idle"
| "analyzing_sample"
| "researching"
| "awaiting_material_confirm"
| "writing_outline"
@@ -158,8 +175,11 @@ export type ProgressEventType =
| "connected"
| "step_start"
| "step_done"
| "sample_chunk"
| "sample_profile_ready"
| "intent_chunk"
| "intent_ready"
| "keyword_chunk"
| "research_progress"
| "materials_ready"
| "outline_chunk"
@@ -226,6 +246,8 @@ export interface ProgressEvent {
sectionIndex?: number;
sectionTitle?: string;
};
// sample_profile_ready:样文仿写的文风画像
sampleStyleProfile?: SampleStyleProfile;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
@@ -237,6 +259,8 @@ export interface UserInterventionPayload {
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook";
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
@@ -253,6 +277,15 @@ export interface AIWritingRequest {
userIntent: string;
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
userOutline?: string;
/** 写作入口模式:normal=普通写作 / imitate=样文仿写 */
writingModeType?: "normal" | "imitate";
/** 样文原文(上传提取或直接粘贴),仅样文仿写模式使用 */
sampleText?: string;
sampleFilename?: string;
imitateStrength?: "light" | "medium" | "strong";
preserveSampleStructure?: boolean;
/** 从初始化对话确认后进入正式写作时,意图字段已经结构化,可跳过后端二次识别。 */
skipIntentRecognition?: boolean;
articleType: string;
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
articleTypeDetail?: string;
@@ -356,6 +389,7 @@ export interface AIWritingState {
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
sampleStyleProfile: SampleStyleProfile | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
@@ -366,12 +400,18 @@ export interface AIWritingState {
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 样文分析(流式 + 结果)
sampleAnalysisStreamingText: string;
sampleAnalysisThinkingText: string;
// 意图解析(流式 + 结果)
intentStreamingText: string;
intentThinkingText: string;
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 检索词生成 LLM 流式打字内容(正文 / 思考分开)
keywordStreamingText: string;
keywordThinkingText: string;
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
skillSearchSteps: SkillSearchStep[];
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
@@ -212,8 +212,10 @@ export function Step3SummaryMode({
const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const prevRightPanelVisibleRef = useRef(rightPanelVisible);
const prevRightPanelVisibleRef = useRef(false);
useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
prevRightPanelVisibleRef.current = rightPanelVisible;
@@ -0,0 +1,85 @@
/**
* 按任务保存的研判情报报告详情。
*
* 后端 `/api/task-reports/by-task/{taskId}` 返回的外壳和原 Consumer
* `selectSqReport` 完全一致:`{ state, msg, data }`。其中 `contentJson`
* 保持为 JSON 字符串,由使用方按 `categoryType` 反序列化。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type TaskReportTaskId = string | number;
export interface TaskReportRecord {
id: string;
taskId: TaskReportTaskId;
createTime: string;
updateTime: string;
sessionId: string;
contentJson: string;
categoryType: string;
}
export interface TaskReportResponse {
state: string;
msg: string;
data: TaskReportRecord[];
}
/** 保存/修改时可省略由后端生成或维护的 id、创建/更新时间。 */
export type TaskReportRecordDraft = Omit<
TaskReportRecord,
"id" | "taskId" | "createTime" | "updateTime"
> &
Partial<Pick<TaskReportRecord, "id" | "taskId" | "createTime" | "updateTime">>;
const BASE = () => `${getBackendBaseURL()}/api/task-reports/by-task`;
function endpoint(taskId: TaskReportTaskId) {
return `${BASE()}/${encodeURIComponent(String(taskId))}`;
}
async function readResponse(res: Response, context: string): Promise<TaskReportResponse> {
const body = (await res.json().catch(() => null)) as TaskReportResponse | { detail?: unknown } | null;
if (!res.ok) {
const detail = body && typeof body === "object" && "detail" in body ? String(body.detail) : "";
throw new Error(detail || `${context} failed (${res.status})`);
}
if (!body || !Array.isArray((body as TaskReportResponse).data)) {
throw new Error(`${context} returned an invalid report payload`);
}
return body as TaskReportResponse;
}
/** 查询任务详情;尚未保存时后端返回 `{ state: "200", data: [] }`。 */
export async function getTaskReport(taskId: TaskReportTaskId): Promise<TaskReportResponse> {
const res = await apiFetch(endpoint(taskId), { method: "GET" });
return readResponse(res, "get task report");
}
/** 首次保存一个任务的完整 `data` 数组。任务已有详情时会返回 409。 */
export async function saveTaskReport(
taskId: TaskReportTaskId,
data: TaskReportRecordDraft[],
): Promise<TaskReportResponse> {
const res = await apiFetch(endpoint(taskId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
});
return readResponse(res, "save task report");
}
/** 修改一个任务的完整 `data` 数组。保留同 id 项的原始 createTime。 */
export async function updateTaskReport(
taskId: TaskReportTaskId,
data: TaskReportRecordDraft[],
): Promise<TaskReportResponse> {
const res = await apiFetch(endpoint(taskId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
});
return readResponse(res, "update task report");
}
@@ -1,63 +1,67 @@
import sqReportMock from './sq-report-mock.json';
import sqReportMock from "./sq-report-mock.json";
/**
* 「研判情报报告」接口 `/cop/selectSqReport` 的假数据 —— 给「大屏绘制智能体」独立页面
* (深链 `?taskId=` 模式)在真实接口未就绪时本地预览。
* 研判情报报告的契约样例。
*
* 真实接口(GET `{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=`)返回结构:
* `{ state, msg, data: [{ id, taskId, createTime, updateTime, sessionId, contentJson, categoryType }] }`
* 其中 `contentJson` 是**被转义的 JSON 字符串**,`categoryType` 形如「敌情关键事件 JSON」。
* 假数据 `sq-report-mock.json` 即后端给出的样例响应(taskId=3332),原样保留 contentJson 字符串。
*
* 启停由环境变量 `VITE_MOCK_SQ_REPORT` 控制(见 `utils/request.ts` 请求拦截器):
* - `VITE_MOCK_SQ_REPORT=true`(默认,.env.development) → 走本地假数据;
* - `VITE_MOCK_SQ_REPORT=false`(.env.production / .env.network / .env.sit) → 走真实接口。
* 与 `VITE_MOCK_COP_TASK_DETAIL`(cop-task-detail)独立,机制完全一致。
* ⚠️ 真实接口联调通过后删除本 mock 及其引用。
* `sq-report-mock.json` 是外部系统提供的真实字段格式,保留在仓库中供接口联调、
* 手工保存测试和解析器测试使用。运行时不再将它注入请求;大屏与 rwfx 检查统一通过
* `/api/task-reports/by-task/{taskId}` 读取数据库保存的数据。
*/
/** 命中本 mock 的接口路径。 */
export const SQ_REPORT_ROUTE = '/cop/selectSqReport';
export interface SqReportMockItem {
id: string;
taskId: string | number;
createTime: string;
updateTime: string;
sessionId: string;
contentJson: string;
categoryType: string;
}
/** 判断某请求 url 是否是 selectSqReport。 */
export const SQ_REPORT_MOCK_RESPONSE = sqReportMock as {
state: string;
msg: string;
data: SqReportMockItem[];
};
/** 大屏 mock 命中的真实接口路径。 */
export const SQ_REPORT_ROUTE = "/cop/selectSqReport";
/** 供 axios 请求拦截器判断是否需要注入大屏样例数据。 */
export function matchSqReportMock(url?: string): boolean {
return !!url && url.includes(SQ_REPORT_ROUTE);
return Boolean(url?.includes(SQ_REPORT_ROUTE));
}
/** 从 axios config 取 taskId(优先 params,其次 url query)。 */
function readTaskIdFromConfig(config: { url?: string; params?: Record<string, unknown> }): string {
function readTaskId(config: { url?: string; params?: Record<string, unknown> }): string {
const fromParams = config.params?.taskId;
if (fromParams != null && fromParams !== '') return String(fromParams);
if (config.url) {
const m = config.url.match(/[?&]taskId=([^&]+)/);
if (m) return decodeURIComponent(m[1]);
}
return '';
if (fromParams != null && fromParams !== "") return String(fromParams);
const matched = config.url?.match(/[?&]taskId=([^&]+)/);
return matched ? decodeURIComponent(matched[1]) : "";
}
/**
* 模拟 selectSqReport 的 body:返回后端给的样例响应,并把 data 里每条的 taskId 改成
* 当前请求的 taskId(让任意 taskId 进来都能看到这份演示报告)。
* 以真实 selectSqReport 的响应结构返回样例数据;请求 taskId 会写回每条记录,
* 让任意深链(例如 demo1)都能加载同一套可解析的大屏数据。
*/
function buildSqReportBody(taskId: string) {
const tid = taskId || String((sqReportMock as { data?: Array<{ taskId?: unknown }> }).data?.[0]?.taskId ?? '');
const src = sqReportMock as { state: string; msg: string; data: Array<Record<string, unknown>> };
return {
state: src.state,
msg: src.msg,
data: (src.data ?? []).map((item) => ({ ...item, taskId: tid })),
};
}
/** 临时假数据 axios adapter:命中 selectSqReport 时直接返回 mock body,不发真实请求。 */
export function sqReportMockAdapter(config: {
url?: string;
params?: Record<string, unknown>;
}): Promise<{ data: unknown; status: number; statusText: string; headers: Record<string, unknown>; config: unknown }> {
const body = buildSqReportBody(readTaskIdFromConfig(config));
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: body, status: 200, statusText: 'OK', headers: {}, config });
}, 220);
}): Promise<{
data: typeof SQ_REPORT_MOCK_RESPONSE;
status: number;
statusText: string;
headers: Record<string, unknown>;
config: unknown;
}> {
const taskId = readTaskId(config);
return Promise.resolve({
data: {
...SQ_REPORT_MOCK_RESPONSE,
data: SQ_REPORT_MOCK_RESPONSE.data.map((item) => ({ ...item, taskId: taskId || item.taskId })),
},
status: 200,
statusText: "OK",
headers: {},
config,
});
}
+2 -4
View File
@@ -22,9 +22,7 @@ const USE_REAL_DATA = readRuntimeFlag('VITE_USE_REAL_DATA')
// 与 USE_REAL_DATA 独立。改 env 后需重启 dev server 生效。
const MOCK_COP_TASK_DETAIL = readRuntimeFlag('VITE_MOCK_COP_TASK_DETAIL')
// 研判情报报告(selectSqReport,大屏绘制智能体深链用)数据源开关:
// 默认真实数据;仅当 VITE_MOCK_SQ_REPORT=true 时走本地假数据(真实接口未就绪时调试用)。
// 与上面两个开关独立。改 env 后需重启 dev server 生效。
// 大屏绘制(selectSqReport)数据源开关:仅当开启时拦截为本地样例数据。
const MOCK_SQ_REPORT = readRuntimeFlag('VITE_MOCK_SQ_REPORT')
// axios.interceptors
@@ -67,7 +65,7 @@ service.interceptors.request.use(
if (MOCK_COP_TASK_DETAIL && matchCopTaskDetailMock(config.url)) {
config.adapter = copTaskDetailMockAdapter as any;
}
// 研判情报报告(大屏绘制智能体深链):默认真实接口;仅 VITE_MOCK_SQ_REPORT=true 时走本地假数据。
// 大屏绘制:mock 模式下返回与真实 selectSqReport 完全相同的响应结构。
if (MOCK_SQ_REPORT && matchSqReportMock(config.url)) {
config.adapter = sqReportMockAdapter as any;
}