This commit is contained in:
许海洋 committed 2026-05-27 09:26:54 +08:00
commit f0ebe81879
156 files changed
+16412 -11687

No files matched your search

+4 -1
View File
@@ -10,6 +10,8 @@ frontend-web/.vscode/
# Frontend local/runtime artifacts # Frontend local/runtime artifacts
frontend-web/.env frontend-web/.env
frontend-web/.env.local
frontend-web/.env.*.local
frontend-web/node_modules/ frontend-web/node_modules/
frontend-web/.pnpm-store/ frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo frontend-web/tsconfig.tsbuildinfo
@@ -24,7 +26,7 @@ frontend-web/src.zip
frontend-web/src9.zip frontend-web/src9.zip
frontend-web/src-v1.zip frontend-web/src-v1.zip
frontend-web/src-*.rar frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts # Frontend build artifacts
frontend-web/dist/ frontend-web/dist/
frontend-web/dist-scheduled-check/ frontend-web/dist-scheduled-check/
@@ -78,3 +80,4 @@ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
deploy deploy
.history
-9
View File
@@ -1,9 +0,0 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
-20
View File
@@ -1,20 +0,0 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/75261c35-a558-4ef8-9501-e8b7f9c98bfb
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Gemini Style Assistant",
"description": "A clean, modern AI assistant interface inspired by Google Gemini with centered search, suggestion pills, and a minimalist sidebar.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
-35
View File
@@ -1,35 +0,0 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}
-238
View File
@@ -1,238 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
MessageSquare,
Bot,
Wrench,
Brain,
ListChecks,
Settings,
Menu,
Mic,
Plus,
Sparkles,
ArrowRight,
User,
History,
LayoutGrid,
Languages,
Code2,
Music,
Database,
Search
} from 'lucide-react';
export default function App() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const navItems = [
{ icon: MessageSquare, label: '对话', active: true },
{ icon: Bot, label: '智能体' },
{ icon: Wrench, label: '技能' },
{ icon: Brain, label: '记忆' },
{ icon: ListChecks, label: '任务管理' },
];
const recentChats = [
'CM助手初次问候交谈',
'Untitled',
'Untitled',
'Untitled',
'帮我写一个500字的文案',
'帮我生成一个写论文的实例',
];
const suggestions = [
{ icon: Code2, label: 'Build an Android app', color: 'text-green-600', bg: 'bg-green-50' },
{ icon: Languages, label: 'Convert text to speech', color: 'text-blue-600', bg: 'bg-blue-50' },
{ icon: Music, label: 'Generate music', color: 'text-purple-600', bg: 'bg-purple-50' },
{ icon: Database, label: 'Add database and auth', color: 'text-cyan-600', bg: 'bg-cyan-50' },
];
return (
<div className="flex h-screen w-full bg-white font-sans text-gray-900 overflow-hidden">
{/* Sidebar */}
<motion.aside
initial={false}
animate={{ width: sidebarOpen ? 280 : 0, opacity: sidebarOpen ? 1 : 0 }}
className="relative bg-gray-50 border-r border-gray-100 flex flex-col h-full"
>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-2 px-2 py-1.5 bg-red-50 text-red-700 rounded-lg cursor-pointer">
<Plus size={18} />
<span className="font-medium text-sm">新对话</span>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-2 space-y-1">
<div className="space-y-1 pb-4">
{navItems.map((item) => (
<div
key={item.label}
className={`flex items-center gap-3 px-3 py-2 rounded-xl cursor-pointer transition-colors ${
item.active ? 'bg-white shadow-sm font-medium' : 'hover:bg-gray-100 text-gray-600'
}`}
>
<item.icon size={18} />
<span className="text-sm">{item.label}</span>
</div>
))}
</div>
<div className="pt-4 border-t border-gray-200">
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
最近对话
</h3>
{recentChats.map((chat, idx) => (
<div
key={idx}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg cursor-pointer truncate"
>
{chat}
</div>
))}
</div>
</nav>
<div className="p-4 border-t border-gray-200">
<div className="flex items-center gap-3 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-xl cursor-pointer">
<Settings size={18} />
<span className="text-sm">设置和更多</span>
</div>
</div>
</motion.aside>
{/* Main Content */}
<main className="flex-1 flex flex-col relative h-full overflow-y-auto">
{/* Top bar */}
<header className="absolute top-0 w-full p-4 flex items-center justify-between z-10">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors"
>
<Menu size={20} />
</button>
<div className="flex items-center gap-2">
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors">
<Settings size={20} />
</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-400 to-purple-400 flex items-center justify-center text-white shadow-sm ring-2 ring-white cursor-pointer overflow-hidden">
<User size={16} />
</div>
</div>
</header>
{/* Center Canvas */}
<div className="flex-1 flex flex-col items-center justify-center px-4 max-w-4xl mx-auto w-full pt-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12 relative"
>
<div className="absolute -top-16 left-1/2 -translate-x-1/2 opacity-10 blur-2xl pointer-events-none">
<Sparkles size={120} className="text-blue-500 animate-pulse" />
</div>
<h1 className="text-5xl font-display font-medium tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-500 mb-4 px-4">
Build your ideas with Gemini
</h1>
<Sparkles className="absolute -right-8 top-0 text-blue-200 opacity-50" size={48} />
</motion.div>
{/* Search/Input Area */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="w-full max-w-2xl relative group"
>
<div className="gemini-gradient-border gemini-shadow rounded-3xl bg-white p-2">
<div className="flex flex-col p-4 space-y-4">
<textarea
placeholder="Describe an app and let Gemini do the rest"
className="w-full bg-transparent resize-none focus:outline-none text-lg text-gray-700 min-h-[80px]"
/>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<button className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
<Mic size={20} />
</button>
<button className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
<Plus size={20} />
</button>
</div>
<button className="flex items-center gap-2 px-5 py-2.5 bg-gray-50 hover:bg-gray-100 text-gray-700 rounded-full border border-gray-200 transition-all font-medium text-sm group/btn shadow-sm">
<Sparkles size={16} className="text-blue-500" />
I'm feeling lucky
<ArrowRight size={14} className="opacity-0 -translate-x-2 group-hover/btn:opacity-100 group-hover/btn:translate-x-0 transition-all" />
</button>
</div>
</div>
</div>
</motion.div>
{/* Suggestion Chips */}
<div className="mt-8 flex flex-wrap justify-center gap-3">
{suggestions.map((suggestion, idx) => (
<motion.button
key={suggestion.label}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + idx * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`flex items-center gap-2 px-4 py-2.5 rounded-2xl border border-gray-100 bg-white hover:bg-gray-50 shadow-sm transition-all text-sm font-medium group hover:border-gray-200 text-gray-700`}
>
<suggestion.icon className={`w-4 h-4 ${suggestion.color}`} />
{suggestion.label}
</motion.button>
))}
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex items-center justify-center p-2 rounded-full border border-gray-100 bg-white hover:bg-gray-50 text-gray-400 shadow-sm transition-all"
>
<ArrowRight size={16} />
</motion.button>
</div>
</div>
{/* Footer info/links */}
<footer className="w-full max-w-4xl mx-auto px-4 py-12 mt-auto">
<div className="rounded-3xl border border-gray-100 bg-gray-50/50 p-8 flex items-center justify-between group hover:bg-gray-50 transition-colors">
<div>
<h2 className="text-2xl font-display font-medium text-gray-800 mb-1">
Discover and remix app ideas
</h2>
<p className="text-gray-500 text-sm italic">
(Gallery view pending implementation)
</p>
</div>
<button className="flex items-center gap-2 px-6 py-2.5 bg-white border border-gray-200 rounded-xl hover:shadow-md transition-all font-medium text-gray-700">
Browse the app gallery
<ArrowRight size={18} />
</button>
</div>
</footer>
{/* Floating Right Bar (from target image) */}
<div className="absolute right-6 top-1/2 -translate-y-1/2 flex flex-col gap-4">
<button className="p-2.5 bg-white shadow-lg border border-gray-100 rounded-xl hover:scale-110 transition-all text-gray-400">
<LayoutGrid size={22} />
</button>
<button className="p-2.5 bg-white shadow-lg border border-gray-100 rounded-xl hover:scale-110 transition-all text-gray-400">
<Languages size={22} />
</button>
</div>
</main>
</div>
);
}
-71
View File
@@ -1,71 +0,0 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Outfit", "Inter", sans-serif;
}
@layer base {
body {
@apply bg-white text-gray-900 antialiased;
}
}
@layer utilities {
.gemini-shadow {
box-shadow: 0 0 20px rgba(66, 133, 244, 0.08),
0 0 40px rgba(219, 68, 55, 0.04),
0 0 60px rgba(244, 180, 0, 0.04),
0 0 80px rgba(15, 157, 88, 0.04);
}
.gemini-gradient-border {
position: relative;
background: white;
border-radius: 1.5rem; /* rounded-3xl */
z-index: 0;
overflow: hidden;
}
.gemini-gradient-border::before {
content: '';
position: absolute;
width: 150%;
height: 150%;
top: -25%;
left: -25%;
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 90deg,
#4285F4 120deg,
#DB4437 150deg,
#F4B400 180deg,
#0F9D58 210deg,
transparent 240deg
);
z-index: -2;
animation: rotate 3s linear infinite;
filter: blur(15px);
}
.gemini-gradient-border::after {
content: '';
position: absolute;
inset: 1.5px; /* The "thickness" of the glow border */
background: white;
border-radius: calc(1.5rem - 1px);
z-index: -1;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@400;500;600&display=swap');
-10
View File
@@ -1,10 +0,0 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
-22
View File
@@ -1,22 +0,0 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});
+12
View File
@@ -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
+28 -7
View File
@@ -1,9 +1,30 @@
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量) # 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如: #
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 # ┌─────────────────┬──────────────────────────────────────────────────┐
# │ 命令 │ 生效文件 │
# ├─────────────────┼──────────────────────────────────────────────────┤
# │ 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=https://ch1.b.uat.4.cn/deerflow # --- 开发环境(见 .env.development)---
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/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=http://47.88.25.99:8000
# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离) # --- 生产打包(见 .env.production)---
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # 部署前修改 .env.production 中的地址,再执行 pnpm build
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
# VITE_GITHUB_OAUTH_TOKEN=
+12
View File
@@ -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,916 +0,0 @@
# AI 写作 — 后端开发文档
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
>
> **配套文档**:
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
---
## 一、产品定位与技术架构
### 产品定位
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
### 技术栈
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
- **业务数据**:MySQL `ai_writing_sessions` 表
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
### 与主 Agent 的关系
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
---
## 二、目录结构
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── routers/
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
│
└── packages/harness/deerflow/
├── agents/ai_writing/
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
│ ├── state.py # AIWritingState TypedDict(121 行)
│ │
│ ├── nodes/ # ── Graph 节点 ──
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
│ │ ├── intent_parser.py # 节点 1:意图解析
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
│ │ ├── writer_outline.py # 节点 3:大纲规划
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
│ │
│ ├── prompts/ # ── Prompt 模板 ──
│ │ ├── __init__.py
│ │ ├── researcher_prompts.py
│ │ ├── writer_prompts.py
│ │ └── editor_prompts.py
│ │
│ └── search/ # ── 素材检索 provider ──
│ ├── base.py # SearchResult / SearchProvider 接口
│ ├── duckduckgo_search.py
│ ├── internet_search.py # 互联网检索(默认)
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
│ └── demo_search.py # 离线 demo 数据
│
└── persistence/ai_writing_sessions/
├── __init__.py # AIWritingSessionRepository 工厂
├── model.py # ORM:AIWritingSessionRow
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
```
---
## 三、Graph 拓扑(最重要)
### StateGraph 节点 + 边
```
START
│
↓
┌─────────────────┐
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
└────────┬────────┘
↓
┌─────────────────┐
│ researcher │ ← (re_search 回到这里)
└────────┬────────┘
↓ (after_researcher: ⼟材 ready)
┌──────────────────────┐
│ pause_material │ ⚙️ PAUSE-1:素材确认
└─┬──────────┬─────────┘
│ confirm │ re_search
↓ └→ researcher
┌─────────────────┐
│ writer_outline │ ← (re_outline 回到这里)
└────────┬────────┘
↓
┌──────────────────────┐
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
└─┬──────────┬─────────┘
│ confirm │ re_outline
↓ └→ writer_outline
┌─────────────────┐
│ writer_draft │ ← (素材补充后回到这里)
└─┬───────────────┘
↓ (after_writer_draft)
┌──────┴────────────────┐
↓ ↓
┌──────────────────┐ ┌────────────────────┐
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
│ help(素材不足)│ │ │
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
│ supplement │ del/ │ │ │ to_editor
│↓ │ loose │ │ user_revise
research writer_ │ final ↓
draft │ ize writer_draft
│↓
done
│
(to_editor)│
↓
┌─────────────────┐
│ editor │ 3 维度并行流式审核
└────────┬────────┘
↓ (after_review)
┌──────────────────────┐
│ pause_review │ ⚙️ PAUSE-4:审核确认
└─┬──────────┬─────────┘
│ confirm │ revise
│↓ └→ writer_draft
done
```
### 节点定义位置一览
| 节点名 | 实现文件 | 类型 | 流式? |
|---|---|---|---|
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
### 条件路由函数(graph.py)
| 函数 | 在哪个节点之后 | 路由依据 |
|---|---|---|
| `after_researcher` | researcher | 是否找到素材 |
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
| `after_section_help_pause` | pause_section_help | status |
| `after_draft_pause` | pause_draft | done / revising / reviewing |
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
| `after_review_pause` | pause_review | done / writing_draft |
---
## 四、State 定义(state.py)
```python
class AIWritingState(TypedDict):
# —— 写作配置(启动时填充)——
user_intent: str
article_type: str
target_audience: str
word_count_target: int
keyword_count: int # 1-10,默认 6
writing_mode: str # "strict" | "loose"
# —— Agent 产出 ——
material_package: Optional[MaterialPackage] # 素材收集专家
current_outline: Optional[Outline] # 作家
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
current_draft: Optional[DraftArticle] # 最新草稿
review_result: Optional[ReviewResult] # 编辑
# —— 用户干预 ——
last_user_intervention: Optional[UserInterventionPayload]
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
# —— 流程控制 ——
revision_count: int
max_revisions: int # 默认 3
status: WritingStatus
# —— SSE 推送队列 ——
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
```
### State 字段的特殊语义
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
---
## 五、路由层(app/gateway/routers/ai_writing.py)
### 14 个接口一览
| HTTP | 路径 | 用途 | 鉴权 |
|---|---|---|---|
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
```python
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件(供重连重投)
_session_model_names: dict[str, str] # 用户选的模型
_session_interventions: dict[str, list] # 累计干预历史
_session_repos: dict[str, repo] # DB 仓库引用
_session_user_ids: dict[str, str] # 用户 id
_session_article_types: dict[str, list] # 动态文章类型列表
_session_error_flags: dict[str, bool]
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
```
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
### `_stream_graph_segment`(graph 推进函数)
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
5. 任何异常 → 推 `error` 事件 → 关闭 queue
6. finally 段:更新 DB 最终状态、清理所有模块字典
### SSE 生成器 `_sse_generator`
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
**关键逻辑**:
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
- 收到 None 哨兵 → 关闭流
### `_recover_session`(跨 worker 恢复)
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
1. 从 checkpointer 读出 state snapshot
2. 重建 `_event_queues[sid]` 队列
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
返回三种状态:
- `"interrupted"` — 图停在干预点,已重建队列
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
---
## 六、Graph 与持久化(graph.py)
### Lazy 单例 + 自动降级
```python
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
_async_checkpointer_singleton = None # checkpointer 单例
async def get_ai_writing_graph():
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
global AI_WRITING_GRAPH
if AI_WRITING_GRAPH is not None:
return AI_WRITING_GRAPH
checkpointer = await _get_async_checkpointer()
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
return AI_WRITING_GRAPH
```
### Checkpointer 优先级(_get_async_checkpointer)
```
1. config.yaml 的 checkpointer 段
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
└── type: memory → InMemorySaver
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
```
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
### Shutdown
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
调用顺序(详见 `app/gateway/app.py` lifespan):
1. 停清理任务
2. `wait_for_inflight_graph_tasks(30s)`
3. `shutdown_ai_writing_graph()`
---
## 七、节点详解(nodes/)
### `nodes/_utils.py`(公共工具)
| 函数 / 类 | 用途 |
|---|---|
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
| `count_words(text)` | 中文 + 英文混合计字数 |
### `nodes/intent_parser.py` — 意图解析
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
- 输出:更新 state 的对应字段,推 `step_done` 事件
### `nodes/researcher.py` — 素材检索
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
- 用 `search/*` 的 provider 并行检索
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
### `nodes/writer_outline.py` — 大纲规划
- 输入:`user_intent` + `material_package`
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
- 解析最终 JSON → `current_outline`,推 `outline_ready`
### `nodes/writer_draft.py` — 草稿生成(最复杂)
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
### `nodes/editor.py` — 编辑审核(3 维度并行)
- 3 个维度(**fact / logic / language**)并行调 LLM
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
- 三个都完成后聚合成 `review_result`,推 `review_ready`
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
---
## 八、持久化(persistence/ai_writing_sessions/)
### `model.py` — ORM
```python
class AIWritingSessionRow(Base):
__tablename__ = "ai_writing_sessions"
id: Mapped[str] # = session_id(uuid)
user_id: Mapped[str | None] # NULL = no-auth 模式
title: Mapped[str] # 从 user_intent 截取前 50 字
user_intent: Mapped[str | None]
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
draft_title: Mapped[str | None]
draft_markdown: Mapped[str | None]
completed_interventions: Mapped[str | None] # JSON 字符串
review_result: Mapped[str | None] # JSON 字符串
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
created_at / updated_at: BeijingDateTime
```
### `sql.py` — Repository
| 方法 | 用途 |
|---|---|
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
| `list(user_id, limit)` | 列出当前用户的会话 |
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
| `delete(session_id, user_id)` | 删除(带权限校验) |
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
### Alembic 迁移
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
---
## 九、定期清理(ai_writing_cleanup.py)
### 设计目标
- 删超过 `retention_days`(默认 7)的旧会话
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
- **绝对不删正在写的会话**(活跃保护)
### 关键函数
```python
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
```
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
执行流程:
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
5. 循环直到本批 < batch_size
### Cron 调度
`cleanup_scheduler_loop()` 常驻后台任务:
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
- `asyncio.sleep` 到下次触发时间
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
### Admin 手动触发
`POST /api/ai-writing/admin/cleanup`:
- 鉴权:`system_role == "admin"`
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
---
## 十、配置(config.yaml)
```yaml
# Checkpointer:graph 状态持久化
checkpointer:
type: sqlite # sqlite | postgres | memory
connection_string: .deer-flow/data/checkpoints.db
# AI 写作子系统
ai_writing:
# ── 清理调度 ─────────────────────────────────────────────
cleanup:
enabled: true
retention_days: 7
cron: "0 3 * * *" # 每天凌晨 3 点
delete_only_finished: false
batch_size: 500
# ── 素材收集专家(researcher)专用配置 ──────────────────
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
# 取值范围 [1, 100],非法值或缺省回退 20。
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
max_materials: 20
# ── 素材检索 provider ───────────────────────────────────
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
search_provider: internet
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
ddg_region: "cn-zh"
# intranet 模式:对接内网知识库 ES 检索接口
# 接口契约(POST):
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
```
### 配置项与代码的对应关系
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|---|---|---|---|
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
### 配置如何传到节点
```
config.yaml
└─ ai_writing: { ... }
│
▼ _ai_writing_config() 取出整个 dict
routers/ai_writing.py:_make_config()
│ 注入到 config.configurable.ai_writing_config
▼
nodes/researcher.py
cfg = config.get("configurable", {})
ai_writing_config = cfg.get("ai_writing_config", {})
keyword_model = ai_writing_config.get("keyword_model")
rank_model = ai_writing_config.get("rank_model")
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
```
### 切换内网检索的最小步骤
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
### 备注
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
---
## 十一、SSE 事件协议(后端 → 前端契约)
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
### 推出事件的位置
| 事件 | 推出位置 |
|---|---|
| `connected` | `_sse_generator` 入口 |
| `step_start` / `step_done` | 节点内部主动 emit |
| `research_progress` | `researcher_node` 内的 `_emit` |
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
| `resumed` | pause 节点 return 时塞进 `progress_events` |
| `notice` | 节点内手动推(非致命告警) |
| `done` | `_stream_graph_segment` 正常结束 |
| `error` | `_stream_graph_segment` except 分支 |
### 节点如何流式推 chunk
通过 `config.configurable.sse_queue`:
```python
async def writer_outline_node(state, config):
sse_queue = config.get("configurable", {}).get("sse_queue")
sse_session_id = config.get("configurable", {}).get("sse_session_id")
async for chunk in llm.astream(prompt):
if sse_queue:
await sse_queue.put(("outline_chunk", {
"session_id": sse_session_id,
"text": chunk.content,
"is_thinking": False,
}))
return {"current_outline": parsed, ...}
```
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
---
## 十二、扩展指南:常见开发任务
### 任务 1:新增一个 Graph 节点
**例**:在 `editor` 之后加一个「术语统一」节点。
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
```python
async def terminology_node(state, config):
# ... 读 current_draft 改术语
return {
"current_draft": new_draft,
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
}
```
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
```python
from .nodes.terminology import terminology_node
g.add_node("terminology", terminology_node)
# 改 editor 后的边
g.add_edge("editor", "terminology")
g.add_edge("terminology", "pause_review")
```
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
### 任务 2:新增一个用户干预点
1. **写 pause 节点**:在 `graph.py` 顶部加:
```python
async def pause_xxx(state):
user_input = interrupt({
"pause_point": "xxx_confirm",
"message": "...",
"context_field": state["..."],
})
action = user_input.get("action", "confirm")
if action == "confirm":
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
# ... 其他分支
```
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
```python
class ResumeRequest(BaseModel):
# ...
new_field: Optional[str] = None
```
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
```python
intervention = {
# ...
"newField": body.new_field,
}
```
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
5. **前端配合**:见前端文档
### 任务 3:新增一种素材检索 provider
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
```python
class MyProvider(SearchProvider):
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
...
```
2. 配置 `config.yaml`:
```yaml
ai_writing:
search:
provider: my_pkg.search:MyProvider
# provider 特定的配置...
```
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
### 任务 4:修改 LLM Prompt
所有 prompt 集中在 `agents/ai_writing/prompts/`:
- `researcher_prompts.py` — 检索专家
- `writer_prompts.py` — 大纲 + 草稿
- `editor_prompts.py` — 审核
改 prompt 后**强烈建议**:
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
2. 跑现有测试确认拓扑兼容
### 任务 5:扩展持久化字段
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
1. `model.py` 加列:
```python
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
```
2. **必须**新建 alembic 迁移:
```bash
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
```
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
---
## 十三、容错策略(关键设计原则)
### 「写作流程能完整继续」契约
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
### 落实位置
| 失败点 | 兜底位置 | 行为 |
|---|---|---|
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
| DB `update(review)` 失败 | 同上 | 同上 |
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
### 容器优雅关闭
lifespan shutdown 顺序:
1. 停 cleanup scheduler
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
3. 超时未结束的 task `cancel()` + 2s ack 等待
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
---
## 十四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|---|---|---|
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
跑全部:
```bash
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
```
期望 **51 个测试全过**。
---
## 十五、关键调试 / 排查命令
### 看 graph 当前状态
```python
# 进 python shell
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
print(snap.values, snap.next, snap.tasks)
```
### 直接读 sqlite checkpointer
```bash
sqlite3 /app/.deer-flow/data/checkpoints.db
.tables # 看表(checkpoints / checkpoint_writes 等)
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
WHERE thread_id = '<session_id>' LIMIT 5;
```
### 查 MySQL 业务行
```sql
SELECT id, status, draft_title, updated_at,
JSON_LENGTH(transcript) AS transcript_size
FROM ai_writing_sessions
WHERE id = '<session_id>';
```
### 查内存中的活跃会话(调试时手动加)
```python
# 临时在路由加一个 admin 接口
@router.get("/admin/inflight")
async def admin_inflight():
return {
"event_queues": list(_event_queues.keys()),
"inflight_tasks": len(_inflight_graph_tasks),
"pending_pause": list(_session_pending_pause.keys()),
}
```
### 日志关键字
```bash
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
```
期望看到(启动时):
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
警告:
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
---
## 十六、性能与限制
### 单 session 资源占用
- 内存:~50KB(state + progress_events 累计)
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
### 并发上限
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
### 长会话风险
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
### 优化方向
- progress_events 节流(聚合 chunk 事件)
- transcript 增量保存(diff 而非全量覆盖)
- 节点级 LLM 调用加 retry(tenacity)
---
## 十七、与主 Agent 的关系
| 维度 | 主 Agent | AI 写作 |
|---|---|---|
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
| Middleware | 18 层中间件 | 无(直接节点链) |
| State | `ThreadState` | `AIWritingState` |
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
---
## 十八、未来 RFC 方向
| 主题 | 描述 |
|---|---|
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
| 多模态素材 | 支持图片素材输入和图文混合输出 |
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
---
## 附录 A:相关文件速查
| 文件 | 行数 | 用途 |
|---|---|---|
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
---
## 附录 B:常见踩坑
| 现象 | 根因 | 解决 |
|---|---|---|
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -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,538 +0,0 @@
# AI 写作 — 前端开发文档
> **本文档目的**:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
>
> **配套文档**:
> - `ai-writing-说明.md` — 故障排查与运维向解决方案
> - `ai-writing-assistant-backend-api.md` — 后端 API 详细字段定义
> - `ai-writing-assistant-source-map.md` — 旧版前端结构索引(已合并到本文档)
---
## 一、产品定位
AI 写作是 DeerFlow 的「**杂志社协作写作**」子产品,由 LangGraph 后端驱动 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章。用户通过 **4 个干预点**(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
### UI 入口
- 路由:`/canvas/ai-writing`(在 `WorkspaceRoutes.tsx` 注册)
- 入口页:`src/open-canvas/pages/AIWritingPage.tsx`
- 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
---
## 二、目录结构
```
frontend-web/src/open-canvas/
├── pages/
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
│
├── api/ # ── 后端 API 适配层 ──
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
│ └── ai-writing-article-types.ts # 文章类型 CRUD
│
├── hooks/ # ── 状态管理 ──
│ ├── useAIWriting.ts # 写作主状态机(最重要)
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
│ └── useArticleTypes.ts # 文章类型选项 hook
│
├── components/ai-writing/ # ── UI 组件 ──
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
│ │
│ ├── AIWritingTimeline.tsx # 时间线主容器
│ ├── TimelineStep.tsx # 单条进度事件
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
│ │
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
│ ├── MaterialsCard.tsx # 素材确认
│ ├── MaterialItem.tsx # 单个素材项
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
│ ├── OutlineSection.tsx # 大纲单章节
│ ├── ReviewCard.tsx # 审核确认
│ ├── IssueList.tsx # 审核意见列表
│ ├── ScoreBar.tsx # 评分进度条
│ │
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
│ ├── DraftStreamingView.tsx # 草稿流式打字机
│ ├── ReviewStreamingView.tsx # 审核流式打字机
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
│ │
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
│
└── types/
└── ai-writing.ts # 全部类型定义(重要参考)
```
---
## 三、数据流总览
```
┌─────────────────────────────────────────────────────────────────┐
│ AIWritingPanel │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ useAIWriting() 主状态 hook │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
│ │ │ ↓ 返回 session_id │ │ │
│ │ │ │ │ │
│ │ │ subscribeToProgress(sessionId) │ │ │
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
│ │ │ ─── handleEvent reducer → setState │ │ │
│ │ │ │ │ │
│ │ │ submitIntervention() │ │ │
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
│ │ │ │ │ │
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 渲染: │
│ ├ AIWritingForm(idle 状态) │
│ ├ AIWritingTimeline(运行中) │
│ │ ├ TimelineStep × N(progressEvents) │
│ │ └ CompletedInterventionCard × N(已完成的干预) │
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 四、状态机(useAIWriting.ts)
### 状态字段(完整定义见 `types/ai-writing.ts`)
```typescript
interface AIWritingState {
// —— 核心标识 ——
sessionId: string | null; // 后端返回的会话 id
status: WritingStatus; // 当前阶段(13 种状态之一)
isPaused: boolean; // 是否在干预点等待用户
error: string | null;
// —— 配置 ——
request: AIWritingRequest | null; // 用户提交的写作请求
// —— 累计数据 ——
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
outline: AIOutline | null; // 大纲(大纲就绪后填充)
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
// —— 干预 ——
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
// —— 流式视图状态 ——
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
outlineStreamingText: string;
outlineThinkingText: string;
researchProgress: string;
streamingReviewItems: StreamingReviewItem[];
revisionSummary: RevisionSummary | null;
pendingRevisionContext: { /* ... */ } | null;
}
```
### 状态转移图(写作状态机)
```
┌──────┐
│ idle │ ←─────────────────────────────────────┐
└──┬───┘ │
│ startWriting() │
↓ │
┌─────────────────┐ │
│ researching │ │
└──┬──────────────┘ │
↓ materials_ready │
┌─────────────────────────────┐ │
│ awaiting_material_confirm │ ← await_user │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_outline │ ← (re_search 路径) │
└──┬──────────────┘ │
↓ outline_ready │
┌─────────────────────────────┐ │
│ awaiting_outline_confirm │ │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_draft │ ← (re_outline 路径) │
└──┬──────────────┘ │
│ │
┌───────┴────────┐ │
↓ ↓ │
awaiting_section_ awaiting_draft_confirm │
help (素材不足) └─┬───┬───┬──────────────┐ │
│ (supplement) │ │ │ to_editor │ │
│ ↑回到 research │ │ ↓ │ │
│ │ │ reviewing │ │
│ │ │ └─┬──────┐ │ │
│ │ │ ↓ ↓ │ │
│ │ │ awaiting_review_confirm │
│ │ │ └─┬────────┐ │
│ │ │ ↓ submit ↓ │
│ │ │ revising done ───────────────┘
│ │ │ │
│ │ │ └─→ writing_draft (循环修订)
│ │ │
│ │ │ user_revise
│ │ └─→ revising
│ │
│ │ finalize
│ └─→ done
│
└──→ (delete/loose) writing_draft 继续
```
### handleEvent reducer(核心)
`useAIWriting.ts` 里的 `handleEvent` 是个**纯 reducer**,根据 SSE 事件类型更新状态:
| 事件类型 | 主要影响的状态字段 |
|---|---|
| `connected` | 仅记录到 progressEvents |
| `step_start` | 切到对应 `status`(如「编辑」→ reviewing) |
| `step_done` | 仅记录 |
| `research_progress` | `researchProgress`(实时检索进度文案) |
| `materials_ready` | `materials`、清空 researchProgress |
| `outline_chunk` | `outlineStreamingText` / `outlineThinkingText`(打字机) |
| `outline_ready` | `outline`、清空流式文本 |
| `draft_chunk` | `streamingDraftSections`(按章节拼接打字机) |
| `draft_ready` | `currentDraftMarkdown` / `currentDraftTitle` |
| `review_chunk` | `streamingReviewItems[i].streamingText`(逐条打字机) |
| `review_item_done` | `streamingReviewItems[i]` 标记 isDone + 结构化结果 |
| `review_ready` | `reviewResult` |
| `await_user` | `currentPause = event`,`isPaused = true`,**弹出 InterventionCard** |
| `resumed` | `currentPause = null` |
| `notice` | 仅记录(非致命告警) |
| `done` | `status = 'done'` |
| `error` | `status = 'error'`,`error = event.message` |
---
## 五、API 层(api/)
### 1. `api/ai-writing.ts` — 核心写作 API
```typescript
// 启动写作 → 返回 session_id
export async function startWritingSession(req: AIWritingRequest): Promise<string>
// 订阅 SSE 进度流 → 返回取消订阅函数
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
): () => void
// 提交用户干预 → 让 graph 续推
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void>
```
**关键实现细节**:
- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换
- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略
- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream
- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
- 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
### 2. `api/ai-writing-sessions.ts` — 历史会话
```typescript
// CRUD
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
deleteAIWritingSession(id) : Promise<void>
// 时间线持久化(fire-and-forget,永不抛错)
saveAIWritingTranscript(id, transcript) : Promise<boolean>
```
**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。
### 3. `api/ai-writing-article-types.ts` — 文章类型管理
| 函数 | 用途 |
|---|---|
| `listArticleTypes()` | 表单下拉框选项 |
| `createArticleType()` | 管理员新增 |
| `updateArticleType()` | 管理员编辑 |
| `deleteArticleType()` | 管理员删除 |
---
## 六、组件树详解
### 顶层:`AIWritingPanel`(457 行,最大)
负责:
1. 调用 `useAIWriting()` 拿主状态
2. 调用 `useAIWritingSessions()` 拿历史列表
3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容
5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧
6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板
### 干预卡:`InterventionCard`(663 行,第二大)
按 `pausePoint` 渲染**不同的卡片**:
| pausePoint | 卡片 | 用户能做的 |
|---|---|---|
| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 |
| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 |
| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 |
| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
### 时间线:`AIWritingTimeline`
迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。
### 流式视图:`{Outline,Draft,Review}StreamingView`
- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `<think>...</think>` 折叠显示
- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机
- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
### 历史回显:`AIWritingHistoryView`
只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。
---
## 七、SSE 事件协议(前后端契约)
后端通过 SSE 推 `event: <type>\ndata: <json>\n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。
### 解析流程
```
ReadableStream → TextDecoder
→ 按 "\n\n" 分帧
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
→ JSON.parse(data)
→ normalize(parsed)(snake → camel)
→ onEvent(normalized)
```
### 事件载荷字段(不完全列表)
| 字段 | 出现在哪些事件 | 用途 |
|---|---|---|
| `type` | 全部 | 事件类别 |
| `sessionId` | 全部 | 标识 |
| `message` | 大多数 | 显示给用户的文案 |
| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
| `materials` | materials_ready | 素材数组 |
| `outline` | outline_ready | 大纲对象 |
| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 |
| `reviewResult` | review_ready | 完整审核结果 |
| `pausePoint` | await_user | 哪个干预点 |
| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) |
| `blockedSections` | await_user (section_help) | 素材不足的章节列表 |
| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 |
---
## 八、扩展指南:常见开发任务
### 新增一个干预点
1. **前端类型扩展**:
- 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case
- 在 `WritingStatus` 加对应的 `awaiting_xxx`
- 在 `CompletedIntervention.pausePoint` 加 case
2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑
3. **后端配合**(详见后端开发文档):
- `graph.py` 加 `pause_xxx_confirm` 节点
- graph 拓扑加这个节点的边
4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到
### 新增一个 SSE 事件类型
1. `types/ai-writing.ts`:
- `ProgressEventType` 联合类型加新值
- `ProgressEvent` 接口加新字段(如果有载荷)
2. `api/ai-writing.ts`:
- `SSE_EVENT_TYPES` 数组加新值(不加会被忽略)
- `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize)
3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新
4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
### 修改一个流式打字机的行为
每个 streaming view 内部都是独立的:
- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段
- 渲染在 `*StreamingView.tsx`
- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支
### 新增草稿到右侧画布的逻辑
`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。
---
## 九、容错与边界处理
### 已落实的容错(不会阻断写作)
- ✅ `saveAIWritingTranscript` 失败仅 `console.warn`
- ✅ 历史会话列表加载失败仅 `console.warn`
- ✅ 重命名/删除会话失败静默吞掉
- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断
- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
### 已知边界(可能影响 UI 体验)
- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新
- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制
- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理
---
## 十、调试技巧
### 看 SSE 原始事件
浏览器 DevTools → Network → 找 `/api/ai-writing/<id>/stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
### 查当前 hook 状态
在浏览器 console 里:
```js
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
// 或在 useAIWriting 里临时加:
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
// 然后在 console 里 __aiwriting 看
```
### 模拟事件
开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。
### 看后端 transcript
```
GET /api/ai-writing/sessions/<id>
```
返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。
---
## 十一、性能注意点
### 大数组渲染
- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过)
- `streamingDraftSections` 章节多时也是潜在瓶颈
### transcript PUT 频率
- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听)
- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大
- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存
### 浏览器内存
- progressEvents 不主动清理 → 长会话可能占用几十 MB
- 完成一篇文章后建议 `reset()` 清空状态
---
## 十二、相关后端接口(速查)
| 接口 | 用途 | 前端调用方 |
|---|---|---|
| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` |
| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` |
| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` |
| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 |
| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` |
| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` |
| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` |
| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` |
| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` |
| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 |
---
## 十三、未来优化方向
| 项目 | 说明 | 工作量 |
|---|---|---|
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
---
## 附录 A:所有类型一览
完整定义在 `types/ai-writing.ts`,主要类型:
```typescript
// 业务实体
AIMaterial // 单个素材
AIOutline // 大纲
OutlineSection // 大纲单章节
ReviewIssue // 审核问题
ReviewResult // 完整审核结果
BlockedSection // 素材不足的章节
SectionDecision // 章节求助决策
// 流程
WritingStatus // 13 种状态
ProgressEventType // 17 种事件类型
ProgressEvent // SSE 事件载荷
UserInterventionPayload // 干预提交体
AIWritingRequest // 启动写作的请求
CompletedIntervention // 已完成的干预记录
// 流式视图
StreamingDraftSection
StreamingReviewItem
RevisionSummary
// Hook 状态
AIWritingState // useAIWriting 的完整状态
```
---
## 附录 B:常见踩坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 |
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 |
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` |
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -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)
-501
View File
@@ -1,501 +0,0 @@
# AI 写作 — 迁移到 LangGraph Server 的分步实现方案
> **文档目的**:彻底解决 AI 写作在多 worker 部署下"SSE 时断时续 / resume 偶发 404"的根因,参照主聊天(`/workspace/chats/new`)的实现,把 AI 写作的 graph 注册成 LangGraph Server 上的一个 assistant,让 Server 接管线程、checkpoint、SSE、interrupt/resume 的全部协议。
>
> **配套文档**:[ai-writing-说明.md](./ai-writing-说明.md) 里描述了为什么会出问题、当前已落地的兜底方案,以及方案 B/C/F 三条"绕过"路径。本文档是其中"长期方案"的具体实现路线 —— 不是绕过多 worker,而是从架构层面让它**天然不受 worker 数影响**。
>
> **更新日期**:2026-05-20
---
## 一、为什么走这条路
主聊天和 AI 写作功能形态几乎一致(多步流式、可中断/恢复、有持久化),但**主聊天完全不受多 worker 影响**。差别在于:
| 维度 | 主聊天(现状) | AI 写作(现状) | AI 写作(迁移后) |
|---|---|---|---|
| 前端流接入 | `useStream` (SDK) | `fetch` + 自写 `EventSource` | `useStream` (SDK) |
| 后端运行时 | **LangGraph Server**(独立进程,端口 2024 / 经 nginx 走 `/api/langgraph`) | 嵌在 FastAPI Gateway 路由里直接跑 | **LangGraph Server**(同主聊天) |
| 线程状态 | Server 自带持久化 checkpointer | 自管 sqlite + 一堆模块级 dict | Server 自带持久化 checkpointer |
| SSE 通道 | Server ↔ SDK 协议层 | 自写 `asyncio.Queue` per session_id | Server ↔ SDK 协议层 |
| 多 worker | 任何 worker 都能查 Server | per-worker 内存 dict 隔离 | 任何 worker 都能查 Server |
| Interrupt/Resume | Server 协议(`Command(resume=...)`)| 自写 `_session_pending_pause` + `/resume` 路由 | Server 协议 |
**核心收益**:迁完后 `_event_queues`、`_session_pending_pause`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_article_types`、`_session_error_flags`、`_session_pending_pause`、`_inflight_graph_tasks`、`_recover_session()`、自管 sqlite checkpointer 这一整套"per-process 内存状态"全部可以删掉 —— 不是"绕过"问题,是**让问题消失**。
**代价**:节点要改造(流式 chunk 改走 LangGraph 原生 `StreamWriter`/state update)、前端干预卡的交互要重写、业务表写入路径要从"路由里写"挪到"节点里写"。约 **6 人日**核心改造 + 50% buffer,建议按 **9 人日**排期。
---
## 二、现状盘点:要删 / 要留 / 要改
### 2.1 要删的(迁完即可移除)
| 位置 | 内容 | 备注 |
|---|---|---|
| `app/gateway/routers/ai_writing.py` | `POST /start`、`GET /{id}/stream`、`POST /{id}/resume`、`GET /{id}/state` | 由 LangGraph Server 的 `runs.stream / threads.create / runs.wait` 替代 |
| 同上 | `_event_queues`、`_session_pending_pause`、`_session_model_names`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_error_flags`、`_session_article_types` 这 8 个模块级 dict | 状态全部交给 LangGraph Server |
| 同上 | `_recover_session()`、`_inflight_graph_tasks`、`_spawn_graph_task()`、`wait_for_inflight_graph_tasks()` | 跨 worker 恢复 / drain 都由 Server 内建机制处理 |
| 同上 | `_make_config()` 里注入 `sse_queue` / `sse_session_id` 的逻辑 | 流式输出改走 LangGraph 原生通道 |
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()`、`AI_WRITING_GRAPH` 单例、`_async_checkpointer_singleton` | checkpoint 由 langgraph.json 的全局 checkpointer 接管 |
| `app/gateway/ai_writing_cleanup.py` | "联动清理 sqlite checkpointer thread" 那一段 | 改成调 `langgraph_sdk` 的 `threads.delete()` |
| `offline-backend-20260512/config.yaml` | `checkpointer:` 段里 AI 写作专用的 sqlite 路径(如果跟主 checkpointer 不同) | 共用主配置 |
### 2.2 要保留的
| 位置 | 内容 | 理由 |
|---|---|---|
| `packages/harness/deerflow/persistence/ai_writing_sessions/` | MySQL 业务表(标题、最终 draft、completed_interventions、transcript、article_type、status…) | 列表页 / 历史回显 / 全文搜索都依赖业务表,**不能让 LangGraph checkpoint 取代它** —— checkpoint 是流程状态,业务表是产品形态 |
| `GET/PATCH/DELETE /api/ai-writing/sessions[/{id}]` | 列表 / 重命名 / 删除 | 只查业务表,不碰 graph |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 时间线持久化 | 维持锦上添花的回显方案 |
| `GET/POST/PATCH/DELETE /api/ai-writing/article-types[/{id}]` | 文章类型 CRUD | 纯业务路由,跟 graph 无关 |
| `POST /api/ai-writing/admin/cleanup` | admin 清理入口 | 路由保留,内部实现改 |
| 4 个 interrupt 暂停点 + 5 个节点 + 全部 state 字段 | 业务逻辑零改动 | `pause_material_confirm` / `pause_outline_confirm` / `pause_section_help` / `pause_draft_confirm` / `pause_review_confirm` 用的就是 LangGraph 原生 `interrupt()`,直接吃迁移红利 |
### 2.3 要改的
| 位置 | 改造点 |
|---|---|
| `graph.py` | 删自管 checkpointer,导出 `make_ai_writing_graph(config) → CompiledStateGraph`,由 Server 注入 checkpointer |
| `langgraph.json` | `graphs` 里增加 `"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"` |
| 各 node(`researcher.py` / `writer_outline.py` / `writer_draft.py` / `editor.py`) | 流式输出从 `sse_queue.put_nowait(...)` 改成 LangGraph 原生 `get_stream_writer()` 推 custom 事件 |
| 各 node | DB 写入(草稿、审核结果、status)从「路由里调 repo」改成「节点里通过 `RunnableConfig` 拿 `thread_id` 调 repo」 |
| 各 node | "创建 session 行"从 `/start` 路由里挪到 `intent_parser_node`(或前端 `onCreated` 回调里调一个轻量级 upsert API) |
| `langgraph_auth.py` | 给 `ai_writing` assistant 加授权规则(保证只能访问自己的 thread) |
| 前端 `frontend-web/src/open-canvas/` | 完全弃用现有 `api/ai-writing.ts` 的 `fetch + EventSource`,新写 `useAIWritingStream` hook(基于 `@langchain/langgraph-sdk/react` 的 `useStream`) |
| 前端 4 个干预卡 | 提交回调从 `POST /resume` 改为 `thread.submit(new Command({ resume: payload }))` |
| 前端"启动写作"按钮 | 从 `POST /start` + 拿 session_id + 建 EventSource 改成 `thread.submit({...args})` 触发 `onCreated` 回调 |
| `tests/test_ai_writing_session_recovery.py` | 整体删除 —— 跨 worker 恢复由 LangGraph Server 接管,不再是 AI 写作自己的事 |
| 新增 `tests/test_ai_writing_assistant.py` | 通过 LangGraph SDK in-process 跑一遍 graph,覆盖 interrupt / resume / custom stream / state 持久化 |
---
## 三、分步实施
### 阶段 0:可行性预演(0.5 d)
**目标**:在不动现有代码的前提下,验证"把 ai_writing graph 注册到 LangGraph Server 并通过 SDK 跑通"这条路完全可行。
1. 临时在 `langgraph.json` 的 `graphs` 里加一条 `"ai_writing_probe": "deerflow.agents.ai_writing.graph:build_ai_writing_graph"`(直接复用现有 `build_ai_writing_graph`,不传 checkpointer 让 Server 注入)。
2. 起 `make dev`,确认 LangGraph Server 把 `ai_writing_probe` 列入 assistant 列表。
3. 用 Python SDK 客户端(`langgraph_sdk.get_client()`):
```python
from langgraph_sdk import get_client
client = get_client(url="http://localhost:8001/api/langgraph")
thread = await client.threads.create()
async for chunk in client.runs.stream(
thread["thread_id"], "ai_writing_probe",
input={"user_intent": "测试", "article_type": "analysis", ...},
stream_mode=["values", "messages-tuple", "custom"],
): print(chunk)
# 跑到 pause_material_confirm 应该收到 interrupt 事件
await client.runs.stream(thread["thread_id"], "ai_writing_probe",
command={"resume": {"action": "confirm", "approvedMaterialIds": [...]}})
```
4. 验证:interrupt 能正确暂停、`Command(resume=...)` 能续跑、state 能持久化到 Server checkpoint。
5. 跑通后**回滚 langgraph.json** —— 这一步只是"摸路"。
**通过条件**:4 个 pause 点全部能用 SDK 跑通 interrupt + resume。如果某个 pause 点的 payload 结构跟 SDK 协议不兼容(例如用了非 JSON 可序列化的对象),阶段 0 就要解决,否则会在阶段 2 翻车。
---
### 阶段 1:graph 注册为 assistant(0.5 d)
**改动文件**:`packages/harness/deerflow/agents/ai_writing/graph.py`、`offline-backend-20260512/backend/langgraph.json`
1. `graph.py` 顶部新增:
```python
from langchain_core.runnables import RunnableConfig
def make_ai_writing_graph(config: RunnableConfig | None = None):
"""LangGraph Server 入口:不接收 checkpointer,由 Server 注入。"""
g = StateGraph(AIWritingState)
# ... 跟现在 build_ai_writing_graph 完全一样的节点 / 边定义 ...
return g.compile() # 注意:不传 checkpointer
```
2. 旧的 `build_ai_writing_graph(checkpointer=None)`、`get_ai_writing_graph()`、`_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()` 全部**先标 `@deprecated` 暂留** —— 阶段 7 再删,避免阶段 2/3 出错时无法快速回退。
3. `langgraph.json`:
```json
{
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
}
}
```
4. `checkpointer.path` 已经指向 `make_checkpointer`(看 `langgraph.json:15-17`),AI 写作直接共享主 checkpointer 即可,**不要**重新搞一套。
**通过条件**:启动 LangGraph Server 后 `GET /api/langgraph/assistants` 能列出 `ai_writing`,state schema 字段齐全。
---
### 阶段 2:节点改造 —— 流式输出走原生通道(1.5 d)
**核心问题**:现在的 `writer_draft_node` / `writer_outline_node` / `researcher_node` 通过 `config["configurable"]["sse_queue"]` 把 LLM token chunk 推给前端。LangGraph Server 不会再注入 `sse_queue`,必须改用原生流通道。
**选型**:用 `langgraph.config.get_stream_writer()` 推 `custom` 事件(保留现有"非聊天消息、自定义协议"的形态)。
> 不选 `messages-tuple` 模式的原因:草稿生成不是聊天消息流,而是大段 markdown;硬塞成 `AIMessage` 会让前端渲染逻辑变得很别扭。custom 通道改动最小、契合度最高。
**改造模板**(每个节点都按这个模板改):
```python
# 旧:
from .nodes._utils import push_sse_event
async def writer_draft_node(state, config):
sse_queue = config["configurable"].get("sse_queue")
sse_queue.put_nowait({"event": "progress", "data": {...}})
async for chunk in llm.astream(...):
sse_queue.put_nowait({"event": "draft_chunk", "data": {"text": chunk}})
# 新:
from langgraph.config import get_stream_writer
async def writer_draft_node(state, config):
writer = get_stream_writer() # 注意:必须在 async 函数体内调,不能模块级缓存
writer({"type": "progress", "agent": "writer", "message": "开始写作"})
async for chunk in llm.astream(...):
writer({"type": "draft_chunk", "text": chunk})
```
**业务表写入改造**:现在 `writer_draft_node` 收尾时通过 `config["configurable"]["sse_session_id"]` + `_session_repos[sid]` 调 `repo.save_draft()`。新方案:
```python
async def writer_draft_node(state, config):
thread_id = config["configurable"]["thread_id"] # LangGraph Server 注入
# 从 thread metadata / state 里拿到 user_id(在阶段 4 写入)
user_id = state.get("_user_id") or config["configurable"].get("user_id")
repo = get_ai_writing_session_store() # 进程内全局,无 per-session 字典
try:
await repo.save_draft(thread_id, user_id, state["current_draft"])
except Exception:
logger.exception("save_draft failed (non-fatal)")
```
**注意事项**:
- `get_stream_writer()` 在子图 / 工具函数里会拿到当前 graph 的 writer,**不要**在模块 import 时缓存。
- 节点要兼容"测试场景下没有 LangGraph runtime":包一层 `try: writer = get_stream_writer() except ...`,单测里 writer 是 no-op。
- progress_events 字段保留写入 state(现在节点都已经 return `{"progress_events": [...]}`),这样 history / 回放仍然能在 state 里看到完整轨迹;custom stream 只是为了**实时推送**。
**通过条件**:手动跑一次 `make dev`,用 SDK 客户端从前一阶段验证脚本流式拿到 `draft_chunk` 事件。
---
### 阶段 3:前端接入 LangGraph SDK(1 d)
**核心改动**:`frontend-web/src/open-canvas/api/ai-writing.ts` 整文件作废,改写 `frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`。
**新 hook 骨架**(参考 `frontend-web/src/core/threads/hooks.ts:207` 现有主聊天写法):
```ts
import { useStream } from "@langchain/langgraph-sdk/react";
import { Command } from "@langchain/langgraph-sdk";
import { getAPIClient } from "@/core/api/api-client";
interface AIWritingValues {
user_intent: string;
article_type: string;
status: string;
material_package?: MaterialPackage;
current_outline?: Outline;
current_draft?: DraftArticle;
review_result?: ReviewResult;
progress_events: any[];
}
export function useAIWritingStream(sessionId: string | "new") {
const stream = useStream<AIWritingValues, { CustomEventType: AIWritingCustomEvent }>({
client: getAPIClient(),
assistantId: "ai_writing",
threadId: sessionId === "new" ? undefined : sessionId,
reconnectOnMount: true,
onCreated(meta) {
// 后端会用同一个 thread_id 做 ai_writing_sessions.id;通知 UI 写入 URL
navigateTo(`/workspace/ai-writing/${meta.thread_id}`);
},
onCustomEvent(event) {
// 节点 push 来的 draft_chunk / progress / notice
pushUIEvent(event);
},
});
return stream;
}
```
**4 个干预卡的提交逻辑**(旧 → 新):
```ts
// 旧(自写 fetch):
await fetch(`/api/ai-writing/${sessionId}/resume`, {
method: "POST",
body: JSON.stringify({ action: "confirm", approvedMaterialIds: [...] }),
});
// 新(SDK 原生 Command):
stream.submit(undefined, {
command: { resume: { action: "confirm", approvedMaterialIds: [...] } },
});
```
**启动写作**:
```ts
// 旧:POST /start → 返回 session_id → 建 EventSource
// 新:
stream.submit({
user_intent, article_type, target_audience, word_count_target, keyword_count, writing_mode,
}, {
config: { configurable: { model_name } },
metadata: { feature: "ai_writing", article_type }, // 走业务表筛选
});
```
**列表页 / 历史回显**:仍然走 `GET /api/ai-writing/sessions` 业务 API,**无改动**。点进某条历史记录时:
- 拿到 `sessionId` (== LangGraph `thread_id`)
- `useAIWritingStream(sessionId)` 自动调 `client.threads.getState()` 拉最新 state,渲染最后一次草稿 / 干预点 / interrupt 等待
- 已结束的会话 (`status == "done"`) 直接读业务表的 `draft_markdown` 字段,**不挂 SDK stream**(省 Server 资源)
**通过条件**:手动跑完一个完整流程(启动 → 4 次干预 → 拿到草稿),过程中刷新页面 / 切 worker,进度都不丢。
---
### 阶段 4:业务表写入解耦(0.5 d)
**问题**:现在 `/start` 路由先 INSERT 一行 `ai_writing_sessions(id=session_id, ...)`,再启动 graph。迁移后前端用 `stream.submit(...)` 直接触发 LangGraph 建 thread + 跑 graph,业务表行没人建 → 列表页查不到。
**两个方案二选一**:
**方案 4A(推荐)**:节点里建业务表行
- `intent_parser_node` 开头:`await repo.upsert_session(thread_id, user_id, user_intent, article_type, status="researching")`
- 优点:单一数据源(节点),不依赖前端做"双写"。
- 缺点:用户从前端点"启动" → 看到列表里出现这条 → 之间有 200~500ms 延迟(intent_parser 跑完才入库),需要前端 onCreated 后乐观插入一条临时行。
**方案 4B**:前端 onCreated 时调一个轻量 upsert API
- 新增 `POST /api/ai-writing/sessions/{thread_id}/init`:只插业务表行,不碰 graph。
- 前端 `onCreated(meta)` 回调里立刻 fire-and-forget 调它。
- 优点:列表瞬间出现新行。
- 缺点:前端要多调一次 API,且失败要兜底;多了一条"双方都能建行"的路径。
**推荐 4A** —— 单一数据源比 UI 响应更重要,200ms 延迟用 loading skeleton 即可。
**用户身份传递**:节点要拿到 `user_id` 写入业务表。两条路:
- LangGraph Server 已有 `langgraph_auth.py` 注入 auth context,在节点里 `config["configurable"].get("langgraph_auth_user")` 取(具体字段名以现有实现为准)。
- 或者前端 `submit()` 时 metadata 里带上 `user_id`,节点从 `config["configurable"]["metadata"]` 拿。
**通过条件**:从启动到列表显示新行,延迟 < 1s;多用户场景下不会出现"A 用户的 thread 落到 B 的业务表行"。
---
### 阶段 5:transcript 持久化(0.2 d)
**当前**:前端 `PUT /api/ai-writing/sessions/{id}/transcript` 全量覆盖 `ai_writing_sessions.transcript` 列。
**改造选择**:
- **维持现状**(推荐):transcript 是前端组装的时间线 blob,让前端继续 fire-and-forget PUT 即可,跟迁移正交。
- 或者完全弃用 transcript 列,直接从 `client.threads.getHistory()` + `getState()` 还原 —— 工作量大且需要重写回显逻辑,**不推荐第一版做**。
**通过条件**:原 PUT 接口照常工作;新建会话也能落库。
---
### 阶段 6:admin 清理改造(0.3 d)
**改动文件**:`app/gateway/ai_writing_cleanup.py`
```python
# 旧:
saver = get_ai_writing_checkpointer()
await saver.adelete_thread(sid) # 自管 sqlite
# 新:
from langgraph_sdk import get_client
lg_client = get_client(url=internal_langgraph_url())
await lg_client.threads.delete(sid)
```
**活跃保护**:旧逻辑是看 `sid in _event_queues`。新逻辑:
```python
state = await lg_client.threads.get_state(sid)
is_active = (
state.get("next") # graph 还有未执行的节点
or state.get("tasks") # 当前有 pending task
or state.get("metadata", {}).get("interrupted") # 卡在 interrupt 上
)
if is_active:
skipped_active += 1
continue
```
**通过条件**:admin cleanup 接口照常返回 `deleted_db / deleted_checkpoints / skipped_active`,且能正确跳过仍在 interrupt 等待用户的会话。
---
### 阶段 7:删冗余代码(0.3 d)
按 §2.1 列表逐项删除。建议**单独开一个 PR**,跟阶段 1~6 解耦:
- 前 6 个 PR 全部上线、灰度验证至少 1 周
- 确认旧代码路径再无流量后,再走清理 PR
- 这样万一灰度阶段需要回滚,旧路径还在
**通过条件**:`make test` 全绿,`grep -r "_event_queues\|_session_pending_pause\|_recover_session" app/ packages/` 零命中。
---
### 阶段 8:测试 + 灰度(1 d)
**新增测试**:
- `tests/test_ai_writing_assistant.py` —— 用 `langgraph_sdk` 客户端打 LangGraph Server,覆盖:
- 完整流程:start → 4 个 interrupt → resume → done
- state 持久化:跑到 pause_outline 后重建客户端,能 `getState` 看到 outline
- custom stream:能收到 `draft_chunk` 等事件
- 错误兜底:节点抛错时 status 落 error、业务表 status 同步
- 保留 `tests/test_portable_long_text.py` / `tests/test_ai_writing_transcript_endpoint.py` / `tests/test_ai_writing_session_repo.py` —— 这几个跟业务表强相关,不受迁移影响。
**删除测试**:
- `tests/test_ai_writing_session_recovery.py` —— 跨 worker 恢复改由 LangGraph Server 提供,不再是 AI 写作自己的能力。
- `tests/test_ai_writing_cleanup.py` 里关于"drain + checkpointer 降级 + SSE 序列化"的几个用例 —— 这些机制已删,对应用例失效。
**灰度策略**:
- **前端开关**:`VITE_AI_WRITING_BACKEND` = `legacy | langgraph`,默认 `legacy`
- `legacy` → 走老的 `fetch + EventSource + /resume`
- `langgraph` → 走新 `useAIWritingStream`
- **后端**:旧路由(`/start /stream /resume /state`)保留两个发版周期,逐步下线
- **数据兼容**:业务表 schema 完全不变,两条路径都用同一张表
- **回滚成本**:改回 `VITE_AI_WRITING_BACKEND=legacy` 重新部署即可,无 DB migration
**通过条件**:
- 灰度环境跑 100+ 写作流程,对比新旧两条路径的成功率、SSE 中断率、resume 成功率
- 新路径在 worker=4 部署下 SSE 中断率 = 0(旧路径基线约 5~15%)
---
## 四、关键设计决策与权衡
### 4.1 为什么不让 LangGraph checkpoint 替代业务表?
LangGraph checkpoint 存的是"图执行状态",业务表存的是"产品形态"。两者职责不同:
- **会话列表筛选/排序**:业务表 `WHERE user_id = ? AND status = ? ORDER BY updated_at` —— LangGraph checkpoint 没法这样查(它是 `(thread_id, checkpoint_ns)` 主键)
- **全文搜索 draft**:业务表 `LIKE '%关键词%'` —— checkpoint 是序列化 blob
- **transcript 自定义 UI 协议**:前端组装的时间线 JSON,跟 graph state 不一一对应
**结论**:两者**并存**,`thread_id` 作为外键。这是主聊天的做法(`threads_meta` + LangGraph checkpoint),AI 写作迁完后也是同样的形态。
### 4.2 为什么 graph 节点里直接调 repo,不发到外面消费?
考虑过"节点只 emit custom event,路由层订阅事件后写业务表" —— 但这又把状态分散回路由层了。
直接在节点里调 `await repo.save_draft(...)`:
- 节点拿得到 `thread_id`(== session_id == business id),主键一致
- `repo` 是单例(DB 连接池),不依赖 per-session 字典
- 写入失败包 try/except 即可(草稿落 state 比落 DB 更关键,DB 失败不阻断流程)
### 4.3 为什么用 custom stream 而不是 messages-tuple?
writer_draft 输出是大段 markdown,强行包成 `AIMessage` 后:
- 前端要把它从消息列表里拎出来当草稿渲染(混杂)
- 大纲 / 审核结果是结构化 JSON,更不像聊天消息
- progress_events / notice / draft_chunk / outline_chunk 几种事件类型,全塞 message 通道得发明 metadata convention
custom stream 是为这种"非聊天但需要流式 push"场景设计的,匹配度更高。
### 4.4 在 Server 进程里跑 graph,资源够用吗?
LangGraph Server 是独立 Python 进程,跟 Gateway 共享同一台机器但不共享内存。新增一个 assistant 的常驻开销 ~10 MB(graph 编译后的 schema 缓存),每个并发会话峰值 ~50 MB(LLM client + state)。
实测建议:在阶段 0 之后跑一次 10 个并发的压测,观察 Server RSS 增量。若一台机器 4 GB 内存,理论支撑 60~80 并发 AI 写作,足够 50 人规模团队使用。
---
## 五、风险清单
| # | 风险 | 缓解 |
|---|---|---|
| R1 | LangGraph Server 升级版本时 `interrupt()` / `Command(resume=...)` 协议有 breaking change | 在 `pyproject.toml` 里 pin `langgraph` minor 版本;升级前先在阶段 0 那个 probe 跑一遍冒烟 |
| R2 | 前端 `useStream` 的 `reconnectOnMount` 行为跟自写 EventSource 不完全一致,刷新页面后某些情况下不会自动续接 SSE | 阶段 3 手动测刷新场景;如不达预期,配合 `client.threads.getState()` 拉一次 state 兜底 |
| R3 | 节点里 `get_stream_writer()` 在单测里没有 LangGraph runtime → 抛错 | 节点 wrap `try: writer = get_stream_writer() except: writer = lambda x: None` |
| R4 | 多用户场景下 `langgraph_auth.py` 没对 ai_writing assistant 做隔离,可能跨用户拿到别人的 thread state | 阶段 4 一并配置 auth;新增专项测试 |
| R5 | `client.threads.delete()` 是异步删,admin 清理脚本调完后立即查 state 还能短暂查到 → `skipped_active` 误判 | 删之前先 lock thread 或调用 `client.runs.cancel()` 强制收敛 |
| R6 | LangGraph Server 自带 checkpoint 仍然是 sqlite(`langgraph.json` 里配的),在容器重启时跟 AI 写作原有的 `.deer-flow/data/checkpoints.db` 是同一个文件 → schema 共用 | 阶段 1 之前先确认主 checkpointer 配置路径,避免双开 sqlite 文件锁竞争 |
| R7 | 现有用户在迁移窗口期发起的会话,灰度切到新路径后无法续跑(state 在旧 sqlite,新路径查不到) | 迁移前 1 小时禁止新会话;或写一个一次性脚本把旧 checkpointer 的 thread 复制到主 checkpointer |
---
## 六、工时与里程碑
| 阶段 | 工时 | 可独立交付 |
|---|---|---|
| 阶段 0:可行性预演 | 0.5 d | ✅ |
| 阶段 1:graph 注册 | 0.5 d | ❌(后端只生效,前端没接) |
| 阶段 2:节点改造 | 1.5 d | ❌ |
| 阶段 3:前端接入 | 1 d | ✅(带开关上灰度) |
| 阶段 4:业务表写入解耦 | 0.5 d | ❌(跟阶段 3 合并发) |
| 阶段 5:transcript | 0.2 d | ✅ |
| 阶段 6:admin 清理改造 | 0.3 d | ✅ |
| 阶段 7:删冗余 | 0.3 d | ✅(独立 PR,灰度后再发) |
| 阶段 8:测试 + 灰度 | 1 d | ✅ |
| **核心工时** | **5.8 d** | |
| **+ 50% buffer** | **8.7 d ≈ 9 d** | |
**建议排期**:
- 第 1 周:阶段 0 + 1 + 2(后端完整、本地能跑通)
- 第 2 周:阶段 3 + 4 + 5 + 6(前端 + 业务集成)
- 第 3 周:阶段 8(测试 + 灰度)
- 灰度 1~2 周后:阶段 7(删冗余)
---
## 七、回滚预案
每个阶段都设计为可独立回滚:
| 场景 | 回滚动作 |
|---|---|
| 阶段 3 上线后发现 SSE 不稳 | 前端开关 `VITE_AI_WRITING_BACKEND=legacy`,重新部署,0 dataloss |
| 阶段 7 删冗余代码后发现新路径有问题 | 从 git revert 删除 commit,恢复旧路由代码;业务表/checkpoint 不受影响 |
| 阶段 1 注册 assistant 后 LangGraph Server 起不来 | 从 `langgraph.json` 移除 `ai_writing` 那一行,重启 Server,回到迁移前状态 |
**数据兼容承诺**:
- 业务表 schema 完全不变
- `thread_id` (== `ai_writing_sessions.id`) 含义保持一致
- transcript 列含义不变
**最坏回滚**:把 `VITE_AI_WRITING_BACKEND` 改回 `legacy`、`langgraph.json` 删 `ai_writing` 那行,回到迁移前的"方案 C/F/B 路径"。已经走完的会话留在新 checkpoint 里成"孤儿",但业务表里的草稿和审核结果**完整无损**。
---
## 八、迁移成功后能享受的"免费红利"
1. **完整 history API**:`client.threads.getHistory(thread_id)` 拿到每一步 checkpoint,做"回到某一步重试"非常便宜
2. **time travel**:用户能从任意 checkpoint 分叉重写
3. **跟主聊天共享基础设施**:升级 LangGraph 版本时一并升级,不用维护两套 SSE / checkpoint 协议
4. **天然支持水平扩容**:哪天上 K8s 多副本部署,AI 写作跟主聊天一样开箱即用
5. **天然支持 IM 渠道接入**:`app/channels/` 那套 Feishu / Slack 实现是 `langgraph-sdk` 的,迁完后 AI 写作也能在 IM 里用,零额外代码
---
## 九、相关文件索引(迁移前后参照)
### 现状(迁前)
- `app/gateway/routers/ai_writing.py:148-176` —— 8 个模块级 dict + drain task
- `packages/harness/deerflow/agents/ai_writing/graph.py:370-538` —— 自管 checkpointer
- `frontend-web/src/open-canvas/api/ai-writing.ts` —— 自写 fetch + EventSource
### 参照(主聊天迁后形态)
- `frontend-web/src/core/threads/hooks.ts:207` —— `useStream` 用法
- `frontend-web/src/core/api/api-client.ts` —— LangGraph SDK Client 初始化
- `offline-backend-20260512/backend/langgraph.json` —— assistant 注册位置
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/` —— graph 工厂参考写法
- `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py` —— 共享 checkpointer 入口
### 目标(迁后)
- 新增:`packages/harness/deerflow/agents/ai_writing/graph.py:make_ai_writing_graph`
- 新增:`frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`
- 删除:`app/gateway/routers/ai_writing.py` 中的 `/start /stream /resume /state` 路由 + 8 个 dict
- 删除:`frontend-web/src/open-canvas/api/ai-writing.ts` 中的 `startWriting / streamSession / resume` 全部 fetch 调用
- 简化:`app/gateway/ai_writing_cleanup.py`(改用 SDK client 删 thread)
---
## 十、决策摘要(给 PM / 维护者)
> **TL;DR**
> 1. 主聊天不受多 worker 影响,是因为它把状态托管给了独立的 LangGraph Server 进程,前端直接通过 SDK 跟 Server 通信。AI 写作要做同样的事。
> 2. 改造范围:后端 graph 注册 + 节点流通道改造 + 前端 hook 换 SDK + 业务表写入解耦。**核心约 6 人日,含 buffer 9 人日**。
> 3. 业务表、节点逻辑、4 个干预点、UI 形态全部保留,**用户感知层面零变化**。
> 4. 改造完一次性获得"刷新不丢进度、多 worker 不裂、跟主聊天共享基础设施、IM 渠道免费支持"等长期红利。
> 5. 建议跟"方案 C"并行:先按 [ai-writing-说明.md](./ai-writing-说明.md) 上方案 C 应急止血,本方案作为长期改造按周排期。两者不冲突,方案 C 上线即受益,本方案完工后可下线方案 C 的双进程配置。
-787
View File
@@ -1,787 +0,0 @@
# AI 写作 — 故障排查与解决方案说明
> **本文档目的**:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。
>
> **适用读者**:维护 AI 写作功能的前端/后端开发者、内网部署运维。
>
> **更新日期**:2026-05-20
---
## 一、问题背景
### 起因
内网 Docker 部署后,AI 写作出现两类报错:
| 接口 | 报错 | 频率 |
|---|---|---|
| `PUT /api/ai-writing/sessions/{id}/transcript` | HTTP 500 | 经常 |
| `POST /api/ai-writing/{id}/resume` | HTTP 4xx(用户记忆为 400,实际可能是 404) | 经常,且没有 Docker 重启时也发生 |
### 这两个接口的作用
#### 1. `PUT /sessions/{id}/transcript` — 对话时间线持久化
- **职责**:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
- **调用时机**:前端在写作过程中**反复 PUT**(事件累计、干预完成、草稿生成等节点全量覆盖)
- **存到哪**:MySQL 的 `ai_writing_sessions.transcript` 列(LONGTEXT)
- **用途**:用户**关闭页面后再回来 / 刷新 / 看历史会话**时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
- **定位**:**纯锦上添花** — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整
#### 2. `POST /{id}/resume` — 写作流程的核心控制接口
- **职责**:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
- **调用时机**:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
- **传什么**:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
- **定位**:**流程主干** — 它失败,用户就**真的卡死**在干预点
---
## 二、根因分析
### 根因 1:`transcript` 列容量不足 + 路由层无兜底
- MySQL `TEXT` 列上限 64 KB
- 用户写作过程中累计 `progressEvents`(含每个流式 chunk)+ `completedInterventions` 后,JSON blob 经常超过 64 KB
- `session.commit()` 抛 `DataError: Data too long for column 'transcript'`
- 原路由没有 try/except,异常直接冒泡成 HTTP 500
### 根因 2:内存中的会话状态在多 worker 部署下隔离
后端用了大量模块级 dict 维护会话状态:
```python
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件
_session_model_names: dict[str, str] # 用户选的模型
_session_interventions: dict[str, list] # 累计干预历史
_session_repos: dict[str, repo] # DB 仓库引用
_session_user_ids: dict[str, str] # 用户 id
_session_article_types: dict[str, list] # 文章类型列表
```
**uvicorn / gunicorn 多 worker 是多进程共享 socket**,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:
```
T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
graph 推进任务跑在 worker A
T1: GET /stream → 落 worker B,没有 QueueA 的引用
→ 触发 _recover_session,从 sqlite 重建 QueueB
→ SSE 推 QueueB 但事件都在 QueueA 里
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态
```
**结果**:SSE 时断时续、resume 偶发 404「会话状态已丢失」。
### 根因 3:Graph 用了内存 checkpointer
原代码:
```python
# ai_writing/graph.py
checkpointer = MemorySaver()
```
进程内的 `MemorySaver` 重启即丢,**容器重启或单 worker 进程被回收**后所有进行中会话都拿不到。
### 根因 4:路径配错
`config.yaml` 里 sqlite 路径写成了 Windows 绝对路径 `D:\cursor-work\...`,Linux 容器里根本无法解析。
---
## 三、已落地的修复(已合入 dhk-1 分支)
### 修复 1:`transcript` 列改 LONGTEXT + 路由兜底
| 文件 | 改动 |
|---|---|
| `packages/harness/deerflow/persistence/types.py` | 新增 `PortableLongText` 类型(MySQL→LONGTEXT,SQLite/PG→TEXT) |
| `packages/harness/deerflow/persistence/ai_writing_sessions/model.py` | `transcript` 列改成 `PortableLongText` |
| `packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py` | Alembic 迁移:MySQL 上 `ALTER ... type_=LONGTEXT()` |
| `app/gateway/routers/ai_writing.py` | `save_transcript` 整段 try/except 包裹,**任何异常都返回 `{success: false, reason}`,永远不抛 5xx** |
**结果**:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 `console.warn`,写作流程不阻断。
### 修复 2:Checkpointer 改为持久化 SQLite
| 文件 | 改动 |
|---|---|
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `MemorySaver()` → 懒加载 `AsyncSqliteSaver` |
| 同上 | 新增 `get_ai_writing_graph()` / `get_ai_writing_checkpointer()` / `shutdown_ai_writing_graph()` |
| `offline-backend-20260512/config.yaml` | sqlite 路径改成相对路径 `.deer-flow/data/checkpoints.db` |
**关键容错**:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会**自动降级 InMemorySaver**,写作流程能跑完,但跨重启不可恢复。
### 修复 3:跨 worker / 进程重启会话恢复
| 文件 | 改动 |
|---|---|
| `app/gateway/routers/ai_writing.py` | 新增 `_recover_session(session_id, request)` |
当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 `_event_queues`、重新注入 `await_user` 事件。
**效果**:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,**resume 接口本身能跑通**(但 SSE 事件流仍隔离 — 见下文剩余风险)。
### 修复 4:多层失败兜底,保证写作流程能继续
| 改动 | 作用 |
|---|---|
| `_sse(event, data)` 加 `default=str` + notice 帧降级 | JSON 序列化失败不会让 SSE 断开 |
| 所有 DB 写入(草稿/审核/干预/status)已包 try/except | DB 异常吞掉,graph 继续推 |
| 文章类型加载失败 → 退回 `[]` | intent_parser 仍能跑 |
| `/start` 加 503 兜底 | graph 构建失败时给前端可读错误而不是 500 |
### 修复 5:定期清理 + admin 触发接口
| 文件 | 改动 |
|---|---|
| `app/gateway/ai_writing_cleanup.py` | 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread) |
| `packages/harness/deerflow/persistence/ai_writing_sessions/sql.py` | 新增 `list_older_than()` / `delete_by_ids()` |
| `app/gateway/app.py` | lifespan 启动/停止清理任务 |
| `app/gateway/routers/ai_writing.py` | 新增 `POST /api/ai-writing/admin/cleanup` 手动触发接口(admin only) |
| `config.yaml` | 新增 `ai_writing.cleanup` 段(默认每天凌晨 3 点跑,保留 7 天) |
**关键安全机制**:清理永远跳过 `_event_queues` 里仍在跑的会话(**活跃保护**),即使 admin 手动传 `retention_days: 0` 也不会秒杀正在写作的用户。
### 修复 6:优雅关闭 — In-flight Drain
| 文件 | 改动 |
|---|---|
| `app/gateway/routers/ai_writing.py` | 新增 `_inflight_graph_tasks` + `_spawn_graph_task()` + `wait_for_inflight_graph_tasks(timeout)` |
| `app/gateway/app.py` | lifespan shutdown 顺序:取消清理 → **等 in-flight 写作 task 收尾(30s 超时)** → 关 checkpointer connection |
容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。
### 修复 7:多 worker 配置陷阱检测
| 文件 | 改动 |
|---|---|
| `app/gateway/app.py` | lifespan startup 检测 `WEB_CONCURRENCY > 1` 或 `/proc` 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志 |
下次启动容器看 docker logs 第一屏就能看到强提醒。
---
## 四、当前状态评估
### transcript 500
✅ **彻底修复**,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。
### resume 报错
⚠️ **大幅缓解**:
- 接口本身(在 worker > 1 下)能跑通
- sqlite 共享让 worker B 也能恢复 worker A 创建的 session
❌ **未彻底解决**:
- SSE 事件流仍 per-worker 隔离
- 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
- 但**草稿、审核结果**都落到 MySQL,刷新 `/sessions/{id}` 总能看到最终成品
### 「写作流程能不能跑完」
✅ **能**。最坏情况下:
- 用户感受:进度断断续续,需要刷新几次
- 数据保证:草稿和审核都落 MySQL,**写出来的内容不会丢**
---
## 五、剩余风险
### 风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要
**症状**:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。
**为什么不能用 `--workers 1` 解决**:业务团队反馈其他业务功能需要多 worker。
**解决方案见第六章**。
### 风险 B:容器优雅关闭时 in-flight 写作进度
**当前缓解**:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。
**完美方案**(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。
### 风险 C:节点级别 LLM 抽风
某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。
**当前缓解**:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。
**改进方向**(不在本次修复范围):
- 给节点加 `tenacity.retry` 重试装饰器
- 节点抛错时把已生成的部分塞进 progress_events
### 风险 D:跨 worker 恢复时降级丢失
worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:
| 数据 | 默认值 | 影响 |
|---|---|---|
| `_session_model_names` | `""` | 退回默认模型 |
| `_session_interventions` | `[]` | 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全) |
| `_session_article_types` | `[]` | 没影响(graph 早过了 intent_parser) |
**改进方向**:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。
---
## 六、Worker > 1 限制下的解决方案路径
### 三条路径对比
| 方案 | 是否引入新依赖 | 代码改动 | 运维改动 | 适用场景 |
|---|---|---|---|---|
| **B. Redis Pub/Sub 共享状态** | ⚠️ 需 Redis | ~150 行 | docker-compose 加 redis | 已有 Redis 或愿意上 |
| **C. AI 写作独立单 worker 进程** | 无 | ~30 行 | docker-compose 加一个容器 / 进程 + nginx 转发 | 不想上 Redis |
| **F. 多容器单 worker + nginx sticky** | 无 | ~5 行(nginx config) | 把 1 个多 worker 容器拆成 N 个单 worker 容器 | 已经在做容器编排 |
### 方案 B:Redis Pub/Sub(最彻底)
**改造点**:
```
之前(per-worker memory dict) 之后(Redis 共享)
───────────────────────── ─────────────────────
_event_queues[sid] = asyncio.Queue() → Redis Pub/Sub channel "ai_writing:events:{sid}"
_session_pending_pause → Redis Hash "ai_writing:pause"
_session_model_names → Redis Hash "ai_writing:meta:model"
_session_interventions → Redis Hash + JSON
_session_repos / _session_user_ids → Redis Hash
```
**关键设计**:
1. `/start` 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
2. `/stream` 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
3. `/resume` 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」
**优点**:根治。任何 worker 数、任何部署形态都能跑。
**缺点**:
- 引入 Redis 依赖(运维负担)
- 代码改动较大(约 150 行)
- 需要处理 Redis 断连 / 分布式锁释放等边缘情况
### 方案 C:AI 写作独立服务(次推荐)
**做法**:在同一个容器里多起一个 uvicorn 进程,专门处理 `/api/ai-writing/*`,**显式 `--workers 1`**。
```bash
# 主 gateway(多 worker,处理其他业务)
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4
# AI 写作专用单 worker
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1
```
nginx 配置:
```nginx
location /api/ai-writing/ {
proxy_pass http://localhost:8002;
proxy_buffering off;
proxy_read_timeout 3600s;
}
location / {
proxy_pass http://localhost:8001;
}
```
**优点**:
- 零代码改动
- 不引入新依赖
- AI 写作彻底单 worker,问题根除
**缺点**:
- 容器内多一个进程
- 内存占用 +1 份 Python runtime(约 200 MB)
### 方案 F:多容器单 worker + nginx sticky
**做法**:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 `ip_hash` 做 sticky。
```yaml
# docker-compose.yml
services:
gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }
nginx:
# upstream 配 ip_hash,让同一个客户端粘到同一个容器
```
```nginx
upstream gateway_pool {
ip_hash;
server gateway-1:8001;
server gateway-2:8001;
server gateway-3:8001;
server gateway-4:8001;
}
```
**优点**:零代码改动,标准做法
**缺点**:
- 改动 docker-compose
- 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)
### 推荐顺序
1. **方案 C(AI 写作独立单 worker 进程)** — 改动最小、风险最低、立刻可上
2. **方案 F(多容器 sticky)** — 如果你们已经在做多容器编排
3. **方案 B(Redis)** — 长期方案,等真正需要水平扩容时再上
---
## 六-A、方案 C 在 Docker 容器内的详细实施指南
> **目标**:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
>
> **基础设施**:本项目镜像基于 `python:3.12-slim-bookworm`,用 `uv` 管理依赖,Dockerfile 位于 `offline-backend-20260512/backend/Dockerfile`,默认入口是
>
> ```
> CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
> ```
### 总览:单容器双 uvicorn 进程
```
┌─────────────── nginx (容器外或前置) ───────────────┐
│ │
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
└────────────────────────────────────────────────────┘
▲
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
```
两进程都用 **同一个 `app.gateway.app:app`**,区别只在:
- `8002` 单 worker,专门接 `/api/ai-writing/*`
- `8001` 多 worker,接其他所有路由
- 容器内用 `supervisord` 同时托管这两个 uvicorn
### 步骤 1:在镜像里加 supervisord
编辑 `offline-backend-20260512/backend/Dockerfile`,给 **runtime stage**(第 67 行 `FROM python:3.12-slim-bookworm` 之后)补一段:
```dockerfile
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
RUN apt-get update \
&& apt-get install -y --no-install-recommends supervisor \
&& rm -rf /var/lib/apt/lists/*
```
如果内网无法访问 debian 源,可用现有的 `APT_MIRROR` build-arg:
```bash
docker build \
--build-arg APT_MIRROR=mirrors.aliyun.com \
-f offline-backend-20260512/backend/Dockerfile \
-t deerflow-gateway:dual .
```
### 步骤 2:写 supervisord 配置
新建 `offline-backend-20260512/backend/deploy/supervisord.conf`:
```ini
[supervisord]
nodaemon=true ; 容器主进程,必须前台
user=root
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
[program:gateway-main]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8001
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
[program:gateway-aiwriting]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8002
--workers 1
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
**几个关键点**:
- `nodaemon=true` 让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程
- `stopasgroup=true` + `killasgroup=true` 确保 uvicorn 多 worker 模式下所有子进程一起退
- `stopwaitsecs=35` 必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL
- `%(ENV_GATEWAY_MAIN_WORKERS)s` 让主进程 worker 数走环境变量,下面会传
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage `COPY --from=builder /app/backend ./backend` 之后加):
```dockerfile
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
```
### 步骤 3:把 Dockerfile 入口换成 supervisord
把 Dockerfile **runtime stage 最后一行** 改成:
```dockerfile
# 之前
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
# 之后
EXPOSE 8001 8002 2024
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
```
> **`dev` stage 不用改**:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
### 步骤 4:暴露端口 / 挂卷 / 环境变量
启动容器时:
```bash
docker run -d --name deerflow-gateway \
-p 8001:8001 \
-p 8002:8002 \
-e GATEWAY_MAIN_WORKERS=4 \
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
-v /your/host/path/.deer-flow:/app/.deer-flow \
-v /your/host/path/config.yaml:/app/config.yaml:ro \
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
deerflow-gateway:dual
```
**两个进程必须共享 `.deer-flow` 卷** —— 因为 `checkpoints.db` 在里面(`offline-backend-20260512/config.yaml` 里配的是相对路径 `.deer-flow/data/checkpoints.db`)。两个进程跑在同一个容器内,天然共享文件系统,**这一步不用做任何额外配置**,只要确认数据卷挂出去就行。
> ⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
### 步骤 5:nginx 路由
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
```nginx
upstream gateway_main { server gateway-host:8001; }
upstream gateway_aiwriting { server gateway-host:8002; }
server {
listen 80;
server_name deerflow.example.com;
# SSE 长连接必须关 buffering
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
# AI 写作 → 单 worker 后端
location /api/ai-writing/ {
proxy_pass http://gateway_aiwriting;
proxy_read_timeout 3600s; # graph 跑得久,给足时间
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 其它一切 API → 多 worker 后端
location /api/ {
proxy_pass http://gateway_main;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端静态资源不变
location / {
proxy_pass http://frontend:5174;
}
}
```
**`proxy_buffering off` 是必须的** —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 `frontend-web` 里 AI 写作相关的 `fetch`/`EventSource` 改成 `:8002`,但更推荐 **始终走 nginx**,对前端透明。
### 步骤 6:如果你用 docker-compose
完整 compose 示例:
```yaml
services:
gateway:
image: deerflow-gateway:dual
container_name: deerflow-gateway
restart: unless-stopped
environment:
GATEWAY_MAIN_WORKERS: "4"
DEER_FLOW_CONFIG_PATH: /app/config.yaml
ports:
- "8001:8001"
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
volumes:
- ./data/deer-flow:/app/.deer-flow
- ./config.yaml:/app/config.yaml:ro
- ./extensions_config.json:/app/extensions_config.json:ro
healthcheck:
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
nginx:
image: nginx:1.27-alpine
depends_on: [gateway]
ports: ["80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
```
`stop_grace_period: 45s` 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
### 步骤 7:验证(按这个清单逐项过)
```bash
# 1) 两个进程都起来了
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
# 期望看到:
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
# 3) 8001 不再警告多 worker
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
# 4) 8002 进程能看到 ai_writing 持久化日志
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
# 5) /api/ai-writing/* 走 8002,其他走 8001
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
```
### 步骤 8:监控两个进程的健康
supervisord 自带 `unix_http_server` + `supervisorctl`,但对容器化场景更直接的是看 docker logs:
```bash
# 实时看两个进程的日志
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
# 进程级状态
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
# 期望:
# gateway-main RUNNING pid 7, uptime 0:05:12
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
```
### 资源开销(实测预估)
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|---|---|---|---|
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
**结论**:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
### 常见坑位
1. **`stop_grace_period` 没改大 → 重启时 in-flight 写作被秒杀**
表现:每次 `docker compose restart` 都有用户报告「写到一半没了」。修复见步骤 6。
2. **8002 没暴露 / nginx 没改 → 所有请求还是落 8001**
表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
3. **两路用了不同的 `config.yaml`**
表现:8001 跟 8002 行为不一致。务必让两个进程**只读同一个文件**(在 compose 里只挂一次卷)。
4. **`.deer-flow` 卷没挂 → 8002 重启后会话全丢**
表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 `-v` 必须有。
5. **AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸**
表现:`gateway-aiwriting` 状态在 RUNNING/EXITED/BACKOFF 间反复。
修复:先 `supervisorctl tail gateway-aiwriting stderr` 看真正原因(多半是 sqlite 权限或配置文件路径),再修,**别盲目调 `autorestart=false`**。
6. **想做 K8s 部署但还想用方案 C**
不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
### 回滚方案
如果上线后发现问题,回滚只需要 1 步:
```bash
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
```
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),**不会丢任何用户数据**,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
---
## 七、运维操作清单
### 部署前验证
```bash
# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
docker exec <gateway> ls -la /app/.deer-flow/data/
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)
# 2) 确认 alembic 迁移跑完了
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
# 应该看到包含 20260519_02 的 head
# 3) 确认 transcript 列已扩容
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
"SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
# Type 列应该是 longtext
# 4) 查 worker 数
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告
```
### 启动时关注的日志
```bash
docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'
```
期望看到:
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理任务起来了
警告:
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 配置警告
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 初始化失败,需立刻排查
### 用户报告「会话丢失」时的排查
```bash
# 1) 查 sqlite 里这个 session 的快照
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
\"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""
# 2) 查 MySQL 里这条会话
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
"SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"
# 3) 看 gateway 日志里这个 session 的活动轨迹
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50
# 4) 看是否有 recover_session 调用
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20
# 5) 看是否有节点异常
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20
```
### 手动触发清理(admin only)
```bash
# 用 admin 账号登录后,取 cookie,调清理接口
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
-H 'Content-Type: application/json' \
-H 'Cookie: <admin session cookie>' \
-d '{"retention_days": 7}'
```
返回:
```json
{
"deleted_db": 12,
"deleted_checkpoints": 12,
"skipped_active": 1,
"elapsed_seconds": 0.234,
"params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
}
```
`skipped_active` > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。
---
## 八、相关文件索引
### 后端
- `offline-backend-20260512/backend/app/gateway/routers/ai_writing.py` — 路由 + 内存状态字典 + drain
- `offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py` — 定期清理
- `offline-backend-20260512/backend/app/gateway/app.py` — lifespan / 多 worker 检测
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py` — Graph 定义 + 持久化 checkpointer
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/` — MySQL repo
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py` — `PortableLongText`
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py` — LONGTEXT 迁移
- `offline-backend-20260512/config.yaml` — `checkpointer:` + `ai_writing.cleanup:` 配置
### 前端
- `frontend-web/src/open-canvas/pages/AIWritingPage.tsx` — 页面顶部有快速排查注释
- `frontend-web/src/open-canvas/api/ai-writing-sessions.ts` — transcript 保存(已做 fire-and-forget 容错)
- `frontend-web/src/open-canvas/api/ai-writing.ts` — resume / stream
### 测试
- `tests/test_ai_writing_cleanup.py` — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
- `tests/test_ai_writing_session_recovery.py` — 跨 worker 会话恢复(8 个测试)
- `tests/test_ai_writing_session_repo.py` — Repo(7 个测试)
- `tests/test_ai_writing_transcript_endpoint.py` — transcript 兜底(5 个测试)
- `tests/test_portable_long_text.py` — LONGTEXT 列类型(5 个测试)
总测试:**51 个,全过**。
---
## 九、变更历史
| 日期 | Commit | 内容 |
|---|---|---|
| 2026-05-19 | c3d0c82 | 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复) |
| 2026-05-20 | (本次) | checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测 |
---
## 十、决策摘要(给未来的你/同事)
> **如果你现在第一次接手这个问题**,按这个清单往下走:
>
> 1. ✅ **transcript 500 不用管** — 已彻底修复
> 2. ⚠️ **resume 报错的根因是多 worker** — sqlite 持久化已减轻,但 SSE 事件流仍隔离
> 3. ⚠️ **写作能跑完,但用户体验断断续续** — 草稿数据安全,刷新就能看到
> 4. 🎯 **要彻底解决,从方案 C / F / B 选一个**(建议从 C 开始)
> 5. 🚨 **务必确认 `.deer-flow` 数据卷挂出来了** — 否则 sqlite 持久化白做
> 6. 📊 **关注的日志关键字**:`ai_writing:`、`checkpointer`、`AsyncSqliteSaver`、`WEB_CONCURRENCY`
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
File diff suppressed because it is too large. Load diff
+265
View File
@@ -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 的实际执行 |
@@ -0,0 +1,359 @@
# 多智能体圆桌研讨 · 后端开发文档
本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。
---
## 1. 功能定位
把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。
对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。
---
## 2. 涉及的后端文件
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── app.py # 注册 multi_agent.router
│ └── routers/
│ └── multi_agent.py # 本功能唯一新增的 router
└── .deer-flow/agents/
├── roundtable-intelligence/ # 6 个内置子智能体目录
│ ├── config.yaml # ├── id / name / description
│ └── SOUL.md # └── 角色 prompt
├── roundtable-environment/
├── roundtable-solution-design/
├── roundtable-risk-review/
├── roundtable-execution-plan/
└── roundtable-summary/
```
**零代码改动构造内置 agent**:6 个目录通过既有的 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。
---
## 3. 接口总览
路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer <JWT>`。
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | `/api/multi-agent/init` | 一次性创建协调智能体 + 6 个子席位线程,返回 `thread_ids` 映射 |
| POST | `/api/multi-agent/run/stream` | SSE 流式驱动一次 leader 或 special 调用 |
字段细节见 `multi-agent-api.md`,这里不重复。
---
## 4. 内部实现详解
### 4.1 `/init` 内部流程
```
[阶段1] 并行准备 6 个子智能体席位 (asyncio.gather)
├─ GET /api/agents/{id} × 6 → 取 name + description
└─ POST /api/threads × 6 → 建新 thread
[阶段2] 创建协调智能体
├─ POST /api/threads → 协调智能体的 thread
└─ POST /api/agents (with model + skills=[agent_orchestration] + soul)
└─ soul 里包含可调度的 6 个 agent_name + description 列表
```
**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`):
```python
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,...\n"
"#可调度智能体:\n"
f"{descriptions}" # ← 6 个子席位的 name+description
)
```
由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。
### 4.2 `/run/stream` 内部流程
接收 `agent_type` 后走两条分支:
#### `agent_type = "leader"` (协调智能体)
```
1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather)
2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream
- excluded_tools: ["web_search"]
- context.model_name = 用户传的 model
3. 流式透传 LangGraph 事件给前端
4. 流结束后从最后一帧 values 取最末一条 message
5. 解析 message.tool_calls (agent_orchestration 技能调用):
- 用 _extract_name_task() 提取 [agent_name, task_text]
- 对每个 [agent_name, task]:
broadcast「总控给 X 派活,内容为: ...」到所有非派活双方
6. 末尾追加状态帧:
{
"status": [["agent_name", "task"], ...], // 空数组 = 共识达成
"content": "<leader 最终文本>",
"agent_name": "<coordinator>"
}
```
#### `agent_type = "special"` (子智能体)
```
1. 拼 RunCreateRequest,转发到 POST /api/threads/{sub_agent}/runs/stream
- excluded_tools: ["web_search","ask_clarification","present_files","view_image"]
2. 流式透传 LangGraph 事件
3. 流结束后从最后一帧 values 取交付内容 (content)
4. broadcast「子智能体 X 完成 Y 工作,交付内容为: ...」到所有非自己线程
5. 末尾追加状态帧:
{
"status": "子智能体 X 完成 Y 工作",
"content": "<交付内容>",
"agent_name": "<sub agent>"
}
```
### 4.3 _broadcast 机制
```python
async def _broadcast(client, base, headers, message, agent_threads, skip):
targets = [tid for name, tid in agent_threads.items() if name not in skip]
await asyncio.gather(*(_append_thread_message(...) for tid in targets))
```
`_append_thread_message` 对每个目标 thread:
```
GET /api/threads/{id}/state # 拿当前 messages 列表
state.values.messages.append({"type":"human","content":...})
POST /api/threads/{id}/state # 写回完整 state
```
这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。
**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。
---
## 5. 6 个内置 agent SOUL 设计要点
每个 `roundtable-*` 的 SOUL.md 遵循统一结构:
```
# {席位名}
## 角色定位
你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度,
你只在被派到任务时发言,不需要主动推动议程。
## 核心职责
- ...具体职责...
## 输出风格
- 每次发言以 {emoji} 开头
- 第一句直接给结论
- 不超过 4 句
## 边界
- 只产出 {本席位输出}
- 不替其他席位做 {其他席位输出}
- 任务结束后停下,等待总控派下一个任务
## 示例输出
> ...
```
| 席位 | agent_id | emoji | 核心职责 |
|---|---|---|---|
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险预警 |
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 量化承载力 |
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源排布 |
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 + 盖印 |
**为什么不让子智能体自己调度**:所有 SOUL 都明文写了"只在被点到时发言,由总控协调智能体派活"。子智能体不装 `agent_orchestration` 技能。只有协调智能体装。这是单调度源,避免多智能体相互抢调度权造成的死循环。
---
## 6. 关键设计决策
### 6.1 为什么用 HTTP loopback 而不是内部函数调用
`multi_agent.py` 调用 `/api/threads`、`/api/agents`、`/api/threads/.../runs/stream` 都是用 `httpx` 走 `http://localhost:8001/...` 自身循环回来,而不是直接 `from app.gateway.services import ...`。
**好处**:
- 自动复用 Gateway 的鉴权 + CSRF + permission 中间件,不用再手写
- 复用既有路由的请求验证(Pydantic)和错误响应
- `Authorization` 头透传,被调端自然知道是哪个用户在调
**代价**: 每次调用都有一次额外的 TCP+JWT 解析开销(~10-50ms)。但因为 init 已经做了并行优化(A)、broadcast 已经省掉(C),目前总体耗时是可接受的。
如未来需要进一步优化,可改为直接调用 `app/gateway/services.py` 里的内部函数,绕过 HTTP 一层。
### 6.2 为什么协调智能体每次新建
前端在每次进入 step 2 时生成 `roundtable-coordinator-{Date.now().toString(36)}`,作为 `main_agent_name`。
**好处**:
- 不同会话/不同用户/同一用户不同任务之间完全隔离
- 协调智能体的对话历史就是本次研讨的全部上下文,不会被上次任务污染
- SOUL 不会被"上次的可调度列表"覆盖(虽然现在 6 个席位是固定的)
**代价**: 每次都要在 `agents` 表插一行,`.deer-flow/agents/<id>/` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 description 以 "你是总控控制协调智能体" 开头的 agent 自动 GC)。
### 6.3 子智能体席位为什么固定 6 个
- 圆桌的 6 个角色定位是产品级强约束(情报/环境/方案/风险/执行/总结)
- 写在后端 SOUL.md 一处维护,跟其他内置 agent 同等地位
- 前端只需要把 6 个 hyphen-case 名作为 `agent_names` 传过来即可
如果后续要支持"用户自选席位",在前端加 mapping 面板让用户从 `GET /api/agents` 里挑,不动后端。
### 6.4 为什么 SSE 末尾还要追加一个 `{status:...}` 帧
LangGraph 原生 SSE 里没有"调度结果"这种语义,前端只能从最后一帧 values 里去翻 tool_calls。如果由前端解析,前后端协议会绑死 LangGraph 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**:
```json
{
"status": [["agent_name", "task"], ...] | "<完成提示>",
"content": "<最终 AI 文本>",
"agent_name": "<本轮 agent>"
}
```
前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。
---
## 7. 性能特征
### 7.1 已应用的优化
| ID | 优化 | 收益 |
|---|---|---|
| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s |
| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s |
应用前 `/init` 耗时 **3-8s**,应用后 **0.5-1.2s**。
### 7.2 仍然存在的开销点
| 调用 | 单次耗时 | 主因 |
|---|---|---|
| `GET /api/agents/{id}` | 50-300ms | 每次触发 `_sync_legacy_agents()` 扫整个 `.deer-flow/agents/` 目录(目前 30+ 项) |
| `POST /api/threads` | 100-200ms | LangGraph thread 表插入 + 默认 state 初始化 |
| `POST /api/agents` | 100-300ms | 文件系统写入 SOUL.md + config.yaml + agents 表 |
| `GET/POST /state` | 50-150ms × 2 | checkpointer 读写 |
| `POST /runs/stream` 启动 | 50-100ms | RunManager + StreamBridge 初始化 |
| LLM 推理 | **数百毫秒~数十秒** | 取决于 model + 上下文长度 |
**LLM 推理才是绝对大头**,基础设施开销在十几个百分点以内。
### 7.3 未来可探索
- **B**(已识别未做): broadcast 跳过 GET-state,直接 POST 新建态。但 POST 需要构造完整的 state shape,改造成本不小。
- **D**(已识别未做): loopback HTTP → 内部函数调用。代码改动大,但能省掉 ~20-30 次 HTTP 解析开销。
---
## 8. 依赖与扩展点
### 8.1 `agent_orchestration` 技能
协调智能体强依赖此技能,SOUL 里硬编码:
```
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
```
技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取:
```python
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
```
**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。
### 8.2 模型选择
前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。
- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置)
- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`)
前端允许"逐席位覆盖模型",由前端在 special 调用时按 `agentConfigs[seat].model` 传入。后端不感知"席位"概念,只看到 `model` 字段。
### 8.3 自定义新席位
如果要加第 7 个席位(比如"成本测算"):
1. `.deer-flow/agents/roundtable-cost/` 加 `config.yaml` + `SOUL.md`
2. 前端 `RoundtablePlanningPage.tsx` 的 `ROUNDTABLE_ROLE_TEMPLATES` + `AGENT_NAME_TO_ROLE` 加映射
3. 圆桌可视化(圆圈环形图)加一个气泡 + 头像
**后端代码完全不需要改**。
---
## 9. 故障排查
| 现象 | 可能原因 | 排查 |
|---|---|---|
| `/init` 500 "create coordinator agent failed" | `agent_orchestration` 技能不存在 / config.yaml 写入失败 | 看后端日志 `multi_agent` logger;检查 `skills/` 目录 |
| `/run/stream` 502 "upstream returned HTTP 4xx" | JWT 过期 / 转发到上游时鉴权挂了 | 前端检查 Authorization 头是否带上 |
| leader 永远 `status: []` 立即结束 | 协调智能体没产生 `tool_calls`,直接给文本回答 | 协调智能体 SOUL 不够强、或模型不支持工具调用,换 model |
| special 内容为空 | 子智能体被 `excluded_tools` 限制到无可用工具 / 模型无文本输出 | 看上游 `runs/stream` 的 values 帧 messages.last.content |
| 进入 step 2 卡住转圈 | `/init` 超时或失败 | F12 Network 看 `/api/multi-agent/init` 状态码与响应体 |
### 服务端日志关键字
```bash
# multi_agent 路由的日志
grep "multi_agent" .runtime/logs/*.log
# 派活解析失败
grep "dispatch failed" .runtime/logs/*.log
# 广播失败(通常无害)
grep "update state .* failed" .runtime/logs/*.log
```
---
## 10. 测试入口
后端目前没有针对本功能的单测。建议补:
```
backend/tests/test_multi_agent.py
- test_init_creates_threads_for_all_subagents
- test_init_creates_coordinator_with_orchestration_skill
- test_init_returns_400_when_agent_names_empty
- test_stream_leader_emits_status_frame_with_content
- test_stream_special_emits_status_frame_with_content
- test_extract_name_task_parses_orchestration_skill_output
- test_broadcast_skips_listed_agents
```
可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。
---
## 11. 路径速查
| 资源 | 路径 |
|---|---|
| 路由实现 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py`(`include_router(multi_agent.router)`) |
| 6 个内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` |
| `agent_orchestration` 技能定义 | `skills/public/agent_orchestration/`(若存在) |
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端 API 速查 | `frontend-web/docs/multi-agent-api.md` |
+492
View File
@@ -0,0 +1,492 @@
# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
>
> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
---
## 1. 目标与边界
### ✅ 要做的事
- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
### ❌ 不做的事
- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
- 不做"评分/置信度"等假指标
- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
---
## 2. 数据流概览
```
[Step 1 入口]
↓
前端:POST /api/intent/init
← {agent_id: "roundtable-intent", thread_id: "..."}
用户输入第一句话:"帮我规划一个生产系统安全改造方案"
↓
前端:POST /api/intent/stream (SSE)
← (流式 AI 文本) "好的,预算大致是多少?"
← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续
用户回:"500 万左右"
↓
前端再次 POST /api/intent/stream
← (流式) "明白,还有什么时间约束吗?"
← {status: "asking", ...}
...对话进行几轮 ...
用户:可以了
↓
前端再 POST /api/intent/stream
← (流式) "好,我理解的任务是: <汇总>"
← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2
[Step 1 完成 → Step 2 init]
前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
```
---
## 3. 接口设计
跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
### 3.1 `POST /api/intent/init`
**请求体**
```json
{
"model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL
}
```
**响应 200**
```json
{
"status": "success",
"agent_id": "roundtable-intent",
"thread_id": "<langgraph_thread_id>"
}
```
**行为**:
1. 鉴权(透传 `Authorization`)
2. 调上游 `POST /api/threads` 建 1 个 thread
3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
**与 step 2 `/init` 的区别**:
- 没有"协调智能体"概念,只有 1 个 agent
- 没有 6 个 sub 席位,只有 1 个 thread
- 不需要传 `agent_names` 数组
- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
### 3.2 `POST /api/intent/stream`
**请求体**
```json
{
"thread_id": "<from /init>",
"message": "用户这一轮要说的话",
"model": "deepseek-chat" // 可选
}
```
**响应**:`text/event-stream`,SSE 流式。
**SSE 帧组成**:
1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
2. 流末追加一个**状态帧**(跟 step 2 同样模式):
```json
{
"status": "asking" | "done" | "error",
"content": "<本轮 AI 文本>",
"summary": {
"objective": "...", // 仅 status=done 时有: 任务目标
"constraints": ["...", ...], // 仅 status=done 时有: 约束清单
"assumptions": ["...", ...] // 仅 status=done 时有: 关键假设
}
}
```
**`status` 取值**:
- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
- `"error"` —— 上游异常,`content` 里是错误信息
**判定 done/asking 的简单策略**(后端实现):
- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
- 否则一律是 `asking`
> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
>
> ```
> [INTENT_READY]
> ```json
> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
> ```
> ```
>
> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
---
## 4. 内置智能体配置
**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
### 4.1 `config.yaml`
```yaml
id: roundtable-intent
name: 任务理解智能体
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
```
### 4.2 `SOUL.md`
```markdown
# 任务理解智能体
## 角色定位
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
## 核心职责
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
- 不要追问已经清楚的事
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
- 信息足够后,立即停止追问,产出结构化汇总
## 信息充分性判定
当满足以下条件时,认为信息已充分:
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
3. 没有明显的歧义点
## 信息汇总产出
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户最终要做的事",
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
"assumptions": ["假设 1", "假设 2", ...]
}
```
```
输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
## 输出风格
- 一次回复不超过 3 句话
- 提问要具体,不要"还有什么补充吗"这种空问
- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
## 边界
- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
- 不写文件、不做计算、不联网
- 用户消息歧义大时,优先调 ask_clarification 工具引导
```
### 4.3 注册方式
跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
---
## 5. 后端实现要点
### 5.1 新建 router 文件
**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
```python
"""任务理解智能体接口 - Step 1 的极简包装。
跟 multi_agent.py 同构但简化:
- 没有 broadcast(单 thread,无需广播)
- 没有派活逻辑
- 没有 leader/special 分支,只有一种调用方式
- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
"""
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
import httpx, json, re, asyncio, logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"])
# 复用 multi_agent.py 里已有的工具函数
from app.gateway.routers.multi_agent import (
_loopback_base, _auth_headers,
_create_thread, _stream_upstream, _parse_last_messages,
_flatten_content, _run_payload,
)
INTENT_AGENT_ID = "roundtable-intent"
_INTENT_READY_PATTERN = re.compile(
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
re.DOTALL,
)
@router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
base = _loopback_base(request)
headers = _auth_headers(request)
async with httpx.AsyncClient(timeout=None) as client:
thread_id = await _create_thread(client, base, headers)
return {
"status": "success",
"agent_id": INTENT_AGENT_ID,
"thread_id": thread_id,
}
@router.post("/stream")
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
thread_id = str(payload.get("thread_id") or "").strip()
message = str(payload.get("message") or "").strip()
model_name = str(payload.get("model") or "deepseek-chat")
if not thread_id or not message:
raise HTTPException(status_code=400, detail="thread_id and message required")
base = _loopback_base(request)
headers = _auth_headers(request)
async def gen():
async with httpx.AsyncClient(timeout=None) as client:
try:
body = _run_payload(
agent_name=INTENT_AGENT_ID,
model_name=model_name,
thread_id=thread_id,
new_message=message,
excluded_tools=["web_search", "present_files",
"view_image", "agent_orchestration"],
skill_stop_names=[],
)
captured = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield (line if line.endswith("\n") else line + "\n")
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
last_ai = {}
for m in reversed(messages):
if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = m
break
content = _flatten_content(last_ai.get("content", ""))
# 检测 [INTENT_READY] 标记
match = _INTENT_READY_PATTERN.search(content)
if match:
try:
summary = json.loads(match.group(1))
except json.JSONDecodeError:
summary = None
frame = {"status": "done", "content": content, "summary": summary}
else:
frame = {"status": "asking", "content": content}
yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
except HTTPException as exc:
yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.exception("intent stream failure")
yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
```
### 5.2 在 `app/gateway/app.py` 注册
```python
from app.gateway.routers import intent
app.include_router(intent.router)
```
---
## 6. 前端实现要点
### 6.1 新增 API helper
**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
```ts
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface IntentInitResponse {
status: "success";
agent_id: string;
thread_id: string;
}
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
return res.json();
}
export interface IntentSummary {
objective: string;
constraints: string[];
assumptions?: string[];
}
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
signal?: AbortSignal;
onTextDelta?: (text: string, id: string) => void;
}
export interface StreamIntentResult {
status: "asking" | "done" | "error";
content: string;
summary: IntentSummary | null;
}
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
// 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
// {status, content, summary?} 格式。
// ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
}
```
### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
替换 `handleSendMessage`:
```ts
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
useEffect(() => {
if (currentStep === 1 && !intentThreadId) {
initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
}
}, [currentStep]);
const handleSendMessage = async () => {
if (!inputText.trim() || !intentThreadId) return;
const userText = inputText.trim();
// (push user msg to interactiveMessages 略)
setInputText("");
// 占位 agent 气泡
const agentMsgId = pushAgentPlaceholder("任务理解智能体");
const res = await streamIntent({
threadId: intentThreadId,
message: userText,
model: DEFAULT_MODEL,
onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
});
finalizeAgentMsg(agentMsgId, res.content);
if (res.status === "done" && res.summary) {
setIntentReady(res.summary);
triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
}
};
```
### 6.3 "进入 step 2"按钮的联动
- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
- 改造:只有 `intentReady !== null` 时该按钮才 enabled
- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
```ts
const buildInitialIntent = useCallback(() => {
if (intentReady) {
return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
}
// fallback: 旧逻辑保留兜底
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨";
const constraints = `预算硬约束:${budgetLimit} 万元`;
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
}, [interactiveMessages, budgetLimit, intentReady]);
```
---
## 7. 实施清单
| 优先级 | 项 | 文件 |
|---|---|---|
| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
---
## 8. 与 step 2 的衔接
```
step 1 输出: intentReady.objective + intentReady.constraints
↓
step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
↓
step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
除非模型遇到完全意料之外的歧义
↓
研讨更聚焦,减少 step2 阶段的 clarification 轮次
```
副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
---
## 9. 路径速查
| 资源 | 位置 |
|---|---|
| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
---
## 10. 验收清单
- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
+264
View File
@@ -0,0 +1,264 @@
# 多智能体圆桌 · Step 3 · 接口与对接说明
> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
---
## 1. 改造方向(在做什么,不做什么)
### ✅ 保留并对接真实数据
| 区块 | 现状 | 改造后 |
|---|---|---|
| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
### ❌ 不做(直接删 / 不接口)
| 区块 | 原因 |
|---|---|
| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
---
## 2. 数据来源全景
```
┌───────────────────────────────────────────────────────────┐
│ step2 全程结束时,前端手里已经有的本地状态 │
├───────────────────────────────────────────────────────────┤
│ coordinatorName: "roundtable-coordinator-xxx" │
│ threadIds: { [agent_name]: thread_id, ... } │ ← /init 返回
│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终 │
│ 交付的 content...] │
│ leaderRes.content (最后一次 leader 的 final text) │
└───────────────────────────────────────────────────────────┘
后端权威数据(需要时从后端拉):
- 每个 sub 线程的完整 state → GET /api/threads/{tid}/state
- 单个 outputs 文件内容/下载 → GET /api/threads/{tid}/artifacts/{path}
- outputs 目录文件清单 ❗暂无 → 新增接口(见 §3)
```
**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
---
## 3. 接口需求
### 3.1 新增(**必需**):列出某 thread 的产出文件
```
GET /api/multi-agent/sessions/artifacts?thread_id=<sub_thread_id>
```
或者更通用、放进 artifacts 路由下:
```
GET /api/threads/{thread_id}/artifacts
```
**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
**响应**:
```json
{
"thread_id": "<sub_thread_id>",
"files": [
{
"path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
"name": "方案设计报告_生产系统安全改造技术方案.md",
"size_bytes": 12480,
"mime_type": "text/markdown",
"modified_at": "2026-05-23T14:32:30.123Z"
},
...
]
}
```
**实现建议**:
- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
```
POST /api/multi-agent/sessions/summary
Body: {
"main_agent_name": "roundtable-coordinator-xxx",
"thread_ids": { "<agent_name>": "<thread_id>", ... }
}
```
**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
**响应**:
```json
{
"main_agent_name": "roundtable-coordinator-xxx",
"coordinator": {
"thread_id": "...",
"final_text": "...", // 最后一条 AIMessage content
"dispatch_rounds": 3 // leader 派活的轮数(可选展示)
},
"seats": [
{
"agent_name": "roundtable-intelligence",
"thread_id": "...",
"display_name": "情报收集",
"final_text": "...", // 最后一条 AIMessage content
"files": [ // 该席位写到 outputs/ 的文件
{ "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
]
},
...
]
}
```
**取舍**:
- 优点:1 次请求 vs 13 次;体验更顺
- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
### 3.3 复用(**无需改动**)
| Endpoint | 用途 |
|---|---|
| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
---
## 4. 前后端对接流程
### 4.1 进入 step3 时
```ts
// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
// 1. 总控结论文本 — 已在内存,直接用
const consensusText = lastLeaderRes.content;
// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
const seatDeliveries = step2RoundtableDialogues
.filter(d => d.confidence === "子智能体交付")
.map(d => ({ sender: d.sender, content: d.content }));
// 3. 文件清单 — 调新接口
const filesPerSeat = await Promise.all(
Object.entries(threadIds)
.filter(([name]) => name !== coordinatorName)
.map(async ([name, tid]) => ({
agentName: name,
files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
})),
);
```
### 4.2 用户点击文件时
```ts
// 在线预览(mime 是 markdown/text/pdf 自动 inline)
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
// 强制下载
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
```
### 4.3 "重新起草新规划" 按钮
```ts
// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
setCurrentStep(1);
setInteractiveMessages(...); // 已有逻辑
setStep2RoundtableDialogues([]);
// 不需要调任何接口
```
---
## 5. 改造前端的具体文件清单
| 文件 | 改动 |
|---|---|
| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
---
## 6. 改造后端的具体文件清单
| 文件 | 改动 |
|---|---|
| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
---
## 7. 实施优先级
| 优先级 | 项 | 备注 |
|---|---|---|
| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
---
## 8. 显式不做的事 ❌
1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
---
## 9. 验收清单
step3 改造完成后应该能:
- [ ] 看到当前 session 的总控最终结论,markdown 渲染
- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
- [ ] 点击下载按钮能下载(带正确文件名)
- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
---
## 10. 路径速查
| 资源 | 位置 |
|---|---|
| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
+310
View File
@@ -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**
+12 -1
View File
@@ -1,7 +1,18 @@
<!doctype html> <!doctype html>
<html lang="en-US"> <html lang="en-US" suppressHydrationWarning>
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CM 助手</title> <title>CM 助手</title>
</head> </head>
+3 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -60,6 +60,7 @@
"docx": "^9.5.1", "docx": "^9.5.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
@@ -92,6 +93,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0", "@types/gsap": "^3.0.0",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
+3 -1
View File
@@ -25,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
return ( return (
<NextThemesProvider <NextThemesProvider
attribute="class" attribute="class"
enableSystem storageKey="strategy-theme"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
@@ -72,6 +72,12 @@ const menuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { 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-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout }, { id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -99,6 +105,12 @@ const menuItems: MenuItem[] = [
icon: List, icon: List,
path: "/page/strategy/light-app/notes", 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-square", label: "应用广场", icon: Layout },
@@ -19,21 +19,307 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
useAdminAddMemoryEntry, useAdminAddMemoryEntry,
useAdminAddV1Fact,
useAdminDeleteMemoryEntry, useAdminDeleteMemoryEntry,
useAdminDeleteV1Fact,
useAdminReplaceMemoryEntry, useAdminReplaceMemoryEntry,
useAdminUpdateV1Fact,
useAdminUpdateV1Section,
useUserMemory, useUserMemory,
} from "@/core/admin/hooks"; } from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/hooks"; import { useAllUsers } from "@/core/auth/hooks";
function getEntries(data: Record<string, unknown>): string[] { export function getEntries(data: Record<string, unknown>): string[] {
const raw = (data as { entries?: unknown }).entries; const raw = (data as { entries?: unknown }).entries;
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0); 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 ────────────────────────────────── // ── Memory entry list with inline editing ──────────────────────────────────
function MemoryEntryList({ export function MemoryEntryList({
entries, entries,
userId, userId,
agent, agent,
@@ -204,7 +490,7 @@ function MemoryEntryList({
// ── Right panel: memory tabs for a selected user ─────────────────────────── // ── Right panel: memory tabs for a selected user ───────────────────────────
function UserMemoryPanel({ userId }: { userId: string }) { export function UserMemoryPanel({ userId }: { userId: string }) {
const { data: memory, isLoading } = useUserMemory(userId); const { data: memory, isLoading } = useUserMemory(userId);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null); const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
@@ -217,11 +503,76 @@ function UserMemoryPanel({ userId }: { userId: string }) {
); );
} }
const isV1 = memory?.version === "v1";
const globalBucket = memory?.buckets.find((b) => b.name === "global"); const globalBucket = memory?.buckets.find((b) => b.name === "global");
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global"); const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
const userEntries = globalBucket ? getEntries(globalBucket.data) : []; // ── 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 currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
const agentBucket = agentBuckets.find((b) => b.name === currentAgent); const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
const agentEntries = agentBucket ? getEntries(agentBucket.data) : []; const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
@@ -284,16 +635,10 @@ function UserMemoryPanel({ userId }: { userId: string }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">— MEMORY.md</span>
— MEMORY.md
</span>
</div> </div>
{currentAgent && ( {currentAgent && (
<MemoryEntryList <MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
entries={agentEntries}
userId={userId}
agent={currentAgent}
/>
)} )}
</> </>
)} )}
@@ -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>
);
}
@@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<Card className="group flex flex-col transition-shadow hover:shadow-md"> <Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2"> <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"> <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" /> <BotIcon className="h-5 w-5" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<CardTitle className="truncate text-base"> <CardTitle className="truncate text-base" title={agent.name}>
{agent.name} {agent.name}
</CardTitle> </CardTitle>
{agent.model && ( {agent.model && (
@@ -2,6 +2,7 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
@@ -134,6 +135,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
@@ -147,6 +149,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void; onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -285,6 +288,14 @@ export function InputBox({
}); });
}, [context, onContextChange]); }, [context, onContextChange]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@@ -590,6 +601,26 @@ export function InputBox({
<span className="text-xs font-normal">写作</span> <span className="text-xs font-normal">写作</span>
</PromptInputButton> </PromptInputButton>
</Tooltip> </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> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
@@ -37,6 +37,11 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
type SearchResultItem,
} from "@/core/messages/search-results";
import { import {
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
findToolCallResult, findToolCallResult,
@@ -216,15 +221,21 @@ function ToolCall({
const { t } = useI18n(); const { t } = useI18n();
const artifacts = useArtifactsOptional(); 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; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (typeof args.query === "string") { if (query) {
label = t.toolCalls.searchOnWebFor(args.query); label =
name === "web_search"
? t.toolCalls.searchOnWebFor(query)
: t.toolCalls.searchFor(query);
} }
const items = normalizeSearchResults(result);
return ( return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}> <ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{items.length > 0 && <WebSearchResultsList results={items} />} {searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
</ChainOfThoughtStep> </ChainOfThoughtStep>
); );
} else if (name === "image_search") { } else if (name === "image_search") {
@@ -452,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> { interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
result?: string; result?: string | Record<string, unknown>;
} }
type CoTStep = CoTReasoningStep | CoTToolCallStep; type CoTStep = CoTReasoningStep | CoTToolCallStep;
@@ -484,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
}; };
const toolCallId = tool_call.id; const toolCallId = tool_call.id;
if (toolCallId) { if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages); const toolCallResult = findToolCallResult(
if (toolCallResult) { toolCallId,
try { messages,
const json = JSON.parse(toolCallResult); tool_call.name,
step.result = json; );
} catch { 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; step.result = toolCallResult;
} }
} }
@@ -501,32 +522,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
return steps; return steps;
} }
type WebSearchItem = { function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
title?: string;
url?: string;
snippet?: string;
content?: string;
published_date?: string;
publishedDate?: string;
date?: string;
};
function normalizeSearchResults(
result: string | Record<string, unknown> | undefined,
): WebSearchItem[] {
if (Array.isArray(result)) {
return result as WebSearchItem[];
}
if (result && typeof result === "object") {
const nested = (result as { results?: unknown }).results;
if (Array.isArray(nested)) {
return nested as WebSearchItem[];
}
}
return [];
}
function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full"> <Collapsible open={open} onOpenChange={setOpen} className="w-full">
@@ -548,16 +544,16 @@ function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
); );
} }
function SearchResultCard({ item }: { item: WebSearchItem }) { function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content; const content = item.snippet ?? item.content;
const date = item.published_date ?? item.publishedDate ?? item.date; const date = item.published_date ?? item.publishedDate ?? item.date;
let source: string | undefined; let sourceLabel: string | undefined = item.source;
if (item.url) { if (item.url) {
try { try {
source = new URL(item.url).hostname.replace(/^www\./, ""); sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
} catch { } catch {
source = undefined; sourceLabel = sourceLabel ?? item.url;
} }
} }
return ( return (
@@ -572,10 +568,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
{item.title} {item.title}
</div> </div>
)} )}
{(source || date) && ( {(sourceLabel || date) && (
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs"> <div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
{source && <span className="truncate">{source}</span>} {sourceLabel && <span className="truncate">{sourceLabel}</span>}
{source && date && <span>·</span>} {sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>} {date && <span>{date}</span>}
</div> </div>
)} )}
@@ -591,10 +587,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
<DialogTitle className="pr-6 text-base leading-relaxed"> <DialogTitle className="pr-6 text-base leading-relaxed">
{item.title ?? "检索结果"} {item.title ?? "检索结果"}
</DialogTitle> </DialogTitle>
{(source || date) && ( {(sourceLabel || date) && (
<DialogDescription className="flex items-center gap-2 text-xs"> <DialogDescription className="flex items-center gap-2 text-xs">
{source && <span>{source}</span>} {sourceLabel && <span>{sourceLabel}</span>}
{source && date && <span>·</span>} {sourceLabel && date && <span>·</span>}
{date && <span>{date}</span>} {date && <span>{date}</span>}
</DialogDescription> </DialogDescription>
)} )}
@@ -1,6 +1,6 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
Conversation, Conversation,
@@ -64,6 +64,111 @@ export function MessageList({
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = thread.messages; 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 () => {
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 = 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) { if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />; return <MessageListSkeleton />;
} }
@@ -89,14 +194,34 @@ export function MessageList({
} else if (group.type === "assistant:clarification") { } else if (group.type === "assistant:clarification") {
const message = group.messages[0]; const message = group.messages[0];
if (message && hasContent(message)) { if (message && hasContent(message)) {
const messageId = message.id ?? "";
const pendingIndex = pendingClarificationIds.indexOf(messageId);
const isPending = pendingIndex >= 0;
const isLastPending =
isPending && pendingIndex === totalPending - 1;
return ( return (
<div key={group.id} className="w-full"> <div key={group.id} className="w-full">
<ClarificationCard <ClarificationCard
fallbackContent={extractContentFromMessage(message)} fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading} isLoading={thread.isLoading}
message={message} message={message}
onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins} 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={handleSubmitAllClarifications}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -289,6 +414,11 @@ type ClarificationPayload = {
allow_custom?: boolean; allow_custom?: boolean;
}; };
type ClarificationSelection = {
selectedIds: string[];
custom: string;
};
export type SetupAgentApprovalPayload = { export type SetupAgentApprovalPayload = {
name?: string; name?: string;
description?: string; description?: string;
@@ -447,19 +577,32 @@ function ClarificationCard({
fallbackContent, fallbackContent,
isLoading, isLoading,
message, message,
onSubmit,
rehypePlugins, rehypePlugins,
isPending,
pendingIndex,
pendingCount,
isLastPending,
selection,
onSelectionChange,
submitting,
canSubmit,
onSubmitAll,
}: { }: {
fallbackContent: string; fallbackContent: string;
isLoading: boolean; isLoading: boolean;
message: unknown; message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>; rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
isPending: boolean;
pendingIndex?: number;
pendingCount?: number;
isLastPending: boolean;
selection: ClarificationSelection;
onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean;
canSubmit: boolean;
onSubmitAll: () => void | Promise<void>;
}) { }) {
const payload = getClarificationPayload(message); const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const options = useMemo( const options = useMemo(
() => () =>
@@ -479,27 +622,36 @@ function ClarificationCard({
); );
} }
const disabled = false; const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1;
async function submitAnswer(answer: string) { function toggleOption(optionId: string) {
const trimmed = answer.trim(); if (disabled) {
if (!trimmed) {
return; return;
} }
setSubmitting(true); const next = selection.selectedIds.includes(optionId)
try { ? selection.selectedIds.filter((id) => id !== optionId)
await onSubmit?.(trimmed); : [...selection.selectedIds, optionId];
setSubmitted(true); onSelectionChange({ ...selection, selectedIds: next });
} finally { }
setSubmitting(false);
function updateCustom(value: string) {
if (disabled) {
return;
} }
onSelectionChange({ ...selection, custom: value });
} }
return ( return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm"> <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="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> </div>
{payload.context ? ( {payload.context ? (
<p className="text-muted-foreground text-sm leading-6"> <p className="text-muted-foreground text-sm leading-6">
@@ -513,49 +665,84 @@ function ClarificationCard({
{options.length > 0 ? ( {options.length > 0 ? (
<div className="mt-4 grid gap-2"> <div className="mt-4 grid gap-2">
{options.map((option) => ( <div className="text-muted-foreground text-xs">
<Button 可多选(再次点击取消选择)
key={option.id || option.label} </div>
type="button" {options.map((option) => {
variant="outline" const optionId = option.id || option.label;
className="h-auto justify-start whitespace-normal px-3 py-2 text-left" const isSelected = selection.selectedIds.includes(optionId);
disabled={disabled} return (
onClick={() => void submitAnswer(`我选择:${option.label}`)} <Button
> key={optionId}
<span className="flex min-w-0 flex-col items-start gap-1"> type="button"
<span>{option.label}</span> variant={isSelected ? "default" : "outline"}
{option.description ? ( className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
<span className="text-muted-foreground text-xs font-normal leading-5"> disabled={disabled}
{option.description} onClick={() => toggleOption(optionId)}
</span> >
<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 ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null} ) : null}
</span> </Button>
</Button> );
))} })}
</div> </div>
) : null} ) : null}
{payload.allow_custom !== false ? ( {payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
<Textarea <Textarea
value={customAnswer} value={selection.custom}
onChange={(event) => setCustomAnswer(event.currentTarget.value)} onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答" placeholder={
options.length > 0
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled} disabled={disabled}
className="min-h-20 resize-none" className="min-h-20 resize-none"
/> />
<div className="flex justify-end"> </div>
) : null}
{isPending ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
) : null}
<Button <Button
type="button" type="button"
size="sm" size="sm"
disabled={disabled || !customAnswer.trim()} disabled={!canSubmit || submitting}
onClick={() => void submitAnswer(customAnswer)} onClick={() => void onSubmitAll()}
> >
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
发送回答 {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button> </Button>
</div> </div>
</div> ) : (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
)
) : null} ) : null}
</div> </div>
); );
@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
ChevronDown,
Download, Download,
FileJson, FileJson,
FileText, FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); 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 } = const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
useParams<{ useParams<{
thread_id: string; thread_id: string;
@@ -150,7 +160,9 @@ export function RecentChatList() {
); );
const visibleThreads = threads.filter( 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) { if (visibleThreads.length === 0) {
@@ -159,12 +171,24 @@ export function RecentChatList() {
return ( return (
<> <>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"> <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"> <SidebarGroupLabel
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" 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"
? t.sidebar.recentChats onClick={toggleCollapsed}
: t.sidebar.demoChats} >
<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> </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> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => { {visibleThreads.map((thread) => {
@@ -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"; "use client";
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react"; import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useMemo, type ComponentType, type SVGProps } from "react"; import { useMemo, type ComponentType, type SVGProps } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
export function AppearanceSettingsPage() { export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n(); const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme(); const { theme, setTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark"; const currentTheme = theme;
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo( const themeOptions = useMemo(
() => [ () => [
{
id: "system",
label: t.settings.appearance.system,
description: t.settings.appearance.systemDescription,
icon: MonitorSmartphoneIcon,
},
{ {
id: "light", id: "light",
label: t.settings.appearance.light, label: t.settings.appearance.light,
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
t.settings.appearance.darkDescription, t.settings.appearance.darkDescription,
t.settings.appearance.light, t.settings.appearance.light,
t.settings.appearance.lightDescription, t.settings.appearance.lightDescription,
t.settings.appearance.system,
t.settings.appearance.systemDescription,
], ],
); );
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
title={t.settings.appearance.themeTitle} title={t.settings.appearance.themeTitle}
description={t.settings.appearance.themeDescription} 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) => ( {themeOptions.map((option) => (
<ThemePreviewCard <ThemePreviewCard
key={option.id} key={option.id}
@@ -76,8 +69,7 @@ export function AppearanceSettingsPage() {
label={option.label} label={option.label}
description={option.description} description={option.description}
active={currentTheme === option.id} active={currentTheme === option.id}
mode={option.id as "system" | "light" | "dark"} mode={option.id as "light" | "dark"}
systemTheme={systemTheme}
onSelect={(value) => setTheme(value)} onSelect={(value) => setTheme(value)}
/> />
))} ))}
@@ -122,7 +114,7 @@ export function AppearanceSettingsPage() {
<div className="text-sm font-medium">回答速度指示器</div> <div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed"> <p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s 在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认关闭。 并用绿到红的渐变条直观反映快慢。默认开启。
</p> </p>
</div> </div>
<Switch <Switch
@@ -144,19 +136,16 @@ function ThemePreviewCard({
description, description,
active, active,
mode, mode,
systemTheme,
onSelect, onSelect,
}: { }: {
icon: ComponentType<SVGProps<SVGSVGElement>>; icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string; label: string;
description: string; description: string;
active: boolean; active: boolean;
mode: "system" | "light" | "dark"; mode: "light" | "dark";
systemTheme?: string; onSelect: (mode: "light" | "dark") => void;
onSelect: (mode: "system" | "light" | "dark") => void;
}) { }) {
const previewMode = const previewMode = mode;
mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
return ( return (
<button <button
type="button" type="button"
@@ -22,6 +22,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -36,6 +37,7 @@ import {
useReplaceMemoryEntry, useReplaceMemoryEntry,
} from "@/core/memory/hooks"; } from "@/core/memory/hooks";
import type { import type {
MemoryFact,
MemorySearchMode, MemorySearchMode,
MemoryTarget, MemoryTarget,
MemoryV2Config, MemoryV2Config,
@@ -54,6 +56,10 @@ export function MemorySettingsPage() {
const { t } = useI18n(); const { t } = useI18n();
const m = t.settings.memory; const m = t.settings.memory;
// 先读配置拿到 version,再决定渲染哪套 UI
const { configResponse } = useMemoryConfig();
const memoryVersion = configResponse?.version ?? "v2";
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。 // 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。 // 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个 // 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
@@ -211,7 +217,33 @@ export function MemorySettingsPage() {
</div> </div>
) : !memory ? ( ) : !memory ? (
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div> <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}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MemoryV1View memory={memory} />
</div>
) : ( ) : (
// V2 模式:USER.md / MEMORY.md 条目编辑
<div className="space-y-4"> <div className="space-y-4">
{renderEntrySection( {renderEntrySection(
"user", "user",
@@ -251,13 +283,17 @@ export function MemorySettingsPage() {
)} )}
</SettingsSection> </SettingsSection>
<div className="mt-8"> {memoryVersion !== "v1" && (
<MemoryConfigPanel /> <>
</div> <div className="mt-8">
<MemoryConfigPanel />
</div>
<div className="mt-8"> <div className="mt-8">
<MemorySearchPanel /> <MemorySearchPanel />
</div> </div>
</>
)}
{/* 新增 / 编辑条目对话框 */} {/* 新增 / 编辑条目对话框 */}
<Dialog <Dialog
@@ -415,6 +451,152 @@ function MemoryConfigPanel() {
); );
} }
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">用户画像 &amp; 历史</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]">{fact.content}</p>
{fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
避免:{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]">{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]">{value}</dd>
</div>
))}
</dl>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}
/** 一行开关控件。 */ /** 一行开关控件。 */
function ToggleRow({ function ToggleRow({
label, label,
@@ -4,6 +4,8 @@ import {
AlertCircle, AlertCircle,
ArchiveIcon, ArchiveIcon,
CheckCircle2, CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon, ClockIcon,
FileArchive, FileArchive,
GlobeIcon, GlobeIcon,
@@ -11,6 +13,7 @@ import {
LockIcon, LockIcon,
PinIcon, PinIcon,
PinOffIcon, PinOffIcon,
RefreshCwIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
@@ -18,7 +21,7 @@ import {
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; 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 { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { import {
useDeleteCustomSkill, useDeleteCustomSkill,
useInstallSkillUpload, useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned, useSetSkillPinned,
useSetSkillPublished, useSetSkillPublished,
useSkills, useSkills,
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
); );
} }
const PAGE_SIZE = 10;
function SkillSettingsList({ onClose }: { onClose?: () => void }) { function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); 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. // Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds }); const { skills, isLoading, error } = useSkills({ search, tagIds });
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const staleCount = staleVisible.length; const staleCount = staleVisible.length;
const archivedCount = archivedVisible.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 = () => { const handleCreateSkill = () => {
onClose?.(); onClose?.();
router.push("/?mode=skill"); 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 ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="flex flex-wrap items-center justify-between gap-3">
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<TrendingUpIcon className="size-4" /> <TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution} {t.settings.skills.goToEvolution}
</Button> </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 <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
)} )}
{!isLoading && !error && {!isLoading && !error &&
filteredSkills.length > 0 && filteredSkills.length > 0 &&
filteredSkills.map((skill) => ( pagedSkills.map((skill) => (
<SkillRow <SkillRow
key={skill.name} key={skill.name}
skill={skill} skill={skill}
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
isAdmin={isAdmin} 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 <InstallSkillDialog
open={installOpen} open={installOpen}
onClose={() => setInstallOpen(false)} onClose={() => setInstallOpen(false)}
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; // import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react"; import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -107,6 +107,30 @@ export function WorkspaceNavChatList({
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </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> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"} isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
)} )}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/users")} onClick={() => navigate("/page/workspace/admin")}
> >
<UsersIcon /> <UsersIcon />
用户管理 用户中心
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/memory")}
>
<BrainIcon />
记忆管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && ( {isAdmin && (
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
定时任务管理 定时任务管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/user-activity")
}
>
<HistoryIcon />
用户活动审计
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")} onClick={() => navigate("/page/workspace/admin/tags")}
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu"; import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} /> <WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />} {isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent> </SidebarContent>
{!compactChrome && ( {!compactChrome && (
<SidebarFooter> <SidebarFooter>
+60 -23
View File
@@ -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'; 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 { interface ThemeContextType {
theme: ThemeType; theme: ThemeType;
themeConfig: ThemeConfig; themeConfig: ThemeConfig;
toggleTheme: () => void; toggleTheme: () => void;
setTheme: (theme: ThemeType) => void; setTheme: (theme: ThemeType) => void;
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
mounted: boolean;
} }
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
} }
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => { export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeType>(() => { const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
// 从 localStorage 读取保存的主题 const [mounted, setMounted] = useState(false);
const saved = localStorage.getItem('strategy-theme');
return (saved as ThemeType) || 'dark';
});
// 切换主题
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
// 设置特定主题
const setTheme = (newTheme: ThemeType) => {
setThemeState(newTheme);
};
// 保存到 localStorage
useEffect(() => { useEffect(() => {
localStorage.setItem('strategy-theme', theme); setMounted(true);
// 更新文档类名 }, []);
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme); // 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
}, [theme]); 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]; const themeConfig = themes[theme];
return ( return (
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}> <ThemeContext.Provider
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );
+70
View File
@@ -31,6 +31,7 @@ export interface AdminMemoryBucket {
export interface AdminMemoryResponse { export interface AdminMemoryResponse {
user_id: string; user_id: string;
version: "v1" | "v2";
buckets: AdminMemoryBucket[]; buckets: AdminMemoryBucket[];
} }
@@ -118,3 +119,72 @@ export async function deleteAdminMemoryEntry(
"Failed to delete memory entry", "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",
);
}
+67
View File
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
addAdminMemoryEntry, addAdminMemoryEntry,
addAdminV1Fact,
deleteAdminMemoryEntry, deleteAdminMemoryEntry,
deleteAdminV1Fact,
getUserMemory, getUserMemory,
listUserThreadMessages, listUserThreadMessages,
listUserThreads, listUserThreads,
replaceAdminMemoryEntry, replaceAdminMemoryEntry,
updateAdminV1Fact,
updateAdminV1Section,
} from "./api"; } from "./api";
/** Threads owned by a given user. Disabled until a user is picked. */ /** Threads owned by a given user. Disabled until a user is picked. */
@@ -70,3 +74,66 @@ export function useAdminDeleteMemoryEntry(userId: string) {
onSuccess: invalidate, 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,
});
}
@@ -446,7 +446,14 @@ export const enUS: Translations = {
"Manage the configuration and enabled status of the agent skills.", "Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill", createSkill: "Create skill",
installSkill: "Install 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", goToEvolution: "Skill evolution",
paginationPrev: "Prev",
paginationNext: "Next",
paginationPageOf: "Page {page} of {total}",
emptyTitle: "No agent skill yet", emptyTitle: "No agent skill yet",
emptyDescription: emptyDescription:
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.", "Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
@@ -381,7 +381,14 @@ export interface Translations {
description: string; description: string;
createSkill: string; createSkill: string;
installSkill: string; installSkill: string;
rescanSkills: string;
rescanSkillsRunning: string;
rescanSkillsAdopted: string;
rescanSkillsNoop: string;
goToEvolution: string; goToEvolution: string;
paginationPrev: string;
paginationNext: string;
paginationPageOf: string;
emptyTitle: string; emptyTitle: string;
emptyDescription: string; emptyDescription: string;
emptyButton: string; emptyButton: string;
@@ -427,7 +427,14 @@ export const zhCN: Translations = {
description: "管理 Agent Skill 配置和启用状态。", description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能", createSkill: "新建技能",
installSkill: "安装技能", installSkill: "安装技能",
rescanSkills: "重新扫描技能目录",
rescanSkillsRunning: "扫描中…",
rescanSkillsAdopted: "已收纳 {count} 个新技能",
rescanSkillsNoop: "没有发现需要收纳的新技能",
goToEvolution: "自我进化", goToEvolution: "自我进化",
paginationPrev: "上一页",
paginationNext: "下一页",
paginationPageOf: "第 {page} / {total} 页",
emptyTitle: "还没有技能", emptyTitle: "还没有技能",
emptyDescription: emptyDescription:
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。", "将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
+29 -3
View File
@@ -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 { export interface MemoryV2Data {
// V2 字段
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */ /** USER.md 条目 —— 用户画像,跨 agent 共享。 */
user_entries: string[]; user_entries: string[];
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */ /** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
@@ -10,17 +26,27 @@ export interface MemoryV2Data {
user_usage: string; user_usage: string;
/** MEMORY.md 字符用量。 */ /** MEMORY.md 字符用量。 */
memory_usage: string; 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 type MemoryTarget = "user" | "memory";
/** 生效记忆配置 + 用户可改字段白名单。 */ /** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
export interface MemoryV2ConfigResponse { export interface MemoryV2ConfigResponse {
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */ /** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
config: MemoryV2Config; config: MemoryV2Config;
/** 用户可在运行时覆盖的字段 (点号路径)。 */ /** 用户可在运行时覆盖的字段 (点号路径)。 */
writable_fields: string[]; writable_fields: string[];
/** 当前记忆版本:v1 或 v2。 */
version: "v1" | "v2";
} }
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */ /** 生效记忆配置的关注字段 (后端 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;
}
+81 -15
View File
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk"; import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
interface GenericMessageGroup<T = string> { interface GenericMessageGroup<T = string> {
type: T; type: T;
id: string | undefined; id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages<T>(
} }
export function extractTextFromMessage(message: Message) { export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") { const payload = extractToolResultPayload(message);
return ( if (typeof payload === "string") {
splitInlineReasoningFromAIMessage(message)?.content ?? return payload;
message.content.trim()
);
} }
if (Array.isArray(message.content)) { if (payload && typeof payload === "object") {
return message.content try {
.map((content) => (content.type === "text" ? content.text : "")) return JSON.stringify(payload);
.join("\n") } catch {
.trim(); return "";
}
} }
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; const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) { function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
return false; 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) { for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) { if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
const content = extractTextFromMessage(message); continue;
if (content) { }
return content; 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; return undefined;
} }
@@ -46,6 +46,7 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
export async function createScheduledTask(data: { export async function createScheduledTask(data: {
name: string; name: string;
description?: string | null;
prompt: string; prompt: string;
schedule: string; schedule: string;
timezone?: string | null; timezone?: string | null;
@@ -203,6 +204,7 @@ export async function updateScheduledTask(
taskId: string, taskId: string,
data: Partial<{ data: Partial<{
name: string; name: string;
description: string | null;
prompt: string; prompt: string;
schedule: string; schedule: string;
timezone: string; timezone: string;
@@ -329,6 +331,7 @@ export async function adminUpdateScheduledTask(
taskId: string, taskId: string,
data: Partial<{ data: Partial<{
name: string; name: string;
description: string | null;
prompt: string; prompt: string;
schedule: string; schedule: string;
timezone: string; timezone: string;
@@ -14,6 +14,7 @@ export type ScheduledTaskSubscription = {
export type ScheduledTask = { export type ScheduledTask = {
task_id: string; task_id: string;
name: string; name: string;
description?: string | null;
prompt: string; prompt: string;
schedule_text: string; schedule_text: string;
cron_expr: string; cron_expr: string;
@@ -27,6 +28,8 @@ export type ScheduledTask = {
scheduler_thread_id: string; scheduler_thread_id: string;
published?: boolean; published?: boolean;
published_at?: string | null; published_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
role?: ScheduledTaskRole; role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null; _subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null; creator_user_id?: string | null;
+2 -2
View File
@@ -5,7 +5,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
enabled: true, enabled: true,
}, },
display: { display: {
streamSpeedMeterEnabled: false, streamSpeedMeterEnabled: true,
}, },
context: { context: {
model_name: undefined, model_name: undefined,
@@ -29,7 +29,7 @@ export interface LocalSettings {
display: { display: {
/** /**
* Whether to render a small token/s speed meter while a streaming * 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. * the appearance settings page.
*/ */
streamSpeedMeterEnabled: boolean; streamSpeedMeterEnabled: boolean;
+16
View File
@@ -135,6 +135,22 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>; 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 { export interface SetSkillPublishedResponse {
name: string; name: string;
owner_user_id: string | null; owner_user_id: string | null;
+11
View File
@@ -11,6 +11,7 @@ import {
getSkill, getSkill,
getSkillContent, getSkillContent,
installSkillUpload, installSkillUpload,
reconcileCustomSkills,
setSkillDetail, setSkillDetail,
setSkillPinned, setSkillPinned,
setSkillPublished, 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() { export function useSetSkillPublished() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+1
View File
@@ -506,6 +506,7 @@ export function useThreadStream({
: context.mode === "thinking" : context.mode === "thinking"
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
thread_id: threadId, thread_id: threadId,
}, },
}, },
+1
View File
@@ -18,6 +18,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean;
agent_id?: string; agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */ /** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string; agent_name?: string;
@@ -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 { interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void; onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean; disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
hideAdvancedFields?: boolean;
} }
interface ModelOption { interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50'; 'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) { const DEFAULT_KEYWORD_COUNT = 6;
const DEFAULT_AUDIENCE = '普通读者';
export function AIWritingForm({
onSubmit,
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis'); const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault(); e.preventDefault();
if (!intent.trim()) return; if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6)); const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({ onSubmit({
userIntent: intent.trim(), userIntent: intent.trim(),
articleType, articleType,
targetAudience: audience.trim() || '普通读者', targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount, keywordCount,
writingMode: strictMode ? 'strict' : 'loose', writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined, modelName: modelName || undefined,
}); });
} }
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div> </div>
</div> </div>
<div className="space-y-1.5"> {!hideAdvancedFields && (
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label> <>
<input <div className="space-y-1.5">
type="number" <label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
min={1} <input
max={10} type="number"
step={1} min={1}
className={INPUT_CLASS} max={10}
placeholder="6" step={1}
value={keywordCountInput} className={INPUT_CLASS}
onChange={(e) => setKeywordCountInput(e.target.value)} placeholder="6"
onBlur={handleKeywordCountBlur} value={keywordCountInput}
disabled={disabled} onChange={(e) => setKeywordCountInput(e.target.value)}
/> onBlur={handleKeywordCountBlur}
<p className="text-[11px] text-muted-foreground/70"> disabled={disabled}
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。 />
</p> <p className="text-[11px] text-muted-foreground/70">
</div> 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2"> <div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input <input
type="checkbox" type="checkbox"
id="strict-mode" id="strict-mode"
checked={strictMode} checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)} onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled} disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer" className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/> />
<label htmlFor="strict-mode" className="cursor-pointer select-none"> <label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span> <span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed"> <p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。 勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p> </p>
</label> </label>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label> <label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input <input
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…" placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience} value={audience}
onChange={(e) => setAudience(e.target.value)} onChange={(e) => setAudience(e.target.value)}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
</>
)}
{models.length > 0 && ( {models.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions'; 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' }, in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' }, draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-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' }, 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 ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000); const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚'; if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} }
interface SessionItemProps { export interface SessionItemProps {
session: AIWritingSessionSummary; session: AIWritingSessionSummary;
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
onRename: (title: string) => void; 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 [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title); const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
error: '出错了', error: '出错了',
}; };
export function AIWritingPanel() { export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext(); const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
reset(); reset();
}, [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 一键清理所有过期会话 ──────────────────────────────── // ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。 // ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。 // 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto"> <div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */} {/* 未开始时显示表单 */}
{isIdle && ( {isIdle && (
<AIWritingForm onSubmit={startWriting} /> <AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)} )}
{/* 意图解析:流式 → 结果卡片承接。 {/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} /> <IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)} )}
{state.parsedIntent && !state.intentStreamingText && ( {state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} /> <IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)} )}
{/* 运行中或完成后显示时间线 */} {/* 运行中或完成后显示时间线 */}
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType} {articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
</span> </span>
</ReqRow> </ReqRow>
<ReqRow label="目标读者"> {!hideAdvancedFields && (
<span className="text-muted-foreground">{state.request.targetAudience}</span> <ReqRow label="目标读者">
</ReqRow> <span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数"> <ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span> <span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow> </ReqRow>
<ReqRow label="检索关键词数量"> {!hideAdvancedFields && (
<span className="text-muted-foreground">{state.request.keywordCount} 个</span> <>
</ReqRow> <ReqRow label="检索关键词数量">
<ReqRow label="写作模式"> <span className="text-muted-foreground">{state.request.keywordCount} 个</span>
<span className="text-muted-foreground"> </ReqRow>
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'} <ReqRow label="写作模式">
</span> <span className="text-muted-foreground">
</ReqRow> {state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && ( {state.request.modelName && (
<ReqRow label="写作模型"> <ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span> <span className="text-muted-foreground">{state.request.modelName}</span>
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps { interface IntentResultCardProps {
intent: ParsedIntent; intent: ParsedIntent;
articleTypes: ArticleType[]; articleTypes: ArticleType[];
hideTargetAudience?: boolean;
} }
/** /**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项: * 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。 * 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/ */
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) { export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道") // article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel = const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType; articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
</div> </div>
<div className="px-3 py-2.5 space-y-1.5 text-xs"> <div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} /> <Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} /> {!hideTargetAudience && (
<Row label="目标读者" value={intent.targetAudience} />
)}
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} /> <Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && ( {intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline /> <Row label="精炼意图" value={intent.refinedIntent} multiline />
@@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
// ── Edit mode ───────────────────────────────────────────────────────── // ── Edit mode ─────────────────────────────────────────────────────────
return ( return (
<div className="h-full flex flex-col overflow-hidden"> <div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} /> <WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
<div className="flex-1 flex min-h-0 overflow-hidden"> <div className="flex-1 flex min-h-0 overflow-hidden">
{/* 左侧目录栏(仿豆包):文档标题层级导航 */} {/* 左侧目录栏(仿豆包):文档标题层级导航 */}
@@ -12,7 +12,11 @@ import {
Quote, Quote,
Code, Code,
Pilcrow, Pilcrow,
Download,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
@@ -21,6 +25,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector"; import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
import { downloadAIWritingDocx } from "../../api/writing-export";
interface ToolbarButtonProps { interface ToolbarButtonProps {
onClick: () => void; onClick: () => void;
@@ -60,11 +65,35 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
interface WritingToolbarProps { interface WritingToolbarProps {
editor: Editor | null; editor: Editor | null;
/** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */
markdown?: string;
/** 文档标题,用作 .docx 文件名与封面 */
title?: string;
} }
export function WritingToolbar({ editor }: WritingToolbarProps) { export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
const [exporting, setExporting] = useState(false);
if (!editor) return null; if (!editor) return null;
const canExport = !exporting && !!markdown?.trim();
const handleExport = async () => {
if (!canExport) return;
setExporting(true);
try {
await downloadAIWritingDocx({
title,
draftMarkdown: markdown ?? "",
});
} catch (err) {
console.error("[writing-export] failed", err);
toast.error("导出 Word 失败,请稍后重试");
} finally {
setExporting(false);
}
};
return ( return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap"> <div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */} {/* Block type */}
@@ -173,8 +202,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) {
<Strikethrough className="h-3.5 w-3.5" /> <Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton> </ToolbarButton>
{/* Model selector — pushed to the right */} {/* Right side: export + model selector */}
<div className="ml-auto"> <div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
handleExport();
}}
disabled={!canExport}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
导出 Word
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-4" />
<CanvasModelSelector /> <CanvasModelSelector />
</div> </div>
</div> </div>
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() { export default function AIWritingPage({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
return ( return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
> >
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */} {/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel /> <AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -0,0 +1,345 @@
/**
* Markdown → Word(.docx) 导出工具。
*
* 设计要点:
* - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。
* - 文档结构:封面标题(无 H1 时补) → 正文。
*/
import {
AlignmentType,
Document,
ExternalHyperlink,
HeadingLevel,
LevelFormat,
Packer,
Paragraph,
TextRun,
} from "docx";
import { saveAs } from "file-saver";
/** 标题层级(H1~H3) */
type HeadingLevelNum = 1 | 2 | 3;
interface ParsedHeading {
level: HeadingLevelNum;
text: string;
}
type Block =
| { kind: "heading"; level: HeadingLevelNum; text: string }
| { kind: "paragraph"; text: string }
| { kind: "blockquote"; text: string }
| { kind: "code"; code: string }
| { kind: "list"; ordered: boolean; items: string[] };
// ────────────────────────────────────────────────────────────────────────────
// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法
// ────────────────────────────────────────────────────────────────────────────
const RE_HEADING = /^(#{1,3})\s+(.*)$/;
const RE_FENCE = /^```/;
const RE_QUOTE = /^>\s?/;
const RE_UL = /^[-*]\s+/;
const RE_OL = /^\d+\.\s+/;
const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/;
function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: Block[] = [];
const headings: ParsedHeading[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
if (RE_FENCE.test(line)) {
const buf: string[] = [];
i++;
while (i < lines.length && !RE_FENCE.test(lines[i])) {
buf.push(lines[i]);
i++;
}
if (i < lines.length) i++; // 跳过闭合 ```
blocks.push({ kind: "code", code: buf.join("\n") });
continue;
}
const hm = RE_HEADING.exec(line);
if (hm) {
const level = hm[1].length as HeadingLevelNum;
const text = hm[2].trim();
headings.push({ level, text });
blocks.push({ kind: "heading", level, text });
i++;
continue;
}
if (RE_QUOTE.test(line)) {
const buf: string[] = [];
while (i < lines.length && RE_QUOTE.test(lines[i])) {
buf.push(lines[i].replace(RE_QUOTE, ""));
i++;
}
blocks.push({ kind: "blockquote", text: buf.join("\n") });
continue;
}
if (RE_UL.test(line)) {
const items: string[] = [];
while (i < lines.length && RE_UL.test(lines[i])) {
items.push(lines[i].replace(RE_UL, ""));
i++;
}
blocks.push({ kind: "list", ordered: false, items });
continue;
}
if (RE_OL.test(line)) {
const items: string[] = [];
while (i < lines.length && RE_OL.test(lines[i])) {
items.push(lines[i].replace(RE_OL, ""));
i++;
}
blocks.push({ kind: "list", ordered: true, items });
continue;
}
// 段落:合并相邻非空、非块起始行
const buf: string[] = [line];
i++;
while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) {
buf.push(lines[i]);
i++;
}
blocks.push({ kind: "paragraph", text: buf.join("\n") });
}
return { blocks, headings };
}
// ────────────────────────────────────────────────────────────────────────────
// 行内格式 → TextRun / Hyperlink
// ────────────────────────────────────────────────────────────────────────────
/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */
const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g;
type InlineChild = TextRun | ExternalHyperlink;
function parseInline(text: string): InlineChild[] {
if (!text) return [new TextRun({ text: "" })];
const out: InlineChild[] = [];
let last = 0;
let m: RegExpExecArray | null;
RE_INLINE.lastIndex = 0;
while ((m = RE_INLINE.exec(text)) !== null) {
if (m.index > last) {
out.push(new TextRun({ text: text.slice(last, m.index) }));
}
const tok = m[0];
if (tok.startsWith("**")) {
out.push(new TextRun({ text: tok.slice(2, -2), bold: true }));
} else if (tok.startsWith("`")) {
out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" }));
} else if (tok.startsWith("[")) {
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok);
if (linkMatch) {
out.push(
new ExternalHyperlink({
link: linkMatch[2],
children: [
new TextRun({
text: linkMatch[1],
color: "0563C1",
underline: {},
}),
],
}),
);
} else {
out.push(new TextRun({ text: tok }));
}
} else if (tok.startsWith("*") || tok.startsWith("_")) {
out.push(new TextRun({ text: tok.slice(1, -1), italics: true }));
}
last = RE_INLINE.lastIndex;
}
if (last < text.length) {
out.push(new TextRun({ text: text.slice(last) }));
}
return out;
}
// ────────────────────────────────────────────────────────────────────────────
// Block → Paragraph[]
// ────────────────────────────────────────────────────────────────────────────
const DOCX_HEADING: Record<HeadingLevelNum, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
};
function blockToParagraphs(b: Block): Paragraph[] {
switch (b.kind) {
case "heading": {
return [
new Paragraph({
heading: DOCX_HEADING[b.level],
children: parseInline(b.text),
}),
];
}
case "paragraph": {
return [
new Paragraph({
children: parseInline(b.text),
spacing: { line: 360, after: 120 },
}),
];
}
case "blockquote": {
return [
new Paragraph({
children: parseInline(b.text),
indent: { left: 480 },
spacing: { line: 360, after: 120 },
}),
];
}
case "code": {
return b.code.split("\n").map(
(line) =>
new Paragraph({
children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })],
}),
);
}
case "list": {
return b.items.map(
(item) =>
new Paragraph({
numbering: {
reference: b.ordered ? "ol" : "ul",
level: 0,
},
children: parseInline(item),
spacing: { line: 320 },
}),
);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// 文件名工具
// ────────────────────────────────────────────────────────────────────────────
function sanitizeFilename(name: string): string {
// Windows 非法字符 + 控制字符 → 下划线
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim();
}
function fallbackFilename(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
}
function resolveFilename(title: string | undefined): string {
const base = title ? sanitizeFilename(title) : "";
return `${base || fallbackFilename()}.docx`;
}
// ────────────────────────────────────────────────────────────────────────────
// 主入口
// ────────────────────────────────────────────────────────────────────────────
/**
* 把 markdown 文档导出为 .docx 并触发浏览器下载。
*
* @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown)
* @param title 文档标题,用作文件名与封面(空则用时间戳兜底)
*/
export async function exportMarkdownToDocx(
markdown: string,
title?: string,
): Promise<void> {
const { blocks, headings } = parseMarkdown(markdown ?? "");
// 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1
const coverText =
(title && title.trim()) ||
headings.find((h) => h.level === 1)?.text ||
"未命名文档";
const children: Paragraph[] = [];
// 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复)
const hasFirstH1 = headings[0]?.level === 1;
if (!hasFirstH1) {
children.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 240, after: 240 },
children: [new TextRun({ text: coverText, bold: true, size: 36 })],
}),
);
}
// 正文
for (const b of blocks) {
children.push(...blockToParagraphs(b));
}
const doc = new Document({
creator: "DeerFlow AI 写作",
title: coverText,
styles: {
default: {
document: {
run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑
},
},
},
numbering: {
config: [
{
reference: "ul",
levels: [
{
level: 0,
format: LevelFormat.BULLET,
text: "•",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
},
],
},
{
reference: "ol",
levels: [
{
level: 0,
format: LevelFormat.DECIMAL,
text: "%1.",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
},
],
},
],
},
sections: [{ children }],
});
const blob = await Packer.toBlob(doc);
saveAs(blob, resolveFilename(title));
}
@@ -0,0 +1,5 @@
import { AdminUserHub } from "@/components/workspace/admin/admin-user-hub";
export default function AdminUserHubPage() {
return <AdminUserHub />;
}
+74 -56
View File
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
@@ -35,14 +36,33 @@ export default function AgentChatPage() {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter(); const router = useRouter();
const { agent_id } = useParams<{ const { agent_id } = useParams<{
agent_id: string; agent_id: string;
}>(); }>();
const { agent } = useAgent(agent_id); // If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI
// before the auto-sent message takes over.
const hasPendingForThisAgent = () => {
if (typeof window === "undefined" || !agent_id) return false;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.agentId === agent_id && !!parsed?.text;
} catch {
return false;
}
};
const [hasStartedConversation, setHasStartedConversation] = useState(
hasPendingForThisAgent,
);
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
const agentDisplayName =
agent?.name ?? (isAgentLoading ? t.common.loading : "");
const { threadId, setThreadId, isNewThread, setIsNewThread } = const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat(); useThreadChat();
@@ -51,14 +71,10 @@ export default function AgentChatPage() {
useEffect(() => { useEffect(() => {
if (isNewThread) { if (isNewThread) {
setHasStartedConversation(false); setHasStartedConversation(hasPendingForThisAgent());
} }
}, [isNewThread, threadId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewThread, threadId, agent_id]);
// Auto-submit a message that the user typed on ChatPage right before
// selecting this agent. ChatPage stashed it in sessionStorage so we don't
// depend on router state (the next/navigation shim doesn't carry it).
const autoSubmitDoneRef = useRef(false);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
@@ -104,28 +120,25 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id], [sendMessage, threadId, agent_id],
); );
// ChatPage stashes the opening message in sessionStorage when the user picks
// an agent on the landing screen, then redirects here. Replay it once so the
// user doesn't have to re-type the question on the agent chat page.
const pendingMessageHandled = useRef(false);
useEffect(() => { useEffect(() => {
if (autoSubmitDoneRef.current) return; if (pendingMessageHandled.current) return;
if (!isNewThread || hasStartedConversation) return; if (!agent_id || !isNewThread || hasStartedConversation) return;
let raw: string | null = null; let pending: { agentId?: string; text?: string } | null = null;
try { try {
raw = sessionStorage.getItem("pendingAgentMessage"); const raw = sessionStorage.getItem("pendingAgentMessage");
if (raw) pending = JSON.parse(raw);
} catch { } catch {
return; pending = null;
} }
if (!raw) return; if (!pending || pending.agentId !== agent_id || !pending.text) return;
try { pendingMessageHandled.current = true;
const parsed = JSON.parse(raw) as { agentId?: string; text?: string }; sessionStorage.removeItem("pendingAgentMessage");
if (parsed.agentId !== agent_id || !parsed.text) { handleSubmit({ text: pending.text, files: [] });
return; }, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
}
autoSubmitDoneRef.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: parsed.text, files: [] });
} catch {
sessionStorage.removeItem("pendingAgentMessage");
}
}, [agent_id, hasStartedConversation, handleSubmit, isNewThread]);
const handleClarificationSubmit = useCallback( const handleClarificationSubmit = useCallback(
(answer: string) => { (answer: string) => {
@@ -143,12 +156,7 @@ export default function AgentChatPage() {
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined; : undefined;
// AgentChatPage skips the centered welcome/landing state entirely — when the const isLandingState = isNewThread && !hasStartedConversation;
// user reaches this page they've already committed to an agent (either by
// selecting one on the homepage or by visiting the agent's URL), so we drop
// them straight into the conversation layout: messages area on top, input at
// the bottom. The agent badge in the header still identifies the agent.
const isLandingState = false;
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
@@ -165,8 +173,11 @@ export default function AgentChatPage() {
{/* Agent badge */} {/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1"> <div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" /> <BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium"> <span
{agent?.name ?? agent_id} className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
{agentDisplayName}
</span> </span>
</div> </div>
@@ -212,8 +223,8 @@ export default function AgentChatPage() {
"relative w-full", "relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]", isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState isLandingState
? "max-w-(--container-width-sm)" ? "max-w-(--container-width-md)"
: "max-w-(--container-width-md)", : "max-w-(--container-width-lg)",
)} )}
> >
<div className="absolute -top-4 right-0 left-0 z-0"> <div className="absolute -top-4 right-0 left-0 z-0">
@@ -228,25 +239,32 @@ export default function AgentChatPage() {
</div> </div>
</div> </div>
<InputBox <div className="w-full -translate-y-4">
className={cn("bg-background/5 w-full -translate-y-4")} <InputBox
isNewThread={isNewThread} className="w-full"
threadId={threadId} isNewThread={isLandingState}
autoFocus={isNewThread} threadId={threadId}
status={ autoFocus={isLandingState}
thread.error status={
? "error" thread.error
: thread.isLoading ? "error"
? "streaming" : thread.isLoading
: "ready" ? "streaming"
} : "ready"
context={settings.context} }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} context={settings.context}
onContextChange={(context) => setSettings("context", context)} extraHeader={
onFollowupsVisibilityChange={setShowFollowups} isLandingState && (
onSubmit={handleSubmit} <AgentWelcome agent={agent} agentName={agentDisplayName} />
onStop={handleStop} )
/> }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> <div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode} {t.common.notAvailableInDemoMode}
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BookOpen, Plus, Send } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (!question.trim()) return;
setQuestion("");
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
isDark ? "bg-slate-950" : "bg-white",
)}
>
{/* 背景光晕 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background: isDark
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
}}
/>
{/* 主内容 */}
<div className="relative flex h-full flex-col items-center justify-center -translate-y-10">
<div className="flex w-full max-w-[520px] flex-col items-center gap-4 px-6">
{/* 笔记图标 */}
<div
className={cn(
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-6 w-6" />
</div>
{/* 笔记标题 */}
<h1
className={cn(
"text-xl font-semibold tracking-tight",
isDark ? "text-slate-100" : "text-slate-900",
)}
>
笔记01
</h1>
{/* 新增来源按钮 */}
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
isDark
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
)}
>
<Plus className="h-3.5 w-3.5" />
新增来源
</button>
{/* 问答输入框 */}
<div
className={cn(
"mt-3 flex w-full items-center gap-3 rounded-full px-5 py-3.5",
isDark
? "bg-slate-800 ring-1 ring-white/10"
: "bg-white shadow-sm ring-1 ring-black/5",
)}
>
<Plus
className={cn(
"h-4 w-4 shrink-0",
isDark ? "text-slate-500" : "text-slate-400",
)}
/>
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
placeholder="开始问答"
className={cn(
"min-w-0 flex-1 bg-transparent text-sm outline-none",
isDark
? "text-slate-200 placeholder:text-slate-500"
: "text-slate-700 placeholder:text-slate-400",
)}
/>
{question.trim() && (
<button
type="button"
onClick={handleSubmit}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
aria-label="发送"
>
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -139,7 +139,7 @@ export default function NewAgentPage() {
if (threadPrepared) { if (threadPrepared) {
return; return;
} }
await getAPIClient().threads.create({ threadId }); await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
setThreadPrepared(true); setThreadPrepared(true);
}, [threadId, threadPrepared]); }, [threadId, threadPrepared]);
+2
View File
@@ -8,6 +8,7 @@ import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes"; import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header"; import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes"; import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
/> />
<Route path="strategy/*" element={<StrategyRoutes />} /> <Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} /> <Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
</Routes> </Routes>
</main> </main>
</div> </div>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
import { Navigate, Route, Routes } from "react-router-dom";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export default function RoundtableRoutes() {
return (
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="planning" replace />} />
<Route path="planning" element={<RoundtablePlanningPage />} />
<Route path="*" element={<Navigate to="planning" replace />} />
</Routes>
</div>
);
}
+38 -15
View File
@@ -78,6 +78,7 @@ function displayUsername(value?: string | null): string {
const emptyForm = { const emptyForm = {
name: "", name: "",
description: "",
schedule: "", schedule: "",
timezone: "Asia/Shanghai", timezone: "Asia/Shanghai",
prompt: "", prompt: "",
@@ -116,9 +117,13 @@ export default function ScheduledTasksPage() {
const sortedTasks = useMemo( const sortedTasks = useMemo(
() => () =>
[...tasks].sort((a, b) => [...tasks].sort((a, b) => {
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")), // Newest task first. Fall back to next_run_at so tasks that lack a
), // created_at (older rows / subscribed tasks) still order consistently.
const aKey = a.created_at ?? a.next_run_at ?? "";
const bKey = b.created_at ?? b.next_run_at ?? "";
return String(bKey).localeCompare(String(aKey));
}),
[tasks], [tasks],
); );
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null; const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
@@ -162,6 +167,7 @@ export default function ScheduledTasksPage() {
setEditing(task); setEditing(task);
setForm({ setForm({
name: task.name, name: task.name,
description: task.description ?? "",
schedule: task.schedule_text, schedule: task.schedule_text,
timezone: task.timezone, timezone: task.timezone,
prompt: task.prompt, prompt: task.prompt,
@@ -190,6 +196,7 @@ export default function ScheduledTasksPage() {
if (creating) { if (creating) {
const task = await actions.create.mutateAsync({ const task = await actions.create.mutateAsync({
name: form.name || "定时任务", name: form.name || "定时任务",
description: form.description.trim() || null,
schedule: cronSchedule, schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai", timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt, prompt: form.prompt,
@@ -202,6 +209,7 @@ export default function ScheduledTasksPage() {
taskId: editing.task_id, taskId: editing.task_id,
data: { data: {
name: form.name, name: form.name,
description: form.description.trim() || null,
schedule: cronSchedule, schedule: cronSchedule,
timezone: form.timezone, timezone: form.timezone,
prompt: form.prompt, prompt: form.prompt,
@@ -324,9 +332,11 @@ export default function ScheduledTasksPage() {
onClick={() => setSelectedTaskId(task.task_id)} onClick={() => setSelectedTaskId(task.task_id)}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="font-medium leading-snug break-all">
<div className="truncate font-medium">{task.name}</div> {task.name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{isRunningNow && ( {isRunningNow && (
<Badge <Badge
variant="secondary" variant="secondary"
@@ -351,7 +361,7 @@ export default function ScheduledTasksPage() {
{task.schedule_text} · {task.timezone} {task.schedule_text} · {task.timezone}
</div> </div>
</div> </div>
<Badge variant={task.enabled ? "default" : "secondary"}> <Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
{task.enabled ? "启用" : "暂停"} {task.enabled ? "启用" : "暂停"}
</Badge> </Badge>
</div> </div>
@@ -748,11 +758,11 @@ export default function ScheduledTasksPage() {
</Dialog> </Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}> <Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader> <DialogHeader className="min-w-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle> <DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex min-w-0 flex-col gap-3">
<Input <Input
value={form.name} value={form.name}
onChange={(event) => onChange={(event) =>
@@ -760,6 +770,14 @@ export default function ScheduledTasksPage() {
} }
placeholder="任务名称" placeholder="任务名称"
/> />
<Textarea
className="min-h-16"
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: event.target.value }))
}
placeholder="任务描述(可选,将显示在公开页标题下方)"
/>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p> <p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} /> <ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -780,15 +798,20 @@ export default function ScheduledTasksPage() {
})) }))
} }
> >
<SelectTrigger> <SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" /> <SelectValue placeholder="选择执行智能体" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem> <SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => ( {agents.map((agent) => (
<SelectItem key={agent.name} value={agent.name}> <SelectItem key={agent.id} value={agent.id}>
{agent.name} <span className="block truncate">
{agent.description ? ` — ${agent.description}` : ""} {agent.name}
{agent.description ? ` — ${agent.description}` : ""}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */} {/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} /> <Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */} {/* 对话 */}
<Route path="chats" element={<ChatsPage />} /> <Route path="chats" element={<ChatsPage />} />
+41 -27
View File
@@ -29,8 +29,12 @@ import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage"; import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export function WorkspaceLayout({ export function WorkspaceLayout({
children, children,
@@ -177,6 +181,26 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
/>
<Route
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<RoundtablePlanningPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="scheduled" path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />} element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -197,18 +221,32 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
{/* Unified admin user hub */}
<Route <Route
path="admin/users" path="admin"
element={ element={
getStoredAuth()?.system_role === "admin" ? ( getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps} pageSidebarHidden>
<UsersPage /> <AdminUserHubPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( ) : (
<Navigate to="/page/workspace/chats/new" replace /> <Navigate to="/page/workspace/chats/new" replace />
) )
} }
/> />
{/* Legacy redirects */}
<Route
path="admin/users"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/user-activity"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/memory"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route <Route
path="admin/scheduled-tasks" path="admin/scheduled-tasks"
element={ element={
@@ -221,30 +259,6 @@ export default function WorkspaceRoutes({
) )
} }
/> />
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminUserActivityPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/memory"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminMemoryPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="admin/tags" path="admin/tags"
element={ element={
@@ -0,0 +1,250 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
* built-in agent and no per-call coordinator: one /init creates a thread
* tied to the built-in `roundtable-intent` agent, and each /stream POST is
* one user-turn of the clarification dialogue. The terminal status frame
* carries either `asking` (more turns needed) or `done` (structured summary
* available — hand off to Step 2).
*/
export interface IntentInitResponse {
status: "success";
agent_id: string;
thread_id: string;
}
/**
* Create a fresh thread for an intent-clarification session.
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
*/
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init intent failed (${res.status}): ${text}`);
}
return (await res.json()) as IntentInitResponse;
}
/**
* Structured task summary the intent agent emits when it judges the user's
* task is fully specified. The frontend feeds this into Step 2's initial
* leader prompt.
*/
export interface IntentSummary {
/** One-line statement of what the user wants accomplished. */
objective: string;
/** Hard constraints (budget / time / compliance / scope). At least 1 entry. */
constraints: string[];
/** Optional assumptions the agent is making on the user's behalf. */
assumptions?: string[];
}
/**
* Same phases as streamMultiAgent so the UI can reuse one phase-label mapper.
* `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`;
* `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or
* similar tool call (which usually means the run is about to interrupt and
* the user-visible text won't grow further this turn).
*/
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
signal?: AbortSignal;
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
onTextDelta?: (text: string, messageId: string) => void;
/**
* Fires whenever the inferred SSE phase changes. The UI uses this to flip
* the placeholder bubble's caption from "调度中" → "已连接,等待响应" →
* "正在输出" so users see what's happening during the inevitable LLM
* first-token wait.
*/
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
}
export interface StreamIntentResult {
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */
status: "asking" | "done" | "error";
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
content: string;
/** Populated only when status === "done". */
summary: IntentSummary | null;
}
/**
* Recognise the terminal status frame the backend appends after the upstream
* LangGraph stream finishes. Must carry `status` and `content`; `summary`
* is optional.
*/
function isFinalIntentFrame(value: unknown): value is {
status: "asking" | "done" | "error";
content?: string;
summary?: IntentSummary | null;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status;
return status === "asking" || status === "done" || status === "error";
}
/**
* Reuse the same delta-extraction shape as multi-agent.ts so AI text streams
* smoothly into the frontend bubble. Returns null for tool / value / tool
* result frames.
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to /api/intent/stream and resolve when the upstream
* stream completes. Each call is one user turn; reuse the same threadId
* across calls to keep the dialogue history.
*/
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: req.threadId,
message: req.message,
model: req.model,
}),
signal: req.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`intent/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let lastEvent = "";
// Single source of truth for the inferred SSE phase. The helper only fires
// the callback when the phase actually changes (or when a tool name is
// provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
if (next === currentPhase && !toolName) return;
currentPhase = next;
req.onPhaseChange?.(next, toolName);
};
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
while (true) {
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 rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
// First `event: metadata` = the upstream LangGraph run was accepted.
// Flip out of "connecting" so the bubble label changes from
// "调度中" → "已连接,等待响应".
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
if (isFinalIntentFrame(parsed)) {
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
}
continue;
}
if (lastEvent === "messages") {
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
if (Array.isArray(parsed) && parsed.length > 0) {
const chunk = parsed[0];
if (chunk && typeof chunk === "object") {
const chunkObj = chunk as Record<string, unknown>;
const chunkType = String(chunkObj.type ?? "").toLowerCase();
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const hasToolCall =
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
if (hasToolCall) {
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
}
}
const delta = extractAiTextDelta(parsed);
if (delta) req.onTextDelta?.(delta.text, delta.id);
}
}
}
return result;
}
@@ -0,0 +1,558 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type ThreadIdMap = Record<string, string>;
export interface SeatReadyEvent {
agentName: string;
threadId: string;
displayName: string;
description: string;
}
export interface CoordinatorReadyEvent {
mainAgentName: string;
threadId: string;
}
export interface InitMultiAgentRequest {
agentNames: string[];
mainAgentName: string;
model?: string;
/** Fires the moment each sub-agent's thread is ready (real completion order). */
onSeatReady?: (event: SeatReadyEvent) => void;
/** Fires when the coordinator's thread + agent have been created. */
onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
signal?: AbortSignal;
}
export interface InitMultiAgentResponse {
status: "success";
main_agent_name: string;
thread_ids: ThreadIdMap;
}
/**
* Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
* callbacks as events arrive so the UI can light up roles individually, and
* resolves with the final {main_agent_name, thread_ids} once init_done lands.
* If the server emits an "error" frame mid-stream, the promise rejects.
*/
export async function initMultiAgent(
payload: InitMultiAgentRequest,
): Promise<InitMultiAgentResponse> {
// Timing telemetry for /api/multi-agent/init — enabled by default so we can
// see where time goes when users report "Step 2 init feels slow". Disable
// with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools.
const timingEnabled =
typeof window === "undefined"
? false
: (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false;
const t0 = performance.now();
let tFirstByte = 0;
let tCoord = 0;
let seatCount = 0;
const tlog = (label: string) => {
if (!timingEnabled) return;
const elapsed = (performance.now() - t0).toFixed(0);
console.debug(`[init-timing] +${elapsed}ms ${label}`);
};
tlog("fetch.start");
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agent_names: payload.agentNames,
main_agent_name: payload.mainAgentName,
model: payload.model,
}),
signal: payload.signal,
});
tlog(`fetch.headers status=${res.status}`);
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let mainAgentName = "";
let threadIds: ThreadIdMap | null = null;
let aborted = false;
let errorDetail: string | null = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (tFirstByte === 0) {
tFirstByte = performance.now();
tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`);
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data) continue;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
if (!parsed || typeof parsed !== "object") continue;
const obj = parsed as Record<string, unknown>;
const event = typeof obj.event === "string" ? obj.event : "";
switch (event) {
case "seat_ready": {
seatCount += 1;
tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`);
payload.onSeatReady?.({
agentName: String(obj.agent_name ?? ""),
threadId: String(obj.thread_id ?? ""),
displayName: String(obj.display_name ?? ""),
description: String(obj.description ?? ""),
});
break;
}
case "coordinator_ready": {
tCoord = performance.now();
tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`);
payload.onCoordinatorReady?.({
mainAgentName: String(obj.main_agent_name ?? ""),
threadId: String(obj.thread_id ?? ""),
});
break;
}
case "init_done": {
tlog(`init_done seats=${seatCount}`);
mainAgentName = String(obj.main_agent_name ?? "");
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
break;
}
case "error": {
errorDetail = String(obj.detail ?? "unknown init error");
break;
}
default:
// Unknown event — ignore for forward-compat.
break;
}
}
}
} catch (err) {
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
if (!aborted) throw err;
}
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
if (errorDetail) {
throw new Error(`init multi-agent failed: ${errorDetail}`);
}
if (aborted) {
throw new DOMException("init aborted", "AbortError");
}
if (!threadIds || !mainAgentName) {
throw new Error("init multi-agent stream ended without init_done");
}
return {
status: "success",
main_agent_name: mainAgentName,
thread_ids: threadIds,
};
}
export type StreamAgentType = "leader" | "special";
export type LeaderDispatch = [string, string];
/**
* High-level phases the SSE stream goes through. Used by the UI to show
* "what the backend is doing" while the user waits — most of the time before
* a first token is upstream/LLM latency, which feels like a stall otherwise.
*
* - `connecting` — fetch sent, no frames yet
* - `connected` — got the `event: metadata` frame (run_id assigned)
* - `streaming` — first AI text chunk arrived; tokens flowing
* - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
* present_files); usually means the run is about to interrupt
* - `tool_result` — a tool result message came back; brief, may go back to streaming
*/
export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
export interface StreamMultiAgentRequest {
agentType: StreamAgentType;
dAgentThreadId: ThreadIdMap;
agentName: string;
newMessage: string;
model?: string;
skillStopNames?: string[];
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
/**
* Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
* `text` is the incremental delta (concatenate to rebuild the message);
* `messageId` is stable per AI message so callers can group deltas.
*/
onTextDelta?: (text: string, messageId: string) => void;
/**
* Fires whenever the inferred SSE phase changes. The UI can use this to
* show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
*/
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
}
export interface ClarificationPayload {
/** Raw question text from the model's ask_clarification args. */
question: string;
/** Optional category hint from the model (e.g. "missing_info"). */
clarificationType: string;
/** Free-text reason the model needs clarification. */
context: string;
/** Suggested answer chips (may be empty). */
options: unknown[];
/** Whether the user is allowed to type a custom answer. */
allowCustom: boolean;
}
export interface StreamMultiAgentResult {
/** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
dispatched: LeaderDispatch[];
/** For special runs: the completion status string. */
message: string | null;
/** Final assistant text emitted by the underlying agent. */
content: string;
/** Echoed agent_name of the agent that produced this run. */
agentName: string | null;
/**
* Populated when the leader called ask_clarification — the run ended without
* dispatching, and the model is asking the user a question instead. When
* present, callers should render `content` (or `clarification.question` as
* a fallback) as a question and collect a user reply rather than treating
* an empty `dispatched` as consensus.
*/
clarification: ClarificationPayload | null;
}
/**
* Identify the final status frame our backend appends after the LangGraph
* stream. Required keys are `status` (array for leader, string for special)
* and `content` (final assistant text); `agent_name` is also expected.
*/
function isFinalStatusFrame(value: unknown): value is {
status: LeaderDispatch[] | string;
content?: string;
agent_name?: string;
question?: string;
clarification_type?: string;
clarification_context?: string;
options?: unknown[];
allow_custom?: boolean;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status;
if (typeof status === "string") return true;
if (Array.isArray(status)) {
return status.every(
(item) =>
Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
);
}
return false;
}
/**
* Extract an AI text delta from a LangGraph `messages-tuple` data frame.
* Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
* {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
* structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots).
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
// Accept any AI-shaped message chunk LangChain might emit across versions:
// "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
* stream completes. The final frame (`{status: ...}`) is interpreted; every
* other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
* its own rendering (we don't try to mimic LangGraph SDK semantics here).
*/
export async function streamMultiAgent(
req: StreamMultiAgentRequest,
): Promise<StreamMultiAgentResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agent_type: req.agentType,
d_agent_thread_id: req.dAgentThreadId,
agent_name: req.agentName,
new_message: req.newMessage,
model: req.model,
skill_stop_names: req.skillStopNames ?? [],
}),
signal: req.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// Track the last `event:` line so logging can show which event a `data:`
// belongs to. The frontend doesn't actually dispatch by event name — every
// data frame goes through the same parser — but the label is invaluable
// for diagnosing why a sub-agent stream "looks empty".
let lastEvent = "";
let frameSeq = 0;
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
const debugEnabled =
typeof window !== "undefined" &&
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
const result: StreamMultiAgentResult = {
dispatched: [],
message: null,
content: "",
agentName: null,
clarification: null,
};
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
// value actually changes (the reader loop sees many frames per phase).
let currentPhase: StreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: StreamPhase, name?: string) => {
if (next === currentPhase && !name) return;
currentPhase = next;
req.onPhaseChange?.(next, name);
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
// First metadata frame = run accepted by upstream. Move phase out
// of "connecting" so the UI can switch from "调度中" to "已连接".
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
continue;
}
frameSeq += 1;
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
// Phase inference from LangGraph messages-tuple frames. We only flip
// to tool_calling/tool_result on chunks that clearly indicate them;
// everything else with non-empty AI text means we're streaming.
if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
const chunk = parsed[0];
if (chunk && typeof chunk === "object") {
const chunkObj = chunk as Record<string, unknown>;
const chunkType = String(chunkObj.type ?? "").toLowerCase();
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const hasToolCall =
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
if (chunkType === "tool") {
setPhase("tool_result");
} else if (hasToolCall) {
// Pick the most informative name we can find in either field.
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
}
}
if (isFinalStatusFrame(parsed)) {
if (Array.isArray(parsed.status)) {
result.dispatched = parsed.status;
} else {
result.message = parsed.status;
// "clarification" status carries the leader's ask_clarification
// payload. Extract the structured fields so the page can render
// a question (or chip choices) instead of mistaking the run for
// a consensus.
if (parsed.status === "clarification") {
result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType:
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false,
};
}
}
if (typeof parsed.content === "string") {
result.content = parsed.content;
}
if (typeof parsed.agent_name === "string") {
result.agentName = parsed.agent_name;
}
continue;
}
const delta = extractAiTextDelta(parsed);
if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
req.onTextDelta?.(delta.text, delta.id);
}
req.onUpstreamFrame?.(parsed);
}
}
if (debugEnabled) {
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
}
return result;
}
export interface SessionArtifactFile {
/** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */
path: string;
/** Bare filename (basename of `path`). */
name: string;
sizeBytes: number;
/** MIME type guessed from the extension, or null when unknown. */
mimeType: string | null;
/** ISO 8601 UTC timestamp of last modification. */
modifiedAt: string;
}
export interface SessionArtifactsResponse {
threadId: string;
files: SessionArtifactFile[];
}
/**
* List every file written to `/mnt/user-data/outputs/` for a thread. Used by
* the Step 3 final-report view to surface a thread's produced artifacts so the
* user can preview/download them via {@link buildArtifactDownloadUrl}.
*
* Returns an empty `files` array when the thread produced no artifacts (the
* backend treats a missing outputs directory as empty, not 404).
*/
export async function listSessionArtifacts(
threadId: string,
options: { signal?: AbortSignal } = {},
): Promise<SessionArtifactsResponse> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
{ method: "GET", signal: options.signal },
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`list artifacts failed (${res.status}): ${text}`);
}
const json = (await res.json()) as {
thread_id: string;
files: Array<{
path: string;
name: string;
size_bytes: number;
mime_type: string | null;
modified_at: string;
}>;
};
return {
threadId: json.thread_id,
files: (json.files ?? []).map((f) => ({
path: f.path,
name: f.name,
sizeBytes: f.size_bytes,
mimeType: f.mime_type,
modifiedAt: f.modified_at,
})),
};
}
/**
* Build a URL that previews (inline) or downloads (attachment) an artifact
* file. The `path` must be a virtual path returned by
* {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`).
*/
export function buildArtifactDownloadUrl(
threadId: string,
virtualPath: string,
options: { download?: boolean } = {},
): string {
const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/");
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
return options.download ? `${base}?download=true` : base;
}
@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
/** The thread that owns the artifact. Both fetch and download URLs use it. */
threadId: string;
/** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */
path: string;
/** Bare filename — shown in the modal header. */
name: string;
/** Mime type from the listing; drives the renderer choice. */
mimeType: string | null;
onClose: () => void;
}
const MARKDOWN_COMPONENTS = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-3 my-2 overflow-x-auto text-[12px] font-mono">
<code>{children}</code>
</pre>
);
}
return <code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[12px] font-mono">{children}</code>;
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
hr: () => <hr className="my-3 border-slate-200" />,
};
function isMarkdown(mimeType: string | null, name: string): boolean {
if (mimeType === "text/markdown") return true;
return /\.(md|markdown)$/i.test(name);
}
export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
threadId,
path,
name,
mimeType,
onClose,
}) => {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Esc closes the modal. Attached once per mount.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Fetch the artifact body via the same URL the original download link used.
// The GET endpoint already returns text/* as decoded UTF-8, so res.text()
// gives us a renderable string directly.
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
setLoading(true);
setError(null);
setContent(null);
(async () => {
try {
const url = buildArtifactDownloadUrl(threadId, path);
const res = await apiFetch(url, { method: "GET", signal: ctrl.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const text = await res.text();
if (!cancelled) setContent(text);
} catch (err) {
if (cancelled || (err as Error).name === "AbortError") return;
setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
ctrl.abort();
};
}, [threadId, path]);
const handleDownload = useCallback(() => {
if (typeof window === "undefined") return;
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
window.open(url, "_blank", "noopener,noreferrer");
}, [threadId, path]);
const renderAsMarkdown = isMarkdown(mimeType, name);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-6"
onMouseDown={(e) => {
// Only close on outer-overlay click — clicks inside the modal panel
// bubble up here too, so check the target.
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 px-5 py-3.5 border-b border-slate-150 flex items-center justify-between gap-3 bg-gradient-to-r from-slate-50 to-white">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-primary shrink-0" />
<span className="text-sm font-bold text-slate-800 truncate" title={name}>{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={handleDownload}
className="bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 text-[11px] font-bold px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition"
title="下载原文件"
>
<Download className="w-3 h-3" />
下载
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 text-slate-500 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs">正在加载文件内容…</span>
</div>
) : error ? (
<div className="text-rose-600 text-xs py-6 text-center">
加载失败:{error}
</div>
) : content === null ? null : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
) : (
<pre className="whitespace-pre-wrap break-words text-xs text-slate-700 font-mono">{content}</pre>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,477 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
CheckCircle,
ChevronDown,
ChevronRight,
Download,
Eye,
FileText,
Loader2,
Printer,
RefreshCw,
} from "lucide-react";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
* images, html) keep the original window.open behaviour so the browser's
* native viewer handles them.
*/
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
return true;
}
if (mime.startsWith("text/")) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
}
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
sender: string;
/** Avatar color hint inherited from the dialogue card. Unused for layout right now. */
avatarType: string;
/** The seat's final markdown delivery from Step 2. */
content: string;
}
interface HighFidelityReportProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */
consensusText: string;
/** True once leader.dispatched === [] was observed (natural convergence). */
hasConsensus: boolean;
/** Each sub-agent's final delivery, filtered out of step2 dialogues. */
seatDeliveries: SeatDelivery[];
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
seatNameMap: Record<string, string>;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
}
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatGeneratedAt(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadIds,
coordinatorName,
consensusText,
hasConsensus,
seatDeliveries,
seatNameMap,
onReset,
}) => {
// Build the per-seat list (excludes the coordinator). useMemo so the file
// fetch effect doesn't re-fire on every parent render.
const seatThreadEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
// Map sender display name → its delivery content for the accordion rows.
// Built once per seatDeliveries change; later entries with the same sender
// win so the *latest* delivery wins.
const deliveryByDisplay = useMemo(() => {
const m = new Map<string, string>();
for (const d of seatDeliveries) m.set(d.sender, d.content);
return m;
}, [seatDeliveries]);
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
const [reloadKey, setReloadKey] = useState(0);
// Active preview target — non-null means the modal is open. We carry the
// owning threadId so the modal can fetch + build download URLs without
// needing a second prop drill.
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
// Fetch artifact listings for every seat thread in parallel. Re-runs when
// the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set
// changes (rare — only on a true re-init).
useEffect(() => {
if (seatThreadEntries.length === 0) {
setArtifactsBySeat([]);
return;
}
const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({
...s,
files: [],
loading: true,
error: null,
}));
setArtifactsBySeat(initial);
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatThreadEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setArtifactsBySeat(
results.map(({ seat, files, error }) => ({
...seat,
files,
loading: false,
error,
})),
);
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatThreadEntries, reloadKey]);
const totalFileCount = useMemo(
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
[artifactsBySeat],
);
const toggleSeat = useCallback((key: string) => {
setExpandedSeats((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const handlePreview = useCallback(
(threadId: string, file: SessionArtifactFile) => {
// Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
// in our in-app modal so the user can read the content without leaving
// the planning page. Binary or active content (pdf / png / html) still
// hands off to the browser's native viewer via the original tab.
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
const url = buildArtifactDownloadUrl(threadId, file.path);
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
<div className="flex flex-wrap items-center gap-2.5">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-emerald-500" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{hasConsensus && (
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm">
● 已达成共识
</span>
)}
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg">
生成时间:{generatedAt}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (typeof window !== "undefined") window.print();
}}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
>
<Printer className="w-3.5 h-3.5 text-slate-500" />
打印 / 另存 PDF
</button>
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
刷新文件清单
</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
总控最终结论
</h4>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
)}
</section>
{/* Section 2: Per-seat deliveries (collapsible) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{seatDeliveries.length}
</span>
</h4>
{seatDeliveries.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
</p>
) : (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const key = `${seat.sender}-${idx}`;
const expanded = expandedSeats.has(key);
return (
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggleSeat(key)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{expanded && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Section 3: Generated files per seat */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{totalFileCount}
</span>
</h4>
{artifactsBySeat.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">
读取失败:{seat.error}
</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
该席位未产生输出文件
</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li
key={f.path}
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">
{formatBytes(f.sizeBytes)}
</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</section>
{/* Footer */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
<span className="text-xs text-slate-400 font-medium">
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
</span>
<button
type="button"
onClick={onReset}
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
>
重新起草新规划
</button>
</div>
</div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</div>
);
};
@@ -0,0 +1,35 @@
import React from "react";
import { ChevronDown } from "lucide-react";
interface ScrollToBottomButtonProps {
onClick: () => void;
/** Label shown next to the icon. Defaults to "回到底部". */
label?: string;
/** Tailwind classes appended to the default positioning. */
className?: string;
}
/**
* Pill-shaped floating button shown when the user has scrolled up away from
* the bottom of a streamed conversation. Click → smooth-scroll back to the
* bottom and resume auto-follow.
*
* Positioning is `absolute` — render inside a `relative` wrapper so it
* floats over the scroll container's lower-center without scrolling along
* with content.
*/
export const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({
onClick,
label = "回到底部",
className = "",
}) => (
<button
type="button"
onClick={onClick}
className={`absolute left-1/2 -translate-x-1/2 bottom-3 z-10 bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-[11px] font-bold px-3 py-1.5 rounded-full shadow-lg shadow-slate-900/10 flex items-center gap-1 transition active:scale-95 ${className}`}
aria-label={label}
>
<ChevronDown className="w-3.5 h-3.5" />
{label}
</button>
);
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, useState, type DependencyList, type RefObject } from "react";
/**
* Auto-scroll a scrollable container while the user is at (or near) the
* bottom; stop following as soon as the user scrolls up, and resume when
* the user clicks the floating "回到底部" button (which calls `scrollToBottom`).
*
* Why a custom hook: the page has two streamed-message lists (Step 1 chat,
* Step 2 roundtable) with identical scroll-follow semantics, and reading
* `scrollHeight` after every state change inside the component would mean
* duplicating the scroll listener / auto-scroll / pause logic per list.
*/
export function useAutoScroll<T extends HTMLElement>(
ref: RefObject<T | null>,
deps: DependencyList,
options: {
/** How close to the bottom (px) still counts as "at bottom". */
threshold?: number;
} = {},
): {
/** True when the user is at the bottom — drives the button's visibility. */
atBottom: boolean;
/** Force-scroll to the bottom and resume auto-follow. */
scrollToBottom: () => void;
} {
const threshold = options.threshold ?? 32;
// Ref mirrors state so the deps-effect can read the latest value without
// re-subscribing when atBottom flips. State is for the button's render.
const atBottomRef = useRef(true);
const [atBottom, setAtBottom] = useState(true);
// Track scroll position. The handler runs for BOTH user-initiated scrolls
// and programmatic `scrollTo` calls — after a programmatic scroll to the
// bottom, the resulting onScroll naturally leaves atBottom=true, so the
// auto-follow effect can keep firing on subsequent deps changes.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
if (atBottomRef.current !== isAtBottom) {
atBottomRef.current = isAtBottom;
setAtBottom(isAtBottom);
}
};
el.addEventListener("scroll", onScroll, { passive: true });
// Initial measurement so we don't start in a stale state if the container
// mounted with content already overflowing.
onScroll();
return () => el.removeEventListener("scroll", onScroll);
}, [ref, threshold]);
// Auto-scroll on deps change — but only if the user hasn't scrolled away.
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!atBottomRef.current) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// Optimistically mark as at-bottom so the next deps change resumes
// auto-follow without waiting for the smooth-scroll's onScroll to land.
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
return { atBottom, scrollToBottom };
}
@@ -0,0 +1,2 @@
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
export { HighFidelityReport } from "./components/HighFidelityReport";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,217 @@
@import "tailwindcss";
/* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */
.roundtable-planning {
--rt-primary: var(--workspace-brand, #f5222d);
--rt-primary-hover: var(--brand-900, #cf1322);
--rt-primary-active: var(--brand-950, #a8071a);
--rt-primary-soft: var(--brand-200, #feeded);
--rt-page-bg: #fef6f6;
}
.roundtable-planning ::-webkit-scrollbar-thumb {
background: #fcb5b9;
}
.roundtable-planning ::-webkit-scrollbar-thumb:hover {
background: #fa9196;
}
.roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: #fddadc;
}
/* 深色模式:跟随全局 ThemeContext(html.dark)或组件 .roundtable-planning--dark */
.dark .roundtable-planning,
.roundtable-planning--dark {
--rt-page-bg: var(--background);
--rt-primary: var(--primary);
--rt-primary-hover: var(--primary);
--rt-primary-soft: var(--accent);
color: var(--foreground);
background-color: var(--background);
}
.dark .roundtable-planning ::-webkit-scrollbar-thumb,
.roundtable-planning--dark ::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 45%, var(--border));
}
.dark .roundtable-planning ::-webkit-scrollbar-thumb:hover,
.roundtable-planning--dark ::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--primary) 65%, var(--border));
}
.dark .roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb,
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 35%, transparent);
}
/* 页面底与浅色专用背景 */
.dark .roundtable-planning.bg-\[\#fef6f6\],
.roundtable-planning--dark.bg-\[\#fef6f6\] {
background-color: var(--background) !important;
}
/* 表面:白底卡片 → card */
.dark .roundtable-planning .bg-white,
.roundtable-planning--dark .bg-white {
background-color: var(--card) !important;
}
.dark .roundtable-planning .bg-slate-50,
.dark .roundtable-planning .bg-slate-50\/20,
.dark .roundtable-planning .bg-slate-50\/30,
.roundtable-planning--dark .bg-slate-50,
.roundtable-planning--dark .bg-slate-50\/20,
.roundtable-planning--dark .bg-slate-50\/30 {
background-color: color-mix(in oklch, var(--muted) 80%, transparent) !important;
}
.dark .roundtable-planning .from-slate-50,
.roundtable-planning--dark .from-slate-50 {
--tw-gradient-from: var(--muted) !important;
}
.dark .roundtable-planning .to-white,
.roundtable-planning--dark .to-white {
--tw-gradient-to: var(--card) !important;
}
/* 品牌浅红底 → accent */
.dark .roundtable-planning [class*="brand-200"],
.roundtable-planning--dark [class*="brand-200"] {
background-color: color-mix(in oklch, var(--accent) 55%, transparent) !important;
}
.dark .roundtable-planning .border-red-100,
.dark .roundtable-planning .border-red-100\/50,
.dark .roundtable-planning .border-red-200,
.dark .roundtable-planning .border-red-200\/50,
.dark .roundtable-planning .border-red-200\/85,
.roundtable-planning--dark .border-red-100,
.roundtable-planning--dark .border-red-100\/50,
.roundtable-planning--dark .border-red-200,
.roundtable-planning--dark .border-red-200\/50,
.roundtable-planning--dark .border-red-200\/85 {
border-color: var(--border) !important;
}
.dark .roundtable-planning .border-slate-100,
.dark .roundtable-planning .border-slate-200,
.dark .roundtable-planning .border-slate-200\/50,
.dark .roundtable-planning .border-\[\#e2e8f0\],
.roundtable-planning--dark .border-slate-100,
.roundtable-planning--dark .border-slate-200,
.roundtable-planning--dark .border-slate-200\/50,
.roundtable-planning--dark .border-\[\#e2e8f0\] {
border-color: var(--border) !important;
}
/* 文字 */
.dark .roundtable-planning .text-slate-900,
.dark .roundtable-planning .text-slate-800,
.dark .roundtable-planning .text-slate-700,
.dark .roundtable-planning .text-\[\#334155\],
.roundtable-planning--dark .text-slate-900,
.roundtable-planning--dark .text-slate-800,
.roundtable-planning--dark .text-slate-700,
.roundtable-planning--dark .text-\[\#334155\] {
color: var(--foreground) !important;
}
.dark .roundtable-planning .text-slate-600,
.dark .roundtable-planning .text-slate-500,
.dark .roundtable-planning .text-slate-400,
.roundtable-planning--dark .text-slate-600,
.roundtable-planning--dark .text-slate-500,
.roundtable-planning--dark .text-slate-400 {
color: var(--muted-foreground) !important;
}
.dark .roundtable-planning .hover\:text-slate-800:hover,
.roundtable-planning--dark .hover\:text-slate-800:hover {
color: var(--foreground) !important;
}
.dark .roundtable-planning .hover\:bg-slate-50:hover,
.roundtable-planning--dark .hover\:bg-slate-50:hover {
background-color: var(--accent) !important;
}
/* 顶栏 */
.dark .roundtable-planning > header,
.roundtable-planning--dark > header {
background-color: var(--card) !important;
border-color: var(--border) !important;
box-shadow: none !important;
}
/* 步骤条容器 */
.dark .roundtable-planning header .rounded-2xl.border,
.roundtable-planning--dark header .rounded-2xl.border {
background-color: var(--muted) !important;
border-color: var(--border) !important;
}
/* 次要按钮(白底) */
.dark .roundtable-planning header button.bg-white,
.roundtable-planning--dark header button.bg-white {
background-color: var(--secondary) !important;
color: var(--foreground) !important;
}
/* 输入区 focus ring */
.dark .roundtable-planning .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within,
.roundtable-planning--dark .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within {
--tw-ring-color: color-mix(in oklch, var(--primary) 25%, transparent) !important;
}
/* 渐变横幅 */
.dark .roundtable-planning .from-\[var\(--brand-200\)\],
.roundtable-planning--dark .from-\[var\(--brand-200\)\] {
--tw-gradient-from: color-mix(in oklch, var(--accent) 40%, transparent) !important;
}
.dark .roundtable-planning .border-primary,
.roundtable-planning--dark .border-2.border-primary {
border-color: color-mix(in oklch, var(--primary) 50%, var(--border)) !important;
}
/* 分隔线 */
.dark .roundtable-planning .bg-slate-200,
.roundtable-planning--dark .bg-slate-200 {
background-color: var(--border) !important;
}
/* Custom elegant scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e2e8f0;
}
.custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: #fddadc;
}
.dark .custom-scrollbar-inner::-webkit-scrollbar-thumb,
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 35%, var(--border));
}
@@ -0,0 +1,195 @@
/**
* Local-only draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
* it; cross-device sync is out of scope (would need a backend). The Step 2
* back-end LangGraph threads are reused on load, so list/preview/download of
* artifacts in Step 3 continue to work as long as the user hasn't deleted the
* threads server-side.
*/
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
const STORAGE_KEY = "roundtable-planning:drafts:v1";
/**
* Snapshot of every piece of UI state needed to resume a session. We capture
* shapes loosely (`unknown[]`) for the chat / role lists because their inline
* types in the page component are wide and would force a refactor we don't
* want as part of the persistence feature.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
}
export interface DraftRecord {
id: string;
/** Auto-derived task summary (objective → first user message → timestamp). */
name: string;
createdAt: string;
updatedAt: string;
/** Furthest step the user had reached when this snapshot was taken. */
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
}
function isBrowser(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readAll(): DraftRecord[] {
if (!isBrowser()) return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as DraftRecord[];
} catch (err) {
console.warn("[drafts] failed to parse localStorage payload:", err);
return [];
}
}
function writeAll(records: DraftRecord[]): void {
if (!isBrowser()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
} catch (err) {
// Quota errors are the realistic failure mode — surface but don't throw,
// since the user can keep working with their in-memory state.
console.warn("[drafts] failed to write localStorage payload:", err);
}
}
function newId(): string {
if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** List drafts newest first. */
export function listDrafts(): DraftRecord[] {
return readAll()
.slice()
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
}
export function getDraft(id: string): DraftRecord | null {
return readAll().find((d) => d.id === id) ?? null;
}
/**
* Create-or-update a draft. If `id` is given and exists, that record is
* overwritten (updatedAt refreshed). Otherwise a new record is inserted.
* Returns the (re-)saved record so callers can read back the new id.
*/
export function upsertDraft(
payload: Omit<DraftRecord, "createdAt" | "updatedAt" | "id"> & { id?: string },
): DraftRecord {
const records = readAll();
const now = new Date().toISOString();
if (payload.id) {
const idx = records.findIndex((d) => d.id === payload.id);
if (idx >= 0) {
const existing = records[idx];
const updated: DraftRecord = {
...existing,
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
updatedAt: now,
};
records[idx] = updated;
writeAll(records);
return updated;
}
}
const created: DraftRecord = {
id: payload.id ?? newId(),
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
createdAt: now,
updatedAt: now,
};
records.push(created);
writeAll(records);
return created;
}
export function deleteDraft(id: string): void {
const records = readAll();
const next = records.filter((d) => d.id !== id);
if (next.length !== records.length) writeAll(next);
}
/**
* Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
* updated record so callers can refresh local UI state.
*/
export function renameDraft(id: string, name: string): DraftRecord | null {
const trimmed = name.trim();
if (!trimmed) return null;
const records = readAll();
const idx = records.findIndex((d) => d.id === id);
if (idx < 0) return null;
const updated: DraftRecord = {
...records[idx],
name: trimmed,
updatedAt: new Date().toISOString(),
};
records[idx] = updated;
writeAll(records);
return updated;
}
/**
* Derive a short task-summary name from whatever the user has supplied so far.
* Preference order: intent agent's structured objective → first user message
* in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
* the dropdown rows stay scannable.
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}
+119 -69
View File
@@ -1,12 +1,24 @@
import React, { useState } from 'react'; import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react'; import { useState } from "react";
import { NotificationDialog } from '@/components/workspace/notification-dialog';
import { useTheme } from '../contexts/ThemeContext';
import { useUnreadCount } from '@/core/notifications';
const Header: React.FC = () => { import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { Button } from "@/components/ui/button";
import { useUnreadCount } from "@/core/notifications";
import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
const iconButtonClass = (isDark: boolean) =>
cn(
"size-8 shrink-0 rounded-sm",
isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
const Header = () => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark'; const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false); const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount(); const { data: unreadCount = 0 } = useUnreadCount();
@@ -16,72 +28,110 @@ const Header: React.FC = () => {
open={notificationOpen} open={notificationOpen}
onOpenChange={setNotificationOpen} onOpenChange={setNotificationOpen}
/> />
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${ <header
isDark className={cn(
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30' "theme-transition flex h-16 items-center justify-between border-b px-4",
: 'bg-red-600 border-b border-red-700' isDark
}`}> ? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
{/* 左侧 - 搜索框 */} : "border-[var(--brand-950)] bg-[var(--brand-900)]",
<div className="flex items-center gap-4"> )}
>
<div className="flex min-w-0 flex-1 items-center gap-4" />
</div> <div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggleTheme}
className={iconButtonClass(isDark)}
title={isDark ? "切换到红白主题" : "切换到深色主题"}
>
{isDark ? (
<Palette className="size-4" />
) : (
<Moon className="size-4" />
)}
</Button>
{/* 右侧 - 用户信息和通知 */} <Button
<div className="flex items-center gap-4"> type="button"
{/* 主题切换按钮 */} variant="ghost"
<button size="icon-sm"
onClick={toggleTheme} onClick={() => setNotificationOpen(true)}
className={`p-2 rounded-lg transition-all duration-200 ${ className={cn(iconButtonClass(isDark), "relative")}
isDark title="通知"
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white' >
: 'hover:bg-red-500 text-white' <Bell className="size-4" />
}`} {unreadCount > 0 && (
title={isDark ? '切换到红白主题' : '切换到深色主题'} <span
> className={cn(
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />} "absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
</button> isDark
? "bg-cyan-500 text-white"
: "bg-white tabular-nums text-[var(--brand-900)]",
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
{/* 通知 */} <div
<button className={cn(
type="button" "mx-1 h-8 w-px",
onClick={() => setNotificationOpen(true)} isDark ? "bg-blue-800/30" : "bg-white/30",
className={`relative p-2 rounded-lg transition-colors ${ )}
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500' aria-hidden
}`} />
title="通知"
> <Button
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} /> type="button"
{unreadCount > 0 && ( variant="ghost"
<span className={cn(
className={`absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${ "h-8 gap-2 rounded-sm px-2",
isDark ? 'bg-cyan-500' : 'bg-white text-red-600' isDark
}`} ? "text-slate-200 hover:bg-slate-800/50"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-sm",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white",
)}
> >
{unreadCount > 99 ? '99+' : unreadCount} <User
</span> className={cn(
)} "size-4",
</button> isDark ? "text-white" : "text-[var(--brand-900)]",
)}
{/* 分隔线 */} />
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} /> </div>
<div className="hidden text-left sm:block">
{/* 用户信息 */} <p className="text-sm leading-tight font-medium text-white">
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${ 张策略
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500' </p>
}`}> <p
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${ className={cn(
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white' "text-xs leading-tight",
}`}> isDark ? "text-blue-400/60" : "text-white/75",
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} /> )}
</div> >
<div className="text-left"> 策略分析师
<p className="text-sm font-medium text-white">张策略</p> </p>
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>策略分析师</p> </div>
</div> <ChevronDown
<ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} /> className={cn(
</button> "size-4 shrink-0",
</div> isDark ? "text-slate-500" : "text-white/80",
</header> )}
/>
</Button>
</div>
</header>
</> </>
); );
}; };
+9 -1
View File
@@ -240,8 +240,11 @@
} }
:root { :root {
--workspace-brand: #d92121; --workspace-brand: #f5222d;
--workspace-brand-foreground: #ffffff; --workspace-brand-foreground: #ffffff;
--brand-200: #feeded;
--brand-900: #cf1322;
--brand-950: #a8071a;
--radius: 0.75rem; --radius: 0.75rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.22 0.02 25); --foreground: oklch(0.22 0.02 25);
@@ -811,3 +814,8 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
* {
border-color: var(--border);
outline-color: var(--ring);
}
+2 -1
View File
@@ -3,11 +3,12 @@ import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url)); const rootDir = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [sourceIdentifierPlugin(), react()],
resolve: { resolve: {
alias: [ alias: [
{ {
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more