This commit is contained in:
董宏凯 committed 2026-06-03 16:28:34 +08:00
commit 016f921cf0
34 files changed
+1921 -54

No files matched your search

+2
View File
@@ -26,6 +26,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
@@ -17,6 +17,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
+4
View File
@@ -25,6 +25,10 @@
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
+2
View File
@@ -23,6 +23,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
+2
View File
@@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
+2
View File
@@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
@@ -0,0 +1,228 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -0,0 +1,274 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
@@ -60,6 +60,8 @@ export function MessageList({
onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
}: {
className?: string;
threadId: string;
@@ -70,6 +72,8 @@ export function MessageList({
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
@@ -77,6 +81,7 @@ export function MessageList({
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const { t } = useI18n();
const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
@@ -307,6 +312,23 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-writing-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupWritingApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupWritingApprove}
onReject={onSetupWritingReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -485,6 +507,16 @@ export type SetupAgentApprovalPayload = {
skills?: string[];
};
export type SetupWritingApprovalPayload = {
user_intent?: string;
article_type?: string;
word_count_target?: number | null;
target_audience?: string;
writing_mode?: string;
material_source?: string;
outline?: string;
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -635,6 +667,155 @@ function SetupAgentApprovalCard({
);
}
function getSetupWritingApprovalPayload(
message: unknown,
): SetupWritingApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupWritingApprovalPayload;
}
const WRITING_MODE_LABELS: Record<string, string> = {
strict: "严格依据素材",
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
knowledge_base: "知识库",
skill: "技能检索",
};
function SetupWritingApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupWritingApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
const rows: Array<{ label: string; value: string }> = [];
if (approvalPayload.article_type) {
rows.push({ label: "文章类型", value: approvalPayload.article_type });
}
if (typeof approvalPayload.word_count_target === "number") {
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
}
if (approvalPayload.target_audience) {
rows.push({ label: "目标读者", value: approvalPayload.target_audience });
}
if (approvalPayload.writing_mode) {
rows.push({
label: "写作模式",
value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
});
}
if (approvalPayload.material_source) {
rows.push({
label: "素材来源",
value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
});
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
</div>
{approvalPayload.user_intent ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">写作意图</div>
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
</div>
) : null}
{rows.length > 0 ? (
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{rows.map((row) => (
<div key={row.label} className="space-y-0.5">
<div className="text-muted-foreground text-xs">{row.label}</div>
<p className="text-sm font-medium">{row.value}</p>
</div>
))}
</div>
) : null}
{approvalPayload.outline ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">已有大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.outline}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
userId: "login.userId",
env: "login.env",
/** MaxKey getTokenByOnlineTicket 返回的 token */
maxkeyOnlineToken: "login.maxkeyOnlineToken",
/** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
userDetailToken: "login.userDetailToken",
} as const;
export type ExternalLoginParams = {
@@ -0,0 +1,142 @@
import { requestCrudService } from "@/service/request-axios-service";
import {
EXTERNAL_LOGIN_STORAGE_KEYS,
persistExternalLoginParams,
} from "./external-login";
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
/\/$/,
"",
);
const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
/\/$/,
"",
);
/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
export const USER_DETAIL_TOKEN_STORAGE_KEY =
EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
export type MaxkeyTokenByTicketResponse = {
code: number;
data: string;
message?: string;
};
export type UserDetailTokenPayload = {
sub?: string;
user_id?: string;
exp?: number;
expired?: boolean;
iat?: number;
institution?: string;
iss?: string;
locale?: string;
kid?: string;
jti?: string;
[key: string]: unknown;
};
type UserDetailTokenResponse = {
code?: number;
data?: UserDetailTokenPayload;
message?: string;
};
function persistMaxkeyOnlineToken(token: string): void {
try {
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
} catch {
// 静默失败,不阻断登录
}
}
function persistUserDetailToken(data: UserDetailTokenPayload): void {
try {
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
if (data.user_id) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
}
} catch {
// 静默失败
}
}
/**
* 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
* - token 存入 localStorage(`login.maxkeyOnlineToken`)
* - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
* @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
*/
export async function resolveUsernameByOnlineTicket(
ticket: string,
): Promise<string> {
const onlineTicket = ticket.trim();
if (!onlineTicket) {
throw new Error("缺少 onlineTicket,无法完成无界登录。");
}
if (!AUTH_BASE_URL) {
throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
}
if (!CONSUMER_URL) {
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
}
persistExternalLoginParams({ ticket: onlineTicket });
const tokenRes = (await requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
)) as unknown as MaxkeyTokenByTicketResponse;
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
if (tokenRes.code !== 200 || !tokenRes.data) {
throw new Error(
tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
);
}
persistMaxkeyOnlineToken(tokenRes.data);
const detailRes = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailToken`,
{ ticket: onlineTicket },
)) as unknown as UserDetailTokenResponse;
console.log("[wujie] userDetailToken:", detailRes);
const userData = detailRes.data;
if (!userData) {
throw new Error("userDetailToken 未返回用户 data。");
}
persistUserDetailToken(userData);
const username = (userData.sub ?? "").trim();
if (!username) {
throw new Error("userDetailToken 中缺少 sub(用户名)。");
}
return username;
}
export function readMaxkeyOnlineToken(): string {
try {
return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
export function readUserDetailToken(): UserDetailTokenPayload | null {
try {
const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as UserDetailTokenPayload;
} catch {
return null;
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
* bootstrap 之前就已注入,因此首帧即可读)。
*
* 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
* ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
*/
@@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login";
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
export interface WujieProps {
/** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
/** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
ticket?: string;
/** 外部用户 id。 */
userId?: string;
+21 -1
View File
@@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -29,6 +31,7 @@ type MessageGroup =
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -50,7 +53,8 @@ export function groupMessages<T>(
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
) {
return last;
}
@@ -88,6 +92,13 @@ export function groupMessages<T>(
type: "assistant:setup-agent-approval",
messages: [message],
});
} else if (isSetupWritingApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-writing-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
@@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
);
}
export function isSetupWritingApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_writing" &&
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
message.additional_kwargs.setup_writing_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import { useModels } from '@/core/models/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
@@ -31,7 +32,6 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
const MAX_INTENT_LENGTH = 500;
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
@@ -49,14 +49,17 @@ export function AIWritingForm({
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] =
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
const [modelName, setModelName] = useState('');
const {
intent, setIntent,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
audience, setAudience,
wordCountInput, setWordCountInput,
strictMode, setStrictMode,
materialSource, setMaterialSource,
modelName, setModelName,
} = useWritingForm();
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -115,6 +118,7 @@ export function AIWritingForm({
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
userOutline: outlineText.trim() || undefined,
articleType,
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
@@ -155,6 +159,43 @@ export function AIWritingForm({
/>
</div>
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => setShowOutline(!showOutline)}
disabled={disabled}
className="flex w-full items-center justify-between text-left"
>
<span className={FIELD_LABEL_CLASS}>
已有大纲
<span className="ml-1 text-sm font-normal text-muted-foreground dark:text-white/70">
(可选)
</span>
</span>
<span className="text-sm text-muted-foreground dark:text-white/70">
{showOutline ? '收起 ▴' : '我已有大纲,直接粘贴 ▾'}
</span>
</button>
{showOutline && (
<>
<Textarea
placeholder={
'粘贴你已有的大纲,将完全按你的大纲生成(支持一/二/三级目录)。例如:\n' +
'一、引言\n(一)背景\n(二)意义\n二、现状分析\n…'
}
value={outlineText}
onChange={(e) => setOutlineText(e.target.value)}
disabled={disabled}
rows={6}
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
/>
<p className="text-xs leading-relaxed text-muted-foreground/80">
填写后将跳过自动大纲规划,按你粘贴的大纲逐章生成;留空则由 AI 自动规划大纲。
</p>
</>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>文章类型</label>
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from '@/components/ai-elements/prompt-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
type SetupWritingApprovalPayload,
} from '@/components/workspace/messages';
import { ThreadContext } from '@/components/workspace/messages/context';
import { getAPIClient } from '@/core/api';
import { useModels } from '@/core/models/hooks';
import { useThreadStream } from '@/core/threads/hooks';
import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
/**
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
*
* 复用智能体创建页的「对话 → setup_writing 工具审批卡 → 回填表单」模式:
* - 用 ``useThreadStream`` 起一条独立的 lead_agent 线程,context 带 ``is_writing_setup``,
* 后端走写作配置专用 bootstrap(只挂 setup_writing 工具)。
* - 模型调用 setup_writing 被 SetupWritingApprovalMiddleware 拦截成审批卡。
* - 用户点「应用到表单」→ ``applyProposal`` 把建议字段写进左侧写作表单(WritingFormContext)。
*
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
*/
export function WritingSetupChat() {
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState('');
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
const { applyProposal } = useWritingForm();
// 首个模型可用时默认选中
useEffect(() => {
if (models.length === 0) return;
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
}, [models]);
// 注入后端 prompt 的合法文章类型(只取 key/label)
const validArticleTypes = useMemo(
() => articleTypes.map((t) => ({ key: t.key, label: t.label })),
[articleTypes],
);
const [thread, sendMessage] = useThreadStream({
threadId,
context: {
mode: 'flash',
is_writing_setup: true,
valid_article_types: validArticleTypes,
model_name: chatModel || undefined,
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload);
toast.success('已应用到左侧表单,可继续编辑或开始写作');
},
[applyProposal],
);
const handleReject = useCallback(async () => {
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
}, [handleChatSubmit]);
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background">
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span>
</header>
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
/>
</div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? '加载中…' : '暂无模型'}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
{models.map((model) => (
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -14,6 +14,7 @@ import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
import { AIWritingProvider } from "../../contexts/AIWritingContext";
import { WritingFormProvider } from "../../contexts/WritingFormContext";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
@@ -68,7 +69,9 @@ export function CanvasResizableLayout() {
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : (
<AIWritingProvider>
<AIWritingPanel />
<WritingFormProvider>
<AIWritingPanel />
</WritingFormProvider>
</AIWritingProvider>
)}
</div>
@@ -259,6 +259,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
await stream.submit(
{
user_intent: request.userIntent,
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
article_type: request.articleType,
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
@@ -0,0 +1,102 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
/**
* 写作配置表单的共享状态。
*
* 上提到 Context 是为了让「右侧写作助手 Q&A」在用户批准模型建议后,能把建议字段
* 一次性回填到「左侧写作表单」的输入框(``applyProposal``)。表单与 Q&A 面板共用同一份
* state,因此 Provider 必须同时包住两侧面板(见 AIWritingPage)。
*/
export interface WritingFormValue {
intent: string;
setIntent: (v: string) => void;
showOutline: boolean;
setShowOutline: (v: boolean) => void;
outlineText: string;
setOutlineText: (v: string) => void;
articleType: string;
setArticleType: (v: string) => void;
wordCountInput: string;
setWordCountInput: (v: string) => void;
audience: string;
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill';
setMaterialSource: (v: 'knowledge_base' | 'skill') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
applyProposal: (payload: SetupWritingApprovalPayload) => void;
}
const WritingFormContext = createContext<WritingFormValue | null>(null);
export function useWritingForm(): WritingFormValue {
const ctx = useContext(WritingFormContext);
if (!ctx) {
throw new Error('useWritingForm must be used within WritingFormProvider');
}
return ctx;
}
export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children }: { children: ReactNode }) {
const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill'>('knowledge_base');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
setWordCountInput(String(payload.word_count_target));
}
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);
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
setMaterialSource(payload.material_source);
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
setShowOutline(true);
}
}, []);
const value = useMemo<WritingFormValue>(
() => ({
intent, setIntent,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
wordCountInput, setWordCountInput,
audience, setAudience,
strictMode, setStrictMode,
materialSource, setMaterialSource,
modelName, setModelName,
applyProposal,
}),
[intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
}
@@ -623,6 +623,7 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
export type AIWritingValues = {
user_intent?: string;
user_outline_raw?: string;
article_type?: string;
status?: string;
material_package?: unknown;
@@ -90,7 +90,9 @@ import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { AIWritingProvider } from '../contexts/AIWritingContext';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { WritingFormProvider } from '../contexts/WritingFormContext';
import {
ResizableHandle,
ResizablePanel,
@@ -104,6 +106,15 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
/**
* 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
* 一旦开始写作(status !== idle)就切回草稿预览。
*/
function AIWritingRightPanel() {
const { state } = useAIWriting();
return state.status === 'idle' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
}
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
@@ -116,6 +127,7 @@ export default function AIWritingPage({
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
@@ -147,12 +159,13 @@ export default function AIWritingPage({
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
<AIWritingRightPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
@@ -220,6 +220,8 @@ export interface UserInterventionPayload {
export interface AIWritingRequest {
userIntent: string;
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
userOutline?: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
@@ -445,10 +445,11 @@ export default function AdminScheduledTasksPage() {
</WorkspaceBody>
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle>编辑定时任务</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="flex flex-col gap-3">
<Input
value={form.name}
@@ -475,7 +476,8 @@ export default function AdminScheduledTasksPage() {
placeholder="时区,例如:Asia/Shanghai"
/>
<Textarea
className="min-h-40"
rows={10}
className="field-sizing-fixed min-h-40 max-h-48 resize-y overflow-y-auto"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
@@ -483,7 +485,8 @@ export default function AdminScheduledTasksPage() {
placeholder="任务 Prompt"
/>
</div>
<DialogFooter>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
+21 -5
View File
@@ -8,8 +8,10 @@ import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
persistExternalLoginParams,
readExternalLoginParams,
resolveThemeFromQuery,
} from "@/core/auth/external-login";
import { resolveUsernameByOnlineTicket } from "@/core/auth/wujie-ticket-login";
import { isWujieEnv, useWujieProps } from "@/core/embed/wujie";
import {
GENERAL_QA_ROUTE,
@@ -54,6 +56,16 @@ export default function LoginPage() {
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
const wujieProps = useWujieProps();
/** onlineTicket:无界 props 优先,其次 URL query,最后读 localStorage */
const onlineTicket = useMemo(() => {
const fromWujie = wujieProps.ticket?.trim() ?? "";
const fromQuery = externalParams.ticket.trim();
if (fromWujie || fromQuery) {
return fromWujie || fromQuery;
}
return readExternalLoginParams().ticket.trim();
}, [wujieProps.ticket, externalParams.ticket]);
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
useEffect(() => {
if (!isWujieEnv()) return;
@@ -71,11 +83,11 @@ export default function LoginPage() {
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => {
persistExternalLoginParams({
ticket: externalParams.ticket,
ticket: onlineTicket || externalParams.ticket,
userId: externalParams.userId,
env: externalParams.env,
});
}, [externalParams.ticket, externalParams.userId, externalParams.env]);
}, [onlineTicket, externalParams.ticket, externalParams.userId, externalParams.env]);
useEffect(() => {
let cancelled = false;
@@ -84,13 +96,17 @@ export default function LoginPage() {
setStatus("loading");
setErrorMessage("");
try {
if (yUserId) {
let deerflowUsername = resolvedUsername;
if (onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
const auth = await loginByUsername(resolvedUsername);
const auth = await loginByUsername(deerflowUsername);
if (cancelled) {
return;
}
@@ -113,7 +129,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]);
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, onlineTicket]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
+22 -5
View File
@@ -112,6 +112,13 @@ const emptyForm = {
require_markdown: false,
};
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
const SCHEDULED_TASK_TEXTAREA_CLASS =
"field-sizing-fixed resize-y overflow-y-auto";
const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
const { theme } = useTheme();
const darkToastOpts =
@@ -824,10 +831,11 @@ export default function ScheduledTasksPage() {
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader className="min-w-0">
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
<DialogHeader className="min-w-0 shrink-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="flex min-w-0 flex-col gap-3">
<Input
value={form.name}
@@ -837,7 +845,11 @@ export default function ScheduledTasksPage() {
placeholder="任务名称"
/>
<Textarea
className="min-h-16"
rows={DESCRIPTION_TEXTAREA_ROWS}
className={cn(
SCHEDULED_TASK_TEXTAREA_CLASS,
"min-h-[4.5rem] max-h-28",
)}
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: event.target.value }))
@@ -883,7 +895,11 @@ export default function ScheduledTasksPage() {
</SelectContent>
</Select>
<Textarea
className="min-h-40"
rows={PROMPT_TEXTAREA_ROWS}
className={cn(
SCHEDULED_TASK_TEXTAREA_CLASS,
"min-h-40 max-h-48",
)}
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
@@ -905,7 +921,8 @@ export default function ScheduledTasksPage() {
/>
</div>
</div>
<DialogFooter>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>