Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy
This commit is contained in:
commit
56ff310f29
248 files changed
+31099
-15975
No files matched your search
+7
-1
@@ -10,6 +10,8 @@ frontend-web/.vscode/
|
||||
|
||||
# Frontend local/runtime artifacts
|
||||
frontend-web/.env
|
||||
frontend-web/.env.local
|
||||
frontend-web/.env.*.local
|
||||
frontend-web/node_modules/
|
||||
frontend-web/.pnpm-store/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
@@ -24,8 +26,9 @@ frontend-web/src.zip
|
||||
frontend-web/src9.zip
|
||||
frontend-web/src-v1.zip
|
||||
frontend-web/src-*.rar
|
||||
|
||||
frontend-web/dist
|
||||
# Frontend build artifacts
|
||||
frontend-web/dist/
|
||||
frontend-web/dist-scheduled-check/
|
||||
|
||||
# Offline backend bundles and local deployment artifacts
|
||||
@@ -76,6 +79,9 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
deploy
|
||||
.history
|
||||
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
@@ -0,0 +1,12 @@
|
||||
# 本地开发(pnpm dev)— 与原先 .env 保持一致
|
||||
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -0,0 +1,7 @@
|
||||
# 复制为 .env.development.local,仅本机生效,不提交 Git
|
||||
# 直连本机 Gateway(需后端 CORS_ORIGINS 包含 http://127.0.0.1:5173 等)
|
||||
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -1,6 +1,30 @@
|
||||
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
|
||||
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
|
||||
#
|
||||
# ┌─────────────────┬──────────────────────────────────────────────────┐
|
||||
# │ 命令 │ 生效文件 │
|
||||
# ├─────────────────┼──────────────────────────────────────────────────┤
|
||||
# │ pnpm dev │ .env.development + .env.development.local │
|
||||
# │ pnpm build │ .env.production + .env.production.local │
|
||||
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
|
||||
# └─────────────────┴──────────────────────────────────────────────────┘
|
||||
#
|
||||
# 推荐:
|
||||
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
|
||||
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
|
||||
# - 内网打包部署:改 .env.production 后再执行 pnpm build
|
||||
#
|
||||
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
|
||||
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
# --- 开发环境(见 .env.development)---
|
||||
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
# 可选
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
@@ -0,0 +1,12 @@
|
||||
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
|
||||
# 打包后变量会写入 dist,运行时不可再改
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env.production.local,打包前临时覆盖生产地址(不提交 Git)
|
||||
|
||||
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
|
||||
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
|
||||
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
@@ -1,726 +0,0 @@
|
||||
# AI 写作助手后端接口与 Prompt 协议文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
|
||||
|
||||
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮后端职责
|
||||
|
||||
后端负责:
|
||||
|
||||
- 接收当前线程信息
|
||||
- 接收用户选区文本
|
||||
- 接收改写动作
|
||||
- 选取必要上下文
|
||||
- 调用模型生成候选改写结果
|
||||
- 返回纯净候选文本
|
||||
|
||||
后端本轮不负责:
|
||||
|
||||
- 直接编辑 Tiptap JSON
|
||||
- 直接操作前端编辑器选区
|
||||
- 直接替换前端文档内容
|
||||
- 做复杂富文本 patch
|
||||
- 做 tracked changes
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
### 3.1 纯文本协议优先
|
||||
|
||||
首版只让后端处理:
|
||||
|
||||
- 选区文本
|
||||
- 前后文文本
|
||||
- 动作类型
|
||||
- 风格参数
|
||||
|
||||
不让后端直接理解完整富文本 JSON。
|
||||
|
||||
### 3.2 前端负责应用,后端负责生成
|
||||
|
||||
- 前端负责选区获取
|
||||
- 前端负责候选结果展示
|
||||
- 前端负责替换 / 插入
|
||||
- 后端只负责“给出候选文本”
|
||||
|
||||
### 3.3 结果必须可直接落文档
|
||||
|
||||
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
|
||||
|
||||
- “当然可以,以下是润色版本:”
|
||||
- “我帮你改写如下:”
|
||||
- “下面是优化后的内容:”
|
||||
|
||||
这类内容必须禁止。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐接口设计
|
||||
|
||||
## 4.1 主接口
|
||||
|
||||
推荐新增统一接口:
|
||||
|
||||
```text
|
||||
POST /api/writing/rewrite
|
||||
```
|
||||
|
||||
由 `action` 决定具体重写类型。
|
||||
|
||||
## 4.2 为什么推荐统一接口
|
||||
|
||||
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
|
||||
|
||||
- 前端判断分支增多
|
||||
- 埋点与异常处理分散
|
||||
- 后续新增动作不方便扩展
|
||||
|
||||
统一接口更适合本轮需求。
|
||||
|
||||
---
|
||||
|
||||
## 5. 请求体定义
|
||||
|
||||
建议请求体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"threadId": "string",
|
||||
"documentId": "string",
|
||||
"selection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"action": "polish",
|
||||
"tone": "professional",
|
||||
"surroundingContext": {
|
||||
"before": "选区前文",
|
||||
"after": "选区后文"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 字段说明
|
||||
|
||||
### 6.1 `threadId`
|
||||
|
||||
用途:
|
||||
|
||||
- 关联当前 DeerFlow 线程
|
||||
- 必要时取线程上下文或会话元数据
|
||||
|
||||
### 6.2 `documentId`
|
||||
|
||||
用途:
|
||||
|
||||
- 标识当前右侧文档
|
||||
- 首版可选
|
||||
- 如果当前系统还没有独立文档 ID,可先允许为空
|
||||
|
||||
### 6.3 `selection.from` / `selection.to`
|
||||
|
||||
用途:
|
||||
|
||||
- 帮助前后端联调排查
|
||||
- 首版后端通常不需要真正使用偏移量
|
||||
- 建议保留,便于日志定位
|
||||
|
||||
### 6.4 `selection.text`
|
||||
|
||||
用途:
|
||||
|
||||
- 这是本轮最核心输入
|
||||
- 后端实际重写的主文本
|
||||
|
||||
### 6.5 `action`
|
||||
|
||||
允许值:
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone`
|
||||
|
||||
### 6.6 `tone`
|
||||
|
||||
仅当 `action = tone` 时使用。
|
||||
|
||||
建议允许值:
|
||||
|
||||
- `professional`
|
||||
- `casual`
|
||||
- `readable`
|
||||
- `academic`
|
||||
- `expressive`
|
||||
|
||||
### 6.7 `surroundingContext.before`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区前文
|
||||
- 帮助模型理解语气、主语、叙事连续性
|
||||
|
||||
建议:
|
||||
|
||||
- 控制长度
|
||||
- 不要塞太多无关上下文
|
||||
|
||||
### 6.8 `surroundingContext.after`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区后文
|
||||
- 帮助模型在扩写、改语气时与后续内容衔接
|
||||
|
||||
---
|
||||
|
||||
## 7. 返回体定义
|
||||
|
||||
建议返回体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": "AI 改写后的候选文本",
|
||||
"originalSelection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"metadata": {
|
||||
"action": "polish",
|
||||
"tone": null,
|
||||
"model": "your-model-name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 返回字段要求
|
||||
|
||||
## 8.1 `output`
|
||||
|
||||
必须:
|
||||
|
||||
- 只包含候选正文
|
||||
- 尽量不夹带解释语
|
||||
- 能直接被前端替换或插入
|
||||
|
||||
## 8.2 `originalSelection`
|
||||
|
||||
作用:
|
||||
|
||||
- 前端可以做校验
|
||||
- 联调时方便确认是针对哪段文本返回的
|
||||
|
||||
## 8.3 `metadata`
|
||||
|
||||
作用:
|
||||
|
||||
- 便于埋点
|
||||
- 便于日志记录
|
||||
- 便于问题追踪
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误响应规范
|
||||
|
||||
建议统一错误结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "WRITING_REWRITE_FAILED",
|
||||
"message": "rewrite failed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议错误码:
|
||||
|
||||
- `WRITING_REWRITE_FAILED`
|
||||
- `WRITING_INVALID_ACTION`
|
||||
- `WRITING_INVALID_TONE`
|
||||
- `WRITING_EMPTY_SELECTION`
|
||||
- `WRITING_MODEL_UNAVAILABLE`
|
||||
- `WRITING_CONTEXT_TOO_LARGE`
|
||||
|
||||
---
|
||||
|
||||
## 10. 动作语义定义
|
||||
|
||||
## 10.1 `polish`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持原意
|
||||
- 改善表达
|
||||
- 提升通顺度、自然度和可读性
|
||||
|
||||
不应:
|
||||
|
||||
- 引入新事实
|
||||
- 改写成完全不同内容
|
||||
|
||||
## 10.2 `expand`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持主旨
|
||||
- 扩充细节
|
||||
- 增强画面感、解释性或完整度
|
||||
|
||||
不应:
|
||||
|
||||
- 无限制发散
|
||||
- 添加明显脱离主题的新内容
|
||||
|
||||
## 10.3 `shorten`
|
||||
|
||||
目标:
|
||||
|
||||
- 压缩篇幅
|
||||
- 保留关键信息
|
||||
- 减少重复表达
|
||||
|
||||
不应:
|
||||
|
||||
- 丢失关键论点
|
||||
- 压缩到语义残缺
|
||||
|
||||
## 10.4 `tone`
|
||||
|
||||
目标:
|
||||
|
||||
- 不改变原意
|
||||
- 调整语言风格
|
||||
|
||||
不应:
|
||||
|
||||
- 改写事实
|
||||
- 改写逻辑结构到完全不同
|
||||
|
||||
---
|
||||
|
||||
## 11. Prompt 设计原则
|
||||
|
||||
## 11.1 通用系统约束
|
||||
|
||||
所有改写类 prompt 都应包含以下约束:
|
||||
|
||||
- 直接输出最终可用正文
|
||||
- 不输出标题,除非原文本身就是标题改写
|
||||
- 不输出解释
|
||||
- 不输出分析过程
|
||||
- 不输出“以下是结果”之类前缀
|
||||
- 保持与上下文语气一致
|
||||
|
||||
## 11.2 上下文使用原则
|
||||
|
||||
前后文只用于理解,不要求模型重写前后文。
|
||||
|
||||
明确告诉模型:
|
||||
|
||||
- 只改写选中文本
|
||||
- 前后文仅用于参考衔接
|
||||
|
||||
## 11.3 长度控制原则
|
||||
|
||||
- `polish`:长度与原文接近
|
||||
- `expand`:长度可明显增加,但不要失控
|
||||
- `shorten`:长度明显缩短,但要保留关键信息
|
||||
- `tone`:长度尽量与原文接近
|
||||
|
||||
---
|
||||
|
||||
## 12. Prompt 模板建议
|
||||
|
||||
## 12.1 润色模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
|
||||
|
||||
要求:
|
||||
1. 保留原文事实和核心意思。
|
||||
2. 不要添加原文没有的新事实。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出润色后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.2 扩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
|
||||
|
||||
要求:
|
||||
1. 保持原文主旨不变。
|
||||
2. 增加内容时要自然,不要跑题。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出扩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.3 缩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
|
||||
|
||||
要求:
|
||||
1. 不要丢掉核心信息。
|
||||
2. 去掉重复、冗余和拖沓表达。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出缩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.4 改语气模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
|
||||
|
||||
要求:
|
||||
1. 保持原文信息不变。
|
||||
2. 只调整表达风格,不改变事实和逻辑。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出改写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Tone 映射建议
|
||||
|
||||
建议前端传标准值,后端映射中文标签:
|
||||
|
||||
- `professional` -> 专业、正式
|
||||
- `casual` -> 口语化、轻松自然
|
||||
- `readable` -> 更易读、更清晰
|
||||
- `academic` -> 学术、严谨
|
||||
- `expressive` -> 更有文采、更有表现力
|
||||
|
||||
---
|
||||
|
||||
## 14. 上下文截取策略
|
||||
|
||||
## 14.1 推荐策略
|
||||
|
||||
前端传:
|
||||
|
||||
- 选区前固定长度文本
|
||||
- 选区后固定长度文本
|
||||
|
||||
例如:
|
||||
|
||||
- 前文 200 到 500 字
|
||||
- 后文 200 到 500 字
|
||||
|
||||
### 原因
|
||||
|
||||
- 足够帮助模型理解文气和衔接
|
||||
- 不会让请求太重
|
||||
|
||||
## 14.2 不建议
|
||||
|
||||
- 把整篇长文全量塞进去
|
||||
- 把整个线程消息都塞进改写接口
|
||||
|
||||
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
|
||||
|
||||
---
|
||||
|
||||
## 15. LangGraph 接入建议
|
||||
|
||||
## 15.1 推荐接入方式
|
||||
|
||||
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
|
||||
|
||||
建议二选一:
|
||||
|
||||
### 方案 A
|
||||
|
||||
新增专用 router:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
内部调用现有模型层 / agent 层。
|
||||
|
||||
### 方案 B
|
||||
|
||||
新增专用 graph node 或 tool:
|
||||
|
||||
- `rewrite_selection`
|
||||
|
||||
再由 router 调用该能力。
|
||||
|
||||
## 15.2 更推荐方案
|
||||
|
||||
推荐:
|
||||
|
||||
- 对外暴露统一 router
|
||||
- 内部走已有模型封装或 LangGraph 节点
|
||||
|
||||
这样前端接口更稳定,后续后端内部重构不会影响前端。
|
||||
|
||||
---
|
||||
|
||||
## 16. 后端模块拆解
|
||||
|
||||
## 16.1 模块 A:接口层
|
||||
|
||||
负责:
|
||||
|
||||
- 请求校验
|
||||
- 错误响应
|
||||
- 参数标准化
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一 rewrite API
|
||||
- action / tone 校验
|
||||
- 空选区校验
|
||||
|
||||
## 16.2 模块 B:上下文拼装层
|
||||
|
||||
负责:
|
||||
|
||||
- 拼装 before / selection / after
|
||||
- 必要时提取线程相关信息
|
||||
|
||||
需要完成:
|
||||
|
||||
- 上下文长度控制
|
||||
- 清洗空白和异常字符
|
||||
|
||||
## 16.3 模块 C:Prompt 层
|
||||
|
||||
负责:
|
||||
|
||||
- 选择模板
|
||||
- 填充 action 和 tone
|
||||
|
||||
需要完成:
|
||||
|
||||
- 4 类动作模板
|
||||
- tone 映射
|
||||
|
||||
## 16.4 模块 D:模型调用层
|
||||
|
||||
负责:
|
||||
|
||||
- 走现有模型配置体系
|
||||
- 执行大模型请求
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一调用入口
|
||||
- 模型不可用错误处理
|
||||
|
||||
## 16.5 模块 E:结果清洗层
|
||||
|
||||
负责:
|
||||
|
||||
- 去掉解释性前缀
|
||||
- 去掉多余包裹
|
||||
- 标准化返回
|
||||
|
||||
需要完成:
|
||||
|
||||
- 输出清洗
|
||||
- metadata 填充
|
||||
|
||||
---
|
||||
|
||||
## 17. 返回结果清洗建议
|
||||
|
||||
即使 prompt 已经约束,模型仍可能输出:
|
||||
|
||||
- “以下是润色后的内容:”
|
||||
- “当然,这里是改写结果:”
|
||||
- “优化如下:”
|
||||
|
||||
建议后端加一层轻量清洗:
|
||||
|
||||
- 去掉常见说明性前缀
|
||||
- 去掉包裹性 markdown 代码块
|
||||
- 去掉多余首尾空白
|
||||
|
||||
注意:
|
||||
|
||||
- 清洗必须保守
|
||||
- 不要误删正文
|
||||
|
||||
---
|
||||
|
||||
## 18. 日志与观测建议
|
||||
|
||||
建议记录:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `selection_length`
|
||||
- `before_length`
|
||||
- `after_length`
|
||||
- `model_name`
|
||||
- `latency_ms`
|
||||
- `success / fail`
|
||||
|
||||
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
|
||||
|
||||
---
|
||||
|
||||
## 19. 后端测试清单
|
||||
|
||||
## 19.1 单元测试
|
||||
|
||||
- action 校验
|
||||
- tone 校验
|
||||
- 空选区校验
|
||||
- prompt 选择逻辑
|
||||
- 输出清洗逻辑
|
||||
|
||||
## 19.2 集成测试
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone-professional`
|
||||
- `tone-casual`
|
||||
- `tone-readable`
|
||||
- `tone-academic`
|
||||
- `tone-expressive`
|
||||
|
||||
## 19.3 边界测试
|
||||
|
||||
- 选区过短
|
||||
- 选区过长
|
||||
- 前后文为空
|
||||
- 多段文本带换行
|
||||
- 模型超时
|
||||
- 模型不可用
|
||||
|
||||
---
|
||||
|
||||
## 20. 联调约束
|
||||
|
||||
## 20.1 前后端必须统一
|
||||
|
||||
- `action` 枚举值
|
||||
- `tone` 枚举值
|
||||
- 错误码
|
||||
- 返回体字段名
|
||||
|
||||
## 20.2 联调重点
|
||||
|
||||
- 多段落文本中换行保留是否正确
|
||||
- AI 返回内容是否适合直接落文档
|
||||
- 超时和失败时前端是否能优雅重试
|
||||
|
||||
## 20.3 联调建议顺序
|
||||
|
||||
1. 先 mock 返回固定文本
|
||||
2. 再接真实模型
|
||||
3. 先验证单段落
|
||||
4. 再验证多段落
|
||||
5. 最后验证大段文本
|
||||
|
||||
---
|
||||
|
||||
## 21. 优先级建议
|
||||
|
||||
### P0
|
||||
|
||||
- 统一 rewrite API
|
||||
- 4 类动作支持
|
||||
- 输出纯净正文
|
||||
- 错误结构标准化
|
||||
|
||||
### P1
|
||||
|
||||
- 输出清洗增强
|
||||
- 埋点与日志
|
||||
- 上下文截断策略优化
|
||||
|
||||
### P2
|
||||
|
||||
- 更细粒度风格控制
|
||||
- 结果质量二次校验
|
||||
|
||||
---
|
||||
|
||||
## 22. 完成定义
|
||||
|
||||
后端满足以下条件即可视为本轮完成:
|
||||
|
||||
- 前端能通过统一接口发起改写
|
||||
- 4 类动作均可稳定返回候选文本
|
||||
- 返回文本基本可直接用于替换或插入
|
||||
- 多段落文本能稳定处理
|
||||
- 错误结构规范统一
|
||||
|
||||
---
|
||||
|
||||
## 23. 结论
|
||||
|
||||
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
|
||||
|
||||
最稳妥的路线是:
|
||||
|
||||
- 统一接口
|
||||
- 统一动作枚举
|
||||
- 统一 prompt 模板
|
||||
- 统一错误结构
|
||||
- 统一输出约束
|
||||
|
||||
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
|
||||
@@ -1,703 +0,0 @@
|
||||
# AI 写作助手前端开发任务清单
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
|
||||
|
||||
目标是让前端团队按模块并行推进,最终完成:
|
||||
|
||||
- 保留左侧聊天区
|
||||
- 右侧升级为 `Tiptap` 写作编辑器
|
||||
- 支持选区浮层
|
||||
- 支持润色 / 扩写 / 缩写 / 改语气
|
||||
- 支持替换 / 插入
|
||||
- 支持标题 / 列表 / 引用 / 代码块工具条
|
||||
- UI 参考豆包 + Novel + open-canvas
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮前端改造范围
|
||||
|
||||
### 2.1 保留不动
|
||||
|
||||
- 左侧聊天主链路
|
||||
- 现有线程消息流
|
||||
- 现有模型选择器
|
||||
- 现有输入框的 `writing_mode` 总入口
|
||||
|
||||
### 2.2 重点改造
|
||||
|
||||
- 右侧 Artifact 面板承载方式
|
||||
- 新增独立写作工作区
|
||||
- 富文本编辑器内核
|
||||
- 选区浮层
|
||||
- AI 改写结果预览
|
||||
- 文档级工具条
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐目录结构
|
||||
|
||||
建议新增独立模块目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas-writing/
|
||||
routes/
|
||||
WritingRoutes.tsx
|
||||
pages/
|
||||
WritingWorkspacePage.tsx
|
||||
components/
|
||||
WritingShell.tsx
|
||||
WritingHeader.tsx
|
||||
WritingToolbar.tsx
|
||||
WritingEditorPane.tsx
|
||||
WritingBubbleMenu.tsx
|
||||
WritingAiMenu.tsx
|
||||
WritingRewritePreview.tsx
|
||||
WritingStatusBar.tsx
|
||||
WritingEmptyState.tsx
|
||||
editor/
|
||||
TiptapEditor.tsx
|
||||
TiptapExtensions.ts
|
||||
commands.ts
|
||||
selection.ts
|
||||
context/
|
||||
WritingEditorContext.tsx
|
||||
hooks/
|
||||
useWritingEditor.ts
|
||||
useWritingSelection.ts
|
||||
useWritingRewrite.ts
|
||||
useWritingToolbar.ts
|
||||
api/
|
||||
rewrite.ts
|
||||
documents.ts
|
||||
types/
|
||||
document.ts
|
||||
rewrite.ts
|
||||
utils/
|
||||
apply-rewrite.ts
|
||||
markdown.ts
|
||||
editor-style.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有代码落点
|
||||
|
||||
## 4.1 需要接入的现有文件
|
||||
|
||||
- `frontend-web/src/pages/PageRoutes.tsx`
|
||||
- 增加新写作路由入口
|
||||
|
||||
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
- 当前双栏布局参考来源
|
||||
- 后续决定是否复用布局容器,或在新页面中重建双栏
|
||||
|
||||
- `frontend-web/src/components/workspace/input-box.tsx`
|
||||
- 保留 `writing_mode`
|
||||
- 后续可在切换写作模式时跳转到新写作页面
|
||||
|
||||
- `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
- 仅保留给旧 Artifact 逻辑
|
||||
- 不要继续承载写作编辑器状态
|
||||
|
||||
## 4.2 不建议继续沿用的方式
|
||||
|
||||
- 不要把编辑器主状态存成 `string[] artifacts`
|
||||
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
|
||||
- 不要让 AI 改写结果先回聊天,再由用户手动复制
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务分阶段拆解
|
||||
|
||||
## 阶段 A:路由与页面壳
|
||||
|
||||
### 目标
|
||||
|
||||
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRoutes.tsx`
|
||||
- 新增 `WritingWorkspacePage.tsx`
|
||||
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
|
||||
- 确认新页面进入方式
|
||||
- 确定是:
|
||||
- 通过左侧聊天上下文进入
|
||||
- 通过 `writing_mode` 入口进入
|
||||
- 或二者都支持
|
||||
|
||||
### 交付物
|
||||
|
||||
- 新写作页面能被路由访问
|
||||
- 页面有基础左右布局和空态
|
||||
|
||||
### 验收
|
||||
|
||||
- 打开 `page/writing` 能进入工作区
|
||||
- 页面不报错
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:编辑器底座
|
||||
|
||||
### 目标
|
||||
|
||||
把右侧正式替换成富文本编辑器。
|
||||
|
||||
### 任务
|
||||
|
||||
- 安装 Tiptap 依赖
|
||||
- 建立 `TiptapEditor.tsx`
|
||||
- 建立 `TiptapExtensions.ts`
|
||||
- 配置节点:
|
||||
- paragraph
|
||||
- heading
|
||||
- bulletList
|
||||
- orderedList
|
||||
- listItem
|
||||
- blockquote
|
||||
- codeBlock
|
||||
- hardBreak
|
||||
- 配置 marks:
|
||||
- bold
|
||||
- italic
|
||||
- underline
|
||||
- strike
|
||||
- code
|
||||
- 建立编辑器初始化逻辑
|
||||
- 建立编辑器销毁逻辑
|
||||
- 定义最小文档状态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 可编辑正文区域
|
||||
- 初始文档可加载
|
||||
- 内容可读可写
|
||||
|
||||
### 验收
|
||||
|
||||
- 可以直接输入正文
|
||||
- 可以换行、加标题、切换列表
|
||||
- 不出现内容错位和明显闪烁
|
||||
|
||||
---
|
||||
|
||||
## 阶段 C:主工具栏
|
||||
|
||||
### 目标
|
||||
|
||||
右侧具备正式写作工具栏。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingToolbar.tsx`
|
||||
- 新增 `useWritingToolbar.ts`
|
||||
- 实现按钮:
|
||||
- 正文
|
||||
- H1
|
||||
- H2
|
||||
- H3
|
||||
- 有序列表
|
||||
- 无序列表
|
||||
- 引用
|
||||
- 代码块
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 下划线
|
||||
- 删除线
|
||||
- 处理 active 状态
|
||||
- 处理不可用状态
|
||||
- 处理光标块状态联动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 主工具栏可用
|
||||
|
||||
### 验收
|
||||
|
||||
- 工具条按钮能正确反映当前块状态
|
||||
- 操作后编辑器内容正确变化
|
||||
|
||||
---
|
||||
|
||||
## 阶段 D:选区状态与浮层
|
||||
|
||||
### 目标
|
||||
|
||||
实现豆包式选区浮层基础能力。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `useWritingSelection.ts`
|
||||
- 新增 `selection.ts`
|
||||
- 新增 `WritingBubbleMenu.tsx`
|
||||
- 基于 Tiptap 监听 selection 变化
|
||||
- 过滤掉空选区
|
||||
- 处理多段连续选区
|
||||
- 浮层按钮:
|
||||
- AI 改写
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 标题
|
||||
- 复制
|
||||
- 处理滚动定位
|
||||
- 处理窗口尺寸变化
|
||||
|
||||
### 交付物
|
||||
|
||||
- 选中内容后出现浮层
|
||||
|
||||
### 验收
|
||||
|
||||
- 单段可触发
|
||||
- 多段可触发
|
||||
- 浮层位置稳定
|
||||
|
||||
---
|
||||
|
||||
## 阶段 E:AI 改写菜单
|
||||
|
||||
### 目标
|
||||
|
||||
把选区浮层接到 AI 改写工作流。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingAiMenu.tsx`
|
||||
- 新增 `useWritingRewrite.ts`
|
||||
- 新增 `api/rewrite.ts`
|
||||
- 实现一级动作:
|
||||
- 润色
|
||||
- 扩写
|
||||
- 缩写
|
||||
- 改语气
|
||||
- 实现改语气二级菜单:
|
||||
- 更专业
|
||||
- 更口语化
|
||||
- 更易读
|
||||
- 更学术
|
||||
- 更有文采
|
||||
- 封装请求入参
|
||||
- 增加 loading 态
|
||||
- 增加 error 态
|
||||
- 增加 retry 入口
|
||||
|
||||
### 交付物
|
||||
|
||||
- AI 改写菜单和请求链路
|
||||
|
||||
### 验收
|
||||
|
||||
- 4 个动作都能发请求
|
||||
- 改语气的二级菜单可正常展开
|
||||
- 请求失败时有反馈
|
||||
|
||||
---
|
||||
|
||||
## 阶段 F:候选结果预览
|
||||
|
||||
### 目标
|
||||
|
||||
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRewritePreview.tsx`
|
||||
- 新增 `apply-rewrite.ts`
|
||||
- 显示原文与候选内容
|
||||
- 提供按钮:
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
- 用 transaction 应用结果
|
||||
- 处理应用成功后的焦点与滚动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 改写结果预览卡片或面板
|
||||
|
||||
### 验收
|
||||
|
||||
- 替换准确
|
||||
- 插入准确
|
||||
- 取消后文档保持原样
|
||||
|
||||
---
|
||||
|
||||
## 阶段 G:文档状态与容器体验
|
||||
|
||||
### 目标
|
||||
|
||||
补齐正式产品的壳层体验。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingShell.tsx`
|
||||
- 新增 `WritingHeader.tsx`
|
||||
- 新增 `WritingStatusBar.tsx`
|
||||
- 新增 `WritingEmptyState.tsx`
|
||||
- 补充文档标题区域
|
||||
- 补充文档状态提示
|
||||
- 补充空态和加载态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 页面完整壳层
|
||||
|
||||
### 验收
|
||||
|
||||
- 页面不是裸编辑器
|
||||
- 有完整的文档工作区感
|
||||
|
||||
---
|
||||
|
||||
## 阶段 H:视觉打磨
|
||||
|
||||
### 目标
|
||||
|
||||
让整体观感接近成熟产品。
|
||||
|
||||
### 任务
|
||||
|
||||
- 调整右侧阅读宽度
|
||||
- 调整字体大小和行高
|
||||
- 调整段距和标题层级
|
||||
- 调整工具栏阴影、边框、圆角
|
||||
- 调整浮层动效
|
||||
- 调整 AI 预览卡片质感
|
||||
- 优化深浅色模式兼容
|
||||
|
||||
### 交付物
|
||||
|
||||
- 完整视觉优化
|
||||
|
||||
### 验收
|
||||
|
||||
- 明显优于当前版本
|
||||
- 有豆包 / Novel 风格的编辑器质感
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件级开发清单
|
||||
|
||||
## 6.1 `WritingWorkspacePage.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面级容器
|
||||
- 获取线程 / 文档上下文
|
||||
- 组织左右布局
|
||||
|
||||
必须完成:
|
||||
|
||||
- 页面初始化
|
||||
- 与左侧聊天协同
|
||||
- 右侧编辑区挂载
|
||||
|
||||
## 6.2 `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 写作工作区总壳层
|
||||
- 布局标题区、工具栏、正文区、状态区
|
||||
|
||||
必须完成:
|
||||
|
||||
- 自适应布局
|
||||
- 桌面端优先
|
||||
- 窄屏降级
|
||||
|
||||
## 6.3 `TiptapEditor.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 包装 Tiptap EditorContent
|
||||
- 负责 editor 实例初始化
|
||||
|
||||
必须完成:
|
||||
|
||||
- `onUpdate`
|
||||
- `onSelectionUpdate`
|
||||
- 内容同步
|
||||
|
||||
## 6.4 `WritingToolbar.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 顶部主工具栏
|
||||
|
||||
必须完成:
|
||||
|
||||
- 所有基础格式化命令
|
||||
- 当前状态高亮
|
||||
|
||||
## 6.5 `WritingBubbleMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 选区浮层
|
||||
|
||||
必须完成:
|
||||
|
||||
- 跟随选区
|
||||
- 基础快捷操作
|
||||
- AI 改写入口
|
||||
|
||||
## 6.6 `WritingAiMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- AI 改写动作菜单
|
||||
|
||||
必须完成:
|
||||
|
||||
- 一级菜单
|
||||
- 改语气二级菜单
|
||||
- 动作触发
|
||||
|
||||
## 6.7 `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 展示候选内容
|
||||
|
||||
必须完成:
|
||||
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
|
||||
---
|
||||
|
||||
## 7. Hook 级开发清单
|
||||
|
||||
## 7.1 `useWritingEditor.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- editor 生命周期
|
||||
- 初始内容装载
|
||||
- 内容变更同步
|
||||
|
||||
## 7.2 `useWritingSelection.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 当前选区抽取
|
||||
- 选区文本缓存
|
||||
- 多段落纯文本拼接
|
||||
|
||||
## 7.3 `useWritingRewrite.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 改写请求
|
||||
- loading / success / error
|
||||
- 结果缓存
|
||||
- 重试
|
||||
|
||||
## 7.4 `useWritingToolbar.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- toolbar active 状态
|
||||
- editor command 映射
|
||||
|
||||
---
|
||||
|
||||
## 8. API 对接清单
|
||||
|
||||
前端需向后端约定以下接口能力:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
前端请求内容至少包括:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `selection.from`
|
||||
- `selection.to`
|
||||
- `selection.text`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `surroundingContext.before`
|
||||
- `surroundingContext.after`
|
||||
|
||||
前端需处理的返回字段:
|
||||
|
||||
- `output`
|
||||
- `originalSelection`
|
||||
- `metadata`
|
||||
|
||||
---
|
||||
|
||||
## 9. 状态管理建议
|
||||
|
||||
建议新增独立 Context:
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
建议持有:
|
||||
|
||||
- 当前文档内容
|
||||
- 当前 editor 实例
|
||||
- 当前选区
|
||||
- 当前改写请求状态
|
||||
- 当前候选结果
|
||||
- 当前 UI 面板状态
|
||||
|
||||
不建议继续复用旧 Artifact Context 的原因:
|
||||
|
||||
- 旧 Context 关注文件列表和文件路径
|
||||
- 新编辑器关注选区、结构、候选结果、应用动作
|
||||
- 关注点完全不同
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端联调检查点
|
||||
|
||||
## 10.1 与后端联调前
|
||||
|
||||
- 先用 mock 数据打通浮层和预览
|
||||
- 先验证替换 / 插入逻辑
|
||||
|
||||
## 10.2 与后端联调时
|
||||
|
||||
- 检查多段落选区拼接结果
|
||||
- 检查 AI 返回内容是否带说明性前缀
|
||||
- 检查替换后位置是否准确
|
||||
|
||||
## 10.3 联调后
|
||||
|
||||
- 验证大段文本性能
|
||||
- 验证撤销 / 重做
|
||||
- 验证快速连续触发是否会状态错乱
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端测试清单
|
||||
|
||||
## 11.1 功能测试
|
||||
|
||||
- 输入正文
|
||||
- 插入标题
|
||||
- 切换列表
|
||||
- 插入引用
|
||||
- 插入代码块
|
||||
- 加粗 / 斜体 / 下划线 / 删除线
|
||||
- 选区浮层显示
|
||||
- 选区浮层隐藏
|
||||
- AI 改写菜单打开
|
||||
- 改语气二级菜单打开
|
||||
- 改写结果替换
|
||||
- 改写结果插入
|
||||
- 改写结果取消
|
||||
|
||||
## 11.2 场景测试
|
||||
|
||||
- 单段短文本润色
|
||||
- 多段文本润色
|
||||
- 多段文本扩写
|
||||
- 多段文本缩写
|
||||
- 多段文本改语气
|
||||
- 连续两次改写同一段
|
||||
- 快速切换线程后重新进入
|
||||
|
||||
## 11.3 回归测试
|
||||
|
||||
- 左侧聊天是否正常
|
||||
- 模型切换是否正常
|
||||
- `writing_mode` 是否正常
|
||||
- 原有 artifact 模块是否仍可使用
|
||||
|
||||
---
|
||||
|
||||
## 12. 任务优先级
|
||||
|
||||
### P0
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
- 选区浮层
|
||||
- 改写菜单
|
||||
- 替换 / 插入
|
||||
|
||||
### P1
|
||||
|
||||
- 页面头部与状态
|
||||
- 优雅 loading / error / retry
|
||||
- 多段落稳定性
|
||||
- UI 打磨
|
||||
|
||||
### P2
|
||||
|
||||
- 空态优化
|
||||
- 动效优化
|
||||
- 快捷键支持
|
||||
|
||||
---
|
||||
|
||||
## 13. 建议分工
|
||||
|
||||
### 前端 1 号
|
||||
|
||||
负责:
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
|
||||
### 前端 2 号
|
||||
|
||||
负责:
|
||||
|
||||
- 选区状态
|
||||
- BubbleMenu
|
||||
- AI 菜单
|
||||
|
||||
### 前端 3 号
|
||||
|
||||
负责:
|
||||
|
||||
- 候选结果预览
|
||||
- 替换 / 插入
|
||||
- 状态栏与页面体验
|
||||
|
||||
### 联调负责人
|
||||
|
||||
负责:
|
||||
|
||||
- 前后端请求协议
|
||||
- 错误态规范
|
||||
- 多段落测试和回归
|
||||
|
||||
---
|
||||
|
||||
## 14. 前端完成定义
|
||||
|
||||
满足以下条件可视为前端完成:
|
||||
|
||||
- 右侧能作为正式编辑器独立使用
|
||||
- 选区后可直接发起 AI 改写
|
||||
- 改写后可替换或插入
|
||||
- 视觉上明显优于当前版本
|
||||
- 左侧聊天和现有系统不被破坏
|
||||
|
||||
---
|
||||
|
||||
## 15. 下一步建议
|
||||
|
||||
前端团队可以按以下顺序开工:
|
||||
|
||||
1. 先起页面和路由
|
||||
2. 再接 Tiptap 与主工具栏
|
||||
3. 再做选区浮层
|
||||
4. 再接 AI 菜单和结果预览
|
||||
5. 最后做视觉打磨
|
||||
|
||||
这个顺序风险最小,也最容易在中途验收。
|
||||
@@ -1,457 +0,0 @@
|
||||
# AI 写作助手源码与实现映射表
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把以下几类信息对应起来:
|
||||
|
||||
- 豆包 AI 写作助手里要借鉴的交互
|
||||
- Novel / open-canvas / Tiptap 中可参考的实现思路
|
||||
- DeerFlow 当前项目里已经存在的落点
|
||||
- 本次新建模块应该落到哪里
|
||||
|
||||
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 使用方式
|
||||
|
||||
开发时建议按以下顺序看:
|
||||
|
||||
1. 先看“目标能力”
|
||||
2. 再看“参考来源”
|
||||
3. 再看“DeerFlow 当前落点”
|
||||
4. 最后看“本次目标文件”
|
||||
|
||||
这样可以避免出现两种常见问题:
|
||||
|
||||
- 直接照搬外部项目结构,结果和你们现有架构冲突
|
||||
- 只看自己项目,不知道成熟产品交互应该怎么落
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体映射
|
||||
|
||||
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
|
||||
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
|
||||
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
|
||||
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
|
||||
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
|
||||
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
|
||||
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
|
||||
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
|
||||
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
|
||||
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 豆包功能到 DeerFlow 的映射
|
||||
|
||||
## 4.1 滑词 / 选区浮层
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 选中文本后出现轻量浮层
|
||||
- 浮层里有:
|
||||
- AI 改写
|
||||
- 文本样式
|
||||
- 复制
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 右侧还没有正式的选区级工具层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 的 `BubbleMenu`
|
||||
- 再叠加自定义菜单逻辑
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.2 多段落改写
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 不是只改单句
|
||||
- 能对连续几段一起润色、扩写、缩写、改语气
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 现有右侧编辑区没有正式的多段落选区改写模型
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 前端提取连续选区纯文本
|
||||
- 前端附带前后文
|
||||
- 后端返回候选纯文本
|
||||
- 前端决定替换或插入
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
|
||||
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.3 替换 / 插入
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 改写结果不是直接覆盖原文
|
||||
- 用户可选择:
|
||||
- 替换
|
||||
- 插入
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前没有候选文本应用层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 建立候选结果预览区
|
||||
- 基于 Tiptap transaction 应用文本
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.4 标题 / 列表 / 引用 / 代码块工具条
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 右侧本身就是正式文档编辑器
|
||||
- 可以快速切换文档结构
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前右侧偏预览,不是正式写作编辑器
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 节点和命令
|
||||
- 在主工具栏中集中承载
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. Novel 参考映射
|
||||
|
||||
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
|
||||
|
||||
- 编辑器主容器的产品感
|
||||
- 工具栏和空态的组织方式
|
||||
- 正文阅读宽度、排版、留白
|
||||
|
||||
## 5.2 对应到 DeerFlow 的实现位置
|
||||
|
||||
- 页面壳:`WritingShell.tsx`
|
||||
- 顶部文档区:`WritingHeader.tsx`
|
||||
- 编辑容器:`TiptapEditor.tsx`
|
||||
- 工具栏:`WritingToolbar.tsx`
|
||||
- 空态:`WritingEmptyState.tsx`
|
||||
|
||||
## 5.3 不建议直接照搬的部分
|
||||
|
||||
- Next.js 页面结构
|
||||
- 依赖其服务端 AI 调用方式
|
||||
- 与 Vercel AI SDK 深绑定的部分
|
||||
|
||||
---
|
||||
|
||||
## 6. open-canvas 参考映射
|
||||
|
||||
## 6.1 主要借鉴对象
|
||||
|
||||
open-canvas 这次最值得借鉴的不是页面外观,而是:
|
||||
|
||||
- AI 工作流组织方式
|
||||
- 改写结果应用节奏
|
||||
- 编辑器和聊天协作的模式
|
||||
|
||||
## 6.2 借鉴重点
|
||||
|
||||
### 借鉴 1:独立工作区思想
|
||||
|
||||
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
|
||||
|
||||
### 借鉴 2:画布状态独立维护
|
||||
|
||||
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
|
||||
|
||||
### 借鉴 3:AI 输出先预览,再应用
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 7. Tiptap 参考映射
|
||||
|
||||
## 7.1 Tiptap 在本项目中的职责
|
||||
|
||||
- 作为右侧正式富文本编辑器内核
|
||||
- 提供结构节点与 marks
|
||||
- 提供 BubbleMenu 能力
|
||||
- 提供 transaction 应用能力
|
||||
|
||||
## 7.2 建议直接参考的实现点
|
||||
|
||||
- `EditorContent` 的封装模式
|
||||
- `BubbleMenu` 的接入方式
|
||||
- heading / list / blockquote / codeBlock 扩展配置
|
||||
- `editor.chain().focus()` 命令调用模式
|
||||
- selection 变化监听
|
||||
|
||||
## 7.3 DeerFlow 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8. DeerFlow 现有文件到新模块的映射
|
||||
|
||||
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 汇总页面路由
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 挂入新的写作助手路由
|
||||
|
||||
### 后续动作
|
||||
|
||||
- 引入 `WritingRoutes`
|
||||
- 增加 `page/writing/*`
|
||||
|
||||
---
|
||||
|
||||
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 左聊天 + 右 Artifact 双栏布局
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 提供双栏工作区组织思路
|
||||
- 可复用布局比例和 panel 交互经验
|
||||
|
||||
### 不建议直接复用的部分
|
||||
|
||||
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 管理文件型 artifact 列表、选中项和开关状态
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 仅作为旧 Artifact 模块保留
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
### 原因
|
||||
|
||||
- 写作编辑器需要的不是文件路径状态
|
||||
- 而是文档状态、选区状态、候选结果状态、工具层状态
|
||||
|
||||
---
|
||||
|
||||
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 聊天输入区
|
||||
- 已有 `writing_mode` 入口
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 保留为用户进入写作模式的现有入口
|
||||
|
||||
### 本次改造建议
|
||||
|
||||
- 写作模式打开时,跳转或唤起新的写作工作区
|
||||
- 不再只停留在“生成 Markdown 文件”这个层面
|
||||
|
||||
---
|
||||
|
||||
## 9. 新模块间的依赖关系
|
||||
|
||||
## 9.1 页面层
|
||||
|
||||
- `WritingRoutes.tsx`
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面路由
|
||||
- 双栏或工作区布局
|
||||
- 上下文装配
|
||||
|
||||
## 9.2 编辑器层
|
||||
|
||||
- `TiptapEditor.tsx`
|
||||
- `TiptapExtensions.ts`
|
||||
- `commands.ts`
|
||||
- `selection.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 编辑器内核
|
||||
- 节点和 marks
|
||||
- 基础命令
|
||||
- 选区抽取
|
||||
|
||||
## 9.3 交互层
|
||||
|
||||
- `WritingToolbar.tsx`
|
||||
- `WritingBubbleMenu.tsx`
|
||||
- `WritingAiMenu.tsx`
|
||||
- `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 工具栏
|
||||
- 浮层
|
||||
- AI 菜单
|
||||
- 候选内容应用
|
||||
|
||||
## 9.4 状态层
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
- `useWritingEditor.ts`
|
||||
- `useWritingSelection.ts`
|
||||
- `useWritingRewrite.ts`
|
||||
- `useWritingToolbar.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 主状态
|
||||
- 选区状态
|
||||
- 改写状态
|
||||
- 工具栏状态
|
||||
|
||||
## 9.5 API 层
|
||||
|
||||
- `api/rewrite.ts`
|
||||
- `api/documents.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 对接后端写作改写接口
|
||||
- 对接文档加载与保存接口
|
||||
|
||||
---
|
||||
|
||||
## 10. 能力到文件的精确映射
|
||||
|
||||
| 能力 | 新文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
|
||||
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
|
||||
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
|
||||
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
|
||||
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
|
||||
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
|
||||
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
|
||||
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
|
||||
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
|
||||
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
|
||||
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
|
||||
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
|
||||
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发时的参考顺序
|
||||
|
||||
建议开发顺序如下:
|
||||
|
||||
1. 先看 `PageRoutes.tsx`,确定路由接法
|
||||
2. 再看 `chat-box.tsx`,理解当前左右布局
|
||||
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
|
||||
4. 再接 `TiptapEditor.tsx`
|
||||
5. 再做 `WritingToolbar.tsx`
|
||||
6. 再做 `WritingBubbleMenu.tsx`
|
||||
7. 再做 `WritingAiMenu.tsx`
|
||||
8. 最后做 `WritingRewritePreview.tsx`
|
||||
|
||||
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
|
||||
|
||||
---
|
||||
|
||||
## 12. 明确哪些是“参考”,哪些是“目标”
|
||||
|
||||
### 12.1 参考
|
||||
|
||||
- 豆包:交互体验参考
|
||||
- Novel:页面和编辑器质感参考
|
||||
- open-canvas:AI 工作流参考
|
||||
- Tiptap:编辑器技术实现参考
|
||||
|
||||
### 12.2 目标
|
||||
|
||||
目标不是复刻任何一个外部项目,而是:
|
||||
|
||||
- 在 DeerFlow 现有架构中
|
||||
- 用 React + Vite + 现有线程体系
|
||||
- 做出一个更成熟的 AI 写作编辑器模式
|
||||
|
||||
---
|
||||
|
||||
## 13. 结论
|
||||
|
||||
这份映射表的核心作用,是避免团队在开发时迷路。
|
||||
|
||||
真正推荐的姿势是:
|
||||
|
||||
- 用豆包定义目标体验
|
||||
- 用 Novel 定义页面质感
|
||||
- 用 open-canvas 定义 AI 工作流
|
||||
- 用 Tiptap 作为编辑器内核
|
||||
- 用 DeerFlow 现有路由、线程、模型体系做宿主
|
||||
|
||||
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,645 +0,0 @@
|
||||
# AI 写作后端新路线设计草案
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
|
||||
|
||||
- 不影响现有同事正在开发的功能
|
||||
- 不强耦合现有 DeerFlow 通用对话主链路
|
||||
- 可独立部署、独立调试、独立回滚
|
||||
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐建设方式
|
||||
|
||||
优先建议:
|
||||
|
||||
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
如果当前后端没有 `apps/` 结构,也可以采用:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
ai-writing-orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术路线建议
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 编排框架:`LangGraph`
|
||||
- 模型调用:接你们内网模型网关
|
||||
- 检索层:接内网知识库 / 文档库 / 搜索服务
|
||||
- 状态存储:先用数据库或 Redis + DB,后续再演进
|
||||
- 事件输出:SSE 或流式 HTTP
|
||||
|
||||
推荐原因:
|
||||
|
||||
- `LangGraph` 适合状态机和多节点回退
|
||||
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
|
||||
- 后续加人工确认节点和 checkpoint 会更自然
|
||||
|
||||
---
|
||||
|
||||
## 4. 分层设计
|
||||
|
||||
建议按四层拆分。
|
||||
|
||||
## 4.1 API 层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收前端请求
|
||||
- 鉴权
|
||||
- 参数校验
|
||||
- 返回 workflow 信息
|
||||
- 提供事件流出口
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
api/
|
||||
routes/
|
||||
workflows.py
|
||||
workflow_events.py
|
||||
schemas/
|
||||
workflow_request.py
|
||||
workflow_response.py
|
||||
```
|
||||
|
||||
## 4.2 Application 层
|
||||
|
||||
职责:
|
||||
|
||||
- 组织用例
|
||||
- 调度 workflow service
|
||||
- 管理人工确认动作
|
||||
- 处理重试和终止逻辑
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
application/
|
||||
services/
|
||||
workflow_service.py
|
||||
workflow_event_service.py
|
||||
human_action_service.py
|
||||
use_cases/
|
||||
create_workflow.py
|
||||
start_workflow.py
|
||||
confirm_outline.py
|
||||
rewrite_section.py
|
||||
approve_final.py
|
||||
```
|
||||
|
||||
## 4.3 Domain 层
|
||||
|
||||
职责:
|
||||
|
||||
- 定义工作流状态机
|
||||
- 定义 Agent 输入输出协议
|
||||
- 定义 artifact 与评分模型
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 4.4 Infrastructure 层
|
||||
|
||||
职责:
|
||||
|
||||
- 访问模型网关
|
||||
- 访问检索服务
|
||||
- 访问数据库
|
||||
- 发送事件
|
||||
- 缓存与日志
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
llm/
|
||||
model_client.py
|
||||
retrieval/
|
||||
intranet_search_client.py
|
||||
knowledge_base_client.py
|
||||
persistence/
|
||||
workflow_repository.py
|
||||
artifact_repository.py
|
||||
streaming/
|
||||
sse_publisher.py
|
||||
logging/
|
||||
workflow_logger.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作流最小闭环
|
||||
|
||||
第一版建议只做如下状态机:
|
||||
|
||||
1. `collect_materials`
|
||||
2. `generate_outline`
|
||||
3. `await_outline_confirmation`
|
||||
4. `write_draft`
|
||||
5. `review_draft`
|
||||
6. `rewrite_if_needed`
|
||||
7. `complete`
|
||||
|
||||
终止规则:
|
||||
|
||||
- 编辑评分达标则完成
|
||||
- 否则进入重写
|
||||
- 最多重写 2 次
|
||||
- 超过次数则标记 `human_intervention_required`
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent 模块设计
|
||||
|
||||
## 6.1 Researcher
|
||||
|
||||
输入:
|
||||
|
||||
- 主题
|
||||
- 用户要求
|
||||
- 检索范围
|
||||
- 可用资料源
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
程序职责:
|
||||
|
||||
- 先做检索
|
||||
- 再把检索结果裁剪后送给模型
|
||||
- 输出必须结构化
|
||||
|
||||
## 6.2 Outliner
|
||||
|
||||
输入:
|
||||
|
||||
- 用户要求
|
||||
- 研究结果
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
约束:
|
||||
|
||||
- 提纲优先,不写正文
|
||||
|
||||
## 6.3 Writer
|
||||
|
||||
输入:
|
||||
|
||||
- 已确认提纲
|
||||
- 素材卡片
|
||||
- 风格要求
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
建议:
|
||||
|
||||
- 按章节分段生成,不要一次长文直出
|
||||
|
||||
## 6.4 Editor
|
||||
|
||||
输入:
|
||||
|
||||
- 初稿
|
||||
- 提纲
|
||||
- 素材
|
||||
- 用户要求
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
规则:
|
||||
|
||||
- 先查事实,再查结构,再查文风
|
||||
|
||||
## 6.5 Supervisor
|
||||
|
||||
输入:
|
||||
|
||||
- 各阶段结果
|
||||
- 重试计数
|
||||
- 评分结果
|
||||
|
||||
输出:
|
||||
|
||||
- 下一跳节点
|
||||
- 是否需要人工确认
|
||||
- 是否结束
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt 组织建议
|
||||
|
||||
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 每个 Agent 一套职责
|
||||
- 每个 Agent 一套输出 schema
|
||||
- Prompt 版本可管理
|
||||
- 后续支持 A/B 调参
|
||||
|
||||
---
|
||||
|
||||
## 8. 弱模型适配策略
|
||||
|
||||
## 8.1 先检索后生成
|
||||
|
||||
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
|
||||
|
||||
## 8.2 短上下文任务化
|
||||
|
||||
每个节点只给弱模型一小段明确任务:
|
||||
|
||||
- 研究归纳
|
||||
- 提纲整理
|
||||
- 单章节写作
|
||||
- 单章节审稿
|
||||
|
||||
## 8.3 结构化输出
|
||||
|
||||
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
|
||||
|
||||
## 8.4 章节级重写
|
||||
|
||||
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
|
||||
|
||||
## 8.5 程序接管质量门槛
|
||||
|
||||
例如:
|
||||
|
||||
- 资料引用数不足,不进入下一步
|
||||
- 提纲章节数过少,直接重生
|
||||
- 编辑评分低于阈值,自动重写
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据模型建议
|
||||
|
||||
## 9.1 Workflow
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `status`
|
||||
- `current_stage`
|
||||
- `user_id`
|
||||
- `thread_id`
|
||||
- `input_payload`
|
||||
- `current_artifact_id`
|
||||
- `retry_count`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
## 9.2 Workflow Artifact
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `artifact_type`
|
||||
- `stage`
|
||||
- `version`
|
||||
- `title`
|
||||
- `content`
|
||||
- `metadata`
|
||||
|
||||
## 9.3 Workflow Event
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `event_type`
|
||||
- `stage`
|
||||
- `agent_name`
|
||||
- `payload`
|
||||
- `created_at`
|
||||
|
||||
## 9.4 Human Action
|
||||
|
||||
建议字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `action_type`
|
||||
- `payload`
|
||||
- `operator`
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## 10. API 草案
|
||||
|
||||
## 10.1 创建任务
|
||||
|
||||
`POST /api/ai-writing/workflows`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "主题",
|
||||
"articleType": "行业分析",
|
||||
"audience": "管理层",
|
||||
"tone": "专业克制",
|
||||
"targetLength": 3000,
|
||||
"constraints": ["避免口语化"],
|
||||
"referenceScope": ["内网知识库", "项目复盘文档"]
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "wf_xxx",
|
||||
"status": "idle"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.2 启动任务
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/start`
|
||||
|
||||
## 10.3 查询任务
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}`
|
||||
|
||||
## 10.4 订阅事件
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}/events`
|
||||
|
||||
返回事件流示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stage_changed",
|
||||
"stage": "generating_outline",
|
||||
"message": "正在生成提纲"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.5 确认提纲
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
|
||||
## 10.6 指定章节重写
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
|
||||
## 10.7 确认终稿
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
---
|
||||
|
||||
## 11. LangGraph 实现建议
|
||||
|
||||
## 11.1 推荐 graph 节点
|
||||
|
||||
- `load_input`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
## 11.2 推荐 state
|
||||
|
||||
建议 state 字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `input_payload`
|
||||
- `materials`
|
||||
- `outline`
|
||||
- `outline_confirmed`
|
||||
- `draft_sections`
|
||||
- `merged_draft`
|
||||
- `review_result`
|
||||
- `rewrite_targets`
|
||||
- `final_article`
|
||||
- `retry_count`
|
||||
- `events`
|
||||
|
||||
## 11.3 条件边
|
||||
|
||||
建议条件边逻辑:
|
||||
|
||||
- 提纲未确认 -> 等待用户
|
||||
- 编辑通过 -> 完成
|
||||
- 编辑不通过且可重试 -> 重写
|
||||
- 编辑不通过且超限 -> 人工介入
|
||||
|
||||
---
|
||||
|
||||
## 12. 可观测性要求
|
||||
|
||||
第一版就建议带上以下能力:
|
||||
|
||||
- 每个 workflow 的事件日志
|
||||
- 每个 Agent 的输入输出摘要
|
||||
- 每次重试原因
|
||||
- 每个阶段耗时
|
||||
- 每次模型调用 token 与错误日志
|
||||
|
||||
后续排问题会非常依赖这些数据。
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试建议
|
||||
|
||||
## 13.1 单元测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- stage 流转
|
||||
- retry policy
|
||||
- quality policy
|
||||
- 事件构造
|
||||
|
||||
## 13.2 集成测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- 创建任务到完成的主链路
|
||||
- 提纲确认链路
|
||||
- 审稿失败后重写链路
|
||||
- 人工介入链路
|
||||
|
||||
## 13.3 Prompt 回归集
|
||||
|
||||
建议建立 10-20 个固定写作样例,做回归:
|
||||
|
||||
- 短文
|
||||
- 长文
|
||||
- 事实密集型文章
|
||||
- 宣传型文章
|
||||
- 分析型文章
|
||||
|
||||
---
|
||||
|
||||
## 14. 开发顺序建议
|
||||
|
||||
## Step 1:先做 domain 与 API 契约
|
||||
|
||||
先定:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- human action payload
|
||||
|
||||
## Step 2:先做 fake orchestrator
|
||||
|
||||
先不接真实模型,只模拟事件流,把前后端联调跑通。
|
||||
|
||||
## Step 3:接 Researcher + Outliner
|
||||
|
||||
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再接初稿和审稿重写。
|
||||
|
||||
## Step 5:接可观测性和缓存
|
||||
|
||||
最后再补生产化能力。
|
||||
|
||||
---
|
||||
|
||||
## 15. 风险控制建议
|
||||
|
||||
### 风险 1:直接复用现有线程流,导致协议污染
|
||||
|
||||
建议:
|
||||
|
||||
- workflow 用独立 route 和独立 event schema
|
||||
|
||||
### 风险 2:写作结果与右侧 artifact 模型不兼容
|
||||
|
||||
建议:
|
||||
|
||||
- 后端统一输出标准 markdown artifact
|
||||
|
||||
### 风险 3:弱模型频繁输出不合法 JSON
|
||||
|
||||
建议:
|
||||
|
||||
- 每个节点加 schema repair
|
||||
- 加轻量重试
|
||||
- 尽量减少复杂嵌套输出
|
||||
|
||||
### 风险 4:研究资料太多导致上下文爆炸
|
||||
|
||||
建议:
|
||||
|
||||
- 检索后先裁剪、摘要、分桶
|
||||
|
||||
---
|
||||
|
||||
## 16. 最终建议
|
||||
|
||||
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
|
||||
|
||||
建议 MVP 只保留:
|
||||
|
||||
1. Researcher
|
||||
2. Outliner
|
||||
3. Writer
|
||||
4. Editor
|
||||
5. Supervisor
|
||||
|
||||
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
|
||||
@@ -1,806 +0,0 @@
|
||||
# AI 写作执行方案
|
||||
|
||||
## 1. 总体原则
|
||||
|
||||
本项目的最终落地原则:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
|
||||
3. 编排框架采用 `LangGraph`
|
||||
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
|
||||
6. 前后端通过 `SSE` 传递工作流事件
|
||||
7. 第一版只做最小闭环,不做过多花哨扩展
|
||||
|
||||
一句话说明:
|
||||
|
||||
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目边界
|
||||
|
||||
## 2.1 前端边界
|
||||
|
||||
继续使用当前页面:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
前端做的事情:
|
||||
|
||||
- 左侧增加“写作工作流模式”
|
||||
- 展示多 Agent 执行进度与中间产物
|
||||
- 提供提纲确认和指定章节重写
|
||||
- 将初稿、终稿同步到右侧 artifact
|
||||
|
||||
前端不做的事情:
|
||||
|
||||
- 不重写右侧编辑器
|
||||
- 不重写现有普通 chat 流程
|
||||
- 不把 workflow 状态散落在多个旧 context 里
|
||||
|
||||
## 2.2 后端边界
|
||||
|
||||
后端采用独立新路线:
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
后端做的事情:
|
||||
|
||||
- 实现写作 workflow 状态机
|
||||
- 实现 A/B/C Agent 与 Supervisor
|
||||
- 接内网模型与内网检索
|
||||
- 输出标准事件流和文章 artifact
|
||||
|
||||
后端不做的事情:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把工作流逻辑塞到现有通用 thread 协议里
|
||||
|
||||
## 2.3 资料搜集边界
|
||||
|
||||
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
|
||||
|
||||
第一版要求:
|
||||
|
||||
- 研究员可以调用互联网搜索能力收集公开资料
|
||||
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
|
||||
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
|
||||
|
||||
第二版预留:
|
||||
|
||||
- 后续替换或并行接入内网搜索接口
|
||||
- 后续接入内部知识库、文档库、FAQ、项目复盘库
|
||||
- 前端和工作流层不因检索源变化而改协议
|
||||
|
||||
统一设计要求:
|
||||
|
||||
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
|
||||
- `writing_graph` 只读取标准化后的素材结果
|
||||
- API、事件流、artifact 结构不区分互联网资料和内网资料
|
||||
|
||||
推荐检索抽象:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
```
|
||||
|
||||
建议接口:
|
||||
|
||||
```python
|
||||
class RetrievalProvider(Protocol):
|
||||
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
|
||||
|
||||
class RetrievedDocument(BaseModel):
|
||||
title: str
|
||||
url: str | None
|
||||
source_type: str
|
||||
snippet: str
|
||||
published_at: str | None = None
|
||||
fetched_at: str
|
||||
reliability: str | None = None
|
||||
```
|
||||
|
||||
第一版路由策略:
|
||||
|
||||
- 默认 `internet`
|
||||
- 配置项预留 `intranet`、`hybrid`
|
||||
|
||||
例如:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
```
|
||||
|
||||
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一版产品目标
|
||||
|
||||
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
|
||||
|
||||
1. 输入主题、体裁、读者、风格、篇幅
|
||||
2. 研究员搜集资料并输出素材卡片
|
||||
3. 提纲 Agent 生成提纲
|
||||
4. 用户确认提纲
|
||||
5. Writer 生成初稿
|
||||
6. Editor 给出问题清单与评分
|
||||
7. 不达标则重写一次或两次
|
||||
8. 终稿写入右侧 artifact
|
||||
|
||||
第一版成功标准:
|
||||
|
||||
- 不是“生成得像最强模型一样好”
|
||||
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
|
||||
|
||||
## 3.1 第一版研究员目标
|
||||
|
||||
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
|
||||
|
||||
第一版输入:
|
||||
|
||||
- 用户主题
|
||||
- 用户写作要求
|
||||
- 检索源配置,第一版默认 `internet`
|
||||
|
||||
第一版输出至少包含:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
每条素材最少要带:
|
||||
|
||||
- 来源标题
|
||||
- 来源 URL
|
||||
- 摘要
|
||||
- 可信度标签
|
||||
- 被引用的原始片段或整理后的事实点
|
||||
|
||||
第一版 Researcher 的程序流程建议:
|
||||
|
||||
1. 根据主题自动生成 3-5 个检索 query
|
||||
2. 调用互联网检索接口拿回候选文档
|
||||
3. 做去重、裁剪、初筛
|
||||
4. 让模型将结果整理成结构化素材卡片
|
||||
5. 将结构化结果返回给 Outliner 和 Writer
|
||||
|
||||
第一版先不做:
|
||||
|
||||
- 深度网页 crawling
|
||||
- 多轮自动追问式深研究
|
||||
- 内网知识库接入
|
||||
- 复杂引用格式生成
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终工作流
|
||||
|
||||
## 4.1 Agent 角色
|
||||
|
||||
### `Researcher`
|
||||
|
||||
职责:
|
||||
|
||||
- 从配置的检索源检索信息
|
||||
- 产出结构化素材卡片
|
||||
- 标注来源、可信度、风险点
|
||||
|
||||
第一版说明:
|
||||
|
||||
- 默认走互联网公开资料检索
|
||||
- 必须预留后续切换到内网检索源的接口
|
||||
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
|
||||
|
||||
第一版检索源字段建议:
|
||||
|
||||
- `source_type = "internet"`
|
||||
- 后续预留 `source_type = "intranet" | "hybrid"`
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
### `Outliner`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户要求和研究结果生成提纲
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
### `Writer`
|
||||
|
||||
职责:
|
||||
|
||||
- 基于提纲和素材写初稿
|
||||
- 按章节写,不一次生成整篇长文
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
### `Editor`
|
||||
|
||||
职责:
|
||||
|
||||
- 检查事实、结构、文风
|
||||
- 给出评分与重写建议
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
### `Supervisor`
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程调度
|
||||
- 负责通过 / 重写 / 人工介入判断
|
||||
|
||||
## 4.2 状态机
|
||||
|
||||
第一版统一采用:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
## 4.3 流程闭环
|
||||
|
||||
最小闭环固定为:
|
||||
|
||||
1. `Research`
|
||||
2. `Outline`
|
||||
3. `HumanConfirmOutline`
|
||||
4. `Draft`
|
||||
5. `Review`
|
||||
6. `RewriteIfNeeded`
|
||||
7. `Complete`
|
||||
|
||||
重试规则:
|
||||
|
||||
- 最多重写 2 次
|
||||
- 达标则完成
|
||||
- 超限则进入人工介入
|
||||
|
||||
## 4.4 LangGraph 推荐节点
|
||||
|
||||
第一版推荐节点:
|
||||
|
||||
- `load_input`
|
||||
- `build_search_queries`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
其中和资料搜集直接相关的节点说明:
|
||||
|
||||
### `build_search_queries`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
|
||||
|
||||
### `retrieve_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 调用检索 provider 获取候选文档
|
||||
- 第一版默认走 `internet_search_client`
|
||||
- 后续可切到 `intranet_search_client`
|
||||
|
||||
### `normalize_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 做去重
|
||||
- 做摘要裁剪
|
||||
- 做来源字段统一
|
||||
- 输出标准 `RetrievedDocument[]`
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端最终做法
|
||||
|
||||
## 5.1 目录建议
|
||||
|
||||
在现有 `open-canvas` 下新增:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
CanvasWorkflowPanel.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
```
|
||||
|
||||
## 5.2 左侧改造策略
|
||||
|
||||
`CanvasChatPanel` 只承担模式切换与容器职责:
|
||||
|
||||
- `chat` 模式:沿用现有普通聊天
|
||||
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
|
||||
|
||||
新面板负责:
|
||||
|
||||
- 写作任务表单
|
||||
- 进度时间线
|
||||
- 事件列表
|
||||
- 中间产物抽屉
|
||||
- 提纲确认卡片
|
||||
|
||||
## 5.3 右侧衔接策略
|
||||
|
||||
继续复用现有 `CanvasArtifactPanel`。
|
||||
|
||||
同步规则:
|
||||
|
||||
- 提纲:可选同步到右侧预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖当前草稿
|
||||
- 终稿:作为最终 artifact
|
||||
|
||||
## 5.4 前端状态设计
|
||||
|
||||
新增:
|
||||
|
||||
- `workflowMode`
|
||||
- `workflowRunId`
|
||||
- `workflowStage`
|
||||
- `workflowStatus`
|
||||
- `workflowEvents`
|
||||
- `workflowArtifacts`
|
||||
- `workflowPendingAction`
|
||||
|
||||
注意:
|
||||
|
||||
- workflow 状态放进新的 `WritingWorkflowContext`
|
||||
- 不塞进现有 `CanvasGraphContext`
|
||||
|
||||
## 5.5 前端显示的资料信息
|
||||
|
||||
第一版左侧至少要能展示研究员产出的资料摘要:
|
||||
|
||||
- 来源标题
|
||||
- 来源域名或 URL
|
||||
- 资料摘要
|
||||
- 可信度标签
|
||||
- 是否被提纲或正文引用
|
||||
|
||||
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
|
||||
|
||||
注意:
|
||||
|
||||
- 第一版显示公开互联网来源即可
|
||||
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端最终做法
|
||||
|
||||
## 6.1 分层
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
## 6.2 核心模块
|
||||
|
||||
建议核心文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 6.3 检索层实现要求
|
||||
|
||||
第一版检索层必须独立成基础设施模块,不允许:
|
||||
|
||||
- 在 Agent prompt 中直接拼接“去网上找资料”
|
||||
- 在 graph 节点中散落请求第三方搜索的代码
|
||||
- 把互联网检索和内网检索混在一个无抽象的函数里
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
material_normalizer.py
|
||||
```
|
||||
|
||||
建议职责:
|
||||
|
||||
- `internet_search_client.py`
|
||||
- 第一版实际使用
|
||||
- 对接互联网搜索 API 或爬取网关
|
||||
|
||||
- `intranet_search_client.py`
|
||||
- 第一版只保留接口和空实现或 mock
|
||||
- 后续接企业内部接口
|
||||
|
||||
- `retriever_router.py`
|
||||
- 根据配置选择 provider
|
||||
|
||||
- `material_normalizer.py`
|
||||
- 统一清洗和标准化结果
|
||||
|
||||
## 6.4 Prompt 管理
|
||||
|
||||
统一独立存放:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
## 6.5 输出约束
|
||||
|
||||
每个 Agent 都必须:
|
||||
|
||||
- 优先输出 JSON / Pydantic schema
|
||||
- 失败时允许轻量 repair 和一次重试
|
||||
- 避免自由散文式输出
|
||||
|
||||
## 6.6 Researcher 的实现细则
|
||||
|
||||
Researcher 推荐由两部分组成:
|
||||
|
||||
1. 程序化检索
|
||||
2. 模型化整理
|
||||
|
||||
推荐伪流程:
|
||||
|
||||
```python
|
||||
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
|
||||
queries = build_queries(topic, article_type)
|
||||
raw_docs = []
|
||||
for query in queries:
|
||||
raw_docs.extend(provider.search(query=query, top_k=5))
|
||||
docs = normalize_materials(raw_docs)
|
||||
docs = dedupe_materials(docs)
|
||||
docs = rank_materials(docs)
|
||||
return llm_extract_material_cards(docs)
|
||||
```
|
||||
|
||||
第一版建议:
|
||||
|
||||
- 先把 query 生成逻辑做简单
|
||||
- `top_k` 先控制在 5-10
|
||||
- 先做轻量去重和摘要裁剪
|
||||
- 不做很重的网页全文抓取
|
||||
|
||||
---
|
||||
|
||||
## 7. 前后端协议
|
||||
|
||||
## 7.1 API
|
||||
|
||||
第一版统一提供:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
## 7.2 SSE 事件
|
||||
|
||||
统一事件类型:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
|
||||
|
||||
## 7.3 配置项补充
|
||||
|
||||
建议增加以下配置项:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
enable_content_fetch: false
|
||||
|
||||
workflow:
|
||||
max_revisions: 2
|
||||
|
||||
models:
|
||||
researcher: your-default-model
|
||||
outliner: your-default-model
|
||||
writer: your-default-model
|
||||
editor: your-default-model
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 第一版 `provider=internet`
|
||||
- 第二版可切成 `intranet` 或 `hybrid`
|
||||
|
||||
---
|
||||
|
||||
## 8. 必须做 / 延后做 / 不建议现在做
|
||||
|
||||
## 8.1 必须做
|
||||
|
||||
### 架构
|
||||
|
||||
- 独立后端路线 `ai-writing-orchestrator`
|
||||
- 前端独立 `workflow/` 目录
|
||||
- LangGraph 状态机
|
||||
- `Researcher / Outliner / Writer / Editor / Supervisor`
|
||||
- 独立检索抽象层,第一版先落互联网 provider
|
||||
|
||||
### 协议
|
||||
|
||||
- 标准 workflow 状态枚举
|
||||
- 标准 SSE 事件枚举
|
||||
- 标准 artifact 结构
|
||||
- JSON / Pydantic 输出约束
|
||||
|
||||
### 功能
|
||||
|
||||
- 启动写作任务
|
||||
- 研究阶段
|
||||
- 提纲阶段
|
||||
- 提纲确认
|
||||
- 初稿生成
|
||||
- 审稿与最多 2 次重写
|
||||
- 终稿同步到右侧
|
||||
- 素材卡片展示
|
||||
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
|
||||
|
||||
### UI
|
||||
|
||||
- 模式切换
|
||||
- 时间线
|
||||
- 事件列表
|
||||
- 提纲确认卡片
|
||||
- 右侧终稿显示
|
||||
- 左侧素材卡片视图
|
||||
|
||||
## 8.2 延后做
|
||||
|
||||
- 章节级精准重写
|
||||
- 初稿多版本树
|
||||
- FactChecker
|
||||
- StyleEditor
|
||||
- ComplianceReviewer
|
||||
- 自动评分看板
|
||||
- 素材引用可视化高亮
|
||||
- 更复杂的人机协作节点
|
||||
- 内网知识库正式接入
|
||||
- 混合检索 `hybrid`
|
||||
- 网页全文抓取与深摘要
|
||||
|
||||
## 8.3 不建议现在做
|
||||
|
||||
- 直接接入现有 DeerFlow 主 agent 包
|
||||
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
|
||||
- 一开始就做 5 个以上 Agent
|
||||
- 一开始就做很复杂的文章版本管理
|
||||
- 一开始就把评分阈值和 prompt 写死得非常细
|
||||
|
||||
## 9. 最终开发顺序
|
||||
|
||||
## Step 1:先定契约
|
||||
|
||||
先一起拍板:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- confirm / rewrite 的 payload
|
||||
- retrieval provider 接口
|
||||
- RetrievedDocument 结构
|
||||
|
||||
## Step 2:先做 fake workflow
|
||||
|
||||
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
|
||||
|
||||
## Step 3:先接互联网版 Researcher + Outliner
|
||||
|
||||
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
|
||||
|
||||
这一阶段要求:
|
||||
|
||||
- `Researcher` 先走互联网检索
|
||||
- `retriever_router` 可配置
|
||||
- `intranet_search_client` 先留接口空壳
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再补初稿与审稿重写闭环。
|
||||
|
||||
## Step 5:接右侧 artifact
|
||||
|
||||
把初稿和终稿稳定写入右侧。
|
||||
|
||||
## Step 6:补生产化
|
||||
|
||||
- 缓存
|
||||
- 日志
|
||||
- 错误回放
|
||||
- 配置化 prompt
|
||||
|
||||
## 10. 推荐任务拆分
|
||||
|
||||
## 前端
|
||||
|
||||
- `CanvasChatPanel` 模式切换
|
||||
- `workflow/` 目录与类型
|
||||
- 时间线 / 事件列表 / 确认卡片
|
||||
- SSE 订阅 hook
|
||||
- 右侧 artifact 映射
|
||||
- 素材卡片列表展示
|
||||
- 来源字段展示规范
|
||||
|
||||
## 后端
|
||||
|
||||
- workflow API
|
||||
- LangGraph graph
|
||||
- 5 个 Agent 模块
|
||||
- Prompt 目录
|
||||
- SSE 输出
|
||||
- 仓储与日志
|
||||
- `RetrievalProvider` 抽象
|
||||
- `internet_search_client`
|
||||
- `intranet_search_client` 预留接口
|
||||
- `material_normalizer`
|
||||
|
||||
## Prompt / 算法
|
||||
|
||||
- Researcher prompt
|
||||
- Outliner prompt
|
||||
- Writer prompt
|
||||
- Editor prompt
|
||||
- JSON schema
|
||||
- 失败样本回归
|
||||
|
||||
## 联调
|
||||
|
||||
- mock SSE
|
||||
- 真模型接入
|
||||
- artifact 同步校验
|
||||
- 提纲确认链路验证
|
||||
- 互联网资料来源字段联调
|
||||
- 素材卡片到提纲引用链路联调
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发过程中的约束
|
||||
|
||||
开发过程中统一采用下面这套口径:
|
||||
|
||||
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
|
||||
- 技术底座:`LangGraph`
|
||||
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
- 后端方式:独立 `ai-writing-orchestrator`
|
||||
- 检索方式:第一版 `internet`,接口预留 `intranet`
|
||||
- 通信方式:`SSE`
|
||||
- 输出方式:`JSON Schema / Pydantic`
|
||||
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
|
||||
|
||||
给开发团队的统一口径:
|
||||
|
||||
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
|
||||
@@ -1,472 +0,0 @@
|
||||
# AI 写作前端改造清单
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
|
||||
|
||||
当前核心文件:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
---
|
||||
|
||||
## 2. 改造原则
|
||||
|
||||
- 不改右侧主编辑器交互心智
|
||||
- 不复制新页面
|
||||
- 不破坏现有普通 chat 模式
|
||||
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
|
||||
- 所有 workflow 能力都应允许逐步灰度
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐前端结构
|
||||
|
||||
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
utils/
|
||||
workflow-event.ts
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `open-canvas` 原有聊天、artifact、editor 目录不重构
|
||||
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态设计
|
||||
|
||||
## 4.1 新增的核心状态
|
||||
|
||||
建议新增:
|
||||
|
||||
- `workflowMode: "chat" | "writing_pipeline"`
|
||||
- `workflowRunId: string | null`
|
||||
- `workflowStage`
|
||||
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
|
||||
- `workflowEvents: WritingWorkflowEvent[]`
|
||||
- `workflowArtifacts: WritingWorkflowArtifact[]`
|
||||
- `workflowPendingAction: WritingPendingAction | null`
|
||||
|
||||
## 4.2 推荐类型定义
|
||||
|
||||
建议在 `workflow/types/workflow.ts` 中先定义:
|
||||
|
||||
```ts
|
||||
export type WritingWorkflowStage =
|
||||
| "idle"
|
||||
| "collecting_materials"
|
||||
| "generating_outline"
|
||||
| "await_outline_confirmation"
|
||||
| "writing_draft"
|
||||
| "reviewing_draft"
|
||||
| "rewriting_draft"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type WritingWorkflowEventType =
|
||||
| "workflow_started"
|
||||
| "stage_changed"
|
||||
| "agent_started"
|
||||
| "agent_output_chunk"
|
||||
| "artifact_generated"
|
||||
| "human_confirmation_required"
|
||||
| "retry_scheduled"
|
||||
| "workflow_completed"
|
||||
| "workflow_failed";
|
||||
```
|
||||
|
||||
第一版把类型先收紧,后续再扩展。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件拆分建议
|
||||
|
||||
## 5.1 左侧主面板改造
|
||||
|
||||
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
|
||||
|
||||
建议改造为:
|
||||
|
||||
- `chat` 模式继续渲染现有消息列表 + 输入框
|
||||
- `writing_pipeline` 模式渲染新的工作流视图
|
||||
|
||||
建议拆分出:
|
||||
|
||||
- `CanvasChatConversation.tsx`
|
||||
- `CanvasChatInputBox.tsx`
|
||||
- `CanvasWorkflowPanel.tsx`
|
||||
|
||||
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
|
||||
|
||||
## 5.2 新增组件职责
|
||||
|
||||
### `WritingWorkflowEntryForm.tsx`
|
||||
|
||||
负责采集:
|
||||
|
||||
- 主题
|
||||
- 文章类型
|
||||
- 目标读者
|
||||
- 语气风格
|
||||
- 目标篇幅
|
||||
- 是否需要引用资料
|
||||
- 禁写项
|
||||
- 补充说明
|
||||
|
||||
### `WritingWorkflowTimeline.tsx`
|
||||
|
||||
负责显示阶段:
|
||||
|
||||
- 研究中
|
||||
- 提纲生成中
|
||||
- 等待确认提纲
|
||||
- 正在写初稿
|
||||
- 正在审稿
|
||||
- 正在重写
|
||||
- 已完成
|
||||
|
||||
### `WritingWorkflowEventList.tsx`
|
||||
|
||||
负责显示细粒度事件:
|
||||
|
||||
- 哪个 Agent 开始执行
|
||||
- 当前在处理哪一步
|
||||
- 当前产生了什么中间结果
|
||||
- 是否重试
|
||||
|
||||
### `WritingWorkflowArtifactDrawer.tsx`
|
||||
|
||||
负责展示中间产物:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑意见
|
||||
- 重写稿
|
||||
|
||||
### `WritingWorkflowConfirmCard.tsx`
|
||||
|
||||
负责展示用户确认点:
|
||||
|
||||
- 确认提纲
|
||||
- 驳回提纲并附带修改意见
|
||||
- 继续写稿
|
||||
- 指定重写章节
|
||||
|
||||
---
|
||||
|
||||
## 6. Context 与 Hook 改造建议
|
||||
|
||||
## 6.1 `CanvasGraphContext`
|
||||
|
||||
当前它只维护:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `streamingArtifact`
|
||||
- `isStreaming`
|
||||
|
||||
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
|
||||
- 新增 `WritingWorkflowContext`
|
||||
|
||||
## 6.2 `WritingWorkflowContext`
|
||||
|
||||
建议承载:
|
||||
|
||||
- 当前 workflow 运行状态
|
||||
- 中间产物列表
|
||||
- 待确认操作
|
||||
- workflow 事件缓存
|
||||
- 启动 / 取消 / 确认 / 重写等 action
|
||||
|
||||
## 6.3 `useCanvasThreadStream`
|
||||
|
||||
当前这个 hook 已经负责:
|
||||
|
||||
- 创建 thread
|
||||
- 提交消息
|
||||
- 接收 thread stream
|
||||
- 将 `update_artifact` 同步到右侧
|
||||
|
||||
建议不要把“多 Agent workflow 状态机”也硬塞进去。
|
||||
|
||||
推荐方案:
|
||||
|
||||
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
|
||||
- 新增 `useWritingWorkflow`
|
||||
- 新增 `useWritingWorkflowEvents`
|
||||
|
||||
分工如下:
|
||||
|
||||
- `useCanvasThreadStream`:普通对话流
|
||||
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
|
||||
- `useWritingWorkflowEvents`:订阅 workflow 事件流
|
||||
|
||||
---
|
||||
|
||||
## 7. API 接入建议
|
||||
|
||||
## 7.1 前端 API 文件
|
||||
|
||||
新增:
|
||||
|
||||
- `workflow/api/writing-workflow.ts`
|
||||
|
||||
建议暴露方法:
|
||||
|
||||
- `createWorkflow(payload)`
|
||||
- `startWorkflow(id)`
|
||||
- `getWorkflow(id)`
|
||||
- `subscribeWorkflowEvents(id, handlers)`
|
||||
- `confirmOutline(id, payload)`
|
||||
- `rewriteSection(id, payload)`
|
||||
- `approveFinal(id)`
|
||||
|
||||
## 7.2 事件流形式
|
||||
|
||||
优先级建议:
|
||||
|
||||
1. `SSE`
|
||||
2. 流式 HTTP chunk
|
||||
3. 轮询兜底
|
||||
|
||||
理由:
|
||||
|
||||
- 事件驱动比整段消息流更适合 workflow UI
|
||||
- 可以更方便映射到时间线和事件列表
|
||||
|
||||
---
|
||||
|
||||
## 8. 与右侧 Artifact 的衔接
|
||||
|
||||
## 8.1 现有优势
|
||||
|
||||
当前 `CanvasArtifactPanel` 已具备:
|
||||
|
||||
- 流式展示内容
|
||||
- 最终 artifact 渲染
|
||||
- 右侧编辑体验成熟
|
||||
|
||||
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
|
||||
|
||||
## 8.2 建议接法
|
||||
|
||||
工作流各阶段结果按两类处理:
|
||||
|
||||
- 中间产物:进入左侧 `workflowArtifacts`
|
||||
- 可阅读正文:同步一份到右侧 `artifact`
|
||||
|
||||
推荐映射:
|
||||
|
||||
- 提纲:可选同步到右侧,供用户预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖或新增版本到右侧
|
||||
- 终稿:作为最终右侧主版本
|
||||
|
||||
## 8.3 版本策略
|
||||
|
||||
建议右侧版本保留:
|
||||
|
||||
- `outline`
|
||||
- `draft_v1`
|
||||
- `draft_v2`
|
||||
- `final`
|
||||
|
||||
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面交互流程
|
||||
|
||||
## 9.1 用户首次进入
|
||||
|
||||
- 默认仍是现有 chat 模式
|
||||
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
|
||||
|
||||
## 9.2 启动写作工作流
|
||||
|
||||
- 用户填写写作任务表单
|
||||
- 点击“开始生成”
|
||||
- 左侧切换到时间线 + 事件流视图
|
||||
- 右侧进入 loading 或素材 / 提纲预览态
|
||||
|
||||
## 9.3 等待提纲确认
|
||||
|
||||
- 时间线停在 `await_outline_confirmation`
|
||||
- 左侧出现确认卡片
|
||||
- 用户可执行:
|
||||
- 通过提纲
|
||||
- 要求调整提纲
|
||||
- 追加要求
|
||||
|
||||
## 9.4 生成初稿与审稿
|
||||
|
||||
- 左侧显示 Agent 事件
|
||||
- 右侧展示初稿内容
|
||||
- 审稿意见在左侧展示,同时高亮需要重写的章节
|
||||
|
||||
## 9.5 完成
|
||||
|
||||
- 左侧显示“终稿已完成”
|
||||
- 右侧进入可继续局部 AI 编辑状态
|
||||
|
||||
---
|
||||
|
||||
## 10. 分步开发清单
|
||||
|
||||
## Step 1:类型与假数据
|
||||
|
||||
目标:
|
||||
|
||||
- 先把 workflow 的类型、mock 数据、组件骨架搭好
|
||||
|
||||
任务:
|
||||
|
||||
- 新建 `workflow/types/workflow.ts`
|
||||
- 新建 `workflow/components/*`
|
||||
- 用本地 mock 事件驱动时间线和事件流
|
||||
|
||||
验收:
|
||||
|
||||
- 不接后端也能在页面中演示完整流程
|
||||
|
||||
## Step 2:模式切换
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
|
||||
|
||||
任务:
|
||||
|
||||
- 拆出 `CanvasWorkflowPanel`
|
||||
- 增加模式切换 UI
|
||||
- 保证旧 chat 不回归
|
||||
|
||||
验收:
|
||||
|
||||
- 现有功能不受影响
|
||||
- 新模式可进入表单页
|
||||
|
||||
## Step 3:接入 workflow API
|
||||
|
||||
目标:
|
||||
|
||||
- 从 mock 切到真实 API
|
||||
|
||||
任务:
|
||||
|
||||
- 接 `create/start/get/events`
|
||||
- 实现基础错误态和重试态
|
||||
|
||||
验收:
|
||||
|
||||
- 前后端可联调跑一条主链路
|
||||
|
||||
## Step 4:接入确认节点
|
||||
|
||||
目标:
|
||||
|
||||
- 支持提纲确认和指定章节重写
|
||||
|
||||
任务:
|
||||
|
||||
- 做确认卡片
|
||||
- 做动作回传
|
||||
|
||||
验收:
|
||||
|
||||
- 用户能在流程中介入
|
||||
|
||||
## Step 5:打通右侧版本
|
||||
|
||||
目标:
|
||||
|
||||
- 让 workflow 产物稳定进入右侧编辑器
|
||||
|
||||
任务:
|
||||
|
||||
- 约定 artifact 映射
|
||||
- 接入版本切换或当前版本覆盖
|
||||
|
||||
验收:
|
||||
|
||||
- 终稿稳定显示在右侧
|
||||
|
||||
---
|
||||
|
||||
## 11. 建议修改点位
|
||||
|
||||
优先涉及文件:
|
||||
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
建议新增,不建议重写原有:
|
||||
|
||||
- `open-canvas/workflow/api/writing-workflow.ts`
|
||||
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
|
||||
- `open-canvas/workflow/components/*`
|
||||
|
||||
---
|
||||
|
||||
## 12. 联调约定
|
||||
|
||||
前后端需要尽早统一:
|
||||
|
||||
- workflow id 命名
|
||||
- stage 枚举
|
||||
- event type 枚举
|
||||
- artifact type 枚举
|
||||
- user action payload
|
||||
- 错误码与可恢复动作
|
||||
|
||||
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
|
||||
|
||||
---
|
||||
|
||||
## 13. 最终建议
|
||||
|
||||
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
|
||||
|
||||
第一版建议先保证三件事:
|
||||
|
||||
1. 左侧能看见阶段进展
|
||||
2. 用户能确认提纲
|
||||
3. 右侧能稳定收到初稿和终稿
|
||||
|
||||
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
|
||||
@@ -1,718 +0,0 @@
|
||||
# AI 写作弱模型多 Agent 编排需求文档
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
|
||||
|
||||
- 结构松散,长文容易跑题
|
||||
- 素材不足,内容空泛
|
||||
- 事实依据弱,容易幻觉
|
||||
- 润色能力有限,语言风格不统一
|
||||
- 一次生成失败后缺少可控的纠偏机制
|
||||
|
||||
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
|
||||
|
||||
建议的角色分工:
|
||||
|
||||
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
|
||||
- `Agent B 作家`:基于选题、素材和结构写初稿
|
||||
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
|
||||
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次建设边界
|
||||
|
||||
### 2.1 前端边界
|
||||
|
||||
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
|
||||
|
||||
当前相关入口:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
前端本期只做以下增强:
|
||||
|
||||
- 在左侧聊天区增加“写作工作流模式”
|
||||
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
|
||||
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
|
||||
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
|
||||
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
|
||||
|
||||
### 2.2 后端边界
|
||||
|
||||
后端必须单开新路线,不影响其他同事当前功能开发。
|
||||
|
||||
建议原则:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把多 Agent 逻辑硬塞进当前通用对话链路
|
||||
- 以独立服务或独立模块形式建设
|
||||
- 通过清晰 API 与当前前端页面对接
|
||||
|
||||
推荐落地位置:
|
||||
|
||||
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
|
||||
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么多 Agent 对弱模型更合适
|
||||
|
||||
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
|
||||
|
||||
- 选题理解
|
||||
- 信息检索
|
||||
- 信息筛选
|
||||
- 结构规划
|
||||
- 长文写作
|
||||
- 自我校验
|
||||
- 风格润色
|
||||
|
||||
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
|
||||
|
||||
- 降低单次上下文复杂度
|
||||
- 降低一次性长输出失败率
|
||||
- 把“写得不好”变成“某一步做得不好”,便于重试
|
||||
- 可以在关键节点插入规则校验与人工确认
|
||||
- 可以用程序补足模型短板,而不是完全依赖模型智力
|
||||
|
||||
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 可借鉴的开源项目
|
||||
|
||||
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
|
||||
|
||||
### 4.1 第一优先级:LangGraph
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 天然适合把写作流程建模成状态图
|
||||
- 支持多步骤、条件分支、重试、人工介入
|
||||
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
|
||||
- 对“弱模型 + 强流程控制”很友好
|
||||
|
||||
可借鉴点:
|
||||
|
||||
- `StateGraph` 的状态驱动编排
|
||||
- 每一步单独定义输入、输出、失败处理
|
||||
- 可视化地表达“通过 / 打回 / 重试 / 终止”
|
||||
|
||||
参考:
|
||||
|
||||
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
|
||||
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
|
||||
### 4.2 第二优先级:STORM
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
|
||||
- 对“先研究后写作”的思路非常契合
|
||||
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
|
||||
|
||||
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
|
||||
|
||||
这和我们的目标高度一致,尤其适合借鉴:
|
||||
|
||||
- 提纲先行
|
||||
- 素材引用先行
|
||||
- 分模块写作
|
||||
- 后置润色
|
||||
|
||||
参考:
|
||||
|
||||
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
|
||||
|
||||
### 4.3 第三优先级:Deep Research Agent
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 明确支持本地模型与云模型混用
|
||||
- 已经包含研究、写作、质量校验、缓存、重试等能力
|
||||
- 很适合借鉴“研究阶段如何约束弱模型”
|
||||
|
||||
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
|
||||
|
||||
特别适合借鉴:
|
||||
|
||||
- 素材缓存
|
||||
- 分阶段质量评分
|
||||
- 节点失败重试
|
||||
- 研究结果结构化存储
|
||||
|
||||
参考:
|
||||
|
||||
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
|
||||
|
||||
### 4.4 可选借鉴:CrewAI
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 角色扮演表达自然
|
||||
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
|
||||
- Flow + Crew 的组织方式适合做 demo 和快速验证
|
||||
|
||||
不建议作为首选底座的原因:
|
||||
|
||||
- 更偏“Agent 角色编排体验”
|
||||
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
|
||||
|
||||
适合借鉴:
|
||||
|
||||
- 角色定义写法
|
||||
- Task 模板
|
||||
- 多角色协作 prompt 结构
|
||||
|
||||
参考:
|
||||
|
||||
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
|
||||
|
||||
### 4.5 不建议新项目优先采用:AutoGen
|
||||
|
||||
原因:
|
||||
|
||||
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
|
||||
- 官方建议新项目优先采用 `Microsoft Agent Framework`
|
||||
|
||||
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
|
||||
|
||||
参考:
|
||||
|
||||
- [microsoft/autogen](https://github.com/microsoft/autogen)
|
||||
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
|
||||
|
||||
### 4.6 结论
|
||||
|
||||
综合本项目需求,建议技术选型优先级如下:
|
||||
|
||||
1. `LangGraph` 作为主编排框架
|
||||
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
|
||||
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
|
||||
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
|
||||
|
||||
---
|
||||
|
||||
## 5. 目标产品形态
|
||||
|
||||
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
|
||||
|
||||
理想交互流程:
|
||||
|
||||
1. 用户输入主题、用途、风格、读者、篇幅要求
|
||||
2. 系统先进入“研究阶段”
|
||||
3. 系统给出资料卡片与候选提纲
|
||||
4. 用户确认提纲或调整
|
||||
5. 系统生成初稿
|
||||
6. 严苛编辑给出问题清单
|
||||
7. 系统自动重写问题段落
|
||||
8. 系统输出终稿到右侧编辑区
|
||||
9. 用户可继续局部改写、扩写、精简
|
||||
|
||||
核心体验目标:
|
||||
|
||||
- 左侧像“编辑部工作台”
|
||||
- 右侧像“成文编辑器”
|
||||
- 用户知道系统现在进行到哪一步
|
||||
- 用户可以在关键节点接管,而不是只能等待最终结果
|
||||
|
||||
---
|
||||
|
||||
## 6. 多 Agent 流程设计
|
||||
|
||||
## 6.1 建议的最小可用流程 MVP
|
||||
|
||||
第一版不要一上来做很复杂,建议先做 4 步闭环:
|
||||
|
||||
1. `Research`
|
||||
- 输入:主题、用户要求、内网资料源
|
||||
- 输出:素材卡片列表、可引用事实列表、风险点列表
|
||||
|
||||
2. `Outline`
|
||||
- 输入:用户要求 + 素材卡片
|
||||
- 输出:结构化提纲
|
||||
|
||||
3. `Draft`
|
||||
- 输入:提纲 + 素材卡片
|
||||
- 输出:初稿
|
||||
|
||||
4. `ReviewAndRewrite`
|
||||
- 输入:初稿 + 用户要求 + 素材卡片
|
||||
- 输出:问题清单、改写稿、终稿
|
||||
|
||||
MVP 终止条件:
|
||||
|
||||
- 编辑评分达标则结束
|
||||
- 不达标最多重写 2 次
|
||||
- 超过次数则返回“需要人工介入”
|
||||
|
||||
## 6.2 第二阶段增强流程
|
||||
|
||||
在 MVP 跑通后,再增加:
|
||||
|
||||
- `Planner`:根据文章类型动态规划流程
|
||||
- `SectionWriter`:按章节分别写作,避免长文崩坏
|
||||
- `FactChecker`:逐段核对素材来源
|
||||
- `StyleEditor`:统一语气、标题风格、句式节奏
|
||||
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
|
||||
|
||||
## 6.3 建议的状态机
|
||||
|
||||
建议将流程状态固定为:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
这样前端和后端都可以围绕统一状态通信。
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent 角色定义建议
|
||||
|
||||
## 7.1 Agent A 研究员
|
||||
|
||||
职责:
|
||||
|
||||
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
|
||||
- 输出结构化素材,而不是长篇自然语言
|
||||
- 对每条素材标注来源、可信度、适用角度
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `facts[]`
|
||||
- `quotes[]`
|
||||
- `examples[]`
|
||||
- `risks[]`
|
||||
- `source_refs[]`
|
||||
|
||||
约束建议:
|
||||
|
||||
- 禁止直接生成整篇文章
|
||||
- 不做风格化表达
|
||||
- 每条结论必须可回溯到来源
|
||||
|
||||
## 7.2 Agent B 作家
|
||||
|
||||
职责:
|
||||
|
||||
- 严格基于提纲和素材写稿
|
||||
- 按章节写,而不是一次写完整篇
|
||||
- 对每段附带素材引用 ID,便于后续审核
|
||||
|
||||
约束建议:
|
||||
|
||||
- 无素材不发挥
|
||||
- 每段只服务一个小节目标
|
||||
- 段落长度控制
|
||||
- 输出结构化草稿数据,而不仅是纯文本
|
||||
|
||||
## 7.3 Agent C 严苛编辑
|
||||
|
||||
职责:
|
||||
|
||||
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
|
||||
- 输出明确的“问题清单”和“改写指令”
|
||||
- 必要时打回指定章节,而不是整篇推翻
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `issues[]`
|
||||
- `severity`
|
||||
- `rewrite_instructions[]`
|
||||
- `accept/reject`
|
||||
|
||||
编辑原则建议:
|
||||
|
||||
- 优先挑事实问题,再挑结构问题,最后才是文风问题
|
||||
- 问题描述必须可执行,不能只说“写得不够好”
|
||||
|
||||
## 7.4 Supervisor
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程启动、状态流转、失败重试、记录日志
|
||||
- 判断是否需要回到上一步
|
||||
- 控制最大重试次数
|
||||
- 对外提供统一 API 与流式事件
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端需求
|
||||
|
||||
## 8.1 保持当前页面结构不变
|
||||
|
||||
继续沿用当前左右布局:
|
||||
|
||||
- 左侧:聊天 + 流程进度 + 中间产物
|
||||
- 右侧:最终文章编辑与局部 AI 改写
|
||||
|
||||
不建议再复制一套新页面,以免造成维护分叉。
|
||||
|
||||
## 8.2 左侧新增 UI 模块
|
||||
|
||||
建议在 `CanvasChatPanel` 基础上新增:
|
||||
|
||||
- 写作任务表单
|
||||
- 工作流阶段时间线
|
||||
- Agent 执行日志面板
|
||||
- 中间产物抽屉
|
||||
- 人工确认卡片
|
||||
|
||||
建议新增的前端概念:
|
||||
|
||||
- `workflowMode`: `chat | writing_pipeline`
|
||||
- `workflowStage`
|
||||
- `workflowRunId`
|
||||
- `workflowEvents[]`
|
||||
- `workflowArtifacts[]`
|
||||
|
||||
## 8.3 需要展示的中间产物
|
||||
|
||||
至少支持展示:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑问题单
|
||||
- 重写结果
|
||||
- 最终定稿
|
||||
|
||||
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
|
||||
|
||||
## 8.4 人工介入节点
|
||||
|
||||
至少提供两个确认点:
|
||||
|
||||
- 提纲确认
|
||||
- 初稿确认或编辑后重跑
|
||||
|
||||
后续可扩展:
|
||||
|
||||
- 指定重写某个章节
|
||||
- 锁定某段不允许再改
|
||||
- 替换某条素材后重新生成
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端需求
|
||||
|
||||
## 9.1 架构原则
|
||||
|
||||
后端新路线必须满足:
|
||||
|
||||
- 与现有通用对话链路解耦
|
||||
- 与现有 Agent 运行时解耦
|
||||
- 独立维护 Prompt、状态机、数据结构
|
||||
- 可以单独部署、单独回滚、单独压测
|
||||
|
||||
## 9.2 推荐模块划分
|
||||
|
||||
建议后端拆分为以下模块:
|
||||
|
||||
- `workflow_orchestrator`
|
||||
- `agent_registry`
|
||||
- `material_retriever`
|
||||
- `outline_generator`
|
||||
- `draft_writer`
|
||||
- `editor_reviewer`
|
||||
- `rewrite_engine`
|
||||
- `workflow_store`
|
||||
- `stream_event_adapter`
|
||||
|
||||
## 9.3 API 建议
|
||||
|
||||
建议最小 API:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- 创建写作任务
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- 启动流程
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- 查询当前状态
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- 获取事件流或轮询事件
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- 用户确认提纲
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- 指定章节重写
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
- 确认终稿
|
||||
|
||||
## 9.4 流式事件建议
|
||||
|
||||
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
前端只要订阅事件,就能把左侧工作台做出来。
|
||||
|
||||
---
|
||||
|
||||
## 10. 弱模型优化策略
|
||||
|
||||
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
|
||||
|
||||
## 10.1 强约束输出格式
|
||||
|
||||
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
|
||||
|
||||
原因:
|
||||
|
||||
- 便于程序消费
|
||||
- 便于后续 Agent 接续
|
||||
- 便于失败重试
|
||||
- 便于质量评估
|
||||
|
||||
## 10.2 长文拆成章节生成
|
||||
|
||||
不要让弱模型一次生成整篇长文,建议:
|
||||
|
||||
- 先出提纲
|
||||
- 再按章节逐段写
|
||||
- 最后统一润色
|
||||
|
||||
## 10.3 把检索和写作分离
|
||||
|
||||
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
|
||||
|
||||
## 10.4 程序兜底而不是模型兜底
|
||||
|
||||
程序需要承担:
|
||||
|
||||
- 去重
|
||||
- 引用格式化
|
||||
- 章节拼接
|
||||
- 长度控制
|
||||
- 评分阈值判断
|
||||
- 重试路由
|
||||
|
||||
## 10.5 小步快跑、失败可回退
|
||||
|
||||
每步都要能独立失败、独立重试、独立回放。
|
||||
|
||||
## 10.6 引入可量化评分
|
||||
|
||||
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
|
||||
|
||||
- 相关性
|
||||
- 事实性
|
||||
- 结构完整度
|
||||
- 语言流畅度
|
||||
- 风格一致性
|
||||
|
||||
系统据此决定是否进入下一步。
|
||||
|
||||
---
|
||||
|
||||
## 11. 分阶段实施计划
|
||||
|
||||
## Phase 1:需求冻结与技术验证
|
||||
|
||||
目标:
|
||||
|
||||
- 确认文章类型范围
|
||||
- 确认内网资料来源
|
||||
- 选定主框架
|
||||
- 跑通最小多 Agent demo
|
||||
|
||||
交付物:
|
||||
|
||||
- 技术选型说明
|
||||
- 角色与状态机设计
|
||||
- 1 个主题的端到端 demo
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
|
||||
|
||||
## Phase 2:后端新路线搭建
|
||||
|
||||
目标:
|
||||
|
||||
- 建立独立 orchestrator
|
||||
- 建立 workflow state store
|
||||
- 建立事件流输出
|
||||
- 建立可配置 prompt 模板
|
||||
|
||||
交付物:
|
||||
|
||||
- 独立服务目录
|
||||
- 基础 API
|
||||
- 状态机运行骨架
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不依赖现有主流程改造即可独立跑通
|
||||
|
||||
## Phase 3:前端接入当前页面
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 接入工作流模式
|
||||
- 增加阶段展示与人工确认
|
||||
- 中间产物可视化
|
||||
|
||||
交付物:
|
||||
|
||||
- 左侧工作流时间线
|
||||
- 中间产物展示面板
|
||||
- 流程控制按钮
|
||||
|
||||
验收标准:
|
||||
|
||||
- 用户可以在当前页面完成一次全流程写作
|
||||
|
||||
## Phase 4:质量优化
|
||||
|
||||
目标:
|
||||
|
||||
- 强化素材检索质量
|
||||
- 提高提纲质量
|
||||
- 引入章节级重写
|
||||
- 引入评分与回退机制
|
||||
|
||||
交付物:
|
||||
|
||||
- 质量评估规则
|
||||
- 重试策略
|
||||
- Prompt 调优包
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型效果明显优于单轮直出
|
||||
|
||||
## Phase 5:生产化
|
||||
|
||||
目标:
|
||||
|
||||
- 加缓存
|
||||
- 加日志
|
||||
- 加监控
|
||||
- 加失败告警
|
||||
- 加审计记录
|
||||
|
||||
交付物:
|
||||
|
||||
- 可观测性方案
|
||||
- 运行日志与回放
|
||||
- 配置中心或 prompt 管理机制
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可长期维护并可定位问题
|
||||
|
||||
---
|
||||
|
||||
## 12. 团队协作与隔离方案
|
||||
|
||||
为了不影响其他同事,建议采用以下协作原则:
|
||||
|
||||
- 新后端目录独立开发
|
||||
- 新接口使用独立路由前缀
|
||||
- 前端只在 `open-canvas` 内增量开发
|
||||
- 不修改现有公共线程协议,必要时增加新事件类型
|
||||
- Prompt、Agent 配置、评分规则独立存放
|
||||
|
||||
推荐分工:
|
||||
|
||||
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
|
||||
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
|
||||
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
|
||||
|
||||
---
|
||||
|
||||
## 13. 风险与规避
|
||||
|
||||
### 风险 1:Agent 太多,成本和耗时暴涨
|
||||
|
||||
规避:
|
||||
|
||||
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
|
||||
- 长文按章节写,失败只重写局部
|
||||
|
||||
### 风险 2:弱模型在自由对话场景中失控
|
||||
|
||||
规避:
|
||||
|
||||
- 全流程结构化
|
||||
- 每步只做一件事
|
||||
- 强约束输入输出格式
|
||||
|
||||
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
|
||||
|
||||
规避:
|
||||
|
||||
- 先把资料源治理好
|
||||
- 对素材做来源、可信度、去重处理
|
||||
|
||||
### 风险 4:前后端协议频繁变化影响联调
|
||||
|
||||
规避:
|
||||
|
||||
- 先定义事件模型和 artifact 模型
|
||||
- 先 mock 数据,再并行开发
|
||||
|
||||
### 风险 5:影响现有功能稳定性
|
||||
|
||||
规避:
|
||||
|
||||
- 新路线、新接口、新状态机
|
||||
- 不侵入现有主链路
|
||||
|
||||
---
|
||||
|
||||
## 14. 推荐结论
|
||||
|
||||
本项目推荐的总体策略:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
|
||||
3. 主框架优先采用 `LangGraph`
|
||||
4. 流程设计借鉴 `STORM`
|
||||
5. 质量控制借鉴 `Deep Research Agent`
|
||||
6. 角色设计与任务模板可参考 `CrewAI`
|
||||
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
|
||||
|
||||
一句话总结:
|
||||
|
||||
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
|
||||
|
||||
---
|
||||
|
||||
## 15. 参考链接
|
||||
|
||||
- [LangGraph](https://github.com/langchain-ai/langgraph)
|
||||
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
- [STORM](https://github.com/stanford-oval/storm)
|
||||
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
|
||||
- [CrewAI](https://github.com/crewAIInc/crewAI)
|
||||
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
|
||||
- [AutoGen](https://github.com/microsoft/autogen)
|
||||
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -1,140 +0,0 @@
|
||||
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
当前 `frontend-web/src/App.tsx` 同时承载了:
|
||||
|
||||
1. 登录/首页相关路由(如 `/login`、`/landing`)
|
||||
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
|
||||
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
|
||||
|
||||
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标(你提出的核心诉求)
|
||||
|
||||
你希望:
|
||||
|
||||
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
|
||||
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
|
||||
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
|
||||
|
||||
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构范围
|
||||
|
||||
### 1)保留在 `App.tsx` 的内容
|
||||
|
||||
- 全局 Provider:`HashRouter`、`AppProviders`
|
||||
- 登录/首页路由:
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/login/:username`
|
||||
- `/landing`
|
||||
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
|
||||
|
||||
### 2)迁移出 `App.tsx` 的内容
|
||||
|
||||
将以下与工作台有关的路由从 `App.tsx` 抽离:
|
||||
|
||||
- `/workspace`(重定向)
|
||||
- `/workspace/chats`
|
||||
- `/workspace/chats/:thread_id`
|
||||
- `/workspace/agents`
|
||||
- `/workspace/agents/new`
|
||||
- `/workspace/agents/:agent_id/chats`(重定向)
|
||||
- `/workspace/agents/:agent_id/chats/:thread_id`
|
||||
- `/workspace/scheduled`
|
||||
- `/workspace/scheduled/tasks`
|
||||
- `/workspace/scheduled/runs/:run_id`
|
||||
|
||||
以及配套的包装组件职责:
|
||||
|
||||
- `WorkspaceLayout`
|
||||
- `ChatRuntime`
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的新结构(方案)
|
||||
|
||||
## 方案 A(推荐):新增“业务路由容器组件”
|
||||
|
||||
在 `pages` 下新增一个路由容器组件,例如:
|
||||
|
||||
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
|
||||
|
||||
职责:
|
||||
- 专门维护 `/workspace/*` 相关路由
|
||||
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
|
||||
- 输出一个可被 `App.tsx` 挂载的路由节点
|
||||
|
||||
同时在 `App.tsx` 里只保留:
|
||||
- 登录/首页/404
|
||||
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
1. **路由容器页(推荐)**:
|
||||
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
|
||||
- 即上面的 `WorkspaceRoutes.tsx`。
|
||||
|
||||
2. **可见的导航页(可选)**:
|
||||
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
|
||||
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
|
||||
|
||||
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施步骤(开发任务拆解)
|
||||
|
||||
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
|
||||
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
|
||||
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
|
||||
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
|
||||
5. 校验以下访问路径行为一致:
|
||||
- `/workspace` 重定向
|
||||
- chats/agents/scheduled 各页面可正常进入
|
||||
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
|
||||
- 404 不受影响
|
||||
6. 运行前端并做手工回归。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
|
||||
2. `/workspace/*` 全部由新建组件统一管理。
|
||||
3. 功能行为与改造前一致(或符合新定义)。
|
||||
4. 代码可读性提升:登录入口与业务入口职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
|
||||
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
|
||||
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
|
||||
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议的命名(可直接采用)
|
||||
|
||||
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
|
||||
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 十、后续可选优化
|
||||
|
||||
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
|
||||
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
|
||||
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
|
||||
@@ -0,0 +1,265 @@
|
||||
# 多智能体协调 API 接口文档
|
||||
|
||||
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
|
||||
路由前缀:`/api/multi-agent`
|
||||
|
||||
## 概览
|
||||
|
||||
提供一个"总控 + 多个子智能体"的协调编排接口。
|
||||
|
||||
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
|
||||
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
|
||||
|
||||
调用方按下面顺序使用:
|
||||
|
||||
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
|
||||
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
|
||||
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
|
||||
|
||||
|
||||
## 1. 初始化多智能体集群
|
||||
|
||||
### `POST /api/multi-agent/init`
|
||||
|
||||
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
|
||||
"main_agent_name": "main-controller",
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
|
||||
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
|
||||
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
|
||||
|
||||
#### 响应(200)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"main_agent_name": "main-controller",
|
||||
"thread_ids": {
|
||||
"main-controller": "thread-uuid-xxx",
|
||||
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
|
||||
"us-intelligence-gathering-agent": "thread-uuid-zzz"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
|
||||
|
||||
#### 错误
|
||||
|
||||
| 状态码 | 触发条件 |
|
||||
|---|---|
|
||||
| `400` | `agent_names` 为空 |
|
||||
| `401` | 未携带 `Authorization` 头 |
|
||||
| `500` | 协调智能体创建失败且不是"已存在"错误 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 流式调度
|
||||
|
||||
### `POST /api/multi-agent/run/stream`
|
||||
|
||||
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_type": "leader",
|
||||
"d_agent_thread_id": {
|
||||
"main-controller": "thread-uuid-xxx",
|
||||
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
|
||||
},
|
||||
"agent_name": "main-controller",
|
||||
"new_message": "请分析特朗普的支持率",
|
||||
"skill_stop_names": [],
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
|
||||
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
|
||||
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
|
||||
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
|
||||
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
|
||||
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
|
||||
|
||||
#### 响应
|
||||
|
||||
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
|
||||
|
||||
##### a. 透传上游事件
|
||||
|
||||
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
|
||||
|
||||
##### b. 最后一帧:调度状态
|
||||
|
||||
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
|
||||
|
||||
**`agent_type == "leader"`:**
|
||||
|
||||
```
|
||||
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
|
||||
```
|
||||
|
||||
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
|
||||
|
||||
**`agent_type == "special"`:**
|
||||
|
||||
```
|
||||
data: {"status": "子智能体xxx完成yyy工作"}
|
||||
```
|
||||
|
||||
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
|
||||
|
||||
#### 副作用(重要)
|
||||
|
||||
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
|
||||
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
|
||||
|
||||
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
|
||||
|
||||
#### 错误
|
||||
|
||||
| 状态码 | 触发条件 |
|
||||
|---|---|
|
||||
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
|
||||
| `401` | 未携带 `Authorization` 头 |
|
||||
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端调用流程示例
|
||||
|
||||
```ts
|
||||
// 1. 拿 token
|
||||
const loginResp = await fetch("/api/v1/auth/login/username", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "yourname" }),
|
||||
});
|
||||
const { access_token } = await loginResp.json();
|
||||
const authHeaders = {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// 2. 初始化
|
||||
const initResp = await fetch("/api/multi-agent/init", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_names: [
|
||||
"taiwan-intelligence-gathering-agent",
|
||||
"us-intelligence-gathering-agent",
|
||||
],
|
||||
main_agent_name: "main-controller",
|
||||
model: "deepseek-chat",
|
||||
}),
|
||||
});
|
||||
const { thread_ids } = await initResp.json();
|
||||
|
||||
// 3. 流式调用 leader
|
||||
async function streamLeader(message: string, model = "deepseek-chat") {
|
||||
const resp = await fetch("/api/multi-agent/run/stream", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_type: "leader",
|
||||
d_agent_thread_id: thread_ids,
|
||||
agent_name: "main-controller",
|
||||
new_message: message,
|
||||
skill_stop_names: [],
|
||||
model,
|
||||
}),
|
||||
});
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let dispatched: [string, string][] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
try {
|
||||
const payload = JSON.parse(line.slice(5).trim());
|
||||
if (Array.isArray(payload.status)) {
|
||||
dispatched = payload.status;
|
||||
}
|
||||
// 其余事件按 LangGraph SDK 协议自行渲染
|
||||
} catch {
|
||||
/* 非 JSON 帧,可忽略或交给现有渲染器 */
|
||||
}
|
||||
}
|
||||
}
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
// 4. 对派活清单逐个调用 special
|
||||
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
|
||||
return fetch("/api/multi-agent/run/stream", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_type: "special",
|
||||
d_agent_thread_id: thread_ids,
|
||||
agent_name: agentName,
|
||||
new_message: task,
|
||||
skill_stop_names: [],
|
||||
model,
|
||||
}),
|
||||
});
|
||||
// 同样按 SSE 解析
|
||||
}
|
||||
|
||||
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
|
||||
const dispatched = await streamLeader("请分析特朗普的支持率");
|
||||
for (const [agentName, task] of dispatched) {
|
||||
await streamSpecial(agentName, task);
|
||||
}
|
||||
const next = await streamLeader("根据上面收集到的信息出最终结论");
|
||||
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 注意事项
|
||||
|
||||
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
|
||||
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
|
||||
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
|
||||
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
|
||||
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
|
||||
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
|
||||
|
||||
---
|
||||
|
||||
## 5. 相关接口(底层依赖)
|
||||
|
||||
本接口内部转发以下 Gateway 原生接口,了解细节可查:
|
||||
|
||||
| 接口 | 路由 | 用途 |
|
||||
|---|---|---|
|
||||
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
|
||||
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
|
||||
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
|
||||
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
|
||||
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
|
||||
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,667 @@
|
||||
# 多智能体圆桌研讨 · 前端开发文档
|
||||
|
||||
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
|
||||
|
||||
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
|
||||
|
||||
---
|
||||
|
||||
## 0. 速读:你需要先知道的事
|
||||
|
||||
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。
|
||||
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
|
||||
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
|
||||
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
|
||||
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
|
||||
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
|
||||
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定位
|
||||
|
||||
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
|
||||
|
||||
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
|
||||
|
||||
**核心能力**:
|
||||
|
||||
| 步骤 | 用户目标 | 真实后端对接 |
|
||||
|------|----------|--------------|
|
||||
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
|
||||
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
|
||||
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
|
||||
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
|
||||
|
||||
**步骤门禁**:
|
||||
|
||||
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
|
||||
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/roundtable-planning/
|
||||
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
|
||||
├── pages/
|
||||
│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行)
|
||||
├── api/
|
||||
│ ├── intent.ts # Step 1:initIntent、streamIntent
|
||||
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
|
||||
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
|
||||
├── components/
|
||||
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
|
||||
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
|
||||
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
|
||||
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
|
||||
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
|
||||
├── hooks/
|
||||
│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
|
||||
├── lib/
|
||||
│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
|
||||
├── utils/
|
||||
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
|
||||
└── styles/
|
||||
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
|
||||
```
|
||||
|
||||
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
|
||||
|
||||
```tsx
|
||||
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
|
||||
```
|
||||
|
||||
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
|
||||
|
||||
**环境变量**(由 `scripts/start-frontend.sh` 注入):
|
||||
|
||||
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
|
||||
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
|
||||
|
||||
---
|
||||
|
||||
## 3. 三步数据流(前端视角)
|
||||
|
||||
```
|
||||
[进入 Step 1]
|
||||
useEffect → runStep1Init()
|
||||
initIntent(model) → { agent_id, thread_id }
|
||||
setIntentThreadId、setTimelineEntries
|
||||
interactiveMessages seed 欢迎语(本地 seed,非 API)
|
||||
|
||||
[用户发送 / 选项卡片提交]
|
||||
sendIntentMessage(text)
|
||||
追加 user 气泡 + agent 占位(streaming + phase)
|
||||
streamIntent({ onTextDelta, onPhaseChange, signal })
|
||||
终态:
|
||||
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
|
||||
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
|
||||
asking → 继续对话
|
||||
error → toast + 红色 (错误) 文案
|
||||
|
||||
[点击「进入多智能体研讨」且 intentReady]
|
||||
setIsRecommendDialogOpen(true)
|
||||
RecommendAgentsDialog
|
||||
listAgents() → filterRecommendCandidates()
|
||||
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
|
||||
用户勾选 2–8 个 → onConfirm(agentIds)
|
||||
handleConfirmAgents
|
||||
setSelectedAgents(agentIds.map(...))
|
||||
step2InitRef.current = false // 复位 Step 2 init 闸门
|
||||
setCurrentStep(2)
|
||||
|
||||
[进入 Step 2]
|
||||
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
|
||||
onSeatReady:右侧席位面板逐个「在线」
|
||||
onCoordinatorReady / init_done → threadIds + coordinatorName
|
||||
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
|
||||
while !cancelled && cycles<8:
|
||||
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
|
||||
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
|
||||
dispatched.length === 0 → hasConsensus = true,可进 Step 3
|
||||
|
||||
[进入 Step 3]
|
||||
HighFidelityReport
|
||||
consensusText ← lastLeaderContent
|
||||
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
|
||||
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
|
||||
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 层
|
||||
|
||||
### 4.1 `api/intent.ts` — Step 1
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
|
||||
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
|
||||
|
||||
**`StreamIntentRequest` 重要字段**:
|
||||
|
||||
- `threadId`:必填,跨多轮对话保持
|
||||
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
|
||||
- `onTextDelta(text, messageId)`:每个 AI 文本增量
|
||||
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
|
||||
|
||||
**`StreamIntentResult`**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
|
||||
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
|
||||
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
|
||||
| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 |
|
||||
|
||||
**SSE 解析要点**:
|
||||
|
||||
- `event: metadata` → phase `connected`
|
||||
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
|
||||
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
|
||||
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`)
|
||||
|
||||
**Abort 行为**:
|
||||
|
||||
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
|
||||
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
|
||||
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
|
||||
|
||||
### 4.2 `api/recommend.ts` — 推荐弹窗
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
|
||||
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
|
||||
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
|
||||
|
||||
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
|
||||
|
||||
- `roundtable-intent`
|
||||
- `roundtable-recommender`
|
||||
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
|
||||
|
||||
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
|
||||
|
||||
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
|
||||
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
|
||||
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
|
||||
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
|
||||
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
|
||||
|
||||
**终态语义**:
|
||||
|
||||
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
|
||||
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
|
||||
|
||||
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
|
||||
| `streamMultiAgent(req)` | leader / special 一轮 |
|
||||
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
|
||||
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
|
||||
|
||||
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
|
||||
|
||||
```ts
|
||||
{ event: "seat_ready", agent_name, thread_id, display_name, description }
|
||||
{ event: "coordinator_ready", main_agent_name, thread_id }
|
||||
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
|
||||
{ event: "error", detail }
|
||||
```
|
||||
|
||||
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
|
||||
|
||||
| `status` | 含义 | 前端处理 |
|
||||
|----------|------|----------|
|
||||
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
|
||||
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
|
||||
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
|
||||
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
|
||||
| `"error"` | 失败 | toast + 红色气泡 |
|
||||
|
||||
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
|
||||
|
||||
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
|
||||
|
||||
**调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
|
||||
```
|
||||
|
||||
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主页面状态(`RoundtablePlanningPage`)
|
||||
|
||||
### 5.1 步骤与门禁
|
||||
|
||||
- `currentStep`: `1 | 2 | 3`
|
||||
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
|
||||
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
|
||||
|
||||
### 5.2 Step 1 关键 state
|
||||
|
||||
| 状态 | 用途 |
|
||||
|------|------|
|
||||
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
|
||||
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
|
||||
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
|
||||
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
|
||||
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
|
||||
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
|
||||
| `intentAborterRef` | 当前 stream 的 AbortController |
|
||||
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
|
||||
| `step1InitRef` | 闸门:true 即认为已 init 过 |
|
||||
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
|
||||
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
|
||||
|
||||
**`step1InitGenRef` 的存在原因**:
|
||||
|
||||
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
|
||||
|
||||
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
|
||||
|
||||
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
|
||||
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
|
||||
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
|
||||
|
||||
### 5.3 推荐与 Step 2 席位
|
||||
|
||||
| 状态 | 用途 |
|
||||
|------|------|
|
||||
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
|
||||
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
|
||||
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
|
||||
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
|
||||
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
|
||||
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
|
||||
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
|
||||
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
|
||||
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
|
||||
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
|
||||
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
|
||||
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
|
||||
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
|
||||
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
|
||||
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
|
||||
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
|
||||
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
|
||||
|
||||
**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
|
||||
|
||||
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
|
||||
|
||||
伪代码:
|
||||
|
||||
```
|
||||
isOrchestrating = true
|
||||
orchestrationCancelRef = false
|
||||
nextLeaderTask = seedMessage
|
||||
cycles = 0
|
||||
MAX_CYCLES = 8
|
||||
|
||||
while !cancelled && cycles < MAX_CYCLES:
|
||||
cycles += 1
|
||||
highlight(speakerId=2) // 总控
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
|
||||
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
|
||||
|
||||
if res.clarification:
|
||||
finalize(leaderBubble, content, "等待用户澄清")
|
||||
setPendingClarification(res.clarification)
|
||||
break // 等用户回复后由 submitClarification 重启循环
|
||||
|
||||
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
|
||||
if res.content: setLastLeaderContent(res.content)
|
||||
|
||||
if res.dispatched.length === 0:
|
||||
setHasConsensus(true); setConsensusPercentage(100); break
|
||||
|
||||
for [subName, task] of res.dispatched: // **顺序**,不是并行
|
||||
meta = agentNameToRole[subName.toLowerCase()]
|
||||
markSpeakerStreaming(meta.speakerId)
|
||||
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
|
||||
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
|
||||
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
|
||||
finalize(subBubble, specRes.content, "子智能体交付")
|
||||
markSpeakerIdle(meta.speakerId)
|
||||
if cancelled: break
|
||||
|
||||
setConsensusPercentage(prev => min(95, prev + 8))
|
||||
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
|
||||
|
||||
if cycles >= MAX_CYCLES:
|
||||
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
|
||||
|
||||
clearAllStreamingSpeakers()
|
||||
isOrchestrating = false
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **`buildInitialIntent()`**:
|
||||
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
|
||||
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
|
||||
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
|
||||
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
|
||||
- **挂起 / 恢复**:
|
||||
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
|
||||
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
|
||||
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
|
||||
|
||||
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
|
||||
|
||||
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
|
||||
- 抽屉控件:
|
||||
- **System Prompt**:仅前端记录(后端覆写未开放)
|
||||
- **Temp**:仅前端记录
|
||||
- **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
|
||||
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
|
||||
- 把新的 `(seatId, model)` 写入 `agentConfigs`
|
||||
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
||||
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
|
||||
|
||||
### 5.6 全局模型选择器(右侧 sidebar 顶部)
|
||||
|
||||
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
|
||||
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
|
||||
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
|
||||
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
|
||||
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
|
||||
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
|
||||
|
||||
### 5.7 草稿持久化(`utils/drafts.ts`)
|
||||
|
||||
- Key:`roundtable-planning:drafts:v1`(`localStorage`)
|
||||
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
|
||||
- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
|
||||
- **加载草稿**:`handleLoadDraft`
|
||||
- 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
|
||||
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
|
||||
- `step1InitGenRef` 自增,淘汰可能晚归的旧 init
|
||||
- `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
|
||||
- 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件说明
|
||||
|
||||
### 6.1 `IntentClarificationCard`
|
||||
|
||||
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`
|
||||
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
|
||||
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
|
||||
- 仅选项 → `我选择:A、B`
|
||||
- 仅自定义 → 直接发自定义文本
|
||||
- 同时存在 → `我选择:A、B;补充:…`
|
||||
- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片)
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
- `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
|
||||
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
|
||||
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
|
||||
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
|
||||
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
|
||||
|
||||
### 6.3 `HighFidelityReport`(Step 3)
|
||||
|
||||
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
|
||||
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
|
||||
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
|
||||
- **文件预览策略**(`shouldPreviewInModal`):
|
||||
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
|
||||
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
|
||||
- 命中规则:
|
||||
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
|
||||
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
|
||||
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
|
||||
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
|
||||
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
|
||||
|
||||
### 6.4 `ArtifactPreviewModal`
|
||||
|
||||
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
|
||||
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
|
||||
- 其它文本类 → `<pre>` 直出
|
||||
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
|
||||
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
|
||||
|
||||
### 6.5 共享 UI 模式与辅助
|
||||
|
||||
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
|
||||
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
|
||||
- `connecting` → 「调度中」
|
||||
- `connected` → 「已连接,等待响应」
|
||||
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
|
||||
- `tool_result` → 「处理工具结果」
|
||||
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
|
||||
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
|
||||
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
|
||||
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
|
||||
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
|
||||
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
|
||||
|
||||
---
|
||||
|
||||
## 7. 澄清交互(与主聊天对齐)
|
||||
|
||||
| 能力 | 实现位置 |
|
||||
|------|----------|
|
||||
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
|
||||
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
|
||||
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
|
||||
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
|
||||
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
|
||||
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
|
||||
|
||||
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
|
||||
|
||||
**关键差异**:
|
||||
|
||||
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
|
||||
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与主聊天的差异
|
||||
|
||||
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|
||||
|------|-----------------------------------|----------|
|
||||
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
|
||||
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
|
||||
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
|
||||
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
|
||||
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
|
||||
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
|
||||
|
||||
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键交互清单
|
||||
|
||||
| 交互 | 行为 |
|
||||
|------|------|
|
||||
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
|
||||
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
|
||||
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
|
||||
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
|
||||
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
|
||||
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
|
||||
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
|
||||
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
|
||||
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
|
||||
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
|
||||
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 类型与常量速查
|
||||
|
||||
```ts
|
||||
// Step 1 输出 → Step 2 输入
|
||||
interface IntentSummary {
|
||||
objective: string;
|
||||
constraints: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
// Step 2 线程映射(init_done.thread_ids)
|
||||
type ThreadIdMap = Record<string, string>;
|
||||
|
||||
// 派活
|
||||
type LeaderDispatch = [string, string]; // [agent_name, task]
|
||||
|
||||
// 当前选中的研讨席位
|
||||
type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
// Step 1 单条澄清问题
|
||||
interface ClarificationPayload {
|
||||
question: string;
|
||||
clarificationType: string;
|
||||
context: string;
|
||||
options: unknown[];
|
||||
allowCustom: boolean;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
// Step 2 stream 终态
|
||||
interface StreamMultiAgentResult {
|
||||
dispatched: LeaderDispatch[];
|
||||
message: string | null;
|
||||
content: string;
|
||||
agentName: string | null;
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
```
|
||||
|
||||
**核心常量**:
|
||||
|
||||
| 常量 | 值 | 位置 |
|
||||
|------|----|------|
|
||||
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
|
||||
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
|
||||
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
|
||||
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
|
||||
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
|
||||
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
|
||||
| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
|
||||
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
|
||||
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
|
||||
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
|
||||
|
||||
---
|
||||
|
||||
## 11. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|------|----------|------|
|
||||
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
|
||||
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
|
||||
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
|
||||
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
|
||||
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
|
||||
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
|
||||
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
|
||||
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
|
||||
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
|
||||
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
|
||||
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
|
||||
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
|
||||
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
|
||||
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
|
||||
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
|
||||
|
||||
**本地调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 扩展指南
|
||||
|
||||
### 12.1 新增默认研讨席位
|
||||
|
||||
1. 后端 `.deer-flow/agents/roundtable-xxx/`
|
||||
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
|
||||
3. 无需改 API 层(`agent_names` 由推荐结果传入)
|
||||
|
||||
### 12.2 调整 Step 1 澄清轮次上限
|
||||
|
||||
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
|
||||
|
||||
### 12.3 调整 Step 2 编排上限
|
||||
|
||||
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
|
||||
|
||||
### 12.4 把 special 改回并行
|
||||
|
||||
不推荐;若一定要做:
|
||||
|
||||
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
|
||||
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
|
||||
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
|
||||
|
||||
### 12.5 接入真实 Persona 覆盖
|
||||
|
||||
当前 prompt / temp 只在前端记录;要落地需:
|
||||
|
||||
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
|
||||
- 前端:`agentConfigs[meta.speakerId]` 一并传入
|
||||
|
||||
### 12.6 拆分页面
|
||||
|
||||
当前刻意单文件维护三步。若拆分建议:
|
||||
|
||||
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
|
||||
- 保持 `api/*` 不变
|
||||
- 保持 `roundtable-planning.css` 主题变量
|
||||
|
||||
### 12.7 文档协同
|
||||
|
||||
`@/roundtable-planning/` 内一切公共 API 都应当:
|
||||
|
||||
1. 在 `api/*.ts` 文件级注释里说明 endpoint
|
||||
2. 在本页第 4 节 / 第 10 节同步对照
|
||||
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 13. 路径速查
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
|
||||
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
|
||||
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
|
||||
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
|
||||
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
|
||||
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
|
||||
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
|
||||
| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
|
||||
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
|
||||
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
|
||||
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,312 +0,0 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
|
||||
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
}
|
||||
|
||||
// core/messages/utils.ts
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ThinkTagParser(AI 改写思考过程解析)
|
||||
|
||||
后端支持两种思考格式:
|
||||
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
|
||||
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
|
||||
|
||||
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
|
||||
|
||||
### 3.4 版本历史:本地切换
|
||||
|
||||
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
|
||||
|
||||
### 3.5 Writing Editor 流程(三阶段状态机)
|
||||
|
||||
```
|
||||
idle → menu → preview → idle
|
||||
↑ ↓
|
||||
openAiMenu closeAll
|
||||
```
|
||||
|
||||
- **idle**:正常编辑
|
||||
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
|
||||
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
|
||||
|
||||
---
|
||||
|
||||
## 四、已解决的技术难题
|
||||
|
||||
| 问题 | 解法 |
|
||||
|------|------|
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
|
||||
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
|
||||
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
|
||||
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
|
||||
|
||||
---
|
||||
|
||||
## 五、当前已知限制(非 bug,设计取舍)
|
||||
|
||||
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
|
||||
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
|
||||
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
|
||||
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
|
||||
|
||||
---
|
||||
|
||||
## 六、快速开始下一次工作
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后端(从 offline-backend-20260512/backend/)
|
||||
make dev # 启动 Gateway API on port 8001
|
||||
|
||||
# 前端(从 frontend-web/)
|
||||
pnpm dev # 启动 Vite on port 5174
|
||||
```
|
||||
|
||||
### 访问路径
|
||||
|
||||
- 主页:`http://localhost:5174`
|
||||
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
cd frontend-web && pnpm typecheck
|
||||
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、如需继续开发的潜在方向
|
||||
|
||||
| 方向 | 描述 | 优先级 |
|
||||
|------|------|------|
|
||||
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
|
||||
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
|
||||
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
|
||||
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
|
||||
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
|
||||
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件速查
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
@@ -1,950 +0,0 @@
|
||||
# Open Canvas 模式需求拆解文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
|
||||
|
||||
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
|
||||
|
||||
- 前端:基于 `React 19 + Vite + react-router-dom`
|
||||
- 后端:基于 `Python + FastAPI + LangGraph`
|
||||
- 模型层:支持云模型与本地模型自由配置
|
||||
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
|
||||
|
||||
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终目标
|
||||
|
||||
### 2.1 用户视角目标
|
||||
|
||||
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
|
||||
|
||||
- 左侧是聊天区,右侧是 Canvas 编辑区
|
||||
- 可以从空白文本或代码模板快速开始
|
||||
- 可以通过聊天让 AI 生成一份结构化 artifact
|
||||
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
|
||||
- artifact 支持版本历史切换
|
||||
- artifact 支持文本和代码两种主要类型
|
||||
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
|
||||
- 支持启用 Web 搜索增强
|
||||
- 支持切换模型
|
||||
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
|
||||
|
||||
### 2.2 工程视角目标
|
||||
|
||||
新增的 Canvas 模式需要满足以下工程原则:
|
||||
|
||||
- 不破坏现有 DeerFlow 工作区
|
||||
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
|
||||
- 可逐阶段上线
|
||||
- 前后端状态协议清晰
|
||||
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 本次需求包含
|
||||
|
||||
- 前端新增 `open-canvas` 专用目录
|
||||
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
|
||||
- 前端新增 Canvas 页面、上下文、组件、状态管理
|
||||
- 后端新增 Canvas 专用线程状态字段
|
||||
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
|
||||
- 模型配置统一为 DeerFlow 后端配置驱动
|
||||
- 支持本地大模型配置与模型列表动态下发
|
||||
- 支持结构化 artifact 的保存、读取、更新、版本切换
|
||||
|
||||
### 3.2 本次需求不包含
|
||||
|
||||
- 一次性复刻 open-canvas 全部 UI 细节
|
||||
- 完整照搬 Supabase 鉴权体系
|
||||
- 直接复用 open-canvas 的 Next.js API Route
|
||||
- 直接运行 open-canvas 的 LangGraph.js 后端
|
||||
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
|
||||
|
||||
### 3.3 迁移原则
|
||||
|
||||
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
|
||||
- 前端尽量复用其交互设计和数据结构
|
||||
- 后端以 Python 实现同等能力
|
||||
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体设计思路
|
||||
|
||||
### 4.1 前端设计思路
|
||||
|
||||
在现有 `frontend-web/src` 下新增一个专用目录,例如:
|
||||
|
||||
```text
|
||||
src/
|
||||
open-canvas/
|
||||
pages/
|
||||
routes/
|
||||
components/
|
||||
contexts/
|
||||
hooks/
|
||||
types/
|
||||
api/
|
||||
utils/
|
||||
constants/
|
||||
```
|
||||
|
||||
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
|
||||
|
||||
建议路由形式:
|
||||
|
||||
- `/canvas`
|
||||
- `/canvas/:threadId`
|
||||
|
||||
也可以挂在现有页面布局下,例如:
|
||||
|
||||
- `/workspace/canvas`
|
||||
- `/workspace/canvas/:threadId`
|
||||
|
||||
推荐优先采用独立一级功能路由:
|
||||
|
||||
- 逻辑更清晰
|
||||
- 状态隔离更容易
|
||||
- 不容易污染现有 `workspace` 功能
|
||||
|
||||
### 4.2 后端设计思路
|
||||
|
||||
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
|
||||
|
||||
#### 方案 A:新增独立 Canvas Assistant
|
||||
|
||||
- 新增 `canvas_agent`
|
||||
- 使用单独的 LangGraph 状态定义
|
||||
- 前端调用时通过 `assistant_id=canvas_agent`
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有 `lead_agent` 隔离
|
||||
- 风险更低
|
||||
- 更容易对齐 open-canvas 的图结构
|
||||
|
||||
缺点:
|
||||
|
||||
- 会产生一套新的 Agent 图维护成本
|
||||
|
||||
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
|
||||
|
||||
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
|
||||
- Agent 根据模式进入 Canvas 路径
|
||||
|
||||
优点:
|
||||
|
||||
- 统一入口
|
||||
|
||||
缺点:
|
||||
|
||||
- 会显著增加现有 Agent 复杂度
|
||||
- 容易影响现有聊天和文件产物模式
|
||||
|
||||
推荐先做方案 A。
|
||||
|
||||
### 4.3 模型配置设计思路
|
||||
|
||||
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
|
||||
|
||||
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
|
||||
|
||||
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `provider`
|
||||
- `supports_thinking`
|
||||
- `supports_reasoning_effort`
|
||||
- `supports_vision`
|
||||
- `supports_tool_calling`
|
||||
- `supports_artifact_editing`
|
||||
- `supports_code_editing`
|
||||
- `temperature_range`
|
||||
- `max_tokens_range`
|
||||
- `base_url_source` 或 provider 标识
|
||||
- `is_local`
|
||||
|
||||
---
|
||||
|
||||
## 5. 与当前项目的关键差异
|
||||
|
||||
### 5.1 当前 DeerFlow 现状
|
||||
|
||||
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
|
||||
|
||||
- `artifacts: string[]`
|
||||
|
||||
这更接近“生成文件产物”的工作模式。
|
||||
|
||||
前端当前 artifact 面板也主要是:
|
||||
|
||||
- 文件列表
|
||||
- 文件详情
|
||||
- 文件下载/预览
|
||||
|
||||
### 5.2 open-canvas 的关键能力
|
||||
|
||||
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
|
||||
|
||||
- 一个 conversation 对应一个主 artifact
|
||||
- artifact 具有类型、标题、内容、版本
|
||||
- 用户可以选中部分内容让 AI 改写
|
||||
- 每次改写都会生成新版本
|
||||
|
||||
### 5.3 需要新增的核心能力
|
||||
|
||||
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
|
||||
|
||||
- 当前 artifact
|
||||
- 当前版本索引
|
||||
- 历史版本列表
|
||||
- 选区信息
|
||||
- 当前编辑模式
|
||||
- 快速动作状态
|
||||
- Web 搜索结果状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施方案
|
||||
|
||||
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段一:前端路由与目录骨架
|
||||
|
||||
### 7.1 目标
|
||||
|
||||
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
|
||||
|
||||
### 7.2 需求
|
||||
|
||||
- 新增 `src/open-canvas/` 目录
|
||||
- 新增 Canvas 页面路由
|
||||
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
|
||||
- 提供 Canvas 首页和 Canvas Thread 页
|
||||
- 支持空状态展示
|
||||
|
||||
### 7.3 建议目录
|
||||
|
||||
```text
|
||||
src/open-canvas/
|
||||
api/
|
||||
threads.ts
|
||||
runs.ts
|
||||
models.ts
|
||||
components/
|
||||
layout/
|
||||
chat/
|
||||
canvas/
|
||||
artifacts/
|
||||
quick-actions/
|
||||
selectors/
|
||||
contexts/
|
||||
CanvasThreadContext.tsx
|
||||
CanvasGraphContext.tsx
|
||||
CanvasUIContext.tsx
|
||||
hooks/
|
||||
useCanvasThread.ts
|
||||
useCanvasStream.ts
|
||||
useCanvasArtifact.ts
|
||||
pages/
|
||||
CanvasHomePage.tsx
|
||||
CanvasThreadPage.tsx
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx
|
||||
types/
|
||||
artifact.ts
|
||||
thread.ts
|
||||
model.ts
|
||||
utils/
|
||||
artifact.ts
|
||||
selection.ts
|
||||
stream.ts
|
||||
constants/
|
||||
index.ts
|
||||
```
|
||||
|
||||
### 7.4 路由建议
|
||||
|
||||
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
|
||||
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
|
||||
|
||||
建议形式:
|
||||
|
||||
```text
|
||||
/canvas
|
||||
/canvas/:threadId
|
||||
```
|
||||
|
||||
### 7.5 验收标准
|
||||
|
||||
- 能访问 Canvas 首页
|
||||
- 能访问指定 thread 的 Canvas 页面
|
||||
- 不影响现有 `workspace/*` 与 `strategy/*`
|
||||
- 页面有独立布局骨架
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段二:前端 Canvas 布局与交互容器
|
||||
|
||||
### 8.1 目标
|
||||
|
||||
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
|
||||
|
||||
### 8.2 需求
|
||||
|
||||
- 左侧聊天区
|
||||
- 右侧画布区
|
||||
- 支持面板拖拽或折叠
|
||||
- 初始空状态
|
||||
- 支持“快速开始”:
|
||||
- 新建空白文本 artifact
|
||||
- 新建代码 artifact
|
||||
|
||||
### 8.3 组件拆分建议
|
||||
|
||||
- `CanvasShell`
|
||||
- `CanvasSidebar`
|
||||
- `CanvasChatPanel`
|
||||
- `CanvasArtifactPanel`
|
||||
- `CanvasEmptyState`
|
||||
- `QuickStartActions`
|
||||
|
||||
### 8.4 快速开始能力
|
||||
|
||||
用户进入空线程时可以选择:
|
||||
|
||||
- 开始写文章
|
||||
- 开始写代码
|
||||
- 从模板开始
|
||||
|
||||
首版模板可只做最小集:
|
||||
|
||||
- 空白文档
|
||||
- HTML 模板
|
||||
- React 组件模板
|
||||
- Python 脚本模板
|
||||
|
||||
### 8.5 验收标准
|
||||
|
||||
- 页面可稳定展示双栏布局
|
||||
- 快速开始能创建前端本地 artifact 状态
|
||||
- 代码和文本可分别进入不同渲染器
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段三:结构化 Artifact 数据模型
|
||||
|
||||
### 9.1 目标
|
||||
|
||||
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
|
||||
|
||||
### 9.2 核心要求
|
||||
|
||||
需要支持以下能力:
|
||||
|
||||
- 文本 artifact
|
||||
- 代码 artifact
|
||||
- 标题
|
||||
- 当前版本索引
|
||||
- 版本历史
|
||||
- 当前选中版本切换
|
||||
- 后续支持局部高亮编辑
|
||||
|
||||
### 9.3 建议数据结构
|
||||
|
||||
```ts
|
||||
type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
|
||||
};
|
||||
|
||||
type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 线程状态建议新增字段
|
||||
|
||||
前端与后端需共同支持:
|
||||
|
||||
- `artifact`
|
||||
- `highlighted_text`
|
||||
- `highlighted_code`
|
||||
- `canvas_mode`
|
||||
- `web_search_enabled`
|
||||
- `web_search_results`
|
||||
- `quick_action_id`
|
||||
|
||||
### 9.5 与现有 `artifacts: string[]` 的关系
|
||||
|
||||
建议保留现有 `artifacts: string[]` 不动。
|
||||
|
||||
新增字段:
|
||||
|
||||
- `artifact` 用于 Canvas 主文档
|
||||
- `artifacts` 继续用于文件产物
|
||||
|
||||
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
|
||||
|
||||
### 9.6 验收标准
|
||||
|
||||
- 前端类型定义完成
|
||||
- 后端状态字段设计完成
|
||||
- 确认与现有线程状态兼容
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段四:后端线程状态与 API 扩展
|
||||
|
||||
### 10.1 目标
|
||||
|
||||
让后端线程状态可以安全存储和返回 Canvas Artifact。
|
||||
|
||||
### 10.2 需求
|
||||
|
||||
- 扩展 Python `ThreadState`
|
||||
- 扩展状态序列化逻辑
|
||||
- 确保 `/api/threads/*` 返回结构化 `artifact`
|
||||
- 确保 LangGraph 流中可带上 `artifact` 更新
|
||||
|
||||
### 10.3 后端改造点
|
||||
|
||||
建议关注以下模块:
|
||||
|
||||
- `packages/harness/deerflow/agents/thread_state.py`
|
||||
- 线程 state serializer
|
||||
- `app/gateway/routers/threads.py`
|
||||
- 流事件适配层
|
||||
|
||||
### 10.4 注意事项
|
||||
|
||||
- 旧线程没有 `artifact` 字段时必须兼容
|
||||
- 状态更新不能破坏现有消息结构
|
||||
- 必须确保 JSON 可序列化
|
||||
|
||||
### 10.5 验收标准
|
||||
|
||||
- 新建线程可写入 `artifact`
|
||||
- 读取线程时可返回 `artifact`
|
||||
- 不影响现有线程读取
|
||||
- 前端能从线程 state 中恢复 Canvas 内容
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段五:Canvas 专用 Agent 图
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
实现与 open-canvas 类似的 AI 路由能力。
|
||||
|
||||
### 11.2 推荐方案
|
||||
|
||||
新增 `canvas_agent`,单独维护图逻辑。
|
||||
|
||||
### 11.3 节点拆分建议
|
||||
|
||||
首版建议至少拆成这些节点:
|
||||
|
||||
- `route_user_intent`
|
||||
- `reply_general_input`
|
||||
- `generate_artifact`
|
||||
- `rewrite_artifact`
|
||||
- `update_highlighted_text`
|
||||
- `update_highlighted_code`
|
||||
- `generate_followup`
|
||||
|
||||
二期可加:
|
||||
|
||||
- `rewrite_artifact_theme`
|
||||
- `rewrite_code_theme`
|
||||
- `change_length`
|
||||
- `change_reading_level`
|
||||
- `translate_artifact`
|
||||
- `add_comments`
|
||||
- `add_logs`
|
||||
- `fix_bugs`
|
||||
- `port_language`
|
||||
- `web_search_enrich`
|
||||
|
||||
### 11.4 路由逻辑要求
|
||||
|
||||
系统需要能判断:
|
||||
|
||||
- 用户是在闲聊
|
||||
- 用户要新生成 artifact
|
||||
- 用户要整体重写当前 artifact
|
||||
- 用户要局部修改当前 artifact
|
||||
- 用户要执行某个快捷动作
|
||||
|
||||
### 11.5 状态输入要求
|
||||
|
||||
节点需要读取:
|
||||
|
||||
- 当前消息历史
|
||||
- 当前 artifact
|
||||
- 当前选区
|
||||
- 当前模型配置
|
||||
- Web 搜索开关
|
||||
|
||||
### 11.6 输出要求
|
||||
|
||||
节点输出必须能更新:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `web_search_results`
|
||||
- `followup`
|
||||
|
||||
### 11.7 验收标准
|
||||
|
||||
- 能根据输入创建新 artifact
|
||||
- 能根据输入整体改写 artifact
|
||||
- 能保留版本历史
|
||||
- 能在改写后返回自然语言 followup
|
||||
|
||||
---
|
||||
|
||||
## 12. 阶段六:前端流式同步与版本管理
|
||||
|
||||
### 12.1 目标
|
||||
|
||||
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
|
||||
|
||||
### 12.2 需求
|
||||
|
||||
- 复用现有 LangGraph stream client
|
||||
- 在 Canvas 模式中解析 `artifact` 更新
|
||||
- 支持流式文本更新
|
||||
- 支持版本切换
|
||||
- 支持断线后恢复线程状态
|
||||
|
||||
### 12.3 前端状态层建议
|
||||
|
||||
- `CanvasThreadContext`:线程基础信息
|
||||
- `CanvasGraphContext`:消息、artifact、streaming 状态
|
||||
- `CanvasUIContext`:面板、编辑态、选中态
|
||||
|
||||
### 12.4 关键能力
|
||||
|
||||
- 当前版本切换
|
||||
- 新版本创建后自动定位到最新版本
|
||||
- 手动编辑内容后保存
|
||||
- 流结束后线程状态持久化
|
||||
|
||||
### 12.5 验收标准
|
||||
|
||||
- 发送消息时聊天和画布可同步更新
|
||||
- 版本数会增加
|
||||
- 切换历史版本稳定
|
||||
- 刷新页面后线程状态能恢复
|
||||
|
||||
---
|
||||
|
||||
## 13. 阶段七:局部选区改写
|
||||
|
||||
### 13.1 目标
|
||||
|
||||
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
|
||||
|
||||
### 13.2 文本选区需求
|
||||
|
||||
- 支持在 Markdown 文本中选择一段文本
|
||||
- 记录选区位置与原始文本
|
||||
- 用户发出修改请求后,仅重写该选区
|
||||
- 将选区结果合并为新版本 artifact
|
||||
|
||||
### 13.3 代码选区需求
|
||||
|
||||
- 支持在代码编辑器中选择一段代码
|
||||
- 记录 start/end index 或行列信息
|
||||
- AI 只返回替换片段
|
||||
- 前端或后端负责将新片段拼接回完整代码
|
||||
|
||||
### 13.4 实现建议
|
||||
|
||||
首版建议:
|
||||
|
||||
- 文本:前端记录纯文本字符位置
|
||||
- 代码:使用 CodeMirror selection 信息
|
||||
|
||||
不建议首版就做复杂块级富文本局部编辑。
|
||||
|
||||
### 13.5 风险
|
||||
|
||||
- Markdown 渲染态与源码态位置映射复杂
|
||||
- 富文本块级位置难维护
|
||||
- 流式更新中选区同步容易错位
|
||||
|
||||
### 13.6 首版折中方案
|
||||
|
||||
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
|
||||
|
||||
### 13.7 验收标准
|
||||
|
||||
- 文本局部改写成功
|
||||
- 代码局部改写成功
|
||||
- 原内容未改动部分保持不变
|
||||
- 新版本正确入历史
|
||||
|
||||
---
|
||||
|
||||
## 14. 阶段八:快捷动作系统
|
||||
|
||||
### 14.1 目标
|
||||
|
||||
实现 open-canvas 风格的快捷动作体系。
|
||||
|
||||
### 14.2 首批推荐动作
|
||||
|
||||
文本类:
|
||||
|
||||
- 扩写
|
||||
- 精简
|
||||
- 改正式语气
|
||||
- 改口语语气
|
||||
- 改阅读难度
|
||||
- 翻译
|
||||
|
||||
代码类:
|
||||
|
||||
- 加注释
|
||||
- 加日志
|
||||
- 修复 bug
|
||||
- 优化代码
|
||||
- 转换语言
|
||||
|
||||
### 14.3 设计方式
|
||||
|
||||
快捷动作不要直接写死在 UI,建议配置化:
|
||||
|
||||
- `id`
|
||||
- `label`
|
||||
- `type`
|
||||
- `applicable_to`
|
||||
- `prompt_template`
|
||||
|
||||
### 14.4 后端设计
|
||||
|
||||
快捷动作可通过:
|
||||
|
||||
- 前端传 `quick_action_id`
|
||||
- 后端路由节点根据动作 id 选择 prompt
|
||||
|
||||
### 14.5 验收标准
|
||||
|
||||
- 文本动作可执行
|
||||
- 代码动作可执行
|
||||
- 结果进入新版本
|
||||
- 不同动作提示词可扩展
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段九:模型配置统一与本地模型支持
|
||||
|
||||
### 15.1 目标
|
||||
|
||||
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
|
||||
|
||||
### 15.2 原则
|
||||
|
||||
- 后端 `config.yaml` 为真源
|
||||
- 前端只展示后端暴露的模型信息
|
||||
- 不维护一份脱离后端的前端常量模型表
|
||||
|
||||
### 15.3 本地模型支持范围
|
||||
|
||||
建议优先支持两类:
|
||||
|
||||
- `Ollama`
|
||||
- OpenAI-compatible 本地服务
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- LocalAI
|
||||
- one-api 风格代理
|
||||
|
||||
### 15.4 后端配置建议
|
||||
|
||||
后端模型配置应至少支持:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `use`
|
||||
- `model`
|
||||
- `api_key`
|
||||
- `base_url`
|
||||
- `supports_vision`
|
||||
- `supports_thinking`
|
||||
- `supports_tool_calling`
|
||||
- `is_local`
|
||||
- `canvas_capabilities`
|
||||
|
||||
### 15.5 Canvas 模式额外要求
|
||||
|
||||
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
|
||||
|
||||
- `supports_canvas_generation`
|
||||
- `supports_canvas_rewrite`
|
||||
- `supports_canvas_code_edit`
|
||||
- `supports_streaming_text`
|
||||
- `supports_streaming_tool_calls`
|
||||
|
||||
### 15.6 与 open-canvas 的关系
|
||||
|
||||
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
|
||||
|
||||
### 15.7 验收标准
|
||||
|
||||
- 前端模型选择器可显示后端返回模型
|
||||
- 可切换本地模型
|
||||
- Canvas 模式能用本地模型生成和改写
|
||||
|
||||
---
|
||||
|
||||
## 16. 阶段十:Web 搜索增强
|
||||
|
||||
### 16.1 目标
|
||||
|
||||
实现类似 open-canvas 的可选 Web 搜索增强。
|
||||
|
||||
### 16.2 需求
|
||||
|
||||
- UI 提供 Web 搜索开关
|
||||
- 后端在需要时走搜索节点
|
||||
- 搜索结果写入线程状态
|
||||
- 结果可在画布旁侧栏展示
|
||||
|
||||
### 16.3 首版建议
|
||||
|
||||
只做:
|
||||
|
||||
- 开关
|
||||
- 搜索结果列表
|
||||
- 搜索结果用于生成 artifact
|
||||
|
||||
暂不做复杂网页抓取编辑链路。
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
- 打开搜索后可返回搜索结果
|
||||
- 搜索结果可参与 artifact 生成
|
||||
- 结果展示不阻塞主画布
|
||||
|
||||
---
|
||||
|
||||
## 17. 阶段十一:持久化、导出与文件产物联动
|
||||
|
||||
### 17.1 目标
|
||||
|
||||
让 Canvas 文档与 DeerFlow 原有文件能力协同。
|
||||
|
||||
### 17.2 需求
|
||||
|
||||
- artifact 持久化在线程 state 中
|
||||
- 可手动导出为文件
|
||||
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
|
||||
- Canvas artifact 与 `artifacts: string[]` 可双向关联
|
||||
|
||||
### 17.3 推荐能力
|
||||
|
||||
- 导出 Markdown
|
||||
- 导出代码文件
|
||||
- 另存为 artifact file
|
||||
- 从 artifact file 回填到 Canvas
|
||||
|
||||
### 17.4 验收标准
|
||||
|
||||
- 当前版本可导出
|
||||
- 导出文件可在现有文件面板查看
|
||||
- 文件型 artifact 和 Canvas artifact 能共存
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐开发顺序
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
|
||||
1. 路由和目录骨架
|
||||
2. Canvas 页面布局
|
||||
3. 前端 artifact 数据模型
|
||||
4. 后端线程状态扩展
|
||||
5. 新建 Canvas Agent 图
|
||||
6. 前后端流式同步
|
||||
7. 整体生成与整体改写
|
||||
8. 局部选区改写
|
||||
9. 快捷动作
|
||||
10. 本地模型支持完善
|
||||
11. Web 搜索增强
|
||||
12. 导出与文件联动
|
||||
|
||||
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
|
||||
|
||||
---
|
||||
|
||||
## 19. 里程碑定义
|
||||
|
||||
### M1:页面可进入
|
||||
|
||||
- 路由接通
|
||||
- 双栏布局完成
|
||||
- 本地 mock artifact 可渲染
|
||||
|
||||
### M2:后端状态打通
|
||||
|
||||
- 线程可保存结构化 artifact
|
||||
- 页面刷新后能恢复
|
||||
|
||||
### M3:AI 生成可用
|
||||
|
||||
- 可新建文本/代码 artifact
|
||||
- 可整体改写
|
||||
- 可产生版本历史
|
||||
|
||||
### M4:核心体验可用
|
||||
|
||||
- 选区改写可用
|
||||
- 快捷动作可用
|
||||
- 模型切换可用
|
||||
|
||||
### M5:增强能力完整
|
||||
|
||||
- 本地模型支持完善
|
||||
- Web 搜索增强
|
||||
- 导出和文件联动
|
||||
|
||||
---
|
||||
|
||||
## 20. 风险与难点
|
||||
|
||||
### 20.1 最大难点
|
||||
|
||||
- 结构化 artifact 状态从无到有
|
||||
- 局部选区改写的精确性
|
||||
- 流式状态与版本管理的一致性
|
||||
- 不破坏现有 DeerFlow 文件型 artifact 体系
|
||||
|
||||
### 20.2 技术风险
|
||||
|
||||
- 线程 state JSON 结构过大
|
||||
- 大模型对局部改写指令遵循不稳定
|
||||
- 本地模型 tool calling 与流式能力差异大
|
||||
- 文本渲染态与源码态位置映射复杂
|
||||
|
||||
### 20.3 规避建议
|
||||
|
||||
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
|
||||
- 首版只支持文本和代码两种 artifact 类型
|
||||
- 首版先做整体生成和整体改写,再做局部改写
|
||||
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
|
||||
|
||||
---
|
||||
|
||||
## 21. 验收总清单
|
||||
|
||||
### 21.1 前端
|
||||
|
||||
- 有独立 Canvas 路由
|
||||
- 有专用目录和模块边界
|
||||
- 双栏布局可用
|
||||
- 文本和代码两类 artifact 可渲染
|
||||
- 历史版本可切换
|
||||
- 选区改写可交互
|
||||
- 快捷动作可触发
|
||||
- 模型可切换
|
||||
|
||||
### 21.2 后端
|
||||
|
||||
- 线程状态支持 `artifact`
|
||||
- 新增 `canvas_agent` 或等价模式
|
||||
- 生成、重写、局部改写可用
|
||||
- 流式返回稳定
|
||||
- 模型列表接口支持 Canvas 能力元数据
|
||||
|
||||
### 21.3 本地模型
|
||||
|
||||
- 可配置 Ollama
|
||||
- 可配置 OpenAI-compatible 本地服务
|
||||
- 前端可见并可切换
|
||||
- 至少能完成文本生成和整体改写
|
||||
|
||||
---
|
||||
|
||||
## 22. 推荐首版最小可交付范围
|
||||
|
||||
如果要控制风险,建议首版只交付以下内容:
|
||||
|
||||
- 新增 `canvas/*` 路由
|
||||
- 双栏布局
|
||||
- 结构化 `artifact`
|
||||
- `canvas_agent`
|
||||
- 文本/代码 artifact 新建
|
||||
- 整体生成
|
||||
- 整体改写
|
||||
- 历史版本切换
|
||||
- 后端模型动态加载
|
||||
- Ollama 或本地 OpenAI-compatible 至少一种可用
|
||||
|
||||
先不要把以下内容放进首版强制目标:
|
||||
|
||||
- 富文本块级精确编辑
|
||||
- 全量快捷动作
|
||||
- 复杂 Web 搜索增强
|
||||
- 多 artifact 并行管理
|
||||
|
||||
---
|
||||
|
||||
## 23. 后续实施建议
|
||||
|
||||
建议下一步基于本文档继续输出两份落地文档:
|
||||
|
||||
### 23.1 前端实施清单
|
||||
|
||||
逐文件列出:
|
||||
|
||||
- 要新建哪些文件
|
||||
- 哪些组件可复用
|
||||
- 哪些页面要接入路由
|
||||
- 哪些状态要从现有 hooks 抽离
|
||||
|
||||
### 23.2 后端实施清单
|
||||
|
||||
逐模块列出:
|
||||
|
||||
- 要改哪些 Python 文件
|
||||
- 新增哪些状态字段
|
||||
- 新增哪些 Agent 节点
|
||||
- 哪些 API 需要扩展
|
||||
|
||||
---
|
||||
|
||||
## 24. 结论
|
||||
|
||||
这个需求很大,但完全可拆。
|
||||
|
||||
正确做法不是“先抄 UI”,而是按下面的主线推进:
|
||||
|
||||
- 先建前端 Canvas 子系统
|
||||
- 再建结构化 artifact 状态
|
||||
- 再建 Canvas Agent 图
|
||||
- 再补局部改写和快捷动作
|
||||
- 最后统一本地模型能力
|
||||
|
||||
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,310 @@
|
||||
# 浅色红色 UI 规范对齐 — 实施计划
|
||||
|
||||
> 依据:`H:\页面设计\UI设计规范\浅色红色设计规范-前端用.pdf`(共 104 页)
|
||||
> 范围:**仅对齐 Light 模式(红色主题)**,**不改动图表部分**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要约束(必读)
|
||||
|
||||
### 1. 主题切换必须保留
|
||||
项目使用 `next-themes` 实现 Light/Dark 切换:
|
||||
|
||||
- `:root` → Light 模式(红色品牌,对应本规范)
|
||||
- `.dark` → Dark 模式(蓝色品牌,**不在本次范围内**)
|
||||
- 切换组件入口:`src/components/theme-provider.tsx`
|
||||
|
||||
**所有改动只能动 `:root` 块和 light-only 选择器,禁止动 `.dark` 块和 `dark:` 前缀样式。**
|
||||
|
||||
### 2. 不动图表
|
||||
本次跳过 `chart-1` ~ `chart-5` 颜色变量和所有图表组件。
|
||||
|
||||
### 3. 同一 token 双模式都用时如何改
|
||||
若变量同时被 light/dark 使用(如 `--radius`、按钮 `h-*`、`--primary-foreground`),改动后需在 Dark 模式下回归检查,确保没有破坏。
|
||||
|
||||
---
|
||||
|
||||
## 改动总目标对照
|
||||
|
||||
| 类别 | UI 规范 | 现状 | 差异 |
|
||||
|------|--------|------|------|
|
||||
| 主品牌红 | `#F5222D` | `#d92121` (`oklch(0.52 0.19 27)`) | 偏差 |
|
||||
| 深色品牌 | `#CF1322` / `#A8071A` | 未使用 | 缺失 |
|
||||
| 圆角-按钮 | 2px | 12px (`--radius: 0.75rem`) | 严重偏大 |
|
||||
| 圆角-卡片 | 4px | 12px | 严重偏大 |
|
||||
| 按钮高度(正常)| 32px | 36px (`h-9`) | 偏大 4px |
|
||||
| 按钮高度(小)| 24px | 32px (`h-8`) | 偏大 8px |
|
||||
| 输入框高度 | 32px | 36px (`h-9`) | 偏大 4px |
|
||||
| 中文字体 | NotoSansSChinese | 系统字体 | 未引入 |
|
||||
| 数字字体 | D-DIN | 系统字体 | 未引入 |
|
||||
| 间距体系 | 8px 基准(4/8/12/16/24/32/40/56)| Tailwind 默认 | 基本一致 |
|
||||
|
||||
---
|
||||
|
||||
## 分步实施计划
|
||||
|
||||
### Step 1 — 颜色变量对齐(Light 模式)
|
||||
|
||||
**文件**:`src/styles/globals.css`
|
||||
**只动 `:root` 块(第 242-277 行),保持 `.dark` 块原样**
|
||||
|
||||
#### 1.1 主色映射
|
||||
|
||||
| 角色 | 规范值 | 用途 |
|
||||
|------|--------|------|
|
||||
| 主色 | `#F5222D` | `--primary`、`--ring`、`--sidebar-primary` |
|
||||
| 主色 hover | `#CF1322` | 按钮 hover 状态(新增辅助变量) |
|
||||
| 主色 active | `#A8071A` | 按钮按下状态 |
|
||||
| 主色淡 1 | `#FEEDED` | `--accent`(极淡红,背景) |
|
||||
| 主色淡 2 | `#FDDADC` | hover bg、tag 背景 |
|
||||
| 主色淡 3 | `#FCB5B9` | 浅边框、selected 态 |
|
||||
| 危险色 | `#F5222D` | `--destructive` |
|
||||
|
||||
#### 1.2 新增工程变量(建议加在 `:root` 内)
|
||||
|
||||
```css
|
||||
/* 红色主题阶梯色(规范色板)*/
|
||||
--brand-50: #FEFAFA;
|
||||
--brand-100: #FEF6F6;
|
||||
--brand-200: #FEEDED;
|
||||
--brand-300: #FDDADC;
|
||||
--brand-400: #FCB5B9;
|
||||
--brand-500: #FA9196;
|
||||
--brand-600: #F86C73;
|
||||
--brand-700: #F74750;
|
||||
--brand-800: #F5222D; /* ← 主色 */
|
||||
--brand-900: #CF1322; /* ← hover */
|
||||
--brand-950: #A8071A; /* ← active */
|
||||
|
||||
/* 辅色(规范)*/
|
||||
--accent-orange: #FF9900;
|
||||
--accent-green: #2FD69A;
|
||||
--accent-cyan: #45C3F3;
|
||||
```
|
||||
|
||||
#### 1.3 `--workspace-brand` 同步
|
||||
|
||||
`:root` 内:`--workspace-brand: #F5222D;`(从 `#d92121` 改)
|
||||
`.dark` 内:**不动**(保持 `#3b82f6` 蓝色)
|
||||
|
||||
#### 1.4 验收
|
||||
- 切到 Light 模式:所有按钮、链接、选中态使用规范红
|
||||
- 切到 Dark 模式:依然是原蓝色品牌(无变化)
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — 圆角规范化
|
||||
|
||||
**文件**:`src/styles/globals.css`(`--radius` 是全局变量,light/dark 共用)
|
||||
|
||||
#### 2.1 改动方案(推荐)
|
||||
|
||||
按规范:按钮 2px、卡片/页面框 4px。但当前许多组件依赖 `rounded-md` / `rounded-lg`,全局把 `--radius` 改成 4px 会引起视觉跳变。
|
||||
|
||||
**采用细粒度策略:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius: 0.25rem; /* 4px → 卡片/页面框 */
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: 2px; /* 按钮、tag */
|
||||
--radius-md: calc(var(--radius) - 1px); /* 3px → 输入框 */
|
||||
--radius-lg: var(--radius); /* 4px → 卡片 */
|
||||
--radius-xl: calc(var(--radius) + 4px); /* 8px → 弹窗 */
|
||||
--radius-2xl: calc(var(--radius) + 8px); /* 12px → 大卡片 */
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 例外保留(设计感样式)
|
||||
以下组件**圆角不改**,保持设计稿原貌:
|
||||
- `.workspace-user-bubble`(聊天气泡,圆角 `1.6rem 1.6rem 0.5rem 1.6rem`)
|
||||
- `.workspace-assistant-card`(圆角 `1.75rem`)
|
||||
- `.gemini-gradient-border`(圆角 `1.5rem`)
|
||||
|
||||
#### 2.3 验收
|
||||
- 普通按钮变为近直角(2px)
|
||||
- 卡片维持温和圆角(4px)
|
||||
- 聊天气泡、AI 卡片不变形
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — 按钮组件尺寸对齐
|
||||
|
||||
**文件**:`src/components/ui/button.tsx`
|
||||
|
||||
#### 3.1 改 size 变体(规范:小 24px / 正常 32px)
|
||||
|
||||
| size | 规范 | 当前 | 改后 |
|
||||
|------|------|------|------|
|
||||
| `sm` | 24px | h-8 (32px) | `h-6 px-2 text-xs` |
|
||||
| `default` | 32px | h-9 (36px) | `h-8 px-3 text-sm` |
|
||||
| `lg` | — | h-10 (40px) | 保留 `h-10`(用于 Banner/登录场景)|
|
||||
|
||||
#### 3.2 圆角
|
||||
- 默认按钮:`rounded-sm`(2px)
|
||||
|
||||
#### 3.3 主按钮配色
|
||||
- bg:`bg-primary`
|
||||
- hover:`hover:bg-[var(--brand-900)]`(深红 `#CF1322`)
|
||||
- active:`active:bg-[var(--brand-950)]`(更深 `#A8071A`)
|
||||
- disabled:`disabled:opacity-50`(保留)
|
||||
|
||||
#### 3.4 次要按钮 / 文本按钮
|
||||
- 次要按钮(`outline` variant):边框 `border-primary`,文字 `text-primary`
|
||||
- 文本按钮(`ghost` variant):仅文字色 `text-primary`,hover 背景 `--brand-200`
|
||||
|
||||
#### 3.5 验收
|
||||
检查所有调用 `<Button>` 的页面:
|
||||
- 工具栏按钮(之前 h-9 → 现在 h-8)
|
||||
- 弹窗确认/取消(之前 h-9 → 现在 h-8)
|
||||
- Landing 主 CTA 按钮(保留 `lg`)
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — 输入框尺寸对齐
|
||||
|
||||
#### 4.1 `src/components/ui/input.tsx`
|
||||
- `h-9` → `h-8`(32px)
|
||||
- `rounded-md`(沿用 3px)
|
||||
- 边框:`border-input`(变量已是浅灰,不变)
|
||||
- focus:`focus-visible:ring-[2px] focus-visible:ring-primary/35`
|
||||
|
||||
#### 4.2 `src/components/ui/textarea.tsx`
|
||||
- 最小高度建议 `min-h-16`
|
||||
- 圆角 `rounded-md`
|
||||
|
||||
#### 4.3 `src/components/ui/select.tsx`
|
||||
- `size: default` → `h-8`
|
||||
- `size: sm` → `h-6`
|
||||
|
||||
#### 4.4 验收
|
||||
- 搜索框、表单输入、下拉框高度一致(32px)
|
||||
- 与按钮对齐时高度一致
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — 字体引入
|
||||
|
||||
#### 5.1 引入字体文件
|
||||
在 `frontend-web/public/fonts/` 下放置:
|
||||
- `NotoSansSC-Regular.woff2` / `Medium.woff2` / `Bold.woff2`
|
||||
- `D-DIN-Regular.woff2` / `Bold.woff2`
|
||||
|
||||
> 若需在线方式,可用 Google Fonts CDN:
|
||||
> `https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap`
|
||||
|
||||
#### 5.2 `globals.css` 顶部添加 `@font-face`
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Noto Sans SC';
|
||||
src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
/* Medium / Bold 同上 */
|
||||
|
||||
@font-face {
|
||||
font-family: 'D-DIN';
|
||||
src: url('/fonts/D-DIN-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3 修改 `--font-sans`(globals.css 第 74 行)
|
||||
|
||||
```css
|
||||
--font-sans:
|
||||
'Noto Sans SC', ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--font-numeric: 'D-DIN', 'Noto Sans SC', sans-serif;
|
||||
```
|
||||
|
||||
#### 5.4 数字字体工具类
|
||||
新增 `.font-numeric { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }`
|
||||
|
||||
仪表盘、表格中重要数字加 `className="font-numeric"`。
|
||||
|
||||
#### 5.5 验收
|
||||
- 中文文本回退到 Noto Sans SC
|
||||
- Dark 模式同样生效(字体是全局变量)
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — Badge / 状态标签色对齐
|
||||
|
||||
**文件**:`src/components/ui/badge.tsx`
|
||||
|
||||
规范状态色(参考 PDF 第 67 页):
|
||||
|
||||
| 状态 | 颜色定位 | 推荐 token |
|
||||
|------|---------|-----------|
|
||||
| 未开始 | 中性灰 | `bg-muted text-muted-foreground` |
|
||||
| 等待中 | 蓝/青 | `bg-blue-50 text-blue-600`(保留) |
|
||||
| 进行中 | 主红 | `bg-[var(--brand-200)] text-primary` |
|
||||
| 已完成 | 绿 | `bg-emerald-50 text-emerald-600` |
|
||||
| 已失败 | 红/橙 | `bg-red-50 text-red-600` |
|
||||
| 待更新 | 黄/橙 | `bg-amber-50 text-amber-700` |
|
||||
|
||||
需要新增 `variant`:`waiting`、`processing`、`success`、`failed`、`pending`。
|
||||
|
||||
圆角统一 `rounded-sm`,padding `px-2 py-0.5`,字号 `text-xs`。
|
||||
|
||||
---
|
||||
|
||||
### Step 7 — 全局间距 & 阴影规范化
|
||||
|
||||
#### 7.1 间距
|
||||
规范基于 8px 网格(4 / 8 / 12 / 16 / 24 / 32 / 40 / 56)。Tailwind 默认 4px 步进(`p-1`=4, `p-2`=8...)已兼容。
|
||||
|
||||
**只需 review**:检查页面是否有 `p-2.5`、`gap-1.5` 等非 8 的倍数,统一改成 8 的倍数。
|
||||
|
||||
#### 7.2 阴影
|
||||
规范阴影仅用于"标题+操作"和"卡片新增"两类。其余卡片保持平。
|
||||
|
||||
新增工具类(可选):
|
||||
```css
|
||||
.shadow-card { box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); }
|
||||
.shadow-card-hover { box-shadow: 0 4px 12px 0 rgba(0,0,0,0.08); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 8 — 验收清单
|
||||
|
||||
每步完成后检查:
|
||||
|
||||
- [ ] **Light 模式**视觉对齐规范(参考 PDF 关键页:5、7、10、36、66)
|
||||
- [ ] **Dark 模式**完全无变化(切换主题验证)
|
||||
- [ ] 首页 / Workspace / 聊天 / 设置弹窗 → 按钮和输入框高度统一
|
||||
- [ ] 主题切换按钮点击 → 颜色平滑切换,无错位/闪烁
|
||||
- [ ] `/landing` 强制 Dark 模式仍正常
|
||||
- [ ] 控制台无 CSS 变量未定义警告
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
Step 1 (颜色变量) ─┐
|
||||
├→ Step 3 (按钮) ─┐
|
||||
Step 2 (圆角) ─┘ ├→ Step 6 (Badge) ─→ Step 7 (间距/阴影) ─→ Step 8 (验收)
|
||||
│
|
||||
Step 4 (输入框) ──┤
|
||||
Step 5 (字体) ────┘
|
||||
```
|
||||
|
||||
并行可行:Step 1 + Step 2 完成后,Step 3/4/5 可同时推进。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
1. **圆角从 12px → 4px 是大幅度视觉变化**,建议先在分支上做、配截图比对再合并
|
||||
2. 字体文件体积较大(NotoSansSC 全字符集 ~10MB),建议子集化或仅引入需要的字重
|
||||
3. `--radius-sm` 改后可能影响 dropdown menu / popover 等小组件外观,需逐个检查
|
||||
4. Dark 模式下的红色 `--workspace-brand` 已是蓝色,**不要把蓝色也改成 #F5222D**
|
||||
@@ -1,243 +0,0 @@
|
||||
# 素材专家检索 —— 内网接口对接说明
|
||||
|
||||
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
|
||||
涉及的代码文件,以及常见错误的排查方式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 检索来源切换
|
||||
|
||||
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
|
||||
search_provider: internet
|
||||
# 以下两项仅在 search_provider=intranet 时生效
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples
|
||||
```
|
||||
|
||||
| `search_provider` 取值 | 含义 |
|
||||
|---|---|
|
||||
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
|
||||
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
|
||||
| `tavily` | Tavily 互联网检索 |
|
||||
| `demo` | 内置演示素材,仅用于本地调试 |
|
||||
|
||||
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
|
||||
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 检索关键词数量
|
||||
|
||||
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
|
||||
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
|
||||
|
||||
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
|
||||
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
|
||||
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
|
||||
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
|
||||
规整到 `[1, 10]`,越界回退默认 `6`。
|
||||
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 素材总量上限
|
||||
|
||||
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 素材总量上限,范围 1~100,非法值回退 20
|
||||
max_materials: 20
|
||||
```
|
||||
|
||||
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
|
||||
|
||||
| `max_materials` | 检索词数 | 每词检索条数 |
|
||||
|---|---|---|
|
||||
| 20 | 1 | 20 |
|
||||
| 20 | 2 | 10 |
|
||||
| 20 | 3 | 7(共 21,合并后截断到 20) |
|
||||
| 20 | 5 | 4 |
|
||||
|
||||
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
|
||||
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
|
||||
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
|
||||
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
|
||||
|
||||
> `config.yaml` 支持热加载,改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内网接口规格
|
||||
|
||||
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
|
||||
- **方法**:`POST`
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "检索关键词",
|
||||
"knowledge_base_name": "samples",
|
||||
"ts": "yes"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应体**:一个数组,每个元素结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"page_content": "正文片段",
|
||||
"metadata": {
|
||||
"m_title": "标题",
|
||||
"content": "内容",
|
||||
"recUuid": "原文id",
|
||||
"m_publish":"发布时间",
|
||||
"source1": "来源"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
我们需要的字段都在 `metadata` 里。
|
||||
|
||||
### 字段映射
|
||||
|
||||
后端把内网响应映射为统一的 `SearchResult` 结构:
|
||||
|
||||
| 内网响应字段 | SearchResult 字段 | 说明 |
|
||||
|--------------------------|--------------------|------|
|
||||
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
|
||||
| `metadata.content` | `content` | 内容,截断到 500 字 |
|
||||
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
|
||||
| `metadata.m_publish` | `published_at` | 发布时间 |
|
||||
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
|
||||
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
|
||||
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
|
||||
| — | `source_type` | 固定为 `"intranet"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对接逻辑与涉及文件
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
|
||||
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
|
||||
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
ai_writing.py(_make_config 注入 ai_writing_config)
|
||||
→ researcher_node
|
||||
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
|
||||
→ IntranetSearchProvider.search() # POST search_docs_es
|
||||
→ SearchResult[] # 字段映射
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
|
||||
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
|
||||
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
|
||||
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
|
||||
|
||||
「查看原文」链接拼接规则:
|
||||
|
||||
```
|
||||
INTRANET_SOURCE_URL_BASE + recUuid
|
||||
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
|
||||
```
|
||||
|
||||
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
|
||||
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与排查
|
||||
|
||||
### 4.1 内网检索返回空 / 调不通
|
||||
|
||||
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
|
||||
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
|
||||
|
||||
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
|
||||
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
|
||||
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
|
||||
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
|
||||
**接口报错不影响写作流程继续**。
|
||||
|
||||
> 容错相关代码:
|
||||
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
|
||||
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
|
||||
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
|
||||
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
|
||||
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. **确认网络可达**:在后端所在机器执行
|
||||
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
|
||||
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
|
||||
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
|
||||
|
||||
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
|
||||
用户可以选择「重新搜索」或「无数据继续写作」。
|
||||
|
||||
### 4.2 字段缺失 / 响应结构变化
|
||||
|
||||
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
|
||||
单条数据字段缺失不会导致整批失败。
|
||||
|
||||
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
|
||||
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
|
||||
|
||||
### 4.3 「查看原文」按钮不显示
|
||||
|
||||
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
|
||||
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
|
||||
|
||||
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
|
||||
|
||||
### 4.4 原文链接打不开 / 地址不对
|
||||
|
||||
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
|
||||
若内网原文详情页地址有变,只改这一个常量即可。
|
||||
|
||||
### 4.5 检索到的素材全是无关内容
|
||||
|
||||
**现象**:素材卡片内容与写作意图完全不沾边。
|
||||
|
||||
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
|
||||
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
|
||||
|
||||
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
|
||||
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
|
||||
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调上线步骤
|
||||
|
||||
1. 确认后端机器能访问 `10.106.57.180:7863`。
|
||||
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
|
||||
3. 后端配置热加载生效(或重启后端)。
|
||||
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
|
||||
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
|
||||
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
|
||||
+12
-1
@@ -1,7 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<html lang="en-US" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("strategy-theme");
|
||||
var root = document.documentElement;
|
||||
root.classList.remove("dark", "light");
|
||||
if (t === "light") return;
|
||||
root.classList.add("dark");
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CM 助手</title>
|
||||
</head>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -60,6 +60,7 @@
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
@@ -92,6 +93,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
@@ -24,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
@@ -50,6 +53,11 @@ export function App() {
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
{/* Public read-only conversation viewer — no authentication. */}
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
{/* Public read-only scheduled-task runs viewer — no authentication. */}
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,45 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
Database,
|
||||
FileBox,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
PlayCircle,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Scale,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Target,
|
||||
TrendingUp,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
@@ -48,19 +63,24 @@ type PageSidebarProps = {
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "new-qa", label: "新建问答", icon: Plus },
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
|
||||
{ id: "agent-qa", label: "智能体问答", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "多智能体", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "六步对话", icon: Workflow },
|
||||
{ id: "qa-records", label: "问答记录", icon: History },
|
||||
{ id: "template-editor", label: "模板配置", icon: Settings },
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{
|
||||
id: "qa-roundtable",
|
||||
label: "圆桌规划",
|
||||
icon: Bot,
|
||||
path: "/page/roundtable/planning",
|
||||
},
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -68,6 +88,31 @@ const menuItems: MenuItem[] = [
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-note-management",
|
||||
label: "笔记管理",
|
||||
icon: NotebookPen,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-new-note",
|
||||
label: "新建笔记",
|
||||
icon: Plus,
|
||||
path: "/page/strategy/light-app/new-note",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-list",
|
||||
label: "笔记列表",
|
||||
icon: List,
|
||||
path: "/page/strategy/light-app/notes",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-qa",
|
||||
label: "笔记问答",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/light-app/note-qa",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
@@ -77,10 +122,9 @@ const menuItems: MenuItem[] = [
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK任务",
|
||||
id: "xk-center-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
@@ -88,124 +132,92 @@ const menuItems: MenuItem[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "规划任务",
|
||||
id: "coh-center-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
|
||||
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
|
||||
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
|
||||
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "MNTY任务",
|
||||
id: "pg-center-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
|
||||
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
|
||||
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "效果PG任务",
|
||||
id: "ty-center-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
|
||||
{ id: "effect-account-pg", label: "账号PG", icon: ListTodo },
|
||||
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{ id: "task-create", label: "创建任务", icon: Plus },
|
||||
{ id: "task-single", label: "单智能体任务", icon: Bot },
|
||||
{ id: "task-scene", label: "场景任务", icon: Workflow },
|
||||
{ id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
|
||||
{ id: "final-plan", label: "最终输出", icon: FileOutput },
|
||||
],
|
||||
},
|
||||
{ id: "scene-list", label: "场景管理", icon: Layers },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: "智能体管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
|
||||
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-management",
|
||||
label: "结果管理",
|
||||
icon: BarChart3,
|
||||
id: "comprehensive-management",
|
||||
label: "综合管理",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "result-planning",
|
||||
label: "规划结果",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "result-planning-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-planning-goal", label: "目标分析", icon: Target },
|
||||
{ id: "result-planning-brand", label: "品牌策略", icon: Target },
|
||||
{ id: "result-planning-content", label: "内容规划", icon: Target },
|
||||
{ id: "result-planning-channel", label: "渠道规划", icon: Target },
|
||||
{ id: "result-planning-narrative", label: "叙事策略", icon: Target },
|
||||
],
|
||||
id: "mgmt-tasks",
|
||||
label: "任务管理",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
{
|
||||
id: "result-mnty",
|
||||
label: "MNTY结果",
|
||||
icon: Brain,
|
||||
children: [
|
||||
{ id: "result-mnty-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
|
||||
{ id: "result-mnty-plan", label: "规划设计", icon: Brain },
|
||||
{ id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
|
||||
],
|
||||
id: "mgmt-recommended-questions",
|
||||
label: "推荐问题配置",
|
||||
icon: MessageSquarePlus,
|
||||
path: "/page/strategy/settings/recommended-questions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "result-effect-pg",
|
||||
label: "效果PG结果",
|
||||
icon: TrendingUp,
|
||||
children: [
|
||||
{ id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
|
||||
],
|
||||
id: "mgmt-prompt-prefix",
|
||||
label: "问答前缀配置",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "result-xk",
|
||||
label: "XK结果",
|
||||
icon: Compass,
|
||||
children: [
|
||||
{ id: "result-xk-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-xk-jc", label: "任务JC", icon: Compass },
|
||||
{ id: "result-xk-dispatch", label: "任务调度", icon: Compass },
|
||||
],
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/settings/llm-metrics",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "result-dq",
|
||||
label: "DQ结果",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "result-dq-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-dq-audience", label: "受众分析", icon: Database },
|
||||
{ id: "result-dq-kb", label: "知识研究", icon: Database },
|
||||
{ id: "result-dq-trend", label: "趋势研究", icon: Database },
|
||||
],
|
||||
id: "mgmt-users",
|
||||
label: "用户管理",
|
||||
icon: Users,
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "result-other",
|
||||
label: "其他结果",
|
||||
icon: MoreHorizontal,
|
||||
children: [
|
||||
{ id: "result-other-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
|
||||
{ id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
|
||||
{ id: "result-other-other", label: "其他", icon: MoreHorizontal },
|
||||
],
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
icon: CalendarClock,
|
||||
path: "/page/strategy/admin/scheduled-tasks",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-activity",
|
||||
label: "用户活动审计",
|
||||
icon: History,
|
||||
path: "/page/strategy/admin/user-activity",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -214,37 +226,82 @@ const menuItems: MenuItem[] = [
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "resource-zz", label: "ZZ资源", icon: FileBox },
|
||||
{ id: "resource-network-actor", label: "网络达人", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "知识库", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "评估指标", icon: Scale },
|
||||
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
|
||||
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultExpandedIds = new Set([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"light-app-note-management",
|
||||
"task-management",
|
||||
"xk-task",
|
||||
"result-management",
|
||||
"result-planning",
|
||||
"xk-center-task",
|
||||
]);
|
||||
|
||||
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const iconButtonClass =
|
||||
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
const panelClass = "ml-4 space-y-1 border-l border-red-100 pl-4";
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const openSettings = (section: SettingsSection) => {
|
||||
setSettingsDefaultSection(section);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const iconButtonClass = isDark
|
||||
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
|
||||
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
const panelClass = isDark
|
||||
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
|
||||
: "ml-4 space-y-1 border-l border-red-100 pl-4";
|
||||
const labelClass = (compact: boolean, small = false) =>
|
||||
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
|
||||
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
||||
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
||||
const menuButtonClass = (isActive: boolean, depth = 0) =>
|
||||
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? isDark
|
||||
? "bg-blue-500/15 text-cyan-300 font-semibold"
|
||||
: "bg-red-50 text-red-600 font-semibold"
|
||||
: isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||||
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
||||
}`;
|
||||
const footerButtonClass = menuButtonClass(false, 0);
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
@@ -268,13 +325,7 @@ export function PageSidebar({
|
||||
type="button"
|
||||
title={collapsed && depth === 0 ? item.label : undefined}
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-red-50 text-red-600 font-semibold"
|
||||
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
||||
}`}
|
||||
className={menuButtonClass(isActive, depth)}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
{(!collapsed || depth > 0) && (
|
||||
@@ -303,9 +354,7 @@ export function PageSidebar({
|
||||
toggleExpanded(item.id);
|
||||
}}
|
||||
title={collapsed && isTopLevel ? item.label : undefined}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
|
||||
collapsed && isTopLevel ? "justify-center px-2" : "gap-2 px-3"
|
||||
}`}
|
||||
className={menuButtonClass(false, depth)}
|
||||
>
|
||||
<Icon
|
||||
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
|
||||
@@ -340,58 +389,121 @@ export function PageSidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 z-40 h-screen overflow-y-auto border-r border-red-100 bg-white shadow-sm transition-all duration-300 ${
|
||||
collapsed ? "w-16" : "w-64"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 items-center border-b border-red-100 ${
|
||||
collapsed ? "justify-center px-2" : "justify-between px-6"
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
} ${collapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
|
||||
isDark
|
||||
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
|
||||
<h1 className="text-sm font-semibold text-gray-900">DeerFlow</h1>
|
||||
<p className="text-xs text-red-400/80">Page Navigation</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={iconButtonClass}
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={`flex w-full items-center justify-center ${iconButtonClass}`}
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{menuItems.map((item) =>
|
||||
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
||||
{visibleMenuItems.map((item) =>
|
||||
item.children ? renderBranch(item) : renderLeaf(item),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full bg-red-500/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full bg-red-400/5 blur-3xl" />
|
||||
<div
|
||||
className={`mt-auto shrink-0 theme-transition ${
|
||||
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
|
||||
} ${collapsed ? "p-2" : "p-4"}`}
|
||||
>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={collapsed ? t.workspace.settingsAndMore : undefined}
|
||||
className={footerButtonClass}
|
||||
>
|
||||
<Settings className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className={labelClass(false)}>
|
||||
{t.workspace.settingsAndMore}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={collapsed ? "start" : "end"}
|
||||
side={collapsed ? "right" : "top"}
|
||||
sideOffset={4}
|
||||
className="min-w-56 rounded-lg"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<Bell className="size-4" />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
|
||||
isDark ? "bg-cyan-500" : "bg-red-500"
|
||||
}`}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openSettings("tools")}>
|
||||
<Wrench className="size-4" />
|
||||
{t.settings.sections.tools}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openSettings("account")}>
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${footerButtonClass} pointer-events-none`}
|
||||
>
|
||||
<Settings className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className={labelClass(false)}>
|
||||
{t.workspace.settingsAndMore}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
|
||||
isDark ? "bg-blue-500/10" : "bg-red-500/5"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
|
||||
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
|
||||
}`}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -397,53 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
@@ -503,99 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
@@ -686,37 +686,37 @@ function SidebarMenuSubItem({
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAdminAddMemoryEntry,
|
||||
useAdminAddV1Fact,
|
||||
useAdminDeleteMemoryEntry,
|
||||
useAdminDeleteV1Fact,
|
||||
useAdminReplaceMemoryEntry,
|
||||
useAdminUpdateV1Fact,
|
||||
useAdminUpdateV1Section,
|
||||
useUserMemory,
|
||||
} from "@/core/admin/hooks";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
|
||||
export function getEntries(data: Record<string, unknown>): string[] {
|
||||
const raw = (data as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||||
}
|
||||
|
||||
// ── V1 只读视图 ────────────────────────────────────────────────────────────
|
||||
|
||||
const CTX_LABELS: Record<string, string> = {
|
||||
workContext: "工作背景",
|
||||
personalContext: "个人信息",
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
|
||||
|
||||
function extractSummary(val: unknown): string {
|
||||
if (typeof val === "string") return val;
|
||||
if (val && typeof val === "object" && "summary" in val)
|
||||
return String((val as Record<string, unknown>).summary ?? "");
|
||||
return "";
|
||||
}
|
||||
|
||||
// 单个摘要字段的行内编辑
|
||||
function V1SectionField({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onSave: (v: string) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (draft.trim() === value.trim()) { setEditing(false); return; }
|
||||
setSaving(true);
|
||||
try { await onSave(draft.trim()); setEditing(false); }
|
||||
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{!editing && (
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-1.5">
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单条事实的行内编辑
|
||||
function V1FactItem({
|
||||
fact,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
fact: Record<string, unknown>;
|
||||
agent: string | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
|
||||
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
|
||||
const [draftConfidence, setDraftConfidence] = useState(
|
||||
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
|
||||
);
|
||||
const updateFact = useAdminUpdateV1Fact(userId);
|
||||
const deleteFact = useAdminDeleteV1Fact(userId);
|
||||
|
||||
const factId = String(fact.id ?? "");
|
||||
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
|
||||
const category = String(fact.category ?? "context");
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await updateFact.mutateAsync({
|
||||
factId,
|
||||
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
|
||||
agent,
|
||||
});
|
||||
setEditing(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try { await deleteFact.mutateAsync({ factId, agent }); }
|
||||
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border p-2 space-y-1 group">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={draftCategory} onValueChange={setDraftCategory}>
|
||||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">置信度</span>
|
||||
<input
|
||||
type="number" min={0} max={100} value={draftConfidence}
|
||||
onChange={(e) => setDraftConfidence(Number(e.target.value))}
|
||||
className="w-16 h-7 rounded border px-2 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
|
||||
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
|
||||
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
|
||||
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 新增事实表单
|
||||
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("context");
|
||||
const [confidence, setConfidence] = useState(80);
|
||||
const addFact = useAdminAddV1Fact(userId);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
|
||||
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-3" />新增事实
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">置信度</span>
|
||||
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
|
||||
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function V1BucketView({
|
||||
data,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
agent: string | null;
|
||||
userId: string;
|
||||
}) {
|
||||
const updateSection = useAdminUpdateV1Section(userId);
|
||||
|
||||
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
|
||||
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
|
||||
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
|
||||
|
||||
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
|
||||
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
|
||||
|
||||
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
|
||||
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 用户画像字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
|
||||
{allCtxFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
label={CTX_LABELS[field] ?? field}
|
||||
value={extractSummary(ctxRaw[field])}
|
||||
onSave={(summary) =>
|
||||
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
|
||||
{allHistoryFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
label={HISTORY_LABELS[field] ?? field}
|
||||
value={extractSummary(historyRaw[field])}
|
||||
onSave={(summary) =>
|
||||
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
|
||||
{factsRaw.map((fact, i) => (
|
||||
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
|
||||
))}
|
||||
<V1AddFactForm agent={agent} userId={userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||
|
||||
export function MemoryEntryList({
|
||||
entries,
|
||||
userId,
|
||||
agent,
|
||||
}: {
|
||||
entries: string[];
|
||||
userId: string;
|
||||
agent: string | null;
|
||||
}) {
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
const [editDraft, setEditDraft] = useState("");
|
||||
const [addDraft, setAddDraft] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const addMutation = useAdminAddMemoryEntry(userId);
|
||||
const replaceMutation = useAdminReplaceMemoryEntry(userId);
|
||||
const deleteMutation = useAdminDeleteMemoryEntry(userId);
|
||||
|
||||
const isPending =
|
||||
addMutation.isPending || replaceMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
function startEdit(index: number) {
|
||||
setEditIndex(index);
|
||||
setEditDraft(entries[index] ?? "");
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditIndex(null);
|
||||
setEditDraft("");
|
||||
}
|
||||
|
||||
async function confirmEdit(index: number) {
|
||||
const oldText = entries[index];
|
||||
if (!oldText || editDraft.trim() === oldText.trim()) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await replaceMutation.mutateAsync({ oldText, content: editDraft.trim(), agent });
|
||||
cancelEdit();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "修改失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entry: string) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ oldText: entry, agent });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const content = addDraft.trim();
|
||||
if (!content) return;
|
||||
try {
|
||||
await addMutation.mutateAsync({ content, agent });
|
||||
setAddDraft("");
|
||||
setIsAdding(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "新增失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{entries.length === 0 && !isAdding && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">暂无记忆条目</p>
|
||||
)}
|
||||
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className="group rounded-md border p-3">
|
||||
{editIndex === i ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
className="min-h-[80px] text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => confirmEdit(i)}
|
||||
disabled={replaceMutation.isPending}
|
||||
>
|
||||
{replaceMutation.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>
|
||||
<X className="size-3" />
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0 text-sm">·</span>
|
||||
<span className="flex-1 whitespace-pre-wrap text-sm">{entry}</span>
|
||||
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={() => startEdit(i)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 hover:text-destructive"
|
||||
onClick={() => handleDelete(entry)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isAdding ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3">
|
||||
<Textarea
|
||||
placeholder="输入新记忆条目内容…"
|
||||
value={addDraft}
|
||||
onChange={(e) => setAddDraft(e.target.value)}
|
||||
className="min-h-[80px] text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending || !addDraft.trim()}>
|
||||
{addMutation.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||
确认
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setIsAdding(false); setAddDraft(""); }}>
|
||||
<X className="size-3" />
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => setIsAdding(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
新增条目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
||||
|
||||
export function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
const { data: memory, isLoading } = useUserMemory(userId);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isV1 = memory?.version === "v1";
|
||||
const globalBucket = memory?.buckets.find((b) => b.name === "global");
|
||||
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
||||
|
||||
// ── V1 只读视图 ──────────────────────────────────────────────────────────
|
||||
if (isV1) {
|
||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||
const currentBucket = currentAgent
|
||||
? agentBuckets.find((b) => b.name === currentAgent)
|
||||
: globalBucket;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b px-4 pt-4">
|
||||
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
|
||||
V1 模式:记忆由系统自动提取,不支持手动管理
|
||||
</div>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="global">用户记忆</TabsTrigger>
|
||||
{agentBuckets.length > 0 && (
|
||||
<TabsTrigger value="agent">
|
||||
Agent 记忆
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{agentBuckets.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{agentBuckets.length > 0 && (
|
||||
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||||
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
|
||||
<SelectTrigger className="h-7 w-48 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||||
{b.display_name || b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
|
||||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
||||
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="user" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b px-4 pt-4">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="user">
|
||||
用户画像
|
||||
{userEntries.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{userEntries.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent">
|
||||
Agent 记忆
|
||||
{agentBuckets.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{agentBuckets.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="user" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
USER.md — 跨 Agent 共享的用户画像
|
||||
</p>
|
||||
<MemoryEntryList entries={userEntries} userId={userId} agent={null} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{agentBuckets.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">该用户暂无 Agent 记忆</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||||
<Select
|
||||
value={currentAgent ?? ""}
|
||||
onValueChange={(v) => setSelectedAgent(v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-48 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||||
{b.display_name || b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
|
||||
</div>
|
||||
{currentAgent && (
|
||||
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminMemoryPage() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
|
||||
const filteredUsers = (users ?? []).filter((u) =>
|
||||
u.email.toLowerCase().includes(userQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left: user list */}
|
||||
<div className="flex w-64 shrink-0 flex-col border-r">
|
||||
<div className="border-b p-3">
|
||||
<Input
|
||||
placeholder="搜索用户…"
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{usersLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
{!usersLoading && filteredUsers.length === 0 && (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">暂无用户</p>
|
||||
)}
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
className={`flex w-full flex-col items-start gap-0.5 px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent ${
|
||||
selectedUserId === user.id ? "bg-accent font-medium" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
<span className="max-w-full truncate">{user.email}</span>
|
||||
{user.system_role === "admin" && (
|
||||
<Badge variant="default" className="h-4 px-1 text-[10px]">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right: memory tabs */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{selectedUserId ? (
|
||||
<UserMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||
请在左侧选择用户
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { AdminMessage } from "@/core/admin/api";
|
||||
import {
|
||||
useUserMemory,
|
||||
useUserThreadMessages,
|
||||
useUserThreads,
|
||||
} from "@/core/admin/hooks";
|
||||
import type { UserListItem } from "@/core/auth/api";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
getEntries,
|
||||
MemoryEntryList,
|
||||
V1BucketView,
|
||||
} from "./admin-memory-page";
|
||||
|
||||
// ── Helpers (shared with AdminUserActivityPage) ─────────────────────────────
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
const text = (block as Record<string, unknown>).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (typeof content === "object") {
|
||||
const inner = (content as Record<string, unknown>).content;
|
||||
if (inner !== undefined) return extractText(inner);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function roleOf(message: AdminMessage): "user" | "assistant" | null {
|
||||
const type = (message.type ?? "").toLowerCase();
|
||||
if (type === "human") return "user";
|
||||
if (type === "ai" || type === "aimessagechunk") return "assistant";
|
||||
return null;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
// ── UserInfoPanel ───────────────────────────────────────────────────────────
|
||||
|
||||
function UserInfoPanel({ user }: { user: UserListItem }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 text-sm">
|
||||
<dt className="text-muted-foreground font-medium">邮箱</dt>
|
||||
<dd className="break-all">{user.email}</dd>
|
||||
<dt className="text-muted-foreground font-medium">角色</dt>
|
||||
<dd>
|
||||
{user.system_role === "admin" ? (
|
||||
<Badge variant="default">管理员</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">普通用户</Badge>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground font-medium">Skills</dt>
|
||||
<dd className="tabular-nums">{user.skill_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Agents</dt>
|
||||
<dd className="tabular-nums">{user.agent_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">User ID</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground break-all">{user.id}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MessageRow / MemoryBucketView (audit sub-components) ────────────────────
|
||||
|
||||
function MessageRow({ message }: { message: AdminMessage }) {
|
||||
const role = roleOf(message);
|
||||
if (!role) return null;
|
||||
const text = extractText(message.content).trim();
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
role === "user" ? "items-start" : "items-end",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{role === "user" ? "用户提问" : "AI 回答"}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
|
||||
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UserAuditPanel ──────────────────────────────────────────────────────────
|
||||
|
||||
function UserAuditPanel({ userId }: { userId: string }) {
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
|
||||
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||
userId,
|
||||
threadId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Thread list */}
|
||||
<div className="flex w-60 shrink-0 flex-col border-r">
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
|
||||
会话列表{threads ? `(${threads.length})` : ""}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{threadsLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!threadsLoading && (threads?.length ?? 0) === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
该用户暂无会话
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{(threads ?? []).map((thread) => (
|
||||
<button
|
||||
key={thread.thread_id}
|
||||
type="button"
|
||||
onClick={() => setThreadId(thread.thread_id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
|
||||
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm">
|
||||
{thread.display_name || "未命名会话"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmtTime(thread.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{!threadId && (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
|
||||
<MessageSquareIcon className="size-6" />
|
||||
请从左侧选择一个会话
|
||||
</div>
|
||||
)}
|
||||
{threadId && messagesLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{threadId && !messagesLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(messages ?? []).map((m, i) => (
|
||||
<MessageRow key={m.id ?? i} message={m} />
|
||||
))}
|
||||
{(messages ?? []).every((m) => !roleOf(m)) && (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
该会话暂无问答记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentMemoryPanel ─────────────────────────────────────────────────────────
|
||||
|
||||
function bucketLabel(name: string, displayName?: string): string {
|
||||
if (name === "default") return "默认智能体";
|
||||
return displayName || name;
|
||||
}
|
||||
|
||||
function AgentMemoryPanel({ userId }: { userId: string }) {
|
||||
const { data: memory, isLoading } = useUserMemory(userId);
|
||||
// 默认选中"默认助手"("default" 对应全局记忆文件)
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>("default");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isV1 = memory?.version === "v1";
|
||||
|
||||
// 非 global、非 default 的其他智能体 bucket
|
||||
const otherBuckets = (memory?.buckets ?? []).filter(
|
||||
(b) => b.name !== "global" && b.name !== "default",
|
||||
);
|
||||
|
||||
// 当前选中的 bucket
|
||||
const currentBucket = (memory?.buckets ?? []).find(
|
||||
(b) => b.name === selectedAgent,
|
||||
);
|
||||
|
||||
// "default" 对应 agent=null(全局记忆文件),与 _resolve_agent_name 的哨兵逻辑一致
|
||||
const bucketAgentParam = selectedAgent === "default" ? null : selectedAgent;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 智能体下拉选择器 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm shrink-0">智能体:</span>
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="h-8 w-48" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 默认助手始终显示,对应全局记忆文件 */}
|
||||
<SelectItem value="default">默认助手</SelectItem>
|
||||
{otherBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name}>
|
||||
{bucketLabel(b.name, b.display_name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isV1 && currentBucket && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{getEntries(currentBucket.data).length} 条
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前智能体记忆内容 */}
|
||||
{!currentBucket ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
暂无记忆数据
|
||||
</div>
|
||||
) : isV1 ? (
|
||||
<V1BucketView
|
||||
data={currentBucket.data}
|
||||
agent={bucketAgentParam}
|
||||
userId={userId}
|
||||
/>
|
||||
) : (
|
||||
<MemoryEntryList
|
||||
entries={getEntries(currentBucket.data)}
|
||||
userId={userId}
|
||||
agent={bucketAgentParam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AdminUserHub (main) ─────────────────────────────────────────────────────
|
||||
|
||||
export function AdminUserHub() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
const list = users ?? [];
|
||||
return needle
|
||||
? list.filter((u) => u.email.toLowerCase().includes(needle))
|
||||
: list;
|
||||
}, [users, userQuery]);
|
||||
|
||||
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
|
||||
|
||||
function selectUser(id: string) {
|
||||
setSelectedUserId(id);
|
||||
setActiveTab("info");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理用户信息、审计历史问答、编辑用户记忆。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left: user list */}
|
||||
<div className="flex w-64 shrink-0 flex-col border-r">
|
||||
<div className="relative p-2">
|
||||
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
placeholder="搜索用户邮箱"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{usersLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!usersLoading && filteredUsers.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
无用户
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{filteredUsers.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => selectUser(u.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
|
||||
u.id === selectedUserId
|
||||
? "bg-accent font-medium"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
{u.system_role === "admin" && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right: tabs */}
|
||||
{!selectedUserId ? (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||
从左侧选择一个用户
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||
className="flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="border-b px-4 pt-3 shrink-0">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||
<TabsTrigger value="memory">记忆管理</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="info" className="flex-1 overflow-auto p-0">
|
||||
{selectedUser && <UserInfoPanel user={selectedUser} />}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="flex min-h-0 flex-1 flex-col p-0"
|
||||
>
|
||||
<UserAuditPanel key={selectedUserId} userId={selectedUserId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="memory"
|
||||
className="flex min-h-0 flex-1 flex-col p-0"
|
||||
>
|
||||
<AgentMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AgentWelcome({
|
||||
@@ -14,8 +15,10 @@ export function AgentWelcome({
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = agent?.name ?? agentName;
|
||||
const description = agent?.description;
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
@@ -38,11 +39,17 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||
import {
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
@@ -53,6 +60,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const router = useRouter();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -147,6 +155,26 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured() {
|
||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
||||
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||
// safe seed; the manage dialog can rewrite to 0..N-1 later).
|
||||
const turningOn = agent.featured_order == null;
|
||||
if (turningOn && !agent.published) {
|
||||
toast.error("只有已发布的智能体可以加入首页选择器");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setFeatured.mutateAsync({
|
||||
id: agent.id,
|
||||
order: turningOn ? Date.now() : null,
|
||||
});
|
||||
toast.success(turningOn ? "已加入首页选择器" : "已从首页选择器移除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSkill(skillName: string) {
|
||||
setSelectedSkills((prev) => {
|
||||
if (prev.includes(skillName)) {
|
||||
@@ -163,17 +191,17 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{agent.name}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-base" title={desensitize(agent.name)}>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{agent.model}
|
||||
{desensitize(agent.model)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,11 +239,21 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title="已加入首页选择器"
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
首页推荐
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{agent.description}
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -235,7 +273,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{group}
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
))}
|
||||
{agent.skills?.map((skill) => (
|
||||
@@ -249,7 +287,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
{desensitize(skill)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -263,6 +301,26 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</Button>
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.featured_order != null &&
|
||||
"text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
onClick={() => void handleToggleFeatured()}
|
||||
disabled={setFeatured.isPending}
|
||||
title={
|
||||
agent.featured_order != null
|
||||
? "从首页选择器移除"
|
||||
: "加入首页选择器"
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -320,7 +378,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
|
||||
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||
</DialogDescription>
|
||||
@@ -371,10 +429,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={skill.description}
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 max-w-[200px] rounded-r-none"
|
||||
>
|
||||
<span className="truncate">{skill.name}</span>
|
||||
<span className="truncate">{desensitize(skill.name)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -469,6 +527,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<SkillDetailDialog
|
||||
skillName={detailSkillName}
|
||||
open={detailSkillName !== null}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
BotIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import {
|
||||
useAgentSelector,
|
||||
useAgents,
|
||||
useReorderFeaturedAgents,
|
||||
useSetAgentFeatured,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentManageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only homepage agent picker management.
|
||||
*
|
||||
* Single list (no tabs, no per-user buckets): admin curates exactly what every
|
||||
* user sees in the new-chat selector. Up/down to reorder, ✕ to remove, + to
|
||||
* add a published agent. All actions hit the server immediately and react to
|
||||
* TanStack Query invalidation, so the selector dropdown stays in lockstep.
|
||||
*/
|
||||
export function AgentManageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentManageDialogProps) {
|
||||
const { agents, isLoading } = useAgentSelector();
|
||||
const { agents: allAgents } = useAgents();
|
||||
const setFeaturedMutation = useSetAgentFeatured();
|
||||
const reorderMutation = useReorderFeaturedAgents();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const existing = new Set(agents.map((a) => a.id));
|
||||
return allAgents.filter((a) => a.published && !existing.has(a.id));
|
||||
}, [agents, allAgents]);
|
||||
|
||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||
|
||||
const move = async (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= agents.length) return;
|
||||
const next = agents.map((a) => a.id);
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
try {
|
||||
await reorderMutation.mutateAsync(next);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (agentId: string) => {
|
||||
try {
|
||||
await setFeaturedMutation.mutateAsync({ id: agentId, order: null });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const add = async (agent: Agent) => {
|
||||
if (!agent.published) {
|
||||
toast.error("只有已发布的智能体可以加入选择器");
|
||||
return;
|
||||
}
|
||||
const maxOrder = agents.reduce(
|
||||
(acc, a) => Math.max(acc, a.featured_order ?? 0),
|
||||
-1,
|
||||
);
|
||||
try {
|
||||
await setFeaturedMutation.mutateAsync({
|
||||
id: agent.id,
|
||||
order: maxOrder + 1,
|
||||
});
|
||||
setPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理 Agent 列表</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置首页对话框里展示的 agent。所有人会看到同一份列表,按下方顺序展示。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
还没有配置任何 agent。点击下方“添加 Agent”开始。
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[50vh] divide-y overflow-y-auto rounded-md border">
|
||||
{agents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
>
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip content="上移">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => void move(index, -1)}
|
||||
className="size-7"
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="下移">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy || index === agents.length - 1}
|
||||
onClick={() => void move(index, 1)}
|
||||
className="size-7"
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="从选择器移除">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(agent.id)}
|
||||
className="text-destructive hover:text-destructive size-7"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-1 size-3.5" />
|
||||
添加 Agent
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogTitle className="sr-only">添加 Agent</DialogTitle>
|
||||
<Command className={cn("**:data-[slot=command-input-wrapper]:h-auto")}>
|
||||
<CommandInput placeholder="搜索可添加的 agent…" className="h-auto py-3.5" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{candidates.length === 0
|
||||
? "没有更多已发布的 agent 可添加。"
|
||||
: "没有找到匹配的 agent。"}
|
||||
</CommandEmpty>
|
||||
{candidates.length > 0 && (
|
||||
<CommandGroup>
|
||||
{candidates.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => void add(agent)}
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, SettingsIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Homepage agent picker — shows the admin-curated selector list.
|
||||
*
|
||||
* - Non-admins see a read-only dropdown.
|
||||
* - Admins also see a "管理 Agent 列表" entry that opens the management dialog.
|
||||
*
|
||||
* Selecting an agent flips this chip to a coloured pill with a ✕ to clear.
|
||||
* The actual handoff to ``AgentChatPage`` happens in ``ChatPage`` on submit.
|
||||
*/
|
||||
export function AgentSelector({
|
||||
selectedAgentId,
|
||||
onAgentChange,
|
||||
disabled,
|
||||
}: AgentSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const { agents, isLoading } = useAgentSelector();
|
||||
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
|
||||
|
||||
const selectedAgent = useMemo<Agent | null>(() => {
|
||||
if (!selectedAgentId) return null;
|
||||
return agents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
}, [agents, selectedAgentId]);
|
||||
|
||||
const handleSelect = (agent: Agent) => {
|
||||
onAgentChange(agent.id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAgentChange(null);
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-3" />
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
{selectedAgent && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="清除已选 agent"
|
||||
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAgentChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</PromptInputButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
|
||||
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
<CommandInput placeholder="搜索 agent…" className="h-auto py-3.5" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? "加载中…" : "没有找到匹配的 agent"}
|
||||
</CommandEmpty>
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup>
|
||||
{agents.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
agent.id === selectedAgentId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!isLoading && agents.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{isAdmin
|
||||
? "还没有配置任何 agent。点击下方 “管理” 添加几个。"
|
||||
: "管理员还没有配置可选的 agent。"}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
{isAdmin && (
|
||||
<div className="border-t px-2 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setManageOpen(true);
|
||||
}}
|
||||
>
|
||||
<SettingsIcon className="mr-2 size-3.5" />
|
||||
管理 Agent 列表 …
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isAdmin && (
|
||||
<AgentManageDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
@@ -44,10 +45,12 @@ import {
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ArtifactLink } from "../citations/artifact-link";
|
||||
@@ -377,6 +380,26 @@ export function ArtifactFileDetail({
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
const [isExportingWord, setIsExportingWord] = useState(false);
|
||||
const handleDownloadAsWord = useCallback(async () => {
|
||||
const source = (displayContent ?? "").trim();
|
||||
if (!source) {
|
||||
toast.error("Markdown 内容为空,无法另存为 Word");
|
||||
return;
|
||||
}
|
||||
setIsExportingWord(true);
|
||||
try {
|
||||
const baseName = getFileName(filepath).replace(/\.(md|markdown)$/i, "");
|
||||
await downloadMarkdownAsDocx(source, baseName || "document", baseName);
|
||||
toast.success("已另存为 Word");
|
||||
} catch (error) {
|
||||
console.error("Failed to export markdown as docx:", error);
|
||||
toast.error(error instanceof Error ? error.message : "另存为 Word 失败");
|
||||
} finally {
|
||||
setIsExportingWord(false);
|
||||
}
|
||||
}, [displayContent, filepath]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
|
||||
@@ -518,6 +541,15 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && language === "markdown" && (
|
||||
<ArtifactAction
|
||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||
label="另存为 Word"
|
||||
tooltip="另存为 Word (.docx)"
|
||||
disabled={isExportingWord || !displayContent}
|
||||
onClick={handleDownloadAsWord}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && (
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
@@ -577,7 +609,7 @@ export function ArtifactFileDetail({
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
content={desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
@@ -587,7 +619,11 @@ export function ArtifactFileDetail({
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
value={
|
||||
canEditArtifact
|
||||
? (displayContent ?? "")
|
||||
: desensitize(displayContent ?? "")
|
||||
}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Download, FileJson, FileText } from "lucide-react";
|
||||
import { Download, FileJson, FileText, FileType2 } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
exportThreadAsJSON,
|
||||
exportThreadAsMarkdown,
|
||||
exportThreadAsWord,
|
||||
} from "@/core/threads/export";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
@@ -28,7 +29,7 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
const messages = thread.messages;
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: "markdown" | "json") => {
|
||||
async (format: "markdown" | "json" | "word") => {
|
||||
if (messages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
@@ -39,12 +40,18 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
values: thread.values,
|
||||
} as AgentThread;
|
||||
|
||||
if (format === "markdown") {
|
||||
exportThreadAsMarkdown(agentThread, messages);
|
||||
} else {
|
||||
exportThreadAsJSON(agentThread, messages);
|
||||
try {
|
||||
if (format === "markdown") {
|
||||
exportThreadAsMarkdown(agentThread, messages);
|
||||
} else if (format === "json") {
|
||||
exportThreadAsJSON(agentThread, messages);
|
||||
} else {
|
||||
await exportThreadAsWord(agentThread, messages);
|
||||
}
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "导出失败");
|
||||
}
|
||||
toast.success(t.common.exportSuccess);
|
||||
},
|
||||
[messages, thread.values, threadId, t],
|
||||
);
|
||||
@@ -67,11 +74,15 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => handleExport("markdown")}>
|
||||
<DropdownMenuItem onSelect={() => void handleExport("markdown")}>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleExport("json")}>
|
||||
<DropdownMenuItem onSelect={() => void handleExport("word")}>
|
||||
<FileType2 className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsWord}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleExport("json")}>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
@@ -64,6 +65,8 @@ import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
|
||||
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
@@ -119,6 +122,7 @@ export function InputBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -131,6 +135,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -144,6 +149,7 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -159,6 +165,15 @@ export function InputBox({
|
||||
on: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
/**
|
||||
* Optional agent picker shown alongside the model selector. Only the new-chat
|
||||
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
|
||||
* thread is already bound to one agent.
|
||||
*/
|
||||
agentSelector?: {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -273,6 +288,14 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -514,9 +537,15 @@ export function InputBox({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem] dark:bg-slate-800 dark:border-slate-700 dark:ring-slate-700 dark:shadow-none",
|
||||
"transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-3xl",
|
||||
// landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
|
||||
: "rounded-3xl bg-background border border-border shadow-sm dark:bg-slate-800 dark:border-slate-700",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -531,7 +560,8 @@ export function InputBox({
|
||||
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
|
||||
<PromptInputTextarea
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a] dark:placeholder:text-slate-500",
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -539,7 +569,7 @@ export function InputBox({
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="flex items-center border-t border-[#f4ede4] dark:border-slate-700 px-2 pt-2.5 pb-1.5">
|
||||
<PromptInputFooter className="flex items-center border-t-0 px-2 pt-1 pb-1.5">
|
||||
<PromptInputTools className="gap-1.5">
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
@@ -550,7 +580,7 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
|
||||
<AddAttachmentsButton className="rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100" />
|
||||
<Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
@@ -560,10 +590,10 @@ export function InputBox({
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none",
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
context.writing_mode
|
||||
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
|
||||
? "border border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleWritingModeToggle}
|
||||
>
|
||||
@@ -571,6 +601,26 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
@@ -582,7 +632,7 @@ export function InputBox({
|
||||
: "flash"
|
||||
}
|
||||
>
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100">
|
||||
<div>
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
@@ -744,7 +794,7 @@ export function InputBox({
|
||||
</PromptInputActionMenu>
|
||||
{supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100">
|
||||
<div className="text-xs font-normal">
|
||||
{t.inputBox.reasoningEffort}:
|
||||
{context.reasoning_effort === "minimal" &&
|
||||
@@ -860,6 +910,13 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className="gap-1.5">
|
||||
{agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -873,17 +930,17 @@ export function InputBox({
|
||||
onClick={promptPrefix.onToggle}
|
||||
aria-pressed={promptPrefix.on}
|
||||
className={cn(
|
||||
"rounded-full border px-3 shadow-none transition-colors",
|
||||
"rounded-full px-3 shadow-none transition-colors",
|
||||
promptPrefix.on
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
|
||||
? "border border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-[160px] truncate text-xs font-normal">
|
||||
{promptPrefix.label}
|
||||
</span>
|
||||
{promptPrefix.on && (
|
||||
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
|
||||
<CheckIcon className="ml-1 size-3.5 text-amber-700 dark:text-amber-200" />
|
||||
)}
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
@@ -893,7 +950,7 @@ export function InputBox({
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
|
||||
<PromptInputButton className="rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100">
|
||||
<div className="flex min-w-0 flex-col items-start text-left">
|
||||
<ModelSelectorName className="text-xs font-normal">
|
||||
{selectedModel?.display_name}
|
||||
@@ -927,7 +984,7 @@ export function InputBox({
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
<PromptInputSubmit
|
||||
className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)] dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
|
||||
className="rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
status={status}
|
||||
@@ -935,9 +992,10 @@ export function InputBox({
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
{!isNewThread && (
|
||||
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa] dark:bg-background"></div>
|
||||
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-background"></div>
|
||||
)}
|
||||
</PromptInput>
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
export function DownloadWordButton({
|
||||
markdown,
|
||||
fileName,
|
||||
title,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & {
|
||||
markdown: string;
|
||||
fileName?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const handleDownload = async () => {
|
||||
const source = markdown?.trim();
|
||||
if (!source) {
|
||||
toast.error("无可下载的 Markdown 内容");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await downloadMarkdownAsDocx(source, fileName || "message", title);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip content="下载为 Word">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={handleDownload}
|
||||
{...props}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
@@ -63,6 +64,8 @@ export function MarkdownContent({
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
const displayContent = desensitize(content);
|
||||
|
||||
return (
|
||||
<MessageResponse
|
||||
className={className}
|
||||
@@ -70,7 +73,7 @@ export function MarkdownContent({
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
{displayContent}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUp,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
@@ -23,7 +24,24 @@ import {
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
resolveSearchQuery,
|
||||
type SearchResultItem,
|
||||
} from "@/core/messages/search-results";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
@@ -203,23 +221,20 @@ function ToolCall({
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
if (name === "web_search") {
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (typeof args.query === "string") {
|
||||
label = t.toolCalls.searchOnWebFor(args.query);
|
||||
if (query) {
|
||||
label =
|
||||
name === "web_search"
|
||||
? t.toolCalls.searchOnWebFor(query)
|
||||
: t.toolCalls.searchFor(query);
|
||||
}
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||
{Array.isArray(result) && (
|
||||
<ChainOfThoughtSearchResults>
|
||||
{result.map((item) => (
|
||||
<ChainOfThoughtSearchResult key={item.url}>
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
{item.title}
|
||||
</a>
|
||||
</ChainOfThoughtSearchResult>
|
||||
))}
|
||||
</ChainOfThoughtSearchResults>
|
||||
{searchItems.length > 0 && (
|
||||
<WebSearchResultsList results={searchItems} />
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
@@ -448,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
|
||||
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
result?: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
@@ -480,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = findToolCallResult(toolCallId, messages);
|
||||
if (toolCallResult) {
|
||||
try {
|
||||
const json = JSON.parse(toolCallResult);
|
||||
step.result = json;
|
||||
} catch {
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
);
|
||||
if (toolCallResult !== undefined) {
|
||||
if (typeof toolCallResult === "string") {
|
||||
try {
|
||||
step.result = JSON.parse(toolCallResult) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
step.result = toolCallResult;
|
||||
}
|
||||
} else {
|
||||
step.result = toolCallResult;
|
||||
}
|
||||
}
|
||||
@@ -496,3 +521,92 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条检索结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item, idx) => (
|
||||
<SearchResultCard key={item.url ?? idx} item={item} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
let sourceLabel: string | undefined = item.source;
|
||||
if (item.url) {
|
||||
try {
|
||||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
||||
>
|
||||
{item.title && (
|
||||
<div className="text-foreground line-clamp-1 text-sm font-medium">
|
||||
{item.title}
|
||||
</div>
|
||||
)}
|
||||
{(sourceLabel || date) && (
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
{sourceLabel && <span className="truncate">{sourceLabel}</span>}
|
||||
{sourceLabel && date && <span>·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<div className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
||||
{item.title ?? "检索结果"}
|
||||
</DialogTitle>
|
||||
{(sourceLabel || date) && (
|
||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||
{sourceLabel && <span>{sourceLabel}</span>}
|
||||
{sourceLabel && date && <span>·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{item.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{item.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,11 +37,13 @@ import {
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CopyButton } from "../copy-button";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
|
||||
@@ -82,6 +84,10 @@ export function MessageListItem({
|
||||
tokenUsageEnabled?: boolean;
|
||||
}) {
|
||||
const isHuman = message.type === "human";
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
"";
|
||||
return (
|
||||
<AIElementMessage
|
||||
className={cn("group/conversation-message relative w-full", className)}
|
||||
@@ -102,13 +108,13 @@ export function MessageListItem({
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton
|
||||
clipboardData={
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
""
|
||||
}
|
||||
/>
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{!isHuman && messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
@@ -221,7 +227,7 @@ function MessageContent_({
|
||||
<TaskTrigger title="">
|
||||
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
||||
<Loader className="size-4" />
|
||||
<span>{contentToDisplay}</span>
|
||||
<span>{desensitize(contentToDisplay)}</span>
|
||||
</div>
|
||||
</TaskTrigger>
|
||||
</Task>
|
||||
@@ -235,7 +241,7 @@ function MessageContent_({
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
|
||||
</Reasoning>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -254,7 +260,7 @@ function MessageContent_({
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{contentToDisplay}
|
||||
{desensitize(contentToDisplay)}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
@@ -64,6 +65,113 @@ export function MessageList({
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
// (no user reply has been sent for them yet).
|
||||
const pendingClarificationIds = useMemo(() => {
|
||||
let lastHumanIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.type === "human") {
|
||||
lastHumanIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ids: string[] = [];
|
||||
for (let i = lastHumanIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg && msg.id && msg.type === "tool" && msg.name === "ask_clarification") {
|
||||
ids.push(msg.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [messages]);
|
||||
|
||||
const [clarificationSelections, setClarificationSelections] = useState<
|
||||
Map<string, ClarificationSelection>
|
||||
>(new Map());
|
||||
const [submittingClarification, setSubmittingClarification] = useState(false);
|
||||
|
||||
const handleClarificationSelectionChange = useCallback(
|
||||
(id: string, next: ClarificationSelection) => {
|
||||
setClarificationSelections((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(id, next);
|
||||
return map;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitAllClarifications = useCallback(async (
|
||||
selectionOverrides?: Map<string, ClarificationSelection>,
|
||||
) => {
|
||||
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||
return;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (let index = 0; index < pendingClarificationIds.length; index++) {
|
||||
const id = pendingClarificationIds[index]!;
|
||||
const message = messages.find((msg) => msg.id === id);
|
||||
const payload = message ? getClarificationPayload(message) : null;
|
||||
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const options = (payload?.options ?? []).filter(
|
||||
(option) => typeof option.label === "string" && option.label.trim(),
|
||||
);
|
||||
const selectedLabels = selection.selectedIds
|
||||
.map(
|
||||
(sid) =>
|
||||
options.find((option) => (option.id || option.label) === sid)?.label,
|
||||
)
|
||||
.filter((label): label is string => Boolean(label && label.trim()));
|
||||
const trimmedCustom = selection.custom.trim();
|
||||
let part = "";
|
||||
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||
part = `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||
} else if (selectedLabels.length > 0) {
|
||||
part = `我选择:${selectedLabels.join("、")}`;
|
||||
} else if (trimmedCustom) {
|
||||
part = trimmedCustom;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (pendingClarificationIds.length > 1) {
|
||||
const question = payload?.question?.trim();
|
||||
parts.push(
|
||||
question
|
||||
? `问题${index + 1}「${question}」:${part}`
|
||||
: `问题${index + 1}:${part}`,
|
||||
);
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const combined =
|
||||
pendingClarificationIds.length > 1
|
||||
? `针对你提出的多个问题,依次回答:\n${parts.map((p) => `- ${p}`).join("\n")}`
|
||||
: parts[0]!;
|
||||
setSubmittingClarification(true);
|
||||
try {
|
||||
await onClarificationSubmit(combined);
|
||||
} finally {
|
||||
setSubmittingClarification(false);
|
||||
}
|
||||
}, [pendingClarificationIds, messages, clarificationSelections, onClarificationSubmit]);
|
||||
|
||||
const totalPending = pendingClarificationIds.length;
|
||||
const hasAnyClarificationAnswer = useMemo(() => {
|
||||
return pendingClarificationIds.some((id) => {
|
||||
const sel = clarificationSelections.get(id);
|
||||
return (sel?.selectedIds.length ?? 0) > 0 || (sel?.custom.trim() ?? "").length > 0;
|
||||
});
|
||||
}, [pendingClarificationIds, clarificationSelections]);
|
||||
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
@@ -89,14 +197,39 @@ export function MessageList({
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
const messageId = message.id ?? "";
|
||||
const pendingIndex = pendingClarificationIds.indexOf(messageId);
|
||||
const isPending = pendingIndex >= 0;
|
||||
const isLastPending =
|
||||
isPending && pendingIndex === totalPending - 1;
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
<ClarificationCard
|
||||
fallbackContent={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
message={message}
|
||||
onSubmit={onClarificationSubmit}
|
||||
rehypePlugins={rehypePlugins}
|
||||
isPending={isPending}
|
||||
pendingIndex={isPending ? pendingIndex : undefined}
|
||||
pendingCount={isPending ? totalPending : undefined}
|
||||
isLastPending={isLastPending}
|
||||
selection={
|
||||
clarificationSelections.get(messageId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
}
|
||||
}
|
||||
onSelectionChange={(next) =>
|
||||
handleClarificationSelectionChange(messageId, next)
|
||||
}
|
||||
submitting={submittingClarification}
|
||||
canSubmit={hasAnyClarificationAnswer}
|
||||
onSubmitAll={(override) => {
|
||||
const overrides = override
|
||||
? new Map([[messageId, override]])
|
||||
: undefined;
|
||||
void handleSubmitAllClarifications(overrides);
|
||||
}}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -287,6 +420,12 @@ type ClarificationPayload = {
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
};
|
||||
|
||||
type ClarificationSelection = {
|
||||
selectedIds: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
export type SetupAgentApprovalPayload = {
|
||||
@@ -447,19 +586,32 @@ function ClarificationCard({
|
||||
fallbackContent,
|
||||
isLoading,
|
||||
message,
|
||||
onSubmit,
|
||||
rehypePlugins,
|
||||
isPending,
|
||||
pendingIndex,
|
||||
pendingCount,
|
||||
isLastPending,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
submitting,
|
||||
canSubmit,
|
||||
onSubmitAll,
|
||||
}: {
|
||||
fallbackContent: string;
|
||||
isLoading: boolean;
|
||||
message: unknown;
|
||||
onSubmit?: (answer: string) => void | Promise<void>;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
isPending: boolean;
|
||||
pendingIndex?: number;
|
||||
pendingCount?: number;
|
||||
isLastPending: boolean;
|
||||
selection: ClarificationSelection;
|
||||
onSelectionChange: (next: ClarificationSelection) => void;
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
@@ -479,27 +631,46 @@ function ClarificationCard({
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = false;
|
||||
const disabled = !isPending || submitting;
|
||||
const multiPending = (pendingCount ?? 0) > 1;
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allow_multiple,
|
||||
payload.clarification_type,
|
||||
);
|
||||
|
||||
async function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed) {
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit?.(trimmed);
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
if (!allowMultiple) {
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
onSelectionChange(next);
|
||||
void onSubmitAll(next);
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
onSelectionChange({ ...selection, selectedIds: next });
|
||||
}
|
||||
|
||||
function updateCustom(value: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onSelectionChange({ ...selection, custom: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
需要你的确认
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<span>需要你的确认</span>
|
||||
{multiPending && pendingIndex !== undefined && pendingCount ? (
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]">
|
||||
{pendingIndex + 1} / {pendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payload.context ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">
|
||||
@@ -513,49 +684,84 @@ function ClarificationCard({
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id || option.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||
{option.description}
|
||||
</span>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
const isSelected = selection.selectedIds.includes(optionId);
|
||||
return (
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
{option.description ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-normal leading-5",
|
||||
isSelected
|
||||
? "text-primary-foreground/80"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allow_custom !== false ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={customAnswer}
|
||||
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
value={selection.custom}
|
||||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||
placeholder={
|
||||
options.length > 0
|
||||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||
}
|
||||
disabled={disabled}
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending && allowMultiple ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || !customAnswer.trim()}
|
||||
onClick={() => void submitAnswer(customAnswer)}
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
发送回答
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : allowMultiple ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
@@ -57,12 +58,22 @@ import {
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
|
||||
export function RecentChatList() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
|
||||
};
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
||||
useParams<{
|
||||
thread_id: string;
|
||||
@@ -150,7 +161,9 @@ export function RecentChatList() {
|
||||
);
|
||||
|
||||
const visibleThreads = threads.filter(
|
||||
(thread) => thread.metadata?.thread_type !== "scheduler",
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap",
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
@@ -158,15 +171,27 @@ export function RecentChatList() {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2">
|
||||
<SidebarGroupLabel className="text-muted-foreground/90 px-2 text-[11px] font-semibold tracking-wide uppercase">
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{visibleThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
return (
|
||||
@@ -174,12 +199,20 @@ export function RecentChatList() {
|
||||
key={thread.thread_id}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive} asChild>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
asChild
|
||||
className={
|
||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
{desensitize(titleOfThread(thread))}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
formatRelative,
|
||||
STATUS_LABELS,
|
||||
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
||||
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
|
||||
|
||||
const AI_WRITING_PATH = "/page/workspace/ai-writing";
|
||||
|
||||
function openSession(
|
||||
session: AIWritingSessionSummary,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (isOnPage) {
|
||||
// 已在 AI 写作页面,直接通过自定义事件通知面板
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ai-writing:open-session", { detail: session }),
|
||||
);
|
||||
} else {
|
||||
// 不在该页面,把 session 存入 sessionStorage 后跳转
|
||||
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
|
||||
router.push(AI_WRITING_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
export function RecentWritingList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { sessions, loading, refresh } = useAIWritingSessions();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
|
||||
);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (!loading && sessions.length === 0) return null;
|
||||
|
||||
const isOnPage = pathname === AI_WRITING_PATH;
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>最近写作</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
sessions.slice(0, 10).map((session) => {
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? {
|
||||
label: session.status,
|
||||
color: "text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<SidebarMenuItem key={session.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openSession(session, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{session.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className={cn("text-[10px] font-medium", color)}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
|
||||
export function AppearanceSettingsPage() {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "system",
|
||||
label: t.settings.appearance.system,
|
||||
description: t.settings.appearance.systemDescription,
|
||||
icon: MonitorSmartphoneIcon,
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
label: t.settings.appearance.light,
|
||||
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.system,
|
||||
t.settings.appearance.systemDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
|
||||
title={t.settings.appearance.themeTitle}
|
||||
description={t.settings.appearance.themeDescription}
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{themeOptions.map((option) => (
|
||||
<ThemePreviewCard
|
||||
key={option.id}
|
||||
@@ -76,8 +69,7 @@ export function AppearanceSettingsPage() {
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "system" | "light" | "dark"}
|
||||
systemTheme={systemTheme}
|
||||
mode={option.id as "light" | "dark"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
@@ -122,7 +114,7 @@ export function AppearanceSettingsPage() {
|
||||
<div className="text-sm font-medium">回答速度指示器</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
在生成回答时显示一个小胶囊,实时估算当前 token/s
|
||||
并用绿到红的渐变条直观反映快慢。默认关闭。
|
||||
并用绿到红的渐变条直观反映快慢。默认开启。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -144,19 +136,16 @@ function ThemePreviewCard({
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
systemTheme,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "system" | "light" | "dark";
|
||||
systemTheme?: string;
|
||||
onSelect: (mode: "system" | "light" | "dark") => void;
|
||||
mode: "light" | "dark";
|
||||
onSelect: (mode: "light" | "dark") => void;
|
||||
}) {
|
||||
const previewMode =
|
||||
mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
|
||||
const previewMode = mode;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -36,10 +37,12 @@ import {
|
||||
useReplaceMemoryEntry,
|
||||
} from "@/core/memory/hooks";
|
||||
import type {
|
||||
MemoryFact,
|
||||
MemorySearchMode,
|
||||
MemoryTarget,
|
||||
MemoryV2Config,
|
||||
} from "@/core/memory/types";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -54,6 +57,10 @@ export function MemorySettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const m = t.settings.memory;
|
||||
|
||||
// 先读配置拿到 version,再决定渲染哪套 UI
|
||||
const { configResponse } = useMemoryConfig();
|
||||
const memoryVersion = configResponse?.version ?? "v2";
|
||||
|
||||
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
|
||||
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
|
||||
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
|
||||
@@ -167,7 +174,7 @@ export function MemorySettingsPage() {
|
||||
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm [overflow-wrap:anywhere]">
|
||||
{entry}
|
||||
{desensitize(entry)}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
@@ -211,7 +218,33 @@ export function MemorySettingsPage() {
|
||||
</div>
|
||||
) : !memory ? (
|
||||
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
|
||||
) : memoryVersion === "v1" ? (
|
||||
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{m.workNotesAgentLabel}:
|
||||
</span>
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="h-8 w-44" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
{m.workNotesDefaultAgent}
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{desensitize(a.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<MemoryV1View memory={memory} />
|
||||
</div>
|
||||
) : (
|
||||
// V2 模式:USER.md / MEMORY.md 条目编辑
|
||||
<div className="space-y-4">
|
||||
{renderEntrySection(
|
||||
"user",
|
||||
@@ -240,7 +273,7 @@ export function MemorySettingsPage() {
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{desensitize(a.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -251,13 +284,17 @@ export function MemorySettingsPage() {
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<div className="mt-8">
|
||||
<MemoryConfigPanel />
|
||||
</div>
|
||||
{memoryVersion !== "v1" && (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<MemoryConfigPanel />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<MemorySearchPanel />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<MemorySearchPanel />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 新增 / 编辑条目对话框 */}
|
||||
<Dialog
|
||||
@@ -320,7 +357,7 @@ export function MemorySettingsPage() {
|
||||
<div className="text-muted-foreground mb-1 font-medium">
|
||||
{m.entryToDeleteLabel}
|
||||
</div>
|
||||
<p className="break-words">{entryToDelete.content}</p>
|
||||
<p className="break-words">{desensitize(entryToDelete.content)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
@@ -345,16 +382,13 @@ export function MemorySettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 记忆设置面板:用户可改的白名单字段。 */
|
||||
/** 记忆设置面板:仅暴露 Hindsight 这一个可选开关,其余默认开启不展示。 */
|
||||
function MemoryConfigPanel() {
|
||||
const { t } = useI18n();
|
||||
const m = t.settings.memory;
|
||||
const { configResponse, isLoading } = useMemoryConfig();
|
||||
const patchConfig = usePatchMemoryConfig();
|
||||
|
||||
const maxTokensId = useId();
|
||||
const bankMissionId = useId();
|
||||
|
||||
async function patch(overrides: Record<string, unknown>) {
|
||||
try {
|
||||
await patchConfig.mutateAsync(overrides);
|
||||
@@ -367,9 +401,7 @@ function MemoryConfigPanel() {
|
||||
const config: MemoryV2Config | null = configResponse?.config ?? null;
|
||||
const writable = new Set(configResponse?.writable_fields ?? []);
|
||||
|
||||
function isWritable(field: string) {
|
||||
return writable.has(field);
|
||||
}
|
||||
const hindsightOn = config?.injection.include_hindsight ?? false;
|
||||
|
||||
return (
|
||||
<SettingsSection title={m.settingsTitle} description={m.settingsDesc}>
|
||||
@@ -377,129 +409,195 @@ function MemoryConfigPanel() {
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<ToggleRow
|
||||
label={m.injectionEnabled}
|
||||
description={m.injectionEnabledDesc}
|
||||
checked={config.injection.enabled}
|
||||
disabled={!isWritable("injection.enabled") || patchConfig.isPending}
|
||||
onChange={(v) => void patch({ injection: { enabled: v } })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={m.includeBuiltin}
|
||||
description={m.includeBuiltinDesc}
|
||||
checked={config.injection.include_builtin}
|
||||
disabled={
|
||||
!isWritable("injection.include_builtin") || patchConfig.isPending
|
||||
}
|
||||
onChange={(v) => void patch({ injection: { include_builtin: v } })}
|
||||
/>
|
||||
{/* 只暴露 Hindsight 开关,记忆总开关和本地记忆默认开启不展示 */}
|
||||
<ToggleRow
|
||||
label={m.includeHindsight}
|
||||
description={m.includeHindsightDesc}
|
||||
checked={config.injection.include_hindsight}
|
||||
checked={hindsightOn}
|
||||
disabled={
|
||||
!isWritable("injection.include_hindsight") || patchConfig.isPending
|
||||
!writable.has("injection.include_hindsight") || patchConfig.isPending
|
||||
}
|
||||
onChange={(v) => void patch({ injection: { include_hindsight: v } })}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={maxTokensId} className="text-sm font-medium">
|
||||
{m.maxTokens}
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">{m.maxTokensDesc}</p>
|
||||
</div>
|
||||
<Input
|
||||
id={maxTokensId}
|
||||
type="number"
|
||||
min="100"
|
||||
max="8000"
|
||||
step="100"
|
||||
className="w-32"
|
||||
defaultValue={config.injection.max_tokens}
|
||||
disabled={!isWritable("injection.max_tokens") || patchConfig.isPending}
|
||||
onBlur={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (
|
||||
Number.isFinite(value) &&
|
||||
value !== config.injection.max_tokens
|
||||
) {
|
||||
void patch({ injection: { max_tokens: value } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Hindsight 细节设置:仅在 Hindsight 开启时展示 */}
|
||||
{hindsightOn && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<label className="text-sm font-medium">{m.recallBudget}</label>
|
||||
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config.hindsight.recall_budget}
|
||||
disabled={!writable.has("hindsight.recall_budget") || patchConfig.isPending}
|
||||
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
|
||||
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
|
||||
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<label className="text-sm font-medium">{m.memoryMode}</label>
|
||||
<p className="text-muted-foreground text-sm">{m.memoryModeDesc}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config.hindsight.memory_mode}
|
||||
disabled={!isWritable("hindsight.memory_mode") || patchConfig.isPending}
|
||||
onValueChange={(v) => void patch({ hindsight: { memory_mode: v } })}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="context">{m.memoryModeContext}</SelectItem>
|
||||
<SelectItem value="tools">{m.memoryModeTools}</SelectItem>
|
||||
<SelectItem value="hybrid">{m.memoryModeHybrid}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<label className="text-sm font-medium">{m.recallBudget}</label>
|
||||
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config.hindsight.recall_budget}
|
||||
disabled={
|
||||
!isWritable("hindsight.recall_budget") || patchConfig.isPending
|
||||
}
|
||||
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
|
||||
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
|
||||
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<label htmlFor={bankMissionId} className="text-sm font-medium">
|
||||
{m.bankMission}
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">{m.bankMissionDesc}</p>
|
||||
<Textarea
|
||||
id={bankMissionId}
|
||||
rows={2}
|
||||
defaultValue={config.hindsight.bank_mission}
|
||||
placeholder={m.bankMissionPlaceholder}
|
||||
disabled={!isWritable("hindsight.bank_mission") || patchConfig.isPending}
|
||||
onBlur={(event) => {
|
||||
const value = event.target.value;
|
||||
if (value !== config.hindsight.bank_mission) {
|
||||
void patch({ hindsight: { bank_mission: value } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const CTX_LABELS: Record<string, string> = {
|
||||
workContext: "工作背景",
|
||||
personalContext: "个人信息",
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||
function MemoryV1View({
|
||||
memory,
|
||||
}: {
|
||||
memory: {
|
||||
facts: MemoryFact[];
|
||||
user_context: Record<string, string>;
|
||||
history: Record<string, string>;
|
||||
memory_usage: string;
|
||||
};
|
||||
}) {
|
||||
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 说明横幅 */}
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="facts">
|
||||
<TabsList>
|
||||
<TabsTrigger value="facts">
|
||||
事实记录
|
||||
{memory.facts.length > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
{memory.facts.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 事实记录 Tab */}
|
||||
<TabsContent value="facts" className="mt-3">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{desensitize(fact.sourceError)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 用户画像 & 历史摘要 Tab */}
|
||||
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行开关控件。 */
|
||||
function ToggleRow({
|
||||
label,
|
||||
@@ -589,7 +687,7 @@ function MemorySearchPanel() {
|
||||
</SelectItem>
|
||||
{experts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{desensitize(a.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -622,7 +720,7 @@ function MemorySearchPanel() {
|
||||
) : results !== null ? (
|
||||
mode === "reflect" ? (
|
||||
<div className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{reflectText.trim() || m.searchNoResults}
|
||||
{reflectText.trim() ? desensitize(reflectText.trim()) : m.searchNoResults}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
|
||||
@@ -638,7 +736,7 @@ function MemorySearchPanel() {
|
||||
key={`${index}-${item.slice(0, 16)}`}
|
||||
className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]"
|
||||
>
|
||||
{item}
|
||||
{desensitize(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
CheckCircle2,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
@@ -64,6 +68,7 @@ import {
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -81,6 +86,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -90,6 +97,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
||||
|
||||
@@ -140,10 +149,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const staleCount = staleVisible.length;
|
||||
const archivedCount = archivedVisible.length;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||
// Filter / search / tag changes may shrink the result set — pull the page
|
||||
// back into range without forcing a reset on every keystroke.
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
const pagedSkills = useMemo(
|
||||
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
const handleCreateSkill = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
if (result.adopted.length > 0) {
|
||||
toast.success(
|
||||
t.settings.skills.rescanSkillsAdopted.replace(
|
||||
"{count}",
|
||||
String(result.adopted.length),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.info(t.settings.skills.rescanSkillsNoop);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -190,6 +228,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<TrendingUpIcon className="size-4" />
|
||||
{t.settings.skills.goToEvolution}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleRescan()}
|
||||
disabled={
|
||||
reconcileMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
title={t.settings.skills.rescanSkills}
|
||||
>
|
||||
{reconcileMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
{reconcileMutation.isPending
|
||||
? t.settings.skills.rescanSkillsRunning
|
||||
: t.settings.skills.rescanSkills}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -220,7 +277,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
)}
|
||||
{!isLoading && !error &&
|
||||
filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
@@ -228,6 +285,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
{t.settings.skills.paginationPrev}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t.settings.skills.paginationPageOf
|
||||
.replace("{page}", String(page))
|
||||
.replace("{total}", String(totalPages))}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
{t.settings.skills.paginationNext}
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
@@ -321,7 +405,7 @@ function SkillRow({
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
<span>{desensitize(skill.name)}</span>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
@@ -391,7 +475,7 @@ function SkillRow({
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
@@ -477,7 +561,9 @@ function SkillRow({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
|
||||
{desensitize(
|
||||
t.settings.skills.deleteConfirm.replace("{name}", skill.name),
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -726,22 +812,22 @@ function InstallSkillDialog({
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">{validation.skill_name}</span>
|
||||
<span className="font-medium">{desensitize(validation.skill_name)}</span>
|
||||
</div>
|
||||
<Field label={dialog.descriptionLabel}>
|
||||
<span className="line-clamp-3 whitespace-pre-wrap">
|
||||
{validation.description || "—"}
|
||||
{validation.description ? desensitize(validation.description) : "—"}
|
||||
</span>
|
||||
</Field>
|
||||
{validation.license && (
|
||||
<Field label={dialog.licenseLabel}>{validation.license}</Field>
|
||||
<Field label={dialog.licenseLabel}>{desensitize(validation.license)}</Field>
|
||||
)}
|
||||
{validation.allowed_tools.length > 0 && (
|
||||
<Field label={dialog.toolsLabel}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{validation.allowed_tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-xs">
|
||||
{tool}
|
||||
{desensitize(tool)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -751,7 +837,7 @@ function InstallSkillDialog({
|
||||
<Field label={dialog.warningsLabel}>
|
||||
<ul className="list-disc pl-4 text-xs">
|
||||
{validation.warnings.map((w, idx) => (
|
||||
<li key={idx}>{w}</li>
|
||||
<li key={idx}>{desensitize(w)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Field>
|
||||
@@ -760,7 +846,9 @@ function InstallSkillDialog({
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
|
||||
{desensitize(
|
||||
dialog.conflictCustom.replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -768,7 +856,9 @@ function InstallSkillDialog({
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
||||
{desensitize(
|
||||
(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Kind = "stale" | "archived";
|
||||
@@ -146,7 +147,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
<span className="font-medium">{desensitize(skill.name)}</span>
|
||||
{isArchived ? (
|
||||
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
|
||||
{tt.badgeArchived}
|
||||
@@ -180,7 +181,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{skill.description}
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>{timeLabel}</span>
|
||||
@@ -254,7 +255,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tt.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tt.deleteConfirm.replace("{name}", skill.name)}
|
||||
{desensitize(tt.deleteConfirm.replace("{name}", skill.name))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -295,12 +296,12 @@ function SkillViewDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogTitle>{desensitize(skill.name)}</DialogTitle>
|
||||
<DialogDescription>{tv.title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<Row label={tv.descriptionLabel}>
|
||||
<span className="whitespace-pre-wrap">{skill.description}</span>
|
||||
<span className="whitespace-pre-wrap">{desensitize(skill.description ?? "")}</span>
|
||||
</Row>
|
||||
<Row label={tv.stateLabel}>
|
||||
<span className={cn("font-medium", stateColor(skill.state))}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileCode2Icon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
@@ -14,11 +15,6 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,6 +22,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -37,17 +34,22 @@ import {
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
|
||||
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
|
||||
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
|
||||
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
|
||||
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
|
||||
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
|
||||
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
|
||||
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
|
||||
"[&_table]:my-4 [&_table]:w-full [&_table]:min-w-max [&_table]:border-collapse " +
|
||||
"[&_th]:border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:px-3 [&_td]:py-2";
|
||||
|
||||
/**
|
||||
* Whether the current user may edit a skill's detail text and view its raw
|
||||
* SKILL.md source.
|
||||
*
|
||||
* Admins qualify for any skill; the publisher (owner of a custom skill) for
|
||||
* their own. Built-in skills have no human owner, so only admins qualify.
|
||||
* Mirrors the backend authz in `PUT /api/skills/{name}/detail` and
|
||||
* `GET /api/skills/{name}/content`.
|
||||
*/
|
||||
function canManageSkill(skill: Skill | null | undefined): boolean {
|
||||
if (!skill) return false;
|
||||
const auth = getStoredAuth();
|
||||
@@ -60,12 +62,10 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable skill detail dialog. Given a skill name it fetches the skill
|
||||
* metadata (including the admin/owner-authored `detail`) and lets authorized
|
||||
* users edit the detail inline. The raw SKILL.md source is shown only to the
|
||||
* publisher or an admin — everyone else sees just the description and detail.
|
||||
*/
|
||||
function stripYamlFrontmatter(content: string): string {
|
||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
||||
}
|
||||
|
||||
export function SkillDetailDialog({
|
||||
skillName,
|
||||
open,
|
||||
@@ -77,33 +77,38 @@ export function SkillDetailDialog({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tx = t.settings.skills;
|
||||
// Only fetch while the dialog is open to avoid background requests.
|
||||
const activeName = open ? skillName : null;
|
||||
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
||||
const setDetail = useSetSkillDetail();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
|
||||
null,
|
||||
);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
|
||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
|
||||
// The SKILL.md source endpoint is publisher/admin-only — only request it
|
||||
// when the viewer is actually allowed to see it.
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
error: contentError,
|
||||
} = useSkillContent(open && canManage ? skillName : null);
|
||||
|
||||
// Reset transient UI whenever the dialog closes or switches to another skill.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setEditing(false);
|
||||
setShowSource(false);
|
||||
}
|
||||
setEditing(false);
|
||||
setActiveTab(null);
|
||||
setShowRawSource(false);
|
||||
}, [open, skillName]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" | "source" =
|
||||
canManage && skill && !skill.detail ? "source" : "detail";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
setDraft(skill?.detail ?? "");
|
||||
setEditing(true);
|
||||
@@ -120,11 +125,22 @@ export function SkillDetailDialog({
|
||||
}
|
||||
}
|
||||
|
||||
async function copySource() {
|
||||
await navigator.clipboard.writeText(contentData?.content ?? "");
|
||||
toast.success("Copied SKILL.md content");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
{/* Header */}
|
||||
<DialogHeader className="space-y-0 border-b px-6 py-4 text-left">
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
style={{
|
||||
width: "min(48rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(760px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<SparklesIcon className="size-5" />
|
||||
@@ -132,7 +148,7 @@ export function SkillDetailDialog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{skill?.name ?? skillName ?? ""}
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
</DialogTitle>
|
||||
{skill?.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -142,49 +158,110 @@ export function SkillDetailDialog({
|
||||
{skill?.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{tx.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DialogDescription className="mt-1 text-sm leading-relaxed">
|
||||
{skill?.description || "—"}
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
{skill?.description ? desensitize(skill.description) : "-"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-auto px-6 py-5">
|
||||
{/* Detail — human-authored long-form introduction */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-foreground/90 flex items-center gap-1.5 text-sm font-semibold">
|
||||
<FileTextIcon className="text-muted-foreground size-4" />
|
||||
{isSkillLoading || !skill ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-10">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as Exclude<typeof activeTab, null>)
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col gap-0"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="detail" className="text-xs">
|
||||
<FileTextIcon className="mr-1 size-3.5" />
|
||||
{tx.detailSectionTitle}
|
||||
</h3>
|
||||
{canManage && !editing && (
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{selectedTab === "detail" && canManage && !editing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{tx.detailEditAction}
|
||||
</Button>
|
||||
)}
|
||||
{selectedTab === "source" &&
|
||||
canManage &&
|
||||
!isContentLoading &&
|
||||
!contentError && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setShowRawSource((value) => !value)}
|
||||
>
|
||||
{showRawSource ? (
|
||||
<>
|
||||
<EyeIcon className="size-3.5" />
|
||||
Rendered
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileCode2Icon className="size-3.5" />
|
||||
Raw
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={() => void copySource()}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="detail"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
placeholder={tx.detailPlaceholder}
|
||||
className="max-h-[320px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
|
||||
className="max-h-[420px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -208,14 +285,10 @@ export function SkillDetailDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isSkillLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-4 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : skill?.detail ? (
|
||||
<div className="bg-muted/30 rounded-lg border p-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={skill.detail}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
@@ -226,53 +299,51 @@ export function SkillDetailDialog({
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||
<p className="text-sm">{tx.detailEmpty}</p>
|
||||
{!canManage && (
|
||||
{canManage ? (
|
||||
<p className="mt-1 text-xs">
|
||||
Open the SKILL.md tab to view the skill source.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
{/* SKILL.md source — publisher / admin only, collapsed by default */}
|
||||
{canManage && (
|
||||
<section>
|
||||
<Collapsible open={showSource} onOpenChange={setShowSource}>
|
||||
<CollapsibleTrigger className="bg-muted/30 hover:bg-muted/60 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-sm font-semibold transition-colors">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileCode2Icon className="text-muted-foreground size-4" />
|
||||
{tx.contentSectionTitle}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 transition-transform",
|
||||
showSource && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="bg-muted/50 max-h-[340px] overflow-auto rounded-lg border">
|
||||
{isContentLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : contentError ? (
|
||||
<div className="text-destructive p-4 text-sm">
|
||||
{contentError instanceof Error
|
||||
? contentError.message
|
||||
: String(contentError)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-foreground/80 p-4 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{contentData?.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isContentLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : contentError ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{contentError instanceof Error
|
||||
? contentError.message
|
||||
: String(contentError)}
|
||||
</div>
|
||||
) : showRawSource ? (
|
||||
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{desensitize(contentData?.content ?? "")}
|
||||
</pre>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={skillBody || "(SKILL.md is empty)"}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
@@ -24,7 +25,7 @@ export function TagChip({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
{desensitize(tag.name)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
@@ -44,7 +45,7 @@ export function ThreadTitle({
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{thread.values.title ?? "Untitled"}
|
||||
{desensitize(thread.values.title ?? "Untitled")}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
@@ -36,12 +36,12 @@ export function Welcome({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-2xl font-bold">
|
||||
<div className="text-[26px] font-medium tracking-tight text-foreground/85">
|
||||
{searchParams.get("mode") === "skill" ? (
|
||||
`✨ ${t.welcome.createYourOwnSkill} ✨`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("inline-block", !waved ? "animate-wave" : "")}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={cn("inline-block text-2xl", !waved ? "animate-wave" : "")}>
|
||||
{isUltra ? "🚀" : "👋"}
|
||||
</div>
|
||||
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
|
||||
|
||||
@@ -23,7 +23,7 @@ function BrandMark({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCollapseButton({ className }: { className?: string }) {
|
||||
export function SidebarCollapseButton({ className }: { className?: string }) {
|
||||
const { open, toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
@@ -57,9 +57,6 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
|
||||
{t.pages.appName}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px] leading-tight">
|
||||
{t.pages.brandSubtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,7 +68,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" ? (
|
||||
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
|
||||
<div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2">
|
||||
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
|
||||
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
|
||||
</div>
|
||||
@@ -80,7 +77,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<BrandMark />
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
||||
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
||||
|
||||
@@ -13,77 +13,138 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
const NAV_ITEM_CLASS =
|
||||
"text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
|
||||
|
||||
// 「新对话」专属:默认仅主色文字/图标(显眼但不是选中态),命中 /chats/new 时才上主色底
|
||||
const NEW_CHAT_CLASS =
|
||||
"rounded-xl h-9 px-3 text-primary transition-colors hover:bg-primary/10 hover:text-primary " +
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
|
||||
|
||||
export function WorkspaceNavChatList({
|
||||
showCollapseTrigger = false,
|
||||
}: {
|
||||
showCollapseTrigger?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
<SidebarMenu className="gap-1">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/skills")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
|
||||
<SparklesIcon />
|
||||
<span>技能</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/memory")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/memory">
|
||||
<BrainIcon />
|
||||
<span>记忆</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new">
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
{!showCollapseTrigger && (
|
||||
<>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats"}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/skills")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/skills">
|
||||
<SparklesIcon />
|
||||
<span>技能</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/memory")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/memory">
|
||||
<BrainIcon />
|
||||
<span>记忆</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/ai-writing")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/ai-writing">
|
||||
<PenLine />
|
||||
<span>AI写作</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/planning">
|
||||
<Users />
|
||||
<span>多智能体规划</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
GaugeIcon,
|
||||
@@ -154,10 +155,10 @@ export function WorkspaceNavMenu() {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/users")}
|
||||
onClick={() => navigate("/page/workspace/admin")}
|
||||
>
|
||||
<UsersIcon />
|
||||
用户管理
|
||||
用户中心
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -170,16 +171,6 @@ export function WorkspaceNavMenu() {
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/user-activity")
|
||||
}
|
||||
>
|
||||
<HistoryIcon />
|
||||
用户活动审计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -8,29 +8,51 @@ import {
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar variant="sidebar" collapsible="icon" {...props}>
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList />
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTheme as useNextTheme } from 'next-themes';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
@@ -75,12 +82,24 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
themeConfig: ThemeConfig;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: ThemeType) => void;
|
||||
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [theme, setThemeState] = useState<ThemeType>(() => {
|
||||
// 从 localStorage 读取保存的主题
|
||||
const saved = localStorage.getItem('strategy-theme');
|
||||
return (saved as ThemeType) || 'dark';
|
||||
});
|
||||
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
// 设置特定主题
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setThemeState(newTheme);
|
||||
};
|
||||
|
||||
// 保存到 localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('strategy-theme', theme);
|
||||
// 更新文档类名
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
document.documentElement.classList.add(theme);
|
||||
}, [theme]);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (current !== 'light' && current !== 'dark') {
|
||||
const fallback =
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
|
||||
setNextTheme(fallback);
|
||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
setNextTheme(legacy);
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
}, [mounted, setNextTheme]);
|
||||
|
||||
const theme: ThemeType = mounted
|
||||
? resolvedTheme === 'light'
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: readPersistedTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setNextTheme(newTheme);
|
||||
};
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
@@ -25,11 +25,13 @@ export interface AdminMessage {
|
||||
/** One memory bucket: the global file or a per-agent file. */
|
||||
export interface AdminMemoryBucket {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdminMemoryResponse {
|
||||
user_id: string;
|
||||
version: "v1" | "v2";
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
@@ -70,3 +72,119 @@ export async function getUserMemory(
|
||||
"Failed to load memory",
|
||||
);
|
||||
}
|
||||
|
||||
export async function addAdminMemoryEntry(
|
||||
userId: string,
|
||||
content: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/entries`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content, agent }),
|
||||
}),
|
||||
"Failed to add memory entry",
|
||||
);
|
||||
}
|
||||
|
||||
export async function replaceAdminMemoryEntry(
|
||||
userId: string,
|
||||
oldText: string,
|
||||
content: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/entries`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ old_text: oldText, content, agent }),
|
||||
}),
|
||||
"Failed to replace memory entry",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAdminMemoryEntry(
|
||||
userId: string,
|
||||
oldText: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
const params = new URLSearchParams({ old_text: oldText });
|
||||
if (agent) params.set("agent", agent);
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/memory/entries?${params}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"Failed to delete memory entry",
|
||||
);
|
||||
}
|
||||
|
||||
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function updateAdminV1Section(
|
||||
userId: string,
|
||||
sectionType: "user" | "history",
|
||||
field: string,
|
||||
summary: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/sections`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
|
||||
}),
|
||||
"Failed to update V1 section",
|
||||
);
|
||||
}
|
||||
|
||||
export async function addAdminV1Fact(
|
||||
userId: string,
|
||||
content: string,
|
||||
category: string,
|
||||
confidence: number,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content, category, confidence, agent }),
|
||||
}),
|
||||
"Failed to add V1 fact",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAdminV1Fact(
|
||||
userId: string,
|
||||
factId: string,
|
||||
updates: { content?: string; category?: string; confidence?: number },
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...updates, agent }),
|
||||
}),
|
||||
"Failed to update V1 fact",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAdminV1Fact(
|
||||
userId: string,
|
||||
factId: string,
|
||||
agent: string | null,
|
||||
): Promise<AdminMemoryBucket> {
|
||||
const params = new URLSearchParams();
|
||||
if (agent) params.set("agent", agent);
|
||||
const qs = params.toString() ? `?${params}` : "";
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"Failed to delete V1 fact",
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getUserMemory, listUserThreadMessages, listUserThreads } from "./api";
|
||||
import {
|
||||
addAdminMemoryEntry,
|
||||
addAdminV1Fact,
|
||||
deleteAdminMemoryEntry,
|
||||
deleteAdminV1Fact,
|
||||
getUserMemory,
|
||||
listUserThreadMessages,
|
||||
listUserThreads,
|
||||
replaceAdminMemoryEntry,
|
||||
updateAdminV1Fact,
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
export function useUserThreads(userId: string | null) {
|
||||
@@ -31,3 +42,98 @@ export function useUserMemory(userId: string | null) {
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
function useMemoryInvalidate(userId: string) {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: ["admin-activity", "memory", userId] });
|
||||
}
|
||||
|
||||
export function useAdminAddMemoryEntry(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({ content, agent }: { content: string; agent: string | null }) =>
|
||||
addAdminMemoryEntry(userId, content, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminReplaceMemoryEntry(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({ oldText, content, agent }: { oldText: string; content: string; agent: string | null }) =>
|
||||
replaceAdminMemoryEntry(userId, oldText, content, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteMemoryEntry(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({ oldText, agent }: { oldText: string; agent: string | null }) =>
|
||||
deleteAdminMemoryEntry(userId, oldText, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ── V1 CRUD hooks ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useAdminUpdateV1Section(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
sectionType,
|
||||
field,
|
||||
summary,
|
||||
agent,
|
||||
}: {
|
||||
sectionType: "user" | "history";
|
||||
field: string;
|
||||
summary: string;
|
||||
agent: string | null;
|
||||
}) => updateAdminV1Section(userId, sectionType, field, summary, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminAddV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
content,
|
||||
category,
|
||||
confidence,
|
||||
agent,
|
||||
}: {
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
agent: string | null;
|
||||
}) => addAdminV1Fact(userId, content, category, confidence, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
factId,
|
||||
updates,
|
||||
agent,
|
||||
}: {
|
||||
factId: string;
|
||||
updates: { content?: string; category?: string; confidence?: number };
|
||||
agent: string | null;
|
||||
}) => updateAdminV1Fact(userId, factId, updates, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteV1Fact(userId: string) {
|
||||
const invalidate = useMemoryInvalidate(userId);
|
||||
return useMutation({
|
||||
mutationFn: ({ factId, agent }: { factId: string; agent: string | null }) =>
|
||||
deleteAdminV1Fact(userId, factId, agent),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSelectorResponse,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
|
||||
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
@@ -75,6 +80,50 @@ export async function deleteAgent(id: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
|
||||
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
|
||||
return res.json() as Promise<AgentSelectorResponse>;
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/featured/order`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_ids: agentIds }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to reorder featured agents: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAgentFeatured(
|
||||
id: string,
|
||||
order: number | null,
|
||||
): Promise<Agent> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/${id}/featured`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to set featured: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
name: string,
|
||||
): Promise<{ available: boolean; name: string }> {
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
@@ -70,3 +73,40 @@ export function useDeleteAgent() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
queryFn: () => getAgentSelector(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
}
|
||||
|
||||
export function useSetAgentFeatured() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
||||
setAgentFeatured(id, order),
|
||||
onSuccess: (_data, { id }) => {
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderFeaturedAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
|
||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,11 @@ export interface Agent {
|
||||
tool_groups?: string[] | null;
|
||||
skills?: string[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
}
|
||||
|
||||
export interface AgentSelectorResponse {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { env } from "@/env";
|
||||
* 素材的 recUuid 直接拼接在其后即为完整原文链接。
|
||||
*/
|
||||
export const INTRANET_SOURCE_URL_BASE =
|
||||
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid=";
|
||||
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid-detail?recUuid=";
|
||||
|
||||
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
|
||||
export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
@@ -68,3 +68,13 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
return "http://localhost:2026/api/langgraph";
|
||||
}
|
||||
}
|
||||
|
||||
/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
|
||||
export function getNoteApiBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
|
||||
return new URL(env.NEXT_PUBLIC_NOTE_API_BASE_URL, getBaseOrigin())
|
||||
.toString()
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useI18nContext } from "./context";
|
||||
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
|
||||
@@ -13,10 +15,34 @@ import {
|
||||
type Locale,
|
||||
} from "./index";
|
||||
|
||||
function wrapWithDesensitize<T>(value: T): T {
|
||||
if (typeof value === "string") {
|
||||
return desensitize(value) as T;
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return ((...args: unknown[]) => {
|
||||
const result = (value as (...a: unknown[]) => unknown)(...args);
|
||||
return wrapWithDesensitize(result);
|
||||
}) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => wrapWithDesensitize(item)) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return new Proxy(value as object, {
|
||||
get(target, prop, receiver) {
|
||||
return wrapWithDesensitize(Reflect.get(target, prop, receiver));
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const { locale, setLocale } = useI18nContext();
|
||||
|
||||
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
|
||||
const rawT = translations[locale] ?? translations[DEFAULT_LOCALE];
|
||||
const t = useMemo(() => wrapWithDesensitize(rawT), [rawT]);
|
||||
|
||||
const changeLocale = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
|
||||
@@ -48,6 +48,7 @@ export const enUS: Translations = {
|
||||
export: "Export",
|
||||
exportAsMarkdown: "Export as Markdown",
|
||||
exportAsJSON: "Export as JSON",
|
||||
exportAsWord: "Export as Word",
|
||||
exportSuccess: "Conversation exported",
|
||||
},
|
||||
|
||||
@@ -445,7 +446,14 @@ export const enUS: Translations = {
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
createSkill: "Create skill",
|
||||
installSkill: "Install skill",
|
||||
rescanSkills: "Rescan skills directory",
|
||||
rescanSkillsRunning: "Scanning…",
|
||||
rescanSkillsAdopted: "Adopted {count} new skill(s)",
|
||||
rescanSkillsNoop: "No new skills found on disk",
|
||||
goToEvolution: "Skill evolution",
|
||||
paginationPrev: "Prev",
|
||||
paginationNext: "Next",
|
||||
paginationPageOf: "Page {page} of {total}",
|
||||
emptyTitle: "No agent skill yet",
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface Translations {
|
||||
export: string;
|
||||
exportAsMarkdown: string;
|
||||
exportAsJSON: string;
|
||||
exportAsWord: string;
|
||||
exportSuccess: string;
|
||||
};
|
||||
|
||||
@@ -380,7 +381,14 @@ export interface Translations {
|
||||
description: string;
|
||||
createSkill: string;
|
||||
installSkill: string;
|
||||
rescanSkills: string;
|
||||
rescanSkillsRunning: string;
|
||||
rescanSkillsAdopted: string;
|
||||
rescanSkillsNoop: string;
|
||||
goToEvolution: string;
|
||||
paginationPrev: string;
|
||||
paginationNext: string;
|
||||
paginationPageOf: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ export const zhCN: Translations = {
|
||||
export: "导出",
|
||||
exportAsMarkdown: "导出为 Markdown",
|
||||
exportAsJSON: "导出为 JSON",
|
||||
exportAsWord: "导出为 Word",
|
||||
exportSuccess: "对话已导出",
|
||||
},
|
||||
|
||||
@@ -426,7 +427,14 @@ export const zhCN: Translations = {
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
createSkill: "新建技能",
|
||||
installSkill: "安装技能",
|
||||
rescanSkills: "重新扫描技能目录",
|
||||
rescanSkillsRunning: "扫描中…",
|
||||
rescanSkillsAdopted: "已收纳 {count} 个新技能",
|
||||
rescanSkillsNoop: "没有发现需要收纳的新技能",
|
||||
goToEvolution: "自我进化",
|
||||
paginationPrev: "上一页",
|
||||
paginationNext: "下一页",
|
||||
paginationPageOf: "第 {page} / {total} 页",
|
||||
emptyTitle: "还没有技能",
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
|
||||
// Memory 数据模型。对应后端 /api/memory/v2 系列端点。支持 V1 / V2 两个版本。
|
||||
|
||||
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
|
||||
/** V1 记忆中的单条事实。 */
|
||||
export interface MemoryFact {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
/** 纠错类事实:被纠正的原错误描述(category=correction 时可能有值)。 */
|
||||
sourceError?: string;
|
||||
}
|
||||
|
||||
/** 记忆数据视图 —— V1/V2 共用结构。
|
||||
*
|
||||
* - V2 模式: user_entries / memory_entries / user_usage / memory_usage 有值
|
||||
* - V1 模式: facts / user_context / memory_usage 有值
|
||||
*/
|
||||
export interface MemoryV2Data {
|
||||
// V2 字段
|
||||
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
|
||||
user_entries: string[];
|
||||
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
|
||||
@@ -10,17 +26,27 @@ export interface MemoryV2Data {
|
||||
user_usage: string;
|
||||
/** MEMORY.md 字符用量。 */
|
||||
memory_usage: string;
|
||||
|
||||
// V1 字段
|
||||
/** V1 facts 列表,按置信度降序。 */
|
||||
facts: MemoryFact[];
|
||||
/** V1 userContext 摘要。 */
|
||||
user_context: Record<string, string>;
|
||||
/** V1 history 历史摘要。 */
|
||||
history: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 记忆条目所属类别。 */
|
||||
export type MemoryTarget = "user" | "memory";
|
||||
|
||||
/** 生效记忆配置 + 用户可改字段白名单。 */
|
||||
/** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
|
||||
export interface MemoryV2ConfigResponse {
|
||||
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
|
||||
config: MemoryV2Config;
|
||||
/** 用户可在运行时覆盖的字段 (点号路径)。 */
|
||||
writable_fields: string[];
|
||||
/** 当前记忆版本:v1 或 v2。 */
|
||||
version: "v1" | "v2";
|
||||
}
|
||||
|
||||
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Normalizes retrieval payloads from web_search, intranet skills, MCP tools, etc.
|
||||
* into a single card-friendly shape for the workspace chat UI.
|
||||
*
|
||||
* Parsing is intentionally tolerant: known field aliases first, then key-name
|
||||
* heuristics, then longest-string fallback for body text.
|
||||
*/
|
||||
|
||||
export type SearchResultItem = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
const RESULT_LIST_KEYS = [
|
||||
"results",
|
||||
"data",
|
||||
"items",
|
||||
"hits",
|
||||
"documents",
|
||||
"records",
|
||||
"entries",
|
||||
"matches",
|
||||
"chunks",
|
||||
"sources",
|
||||
] as const;
|
||||
|
||||
const WRAPPER_KEYS = ["result", "payload", "response", "output", "data"] as const;
|
||||
|
||||
const NESTED_META_KEYS = ["metadata", "meta", "attrs", "attributes", "properties", "extra"] as const;
|
||||
|
||||
/** Keys ignored when guessing content from arbitrary string fields. */
|
||||
const SKIP_HEURISTIC_KEYS = new Set([
|
||||
"id",
|
||||
"uuid",
|
||||
"recuuid",
|
||||
"score",
|
||||
"relevance_score",
|
||||
"rank",
|
||||
"index",
|
||||
"type",
|
||||
"status",
|
||||
"ok",
|
||||
"success",
|
||||
"error",
|
||||
"query",
|
||||
]);
|
||||
|
||||
const TITLE_ALIASES = [
|
||||
"title",
|
||||
"m_title",
|
||||
"name",
|
||||
"heading",
|
||||
"headline",
|
||||
"subject",
|
||||
"document_title",
|
||||
"doc_title",
|
||||
"file_name",
|
||||
"filename",
|
||||
] as const;
|
||||
|
||||
const CONTENT_ALIASES = [
|
||||
"content",
|
||||
"content_preview",
|
||||
"snippet",
|
||||
"page_content",
|
||||
"body",
|
||||
"text",
|
||||
"summary",
|
||||
"abstract",
|
||||
"description",
|
||||
"desc",
|
||||
"excerpt",
|
||||
"preview",
|
||||
"passage",
|
||||
"paragraph",
|
||||
"chunk",
|
||||
"chunk_text",
|
||||
"document",
|
||||
"doc_content",
|
||||
"answer",
|
||||
"highlight",
|
||||
"context",
|
||||
"message",
|
||||
] as const;
|
||||
|
||||
const URL_ALIASES = ["url", "href", "link", "uri", "source_url", "web_url", "page_url"] as const;
|
||||
|
||||
const SOURCE_ALIASES = [
|
||||
"source",
|
||||
"source1",
|
||||
"source_name",
|
||||
"origin",
|
||||
"site",
|
||||
"site_name",
|
||||
"publisher",
|
||||
"domain",
|
||||
"from",
|
||||
"channel",
|
||||
"database",
|
||||
"knowledge_base",
|
||||
"kb",
|
||||
] as const;
|
||||
|
||||
const DATE_ALIASES = [
|
||||
"published_date",
|
||||
"publishedDate",
|
||||
"publish_date",
|
||||
"date",
|
||||
"m_publish",
|
||||
"published_at",
|
||||
"publish_time",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"time",
|
||||
] as const;
|
||||
|
||||
const TITLE_KEY_PATTERN =
|
||||
/(?:^|_)(?:title|name|heading|headline|subject)(?:$|_)/i;
|
||||
const CONTENT_KEY_PATTERN =
|
||||
/(?:^|_)(?:content|snippet|summary|abstract|body|text|preview|passage|excerpt|desc|description|chunk|paragraph|document|answer|highlight|context|message)(?:$|_)/i;
|
||||
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||
const SOURCE_KEY_PATTERN =
|
||||
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||
const DATE_KEY_PATTERN =
|
||||
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||
|
||||
function pickString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function looksLikeUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^www\./i.test(value);
|
||||
}
|
||||
|
||||
function collectNestedRecords(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown>[] {
|
||||
const layers: Record<string, unknown>[] = [record];
|
||||
for (const key of NESTED_META_KEYS) {
|
||||
const nested = asRecord(record[key]);
|
||||
if (nested) {
|
||||
layers.push(nested);
|
||||
}
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
function pickFromLayers(
|
||||
layers: Record<string, unknown>[],
|
||||
aliases: readonly string[],
|
||||
): string | undefined {
|
||||
for (const layer of layers) {
|
||||
for (const alias of aliases) {
|
||||
const value = pickString(layer[alias]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickByKeyPattern(
|
||||
layers: Record<string, unknown>[],
|
||||
pattern: RegExp,
|
||||
options?: { excludeUrl?: boolean; maxLength?: number },
|
||||
): string | undefined {
|
||||
for (const layer of layers) {
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
if (SKIP_HEURISTIC_KEYS.has(key.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
if (!pattern.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const text = pickString(value);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
if (options?.excludeUrl && looksLikeUrl(text)) {
|
||||
continue;
|
||||
}
|
||||
if (options?.maxLength !== undefined && text.length > options.maxLength) {
|
||||
continue;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickLongestContentFallback(
|
||||
layers: Record<string, unknown>[],
|
||||
reserved: Set<string>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
for (const layer of layers) {
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (SKIP_HEURISTIC_KEYS.has(lower)) {
|
||||
continue;
|
||||
}
|
||||
if (TITLE_KEY_PATTERN.test(key) || URL_KEY_PATTERN.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const text = pickString(value);
|
||||
if (!text || text.length < 8) {
|
||||
continue;
|
||||
}
|
||||
if (reserved.has(text)) {
|
||||
continue;
|
||||
}
|
||||
if (looksLikeUrl(text) && text.length < 120) {
|
||||
continue;
|
||||
}
|
||||
if (!best || text.length > best.length) {
|
||||
best = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function pickShortTitleFallback(
|
||||
layers: Record<string, unknown>[],
|
||||
reserved: Set<string>,
|
||||
): string | undefined {
|
||||
let best: string | undefined;
|
||||
for (const layer of layers) {
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
if (URL_KEY_PATTERN.test(key) || CONTENT_KEY_PATTERN.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const text = pickString(value);
|
||||
if (!text || text.length > 200 || text.length < 1) {
|
||||
continue;
|
||||
}
|
||||
if (reserved.has(text)) {
|
||||
continue;
|
||||
}
|
||||
if (looksLikeUrl(text)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || text.length < best.length) {
|
||||
best = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const record = asRecord(raw);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layers = collectNestedRecords(record);
|
||||
|
||||
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
|
||||
|
||||
let title =
|
||||
pickFromLayers(layers, TITLE_ALIASES) ??
|
||||
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
|
||||
if (!title && indexLabel) {
|
||||
title = `结果 ${indexLabel}`;
|
||||
}
|
||||
let content =
|
||||
pickFromLayers(layers, CONTENT_ALIASES) ??
|
||||
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
|
||||
let url =
|
||||
pickFromLayers(layers, URL_ALIASES) ??
|
||||
pickByKeyPattern(layers, URL_KEY_PATTERN);
|
||||
let source =
|
||||
pickFromLayers(layers, SOURCE_ALIASES) ??
|
||||
pickByKeyPattern(layers, SOURCE_KEY_PATTERN, {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const date =
|
||||
pickFromLayers(layers, DATE_ALIASES) ??
|
||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, date]) {
|
||||
if (value) {
|
||||
reserved.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
content = pickLongestContentFallback(layers, reserved);
|
||||
if (content) {
|
||||
reserved.add(content);
|
||||
}
|
||||
}
|
||||
if (!title) {
|
||||
title = pickShortTitleFallback(layers, reserved);
|
||||
}
|
||||
|
||||
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
|
||||
if (!title && !content && !url && !indexLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
snippet: content,
|
||||
content,
|
||||
source,
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
};
|
||||
}
|
||||
|
||||
function extractRawResults(payload: unknown, depth = 0): unknown[] | null {
|
||||
if (depth > 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.length > 0 ? payload : null;
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of RESULT_LIST_KEYS) {
|
||||
const nested = record[key];
|
||||
if (Array.isArray(nested) && nested.length > 0) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of WRAPPER_KEYS) {
|
||||
const nested = record[key];
|
||||
const found = extractRawResults(nested, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseResultPayload(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): unknown {
|
||||
if (result === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof result === "string") {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** True when the tool return value looks like a retrieval hit list. */
|
||||
export function hasSearchResultPayload(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
return normalizeSearchResults(result).length > 0;
|
||||
}
|
||||
|
||||
export function normalizeSearchResults(
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): SearchResultItem[] {
|
||||
const payload = parseResultPayload(result);
|
||||
const rawList = extractRawResults(payload);
|
||||
if (!rawList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: SearchResultItem[] = [];
|
||||
for (const raw of rawList) {
|
||||
const item = normalizeSearchResultItem(raw);
|
||||
if (item) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function resolveSearchQuery(
|
||||
args: Record<string, unknown>,
|
||||
result: string | Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (typeof args.query === "string" && args.query.trim()) {
|
||||
return args.query.trim();
|
||||
}
|
||||
const payload = asRecord(parseResultPayload(result));
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const queryKeys = ["query", "q", "keyword", "keywords", "search_query"] as const;
|
||||
for (const key of queryKeys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
||||
|
||||
interface GenericMessageGroup<T = string> {
|
||||
type: T;
|
||||
id: string | undefined;
|
||||
@@ -141,21 +143,63 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
export function extractTextFromMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return (
|
||||
splitInlineReasoningFromAIMessage(message)?.content ??
|
||||
message.content.trim()
|
||||
);
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (typeof payload === "string") {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => (content.type === "text" ? content.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (payload && typeof payload === "object") {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
|
||||
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
|
||||
*/
|
||||
export function extractToolResultPayload(
|
||||
message: Message,
|
||||
): string | Record<string, unknown> | undefined {
|
||||
const { content } = message;
|
||||
|
||||
if (typeof content === "string") {
|
||||
const trimmed = content.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return content as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const textParts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = part as Record<string, unknown>;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "json" && block.json && typeof block.json === "object") {
|
||||
return block.json as Record<string, unknown>;
|
||||
}
|
||||
if (hasSearchResultPayload(block)) {
|
||||
return block;
|
||||
}
|
||||
}
|
||||
const joined = textParts.join("\n").trim();
|
||||
return joined || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
export function findToolCallResult(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
toolName?: string,
|
||||
): string | Record<string, unknown> | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
||||
const content = extractTextFromMessage(message);
|
||||
if (content) {
|
||||
return content;
|
||||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
}
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (payload !== undefined) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||||
if (toolName) {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || message.name !== toolName) {
|
||||
continue;
|
||||
}
|
||||
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
}
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (payload !== undefined && hasSearchResultPayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getNoteApiBaseURL } from "@/core/config";
|
||||
|
||||
import {
|
||||
ensureNoteAuth,
|
||||
getNoteAuthorizationHeader,
|
||||
getStoredNoteAuth,
|
||||
} from "./auth";
|
||||
|
||||
export type { NoteAuth } from "./auth";
|
||||
|
||||
export interface SearchSpace {
|
||||
created_at: string;
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
user_id: string;
|
||||
citations_enabled: boolean;
|
||||
qna_custom_instructions: string;
|
||||
summary: string | null;
|
||||
avatar_url: string | null;
|
||||
sources_limit: number;
|
||||
sources_used: number;
|
||||
is_shared: boolean;
|
||||
is_owner: boolean;
|
||||
owner_email: string | null;
|
||||
member_count?: number;
|
||||
source_count?: number;
|
||||
membership_role?: string;
|
||||
}
|
||||
|
||||
export interface ListSearchSpacesQuery {
|
||||
owned_only?: boolean;
|
||||
limit?: number;
|
||||
order_by?: string;
|
||||
}
|
||||
|
||||
export interface CreateSearchSpaceInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
citations_enabled?: boolean;
|
||||
}
|
||||
|
||||
export async function noteApiFetch(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const auth = getStoredNoteAuth() ?? (await ensureNoteAuth());
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Authorization", getNoteAuthorizationHeader(auth));
|
||||
|
||||
return fetch(`${getNoteApiBaseURL()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSearchSpace(
|
||||
input: CreateSearchSpaceInput,
|
||||
): Promise<SearchSpace> {
|
||||
await ensureNoteAuth();
|
||||
|
||||
const response = await noteApiFetch("/api/v1/searchspaces", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
description: input.description ?? "",
|
||||
citations_enabled: input.citations_enabled ?? true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建笔记失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as SearchSpace;
|
||||
}
|
||||
|
||||
export async function listSearchSpaces(
|
||||
query: ListSearchSpacesQuery = {},
|
||||
): Promise<SearchSpace[]> {
|
||||
await ensureNoteAuth();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
owned_only: String(query.owned_only ?? true),
|
||||
limit: String(query.limit ?? 1000),
|
||||
order_by: query.order_by ?? "created_at",
|
||||
});
|
||||
|
||||
const response = await noteApiFetch(`/api/v1/searchspaces?${params.toString()}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取笔记列表失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
return Array.isArray(data) ? (data as SearchSpace[]) : [];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getNoteApiBaseURL } from "@/core/config";
|
||||
|
||||
const NOTE_AUTH_STORAGE_KEY = "deerflow.note.auth";
|
||||
|
||||
const NOTE_LOGIN_USERNAME = "dhk@qq.com";
|
||||
const NOTE_LOGIN_PASSWORD = "1qaz@WSX";
|
||||
|
||||
export interface NoteAuth {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function assertNoteApiBaseURL(): string {
|
||||
const baseURL = getNoteApiBaseURL();
|
||||
if (!baseURL) {
|
||||
throw new Error("未配置 VITE_NOTE_API_BASE_URL");
|
||||
}
|
||||
return baseURL;
|
||||
}
|
||||
|
||||
export function getStoredNoteAuth(): NoteAuth | null {
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
const raw = window.localStorage.getItem(NOTE_AUTH_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as NoteAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredNoteAuth(auth: NoteAuth): void {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(NOTE_AUTH_STORAGE_KEY, JSON.stringify(auth));
|
||||
}
|
||||
|
||||
export function clearStoredNoteAuth(): void {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(NOTE_AUTH_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getNoteAuthorizationHeader(auth: NoteAuth): string {
|
||||
const token = auth.access_token.trim();
|
||||
const tokenType = auth.token_type?.trim() || "Bearer";
|
||||
return `${tokenType} ${token}`;
|
||||
}
|
||||
|
||||
export async function loginNoteApi(): Promise<NoteAuth> {
|
||||
const formData = new FormData();
|
||||
formData.append("username", NOTE_LOGIN_USERNAME);
|
||||
formData.append("password", NOTE_LOGIN_PASSWORD);
|
||||
formData.append("grant_type", "password");
|
||||
|
||||
const response = await fetch(`${assertNoteApiBaseURL()}/auth/jwt/login`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`笔记登录失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Partial<NoteAuth>;
|
||||
if (!data.access_token) {
|
||||
throw new Error("笔记登录失败: 未返回 access_token");
|
||||
}
|
||||
|
||||
const auth: NoteAuth = {
|
||||
access_token: data.access_token,
|
||||
token_type: data.token_type?.trim() || "bearer",
|
||||
};
|
||||
setStoredNoteAuth(auth);
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function ensureNoteAuth(): Promise<NoteAuth> {
|
||||
const existing = getStoredNoteAuth();
|
||||
if (existing?.access_token) {
|
||||
return existing;
|
||||
}
|
||||
return loginNoteApi();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const PAGE_SIDEBAR_QUERY_PARAM = "sidebar";
|
||||
|
||||
const STORAGE_KEY = "deerflow-page-sidebar-enabled";
|
||||
|
||||
export function isSidebarEnabledFromSearch(search: string): boolean {
|
||||
const params = new URLSearchParams(
|
||||
search.startsWith("?") ? search : `?${search}`,
|
||||
);
|
||||
return params.get(PAGE_SIDEBAR_QUERY_PARAM) === "1";
|
||||
}
|
||||
|
||||
export function persistSidebarEnabled(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
sessionStorage.setItem(STORAGE_KEY, "1");
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function readSidebarEnabled(): boolean {
|
||||
return sessionStorage.getItem(STORAGE_KEY) === "1";
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
@@ -203,6 +204,7 @@ export async function updateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
@@ -329,6 +331,7 @@ export async function adminUpdateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
|
||||
@@ -41,6 +41,11 @@ export function useSchedulerThread() {
|
||||
});
|
||||
}
|
||||
|
||||
/** A task is currently executing — used to drive live polling of the task list. */
|
||||
function taskIsLive(status?: string | null): boolean {
|
||||
return status === "running" || status === "pending";
|
||||
}
|
||||
|
||||
export function useScheduledTasks(
|
||||
params: { search?: string; tagIds?: string[] } = {},
|
||||
) {
|
||||
@@ -51,6 +56,12 @@ export function useScheduledTasks(
|
||||
queryFn: () => listScheduledTasks({ search, tagIds }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
// Poll the list while any task is currently executing so its last_status
|
||||
// and last_run_at refresh without a manual reload.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((task) => taskIsLive(task.last_status))
|
||||
? 5000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +71,13 @@ export function useScheduledTaskRuns(taskId?: string | null) {
|
||||
queryFn: () => listScheduledTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchOnWindowFocus: false,
|
||||
// Poll the run list while any run is in flight so the user can watch
|
||||
// status transitions (pending → running → succeeded/failed) without
|
||||
// refreshing the page.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((run) => runIsLive(run.status))
|
||||
? 4000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +131,11 @@ export function useAllScheduledTasks(
|
||||
enabled: params.enabled ?? true,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
// Mirror the user list polling so the admin view also refreshes live.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((task) => taskIsLive(task.last_status))
|
||||
? 5000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,6 +146,11 @@ export function useAdminScheduledTaskRuns(taskId?: string | null) {
|
||||
queryFn: () => adminListScheduledTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchOnWindowFocus: false,
|
||||
// Poll while any run is still executing so admins see live progress.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((run) => runIsLive(run.status))
|
||||
? 4000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ScheduledTaskSubscription = {
|
||||
export type ScheduledTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt: string;
|
||||
schedule_text: string;
|
||||
cron_expr: string;
|
||||
@@ -27,6 +28,8 @@ export type ScheduledTask = {
|
||||
scheduler_thread_id: string;
|
||||
published?: boolean;
|
||||
published_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
role?: ScheduledTaskRole;
|
||||
_subscription?: ScheduledTaskSubscription | null;
|
||||
creator_user_id?: string | null;
|
||||
|
||||
@@ -5,15 +5,15 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: false,
|
||||
streamSpeedMeterEnabled: true,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
},
|
||||
};
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
@@ -29,7 +29,7 @@ export interface LocalSettings {
|
||||
display: {
|
||||
/**
|
||||
* Whether to render a small token/s speed meter while a streaming
|
||||
* AI reply is in flight. Default off; controlled by the user from
|
||||
* AI reply is in flight. Default on; controlled by the user from
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
|
||||
@@ -135,6 +135,22 @@ export async function installSkillUpload(
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export interface ReconcileCustomSkillsResponse {
|
||||
adopted: string[];
|
||||
already_registered: string[];
|
||||
}
|
||||
|
||||
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to reconcile skills directory");
|
||||
}
|
||||
return response.json() as Promise<ReconcileCustomSkillsResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillPublishedResponse {
|
||||
name: string;
|
||||
owner_user_id: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
setSkillDetail,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
@@ -95,6 +96,16 @@ export function useInstallSkillUpload() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useReconcileCustomSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => reconcileCustomSkills(),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillPublished() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -138,3 +140,17 @@ export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) {
|
||||
const filename = `${sanitizeFilename(titleOfThread(thread))}.json`;
|
||||
downloadAsFile(json, filename, "application/json;charset=utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the conversation as a Word document (.docx). The whole thread is
|
||||
* serialised to Markdown first (same formatter the `.md` export uses), and
|
||||
* then converted to a docx Blob in the browser via `markdownToDocxBlob`.
|
||||
*/
|
||||
export async function exportThreadAsWord(
|
||||
thread: AgentThread,
|
||||
messages: Message[],
|
||||
): Promise<void> {
|
||||
const markdown = formatThreadAsMarkdown(thread, messages);
|
||||
const title = titleOfThread(thread);
|
||||
await downloadMarkdownAsDocx(markdown, sanitizeFilename(title), title);
|
||||
}
|
||||
@@ -506,6 +506,7 @@ export function useThreadStream({
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,11 +14,12 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
agent_id?: string;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ function readEnv(
|
||||
| "VITE_BACKEND_BASE_URL"
|
||||
| "VITE_LANGGRAPH_BASE_URL"
|
||||
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
||||
| "VITE_NOTE_API_BASE_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
): EnvValue {
|
||||
@@ -18,6 +19,7 @@ export const env = {
|
||||
// Canvas page uses its own LangGraph base URL so it can be independently
|
||||
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
||||
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
||||
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { loginNoteApi, type NoteAuth } from "@/core/note/auth";
|
||||
|
||||
export function useNoteAuth() {
|
||||
const [auth, setAuth] = useState<NoteAuth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextAuth = await loginNoteApi();
|
||||
setAuth(nextAuth);
|
||||
return nextAuth;
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "笔记登录失败,请稍后重试";
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth().catch(() => {
|
||||
// 错误已在 refreshAuth 中写入 state
|
||||
});
|
||||
}, [refreshAuth]);
|
||||
|
||||
return {
|
||||
auth,
|
||||
loading,
|
||||
error,
|
||||
refreshAuth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 脱敏词映射表
|
||||
* key: 原始词(支持正则特殊字符,会被转义)
|
||||
* value: 脱敏后替换词
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
态势: "TS",
|
||||
情报: "QB",
|
||||
指挥: "ZH",
|
||||
信息准备: "XX准备",
|
||||
行为体: "XWT",
|
||||
驱动因素: "QDYS",
|
||||
叙事: "XS",
|
||||
前三定: "QSD",
|
||||
受众目标: "SZMB",
|
||||
作战: "ZZ",
|
||||
目的: "MD",
|
||||
关键传播节点: "GJCBJD",
|
||||
战法: "ZF",
|
||||
行动: "XD",
|
||||
打击: "DJ",
|
||||
目标: "MB",
|
||||
战力: "ZL",
|
||||
筹划: "COH",
|
||||
策划: "CEH",
|
||||
模拟: "MN",
|
||||
推演: "TY",
|
||||
认知: "RZ",
|
||||
触及: "CJ",
|
||||
军: "J",
|
||||
美军: "MJ",
|
||||
台军: "TJ",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。
|
||||
* 仅在模块首次加载时执行一次。
|
||||
*/
|
||||
function buildRegex(map: Record<string, string>): RegExp | null {
|
||||
const keys = Object.keys(map);
|
||||
if (keys.length === 0) return null;
|
||||
const pattern = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
||||
return new RegExp(pattern, "g");
|
||||
}
|
||||
|
||||
let _regex: RegExp | null = buildRegex(DESENSITIZE_MAP);
|
||||
|
||||
/**
|
||||
* 对文本进行脱敏处理,将 DESENSITIZE_MAP 中的词替换为对应脱敏词。
|
||||
*
|
||||
* @param text 原始文本
|
||||
* @returns 脱敏后的文本
|
||||
*
|
||||
* @example
|
||||
* desensitize("当前态势分析") // => "当前TS分析"
|
||||
*/
|
||||
export function desensitize(text: string): string {
|
||||
if (!_regex) return text;
|
||||
// 每次 exec 需要重置 lastIndex,使用 replace 更安全
|
||||
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时动态追加脱敏词,追加后自动重编译正则。
|
||||
*
|
||||
* @param entries 要追加的词条,格式同 DESENSITIZE_MAP
|
||||
*
|
||||
* @example
|
||||
* addDesensitizeEntries({ 情报: "QB", 指挥: "ZH" });
|
||||
*/
|
||||
export function addDesensitizeEntries(entries: Record<string, string>): void {
|
||||
Object.assign(DESENSITIZE_MAP, entries);
|
||||
_regex = buildRegex(DESENSITIZE_MAP);
|
||||
}
|
||||
@@ -32,6 +32,34 @@ export interface AIWritingTranscript {
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
|
||||
|
||||
// ── admin 调试用 —— 阶段 7 删冗余时一并移除 ───────────────────────────────────
|
||||
export interface AIWritingCleanupResult {
|
||||
deleted_db: number;
|
||||
deleted_checkpoints: number;
|
||||
skipped_active: number;
|
||||
elapsed_seconds: number;
|
||||
params: { retention_days: number; only_finished: boolean; batch_size: number };
|
||||
}
|
||||
|
||||
/** 仅 admin 可用:触发一次性会话清理(保留 ai-writing-优化.md 迁移期临时调试入口)。 */
|
||||
export async function adminCleanupAIWritingSessions(body: {
|
||||
retention_days?: number;
|
||||
only_finished?: boolean;
|
||||
batch_size?: number;
|
||||
} = {}): Promise<AIWritingCleanupResult> {
|
||||
const resp = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/admin/cleanup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
throw new Error(`清理失败 ${resp.status}: ${text || resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
|
||||
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
ProgressEvent,
|
||||
ProgressEventType,
|
||||
UserInterventionPayload,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
|
||||
|
||||
/** 启动写作流程,返回 session_id */
|
||||
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
|
||||
const resp = await apiFetch(`${BASE()}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_intent: req.userIntent,
|
||||
article_type: req.articleType,
|
||||
target_audience: req.targetAudience,
|
||||
word_count_target: req.wordCountTarget,
|
||||
model_name: req.modelName ?? '',
|
||||
keyword_count: req.keywordCount,
|
||||
writing_mode: req.writingMode,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.session_id;
|
||||
}
|
||||
|
||||
const SSE_EVENT_TYPES: ProgressEventType[] = [
|
||||
'connected', 'step_start', 'step_done', 'research_progress',
|
||||
'materials_ready', 'outline_chunk', 'outline_ready',
|
||||
'draft_chunk', 'draft_ready',
|
||||
'review_chunk', 'review_item_done', 'review_ready',
|
||||
'await_user', 'resumed', 'notice', 'done', 'error',
|
||||
];
|
||||
|
||||
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
|
||||
|
||||
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...m,
|
||||
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
|
||||
publishedAt: m.published_at ?? m.publishedAt ?? '',
|
||||
sourceType: m.source_type ?? m.sourceType ?? 'internet',
|
||||
recUuid: m.recUuid ?? m.rec_uuid ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...s,
|
||||
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
|
||||
keyPoints: s.key_points ?? s.keyPoints ?? [],
|
||||
materialIds: s.material_ids ?? s.materialIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutline(o: unknown): unknown {
|
||||
if (!o || typeof o !== 'object') return o;
|
||||
const obj = o as Record<string, unknown>;
|
||||
const sections = Array.isArray(obj.sections)
|
||||
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
|
||||
: [];
|
||||
return { ...obj, sections };
|
||||
}
|
||||
|
||||
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
|
||||
return { ...i };
|
||||
}
|
||||
|
||||
function normalizeReviewResult(r: unknown): unknown {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const obj = r as Record<string, unknown>;
|
||||
return {
|
||||
...obj,
|
||||
factScore: obj.fact_score ?? obj.factScore ?? 0,
|
||||
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
|
||||
languageScore: obj.language_score ?? obj.languageScore ?? 0,
|
||||
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
|
||||
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
|
||||
revisionNotes: Array.isArray(obj.revision_notes)
|
||||
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
|
||||
: Array.isArray(obj.revisionNotes)
|
||||
? obj.revisionNotes
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
|
||||
const out: Record<string, unknown> = { ...parsed };
|
||||
|
||||
// 通用字段
|
||||
if ('agent_name' in out) { out.agentName = out.agent_name; }
|
||||
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
|
||||
|
||||
// materials 数组(materials_ready 和 await_user 里都可能有)
|
||||
if (Array.isArray(out.materials)) {
|
||||
out.materials = out.materials.map((m: unknown) =>
|
||||
normalizeMaterial(m as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
// outline
|
||||
if (out.outline) out.outline = normalizeOutline(out.outline);
|
||||
|
||||
// blocked_sections(section_help await_user)
|
||||
if (Array.isArray(out.blocked_sections)) {
|
||||
out.blockedSections = out.blocked_sections.map((b: unknown) => {
|
||||
const o = b as Record<string, unknown>;
|
||||
return {
|
||||
sectionTitle: o.section_title ?? o.sectionTitle ?? '',
|
||||
reason: o.reason ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
|
||||
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
|
||||
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
|
||||
if ('revision_count' in out) out.revisionCount = out.revision_count;
|
||||
|
||||
// review_ready
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
// review_item_done (streaming per-category result from sse_queue)
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
out.reviewItemIssues = out.issues.map((x: unknown) =>
|
||||
normalizeReviewIssue(x as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
return out as Partial<ProgressEvent>;
|
||||
}
|
||||
|
||||
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
|
||||
export function subscribeToProgress(
|
||||
sessionId: string,
|
||||
onEvent: (event: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
|
||||
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentType = '';
|
||||
let currentData = '';
|
||||
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6).trim();
|
||||
} else if (line === '' && currentType && currentData) {
|
||||
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentData);
|
||||
const normalized = normalizeEvent(parsed);
|
||||
onEvent({
|
||||
type: currentType as ProgressEventType,
|
||||
sessionId,
|
||||
message: '',
|
||||
...normalized,
|
||||
} as ProgressEvent);
|
||||
} catch {
|
||||
// malformed JSON — skip
|
||||
}
|
||||
}
|
||||
currentType = '';
|
||||
currentData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { stopped = true; };
|
||||
}
|
||||
|
||||
/** 提交用户干预决策,恢复暂停的流程 */
|
||||
export async function resumeWriting(
|
||||
sessionId: string,
|
||||
payload: UserInterventionPayload,
|
||||
): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: payload.action,
|
||||
approved_material_ids: payload.approvedMaterialIds ?? [],
|
||||
extra_keywords: payload.extraKeywords ?? [],
|
||||
edited_outline: payload.editedOutline ?? null,
|
||||
outline_feedback: payload.outlineFeedback ?? '',
|
||||
user_revision_notes: payload.userRevisionNotes ?? '',
|
||||
override_verdict: payload.overrideVerdict ?? false,
|
||||
selected_issue_indices: payload.selectedIssueIndices ?? null,
|
||||
section_decisions: (payload.sectionDecisions ?? []).map((d) => ({
|
||||
section_title: d.sectionTitle,
|
||||
decision: d.decision,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||
if (star) {
|
||||
const raw = star[1].replace(/["']/g, "").trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeFallbackFilename(title: string | undefined): string {
|
||||
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
||||
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
||||
}
|
||||
|
||||
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "导出 Word 失败" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
throw new Error("导出 Word 失败");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename =
|
||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||
fallbackFilename;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
|
||||
export async function downloadAIWritingDocx(options: {
|
||||
title?: string;
|
||||
draftMarkdown: string;
|
||||
}): Promise<void> {
|
||||
const draftMarkdown = options.draftMarkdown.trim();
|
||||
if (!draftMarkdown) {
|
||||
throw new Error("文档内容为空");
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: options.title?.trim() || undefined,
|
||||
draft_markdown: draftMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
@@ -27,16 +29,27 @@ interface ModelOption {
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||||
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring disabled:opacity-50';
|
||||
|
||||
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
const MAX_INTENT_LENGTH = 500;
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
const FIELD_TRIGGER_CLASS =
|
||||
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
||||
|
||||
export function AIWritingForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
}: AIWritingFormProps) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [keywordCountInput, setKeywordCountInput] = useState('6');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
@@ -96,157 +109,149 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 检索关键词数量限定 1~10,非法值回退默认 6
|
||||
function handleKeywordCountBlur() {
|
||||
const num = parseInt(keywordCountInput, 10);
|
||||
if (isNaN(num)) {
|
||||
setKeywordCountInput('6');
|
||||
} else {
|
||||
setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
articleType,
|
||||
targetAudience: audience.trim() || '普通读者',
|
||||
targetAudience: hideAdvancedFields
|
||||
? DEFAULT_AUDIENCE
|
||||
: audience.trim() || DEFAULT_AUDIENCE,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount,
|
||||
writingMode: strictMode ? 'strict' : 'loose',
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
modelName: modelName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
||||
<Select
|
||||
value={articleType}
|
||||
onValueChange={handleArticleTypeChange}
|
||||
disabled={disabled || typesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleTypes.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
|
||||
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4">
|
||||
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3">
|
||||
<PenLine className="size-4 text-[#8b6f6f]" />
|
||||
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f]">写作配置选项</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="800"
|
||||
value={wordCountInput}
|
||||
onChange={(e) => setWordCountInput(e.target.value)}
|
||||
onBlur={handleWordCountBlur}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={FIELD_LABEL_CLASS}>写作意图</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{intent.length} / {MAX_INTENT_LENGTH} 字
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局..."
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={5}
|
||||
maxLength={MAX_INTENT_LENGTH}
|
||||
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</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>
|
||||
<Select
|
||||
value={articleType}
|
||||
onValueChange={handleArticleTypeChange}
|
||||
disabled={disabled || typesLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleTypes.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-sm">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>目标字数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="800"
|
||||
value={wordCountInput}
|
||||
onChange={(e) => setWordCountInput(e.target.value)}
|
||||
onBlur={handleWordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模型</label>
|
||||
<Select
|
||||
value={modelName}
|
||||
onValueChange={setModelName}
|
||||
disabled={disabled}
|
||||
disabled={disabled || models.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="选择模型…" />
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={models.length > 0 ? '选择模型…' : '模型加载中…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
<SelectItem key={m.name} value={m.name} className="text-sm">
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
className="mt-4 h-12 w-full gap-2 rounded-2xl text-base font-semibold"
|
||||
>
|
||||
<PenLine className="size-4" />
|
||||
开始 AI 写作
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
|
||||
<PenLine className="size-3.5" />
|
||||
开始 AI 写作
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '进行中', color: 'text-blue-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
error: { label: '错误', color: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelative(isoStr: string): string {
|
||||
export function formatRelative(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
|
||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
export interface SessionItemProps {
|
||||
session: AIWritingSessionSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
@@ -33,7 +33,7 @@ interface SessionItemProps {
|
||||
onRename: (title: string) => void;
|
||||
}
|
||||
|
||||
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -13,13 +14,34 @@ import {
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useAIWriting } from '../../hooks/useAIWriting';
|
||||
import { useAIWriting } from '../../hooks/useAIWritingStream';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||
|
||||
/**
|
||||
* 暂停 / 继续功能开关(默认 false = 关)。
|
||||
*
|
||||
* 历史背景:legacy 路径下,为了让用户点"停止"后还能"继续",引入了 per-worker
|
||||
* 内存 dict(``_event_queues`` / ``_session_pending_pause``)。多 worker 部署下
|
||||
* 这些 dict 互相看不见 → SSE 重连失败 / "会话丢失" → 触发了整个 ai-writing-优化.md
|
||||
* 的迁移计划。
|
||||
*
|
||||
* 当前 LangGraph Server 路径下架构已无 per-worker 状态,但 ``stream.stop()`` 仅
|
||||
* 取消客户端 SSE 订阅,**不会真正暂停后端 run**(LLM 继续烧 token);``resume``
|
||||
* 也无法重新订阅同一个 run。这是个产品语义没想清楚的功能,先关掉。
|
||||
*
|
||||
* 后期想清楚怎么做"真·暂停"(``client.runs.cancel(run_id, action='interrupt')``
|
||||
* 保留 checkpoint)+ "真·继续"(``client.runs.joinStream`` 续上原 run)时,
|
||||
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
|
||||
*/
|
||||
const ENABLE_PAUSE_RESUME_UI = false;
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||
import { DraftStreamingView } from './DraftStreamingView';
|
||||
import { IntentResultCard } from './IntentResultCard';
|
||||
import { IntentStreamingView } from './IntentStreamingView';
|
||||
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||
import { ReviewStreamingView } from './ReviewStreamingView';
|
||||
import { RevisionSummaryCard } from './RevisionSummaryCard';
|
||||
@@ -43,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
error: '出错了',
|
||||
};
|
||||
|
||||
export function AIWritingPanel() {
|
||||
export function AIWritingPanel({
|
||||
hideAdvancedFields = false,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
@@ -171,6 +197,52 @@ export function AIWritingPanel() {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
|
||||
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
|
||||
const handleSelectHistoryRef = useRef(handleSelectHistory);
|
||||
useEffect(() => {
|
||||
handleSelectHistoryRef.current = handleSelectHistory;
|
||||
}, [handleSelectHistory]);
|
||||
|
||||
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem('ai-writing:pending-session');
|
||||
if (!raw) return;
|
||||
sessionStorage.removeItem('ai-writing:pending-session');
|
||||
try {
|
||||
const session = JSON.parse(raw) as AIWritingSessionSummary;
|
||||
handleSelectHistoryRef.current(session);
|
||||
} catch { /* ignore */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅挂载时执行一次
|
||||
|
||||
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
|
||||
useEffect(() => {
|
||||
function onOpenSession(e: Event) {
|
||||
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
|
||||
if (session) handleSelectHistoryRef.current(session);
|
||||
}
|
||||
window.addEventListener('ai-writing:open-session', onOpenSession);
|
||||
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
|
||||
}, []);
|
||||
|
||||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||
const handleAdminCleanup = useCallback(async () => {
|
||||
if (!window.confirm('确认清理:retention_days=0 会清掉所有 done/error 的历史会话,正在跑的会跳过。继续?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await adminCleanupAIWritingSessions({ retention_days: 0, only_finished: true });
|
||||
toast.success(`已清理:DB ${result.deleted_db} 行,checkpoint ${result.deleted_checkpoints} 条,跳过活跃 ${result.skipped_active}`);
|
||||
// 刷新历史列表
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const showNewButton = historySession !== null || state.status !== 'idle';
|
||||
|
||||
// History view mode
|
||||
@@ -225,6 +297,16 @@ export function AIWritingPanel() {
|
||||
AI 智能写作
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={handleAdminCleanup}
|
||||
title="[admin 调试] 清理所有过期会话"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -240,7 +322,9 @@ export function AIWritingPanel() {
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && !state.isPaused && (
|
||||
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
|
||||
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
|
||||
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -251,7 +335,7 @@ export function AIWritingPanel() {
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
{state.isPaused && (
|
||||
{ENABLE_PAUSE_RESUME_UI && state.isPaused && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -291,7 +375,25 @@ export function AIWritingPanel() {
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||
{/* 未开始时显示表单 */}
|
||||
{isIdle && (
|
||||
<AIWritingForm onSubmit={startWriting} />
|
||||
<AIWritingForm
|
||||
onSubmit={startWriting}
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 意图解析:流式 → 结果卡片承接。
|
||||
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
|
||||
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
|
||||
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
|
||||
{(state.intentStreamingText || state.intentThinkingText) && (
|
||||
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
|
||||
)}
|
||||
{state.parsedIntent && !state.intentStreamingText && (
|
||||
<IntentResultCard
|
||||
intent={state.parsedIntent}
|
||||
articleTypes={articleTypes}
|
||||
hideTargetAudience={hideAdvancedFields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 运行中或完成后显示时间线 */}
|
||||
@@ -345,7 +447,9 @@ export function AIWritingPanel() {
|
||||
{showSkeleton &&
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||||
!state.intentStreamingText && !state.intentThinkingText && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
|
||||
</div>
|
||||
@@ -386,7 +490,7 @@ export function AIWritingPanel() {
|
||||
isError ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{state.isPaused ? (
|
||||
{ENABLE_PAUSE_RESUME_UI && state.isPaused ? (
|
||||
<>
|
||||
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
|
||||
已暂停
|
||||
@@ -420,20 +524,26 @@ export function AIWritingPanel() {
|
||||
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
|
||||
</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
{!hideAdvancedFields && (
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
)}
|
||||
<ReqRow label="目标字数">
|
||||
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
</>
|
||||
)}
|
||||
{state.request.modelName && (
|
||||
<ReqRow label="写作模型">
|
||||
<span className="text-muted-foreground">{state.request.modelName}</span>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CollapseTransitionProps {
|
||||
open: boolean;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
duration?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CollapseTransition({
|
||||
open,
|
||||
className,
|
||||
innerClassName,
|
||||
duration = 200,
|
||||
children,
|
||||
}: CollapseTransitionProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
style={{ transitionDuration: `${duration}ms` }}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] ease-out',
|
||||
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('min-h-0 overflow-hidden', innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2, ChevronRight, ChevronDown,
|
||||
CheckCircle2, ChevronRight,
|
||||
ExternalLink, ListTree, FileText,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildIntranetSourceUrl } from '@/core/config';
|
||||
import type { CompletedIntervention } from '../../types/ai-writing';
|
||||
import type { AIMaterial, CompletedIntervention } from '../../types/ai-writing';
|
||||
import { OutlineCard } from './OutlineCard';
|
||||
import { ReviewCard } from './ReviewCard';
|
||||
import { ScoreBar } from './ScoreBar';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
|
||||
interface Props {
|
||||
record: CompletedIntervention;
|
||||
@@ -57,45 +58,95 @@ function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIn
|
||||
)}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.materials && record.materials.length > 0 && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
{record.materials.map((m) => {
|
||||
const pct = Math.round(m.relevanceScore * 100);
|
||||
const barColor =
|
||||
m.relevanceScore >= 0.8 ? 'bg-green-500'
|
||||
: m.relevanceScore >= 0.5 ? 'bg-orange-400'
|
||||
: 'bg-red-400';
|
||||
return (
|
||||
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
|
||||
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="truncate max-w-[100px]">{m.source}</span>
|
||||
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.publishedAt}</span></>}
|
||||
<div className="flex flex-1 items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
|
||||
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
|
||||
</div>
|
||||
{m.recUuid && (
|
||||
<button type="button"
|
||||
onClick={() => window.open(buildIntranetSourceUrl(m.recUuid), '_blank', 'noopener,noreferrer')}
|
||||
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
|
||||
原文 <ExternalLink className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<CollapseTransition open={expanded && !!record.materials && record.materials.length > 0}>
|
||||
<div className="px-2.5 pb-2.5 pt-1.5 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
{record.materials?.map((m) => (
|
||||
<CompletedMaterialItem key={m.id} material={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function relevanceLabel(score: number): string {
|
||||
if (score >= 0.8) return '高相关';
|
||||
if (score >= 0.5) return '中相关';
|
||||
return '低相关';
|
||||
}
|
||||
|
||||
function CompletedMaterialItem({ material }: { material: AIMaterial }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pct = Math.round(material.relevanceScore * 100);
|
||||
const barColor =
|
||||
material.relevanceScore >= 0.8 ? 'bg-green-500'
|
||||
: material.relevanceScore >= 0.5 ? 'bg-orange-400'
|
||||
: 'bg-red-400';
|
||||
const hasDetail = !!material.content || !!material.recUuid;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border/50 bg-background overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasDetail ? () => setOpen(v => !v) : undefined}
|
||||
className={cn(
|
||||
'w-full px-2.5 py-1.5 space-y-1 text-left',
|
||||
hasDetail && 'hover:bg-muted/30 transition-colors',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="flex-1 font-medium leading-snug line-clamp-2">{material.title}</span>
|
||||
{hasDetail && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mt-0.5 size-3 shrink-0 text-muted-foreground transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="truncate max-w-[100px]">{material.source}</span>
|
||||
{material.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{material.publishedAt}</span></>}
|
||||
<div className="flex flex-1 items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
|
||||
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
|
||||
<span className="text-muted-foreground/60 shrink-0 text-[10px]">({relevanceLabel(material.relevanceScore)})</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<CollapseTransition open={open && hasDetail}>
|
||||
<div className="px-2.5 pb-2 pt-1.5 space-y-1.5 border-t border-border/40">
|
||||
{material.content && (
|
||||
<p className="text-muted-foreground leading-relaxed line-clamp-6">
|
||||
{material.content.slice(0, 300)}
|
||||
{material.content.length > 300 && '…'}
|
||||
</p>
|
||||
)}
|
||||
{material.recUuid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer')}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLink className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,17 +175,20 @@ function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedInt
|
||||
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
|
||||
</span>
|
||||
{record.outline && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.outline && (
|
||||
<CollapseTransition open={expanded && !!record.outline}>
|
||||
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<OutlineCard outline={record.outline} readOnly />
|
||||
{record.outline && <OutlineCard outline={record.outline} readOnly />}
|
||||
</div>
|
||||
)}
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,17 +227,20 @@ function DraftCompleted({ record, expanded, onToggle }: { record: CompletedInter
|
||||
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
|
||||
</span>
|
||||
{hasReview && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.reviewResult && (
|
||||
<CollapseTransition open={expanded && !!record.reviewResult}>
|
||||
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<ReviewCard review={record.reviewResult} />
|
||||
{record.reviewResult && <ReviewCard review={record.reviewResult} />}
|
||||
</div>
|
||||
)}
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,19 +272,26 @@ function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedInte
|
||||
</span>
|
||||
)}
|
||||
{hasReview && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.reviewResult && (
|
||||
<CollapseTransition open={expanded && !!record.reviewResult}>
|
||||
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
|
||||
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
|
||||
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
|
||||
{record.reviewResult && (
|
||||
<>
|
||||
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
|
||||
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
|
||||
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Brain, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StreamingDraftSection } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
|
||||
interface DraftStreamingViewProps {
|
||||
title: string;
|
||||
@@ -40,10 +41,10 @@ function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDon
|
||||
<Brain className="size-3 text-amber-500 shrink-0" />
|
||||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
<ChevronRight className={cn('size-3 transition-transform duration-200', open && 'rotate-90')} />
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<CollapseTransition open={open}>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
|
||||
@@ -53,7 +54,7 @@ function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDon
|
||||
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
import type { ParsedIntent } from '../../types/ai-writing';
|
||||
import type { ArticleType } from '../../api/ai-writing-article-types';
|
||||
|
||||
interface IntentResultCardProps {
|
||||
intent: ParsedIntent;
|
||||
articleTypes: ArticleType[];
|
||||
hideTargetAudience?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
|
||||
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
|
||||
*/
|
||||
export function IntentResultCard({
|
||||
intent,
|
||||
articleTypes,
|
||||
hideTargetAudience = false,
|
||||
}: IntentResultCardProps) {
|
||||
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
|
||||
const typeLabel =
|
||||
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/40 dark:bg-blue-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
|
||||
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
意图解析完成
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-1.5 text-xs">
|
||||
<Row label="文章类型" value={typeLabel} />
|
||||
{!hideTargetAudience && (
|
||||
<Row label="目标读者" value={intent.targetAudience} />
|
||||
)}
|
||||
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
|
||||
{intent.refinedIntent && (
|
||||
<Row label="精炼意图" value={intent.refinedIntent} multiline />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, multiline }: { label: string; value: string; multiline?: boolean }) {
|
||||
return (
|
||||
<div className={multiline ? 'flex flex-col gap-0.5' : 'flex items-baseline gap-2'}>
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className={multiline ? 'text-foreground leading-relaxed' : 'text-foreground truncate'}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Sparkles, ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
|
||||
interface IntentStreamingViewProps {
|
||||
text: string;
|
||||
thinkingText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 意图解析流式视图:与 OutlineStreamingView / DraftStreamingView 同样的打字机机制,
|
||||
* 颜色用蓝色主题区分意图理解阶段。展示 LLM 边吐 JSON 边滚动的过程,结果由
|
||||
* IntentResultCard 接力展示结构化字段。
|
||||
*/
|
||||
export function IntentStreamingView({ text, thinkingText }: IntentStreamingViewProps) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) {
|
||||
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
if (!text && !thinkingText) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
思考过程
|
||||
</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{thinkOpen
|
||||
? <ChevronDown className="size-3.5" />
|
||||
: <ChevronRight className="size-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{thinkOpen && (
|
||||
<pre
|
||||
ref={thinkRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{!text && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/50 dark:bg-blue-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
|
||||
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
意图解析中…
|
||||
</span>
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-blue-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
|
||||
>
|
||||
{text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-blue-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user