Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
016f921cf0
34 files changed
+1921
-54
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -209,6 +209,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"max_concurrent_subagents",
|
||||
"agent_name",
|
||||
"is_bootstrap",
|
||||
"is_writing_setup",
|
||||
"valid_article_types",
|
||||
"excluded_tools",
|
||||
"skill_stop_names",
|
||||
"skill_stop_message",
|
||||
|
||||
+120
-3
@@ -7,11 +7,19 @@ import logging
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
|
||||
from ..outline_parser import parse_user_outline
|
||||
from ..prompts.writer_prompts import (
|
||||
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
||||
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
|
||||
OUTLINE_MATERIAL_ASSIGN_USER,
|
||||
OUTLINE_NORMALIZE_SYSTEM,
|
||||
OUTLINE_NORMALIZE_USER,
|
||||
OUTLINE_REVISE_SYSTEM,
|
||||
OUTLINE_REVISE_USER,
|
||||
OUTLINE_SYSTEM,
|
||||
OUTLINE_USER,
|
||||
)
|
||||
from ._utils import get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
||||
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
|
||||
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,6 +88,108 @@ def _parse_outline(raw: dict) -> Outline:
|
||||
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
|
||||
|
||||
|
||||
def _derive_title(state: AIWritingState) -> str:
|
||||
"""用户大纲没给总标题时,用写作意图兜底一个标题。"""
|
||||
intent = (state.get("user_intent") or "").strip()
|
||||
if not intent:
|
||||
return "未命名文章"
|
||||
return intent[:30].rstrip() + ("…" if len(intent) > 30 else "")
|
||||
|
||||
|
||||
async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline:
|
||||
"""确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。
|
||||
|
||||
再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。
|
||||
"""
|
||||
model_name = (state.get("model_name") or "").strip() or None
|
||||
line_fallback = {
|
||||
"title": "",
|
||||
"sections": [
|
||||
{"section_title": ln.strip(), "key_points": [], "material_ids": []}
|
||||
for ln in raw.splitlines() if ln.strip()
|
||||
] or [{"section_title": "正文", "key_points": [], "material_ids": []}],
|
||||
}
|
||||
result = await llm_json(
|
||||
OUTLINE_NORMALIZE_SYSTEM,
|
||||
OUTLINE_NORMALIZE_USER.format(raw_outline=raw),
|
||||
line_fallback,
|
||||
config,
|
||||
model_name=model_name,
|
||||
)
|
||||
return _parse_outline(result)
|
||||
|
||||
|
||||
async def _assign_materials_to_outline(
|
||||
state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict
|
||||
) -> Outline:
|
||||
"""为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。
|
||||
|
||||
LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则
|
||||
保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。
|
||||
"""
|
||||
sections = outline.get("sections", [])
|
||||
materials = pkg.get("materials") or []
|
||||
if not sections or not materials:
|
||||
return outline
|
||||
|
||||
valid_ids = {m["id"] for m in materials}
|
||||
sections_list = "\n".join(
|
||||
f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections)
|
||||
)
|
||||
model_name = (state.get("model_name") or "").strip() or None
|
||||
result = await llm_json(
|
||||
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
|
||||
OUTLINE_MATERIAL_ASSIGN_USER.format(
|
||||
user_intent=state.get("user_intent", ""),
|
||||
sections_list=sections_list,
|
||||
materials_summary=_materials_summary(pkg),
|
||||
),
|
||||
{"assignments": []},
|
||||
config,
|
||||
model_name=model_name,
|
||||
)
|
||||
|
||||
assignments = result.get("assignments")
|
||||
if not isinstance(assignments, list):
|
||||
return outline
|
||||
for i, section in enumerate(sections):
|
||||
if i >= len(assignments):
|
||||
break
|
||||
ids = assignments[i]
|
||||
if isinstance(ids, list):
|
||||
section["material_ids"] = [mid for mid in ids if mid in valid_ids]
|
||||
return outline
|
||||
|
||||
|
||||
async def _build_outline_from_user(
|
||||
state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str
|
||||
) -> dict:
|
||||
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。"""
|
||||
writer = safe_get_stream_writer()
|
||||
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
|
||||
|
||||
outline = parse_user_outline(raw)
|
||||
if outline is None:
|
||||
# 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一
|
||||
outline = await _normalize_user_outline_via_llm(state, config, raw)
|
||||
|
||||
if not outline.get("title"):
|
||||
outline = {**outline, "title": _derive_title(state)}
|
||||
|
||||
# 仍走了检索 → 把已检索素材关联到用户章节(结构锁定)
|
||||
outline = await _assign_materials_to_outline(state, config, outline, pkg)
|
||||
|
||||
section_count = len(outline.get("sections", []))
|
||||
return {
|
||||
"current_outline": outline,
|
||||
"status": "awaiting_outline_confirm",
|
||||
"progress_events": [
|
||||
{"type": "step_start", "agent_name": "作家", "message": "采用你提供的大纲"},
|
||||
{"type": "outline_ready", "agent_name": "作家", "message": f"已整理你的大纲,共 {section_count} 个章节"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""}
|
||||
intervention = state.get("last_user_intervention") or {}
|
||||
@@ -95,6 +205,13 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
current_outline = state.get("current_outline")
|
||||
is_revise = bool(feedback) and bool(current_outline)
|
||||
|
||||
# 用户自带大纲:首轮(尚无大纲、非重规划)且表单粘了大纲原文 → 走「采用用户大纲」分支,
|
||||
# 跳过 LLM 重新规划。后续若用户在确认卡里点「重新规划」,current_outline 已存在 → 落到
|
||||
# 下方 is_revise 编辑模式,按反馈调整用户大纲(符合预期)。
|
||||
user_outline_raw = (state.get("user_outline_raw") or "").strip()
|
||||
if user_outline_raw and not current_outline and not is_revise:
|
||||
return await _build_outline_from_user(state, config, pkg, user_outline_raw)
|
||||
|
||||
if is_revise:
|
||||
system_prompt = OUTLINE_REVISE_SYSTEM
|
||||
user_prompt = OUTLINE_REVISE_USER.format(
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
"""把用户粘贴的大纲文本解析成三级 ``Outline`` 结构(确定性解析,不依赖 LLM)。
|
||||
|
||||
用户在写作表单里可能直接粘贴一份已有大纲,要求「完全按我的大纲生成」。本模块负责
|
||||
把各种常见的大纲文本格式严格、保真地转成后端三级结构:
|
||||
|
||||
Outline.sections[] = Level 1 章节(section_title)
|
||||
OutlineSection.key_points[] = Level 2 小节(point_title)
|
||||
KeyPoint.sub_points[] = Level 3 子要点
|
||||
|
||||
兼容的输入格式:
|
||||
- Markdown 标题:``#`` / ``##`` / ``###`` / ``####``(首个孤立 ``#`` 视为文章标题)
|
||||
- 中文编号:``一、`` / ``(一)`` / ``第一章`` / ``第一节``
|
||||
- 阿拉伯编号:``1.`` / ``1、`` / ``1.1`` / ``1.1.1``
|
||||
- 缩进 bullet:``-`` / ``*`` / ``•`` / ``·``(用缩进深度表示层级)
|
||||
- 纯文本平铺:每行一个一级标题(短标题、无句末标点)
|
||||
|
||||
设计原则:
|
||||
- **保真不改写**:只识别层级、原样保留标题文本,绝不增删或改写用户内容。
|
||||
- **多级混排自适应**:把文本里实际出现的各种「层级标记」收集起来排序,按相对外层
|
||||
顺序映射到 1/2/3 级——所以不依赖固定的标记搭配,``##``+``-`` 或 ``一、``+缩进
|
||||
都能正确分层。
|
||||
- **拿不准就交给上层**:识别不出可靠层级(如整段散文)时返回 ``None``,由调用方走
|
||||
LLM 兜底归一,避免硬把散文拆成错误结构。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .state import KeyPoint, Outline, OutlineSection
|
||||
|
||||
# ── 行首标记识别 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_CN_NUM = "一二三四五六七八九十百千零〇两"
|
||||
|
||||
# Markdown 标题:## 标题
|
||||
_MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
|
||||
# 第一章 / 第1节 / 第一部分
|
||||
_CN_CHAPTER_RE = re.compile(rf"^第\s*[{_CN_NUM}\d]+\s*[章节部分篇讲回卷](?:[\s::、..))]\s*|\s+)?(.*)$")
|
||||
# 一、 / 十二、 / 一. / 一.
|
||||
_CN_DUN_RE = re.compile(rf"^[{_CN_NUM}]+\s*[、..]\s*(.*)$")
|
||||
# (一) / (一) / (1) / (1)
|
||||
_PAREN_RE = re.compile(rf"^[((]\s*[{_CN_NUM}\d]+\s*[))]\s*(.*)$")
|
||||
# 1.1 / 1.1.1(带至少一个点的多级阿拉伯编号)
|
||||
_ARABIC_DOTTED_RE = re.compile(r"^(\d+(?:\.\d+)+)\s*[、..))]?\s*(.*)$")
|
||||
# 1. / 1、 / 1) / 1.(单级阿拉伯编号)
|
||||
_ARABIC_SINGLE_RE = re.compile(r"^\d+\s*[、..))]\s*(.*)$")
|
||||
# 带圈数字 ①②③ / 罗马 ⅰ
|
||||
_CIRCLED_RE = re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]\s*(.*)$")
|
||||
# bullet:- / * / • / ·(要求标记后有空白,避免误吞「*强调*」之类)
|
||||
_BULLET_RE = re.compile(r"^[-*•·]\s+(.*\S)\s*$")
|
||||
|
||||
# 标记分组的「外层度」基准值(越小越外层)。group: 0=标题 1=编号 2=缩进。
|
||||
# 同组内 order 决定相对层级;不同组之间靠 group 排序(标题 < 编号 < 缩进)。
|
||||
# 编号顺序遵循中文公文惯例:第一章 > 一、 > (一) > 1. > 1.1 > ①。
|
||||
_NUM_ORDER = {
|
||||
"cn_chapter": 0.0, # 第一章
|
||||
"cn_dun": 1.0, # 一、
|
||||
"paren": 2.0, # (一)
|
||||
"arabic_single": 3.0, # 1.
|
||||
"circled": 8.0, # ①
|
||||
}
|
||||
|
||||
|
||||
def _indent_width(line: str) -> int:
|
||||
"""计算行首缩进宽度(tab 记 4 列)。"""
|
||||
width = 0
|
||||
for ch in line:
|
||||
if ch == " ":
|
||||
width += 1
|
||||
elif ch == "\t":
|
||||
width += 4
|
||||
else:
|
||||
break
|
||||
return width
|
||||
|
||||
|
||||
def _classify(stripped: str, indent: int) -> tuple[tuple[int, float], str] | None:
|
||||
"""识别一行的层级标记,返回 ``((group, order), 纯文本)``;无法识别返回 None。
|
||||
|
||||
``(group, order)`` 是该行的「层级键」:把全文出现过的不同键排序后,按位置映射到
|
||||
1/2/3 级。这样不必预设标记搭配,相对顺序对了层级就对。
|
||||
"""
|
||||
if not stripped:
|
||||
return None
|
||||
|
||||
# 1) Markdown 标题(group 0,order = 井号数)
|
||||
m = _MD_HEADING_RE.match(stripped)
|
||||
if m:
|
||||
depth = len(m.group(1))
|
||||
text = m.group(2).strip()
|
||||
return (0, float(depth)), text
|
||||
|
||||
# 2) 各类编号(group 1)
|
||||
m = _CN_CHAPTER_RE.match(stripped)
|
||||
if m and stripped.startswith("第"):
|
||||
return (1, _NUM_ORDER["cn_chapter"]), (m.group(1) or "").strip()
|
||||
m = _CN_DUN_RE.match(stripped)
|
||||
if m:
|
||||
return (1, _NUM_ORDER["cn_dun"]), m.group(1).strip()
|
||||
m = _ARABIC_DOTTED_RE.match(stripped)
|
||||
if m:
|
||||
depth = m.group(1).count(".") + 1 # 1.1 → 2, 1.1.1 → 3
|
||||
# 多级阿拉伯:order 随层级递增,落在单级(3.0)与圈号(8.0)之间
|
||||
return (1, 3.0 + depth), m.group(2).strip()
|
||||
m = _PAREN_RE.match(stripped)
|
||||
if m:
|
||||
return (1, _NUM_ORDER["paren"]), m.group(1).strip()
|
||||
m = _ARABIC_SINGLE_RE.match(stripped)
|
||||
if m:
|
||||
return (1, _NUM_ORDER["arabic_single"]), m.group(1).strip()
|
||||
m = _CIRCLED_RE.match(stripped)
|
||||
if m:
|
||||
return (1, _NUM_ORDER["circled"]), m.group(1).strip()
|
||||
|
||||
# 3) bullet:用缩进深度区分层级(group 2)
|
||||
m = _BULLET_RE.match(stripped)
|
||||
if m:
|
||||
return (2, float(indent)), m.group(1).strip()
|
||||
|
||||
# 4) 纯文本:用缩进深度区分层级(group 3,最内层基准)
|
||||
return (3, float(indent)), stripped
|
||||
|
||||
|
||||
def _looks_like_prose(text: str) -> bool:
|
||||
"""判断一行是否更像散文句子而非大纲标题(长、或带句末标点)。"""
|
||||
if len(text) > 50:
|
||||
return True
|
||||
return text.rstrip()[-1:] in "。!?!?;;"
|
||||
|
||||
|
||||
def parse_user_outline(raw: str) -> Outline | None:
|
||||
"""把用户粘贴的大纲文本解析成三级 ``Outline``;无法可靠解析时返回 ``None``。
|
||||
|
||||
返回 ``None`` 的情形:空白输入、只有一行且无任何标记、或整体更像散文段落——这些交给
|
||||
上层用 LLM 兜底归一,避免误拆。
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
|
||||
rows: list[tuple[tuple[int, float], str]] = []
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = _indent_width(line)
|
||||
classified = _classify(line.strip(), indent)
|
||||
if classified is None:
|
||||
continue
|
||||
key, text = classified
|
||||
if text:
|
||||
rows.append((key, text))
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
marked = any(key[0] != 3 for key, _ in rows) # 是否出现过标题/编号/bullet 标记
|
||||
|
||||
# 纯文本且无任何标记:可能是「平铺标题列表」也可能是散文。
|
||||
# 单行、或任一行像句子 → 判为散文,返回 None 交给 LLM。
|
||||
if not marked:
|
||||
if len(rows) < 2:
|
||||
return None
|
||||
if any(_looks_like_prose(text) for _, text in rows):
|
||||
return None
|
||||
|
||||
# 文章标题:仅在「Markdown 首行是孤立的最浅井号、且存在更深井号」时认定。
|
||||
title = ""
|
||||
body = rows
|
||||
heading_depths = [key[1] for key, _ in rows if key[0] == 0]
|
||||
if heading_depths:
|
||||
min_depth = min(heading_depths)
|
||||
first_key = rows[0][0]
|
||||
is_first_top_heading = first_key == (0, min_depth)
|
||||
only_one_at_top = sum(1 for d in heading_depths if d == min_depth) == 1
|
||||
has_deeper = any(d > min_depth for d in heading_depths)
|
||||
if is_first_top_heading and only_one_at_top and has_deeper:
|
||||
title = rows[0][1]
|
||||
body = rows[1:]
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
# 把 body 里出现的不同「层级键」排序,按位置映射到 1/2/3 级(超过 3 级压到 3)。
|
||||
distinct_keys = sorted({key for key, _ in body})
|
||||
level_of: dict[tuple[int, float], int] = {
|
||||
key: min(idx + 1, 3) for idx, key in enumerate(distinct_keys)
|
||||
}
|
||||
|
||||
sections: list[OutlineSection] = []
|
||||
cur_section: OutlineSection | None = None
|
||||
cur_point: KeyPoint | None = None
|
||||
|
||||
for key, text in body:
|
||||
level = level_of[key]
|
||||
if level <= 1:
|
||||
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
|
||||
sections.append(cur_section)
|
||||
cur_point = None
|
||||
elif level == 2:
|
||||
if cur_section is None: # 孤立 L2:提升为章节,避免空标题
|
||||
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
|
||||
sections.append(cur_section)
|
||||
cur_point = None
|
||||
continue
|
||||
cur_point = KeyPoint(point_title=text, sub_points=[])
|
||||
cur_section["key_points"].append(cur_point)
|
||||
else: # level == 3
|
||||
if cur_section is None: # 孤立 L3:提升为章节
|
||||
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
|
||||
sections.append(cur_section)
|
||||
cur_point = None
|
||||
elif cur_point is None: # 章节下尚无小节:提升为小节
|
||||
cur_point = KeyPoint(point_title=text, sub_points=[])
|
||||
cur_section["key_points"].append(cur_point)
|
||||
else:
|
||||
cur_point["sub_points"].append(text)
|
||||
|
||||
if not sections:
|
||||
return None
|
||||
|
||||
return Outline(title=title, sections=sections)
|
||||
+54
@@ -123,6 +123,60 @@ OUTLINE_REVISE_USER = """写作意图:{user_intent}
|
||||
|
||||
请在「当前大纲」基础上落实上述「用户修改要求」,未提到的章节保持原样,输出调整后的完整大纲 JSON。"""
|
||||
|
||||
# ── 用户自带大纲:LLM 兜底归一(确定性解析失败时才用)──────────────────────────
|
||||
|
||||
OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。
|
||||
用户粘贴了一份已有的文章大纲(格式可能不规范),你的任务是把它原样转成结构化 JSON。
|
||||
|
||||
【最高原则:保真,绝不改写】
|
||||
- 严格保留用户写的每一条标题文字,逐字照搬,不要润色、改写、翻译、增加或删除任何条目。
|
||||
- 你只判断层级(哪条是章节、哪条是小节、哪条是子要点),不创作内容。
|
||||
- 层级判断依据用户原文里的编号(一、/(一)/1./1.1)、Markdown 井号数、或缩进深度。
|
||||
|
||||
【三级结构】
|
||||
- Level 1 章节 → section_title
|
||||
- Level 2 小节 → key_points[].point_title
|
||||
- Level 3 子要点 → key_points[].sub_points[](字符串数组)
|
||||
- 用户大纲只有一级时,每条都是章节,key_points 留空 []。
|
||||
- 用户大纲只有两级时,sub_points 留空 []。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
|
||||
- material_ids 一律输出 [](此步不关联素材)。
|
||||
- 结构:{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": []}]}
|
||||
- 用户大纲若包含一个总标题,放进 title;没有就留空字符串。"""
|
||||
|
||||
OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文:
|
||||
{raw_outline}
|
||||
|
||||
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。"""
|
||||
|
||||
# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)──────────────────────────
|
||||
|
||||
OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。
|
||||
给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节
|
||||
挑选最相关的素材编号。
|
||||
|
||||
【规则】
|
||||
- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。
|
||||
- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。
|
||||
- material_id 必须来自给定素材清单里出现过的编号,不要编造。
|
||||
- 一条素材可以分配给多个章节,也可以一个都不分配。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
|
||||
- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。"""
|
||||
|
||||
OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent}
|
||||
|
||||
文章大纲(章节按顺序编号):
|
||||
{sections_list}
|
||||
|
||||
可用素材:
|
||||
{materials_summary}
|
||||
|
||||
请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。"""
|
||||
|
||||
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
|
||||
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。
|
||||
你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。
|
||||
|
||||
@@ -125,6 +125,9 @@ class BlockedSection(TypedDict):
|
||||
class AIWritingState(TypedDict):
|
||||
# 写作配置
|
||||
user_intent: str
|
||||
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
|
||||
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
|
||||
user_outline_raw: str
|
||||
article_type: str
|
||||
target_audience: str
|
||||
word_count_target: int
|
||||
|
||||
+24
-3
@@ -11,6 +11,7 @@ from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||
@@ -272,6 +273,7 @@ def _build_middlewares(
|
||||
# Add TodoList middleware if plan mode is enabled
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
is_writing_setup = cfg.get("is_writing_setup", False)
|
||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||
if todo_list_middleware is not None:
|
||||
middlewares.append(todo_list_middleware)
|
||||
@@ -285,7 +287,7 @@ def _build_middlewares(
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
|
||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||
if not is_bootstrap:
|
||||
if not is_bootstrap and not is_writing_setup:
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
||||
|
||||
# Add ViewImageMiddleware only if the current model supports vision.
|
||||
@@ -314,6 +316,7 @@ def _build_middlewares(
|
||||
middlewares.extend(custom_middlewares)
|
||||
|
||||
middlewares.append(SetupAgentApprovalMiddleware())
|
||||
middlewares.append(SetupWritingApprovalMiddleware())
|
||||
|
||||
# SkillStopMiddleware — intercepts skill invocations and halts execution
|
||||
# when a configured skill is about to run. Request-level options
|
||||
@@ -361,7 +364,7 @@ def make_lead_agent(config: RunnableConfig):
|
||||
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
# Lazy import to avoid circular dependency
|
||||
from deerflow.tools import get_available_tools
|
||||
from deerflow.tools.builtins import setup_agent
|
||||
from deerflow.tools.builtins import setup_agent, setup_writing
|
||||
|
||||
cfg = _get_runtime_config(config)
|
||||
resolved_app_config = app_config
|
||||
@@ -373,6 +376,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
|
||||
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
|
||||
is_writing_setup = cfg.get("is_writing_setup", False)
|
||||
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
|
||||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||||
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
|
||||
writing_mode = bool(cfg.get("writing_mode", False))
|
||||
@@ -383,7 +390,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
requested_excluded_tools = cfg.get("excluded_tools") or None
|
||||
|
||||
try:
|
||||
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
|
||||
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup else None
|
||||
except FileNotFoundError:
|
||||
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
|
||||
agent_config = None
|
||||
@@ -443,6 +450,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
}
|
||||
)
|
||||
|
||||
if is_writing_setup:
|
||||
# 写作配置 Q&A:最小化 agent —— **只挂 setup_writing 一个工具** + 一份职责单一的独立提示。
|
||||
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
|
||||
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
|
||||
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
|
||||
tools=[setup_writing],
|
||||
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
if is_bootstrap:
|
||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||
return create_agent(
|
||||
|
||||
+58
-18
@@ -703,14 +703,14 @@ def _get_cached_skills_prompt_section(
|
||||
|
||||
display_pattern = (
|
||||
"""
|
||||
**Skill Result Display Pattern:**
|
||||
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
|
||||
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
|
||||
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
|
||||
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
|
||||
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
|
||||
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
|
||||
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
|
||||
**Skill Result Display Pattern:**
|
||||
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
|
||||
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
|
||||
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
|
||||
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
|
||||
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
|
||||
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
|
||||
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
|
||||
"""
|
||||
if display_signature
|
||||
else ""
|
||||
@@ -904,6 +904,46 @@ def _build_bootstrap_section() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
|
||||
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
|
||||
|
||||
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
|
||||
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
|
||||
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
|
||||
死死框在「聊清需求 → 调 setup_writing」上。
|
||||
"""
|
||||
if article_types:
|
||||
type_lines = "\n".join(
|
||||
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
|
||||
)
|
||||
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
|
||||
else:
|
||||
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
|
||||
|
||||
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
|
||||
|
||||
【铁律——最高优先级】
|
||||
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
|
||||
- 你能做的动作只有两个:① 用自然语言向用户提问或确认;② 调用 `setup_writing` 工具。**除此之外你没有任何其它工具**,也不要假装调用别的工具、不要做检索、不要做研究。
|
||||
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
|
||||
|
||||
【工作流程】
|
||||
1. 读用户输入。如果「写什么」已经明确,关键信息(文章类型、字数、读者)即使缺失也可以用合理默认补全——**信息基本够了就直接进入第 3 步,不要为了凑齐而反复追问**。
|
||||
2. 只有当「写什么」本身都不清楚时,才用一句话问 1-2 个最关键的问题(最多一轮)。
|
||||
3. 调用 `setup_writing` 提交配置:
|
||||
- `user_intent`:精炼成一句话的写作目标(必填)。
|
||||
- `article_type`:见下方可选类型;不确定就留空。
|
||||
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
|
||||
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
|
||||
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
|
||||
- `material_source`:用户提到用「技能/技能检索」→ `skill`,否则 `knowledge_base`。
|
||||
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
|
||||
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
|
||||
|
||||
{type_block}
|
||||
"""
|
||||
|
||||
|
||||
def apply_prompt_template(
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
@@ -995,16 +1035,16 @@ Writing mode is enabled for this run.
|
||||
network_policy_section = _build_network_policy_section()
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
|
||||
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
|
||||
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
|
||||
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
|
||||
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
|
||||
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
|
||||
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
|
||||
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
|
||||
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"""Approval middleware for the AI-writing config setup tool.
|
||||
|
||||
与 ``SetupAgentApprovalMiddleware`` 同构:模型在「写作配置 Q&A」里调用 ``setup_writing``
|
||||
时,于 ``after_model`` 拦截,附上一条结构化审批 ToolMessage(建议的写作配置打包在
|
||||
``additional_kwargs["setup_writing_approval"]``)并 ``jump_to="end"`` 立即结束本轮运行——
|
||||
前端据此渲染审批卡,用户点「应用到表单」后把这些字段回填到左侧写作表单。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, hook_config
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SetupWritingApprovalMiddlewareState(AgentState):
|
||||
"""Compatible with the DeerFlow thread state schema."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlewareState]):
|
||||
"""Stop before ``setup_writing`` and ask the user to apply the proposed config.
|
||||
|
||||
See ``SetupAgentApprovalMiddleware`` for the rationale behind intercepting in
|
||||
``after_model`` + ``jump_to="end"`` (robust against parallel tool calls).
|
||||
"""
|
||||
|
||||
state_schema = SetupWritingApprovalMiddlewareState
|
||||
|
||||
def _is_approved(self, runtime: Runtime) -> bool:
|
||||
context = getattr(runtime, "context", None) or {}
|
||||
if not isinstance(context, dict):
|
||||
return False
|
||||
return context.get("setup_writing_approved") is True
|
||||
|
||||
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
|
||||
if tool_call_id:
|
||||
return f"setup-writing-approval:{tool_call_id}"
|
||||
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
|
||||
return f"setup-writing-approval:{digest}"
|
||||
|
||||
def _coerce_word_count(self, raw: Any) -> int | None:
|
||||
if isinstance(raw, bool): # bool 是 int 子类,先排除
|
||||
return None
|
||||
if isinstance(raw, int):
|
||||
return raw if raw > 0 else None
|
||||
if isinstance(raw, float):
|
||||
return int(raw) if raw > 0 else None
|
||||
if isinstance(raw, str):
|
||||
import re
|
||||
|
||||
m = re.search(r"\d[\d,]*", raw)
|
||||
if m:
|
||||
try:
|
||||
value = int(m.group().replace(",", ""))
|
||||
return value if value > 0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"user_intent": str(args.get("user_intent") or "").strip(),
|
||||
"article_type": str(args.get("article_type") or "").strip(),
|
||||
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
|
||||
"target_audience": str(args.get("target_audience") or "").strip(),
|
||||
"writing_mode": str(args.get("writing_mode") or "").strip(),
|
||||
"material_source": str(args.get("material_source") or "").strip(),
|
||||
"outline": str(args.get("outline") or "").strip(),
|
||||
}
|
||||
|
||||
def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
|
||||
"""Return a state update if setup_writing must be gated; None to passthrough."""
|
||||
tool_calls = list(last.tool_calls or [])
|
||||
setup_call = next((tc for tc in tool_calls if tc.get("name") == "setup_writing"), None)
|
||||
if setup_call is None:
|
||||
return None
|
||||
|
||||
args = setup_call.get("args") or {}
|
||||
tool_call_id = setup_call.get("id") or ""
|
||||
payload = self._build_payload(args)
|
||||
logger.info("Setup writing approval requested before applying writing config")
|
||||
|
||||
approval_msg = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, args),
|
||||
content="请确认是否把这份写作配置应用到表单。",
|
||||
tool_call_id=tool_call_id,
|
||||
name="setup_writing",
|
||||
additional_kwargs={"setup_writing_approval": payload},
|
||||
)
|
||||
|
||||
return {
|
||||
"messages": [approval_msg],
|
||||
"jump_to": "end",
|
||||
}
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
@override
|
||||
def after_model(
|
||||
self,
|
||||
state: SetupWritingApprovalMiddlewareState,
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any] | None:
|
||||
if self._is_approved(runtime):
|
||||
return None
|
||||
messages = state.get("messages") or []
|
||||
if not messages:
|
||||
return None
|
||||
last = messages[-1]
|
||||
if not isinstance(last, AIMessage):
|
||||
return None
|
||||
return self._intercept(last)
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
@override
|
||||
async def aafter_model(
|
||||
self,
|
||||
state: SetupWritingApprovalMiddlewareState,
|
||||
runtime: Runtime,
|
||||
) -> dict[str, Any] | None:
|
||||
return self.after_model(state, runtime)
|
||||
@@ -4,11 +4,13 @@ from .memory_tool import memory_tool
|
||||
from .present_file_tool import present_file_tool
|
||||
from .scheduled_task_tool import scheduled_task_tool
|
||||
from .setup_agent_tool import setup_agent
|
||||
from .setup_writing_tool import setup_writing
|
||||
from .task_tool import task_tool
|
||||
from .view_image_tool import view_image_tool
|
||||
|
||||
__all__ = [
|
||||
"setup_agent",
|
||||
"setup_writing",
|
||||
"present_file_tool",
|
||||
"ask_clarification_tool",
|
||||
"agent_orchestration_tool",
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""写作配置助手用的提交工具。
|
||||
|
||||
与 ``setup_agent`` 同构:模型在「写作配置 Q&A」里与用户聊清需求后,调用本工具把建议的
|
||||
写作配置「提交」出来。实际它总是被 ``SetupWritingApprovalMiddleware`` 在 ``after_model``
|
||||
拦截(不真正执行)——拦截后把建议字段打包进审批 ToolMessage,前端据此回填左侧写作表单。
|
||||
|
||||
因此本工具的函数体基本是兜底:万一未被拦截(如审批被预先放行),也只回一句确认、不落任何盘。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.prebuilt import ToolRuntime
|
||||
from langgraph.types import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool
|
||||
def setup_writing(
|
||||
user_intent: str,
|
||||
runtime: ToolRuntime,
|
||||
article_type: str | None = None,
|
||||
word_count_target: int | None = None,
|
||||
target_audience: str | None = None,
|
||||
writing_mode: str | None = None,
|
||||
material_source: str | None = None,
|
||||
outline: str | None = None,
|
||||
) -> Command:
|
||||
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
|
||||
|
||||
Args:
|
||||
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
|
||||
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
|
||||
word_count_target: 目标字数(正整数)。
|
||||
target_audience: 目标读者(如「行业专家」「普通读者」)。
|
||||
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
|
||||
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
|
||||
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
|
||||
"""
|
||||
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
|
||||
return Command(
|
||||
update={
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="写作配置已生成,请在右侧确认后应用到表单。",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user