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>
|
||||
|
||||
Reference in new issue
Block a user