diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 6228c720..15aac5a9 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -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) diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index f01f2187..a5092803 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -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 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 80a2b0f7..0c211a17 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -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 diff --git a/frontend-web/.env.network b/frontend-web/.env.network index f8962ea6..4bcd8f15 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -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 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 0f10b55c..67dabce1 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -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 diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 5ae77457..913fe3a3 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -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 diff --git a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md new file mode 100644 index 00000000..ea9c4d50 --- /dev/null +++ b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md @@ -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
(流式只读 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` 的 `` | **未使用** | + +两套菜单均采用: + +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`)。 diff --git a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md new file mode 100644 index 00000000..da73fbeb --- /dev/null +++ b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md @@ -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`(自带大纲分支 + 素材关联)。 diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 33463bf9..d5149b45 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -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; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; + onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; + onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { 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({ /> ) : null; + } else if (group.type === "assistant:setup-writing-approval") { + const message = group.messages[0]; + return message ? ( +
+ + +
+ ) : 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 = { + strict: "严格依据素材", + loose: "可结合通用知识", +}; +const MATERIAL_SOURCE_LABELS: Record = { + knowledge_base: "知识库", + skill: "技能检索", +}; + +function SetupWritingApprovalCard({ + disabled, + message, + onApprove, + onReject, +}: { + disabled: boolean; + message: unknown; + onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; + onReject?: (payload: SetupWritingApprovalPayload) => void | Promise; +}) { + 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 ( +
+
+
+
写作配置建议
+
确认后将应用到左侧写作表单。
+
+ + {approvalPayload.user_intent ? ( +
+
写作意图
+

{approvalPayload.user_intent}

+
+ ) : null} + + {rows.length > 0 ? ( +
+ {rows.map((row) => ( +
+
{row.label}
+

{row.value}

+
+ ))} +
+ ) : null} + + {approvalPayload.outline ? ( +
+
已有大纲
+
+              {approvalPayload.outline}
+            
+
+ ) : null} +
+ +
+ + +
+
+ ); +} + function getClarificationPayload(message: unknown): ClarificationPayload | null { if (typeof message !== "object" || message === null) { return null; diff --git a/frontend-web/src/core/auth/external-login.ts b/frontend-web/src/core/auth/external-login.ts index bd80d522..37c865a6 100644 --- a/frontend-web/src/core/auth/external-login.ts +++ b/frontend-web/src/core/auth/external-login.ts @@ -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 = { diff --git a/frontend-web/src/core/auth/wujie-ticket-login.ts b/frontend-web/src/core/auth/wujie-ticket-login.ts new file mode 100644 index 00000000..9bccd610 --- /dev/null +++ b/frontend-web/src/core/auth/wujie-ticket-login.ts @@ -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 { + 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; + } +} diff --git a/frontend-web/src/core/embed/wujie.ts b/frontend-web/src/core/embed/wujie.ts index cd98e224..e725cba2 100644 --- a/frontend-web/src/core/embed/wujie.ts +++ b/frontend-web/src/core/embed/wujie.ts @@ -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; diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index f13c5747..fbc038a7 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -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( @@ -50,7 +53,8 @@ export function groupMessages( 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( 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 []; diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 54c20f4a..bfa8dfd6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -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({ /> +
+ + {showOutline && ( + <> +