Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
a800baf8ae
107 files changed
+13645
-16203
No files matched your search
@@ -10,6 +10,8 @@ frontend-web/.vscode/
|
||||
|
||||
# Frontend local/runtime artifacts
|
||||
frontend-web/.env
|
||||
frontend-web/.env.local
|
||||
frontend-web/.env.*.local
|
||||
frontend-web/node_modules/
|
||||
frontend-web/.pnpm-store/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
# 本地开发(pnpm dev)— 与原先 .env 保持一致
|
||||
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -0,0 +1,7 @@
|
||||
# 复制为 .env.development.local,仅本机生效,不提交 Git
|
||||
# 直连本机 Gateway(需后端 CORS_ORIGINS 包含 http://127.0.0.1:5173 等)
|
||||
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -1,9 +1,30 @@
|
||||
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
|
||||
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
|
||||
#
|
||||
# ┌─────────────────┬──────────────────────────────────────────────────┐
|
||||
# │ 命令 │ 生效文件 │
|
||||
# ├─────────────────┼──────────────────────────────────────────────────┤
|
||||
# │ pnpm dev │ .env.development + .env.development.local │
|
||||
# │ pnpm build │ .env.production + .env.production.local │
|
||||
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
|
||||
# └─────────────────┴──────────────────────────────────────────────────┘
|
||||
#
|
||||
# 推荐:
|
||||
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
|
||||
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
|
||||
# - 内网打包部署:改 .env.production 后再执行 pnpm build
|
||||
#
|
||||
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
|
||||
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
# --- 开发环境(见 .env.development)---
|
||||
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离)
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
# 可选
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
# VITE_GITHUB_OAUTH_TOKEN=
|
||||
@@ -0,0 +1,12 @@
|
||||
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
|
||||
# 打包后变量会写入 dist,运行时不可再改
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env.production.local,打包前临时覆盖生产地址(不提交 Git)
|
||||
|
||||
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
|
||||
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
|
||||
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
@@ -1,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)
|
||||
@@ -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 的双进程配置。
|
||||
@@ -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
Whitespace-only changes.
@@ -1,140 +0,0 @@
|
||||
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
当前 `frontend-web/src/App.tsx` 同时承载了:
|
||||
|
||||
1. 登录/首页相关路由(如 `/login`、`/landing`)
|
||||
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
|
||||
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
|
||||
|
||||
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标(你提出的核心诉求)
|
||||
|
||||
你希望:
|
||||
|
||||
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
|
||||
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
|
||||
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
|
||||
|
||||
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构范围
|
||||
|
||||
### 1)保留在 `App.tsx` 的内容
|
||||
|
||||
- 全局 Provider:`HashRouter`、`AppProviders`
|
||||
- 登录/首页路由:
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/login/:username`
|
||||
- `/landing`
|
||||
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
|
||||
|
||||
### 2)迁移出 `App.tsx` 的内容
|
||||
|
||||
将以下与工作台有关的路由从 `App.tsx` 抽离:
|
||||
|
||||
- `/workspace`(重定向)
|
||||
- `/workspace/chats`
|
||||
- `/workspace/chats/:thread_id`
|
||||
- `/workspace/agents`
|
||||
- `/workspace/agents/new`
|
||||
- `/workspace/agents/:agent_id/chats`(重定向)
|
||||
- `/workspace/agents/:agent_id/chats/:thread_id`
|
||||
- `/workspace/scheduled`
|
||||
- `/workspace/scheduled/tasks`
|
||||
- `/workspace/scheduled/runs/:run_id`
|
||||
|
||||
以及配套的包装组件职责:
|
||||
|
||||
- `WorkspaceLayout`
|
||||
- `ChatRuntime`
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的新结构(方案)
|
||||
|
||||
## 方案 A(推荐):新增“业务路由容器组件”
|
||||
|
||||
在 `pages` 下新增一个路由容器组件,例如:
|
||||
|
||||
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
|
||||
|
||||
职责:
|
||||
- 专门维护 `/workspace/*` 相关路由
|
||||
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
|
||||
- 输出一个可被 `App.tsx` 挂载的路由节点
|
||||
|
||||
同时在 `App.tsx` 里只保留:
|
||||
- 登录/首页/404
|
||||
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
1. **路由容器页(推荐)**:
|
||||
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
|
||||
- 即上面的 `WorkspaceRoutes.tsx`。
|
||||
|
||||
2. **可见的导航页(可选)**:
|
||||
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
|
||||
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
|
||||
|
||||
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施步骤(开发任务拆解)
|
||||
|
||||
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
|
||||
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
|
||||
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
|
||||
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
|
||||
5. 校验以下访问路径行为一致:
|
||||
- `/workspace` 重定向
|
||||
- chats/agents/scheduled 各页面可正常进入
|
||||
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
|
||||
- 404 不受影响
|
||||
6. 运行前端并做手工回归。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
|
||||
2. `/workspace/*` 全部由新建组件统一管理。
|
||||
3. 功能行为与改造前一致(或符合新定义)。
|
||||
4. 代码可读性提升:登录入口与业务入口职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
|
||||
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
|
||||
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
|
||||
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议的命名(可直接采用)
|
||||
|
||||
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
|
||||
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 十、后续可选优化
|
||||
|
||||
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
|
||||
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
|
||||
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
|
||||
@@ -0,0 +1,265 @@
|
||||
# 多智能体协调 API 接口文档
|
||||
|
||||
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
|
||||
路由前缀:`/api/multi-agent`
|
||||
|
||||
## 概览
|
||||
|
||||
提供一个"总控 + 多个子智能体"的协调编排接口。
|
||||
|
||||
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
|
||||
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
|
||||
|
||||
调用方按下面顺序使用:
|
||||
|
||||
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
|
||||
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
|
||||
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
|
||||
|
||||
|
||||
## 1. 初始化多智能体集群
|
||||
|
||||
### `POST /api/multi-agent/init`
|
||||
|
||||
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
|
||||
"main_agent_name": "main-controller",
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
|
||||
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
|
||||
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
|
||||
|
||||
#### 响应(200)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"main_agent_name": "main-controller",
|
||||
"thread_ids": {
|
||||
"main-controller": "thread-uuid-xxx",
|
||||
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
|
||||
"us-intelligence-gathering-agent": "thread-uuid-zzz"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
|
||||
|
||||
#### 错误
|
||||
|
||||
| 状态码 | 触发条件 |
|
||||
|---|---|
|
||||
| `400` | `agent_names` 为空 |
|
||||
| `401` | 未携带 `Authorization` 头 |
|
||||
| `500` | 协调智能体创建失败且不是"已存在"错误 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 流式调度
|
||||
|
||||
### `POST /api/multi-agent/run/stream`
|
||||
|
||||
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_type": "leader",
|
||||
"d_agent_thread_id": {
|
||||
"main-controller": "thread-uuid-xxx",
|
||||
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
|
||||
},
|
||||
"agent_name": "main-controller",
|
||||
"new_message": "请分析特朗普的支持率",
|
||||
"skill_stop_names": [],
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
|
||||
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
|
||||
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
|
||||
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
|
||||
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
|
||||
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
|
||||
|
||||
#### 响应
|
||||
|
||||
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
|
||||
|
||||
##### a. 透传上游事件
|
||||
|
||||
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
|
||||
|
||||
##### b. 最后一帧:调度状态
|
||||
|
||||
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
|
||||
|
||||
**`agent_type == "leader"`:**
|
||||
|
||||
```
|
||||
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
|
||||
```
|
||||
|
||||
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
|
||||
|
||||
**`agent_type == "special"`:**
|
||||
|
||||
```
|
||||
data: {"status": "子智能体xxx完成yyy工作"}
|
||||
```
|
||||
|
||||
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
|
||||
|
||||
#### 副作用(重要)
|
||||
|
||||
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
|
||||
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
|
||||
|
||||
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
|
||||
|
||||
#### 错误
|
||||
|
||||
| 状态码 | 触发条件 |
|
||||
|---|---|
|
||||
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
|
||||
| `401` | 未携带 `Authorization` 头 |
|
||||
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端调用流程示例
|
||||
|
||||
```ts
|
||||
// 1. 拿 token
|
||||
const loginResp = await fetch("/api/v1/auth/login/username", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "yourname" }),
|
||||
});
|
||||
const { access_token } = await loginResp.json();
|
||||
const authHeaders = {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// 2. 初始化
|
||||
const initResp = await fetch("/api/multi-agent/init", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_names: [
|
||||
"taiwan-intelligence-gathering-agent",
|
||||
"us-intelligence-gathering-agent",
|
||||
],
|
||||
main_agent_name: "main-controller",
|
||||
model: "deepseek-chat",
|
||||
}),
|
||||
});
|
||||
const { thread_ids } = await initResp.json();
|
||||
|
||||
// 3. 流式调用 leader
|
||||
async function streamLeader(message: string, model = "deepseek-chat") {
|
||||
const resp = await fetch("/api/multi-agent/run/stream", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_type: "leader",
|
||||
d_agent_thread_id: thread_ids,
|
||||
agent_name: "main-controller",
|
||||
new_message: message,
|
||||
skill_stop_names: [],
|
||||
model,
|
||||
}),
|
||||
});
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let dispatched: [string, string][] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
try {
|
||||
const payload = JSON.parse(line.slice(5).trim());
|
||||
if (Array.isArray(payload.status)) {
|
||||
dispatched = payload.status;
|
||||
}
|
||||
// 其余事件按 LangGraph SDK 协议自行渲染
|
||||
} catch {
|
||||
/* 非 JSON 帧,可忽略或交给现有渲染器 */
|
||||
}
|
||||
}
|
||||
}
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
// 4. 对派活清单逐个调用 special
|
||||
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
|
||||
return fetch("/api/multi-agent/run/stream", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_type: "special",
|
||||
d_agent_thread_id: thread_ids,
|
||||
agent_name: agentName,
|
||||
new_message: task,
|
||||
skill_stop_names: [],
|
||||
model,
|
||||
}),
|
||||
});
|
||||
// 同样按 SSE 解析
|
||||
}
|
||||
|
||||
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
|
||||
const dispatched = await streamLeader("请分析特朗普的支持率");
|
||||
for (const [agentName, task] of dispatched) {
|
||||
await streamSpecial(agentName, task);
|
||||
}
|
||||
const next = await streamLeader("根据上面收集到的信息出最终结论");
|
||||
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 注意事项
|
||||
|
||||
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
|
||||
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
|
||||
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
|
||||
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
|
||||
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
|
||||
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
|
||||
|
||||
---
|
||||
|
||||
## 5. 相关接口(底层依赖)
|
||||
|
||||
本接口内部转发以下 Gateway 原生接口,了解细节可查:
|
||||
|
||||
| 接口 | 路由 | 用途 |
|
||||
|---|---|---|
|
||||
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
|
||||
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
|
||||
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
|
||||
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
|
||||
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
|
||||
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
|
||||
@@ -0,0 +1,437 @@
|
||||
# 多智能体圆桌研讨 · 后端开发文档
|
||||
|
||||
本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`、`multi-agent-step1-api.md`、`multi-agent-step3-api.md`;推荐弹窗见 `recommend-agents-progress.md`;前端实现见 `multi-agent-frontend-dev.md`。本文关注**实现细节、协议帧、设计取舍**。
|
||||
|
||||
源码内另有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py` 及 `clarification_utils.py`),与本文互为补充。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定位与三步数据流
|
||||
|
||||
对应前端页面:`/page/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。
|
||||
|
||||
```
|
||||
Step 1 任务意图理解
|
||||
POST /api/intent/init → 建 thread,绑定内置 roundtable-intent
|
||||
POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary
|
||||
|
||||
↓ intentReady { objective, constraints, assumptions }
|
||||
|
||||
推荐弹窗(Step 1 → Step 2 之间,可选)
|
||||
POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks
|
||||
|
||||
↓ selectedAgents[]
|
||||
|
||||
Step 2 多智能体圆桌研讨
|
||||
POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(席位列表由前端传入)
|
||||
POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清
|
||||
|
||||
↓ lastLeaderContent、各席位交付、hasConsensus
|
||||
|
||||
Step 3 最终方案展示
|
||||
GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件
|
||||
GET /api/threads/{tid}/artifacts/{path} → 预览或下载
|
||||
(总控结论、席位文本主要来自 Step 2 前端内存,一般不再调 run/stream)
|
||||
```
|
||||
|
||||
| 步骤 | 前端 `currentStep` | 专属 Router | 内置 Agent |
|
||||
|------|-------------------|-------------|------------|
|
||||
| 1 | 1 | `intent.py` | `roundtable-intent`(固定 id,复用) |
|
||||
| 1→2 | 推荐弹窗 | `recommend.py` | `roundtable-recommender`(每请求新建 thread) |
|
||||
| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 |
|
||||
| 3 | 3 | `artifacts.py`(通用) | 无新 agent |
|
||||
|
||||
所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:单次请求卡住 40s+)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 涉及的后端文件
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── app/gateway/
|
||||
│ ├── app.py # include_router: multi_agent, intent, recommend
|
||||
│ └── routers/
|
||||
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream
|
||||
│ ├── intent.py # Step 1:init + stream
|
||||
│ ├── recommend.py # Step 1→2:推荐 stream
|
||||
│ └── artifacts.py # Step 3:列出/下载 thread 产出
|
||||
└── .deer-flow/agents/
|
||||
├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill)
|
||||
├── roundtable-recommender/ # 推荐智能体
|
||||
├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换)
|
||||
├── roundtable-environment/
|
||||
├── roundtable-solution-design/
|
||||
├── roundtable-risk-review/
|
||||
├── roundtable-execution-plan/
|
||||
└── roundtable-summary/
|
||||
```
|
||||
|
||||
**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。
|
||||
|
||||
**共享工具逻辑**:`packages/harness/deerflow/tools/builtins/clarification_utils.py` 中的 `resolve_allow_multiple()` 被 `intent.py`、`multi_agent.py` 与 `ClarificationMiddleware` 共用,决定选项卡片默认单选还是多选。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口总览
|
||||
|
||||
| 方法 | 路径 | 步骤 | 用途 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/intent/init` | 1 | 创建意图澄清 thread |
|
||||
| POST | `/api/intent/stream` | 1 | SSE,一轮用户对话 |
|
||||
| POST | `/api/recommend/stream` | 1→2 | SSE,一次性推荐席位 |
|
||||
| POST | `/api/multi-agent/init` | 2 | SSE,并行拉起席位 + 协调智能体 |
|
||||
| POST | `/api/multi-agent/run/stream` | 2 | SSE,leader 或 special 一轮 |
|
||||
| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 |
|
||||
| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出 |
|
||||
|
||||
字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。
|
||||
|
||||
---
|
||||
|
||||
## 4. Step 1:任务理解(`intent.py`)
|
||||
|
||||
### 4.1 定位
|
||||
|
||||
轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。
|
||||
|
||||
### 4.2 `POST /api/intent/init`
|
||||
|
||||
1. `_create_thread()` → LangGraph thread
|
||||
2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent)
|
||||
|
||||
与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。
|
||||
|
||||
### 4.3 `POST /api/intent/stream`
|
||||
|
||||
**上游**:`_run_payload(agent_name=roundtable-intent, excluded_tools=[...])`,硬排除 `agent_orchestration`、`write_file`、`web_search` 等,**仅保留** `ask_clarification` 等澄清能力,防止模型写方案文档。
|
||||
|
||||
**流式**:透传 LangGraph SSE → 解析 `_parse_last_messages(captured)`。
|
||||
|
||||
**终态帧判定顺序(重要)**:
|
||||
|
||||
1. **先**扫描全部 AI 消息中的 `[INTENT_READY]\n\`\`\`json {...}\`\`\``(正则 `_INTENT_READY_PATTERN`)
|
||||
- 命中 → `status: "done"`,`summary` 为解析后的 JSON,`content` 为含标记的整段文本
|
||||
- **优先于** `ask_clarification`,避免「已汇总仍被当成待澄清」
|
||||
|
||||
2. 否则查最后一则带 `tool_calls` 的 AIMessage 是否含 `ask_clarification`
|
||||
- 命中 → `status: "clarification"`,附带结构化字段供前端选项卡片:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "clarification",
|
||||
"content": "<引导语/前言>",
|
||||
"question": "...",
|
||||
"clarification_type": "missing_info",
|
||||
"clarification_context": "...",
|
||||
"options": ["..."],
|
||||
"allow_custom": true,
|
||||
"allow_multiple": false,
|
||||
"summary": null
|
||||
}
|
||||
```
|
||||
|
||||
`allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定:**默认单选**(点击即发送);仅模型显式传 `allow_multiple: true` 时为多选。
|
||||
|
||||
3. 否则 → `status: "asking"`(继续对话,无结构化澄清)
|
||||
|
||||
**`summary` 形状**(`done` 时):
|
||||
|
||||
```json
|
||||
{
|
||||
"objective": "一句话目标",
|
||||
"constraints": ["硬约束 1", "..."],
|
||||
"assumptions": ["可选假设"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 内置 Agent:`roundtable-intent`
|
||||
|
||||
- 配置:`.deer-flow/agents/roundtable-intent/config.yaml` + `SOUL.md`
|
||||
- SOUL 内写清:工作流、`ask_clarification` 用法、`[INTENT_READY]` 格式、禁止派活/写文件
|
||||
- **可不配置 `skills`**;若配置 Skill,SOUL 要求不得偏离澄清与结束协议
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐智能体(`recommend.py`,Step 1 → 2)
|
||||
|
||||
### 5.1 定位
|
||||
|
||||
无状态单次调用:每次请求内部 `_create_thread()`,不暴露 `/init`。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(排除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。
|
||||
|
||||
### 5.2 `POST /api/recommend/stream`
|
||||
|
||||
**入参校验**:
|
||||
|
||||
- `intent.objective` 必填
|
||||
- `candidates` 非空,且至少一个有效 `agent_id`
|
||||
|
||||
**Prompt 拼装**(`_build_prompt`):把 objective/constraints/assumptions 与候选池格式化为一条 user message。
|
||||
|
||||
**上游**:`roundtable-recommender`,`excluded_tools` 含 `ask_clarification`、`agent_orchestration` 等(纯判断,不调工具)。
|
||||
|
||||
**终态**:
|
||||
|
||||
- 解析 `[RECOMMEND_READY]\n\`\`\`json {"picks":[{"agent_id","reason"},...]}\`\`\``
|
||||
- `_validate_picks()`:只保留候选池内的 id、去重、数量 clamp 到 2–6
|
||||
- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值
|
||||
- 否则 → `status: "asking"`(前端回退默认席位列表)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "done",
|
||||
"content": "<自然语言推荐理由>",
|
||||
"picks": [{"agent_id": "roundtable-risk-review", "reason": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤,避免污染推荐理由区。
|
||||
|
||||
---
|
||||
|
||||
## 6. Step 2:多智能体研讨(`multi_agent.py`)
|
||||
|
||||
### 6.1 `/init` 内部流程(SSE,非一次性 JSON)
|
||||
|
||||
当前实现为 **流式 init**,便于前端按席位点亮 UI:
|
||||
|
||||
```
|
||||
asyncio.as_completed(各席位 _prepare_sub_agent)
|
||||
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
|
||||
|
||||
协调智能体:
|
||||
POST /api/threads → coord_thread
|
||||
POST /api/agents(skills=[agent_orchestration],SOUL 动态拼入可调度列表)
|
||||
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
|
||||
|
||||
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
|
||||
失败 → data: {"event":"error","detail":"..."}
|
||||
```
|
||||
|
||||
**席位列表**:由前端 `agent_names` 传入(推荐弹窗确认后的 2–6 个 id),不再写死 6 个。
|
||||
|
||||
**协调智能体 SOUL**(动态拼接)包含:只负责协调汇总、通过 `agent_orchestration` 派活、`agent_name` 必须用英文 id(不要用中文显示名)。
|
||||
|
||||
### 6.2 `/run/stream` — `agent_type = "leader"`
|
||||
|
||||
```
|
||||
1. (可选)pre_broadcast:总控收到用户消息 → 广播到非自己的 thread
|
||||
2. POST /api/threads/{coordinator}/runs/stream
|
||||
- excluded_tools: ["web_search"]
|
||||
3. 透传 LangGraph 事件
|
||||
4. 解析 messages → tool_calls
|
||||
|
||||
分支 A — ask_clarification:
|
||||
status: "clarification"
|
||||
+ question / options / allow_multiple / clarification_type / clarification_context
|
||||
(空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环)
|
||||
|
||||
分支 B — 有 agent_orchestration 派活:
|
||||
并发 _broadcast 派活消息到相关 thread
|
||||
status: [["sub_agent_id", "task_text"], ...]
|
||||
|
||||
分支 C — 无 tool_calls:
|
||||
status: [] // 共识达成,content 为总控最终文本
|
||||
```
|
||||
|
||||
### 6.3 `/run/stream` — `agent_type = "special"`
|
||||
|
||||
```
|
||||
1. POST /api/threads/{sub}/runs/stream
|
||||
- excluded_tools: web_search, ask_clarification, present_files, view_image, agent_orchestration
|
||||
2. 透传 + 解析最后 AI 文本
|
||||
3. post_broadcast:子智能体完成交付 → 广播到非自己的 thread
|
||||
4. status: "<完成提示字符串>" 或业务自定义;content 为交付正文;agent_name 为子 agent id
|
||||
```
|
||||
|
||||
### 6.4 `_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]
|
||||
# 对每个 target: GET state → append human message → POST state
|
||||
```
|
||||
|
||||
目的:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文。`skip` 通常含发起方与接收方,避免回声。
|
||||
|
||||
### 6.5 终态帧协议(leader / special / clarification)
|
||||
|
||||
前端 `streamMultiAgent` 用 `isFinalStatusFrame()` 识别:**同时存在 `status` 与 `content`**。
|
||||
|
||||
| `status` 类型 | 含义 |
|
||||
|---------------|------|
|
||||
| `[["agent","task"],...]` | 派活列表;`[]` = 共识 |
|
||||
| `"clarification"` | 总控追问,需用户回复后再 leader |
|
||||
| `"子智能体 X 完成..."` | special 完成(字符串) |
|
||||
| `"error"` | 异常 |
|
||||
|
||||
与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。
|
||||
|
||||
---
|
||||
|
||||
## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API)
|
||||
|
||||
Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力。
|
||||
|
||||
### 7.1 `GET /api/threads/{thread_id}/artifacts`
|
||||
|
||||
- 鉴权:`threads:read`,owner 校验
|
||||
- 扫描:`users/{user_id}/threads/{thread_id}/user-data/outputs/`(`collect_outputs_listing`)
|
||||
- 目录不存在 → `files: []`(非 404)
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": "...",
|
||||
"files": [
|
||||
{
|
||||
"path": "mnt/user-data/outputs/report.md",
|
||||
"name": "report.md",
|
||||
"size_bytes": 12480,
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-05-23T14:32:30.123Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 可对协调 thread 与各席位 thread 分别拉取,合并为下载清单。
|
||||
|
||||
### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}`
|
||||
|
||||
- 虚拟路径前缀 `mnt/user-data/outputs/...`
|
||||
- 路径穿越防护;`download=true` 强制附件下载
|
||||
- HTML/XHTML/SVG 等 active content 始终按下载处理
|
||||
|
||||
**总控结论、各席位最终段落**:主要来自 Step 2 已缓存的 `lastLeaderContent`、`step2RoundtableDialogues`;无需为 Step 3 新增聚合接口(可选优化见 `multi-agent-step3-api.md` 中的 `sessions/summary` 草案)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 内置席位 SOUL 设计要点
|
||||
|
||||
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
|
||||
|
||||
| 席位 | agent_id | emoji | 核心职责 |
|
||||
|------|----------|-------|----------|
|
||||
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险 |
|
||||
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 承载力 |
|
||||
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
|
||||
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
|
||||
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源 |
|
||||
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 |
|
||||
|
||||
子智能体**不**安装 `agent_orchestration`;仅协调智能体安装,保证单调度源。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键设计决策
|
||||
|
||||
### 9.1 HTTP loopback
|
||||
|
||||
三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。
|
||||
|
||||
### 9.2 协调智能体每次新建
|
||||
|
||||
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录,需后续 GC 策略。
|
||||
|
||||
### 9.3 澄清协议统一
|
||||
|
||||
Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification` 帧驱动选项卡片;`[INTENT_READY]` / 派活列表 / `[]` 各自表示阶段结束语义,互不混用。
|
||||
|
||||
### 9.4 选项单选默认
|
||||
|
||||
`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。
|
||||
|
||||
### 9.5 SSE 末尾状态帧
|
||||
|
||||
LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端与 LangGraph 帧区分解析。
|
||||
|
||||
---
|
||||
|
||||
## 10. 性能特征
|
||||
|
||||
| 阶段 | 已做优化 | 典型耗时 |
|
||||
|------|----------|----------|
|
||||
| Step 2 `/init` | 席位 `as_completed` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) |
|
||||
| LLM `/run/stream` | — | 数百 ms~数十秒(主因) |
|
||||
| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 |
|
||||
|
||||
仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用(见原 B/D 优化项)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 依赖与扩展点
|
||||
|
||||
### 11.1 `agent_orchestration` 技能
|
||||
|
||||
协调智能体派活依赖此技能;正则 `_TOOL_CALL_PATTERN` 从 tool 输出提取 `agent_name` 与 task。技能缺失时 leader 易变成「无 tool_calls → 空 status → 前端误判共识」。
|
||||
|
||||
### 11.2 模型
|
||||
|
||||
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型。
|
||||
|
||||
### 11.3 新增席位
|
||||
|
||||
1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md`
|
||||
2. 前端候选池 / 推荐逻辑纳入该 id
|
||||
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
|
||||
|
||||
### 11.4 新增 Step 1 工具
|
||||
|
||||
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 白名单逻辑(当前为黑名单,仅 `ask_clarification` 实际可用)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|------|----------|------|
|
||||
| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY |
|
||||
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple` |
|
||||
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 | Network + 后端 `multi_agent` 日志 |
|
||||
| leader 立即 `status: []` | 未调用 orchestration 技能 | 换模型或加强 SOUL |
|
||||
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧 |
|
||||
| Step 3 无文件 | 子 agent 未写 outputs | `GET .../artifacts` 对子 thread_id 逐个查 |
|
||||
|
||||
### 日志关键字
|
||||
|
||||
```bash
|
||||
grep "intent stream" .runtime/logs/*.log
|
||||
grep "multi_agent\|multi-agent" .runtime/logs/*.log
|
||||
grep "recommend stream" .runtime/logs/*.log
|
||||
grep "dispatch failed" .runtime/logs/*.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试建议
|
||||
|
||||
```
|
||||
backend/tests/test_intent.py # init、INTENT_READY、clarification 优先级
|
||||
backend/tests/test_recommend.py # picks 校验、候选池过滤
|
||||
backend/tests/test_multi_agent.py # init SSE 事件、leader/special 终态、clarification
|
||||
```
|
||||
|
||||
可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway。
|
||||
|
||||
---
|
||||
|
||||
## 14. 路径速查
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| Step 1 路由 | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
|
||||
| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` |
|
||||
| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
|
||||
| Step 3 列出/下载 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
|
||||
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
|
||||
| 澄清单选/多选 | `packages/harness/deerflow/tools/builtins/clarification_utils.py` |
|
||||
| 任务理解 Agent | `.deer-flow/agents/roundtable-intent/` |
|
||||
| 推荐 Agent | `.deer-flow/agents/roundtable-recommender/` |
|
||||
| 圆桌席位 | `.deer-flow/agents/roundtable-*/` |
|
||||
| 前端 Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 前端推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
|
||||
| 前端 Step 2 API | `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` |
|
||||
| Step 1 API 说明 | `frontend-web/docs/multi-agent-step1-api.md` |
|
||||
| Step 3 API 说明 | `frontend-web/docs/multi-agent-step3-api.md` |
|
||||
@@ -0,0 +1,659 @@
|
||||
# 多智能体圆桌研讨 · 前端开发文档
|
||||
|
||||
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
|
||||
|
||||
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
|
||||
|
||||
---
|
||||
|
||||
## 0. 速读:你需要先知道的事
|
||||
|
||||
- **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。
|
||||
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
|
||||
- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。
|
||||
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
|
||||
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
|
||||
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
|
||||
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定位
|
||||
|
||||
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
|
||||
|
||||
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
|
||||
|
||||
**核心能力**:
|
||||
|
||||
| 步骤 | 用户目标 | 真实后端对接 |
|
||||
|------|----------|--------------|
|
||||
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
|
||||
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
|
||||
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
|
||||
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
|
||||
|
||||
**步骤门禁**:
|
||||
|
||||
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
|
||||
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/roundtable-planning/
|
||||
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
|
||||
├── pages/
|
||||
│ └── RoundtablePlanningPage.tsx # 三步主页面 + 状态机 + 编排循环(≈3600 行)
|
||||
├── api/
|
||||
│ ├── intent.ts # Step 1:initIntent、streamIntent
|
||||
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
|
||||
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
|
||||
├── components/
|
||||
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
|
||||
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
|
||||
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
|
||||
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
|
||||
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
|
||||
├── hooks/
|
||||
│ └── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
|
||||
├── lib/
|
||||
│ └── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
|
||||
├── utils/
|
||||
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
|
||||
└── styles/
|
||||
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
|
||||
```
|
||||
|
||||
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
|
||||
|
||||
```tsx
|
||||
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
|
||||
```
|
||||
|
||||
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
|
||||
|
||||
**环境变量**(由 `scripts/start-frontend.sh` 注入):
|
||||
|
||||
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
|
||||
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
|
||||
|
||||
---
|
||||
|
||||
## 3. 三步数据流(前端视角)
|
||||
|
||||
```
|
||||
[进入 Step 1]
|
||||
useEffect → runStep1Init()
|
||||
initIntent(model) → { agent_id, thread_id }
|
||||
setIntentThreadId、setTimelineEntries
|
||||
interactiveMessages seed 欢迎语(本地 seed,非 API)
|
||||
|
||||
[用户发送 / 选项卡片提交]
|
||||
sendIntentMessage(text)
|
||||
追加 user 气泡 + agent 占位(streaming + phase)
|
||||
streamIntent({ onTextDelta, onPhaseChange, signal })
|
||||
终态:
|
||||
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
|
||||
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
|
||||
asking → 继续对话
|
||||
error → toast + 红色 (错误) 文案
|
||||
|
||||
[点击「进入多智能体研讨」且 intentReady]
|
||||
setIsRecommendDialogOpen(true)
|
||||
RecommendAgentsDialog
|
||||
listAgents() → filterRecommendCandidates()
|
||||
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
|
||||
用户勾选 2–8 个 → onConfirm(agentIds)
|
||||
handleConfirmAgents
|
||||
setSelectedAgents(agentIds.map(...))
|
||||
step2InitRef.current = false // 复位 Step 2 init 闸门
|
||||
setCurrentStep(2)
|
||||
|
||||
[进入 Step 2]
|
||||
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
|
||||
onSeatReady:右侧席位面板逐个「在线」
|
||||
onCoordinatorReady / init_done → threadIds + coordinatorName
|
||||
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
|
||||
while !cancelled && cycles<8:
|
||||
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
|
||||
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
|
||||
dispatched.length === 0 → hasConsensus = true,可进 Step 3
|
||||
|
||||
[进入 Step 3]
|
||||
HighFidelityReport
|
||||
consensusText ← lastLeaderContent
|
||||
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
|
||||
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
|
||||
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 层
|
||||
|
||||
### 4.1 `api/intent.ts` — Step 1
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
|
||||
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
|
||||
|
||||
**`StreamIntentRequest` 重要字段**:
|
||||
|
||||
- `threadId`:必填,跨多轮对话保持
|
||||
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
|
||||
- `onTextDelta(text, messageId)`:每个 AI 文本增量
|
||||
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
|
||||
|
||||
**`StreamIntentResult`**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
|
||||
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
|
||||
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
|
||||
| `clarification` | `ClarificationPayload \| null` | 仅 `clarification` 有 |
|
||||
|
||||
**SSE 解析要点**:
|
||||
|
||||
- `event: metadata` → phase `connected`
|
||||
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
|
||||
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
|
||||
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`)
|
||||
|
||||
**Abort 行为**:
|
||||
|
||||
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
|
||||
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
|
||||
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
|
||||
|
||||
### 4.2 `api/recommend.ts` — 推荐弹窗
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
|
||||
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
|
||||
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
|
||||
|
||||
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
|
||||
|
||||
- `roundtable-intent`
|
||||
- `roundtable-recommender`
|
||||
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
|
||||
|
||||
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
|
||||
|
||||
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
|
||||
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
|
||||
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
|
||||
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
|
||||
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
|
||||
|
||||
**终态语义**:
|
||||
|
||||
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
|
||||
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
|
||||
|
||||
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
|
||||
| `streamMultiAgent(req)` | leader / special 一轮 |
|
||||
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
|
||||
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
|
||||
|
||||
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
|
||||
|
||||
```ts
|
||||
{ event: "seat_ready", agent_name, thread_id, display_name, description }
|
||||
{ event: "coordinator_ready", main_agent_name, thread_id }
|
||||
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
|
||||
{ event: "error", detail }
|
||||
```
|
||||
|
||||
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
|
||||
|
||||
| `status` | 含义 | 前端处理 |
|
||||
|----------|------|----------|
|
||||
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
|
||||
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
|
||||
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
|
||||
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
|
||||
| `"error"` | 失败 | toast + 红色气泡 |
|
||||
|
||||
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
|
||||
|
||||
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
|
||||
|
||||
**调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
|
||||
```
|
||||
|
||||
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主页面状态(`RoundtablePlanningPage`)
|
||||
|
||||
### 5.1 步骤与门禁
|
||||
|
||||
- `currentStep`: `1 | 2 | 3`
|
||||
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
|
||||
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
|
||||
|
||||
### 5.2 Step 1 关键 state
|
||||
|
||||
| 状态 | 用途 |
|
||||
|------|------|
|
||||
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
|
||||
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
|
||||
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
|
||||
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
|
||||
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
|
||||
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
|
||||
| `intentAborterRef` | 当前 stream 的 AbortController |
|
||||
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
|
||||
| `step1InitRef` | 闸门:true 即认为已 init 过 |
|
||||
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
|
||||
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
|
||||
|
||||
**`step1InitGenRef` 的存在原因**:
|
||||
|
||||
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
|
||||
|
||||
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
|
||||
|
||||
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
|
||||
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
|
||||
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
|
||||
|
||||
### 5.3 推荐与 Step 2 席位
|
||||
|
||||
| 状态 | 用途 |
|
||||
|------|------|
|
||||
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
|
||||
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
|
||||
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
|
||||
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
|
||||
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
|
||||
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
|
||||
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
|
||||
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
|
||||
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
|
||||
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
|
||||
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
|
||||
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
|
||||
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
|
||||
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
|
||||
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
|
||||
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
|
||||
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
|
||||
|
||||
**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
|
||||
|
||||
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
|
||||
|
||||
伪代码:
|
||||
|
||||
```
|
||||
isOrchestrating = true
|
||||
orchestrationCancelRef = false
|
||||
nextLeaderTask = seedMessage
|
||||
cycles = 0
|
||||
MAX_CYCLES = 8
|
||||
|
||||
while !cancelled && cycles < MAX_CYCLES:
|
||||
cycles += 1
|
||||
highlight(speakerId=2) // 总控
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
|
||||
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
|
||||
|
||||
if res.clarification:
|
||||
finalize(leaderBubble, content, "等待用户澄清")
|
||||
setPendingClarification(res.clarification)
|
||||
break // 等用户回复后由 submitClarification 重启循环
|
||||
|
||||
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
|
||||
if res.content: setLastLeaderContent(res.content)
|
||||
|
||||
if res.dispatched.length === 0:
|
||||
setHasConsensus(true); setConsensusPercentage(100); break
|
||||
|
||||
for [subName, task] of res.dispatched: // **顺序**,不是并行
|
||||
meta = agentNameToRole[subName.toLowerCase()]
|
||||
markSpeakerStreaming(meta.speakerId)
|
||||
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
|
||||
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
|
||||
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
|
||||
finalize(subBubble, specRes.content, "子智能体交付")
|
||||
markSpeakerIdle(meta.speakerId)
|
||||
if cancelled: break
|
||||
|
||||
setConsensusPercentage(prev => min(95, prev + 8))
|
||||
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
|
||||
|
||||
if cycles >= MAX_CYCLES:
|
||||
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
|
||||
|
||||
clearAllStreamingSpeakers()
|
||||
isOrchestrating = false
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **`buildInitialIntent()`**:
|
||||
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
|
||||
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
|
||||
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
|
||||
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
|
||||
- **挂起 / 恢复**:
|
||||
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
|
||||
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
|
||||
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
|
||||
|
||||
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
|
||||
|
||||
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
|
||||
- 抽屉控件:
|
||||
- **System Prompt**:仅前端记录(后端覆写未开放)
|
||||
- **Temp**:仅前端记录
|
||||
- **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL`
|
||||
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
|
||||
- 把新的 `(seatId, model)` 写入 `agentConfigs`
|
||||
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
||||
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数
|
||||
|
||||
### 5.6 草稿持久化(`utils/drafts.ts`)
|
||||
|
||||
- Key:`roundtable-planning:drafts:v1`(`localStorage`)
|
||||
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
|
||||
- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
|
||||
- **加载草稿**:`handleLoadDraft`
|
||||
- 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
|
||||
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
|
||||
- `step1InitGenRef` 自增,淘汰可能晚归的旧 init
|
||||
- `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
|
||||
- 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件说明
|
||||
|
||||
### 6.1 `IntentClarificationCard`
|
||||
|
||||
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`
|
||||
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
|
||||
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
|
||||
- 仅选项 → `我选择:A、B`
|
||||
- 仅自定义 → 直接发自定义文本
|
||||
- 同时存在 → `我选择:A、B;补充:…`
|
||||
- 展示条件:`isPendingClarification && !intentReady && !parts.summary && pendingIntentClarificationMsgId === msg.id`(已出现 `[INTENT_READY]` 则隐藏可操作卡片)
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
- `idle` → `loading_agents` → `recommending`(顶部 rationale 流;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
|
||||
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
|
||||
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
|
||||
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
|
||||
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
|
||||
|
||||
### 6.3 `HighFidelityReport`(Step 3)
|
||||
|
||||
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
|
||||
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
|
||||
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
|
||||
- **文件预览策略**(`shouldPreviewInModal`):
|
||||
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
|
||||
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
|
||||
- 命中规则:
|
||||
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
|
||||
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
|
||||
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
|
||||
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
|
||||
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
|
||||
|
||||
### 6.4 `ArtifactPreviewModal`
|
||||
|
||||
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
|
||||
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
|
||||
- 其它文本类 → `<pre>` 直出
|
||||
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
|
||||
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
|
||||
|
||||
### 6.5 共享 UI 模式与辅助
|
||||
|
||||
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
|
||||
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
|
||||
- `connecting` → 「调度中」
|
||||
- `connected` → 「已连接,等待响应」
|
||||
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
|
||||
- `tool_result` → 「处理工具结果」
|
||||
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
|
||||
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
|
||||
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
|
||||
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
|
||||
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
|
||||
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
|
||||
|
||||
---
|
||||
|
||||
## 7. 澄清交互(与主聊天对齐)
|
||||
|
||||
| 能力 | 实现位置 |
|
||||
|------|----------|
|
||||
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
|
||||
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
|
||||
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
|
||||
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
|
||||
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
|
||||
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
|
||||
|
||||
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
|
||||
|
||||
**关键差异**:
|
||||
|
||||
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
|
||||
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与主聊天的差异
|
||||
|
||||
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|
||||
|------|-----------------------------------|----------|
|
||||
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
|
||||
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
|
||||
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
|
||||
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
|
||||
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
|
||||
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
|
||||
|
||||
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键交互清单
|
||||
|
||||
| 交互 | 行为 |
|
||||
|------|------|
|
||||
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
|
||||
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
|
||||
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
|
||||
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
|
||||
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
|
||||
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
|
||||
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
|
||||
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
|
||||
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
|
||||
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
|
||||
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 类型与常量速查
|
||||
|
||||
```ts
|
||||
// Step 1 输出 → Step 2 输入
|
||||
interface IntentSummary {
|
||||
objective: string;
|
||||
constraints: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
// Step 2 线程映射(init_done.thread_ids)
|
||||
type ThreadIdMap = Record<string, string>;
|
||||
|
||||
// 派活
|
||||
type LeaderDispatch = [string, string]; // [agent_name, task]
|
||||
|
||||
// 当前选中的研讨席位
|
||||
type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
// Step 1 单条澄清问题
|
||||
interface ClarificationPayload {
|
||||
question: string;
|
||||
clarificationType: string;
|
||||
context: string;
|
||||
options: unknown[];
|
||||
allowCustom: boolean;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
// Step 2 stream 终态
|
||||
interface StreamMultiAgentResult {
|
||||
dispatched: LeaderDispatch[];
|
||||
message: string | null;
|
||||
content: string;
|
||||
agentName: string | null;
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
```
|
||||
|
||||
**核心常量**:
|
||||
|
||||
| 常量 | 值 | 位置 |
|
||||
|------|----|------|
|
||||
| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage |
|
||||
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
|
||||
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
|
||||
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
|
||||
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
|
||||
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
|
||||
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
|
||||
| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
|
||||
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
|
||||
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
|
||||
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
|
||||
|
||||
---
|
||||
|
||||
## 11. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 排查 |
|
||||
|------|----------|------|
|
||||
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
|
||||
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
|
||||
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
|
||||
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
|
||||
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
|
||||
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
|
||||
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
|
||||
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
|
||||
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
|
||||
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
|
||||
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
|
||||
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
|
||||
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
|
||||
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
|
||||
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
|
||||
|
||||
**本地调试开关**:
|
||||
|
||||
```js
|
||||
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
|
||||
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 扩展指南
|
||||
|
||||
### 12.1 新增默认研讨席位
|
||||
|
||||
1. 后端 `.deer-flow/agents/roundtable-xxx/`
|
||||
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
|
||||
3. 无需改 API 层(`agent_names` 由推荐结果传入)
|
||||
|
||||
### 12.2 调整 Step 1 澄清轮次上限
|
||||
|
||||
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
|
||||
|
||||
### 12.3 调整 Step 2 编排上限
|
||||
|
||||
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
|
||||
|
||||
### 12.4 把 special 改回并行
|
||||
|
||||
不推荐;若一定要做:
|
||||
|
||||
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
|
||||
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
|
||||
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
|
||||
|
||||
### 12.5 接入真实 Persona 覆盖
|
||||
|
||||
当前 prompt / temp 只在前端记录;要落地需:
|
||||
|
||||
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
|
||||
- 前端:`agentConfigs[meta.speakerId]` 一并传入
|
||||
|
||||
### 12.6 拆分页面
|
||||
|
||||
当前刻意单文件维护三步。若拆分建议:
|
||||
|
||||
- `useRoundtableStep1.ts` / `useRoundtableStep2.ts` — 状态与副作用
|
||||
- 保持 `api/*` 不变
|
||||
- 保持 `roundtable-planning.css` 主题变量
|
||||
|
||||
### 12.7 文档协同
|
||||
|
||||
`@/roundtable-planning/` 内一切公共 API 都应当:
|
||||
|
||||
1. 在 `api/*.ts` 文件级注释里说明 endpoint
|
||||
2. 在本页第 4 节 / 第 10 节同步对照
|
||||
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 13. 路径速查
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| 主页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
|
||||
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
|
||||
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
|
||||
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
|
||||
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
|
||||
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
|
||||
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
|
||||
| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
|
||||
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
|
||||
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
|
||||
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
|
||||
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
|
||||
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,312 +0,0 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
|
||||
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
}
|
||||
|
||||
// core/messages/utils.ts
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ThinkTagParser(AI 改写思考过程解析)
|
||||
|
||||
后端支持两种思考格式:
|
||||
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
|
||||
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
|
||||
|
||||
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
|
||||
|
||||
### 3.4 版本历史:本地切换
|
||||
|
||||
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
|
||||
|
||||
### 3.5 Writing Editor 流程(三阶段状态机)
|
||||
|
||||
```
|
||||
idle → menu → preview → idle
|
||||
↑ ↓
|
||||
openAiMenu closeAll
|
||||
```
|
||||
|
||||
- **idle**:正常编辑
|
||||
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
|
||||
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
|
||||
|
||||
---
|
||||
|
||||
## 四、已解决的技术难题
|
||||
|
||||
| 问题 | 解法 |
|
||||
|------|------|
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
|
||||
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
|
||||
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
|
||||
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
|
||||
|
||||
---
|
||||
|
||||
## 五、当前已知限制(非 bug,设计取舍)
|
||||
|
||||
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
|
||||
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
|
||||
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
|
||||
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
|
||||
|
||||
---
|
||||
|
||||
## 六、快速开始下一次工作
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后端(从 offline-backend-20260512/backend/)
|
||||
make dev # 启动 Gateway API on port 8001
|
||||
|
||||
# 前端(从 frontend-web/)
|
||||
pnpm dev # 启动 Vite on port 5174
|
||||
```
|
||||
|
||||
### 访问路径
|
||||
|
||||
- 主页:`http://localhost:5174`
|
||||
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
cd frontend-web && pnpm typecheck
|
||||
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、如需继续开发的潜在方向
|
||||
|
||||
| 方向 | 描述 | 优先级 |
|
||||
|------|------|------|
|
||||
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
|
||||
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
|
||||
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
|
||||
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
|
||||
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
|
||||
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件速查
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
@@ -1,950 +0,0 @@
|
||||
# Open Canvas 模式需求拆解文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
|
||||
|
||||
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
|
||||
|
||||
- 前端:基于 `React 19 + Vite + react-router-dom`
|
||||
- 后端:基于 `Python + FastAPI + LangGraph`
|
||||
- 模型层:支持云模型与本地模型自由配置
|
||||
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
|
||||
|
||||
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终目标
|
||||
|
||||
### 2.1 用户视角目标
|
||||
|
||||
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
|
||||
|
||||
- 左侧是聊天区,右侧是 Canvas 编辑区
|
||||
- 可以从空白文本或代码模板快速开始
|
||||
- 可以通过聊天让 AI 生成一份结构化 artifact
|
||||
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
|
||||
- artifact 支持版本历史切换
|
||||
- artifact 支持文本和代码两种主要类型
|
||||
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
|
||||
- 支持启用 Web 搜索增强
|
||||
- 支持切换模型
|
||||
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
|
||||
|
||||
### 2.2 工程视角目标
|
||||
|
||||
新增的 Canvas 模式需要满足以下工程原则:
|
||||
|
||||
- 不破坏现有 DeerFlow 工作区
|
||||
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
|
||||
- 可逐阶段上线
|
||||
- 前后端状态协议清晰
|
||||
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 本次需求包含
|
||||
|
||||
- 前端新增 `open-canvas` 专用目录
|
||||
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
|
||||
- 前端新增 Canvas 页面、上下文、组件、状态管理
|
||||
- 后端新增 Canvas 专用线程状态字段
|
||||
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
|
||||
- 模型配置统一为 DeerFlow 后端配置驱动
|
||||
- 支持本地大模型配置与模型列表动态下发
|
||||
- 支持结构化 artifact 的保存、读取、更新、版本切换
|
||||
|
||||
### 3.2 本次需求不包含
|
||||
|
||||
- 一次性复刻 open-canvas 全部 UI 细节
|
||||
- 完整照搬 Supabase 鉴权体系
|
||||
- 直接复用 open-canvas 的 Next.js API Route
|
||||
- 直接运行 open-canvas 的 LangGraph.js 后端
|
||||
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
|
||||
|
||||
### 3.3 迁移原则
|
||||
|
||||
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
|
||||
- 前端尽量复用其交互设计和数据结构
|
||||
- 后端以 Python 实现同等能力
|
||||
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体设计思路
|
||||
|
||||
### 4.1 前端设计思路
|
||||
|
||||
在现有 `frontend-web/src` 下新增一个专用目录,例如:
|
||||
|
||||
```text
|
||||
src/
|
||||
open-canvas/
|
||||
pages/
|
||||
routes/
|
||||
components/
|
||||
contexts/
|
||||
hooks/
|
||||
types/
|
||||
api/
|
||||
utils/
|
||||
constants/
|
||||
```
|
||||
|
||||
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
|
||||
|
||||
建议路由形式:
|
||||
|
||||
- `/canvas`
|
||||
- `/canvas/:threadId`
|
||||
|
||||
也可以挂在现有页面布局下,例如:
|
||||
|
||||
- `/workspace/canvas`
|
||||
- `/workspace/canvas/:threadId`
|
||||
|
||||
推荐优先采用独立一级功能路由:
|
||||
|
||||
- 逻辑更清晰
|
||||
- 状态隔离更容易
|
||||
- 不容易污染现有 `workspace` 功能
|
||||
|
||||
### 4.2 后端设计思路
|
||||
|
||||
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
|
||||
|
||||
#### 方案 A:新增独立 Canvas Assistant
|
||||
|
||||
- 新增 `canvas_agent`
|
||||
- 使用单独的 LangGraph 状态定义
|
||||
- 前端调用时通过 `assistant_id=canvas_agent`
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有 `lead_agent` 隔离
|
||||
- 风险更低
|
||||
- 更容易对齐 open-canvas 的图结构
|
||||
|
||||
缺点:
|
||||
|
||||
- 会产生一套新的 Agent 图维护成本
|
||||
|
||||
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
|
||||
|
||||
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
|
||||
- Agent 根据模式进入 Canvas 路径
|
||||
|
||||
优点:
|
||||
|
||||
- 统一入口
|
||||
|
||||
缺点:
|
||||
|
||||
- 会显著增加现有 Agent 复杂度
|
||||
- 容易影响现有聊天和文件产物模式
|
||||
|
||||
推荐先做方案 A。
|
||||
|
||||
### 4.3 模型配置设计思路
|
||||
|
||||
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
|
||||
|
||||
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
|
||||
|
||||
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `provider`
|
||||
- `supports_thinking`
|
||||
- `supports_reasoning_effort`
|
||||
- `supports_vision`
|
||||
- `supports_tool_calling`
|
||||
- `supports_artifact_editing`
|
||||
- `supports_code_editing`
|
||||
- `temperature_range`
|
||||
- `max_tokens_range`
|
||||
- `base_url_source` 或 provider 标识
|
||||
- `is_local`
|
||||
|
||||
---
|
||||
|
||||
## 5. 与当前项目的关键差异
|
||||
|
||||
### 5.1 当前 DeerFlow 现状
|
||||
|
||||
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
|
||||
|
||||
- `artifacts: string[]`
|
||||
|
||||
这更接近“生成文件产物”的工作模式。
|
||||
|
||||
前端当前 artifact 面板也主要是:
|
||||
|
||||
- 文件列表
|
||||
- 文件详情
|
||||
- 文件下载/预览
|
||||
|
||||
### 5.2 open-canvas 的关键能力
|
||||
|
||||
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
|
||||
|
||||
- 一个 conversation 对应一个主 artifact
|
||||
- artifact 具有类型、标题、内容、版本
|
||||
- 用户可以选中部分内容让 AI 改写
|
||||
- 每次改写都会生成新版本
|
||||
|
||||
### 5.3 需要新增的核心能力
|
||||
|
||||
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
|
||||
|
||||
- 当前 artifact
|
||||
- 当前版本索引
|
||||
- 历史版本列表
|
||||
- 选区信息
|
||||
- 当前编辑模式
|
||||
- 快速动作状态
|
||||
- Web 搜索结果状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施方案
|
||||
|
||||
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段一:前端路由与目录骨架
|
||||
|
||||
### 7.1 目标
|
||||
|
||||
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
|
||||
|
||||
### 7.2 需求
|
||||
|
||||
- 新增 `src/open-canvas/` 目录
|
||||
- 新增 Canvas 页面路由
|
||||
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
|
||||
- 提供 Canvas 首页和 Canvas Thread 页
|
||||
- 支持空状态展示
|
||||
|
||||
### 7.3 建议目录
|
||||
|
||||
```text
|
||||
src/open-canvas/
|
||||
api/
|
||||
threads.ts
|
||||
runs.ts
|
||||
models.ts
|
||||
components/
|
||||
layout/
|
||||
chat/
|
||||
canvas/
|
||||
artifacts/
|
||||
quick-actions/
|
||||
selectors/
|
||||
contexts/
|
||||
CanvasThreadContext.tsx
|
||||
CanvasGraphContext.tsx
|
||||
CanvasUIContext.tsx
|
||||
hooks/
|
||||
useCanvasThread.ts
|
||||
useCanvasStream.ts
|
||||
useCanvasArtifact.ts
|
||||
pages/
|
||||
CanvasHomePage.tsx
|
||||
CanvasThreadPage.tsx
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx
|
||||
types/
|
||||
artifact.ts
|
||||
thread.ts
|
||||
model.ts
|
||||
utils/
|
||||
artifact.ts
|
||||
selection.ts
|
||||
stream.ts
|
||||
constants/
|
||||
index.ts
|
||||
```
|
||||
|
||||
### 7.4 路由建议
|
||||
|
||||
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
|
||||
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
|
||||
|
||||
建议形式:
|
||||
|
||||
```text
|
||||
/canvas
|
||||
/canvas/:threadId
|
||||
```
|
||||
|
||||
### 7.5 验收标准
|
||||
|
||||
- 能访问 Canvas 首页
|
||||
- 能访问指定 thread 的 Canvas 页面
|
||||
- 不影响现有 `workspace/*` 与 `strategy/*`
|
||||
- 页面有独立布局骨架
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段二:前端 Canvas 布局与交互容器
|
||||
|
||||
### 8.1 目标
|
||||
|
||||
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
|
||||
|
||||
### 8.2 需求
|
||||
|
||||
- 左侧聊天区
|
||||
- 右侧画布区
|
||||
- 支持面板拖拽或折叠
|
||||
- 初始空状态
|
||||
- 支持“快速开始”:
|
||||
- 新建空白文本 artifact
|
||||
- 新建代码 artifact
|
||||
|
||||
### 8.3 组件拆分建议
|
||||
|
||||
- `CanvasShell`
|
||||
- `CanvasSidebar`
|
||||
- `CanvasChatPanel`
|
||||
- `CanvasArtifactPanel`
|
||||
- `CanvasEmptyState`
|
||||
- `QuickStartActions`
|
||||
|
||||
### 8.4 快速开始能力
|
||||
|
||||
用户进入空线程时可以选择:
|
||||
|
||||
- 开始写文章
|
||||
- 开始写代码
|
||||
- 从模板开始
|
||||
|
||||
首版模板可只做最小集:
|
||||
|
||||
- 空白文档
|
||||
- HTML 模板
|
||||
- React 组件模板
|
||||
- Python 脚本模板
|
||||
|
||||
### 8.5 验收标准
|
||||
|
||||
- 页面可稳定展示双栏布局
|
||||
- 快速开始能创建前端本地 artifact 状态
|
||||
- 代码和文本可分别进入不同渲染器
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段三:结构化 Artifact 数据模型
|
||||
|
||||
### 9.1 目标
|
||||
|
||||
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
|
||||
|
||||
### 9.2 核心要求
|
||||
|
||||
需要支持以下能力:
|
||||
|
||||
- 文本 artifact
|
||||
- 代码 artifact
|
||||
- 标题
|
||||
- 当前版本索引
|
||||
- 版本历史
|
||||
- 当前选中版本切换
|
||||
- 后续支持局部高亮编辑
|
||||
|
||||
### 9.3 建议数据结构
|
||||
|
||||
```ts
|
||||
type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
|
||||
};
|
||||
|
||||
type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 线程状态建议新增字段
|
||||
|
||||
前端与后端需共同支持:
|
||||
|
||||
- `artifact`
|
||||
- `highlighted_text`
|
||||
- `highlighted_code`
|
||||
- `canvas_mode`
|
||||
- `web_search_enabled`
|
||||
- `web_search_results`
|
||||
- `quick_action_id`
|
||||
|
||||
### 9.5 与现有 `artifacts: string[]` 的关系
|
||||
|
||||
建议保留现有 `artifacts: string[]` 不动。
|
||||
|
||||
新增字段:
|
||||
|
||||
- `artifact` 用于 Canvas 主文档
|
||||
- `artifacts` 继续用于文件产物
|
||||
|
||||
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
|
||||
|
||||
### 9.6 验收标准
|
||||
|
||||
- 前端类型定义完成
|
||||
- 后端状态字段设计完成
|
||||
- 确认与现有线程状态兼容
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段四:后端线程状态与 API 扩展
|
||||
|
||||
### 10.1 目标
|
||||
|
||||
让后端线程状态可以安全存储和返回 Canvas Artifact。
|
||||
|
||||
### 10.2 需求
|
||||
|
||||
- 扩展 Python `ThreadState`
|
||||
- 扩展状态序列化逻辑
|
||||
- 确保 `/api/threads/*` 返回结构化 `artifact`
|
||||
- 确保 LangGraph 流中可带上 `artifact` 更新
|
||||
|
||||
### 10.3 后端改造点
|
||||
|
||||
建议关注以下模块:
|
||||
|
||||
- `packages/harness/deerflow/agents/thread_state.py`
|
||||
- 线程 state serializer
|
||||
- `app/gateway/routers/threads.py`
|
||||
- 流事件适配层
|
||||
|
||||
### 10.4 注意事项
|
||||
|
||||
- 旧线程没有 `artifact` 字段时必须兼容
|
||||
- 状态更新不能破坏现有消息结构
|
||||
- 必须确保 JSON 可序列化
|
||||
|
||||
### 10.5 验收标准
|
||||
|
||||
- 新建线程可写入 `artifact`
|
||||
- 读取线程时可返回 `artifact`
|
||||
- 不影响现有线程读取
|
||||
- 前端能从线程 state 中恢复 Canvas 内容
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段五:Canvas 专用 Agent 图
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
实现与 open-canvas 类似的 AI 路由能力。
|
||||
|
||||
### 11.2 推荐方案
|
||||
|
||||
新增 `canvas_agent`,单独维护图逻辑。
|
||||
|
||||
### 11.3 节点拆分建议
|
||||
|
||||
首版建议至少拆成这些节点:
|
||||
|
||||
- `route_user_intent`
|
||||
- `reply_general_input`
|
||||
- `generate_artifact`
|
||||
- `rewrite_artifact`
|
||||
- `update_highlighted_text`
|
||||
- `update_highlighted_code`
|
||||
- `generate_followup`
|
||||
|
||||
二期可加:
|
||||
|
||||
- `rewrite_artifact_theme`
|
||||
- `rewrite_code_theme`
|
||||
- `change_length`
|
||||
- `change_reading_level`
|
||||
- `translate_artifact`
|
||||
- `add_comments`
|
||||
- `add_logs`
|
||||
- `fix_bugs`
|
||||
- `port_language`
|
||||
- `web_search_enrich`
|
||||
|
||||
### 11.4 路由逻辑要求
|
||||
|
||||
系统需要能判断:
|
||||
|
||||
- 用户是在闲聊
|
||||
- 用户要新生成 artifact
|
||||
- 用户要整体重写当前 artifact
|
||||
- 用户要局部修改当前 artifact
|
||||
- 用户要执行某个快捷动作
|
||||
|
||||
### 11.5 状态输入要求
|
||||
|
||||
节点需要读取:
|
||||
|
||||
- 当前消息历史
|
||||
- 当前 artifact
|
||||
- 当前选区
|
||||
- 当前模型配置
|
||||
- Web 搜索开关
|
||||
|
||||
### 11.6 输出要求
|
||||
|
||||
节点输出必须能更新:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `web_search_results`
|
||||
- `followup`
|
||||
|
||||
### 11.7 验收标准
|
||||
|
||||
- 能根据输入创建新 artifact
|
||||
- 能根据输入整体改写 artifact
|
||||
- 能保留版本历史
|
||||
- 能在改写后返回自然语言 followup
|
||||
|
||||
---
|
||||
|
||||
## 12. 阶段六:前端流式同步与版本管理
|
||||
|
||||
### 12.1 目标
|
||||
|
||||
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
|
||||
|
||||
### 12.2 需求
|
||||
|
||||
- 复用现有 LangGraph stream client
|
||||
- 在 Canvas 模式中解析 `artifact` 更新
|
||||
- 支持流式文本更新
|
||||
- 支持版本切换
|
||||
- 支持断线后恢复线程状态
|
||||
|
||||
### 12.3 前端状态层建议
|
||||
|
||||
- `CanvasThreadContext`:线程基础信息
|
||||
- `CanvasGraphContext`:消息、artifact、streaming 状态
|
||||
- `CanvasUIContext`:面板、编辑态、选中态
|
||||
|
||||
### 12.4 关键能力
|
||||
|
||||
- 当前版本切换
|
||||
- 新版本创建后自动定位到最新版本
|
||||
- 手动编辑内容后保存
|
||||
- 流结束后线程状态持久化
|
||||
|
||||
### 12.5 验收标准
|
||||
|
||||
- 发送消息时聊天和画布可同步更新
|
||||
- 版本数会增加
|
||||
- 切换历史版本稳定
|
||||
- 刷新页面后线程状态能恢复
|
||||
|
||||
---
|
||||
|
||||
## 13. 阶段七:局部选区改写
|
||||
|
||||
### 13.1 目标
|
||||
|
||||
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
|
||||
|
||||
### 13.2 文本选区需求
|
||||
|
||||
- 支持在 Markdown 文本中选择一段文本
|
||||
- 记录选区位置与原始文本
|
||||
- 用户发出修改请求后,仅重写该选区
|
||||
- 将选区结果合并为新版本 artifact
|
||||
|
||||
### 13.3 代码选区需求
|
||||
|
||||
- 支持在代码编辑器中选择一段代码
|
||||
- 记录 start/end index 或行列信息
|
||||
- AI 只返回替换片段
|
||||
- 前端或后端负责将新片段拼接回完整代码
|
||||
|
||||
### 13.4 实现建议
|
||||
|
||||
首版建议:
|
||||
|
||||
- 文本:前端记录纯文本字符位置
|
||||
- 代码:使用 CodeMirror selection 信息
|
||||
|
||||
不建议首版就做复杂块级富文本局部编辑。
|
||||
|
||||
### 13.5 风险
|
||||
|
||||
- Markdown 渲染态与源码态位置映射复杂
|
||||
- 富文本块级位置难维护
|
||||
- 流式更新中选区同步容易错位
|
||||
|
||||
### 13.6 首版折中方案
|
||||
|
||||
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
|
||||
|
||||
### 13.7 验收标准
|
||||
|
||||
- 文本局部改写成功
|
||||
- 代码局部改写成功
|
||||
- 原内容未改动部分保持不变
|
||||
- 新版本正确入历史
|
||||
|
||||
---
|
||||
|
||||
## 14. 阶段八:快捷动作系统
|
||||
|
||||
### 14.1 目标
|
||||
|
||||
实现 open-canvas 风格的快捷动作体系。
|
||||
|
||||
### 14.2 首批推荐动作
|
||||
|
||||
文本类:
|
||||
|
||||
- 扩写
|
||||
- 精简
|
||||
- 改正式语气
|
||||
- 改口语语气
|
||||
- 改阅读难度
|
||||
- 翻译
|
||||
|
||||
代码类:
|
||||
|
||||
- 加注释
|
||||
- 加日志
|
||||
- 修复 bug
|
||||
- 优化代码
|
||||
- 转换语言
|
||||
|
||||
### 14.3 设计方式
|
||||
|
||||
快捷动作不要直接写死在 UI,建议配置化:
|
||||
|
||||
- `id`
|
||||
- `label`
|
||||
- `type`
|
||||
- `applicable_to`
|
||||
- `prompt_template`
|
||||
|
||||
### 14.4 后端设计
|
||||
|
||||
快捷动作可通过:
|
||||
|
||||
- 前端传 `quick_action_id`
|
||||
- 后端路由节点根据动作 id 选择 prompt
|
||||
|
||||
### 14.5 验收标准
|
||||
|
||||
- 文本动作可执行
|
||||
- 代码动作可执行
|
||||
- 结果进入新版本
|
||||
- 不同动作提示词可扩展
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段九:模型配置统一与本地模型支持
|
||||
|
||||
### 15.1 目标
|
||||
|
||||
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
|
||||
|
||||
### 15.2 原则
|
||||
|
||||
- 后端 `config.yaml` 为真源
|
||||
- 前端只展示后端暴露的模型信息
|
||||
- 不维护一份脱离后端的前端常量模型表
|
||||
|
||||
### 15.3 本地模型支持范围
|
||||
|
||||
建议优先支持两类:
|
||||
|
||||
- `Ollama`
|
||||
- OpenAI-compatible 本地服务
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- LocalAI
|
||||
- one-api 风格代理
|
||||
|
||||
### 15.4 后端配置建议
|
||||
|
||||
后端模型配置应至少支持:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `use`
|
||||
- `model`
|
||||
- `api_key`
|
||||
- `base_url`
|
||||
- `supports_vision`
|
||||
- `supports_thinking`
|
||||
- `supports_tool_calling`
|
||||
- `is_local`
|
||||
- `canvas_capabilities`
|
||||
|
||||
### 15.5 Canvas 模式额外要求
|
||||
|
||||
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
|
||||
|
||||
- `supports_canvas_generation`
|
||||
- `supports_canvas_rewrite`
|
||||
- `supports_canvas_code_edit`
|
||||
- `supports_streaming_text`
|
||||
- `supports_streaming_tool_calls`
|
||||
|
||||
### 15.6 与 open-canvas 的关系
|
||||
|
||||
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
|
||||
|
||||
### 15.7 验收标准
|
||||
|
||||
- 前端模型选择器可显示后端返回模型
|
||||
- 可切换本地模型
|
||||
- Canvas 模式能用本地模型生成和改写
|
||||
|
||||
---
|
||||
|
||||
## 16. 阶段十:Web 搜索增强
|
||||
|
||||
### 16.1 目标
|
||||
|
||||
实现类似 open-canvas 的可选 Web 搜索增强。
|
||||
|
||||
### 16.2 需求
|
||||
|
||||
- UI 提供 Web 搜索开关
|
||||
- 后端在需要时走搜索节点
|
||||
- 搜索结果写入线程状态
|
||||
- 结果可在画布旁侧栏展示
|
||||
|
||||
### 16.3 首版建议
|
||||
|
||||
只做:
|
||||
|
||||
- 开关
|
||||
- 搜索结果列表
|
||||
- 搜索结果用于生成 artifact
|
||||
|
||||
暂不做复杂网页抓取编辑链路。
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
- 打开搜索后可返回搜索结果
|
||||
- 搜索结果可参与 artifact 生成
|
||||
- 结果展示不阻塞主画布
|
||||
|
||||
---
|
||||
|
||||
## 17. 阶段十一:持久化、导出与文件产物联动
|
||||
|
||||
### 17.1 目标
|
||||
|
||||
让 Canvas 文档与 DeerFlow 原有文件能力协同。
|
||||
|
||||
### 17.2 需求
|
||||
|
||||
- artifact 持久化在线程 state 中
|
||||
- 可手动导出为文件
|
||||
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
|
||||
- Canvas artifact 与 `artifacts: string[]` 可双向关联
|
||||
|
||||
### 17.3 推荐能力
|
||||
|
||||
- 导出 Markdown
|
||||
- 导出代码文件
|
||||
- 另存为 artifact file
|
||||
- 从 artifact file 回填到 Canvas
|
||||
|
||||
### 17.4 验收标准
|
||||
|
||||
- 当前版本可导出
|
||||
- 导出文件可在现有文件面板查看
|
||||
- 文件型 artifact 和 Canvas artifact 能共存
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐开发顺序
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
|
||||
1. 路由和目录骨架
|
||||
2. Canvas 页面布局
|
||||
3. 前端 artifact 数据模型
|
||||
4. 后端线程状态扩展
|
||||
5. 新建 Canvas Agent 图
|
||||
6. 前后端流式同步
|
||||
7. 整体生成与整体改写
|
||||
8. 局部选区改写
|
||||
9. 快捷动作
|
||||
10. 本地模型支持完善
|
||||
11. Web 搜索增强
|
||||
12. 导出与文件联动
|
||||
|
||||
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
|
||||
|
||||
---
|
||||
|
||||
## 19. 里程碑定义
|
||||
|
||||
### M1:页面可进入
|
||||
|
||||
- 路由接通
|
||||
- 双栏布局完成
|
||||
- 本地 mock artifact 可渲染
|
||||
|
||||
### M2:后端状态打通
|
||||
|
||||
- 线程可保存结构化 artifact
|
||||
- 页面刷新后能恢复
|
||||
|
||||
### M3:AI 生成可用
|
||||
|
||||
- 可新建文本/代码 artifact
|
||||
- 可整体改写
|
||||
- 可产生版本历史
|
||||
|
||||
### M4:核心体验可用
|
||||
|
||||
- 选区改写可用
|
||||
- 快捷动作可用
|
||||
- 模型切换可用
|
||||
|
||||
### M5:增强能力完整
|
||||
|
||||
- 本地模型支持完善
|
||||
- Web 搜索增强
|
||||
- 导出和文件联动
|
||||
|
||||
---
|
||||
|
||||
## 20. 风险与难点
|
||||
|
||||
### 20.1 最大难点
|
||||
|
||||
- 结构化 artifact 状态从无到有
|
||||
- 局部选区改写的精确性
|
||||
- 流式状态与版本管理的一致性
|
||||
- 不破坏现有 DeerFlow 文件型 artifact 体系
|
||||
|
||||
### 20.2 技术风险
|
||||
|
||||
- 线程 state JSON 结构过大
|
||||
- 大模型对局部改写指令遵循不稳定
|
||||
- 本地模型 tool calling 与流式能力差异大
|
||||
- 文本渲染态与源码态位置映射复杂
|
||||
|
||||
### 20.3 规避建议
|
||||
|
||||
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
|
||||
- 首版只支持文本和代码两种 artifact 类型
|
||||
- 首版先做整体生成和整体改写,再做局部改写
|
||||
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
|
||||
|
||||
---
|
||||
|
||||
## 21. 验收总清单
|
||||
|
||||
### 21.1 前端
|
||||
|
||||
- 有独立 Canvas 路由
|
||||
- 有专用目录和模块边界
|
||||
- 双栏布局可用
|
||||
- 文本和代码两类 artifact 可渲染
|
||||
- 历史版本可切换
|
||||
- 选区改写可交互
|
||||
- 快捷动作可触发
|
||||
- 模型可切换
|
||||
|
||||
### 21.2 后端
|
||||
|
||||
- 线程状态支持 `artifact`
|
||||
- 新增 `canvas_agent` 或等价模式
|
||||
- 生成、重写、局部改写可用
|
||||
- 流式返回稳定
|
||||
- 模型列表接口支持 Canvas 能力元数据
|
||||
|
||||
### 21.3 本地模型
|
||||
|
||||
- 可配置 Ollama
|
||||
- 可配置 OpenAI-compatible 本地服务
|
||||
- 前端可见并可切换
|
||||
- 至少能完成文本生成和整体改写
|
||||
|
||||
---
|
||||
|
||||
## 22. 推荐首版最小可交付范围
|
||||
|
||||
如果要控制风险,建议首版只交付以下内容:
|
||||
|
||||
- 新增 `canvas/*` 路由
|
||||
- 双栏布局
|
||||
- 结构化 `artifact`
|
||||
- `canvas_agent`
|
||||
- 文本/代码 artifact 新建
|
||||
- 整体生成
|
||||
- 整体改写
|
||||
- 历史版本切换
|
||||
- 后端模型动态加载
|
||||
- Ollama 或本地 OpenAI-compatible 至少一种可用
|
||||
|
||||
先不要把以下内容放进首版强制目标:
|
||||
|
||||
- 富文本块级精确编辑
|
||||
- 全量快捷动作
|
||||
- 复杂 Web 搜索增强
|
||||
- 多 artifact 并行管理
|
||||
|
||||
---
|
||||
|
||||
## 23. 后续实施建议
|
||||
|
||||
建议下一步基于本文档继续输出两份落地文档:
|
||||
|
||||
### 23.1 前端实施清单
|
||||
|
||||
逐文件列出:
|
||||
|
||||
- 要新建哪些文件
|
||||
- 哪些组件可复用
|
||||
- 哪些页面要接入路由
|
||||
- 哪些状态要从现有 hooks 抽离
|
||||
|
||||
### 23.2 后端实施清单
|
||||
|
||||
逐模块列出:
|
||||
|
||||
- 要改哪些 Python 文件
|
||||
- 新增哪些状态字段
|
||||
- 新增哪些 Agent 节点
|
||||
- 哪些 API 需要扩展
|
||||
|
||||
---
|
||||
|
||||
## 24. 结论
|
||||
|
||||
这个需求很大,但完全可拆。
|
||||
|
||||
正确做法不是“先抄 UI”,而是按下面的主线推进:
|
||||
|
||||
- 先建前端 Canvas 子系统
|
||||
- 再建结构化 artifact 状态
|
||||
- 再建 Canvas Agent 图
|
||||
- 再补局部改写和快捷动作
|
||||
- 最后统一本地模型能力
|
||||
|
||||
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
|
||||
@@ -0,0 +1,310 @@
|
||||
# 浅色红色 UI 规范对齐 — 实施计划
|
||||
|
||||
> 依据:`H:\页面设计\UI设计规范\浅色红色设计规范-前端用.pdf`(共 104 页)
|
||||
> 范围:**仅对齐 Light 模式(红色主题)**,**不改动图表部分**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要约束(必读)
|
||||
|
||||
### 1. 主题切换必须保留
|
||||
项目使用 `next-themes` 实现 Light/Dark 切换:
|
||||
|
||||
- `:root` → Light 模式(红色品牌,对应本规范)
|
||||
- `.dark` → Dark 模式(蓝色品牌,**不在本次范围内**)
|
||||
- 切换组件入口:`src/components/theme-provider.tsx`
|
||||
|
||||
**所有改动只能动 `:root` 块和 light-only 选择器,禁止动 `.dark` 块和 `dark:` 前缀样式。**
|
||||
|
||||
### 2. 不动图表
|
||||
本次跳过 `chart-1` ~ `chart-5` 颜色变量和所有图表组件。
|
||||
|
||||
### 3. 同一 token 双模式都用时如何改
|
||||
若变量同时被 light/dark 使用(如 `--radius`、按钮 `h-*`、`--primary-foreground`),改动后需在 Dark 模式下回归检查,确保没有破坏。
|
||||
|
||||
---
|
||||
|
||||
## 改动总目标对照
|
||||
|
||||
| 类别 | UI 规范 | 现状 | 差异 |
|
||||
|------|--------|------|------|
|
||||
| 主品牌红 | `#F5222D` | `#d92121` (`oklch(0.52 0.19 27)`) | 偏差 |
|
||||
| 深色品牌 | `#CF1322` / `#A8071A` | 未使用 | 缺失 |
|
||||
| 圆角-按钮 | 2px | 12px (`--radius: 0.75rem`) | 严重偏大 |
|
||||
| 圆角-卡片 | 4px | 12px | 严重偏大 |
|
||||
| 按钮高度(正常)| 32px | 36px (`h-9`) | 偏大 4px |
|
||||
| 按钮高度(小)| 24px | 32px (`h-8`) | 偏大 8px |
|
||||
| 输入框高度 | 32px | 36px (`h-9`) | 偏大 4px |
|
||||
| 中文字体 | NotoSansSChinese | 系统字体 | 未引入 |
|
||||
| 数字字体 | D-DIN | 系统字体 | 未引入 |
|
||||
| 间距体系 | 8px 基准(4/8/12/16/24/32/40/56)| Tailwind 默认 | 基本一致 |
|
||||
|
||||
---
|
||||
|
||||
## 分步实施计划
|
||||
|
||||
### Step 1 — 颜色变量对齐(Light 模式)
|
||||
|
||||
**文件**:`src/styles/globals.css`
|
||||
**只动 `:root` 块(第 242-277 行),保持 `.dark` 块原样**
|
||||
|
||||
#### 1.1 主色映射
|
||||
|
||||
| 角色 | 规范值 | 用途 |
|
||||
|------|--------|------|
|
||||
| 主色 | `#F5222D` | `--primary`、`--ring`、`--sidebar-primary` |
|
||||
| 主色 hover | `#CF1322` | 按钮 hover 状态(新增辅助变量) |
|
||||
| 主色 active | `#A8071A` | 按钮按下状态 |
|
||||
| 主色淡 1 | `#FEEDED` | `--accent`(极淡红,背景) |
|
||||
| 主色淡 2 | `#FDDADC` | hover bg、tag 背景 |
|
||||
| 主色淡 3 | `#FCB5B9` | 浅边框、selected 态 |
|
||||
| 危险色 | `#F5222D` | `--destructive` |
|
||||
|
||||
#### 1.2 新增工程变量(建议加在 `:root` 内)
|
||||
|
||||
```css
|
||||
/* 红色主题阶梯色(规范色板)*/
|
||||
--brand-50: #FEFAFA;
|
||||
--brand-100: #FEF6F6;
|
||||
--brand-200: #FEEDED;
|
||||
--brand-300: #FDDADC;
|
||||
--brand-400: #FCB5B9;
|
||||
--brand-500: #FA9196;
|
||||
--brand-600: #F86C73;
|
||||
--brand-700: #F74750;
|
||||
--brand-800: #F5222D; /* ← 主色 */
|
||||
--brand-900: #CF1322; /* ← hover */
|
||||
--brand-950: #A8071A; /* ← active */
|
||||
|
||||
/* 辅色(规范)*/
|
||||
--accent-orange: #FF9900;
|
||||
--accent-green: #2FD69A;
|
||||
--accent-cyan: #45C3F3;
|
||||
```
|
||||
|
||||
#### 1.3 `--workspace-brand` 同步
|
||||
|
||||
`:root` 内:`--workspace-brand: #F5222D;`(从 `#d92121` 改)
|
||||
`.dark` 内:**不动**(保持 `#3b82f6` 蓝色)
|
||||
|
||||
#### 1.4 验收
|
||||
- 切到 Light 模式:所有按钮、链接、选中态使用规范红
|
||||
- 切到 Dark 模式:依然是原蓝色品牌(无变化)
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — 圆角规范化
|
||||
|
||||
**文件**:`src/styles/globals.css`(`--radius` 是全局变量,light/dark 共用)
|
||||
|
||||
#### 2.1 改动方案(推荐)
|
||||
|
||||
按规范:按钮 2px、卡片/页面框 4px。但当前许多组件依赖 `rounded-md` / `rounded-lg`,全局把 `--radius` 改成 4px 会引起视觉跳变。
|
||||
|
||||
**采用细粒度策略:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius: 0.25rem; /* 4px → 卡片/页面框 */
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: 2px; /* 按钮、tag */
|
||||
--radius-md: calc(var(--radius) - 1px); /* 3px → 输入框 */
|
||||
--radius-lg: var(--radius); /* 4px → 卡片 */
|
||||
--radius-xl: calc(var(--radius) + 4px); /* 8px → 弹窗 */
|
||||
--radius-2xl: calc(var(--radius) + 8px); /* 12px → 大卡片 */
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 例外保留(设计感样式)
|
||||
以下组件**圆角不改**,保持设计稿原貌:
|
||||
- `.workspace-user-bubble`(聊天气泡,圆角 `1.6rem 1.6rem 0.5rem 1.6rem`)
|
||||
- `.workspace-assistant-card`(圆角 `1.75rem`)
|
||||
- `.gemini-gradient-border`(圆角 `1.5rem`)
|
||||
|
||||
#### 2.3 验收
|
||||
- 普通按钮变为近直角(2px)
|
||||
- 卡片维持温和圆角(4px)
|
||||
- 聊天气泡、AI 卡片不变形
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — 按钮组件尺寸对齐
|
||||
|
||||
**文件**:`src/components/ui/button.tsx`
|
||||
|
||||
#### 3.1 改 size 变体(规范:小 24px / 正常 32px)
|
||||
|
||||
| size | 规范 | 当前 | 改后 |
|
||||
|------|------|------|------|
|
||||
| `sm` | 24px | h-8 (32px) | `h-6 px-2 text-xs` |
|
||||
| `default` | 32px | h-9 (36px) | `h-8 px-3 text-sm` |
|
||||
| `lg` | — | h-10 (40px) | 保留 `h-10`(用于 Banner/登录场景)|
|
||||
|
||||
#### 3.2 圆角
|
||||
- 默认按钮:`rounded-sm`(2px)
|
||||
|
||||
#### 3.3 主按钮配色
|
||||
- bg:`bg-primary`
|
||||
- hover:`hover:bg-[var(--brand-900)]`(深红 `#CF1322`)
|
||||
- active:`active:bg-[var(--brand-950)]`(更深 `#A8071A`)
|
||||
- disabled:`disabled:opacity-50`(保留)
|
||||
|
||||
#### 3.4 次要按钮 / 文本按钮
|
||||
- 次要按钮(`outline` variant):边框 `border-primary`,文字 `text-primary`
|
||||
- 文本按钮(`ghost` variant):仅文字色 `text-primary`,hover 背景 `--brand-200`
|
||||
|
||||
#### 3.5 验收
|
||||
检查所有调用 `<Button>` 的页面:
|
||||
- 工具栏按钮(之前 h-9 → 现在 h-8)
|
||||
- 弹窗确认/取消(之前 h-9 → 现在 h-8)
|
||||
- Landing 主 CTA 按钮(保留 `lg`)
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — 输入框尺寸对齐
|
||||
|
||||
#### 4.1 `src/components/ui/input.tsx`
|
||||
- `h-9` → `h-8`(32px)
|
||||
- `rounded-md`(沿用 3px)
|
||||
- 边框:`border-input`(变量已是浅灰,不变)
|
||||
- focus:`focus-visible:ring-[2px] focus-visible:ring-primary/35`
|
||||
|
||||
#### 4.2 `src/components/ui/textarea.tsx`
|
||||
- 最小高度建议 `min-h-16`
|
||||
- 圆角 `rounded-md`
|
||||
|
||||
#### 4.3 `src/components/ui/select.tsx`
|
||||
- `size: default` → `h-8`
|
||||
- `size: sm` → `h-6`
|
||||
|
||||
#### 4.4 验收
|
||||
- 搜索框、表单输入、下拉框高度一致(32px)
|
||||
- 与按钮对齐时高度一致
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — 字体引入
|
||||
|
||||
#### 5.1 引入字体文件
|
||||
在 `frontend-web/public/fonts/` 下放置:
|
||||
- `NotoSansSC-Regular.woff2` / `Medium.woff2` / `Bold.woff2`
|
||||
- `D-DIN-Regular.woff2` / `Bold.woff2`
|
||||
|
||||
> 若需在线方式,可用 Google Fonts CDN:
|
||||
> `https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap`
|
||||
|
||||
#### 5.2 `globals.css` 顶部添加 `@font-face`
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Noto Sans SC';
|
||||
src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
/* Medium / Bold 同上 */
|
||||
|
||||
@font-face {
|
||||
font-family: 'D-DIN';
|
||||
src: url('/fonts/D-DIN-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3 修改 `--font-sans`(globals.css 第 74 行)
|
||||
|
||||
```css
|
||||
--font-sans:
|
||||
'Noto Sans SC', ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--font-numeric: 'D-DIN', 'Noto Sans SC', sans-serif;
|
||||
```
|
||||
|
||||
#### 5.4 数字字体工具类
|
||||
新增 `.font-numeric { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }`
|
||||
|
||||
仪表盘、表格中重要数字加 `className="font-numeric"`。
|
||||
|
||||
#### 5.5 验收
|
||||
- 中文文本回退到 Noto Sans SC
|
||||
- Dark 模式同样生效(字体是全局变量)
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — Badge / 状态标签色对齐
|
||||
|
||||
**文件**:`src/components/ui/badge.tsx`
|
||||
|
||||
规范状态色(参考 PDF 第 67 页):
|
||||
|
||||
| 状态 | 颜色定位 | 推荐 token |
|
||||
|------|---------|-----------|
|
||||
| 未开始 | 中性灰 | `bg-muted text-muted-foreground` |
|
||||
| 等待中 | 蓝/青 | `bg-blue-50 text-blue-600`(保留) |
|
||||
| 进行中 | 主红 | `bg-[var(--brand-200)] text-primary` |
|
||||
| 已完成 | 绿 | `bg-emerald-50 text-emerald-600` |
|
||||
| 已失败 | 红/橙 | `bg-red-50 text-red-600` |
|
||||
| 待更新 | 黄/橙 | `bg-amber-50 text-amber-700` |
|
||||
|
||||
需要新增 `variant`:`waiting`、`processing`、`success`、`failed`、`pending`。
|
||||
|
||||
圆角统一 `rounded-sm`,padding `px-2 py-0.5`,字号 `text-xs`。
|
||||
|
||||
---
|
||||
|
||||
### Step 7 — 全局间距 & 阴影规范化
|
||||
|
||||
#### 7.1 间距
|
||||
规范基于 8px 网格(4 / 8 / 12 / 16 / 24 / 32 / 40 / 56)。Tailwind 默认 4px 步进(`p-1`=4, `p-2`=8...)已兼容。
|
||||
|
||||
**只需 review**:检查页面是否有 `p-2.5`、`gap-1.5` 等非 8 的倍数,统一改成 8 的倍数。
|
||||
|
||||
#### 7.2 阴影
|
||||
规范阴影仅用于"标题+操作"和"卡片新增"两类。其余卡片保持平。
|
||||
|
||||
新增工具类(可选):
|
||||
```css
|
||||
.shadow-card { box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); }
|
||||
.shadow-card-hover { box-shadow: 0 4px 12px 0 rgba(0,0,0,0.08); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 8 — 验收清单
|
||||
|
||||
每步完成后检查:
|
||||
|
||||
- [ ] **Light 模式**视觉对齐规范(参考 PDF 关键页:5、7、10、36、66)
|
||||
- [ ] **Dark 模式**完全无变化(切换主题验证)
|
||||
- [ ] 首页 / Workspace / 聊天 / 设置弹窗 → 按钮和输入框高度统一
|
||||
- [ ] 主题切换按钮点击 → 颜色平滑切换,无错位/闪烁
|
||||
- [ ] `/landing` 强制 Dark 模式仍正常
|
||||
- [ ] 控制台无 CSS 变量未定义警告
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
Step 1 (颜色变量) ─┐
|
||||
├→ Step 3 (按钮) ─┐
|
||||
Step 2 (圆角) ─┘ ├→ Step 6 (Badge) ─→ Step 7 (间距/阴影) ─→ Step 8 (验收)
|
||||
│
|
||||
Step 4 (输入框) ──┤
|
||||
Step 5 (字体) ────┘
|
||||
```
|
||||
|
||||
并行可行:Step 1 + Step 2 完成后,Step 3/4/5 可同时推进。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
1. **圆角从 12px → 4px 是大幅度视觉变化**,建议先在分支上做、配截图比对再合并
|
||||
2. 字体文件体积较大(NotoSansSC 全字符集 ~10MB),建议子集化或仅引入需要的字重
|
||||
3. `--radius-sm` 改后可能影响 dropdown menu / popover 等小组件外观,需逐个检查
|
||||
4. Dark 模式下的红色 `--workspace-brand` 已是蓝色,**不要把蓝色也改成 #F5222D**
|
||||
@@ -1,243 +0,0 @@
|
||||
# 素材专家检索 —— 内网接口对接说明
|
||||
|
||||
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
|
||||
涉及的代码文件,以及常见错误的排查方式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 检索来源切换
|
||||
|
||||
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
|
||||
search_provider: internet
|
||||
# 以下两项仅在 search_provider=intranet 时生效
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples
|
||||
```
|
||||
|
||||
| `search_provider` 取值 | 含义 |
|
||||
|---|---|
|
||||
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
|
||||
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
|
||||
| `tavily` | Tavily 互联网检索 |
|
||||
| `demo` | 内置演示素材,仅用于本地调试 |
|
||||
|
||||
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
|
||||
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 检索关键词数量
|
||||
|
||||
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
|
||||
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
|
||||
|
||||
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
|
||||
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
|
||||
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
|
||||
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
|
||||
规整到 `[1, 10]`,越界回退默认 `6`。
|
||||
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 素材总量上限
|
||||
|
||||
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 素材总量上限,范围 1~100,非法值回退 20
|
||||
max_materials: 20
|
||||
```
|
||||
|
||||
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
|
||||
|
||||
| `max_materials` | 检索词数 | 每词检索条数 |
|
||||
|---|---|---|
|
||||
| 20 | 1 | 20 |
|
||||
| 20 | 2 | 10 |
|
||||
| 20 | 3 | 7(共 21,合并后截断到 20) |
|
||||
| 20 | 5 | 4 |
|
||||
|
||||
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
|
||||
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
|
||||
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
|
||||
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
|
||||
|
||||
> `config.yaml` 支持热加载,改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内网接口规格
|
||||
|
||||
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
|
||||
- **方法**:`POST`
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "检索关键词",
|
||||
"knowledge_base_name": "samples",
|
||||
"ts": "yes"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应体**:一个数组,每个元素结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"page_content": "正文片段",
|
||||
"metadata": {
|
||||
"m_title": "标题",
|
||||
"content": "内容",
|
||||
"recUuid": "原文id",
|
||||
"m_publish":"发布时间",
|
||||
"source1": "来源"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
我们需要的字段都在 `metadata` 里。
|
||||
|
||||
### 字段映射
|
||||
|
||||
后端把内网响应映射为统一的 `SearchResult` 结构:
|
||||
|
||||
| 内网响应字段 | SearchResult 字段 | 说明 |
|
||||
|--------------------------|--------------------|------|
|
||||
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
|
||||
| `metadata.content` | `content` | 内容,截断到 500 字 |
|
||||
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
|
||||
| `metadata.m_publish` | `published_at` | 发布时间 |
|
||||
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
|
||||
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
|
||||
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
|
||||
| — | `source_type` | 固定为 `"intranet"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对接逻辑与涉及文件
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
|
||||
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
|
||||
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
ai_writing.py(_make_config 注入 ai_writing_config)
|
||||
→ researcher_node
|
||||
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
|
||||
→ IntranetSearchProvider.search() # POST search_docs_es
|
||||
→ SearchResult[] # 字段映射
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
|
||||
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
|
||||
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
|
||||
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
|
||||
|
||||
「查看原文」链接拼接规则:
|
||||
|
||||
```
|
||||
INTRANET_SOURCE_URL_BASE + recUuid
|
||||
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
|
||||
```
|
||||
|
||||
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
|
||||
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与排查
|
||||
|
||||
### 4.1 内网检索返回空 / 调不通
|
||||
|
||||
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
|
||||
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
|
||||
|
||||
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
|
||||
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
|
||||
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
|
||||
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
|
||||
**接口报错不影响写作流程继续**。
|
||||
|
||||
> 容错相关代码:
|
||||
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
|
||||
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
|
||||
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
|
||||
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
|
||||
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. **确认网络可达**:在后端所在机器执行
|
||||
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
|
||||
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
|
||||
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
|
||||
|
||||
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
|
||||
用户可以选择「重新搜索」或「无数据继续写作」。
|
||||
|
||||
### 4.2 字段缺失 / 响应结构变化
|
||||
|
||||
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
|
||||
单条数据字段缺失不会导致整批失败。
|
||||
|
||||
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
|
||||
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
|
||||
|
||||
### 4.3 「查看原文」按钮不显示
|
||||
|
||||
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
|
||||
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
|
||||
|
||||
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
|
||||
|
||||
### 4.4 原文链接打不开 / 地址不对
|
||||
|
||||
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
|
||||
若内网原文详情页地址有变,只改这一个常量即可。
|
||||
|
||||
### 4.5 检索到的素材全是无关内容
|
||||
|
||||
**现象**:素材卡片内容与写作意图完全不沾边。
|
||||
|
||||
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
|
||||
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
|
||||
|
||||
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
|
||||
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
|
||||
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调上线步骤
|
||||
|
||||
1. 确认后端机器能访问 `10.106.57.180:7863`。
|
||||
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
|
||||
3. 后端配置热加载生效(或重启后端)。
|
||||
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
|
||||
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
|
||||
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
|
||||
+12
-1
@@ -1,7 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<html lang="en-US" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("strategy-theme");
|
||||
var root = document.documentElement;
|
||||
root.classList.remove("dark", "light");
|
||||
if (t === "light") return;
|
||||
root.classList.add("dark");
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CM 助手</title>
|
||||
</head>
|
||||
|
||||
@@ -25,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
|
||||
@@ -72,6 +72,12 @@ const menuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{
|
||||
id: "qa-roundtable",
|
||||
label: "圆桌规划",
|
||||
icon: Bot,
|
||||
path: "/page/roundtable/planning",
|
||||
},
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
@@ -99,6 +105,12 @@ const menuItems: MenuItem[] = [
|
||||
icon: List,
|
||||
path: "/page/strategy/light-app/notes",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-qa",
|
||||
label: "笔记问答",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/light-app/note-qa",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
|
||||
@@ -397,53 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
@@ -503,99 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
@@ -686,37 +686,37 @@ function SidebarMenuSubItem({
|
||||
);
|
||||
}
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
|
||||
@@ -526,7 +526,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
|
||||
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
result?: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
@@ -558,12 +558,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = findToolCallResult(toolCallId, messages);
|
||||
if (toolCallResult) {
|
||||
try {
|
||||
const json = JSON.parse(toolCallResult);
|
||||
step.result = json;
|
||||
} catch {
|
||||
const toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
);
|
||||
if (toolCallResult !== undefined) {
|
||||
if (typeof toolCallResult === "string") {
|
||||
try {
|
||||
step.result = JSON.parse(toolCallResult) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
step.result = toolCallResult;
|
||||
}
|
||||
} else {
|
||||
step.result = toolCallResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
@@ -115,7 +116,9 @@ export function MessageList({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitAllClarifications = useCallback(async () => {
|
||||
const handleSubmitAllClarifications = useCallback(async (
|
||||
selectionOverrides?: Map<string, ClarificationSelection>,
|
||||
) => {
|
||||
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +127,7 @@ export function MessageList({
|
||||
const id = pendingClarificationIds[index]!;
|
||||
const message = messages.find((msg) => msg.id === id);
|
||||
const payload = message ? getClarificationPayload(message) : null;
|
||||
const selection = clarificationSelections.get(id) ?? {
|
||||
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
@@ -244,7 +247,12 @@ export function MessageList({
|
||||
}
|
||||
submitting={submittingClarification}
|
||||
canSubmit={hasAnyClarificationAnswer}
|
||||
onSubmitAll={handleSubmitAllClarifications}
|
||||
onSubmitAll={(override) => {
|
||||
const overrides = override
|
||||
? new Map([[messageId, override]])
|
||||
: undefined;
|
||||
void handleSubmitAllClarifications(overrides);
|
||||
}}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -435,6 +443,7 @@ type ClarificationPayload = {
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
};
|
||||
|
||||
type ClarificationSelection = {
|
||||
@@ -641,7 +650,7 @@ function ClarificationCard({
|
||||
onSelectionChange: (next: ClarificationSelection) => void;
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onSubmitAll: () => void | Promise<void>;
|
||||
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
|
||||
@@ -665,11 +674,21 @@ function ClarificationCard({
|
||||
|
||||
const disabled = !isPending || submitting;
|
||||
const multiPending = (pendingCount ?? 0) > 1;
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allow_multiple,
|
||||
payload.clarification_type,
|
||||
);
|
||||
|
||||
function toggleOption(optionId: string) {
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!allowMultiple) {
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
onSelectionChange(next);
|
||||
void onSubmitAll(next);
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
@@ -707,7 +726,7 @@ function ClarificationCard({
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可多选(再次点击取消选择)
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
@@ -716,10 +735,10 @@ function ClarificationCard({
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleOption(optionId)}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
@@ -736,7 +755,7 @@ function ClarificationCard({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
{isSelected && allowMultiple ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
@@ -761,10 +780,10 @@ function ClarificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending ? (
|
||||
{multiPending && allowMultiple ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
@@ -776,14 +795,14 @@ function ClarificationCard({
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : allowMultiple ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
@@ -63,6 +64,15 @@ export function RecentChatList() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
|
||||
};
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
||||
useParams<{
|
||||
thread_id: string;
|
||||
@@ -161,12 +171,24 @@ export function RecentChatList() {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{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";
|
||||
|
||||
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
|
||||
export function AppearanceSettingsPage() {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "system",
|
||||
label: t.settings.appearance.system,
|
||||
description: t.settings.appearance.systemDescription,
|
||||
icon: MonitorSmartphoneIcon,
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
label: t.settings.appearance.light,
|
||||
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.system,
|
||||
t.settings.appearance.systemDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
|
||||
title={t.settings.appearance.themeTitle}
|
||||
description={t.settings.appearance.themeDescription}
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{themeOptions.map((option) => (
|
||||
<ThemePreviewCard
|
||||
key={option.id}
|
||||
@@ -76,8 +69,7 @@ export function AppearanceSettingsPage() {
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "system" | "light" | "dark"}
|
||||
systemTheme={systemTheme}
|
||||
mode={option.id as "light" | "dark"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
@@ -159,19 +151,16 @@ function ThemePreviewCard({
|
||||
description,
|
||||
active,
|
||||
mode,
|
||||
systemTheme,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "system" | "light" | "dark";
|
||||
systemTheme?: string;
|
||||
onSelect: (mode: "system" | "light" | "dark") => void;
|
||||
mode: "light" | "dark";
|
||||
onSelect: (mode: "light" | "dark") => void;
|
||||
}) {
|
||||
const previewMode =
|
||||
mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
|
||||
const previewMode = mode;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -119,6 +119,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/planning">
|
||||
<Users />
|
||||
<span>多智能体规划</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTheme as useNextTheme } from 'next-themes';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
@@ -75,12 +82,24 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
themeConfig: ThemeConfig;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: ThemeType) => void;
|
||||
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [theme, setThemeState] = useState<ThemeType>(() => {
|
||||
// 从 localStorage 读取保存的主题
|
||||
const saved = localStorage.getItem('strategy-theme');
|
||||
return (saved as ThemeType) || 'dark';
|
||||
});
|
||||
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
// 设置特定主题
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setThemeState(newTheme);
|
||||
};
|
||||
|
||||
// 保存到 localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('strategy-theme', theme);
|
||||
// 更新文档类名
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
document.documentElement.classList.add(theme);
|
||||
}, [theme]);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (current !== 'light' && current !== 'dark') {
|
||||
const fallback =
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
|
||||
setNextTheme(fallback);
|
||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
setNextTheme(legacy);
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
}, [mounted, setNextTheme]);
|
||||
|
||||
const theme: ThemeType = mounted
|
||||
? resolvedTheme === 'light'
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: readPersistedTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setNextTheme(newTheme);
|
||||
};
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | 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 });
|
||||
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
title = pickShortTitleFallback(layers, reserved);
|
||||
}
|
||||
|
||||
if (!title && !content && !url) {
|
||||
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
|
||||
if (!title && !content && !url && !indexLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
||||
|
||||
interface GenericMessageGroup<T = string> {
|
||||
type: T;
|
||||
id: string | undefined;
|
||||
@@ -141,21 +143,63 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
export function extractTextFromMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return (
|
||||
splitInlineReasoningFromAIMessage(message)?.content ??
|
||||
message.content.trim()
|
||||
);
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (typeof payload === "string") {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => (content.type === "text" ? content.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (payload && typeof payload === "object") {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
|
||||
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
|
||||
*/
|
||||
export function extractToolResultPayload(
|
||||
message: Message,
|
||||
): string | Record<string, unknown> | undefined {
|
||||
const { content } = message;
|
||||
|
||||
if (typeof content === "string") {
|
||||
const trimmed = content.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return content as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const textParts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = part as Record<string, unknown>;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "json" && block.json && typeof block.json === "object") {
|
||||
return block.json as Record<string, unknown>;
|
||||
}
|
||||
if (hasSearchResultPayload(block)) {
|
||||
return block;
|
||||
}
|
||||
}
|
||||
const joined = textParts.join("\n").trim();
|
||||
return joined || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
export function findToolCallResult(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
toolName?: string,
|
||||
): string | Record<string, unknown> | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
||||
const content = extractTextFromMessage(message);
|
||||
if (content) {
|
||||
return content;
|
||||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
}
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (payload !== undefined) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||||
if (toolName) {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "tool" || message.name !== toolName) {
|
||||
continue;
|
||||
}
|
||||
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
|
||||
continue;
|
||||
}
|
||||
const payload = extractToolResultPayload(message);
|
||||
if (payload !== undefined && hasSearchResultPayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||
if (star) {
|
||||
const raw = star[1].replace(/["']/g, "").trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeFallbackFilename(title: string | undefined): string {
|
||||
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
||||
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
||||
}
|
||||
|
||||
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "导出 Word 失败" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
throw new Error("导出 Word 失败");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename =
|
||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||
fallbackFilename;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
|
||||
export async function downloadAIWritingDocx(options: {
|
||||
title?: string;
|
||||
draftMarkdown: string;
|
||||
}): Promise<void> {
|
||||
const draftMarkdown = options.draftMarkdown.trim();
|
||||
if (!draftMarkdown) {
|
||||
throw new Error("文档内容为空");
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: options.title?.trim() || undefined,
|
||||
draft_markdown: draftMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
@@ -31,7 +33,14 @@ const INPUT_CLASS =
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring disabled:opacity-50';
|
||||
|
||||
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
const DEFAULT_KEYWORD_COUNT = 6;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
|
||||
export function AIWritingForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
}: AIWritingFormProps) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
|
||||
const keywordCount = hideAdvancedFields
|
||||
? DEFAULT_KEYWORD_COUNT
|
||||
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
articleType,
|
||||
targetAudience: audience.trim() || '普通读者',
|
||||
targetAudience: hideAdvancedFields
|
||||
? DEFAULT_AUDIENCE
|
||||
: audience.trim() || DEFAULT_AUDIENCE,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount,
|
||||
writingMode: strictMode ? 'strict' : 'loose',
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
modelName: modelName || undefined,
|
||||
});
|
||||
}
|
||||
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</p>
|
||||
</div>
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '进行中', color: 'text-blue-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
error: { label: '错误', color: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelative(isoStr: string): string {
|
||||
export function formatRelative(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
|
||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
export interface SessionItemProps {
|
||||
session: AIWritingSessionSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
@@ -33,7 +33,7 @@ interface SessionItemProps {
|
||||
onRename: (title: string) => void;
|
||||
}
|
||||
|
||||
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
error: '出错了',
|
||||
};
|
||||
|
||||
export function AIWritingPanel() {
|
||||
export function AIWritingPanel({
|
||||
hideAdvancedFields = false,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
|
||||
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
|
||||
const handleSelectHistoryRef = useRef(handleSelectHistory);
|
||||
useEffect(() => {
|
||||
handleSelectHistoryRef.current = handleSelectHistory;
|
||||
}, [handleSelectHistory]);
|
||||
|
||||
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem('ai-writing:pending-session');
|
||||
if (!raw) return;
|
||||
sessionStorage.removeItem('ai-writing:pending-session');
|
||||
try {
|
||||
const session = JSON.parse(raw) as AIWritingSessionSummary;
|
||||
handleSelectHistoryRef.current(session);
|
||||
} catch { /* ignore */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅挂载时执行一次
|
||||
|
||||
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
|
||||
useEffect(() => {
|
||||
function onOpenSession(e: Event) {
|
||||
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
|
||||
if (session) handleSelectHistoryRef.current(session);
|
||||
}
|
||||
window.addEventListener('ai-writing:open-session', onOpenSession);
|
||||
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
|
||||
}, []);
|
||||
|
||||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||
{/* 未开始时显示表单 */}
|
||||
{isIdle && (
|
||||
<AIWritingForm onSubmit={startWriting} />
|
||||
<AIWritingForm
|
||||
onSubmit={startWriting}
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 意图解析:流式 → 结果卡片承接。
|
||||
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
|
||||
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
{!hideAdvancedFields && (
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
)}
|
||||
<ReqRow label="目标字数">
|
||||
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
{!hideAdvancedFields && (
|
||||
<>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
</>
|
||||
)}
|
||||
{state.request.modelName && (
|
||||
<ReqRow label="写作模型">
|
||||
<span className="text-muted-foreground">{state.request.modelName}</span>
|
||||
|
||||
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
|
||||
interface IntentResultCardProps {
|
||||
intent: ParsedIntent;
|
||||
articleTypes: ArticleType[];
|
||||
hideTargetAudience?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
|
||||
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
|
||||
*/
|
||||
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
|
||||
export function IntentResultCard({
|
||||
intent,
|
||||
articleTypes,
|
||||
hideTargetAudience = false,
|
||||
}: IntentResultCardProps) {
|
||||
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
|
||||
const typeLabel =
|
||||
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
|
||||
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-1.5 text-xs">
|
||||
<Row label="文章类型" value={typeLabel} />
|
||||
<Row label="目标读者" value={intent.targetAudience} />
|
||||
{!hideTargetAudience && (
|
||||
<Row label="目标读者" value={intent.targetAudience} />
|
||||
)}
|
||||
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
|
||||
{intent.refinedIntent && (
|
||||
<Row label="精炼意图" value={intent.refinedIntent} multiline />
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
import { exportMarkdownToDocx } from "../../utils/export-docx";
|
||||
import { downloadAIWritingDocx } from "../../api/writing-export";
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -82,9 +82,12 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
|
||||
if (!canExport) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportMarkdownToDocx(markdown ?? "", title);
|
||||
await downloadAIWritingDocx({
|
||||
title,
|
||||
draftMarkdown: markdown ?? "",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[export-docx] failed", err);
|
||||
console.error("[writing-export] failed", err);
|
||||
toast.error("导出 Word 失败,请稍后重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
|
||||
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
|
||||
// Match the original canvas page proportions (chat=35, artifact=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);
|
||||
|
||||
return (
|
||||
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
|
||||
>
|
||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel />
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
import RoundtableRoutes from "./RoundtableRoutes";
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -43,6 +44,7 @@ function PageLayout() {
|
||||
/>
|
||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
<Route path="roundtable/*" element={<RoundtableRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
|
||||
@@ -33,6 +33,8 @@ import AdminUserHubPage from "./AdminUserHubPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
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({
|
||||
children,
|
||||
@@ -183,7 +185,19 @@ export default function WorkspaceRoutes({
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage />
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable"
|
||||
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/planning"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RoundtablePlanningPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ClarificationPayload } from "./multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type { ClarificationPayload };
|
||||
|
||||
/**
|
||||
* 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; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "clarification" | "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;
|
||||
/**
|
||||
* Populated when the intent agent called ask_clarification — render option
|
||||
* cards + custom input instead of treating the turn as plain text only.
|
||||
*/
|
||||
clarification: ClarificationPayload | 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" | "clarification" | "done" | "error";
|
||||
content?: string;
|
||||
summary?: IntentSummary | null;
|
||||
question?: string;
|
||||
clarification_type?: string;
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
} {
|
||||
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 === "clarification" ||
|
||||
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,
|
||||
clarification: 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;
|
||||
}
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
}
|
||||
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,568 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
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;
|
||||
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
|
||||
allowMultiple?: 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;
|
||||
allow_multiple?: 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") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
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,303 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { Agent } from "@/core/agents";
|
||||
|
||||
import type { IntentSummary } from "./intent";
|
||||
|
||||
/**
|
||||
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
|
||||
* Step 2 (multi-agent roundtable): given the IntentSummary and a candidate
|
||||
* agent pool, the built-in `roundtable-recommender` picks 2-6 agents most
|
||||
* relevant to the task and explains why.
|
||||
*
|
||||
* One-shot, stateless: no /init, no thread_id, each call spins up a fresh
|
||||
* backend-managed thread. Use {@link filterRecommendCandidates} to clean the
|
||||
* `/api/agents` list before passing it as `candidates`.
|
||||
*/
|
||||
|
||||
/** Single agent pick returned by the recommender. */
|
||||
export interface RecommendPick {
|
||||
agent_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Final status frame the backend appends after the upstream stream finishes. */
|
||||
export interface StreamRecommendResult {
|
||||
/**
|
||||
* `done` → picks parsed and validated, ready to populate the dialog as
|
||||
* default-checked selections.
|
||||
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
|
||||
* caller should apply its fallback list (typically the 6 roundtable-* seats).
|
||||
* `error` → upstream / network failure.
|
||||
*/
|
||||
status: "done" | "asking" | "error";
|
||||
/** Full final AI text (includes the natural-language rationale). */
|
||||
content: string;
|
||||
/** Populated only when status === "done"; null otherwise. */
|
||||
picks: RecommendPick[] | null;
|
||||
}
|
||||
|
||||
/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */
|
||||
export type RecommendStreamPhase = "connecting" | "connected" | "streaming";
|
||||
|
||||
export interface StreamRecommendRequest {
|
||||
/** Step 1 output. `objective` is required by the backend. */
|
||||
intent: IntentSummary;
|
||||
/** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */
|
||||
candidates: RecommendCandidate[];
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
/** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */
|
||||
onRationaleUpdate?: (rationale: string) => void;
|
||||
/** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */
|
||||
onPicksUpdate?: (picks: RecommendPick[]) => void;
|
||||
/** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
/** Fires whenever the inferred SSE phase changes. */
|
||||
onPhaseChange?: (phase: RecommendStreamPhase) => void;
|
||||
}
|
||||
|
||||
/** Shape the backend expects in `candidates[]`. */
|
||||
export interface RecommendCandidate {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ids that must never enter the candidate pool. Centralized here so
|
||||
* every caller (recommend dialog, future tools) applies the same rule.
|
||||
*
|
||||
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
|
||||
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
|
||||
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
|
||||
* accumulates one per session); meaningless as roundtable participants.
|
||||
*/
|
||||
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
|
||||
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
|
||||
|
||||
/**
|
||||
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
|
||||
* to the slim shape the backend expects. Returns a fresh array; original
|
||||
* objects are not mutated.
|
||||
*/
|
||||
export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] {
|
||||
const out: RecommendCandidate[] = [];
|
||||
for (const a of agents) {
|
||||
if (!a || !a.id) continue;
|
||||
if (EXCLUDED_AGENT_IDS.has(a.id)) continue;
|
||||
if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue;
|
||||
out.push({
|
||||
agent_id: a.id,
|
||||
name: a.name ?? a.id,
|
||||
description: a.description ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isFinalRecommendFrame(value: unknown): value is {
|
||||
status: "done" | "asking" | "error";
|
||||
content?: string;
|
||||
picks?: RecommendPick[] | 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 === "done" || status === "asking" || status === "error";
|
||||
}
|
||||
|
||||
const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]";
|
||||
|
||||
/** Match one complete pick object inside a partially streamed JSON block. */
|
||||
const PICK_OBJECT_PATTERN =
|
||||
/\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g;
|
||||
|
||||
function unescapeJsonString(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
} catch {
|
||||
return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split accumulated recommender output into visible rationale text and any
|
||||
* pick objects that have fully arrived in the JSON section.
|
||||
*/
|
||||
export function parseRecommendStreamContent(raw: string): {
|
||||
rationale: string;
|
||||
picks: RecommendPick[];
|
||||
} {
|
||||
const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER);
|
||||
let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw;
|
||||
rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd();
|
||||
|
||||
const jsonSection =
|
||||
markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : "";
|
||||
|
||||
const picks: RecommendPick[] = [];
|
||||
const seen = new Set<string>();
|
||||
PICK_OBJECT_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) {
|
||||
const agent_id = unescapeJsonString(match[1]).trim();
|
||||
const reason = unescapeJsonString(match[2]).trim();
|
||||
if (!agent_id || seen.has(agent_id)) continue;
|
||||
seen.add(agent_id);
|
||||
picks.push({ agent_id, reason });
|
||||
}
|
||||
|
||||
return { rationale, picks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an AI text delta from one LangGraph `messages-tuple` frame. Same
|
||||
* shape as the helper in intent.ts; duplicated rather than imported to keep
|
||||
* each helper independently readable.
|
||||
*/
|
||||
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||
|
||||
if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") {
|
||||
const meta = parsed[1] as Record<string, unknown>;
|
||||
const node = String(meta.langgraph_node ?? "");
|
||||
if (node.includes("TitleMiddleware")) return null;
|
||||
const tags = meta.tags;
|
||||
if (Array.isArray(tags) && tags.includes("middleware:title")) 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/recommend/stream` and resolve once the
|
||||
* upstream stream finishes. Each call is one recommendation round — the
|
||||
* recommender is stateless, so calling twice in a row produces two
|
||||
* independent runs.
|
||||
*/
|
||||
export async function streamRecommend(req: StreamRecommendRequest): Promise<StreamRecommendResult> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent: req.intent,
|
||||
candidates: req.candidates,
|
||||
model: req.model,
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let lastEvent = "";
|
||||
|
||||
let currentPhase: RecommendStreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: RecommendStreamPhase) => {
|
||||
if (next === currentPhase) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next);
|
||||
};
|
||||
|
||||
const result: StreamRecommendResult = { status: "asking", content: "", picks: null };
|
||||
let rawContent = "";
|
||||
let lastRationale = "";
|
||||
let lastPickCount = 0;
|
||||
|
||||
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();
|
||||
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 (isFinalRecommendFrame(parsed)) {
|
||||
result.status = parsed.status;
|
||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||
if (Array.isArray(parsed.picks)) {
|
||||
result.picks = parsed.picks as RecommendPick[];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastEvent === "messages") {
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
setPhase("streaming");
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
rawContent += delta.text;
|
||||
const { rationale, picks } = parseRecommendStreamContent(rawContent);
|
||||
if (rationale !== lastRationale) {
|
||||
lastRationale = rationale;
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
lastPickCount = picks.length;
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.content) {
|
||||
const { rationale, picks } = parseRecommendStreamContent(result.content);
|
||||
if (rationale !== lastRationale) {
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -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,217 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type IntentClarificationSelection = {
|
||||
selectedIds: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function normalizeOptions(raw: unknown[]): ClarificationOption[] {
|
||||
return raw
|
||||
.map((opt, index) => {
|
||||
if (typeof opt === "string") {
|
||||
const label = opt.trim();
|
||||
return label ? { id: `option-${index}`, label } : null;
|
||||
}
|
||||
if (opt && typeof opt === "object" && "label" in opt) {
|
||||
const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
|
||||
const label = String(obj.label ?? "").trim();
|
||||
if (!label) return null;
|
||||
const id = String(obj.id ?? label).trim() || `option-${index}`;
|
||||
const description =
|
||||
typeof obj.description === "string" && obj.description.trim()
|
||||
? obj.description.trim()
|
||||
: undefined;
|
||||
return { id, label, description };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((opt): opt is ClarificationOption => opt !== null);
|
||||
}
|
||||
|
||||
export function formatIntentClarificationAnswer(
|
||||
payload: ClarificationPayload,
|
||||
selection: IntentClarificationSelection,
|
||||
): string | null {
|
||||
const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
|
||||
const selectedLabels = selection.selectedIds
|
||||
.map((sid) => options.find((option) => option.id === sid)?.label)
|
||||
.filter((label): label is string => Boolean(label && label.trim()));
|
||||
const trimmedCustom = selection.custom.trim();
|
||||
|
||||
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||
return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||
}
|
||||
if (selectedLabels.length > 0) {
|
||||
return `我选择:${selectedLabels.join("、")}`;
|
||||
}
|
||||
if (trimmedCustom) {
|
||||
return trimmedCustom;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface IntentClarificationCardProps {
|
||||
payload: ClarificationPayload;
|
||||
fallbackContent?: string;
|
||||
selection: IntentClarificationSelection;
|
||||
onSelectionChange: (next: IntentClarificationSelection) => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
/** Multi-select: submit current selection. Single-select: also used for custom text. */
|
||||
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
|
||||
}
|
||||
|
||||
export function IntentClarificationCard({
|
||||
payload,
|
||||
fallbackContent,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
}: IntentClarificationCardProps) {
|
||||
const options = useMemo(
|
||||
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
|
||||
[payload.options],
|
||||
);
|
||||
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allowMultiple,
|
||||
payload.clarificationType,
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
|
||||
}, [selection.custom, selection.selectedIds]);
|
||||
|
||||
const showSubmitButton = allowMultiple || payload.allowCustom !== false;
|
||||
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled || submitting) return;
|
||||
if (!allowMultiple) {
|
||||
onSubmit({ selectedIds: [optionId], custom: "" });
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
onSelectionChange({ ...selection, selectedIds: next });
|
||||
}
|
||||
|
||||
function updateCustom(value: string) {
|
||||
if (disabled || submitting) return;
|
||||
onSelectionChange({ ...selection, custom: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
需要你的确认
|
||||
</span>
|
||||
{payload.clarificationType ? (
|
||||
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
|
||||
{payload.clarificationType}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payload.context ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-xs font-medium leading-relaxed text-slate-800">
|
||||
{payload.question || fallbackContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const isSelected = selection.selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled || submitting}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
|
||||
isSelected && allowMultiple
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
|
||||
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
|
||||
(disabled || submitting) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span className="text-[11px] font-bold">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-snug",
|
||||
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allowCustom !== false ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={selection.custom}
|
||||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||
placeholder={
|
||||
options.length > 0
|
||||
? allowMultiple
|
||||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||
}
|
||||
disabled={disabled || submitting}
|
||||
className="min-h-16 resize-none border-slate-200 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSubmitButton ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || submitting || !canSubmit}
|
||||
onClick={() => onSubmit()}
|
||||
className="h-8 rounded-xl px-4 text-xs font-bold"
|
||||
>
|
||||
{submitting ? "发送中…" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
parseRecommendStreamContent,
|
||||
streamRecommend,
|
||||
type RecommendCandidate,
|
||||
type RecommendPick,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
|
||||
/**
|
||||
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
|
||||
* (roundtable). Opens with a streaming recommendation from
|
||||
* `roundtable-recommender`, defaults its picks to checked, and lets the user
|
||||
* trim or extend the selection from the rest of the agent pool before
|
||||
* confirming.
|
||||
*
|
||||
* Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the
|
||||
* backend also clamps but UI feedback is faster here.
|
||||
*/
|
||||
|
||||
export interface RecommendAgentsDialogProps {
|
||||
open: boolean;
|
||||
intent: IntentSummary | null;
|
||||
/** Falls back to the recommender's own default if omitted. */
|
||||
model?: string;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agentIds: string[]) => void;
|
||||
}
|
||||
|
||||
const MIN_SELECTED = 2;
|
||||
const MAX_SELECTED = 8;
|
||||
|
||||
/**
|
||||
* Default selection when the recommender fails or returns < 2 valid picks.
|
||||
* Intersected with the live candidate pool at runtime so missing built-ins
|
||||
* don't break the dialog.
|
||||
*/
|
||||
const FALLBACK_AGENT_IDS = [
|
||||
"roundtable-intelligence",
|
||||
"roundtable-environment",
|
||||
"roundtable-solution-design",
|
||||
"roundtable-risk-review",
|
||||
"roundtable-execution-plan",
|
||||
"roundtable-summary",
|
||||
];
|
||||
|
||||
type DialogPhase =
|
||||
| "idle"
|
||||
| "loading_agents"
|
||||
| "recommending"
|
||||
| "ready"
|
||||
| "fallback"
|
||||
| "error";
|
||||
|
||||
interface InternalState {
|
||||
phase: DialogPhase;
|
||||
/** Streaming + final rationale text from the recommender. */
|
||||
rationale: string;
|
||||
/** Streaming SSE phase from the recommender helper. */
|
||||
streamPhase: RecommendStreamPhase | null;
|
||||
/** Pre-filtered candidate pool fetched from `/api/agents`. */
|
||||
candidates: RecommendCandidate[];
|
||||
/** Reason-by-agent_id, only for picks returned by the recommender. */
|
||||
reasons: Record<string, string>;
|
||||
/** Agent ids the recommender returned (in order). */
|
||||
recommendedIds: string[];
|
||||
/** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */
|
||||
warning: string | null;
|
||||
/** Latest error message; only shown when `phase === "error"`. */
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InternalState = {
|
||||
phase: "idle",
|
||||
rationale: "",
|
||||
streamPhase: null,
|
||||
candidates: [],
|
||||
reasons: {},
|
||||
recommendedIds: [],
|
||||
warning: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which candidate ids should be checked by default given a set of
|
||||
* recommended ids. The result preserves recommendation order and silently
|
||||
* drops ids that aren't in the candidate pool.
|
||||
*/
|
||||
function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] {
|
||||
const poolIds = new Set(pool.map((c) => c.agent_id));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of preferred) {
|
||||
if (!poolIds.has(id) || seen.has(id)) continue;
|
||||
out.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function RecommendAgentsDialog({
|
||||
open,
|
||||
intent,
|
||||
model,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RecommendAgentsDialogProps) {
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Latest stream abort controller, so closing or re-running cancels the
|
||||
// in-flight request instead of leaking it.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Generation token guards against stale completion handlers writing into
|
||||
// state after a re-run (e.g. user clicks "重新推荐" mid-stream).
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const cancelInflight = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The full pipeline: fetch agents → run recommendation → seed selection.
|
||||
* Wrapped so the user can re-trigger it via the "重新推荐" button.
|
||||
*/
|
||||
const runRecommendation = useCallback(async () => {
|
||||
if (!intent || !intent.objective) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "缺少任务目标,无法启动推荐",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInflight();
|
||||
const myGen = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({ ...INITIAL_STATE, phase: "loading_agents" });
|
||||
setSelectedIds(new Set());
|
||||
|
||||
let agents: Agent[];
|
||||
try {
|
||||
agents = await listAgents();
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
if (candidates.length === 0) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "recommending",
|
||||
candidates,
|
||||
rationale: "",
|
||||
streamPhase: "connecting",
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await streamRecommend({
|
||||
intent,
|
||||
candidates,
|
||||
model,
|
||||
signal: controller.signal,
|
||||
onRationaleUpdate: (rationale) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, rationale }));
|
||||
},
|
||||
onPicksUpdate: (picks) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of seed) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
}));
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, streamPhase: phase }));
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
if (result.picks) {
|
||||
for (const p of result.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "ready",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
const seed = fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "fallback",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
|
||||
}));
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
// AbortError fires when the user closes the dialog mid-stream — no
|
||||
// state update needed because the dialog is going away.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
|
||||
}));
|
||||
}
|
||||
}, [intent, model, cancelInflight]);
|
||||
|
||||
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
|
||||
// in-flight stream so the next open starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
void runRecommendation();
|
||||
return () => {
|
||||
cancelInflight();
|
||||
};
|
||||
}, [open, runRecommendation, cancelInflight]);
|
||||
|
||||
const handleToggle = useCallback((agentId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) next.delete(agentId);
|
||||
else next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Split candidates into "recommended" and "other" — order within each
|
||||
// group matches the recommender's pick order / the API's list order so the
|
||||
// dialog is deterministic between renders.
|
||||
const { recommendedCards, otherCards } = useMemo(() => {
|
||||
const recSet = new Set(state.recommendedIds);
|
||||
const byId = new Map(state.candidates.map((c) => [c.agent_id, c]));
|
||||
const recommended: RecommendCandidate[] = [];
|
||||
for (const id of state.recommendedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) recommended.push(c);
|
||||
}
|
||||
const others = state.candidates.filter((c) => !recSet.has(c.agent_id));
|
||||
return { recommendedCards: recommended, otherCards: others };
|
||||
}, [state.candidates, state.recommendedIds]);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const canConfirm =
|
||||
(state.phase === "ready" || state.phase === "fallback") &&
|
||||
selectedCount >= MIN_SELECTED &&
|
||||
selectedCount <= MAX_SELECTED;
|
||||
|
||||
const selectionHint = (() => {
|
||||
if (selectedCount < MIN_SELECTED) {
|
||||
return `至少再选 ${MIN_SELECTED - selectedCount} 个`;
|
||||
}
|
||||
if (selectedCount > MAX_SELECTED) {
|
||||
return `已超出上限 ${selectedCount - MAX_SELECTED} 个`;
|
||||
}
|
||||
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
|
||||
})();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canConfirm) return;
|
||||
// Order: recommended-and-selected first (in recommender order), then
|
||||
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
|
||||
// list visually stable.
|
||||
const ordered: string[] = [];
|
||||
for (const c of recommendedCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
}
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
}
|
||||
onConfirm(ordered);
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-purple-500" />
|
||||
选择参会智能体
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Rationale + status block */}
|
||||
<div className="border rounded-md bg-muted/30 p-3 text-sm">
|
||||
<RationaleBlock state={state} />
|
||||
</div>
|
||||
|
||||
{/* Selection area: scrollable, takes remaining height */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
||||
{state.phase === "error" ? (
|
||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||
{state.errorMessage ?? "未知错误"}
|
||||
</div>
|
||||
) : state.phase === "recommending" ? (
|
||||
<>
|
||||
{recommendedCards.length > 0 ? (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
streaming
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
等待推荐结果...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (state.phase === "ready" || state.phase === "fallback") ? (
|
||||
<>
|
||||
{state.warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
|
||||
{state.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recommendedCards.length > 0 && (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{otherCards.length > 0 && (
|
||||
<Section title={`其他候选 (${otherCards.length})`}>
|
||||
{otherCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={undefined}
|
||||
recommended={false}
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectionHint}
|
||||
</span>
|
||||
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void runRecommendation()}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon className="size-3 mr-1" />
|
||||
重新推荐
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
进入研讨
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RationaleBlock({ state }: { state: InternalState }) {
|
||||
if (state.phase === "loading_agents") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载候选智能体...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "recommending") {
|
||||
const label =
|
||||
state.streamPhase === "connecting"
|
||||
? "调度推荐智能体..."
|
||||
: state.streamPhase === "connected"
|
||||
? "已连接,等待响应..."
|
||||
: "正在生成推荐...";
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
{state.rationale ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
|
||||
{state.rationale}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "ready" || state.phase === "fallback") {
|
||||
if (!state.rationale) {
|
||||
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
|
||||
}
|
||||
return (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
|
||||
{state.rationale}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.phase === "error") {
|
||||
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</div>
|
||||
<div className="grid gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
candidate: RecommendCandidate;
|
||||
reason: string | undefined;
|
||||
recommended: boolean;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
/** Card is still being filled in from the SSE stream. */
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
candidate,
|
||||
reason,
|
||||
recommended,
|
||||
checked,
|
||||
onToggle,
|
||||
streaming = false,
|
||||
}: AgentCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
|
||||
checked
|
||||
? "border-primary bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border hover:bg-muted/40",
|
||||
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
|
||||
)}
|
||||
>
|
||||
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
|
||||
{recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
|
||||
推荐
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{candidate.description}
|
||||
</div>
|
||||
)}
|
||||
{reason ? (
|
||||
<div className="text-xs text-primary/80 mt-1 italic">
|
||||
<span className="font-medium not-italic">推荐理由:</span> {reason}
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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,10 @@
|
||||
/**
|
||||
* 圆桌规划(Roundtable Planning)模块入口。
|
||||
*
|
||||
* 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。
|
||||
* 这里只暴露两个外部使用的组件:
|
||||
* - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。
|
||||
* - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。
|
||||
*/
|
||||
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
|
||||
export { HighFidelityReport } from "./components/HighFidelityReport";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolve whether option cards should allow multi-select.
|
||||
* Default is single-select (click to send); only multi when explicitly requested.
|
||||
*/
|
||||
export function resolveAllowMultiple(
|
||||
allowMultiple: boolean | undefined,
|
||||
_clarificationType?: string | undefined,
|
||||
): boolean {
|
||||
if (typeof allowMultiple === "boolean") {
|
||||
return allowMultiple;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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 })}`;
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
|
||||
import { NotificationDialog } from '@/components/workspace/notification-dialog';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useUnreadCount } from '@/core/notifications';
|
||||
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
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 isDark = theme === 'dark';
|
||||
const isDark = theme === "dark";
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
|
||||
@@ -16,72 +28,110 @@ const Header: React.FC = () => {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
|
||||
: 'bg-red-600 border-b border-red-700'
|
||||
}`}>
|
||||
{/* 左侧 - 搜索框 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<header
|
||||
className={cn(
|
||||
"theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||
isDark
|
||||
? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
|
||||
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 右侧 - 用户信息和通知 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
|
||||
: 'hover:bg-red-500 text-white'
|
||||
}`}
|
||||
title={isDark ? '切换到红白主题' : '切换到深色主题'}
|
||||
>
|
||||
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setNotificationOpen(true)}
|
||||
className={cn(iconButtonClass(isDark), "relative")}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"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",
|
||||
isDark
|
||||
? "bg-cyan-500 text-white"
|
||||
: "bg-white tabular-nums text-[var(--brand-900)]",
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 通知 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationOpen(true)}
|
||||
className={`relative p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
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 ${
|
||||
isDark ? 'bg-cyan-500' : 'bg-white text-red-600'
|
||||
}`}
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-8 w-px",
|
||||
isDark ? "bg-blue-800/30" : "bg-white/30",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 gap-2 rounded-sm px-2",
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
|
||||
|
||||
{/* 用户信息 */}
|
||||
<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'
|
||||
}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white'
|
||||
}`}>
|
||||
<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 className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>策略分析师</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<User
|
||||
className={cn(
|
||||
"size-4",
|
||||
isDark ? "text-white" : "text-[var(--brand-900)]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden text-left sm:block">
|
||||
<p className="text-sm leading-tight font-medium text-white">
|
||||
张策略
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs leading-tight",
|
||||
isDark ? "text-blue-400/60" : "text-white/75",
|
||||
)}
|
||||
>
|
||||
策略分析师
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isDark ? "text-slate-500" : "text-white/80",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+821
-813
File diff suppressed because it is too large.
Load diff
@@ -3,11 +3,12 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
|
||||
|
||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [sourceIdentifierPlugin(), react()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
测试流式接口的完整脚本
|
||||
API流程:
|
||||
1. 登录获取token
|
||||
2. 创建thread(可选,也可以使用无状态接口)
|
||||
3. 调用流式接口
|
||||
|
||||
两种方式:
|
||||
- 方式1: POST /api/runs/stream (无状态,自动创建thread)
|
||||
- 方式2: POST /api/threads/{thread_id}/runs/stream (需要先创建thread)
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import asyncio
|
||||
import httpx # 异步 HTTP 客户端
|
||||
# ============ 配置区域 ============
|
||||
BASE_URL = "http://127.0.0.1:8001"
|
||||
LOGIN_URL = f"{BASE_URL}/api/v1/auth/login/username"
|
||||
THREADS_URL = f"{BASE_URL}/api/threads"
|
||||
RUNS_STREAM_URL = f"{BASE_URL}/api/runs/stream" # 无状态流式接口
|
||||
|
||||
# ============ 登录获取token ============
|
||||
def get_token(username: str) -> str:
|
||||
"""登录获取token"""
|
||||
response = requests.post(
|
||||
LOGIN_URL,
|
||||
json={"username": username},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# print(f"登录状态码: {response.status_code}")
|
||||
data = response.json()
|
||||
# print(f"登录响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
|
||||
# 根据实际返回结构调整
|
||||
token = data.get("token") or data.get("access_token") or data.get("data", {}).get("token")
|
||||
return token
|
||||
# ============ 方式2: 先创建thread,再调用流式接口 ============
|
||||
def create_thread(token: str) -> str:
|
||||
"""创建thread,返回thread_id"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
THREADS_URL,
|
||||
json={}, # 空body,使用默认值
|
||||
headers=headers
|
||||
)
|
||||
|
||||
print(f"创建thread状态码: {response.status_code}")
|
||||
data = response.json()
|
||||
print(f"创建thread响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
|
||||
return data.get("thread_id")
|
||||
def get_agent_name_description(agent_name="Taiwan-intelligence-gathering-agent"):
|
||||
url = f"{BASE_URL}/api/agents/{agent_name}"
|
||||
token=get_token("ji")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
)
|
||||
print(response.json())
|
||||
return response.json()["name"].lower(),"agent_name:"+response.json()["name"].lower()+","+response.json()["description"]
|
||||
|
||||
# ============ 创建智能体 ============
|
||||
def create_agent(
|
||||
name: str,
|
||||
description: str = "",
|
||||
model: str | None = None,
|
||||
tool_groups: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
soul: str = "",
|
||||
):
|
||||
"""
|
||||
创建一个新的智能体
|
||||
|
||||
Args:
|
||||
name: 智能体名称(必须是 hyphen-case,如 my-agent)
|
||||
description: 智能体描述
|
||||
model: 可选的模型覆盖(如 gpt-4, claude-3-opus 等)
|
||||
tool_groups: 工具组白名单(如 ["web", "file"])
|
||||
skills: 技能白名单(如 ["skill1", "skill2"])
|
||||
soul: SOUL.md 内容(智能体人格/身份描述)
|
||||
|
||||
Returns:
|
||||
创建结果
|
||||
"""
|
||||
TOKEN =get_token("ji")
|
||||
url = f"{BASE_URL}/api/agents"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {TOKEN}"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"soul": soul
|
||||
}
|
||||
|
||||
# 添加可选字段
|
||||
if model is not None:
|
||||
payload["model"] = model
|
||||
if tool_groups is not None:
|
||||
payload["tool_groups"] = tool_groups
|
||||
if skills is not None:
|
||||
payload["skills"] = skills
|
||||
|
||||
print(f"请求: POST {url}")
|
||||
print(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
print(f"✓ 智能体创建成功!")
|
||||
print(f"响应: {json.dumps(result, indent=2, ensure_ascii=False)}")
|
||||
return result
|
||||
else:
|
||||
print(f"✗ 创建失败: {response.text}")
|
||||
return f"✗ 创建失败: {response.text}"
|
||||
|
||||
def create_main_agent(zhihui_name,agent_names):
|
||||
d={}
|
||||
token=get_token("ji")
|
||||
l=[]
|
||||
for i in agent_names:
|
||||
# print(i)
|
||||
name,description=get_agent_name_description(i)
|
||||
thread_id_tmp=create_thread(token)
|
||||
d[name]=thread_id_tmp
|
||||
l.append(description)
|
||||
name="你是总控控制协调智能体,负责协调其他智能体干活"
|
||||
miaoshu=f"""#核心定位:
|
||||
用途:跟用用户的任务目标,调用其他智能体干活,只负责协调汇总,当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
|
||||
#调度方法:
|
||||
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
|
||||
#可调度智能体:
|
||||
{l}"""
|
||||
thread_id_tmp=create_thread(token)
|
||||
engname=zhihui_name
|
||||
d[engname]=thread_id_tmp
|
||||
tmp_skills=["agent_orchestration"]
|
||||
tmpres=create_agent(
|
||||
name=engname,
|
||||
description=name,
|
||||
model="glm-5", # 指定模型
|
||||
skills=tmp_skills, # 关联技能
|
||||
soul=miaoshu)
|
||||
# print("创建智能体响应:",tmpres)
|
||||
if "失败" in tmpres and "exists" not in tmpres:
|
||||
return tmpres
|
||||
print(f"创建协调智能体成功,可以协调列表:{l}")
|
||||
return d
|
||||
import re
|
||||
def extract_name_task(text="python3 scripts/agent_orchestration.py \"Us-intelligence-gathering-agent\" \"收集和分析美国人民对特朗普的看法,包括支持率和反对率、不同群体的观点差异、主要支持理由和反对理由等\""):
|
||||
try:
|
||||
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
agent_name = match.group(1)
|
||||
task_content = match.group(2)
|
||||
return agent_name,task_content
|
||||
except:
|
||||
return "",""
|
||||
def update_agent_message(thread_id,new_content):
|
||||
token=get_token("ji")
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/state"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
tmp = requests.get(
|
||||
url,
|
||||
headers=headers
|
||||
).json()
|
||||
# print(tmp)
|
||||
if "messages" in tmp["values"]:
|
||||
tmp["values"]["messages"].append({"type": "human", "content": new_content})
|
||||
else:
|
||||
tmp["values"]["messages"]=[]
|
||||
tmp["values"]["messages"].append({"type": "human", "content": new_content})
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/state"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
json=tmp,
|
||||
headers=headers,
|
||||
stream=True
|
||||
)
|
||||
del tmp
|
||||
print(f"线程{thread_id}更新状态码: {response.status_code}")
|
||||
def Broadcast_message(message,d_agent_thread_id,pingbi_agent_name):
|
||||
for key in d_agent_thread_id:
|
||||
if key not in pingbi_agent_name:
|
||||
update_agent_message(d_agent_thread_id[key],message)
|
||||
print("广播消息成功:",key,message)
|
||||
null=None
|
||||
true=True
|
||||
false=False
|
||||
async def leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
|
||||
"""使用 httpx 异步请求的 leader_agent"""
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, "总控智能体收到用户消息:" + new_message, d_agent_thread_id, [agent_name])
|
||||
thread_id = d_agent_thread_id[agent_name]
|
||||
l = []
|
||||
token = get_token("ji")
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
payload = {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": new_message
|
||||
}
|
||||
],
|
||||
"additional_kwargs": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"recursion_limit": 1000
|
||||
},
|
||||
"context": {
|
||||
"agent_name": agent_name,
|
||||
"model_name": "deepseek-chat",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "medium",
|
||||
"thinking_enabled": true,
|
||||
"is_plan_mode": false,
|
||||
"subagent_enabled": false,
|
||||
"thread_id": thread_id
|
||||
},
|
||||
"stream_mode": [
|
||||
"messages-tuple",
|
||||
"values",
|
||||
],
|
||||
"stream_subgraphs": true,
|
||||
"stream_resumable": true,
|
||||
"assistant_id": "lead_agent",
|
||||
"on_disconnect": "continue",
|
||||
"excluded_tools": ["web_search"],
|
||||
"skill_stop_names": skill_stop_names,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
print(f"状态码: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
body = await response.aread()
|
||||
raise RuntimeError(
|
||||
f"upstream {url} returned HTTP {response.status_code}: "
|
||||
f"{body.decode('utf-8', 'replace')[:1000]}"
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
if line[:5] == "data:":
|
||||
print(line)
|
||||
l.append(line)
|
||||
if len(l) < 2:
|
||||
raise RuntimeError(
|
||||
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
|
||||
f"Check the server log on {url} for the real failure."
|
||||
)
|
||||
|
||||
null = None
|
||||
tmpres = eval(l[-2][5:])
|
||||
tmpres = tmpres["messages"][-1]
|
||||
tmp_task = []
|
||||
if tmpres.get("tool_calls", []) == []:
|
||||
yield {"status": []}
|
||||
else:
|
||||
for tool_call in tmpres.get("tool_calls", []):
|
||||
try:
|
||||
agentname, task_text = extract_name_task(str(tool_call["args"]))
|
||||
print("agentname", agentname)
|
||||
print("task_text:", task_text)
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, f"总控智能体给{agentname}派活,内容为:" + task_text, d_agent_thread_id, [agent_name, agentname])
|
||||
tmp_task.append([agentname, task_text])
|
||||
except:
|
||||
pass
|
||||
yield {"status": tmp_task}
|
||||
|
||||
|
||||
async def special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
|
||||
"""使用 httpx 异步请求的 special_agent"""
|
||||
thread_id = d_agent_thread_id[agent_name]
|
||||
l = []
|
||||
token = get_token("ji")
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
payload = {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": new_message
|
||||
}
|
||||
],
|
||||
"additional_kwargs": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"recursion_limit": 1000
|
||||
},
|
||||
"context": {
|
||||
"agent_name": agent_name,
|
||||
"model_name": "deepseek-chat",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "medium",
|
||||
"thinking_enabled": true,
|
||||
"is_plan_mode": false,
|
||||
"subagent_enabled": false,
|
||||
"thread_id": thread_id
|
||||
},
|
||||
"stream_mode": [
|
||||
"messages-tuple",
|
||||
"values",
|
||||
],
|
||||
"stream_subgraphs": true,
|
||||
"stream_resumable": true,
|
||||
"assistant_id": "lead_agent",
|
||||
"on_disconnect": "continue",
|
||||
"excluded_tools": ["web_search", "ask_clarification", "present_files", "view_image"],
|
||||
"skill_stop_names": skill_stop_names,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
print(f"状态码: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
body = await response.aread()
|
||||
raise RuntimeError(
|
||||
f"upstream {url} returned HTTP {response.status_code}: "
|
||||
f"{body.decode('utf-8', 'replace')[:1000]}"
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
if line[:5] == "data:":
|
||||
print(line)
|
||||
l.append(line)
|
||||
if len(l) < 2:
|
||||
raise RuntimeError(
|
||||
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
|
||||
f"Check the server log on {url} for the real failure."
|
||||
)
|
||||
|
||||
null = None
|
||||
tmpres = eval(l[-2][5:])
|
||||
tmpres = tmpres["messages"][-1]
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + tmpres["content"], d_agent_thread_id, [agent_name])
|
||||
yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
|
||||
|
||||
async def agent_init(agent_names, main_agent_name):
|
||||
"""异步的 agent_init,同步函数调用使用 to_thread"""
|
||||
agent_names = [i.lower() for i in agent_names]
|
||||
# 使用 to_thread 异步运行同步的 create_main_agent
|
||||
tmpd = await asyncio.to_thread(create_main_agent, main_agent_name, agent_names)
|
||||
message = """
|
||||
你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活
|
||||
注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,你只关心于完成总控控制协调智能体分配的活即可
|
||||
"""
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, message, tmpd, [main_agent_name])
|
||||
return main_agent_name, tmpd
|
||||
|
||||
|
||||
from fastapi import FastAPI, HTTPException,Body
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncio
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI()
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
import time
|
||||
@app.post("/generate_mul_agent")
|
||||
async def generate_stream(payload: dict = Body(...)):
|
||||
"""
|
||||
根据 payload 中的 agent_type 字段判断调用 leader_agent 还是 special_agent
|
||||
|
||||
payload 结构示例:
|
||||
{
|
||||
"agent_type": "leader", # 或 "special"
|
||||
"d_agent_thread_id": {...},
|
||||
"agent_name": "xxx",
|
||||
"new_message": "xxx",
|
||||
"skill_stop_names": [...]
|
||||
}
|
||||
"""
|
||||
# 从 payload 中提取参数
|
||||
agent_type = payload.get("agent_type", "leader") # 默认为 leader
|
||||
d_agent_thread_id = payload.get("d_agent_thread_id", {})
|
||||
agent_name = payload.get("agent_name", "")
|
||||
new_message = payload.get("new_message", "")
|
||||
skill_stop_names = payload.get("skill_stop_names", [])
|
||||
|
||||
# 流式响应生成器
|
||||
async def stream_generator():
|
||||
# 根据 agent_type 选择调用的函数
|
||||
if agent_type == "leader":
|
||||
async_generator = leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
|
||||
else: # special
|
||||
async_generator = special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
|
||||
|
||||
# 异步迭代生成器
|
||||
async for chunk in async_generator:
|
||||
if isinstance(chunk, dict):
|
||||
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"{chunk}"
|
||||
|
||||
# 使用 SSE 流式响应
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/agent_init")
|
||||
async def init_agents(payload: dict = Body(...)):
|
||||
"""
|
||||
初始化智能体接口
|
||||
payload 结构示例:
|
||||
{
|
||||
"agent_names": ["agent1", "agent2"],
|
||||
"main_agent_name": "main-agent"
|
||||
}
|
||||
"""
|
||||
agent_names = payload.get("agent_names", [])
|
||||
main_agent_name = payload.get("main_agent_name", "main-agent")
|
||||
|
||||
# 调用异步的 agent_init
|
||||
name, thread_id_dict = await agent_init(agent_names, main_agent_name)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"main_agent_name": name,
|
||||
"thread_ids": thread_id_dict
|
||||
}
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("testapi:app", host="0.0.0.0", port=8095, workers=1,limit_concurrency=1000) # 多进程提升并发能力
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
测试流式接口的完整脚本
|
||||
API流程:
|
||||
1. 登录获取token
|
||||
2. 创建thread(可选,也可以使用无状态接口)
|
||||
3. 调用流式接口
|
||||
|
||||
两种方式:
|
||||
- 方式1: POST /api/runs/stream (无状态,自动创建thread)
|
||||
- 方式2: POST /api/threads/{thread_id}/runs/stream (需要先创建thread)
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import asyncio
|
||||
import httpx # 异步 HTTP 客户端
|
||||
# ============ 配置区域 ============
|
||||
BASE_URL = "http://127.0.0.1:8001"
|
||||
LOGIN_URL = f"{BASE_URL}/api/v1/auth/login/username"
|
||||
THREADS_URL = f"{BASE_URL}/api/threads"
|
||||
RUNS_STREAM_URL = f"{BASE_URL}/api/runs/stream" # 无状态流式接口
|
||||
|
||||
# ============ 登录获取token ============
|
||||
def get_token(username: str) -> str:
|
||||
"""登录获取token"""
|
||||
response = requests.post(
|
||||
LOGIN_URL,
|
||||
json={"username": username},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# print(f"登录状态码: {response.status_code}")
|
||||
data = response.json()
|
||||
# print(f"登录响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
|
||||
# 根据实际返回结构调整
|
||||
token = data.get("token") or data.get("access_token") or data.get("data", {}).get("token")
|
||||
return token
|
||||
# ============ 方式2: 先创建thread,再调用流式接口 ============
|
||||
def create_thread(token: str) -> str:
|
||||
"""创建thread,返回thread_id"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
THREADS_URL,
|
||||
json={}, # 空body,使用默认值
|
||||
headers=headers
|
||||
)
|
||||
|
||||
print(f"创建thread状态码: {response.status_code}")
|
||||
data = response.json()
|
||||
print(f"创建thread响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
|
||||
return data.get("thread_id")
|
||||
def get_agent_name_description(agent_name="Taiwan-intelligence-gathering-agent"):
|
||||
url = f"{BASE_URL}/api/agents/{agent_name}"
|
||||
token=get_token("ji")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
)
|
||||
print(response.json())
|
||||
return response.json()["name"].lower(),"agent_name:"+response.json()["name"].lower()+","+response.json()["description"]
|
||||
|
||||
# ============ 创建智能体 ============
|
||||
def create_agent(
|
||||
name: str,
|
||||
description: str = "",
|
||||
model: str | None = None,
|
||||
tool_groups: list[str] | None = None,
|
||||
skills: list[str] | None = None,
|
||||
soul: str = "",
|
||||
):
|
||||
"""
|
||||
创建一个新的智能体
|
||||
|
||||
Args:
|
||||
name: 智能体名称(必须是 hyphen-case,如 my-agent)
|
||||
description: 智能体描述
|
||||
model: 可选的模型覆盖(如 gpt-4, claude-3-opus 等)
|
||||
tool_groups: 工具组白名单(如 ["web", "file"])
|
||||
skills: 技能白名单(如 ["skill1", "skill2"])
|
||||
soul: SOUL.md 内容(智能体人格/身份描述)
|
||||
|
||||
Returns:
|
||||
创建结果
|
||||
"""
|
||||
TOKEN =get_token("ji")
|
||||
url = f"{BASE_URL}/api/agents"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {TOKEN}"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"soul": soul
|
||||
}
|
||||
|
||||
# 添加可选字段
|
||||
if model is not None:
|
||||
payload["model"] = model
|
||||
if tool_groups is not None:
|
||||
payload["tool_groups"] = tool_groups
|
||||
if skills is not None:
|
||||
payload["skills"] = skills
|
||||
|
||||
print(f"请求: POST {url}")
|
||||
print(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
print(f"✓ 智能体创建成功!")
|
||||
print(f"响应: {json.dumps(result, indent=2, ensure_ascii=False)}")
|
||||
return result
|
||||
else:
|
||||
print(f"✗ 创建失败: {response.text}")
|
||||
return f"✗ 创建失败: {response.text}"
|
||||
|
||||
def create_main_agent(zhihui_name,agent_names):
|
||||
d={}
|
||||
token=get_token("ji")
|
||||
l=[]
|
||||
for i in agent_names:
|
||||
# print(i)
|
||||
name,description=get_agent_name_description(i)
|
||||
thread_id_tmp=create_thread(token)
|
||||
d[name]=thread_id_tmp
|
||||
l.append(description)
|
||||
name="你是总控控制协调智能体,负责协调其他智能体干活"
|
||||
miaoshu=f"""#核心定位:
|
||||
用途:跟用用户的任务目标,调用其他智能体干活,只负责协调汇总,当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
|
||||
#调度方法:
|
||||
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
|
||||
#可调度智能体:
|
||||
{l}"""
|
||||
thread_id_tmp=create_thread(token)
|
||||
engname=zhihui_name
|
||||
d[engname]=thread_id_tmp
|
||||
tmp_skills=["agent_orchestration"]
|
||||
tmpres=create_agent(
|
||||
name=engname,
|
||||
description=name,
|
||||
model="glm-5", # 指定模型
|
||||
skills=tmp_skills, # 关联技能
|
||||
soul=miaoshu)
|
||||
# print("创建智能体响应:",tmpres)
|
||||
if "失败" in tmpres and "exists" not in tmpres:
|
||||
return tmpres
|
||||
print(f"创建协调智能体成功,可以协调列表:{l}")
|
||||
return d
|
||||
import re
|
||||
def extract_name_task(text="python3 scripts/agent_orchestration.py \"Us-intelligence-gathering-agent\" \"收集和分析美国人民对特朗普的看法,包括支持率和反对率、不同群体的观点差异、主要支持理由和反对理由等\""):
|
||||
try:
|
||||
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
agent_name = match.group(1)
|
||||
task_content = match.group(2)
|
||||
return agent_name,task_content
|
||||
except:
|
||||
return "",""
|
||||
def update_agent_message(thread_id,new_content):
|
||||
token=get_token("ji")
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/state"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
tmp = requests.get(
|
||||
url,
|
||||
headers=headers
|
||||
).json()
|
||||
# print(tmp)
|
||||
if "messages" in tmp["values"]:
|
||||
tmp["values"]["messages"].append({"type": "human", "content": new_content})
|
||||
else:
|
||||
tmp["values"]["messages"]=[]
|
||||
tmp["values"]["messages"].append({"type": "human", "content": new_content})
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/state"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
response = requests.post(
|
||||
url,
|
||||
json=tmp,
|
||||
headers=headers,
|
||||
stream=True
|
||||
)
|
||||
del tmp
|
||||
print(f"线程{thread_id}更新状态码: {response.status_code}")
|
||||
def Broadcast_message(message,d_agent_thread_id,pingbi_agent_name):
|
||||
for key in d_agent_thread_id:
|
||||
if key not in pingbi_agent_name:
|
||||
update_agent_message(d_agent_thread_id[key],message)
|
||||
print("广播消息成功:",key,message)
|
||||
null=None
|
||||
true=True
|
||||
false=False
|
||||
async def leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
|
||||
"""使用 httpx 异步请求的 leader_agent"""
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, "总控智能体收到用户消息:" + new_message, d_agent_thread_id, [agent_name])
|
||||
thread_id = d_agent_thread_id[agent_name]
|
||||
l = []
|
||||
token = get_token("ji")
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
payload = {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": new_message
|
||||
}
|
||||
],
|
||||
"additional_kwargs": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"recursion_limit": 1000
|
||||
},
|
||||
"context": {
|
||||
"agent_name": agent_name,
|
||||
"model_name": "deepseek-chat",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "medium",
|
||||
"thinking_enabled": true,
|
||||
"is_plan_mode": false,
|
||||
"subagent_enabled": false,
|
||||
"thread_id": thread_id
|
||||
},
|
||||
"stream_mode": [
|
||||
"messages-tuple",
|
||||
"values",
|
||||
],
|
||||
"stream_subgraphs": true,
|
||||
"stream_resumable": true,
|
||||
"assistant_id": "lead_agent",
|
||||
"on_disconnect": "continue",
|
||||
"excluded_tools": ["web_search"],
|
||||
"skill_stop_names": skill_stop_names,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
print(f"状态码: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
body = await response.aread()
|
||||
raise RuntimeError(
|
||||
f"upstream {url} returned HTTP {response.status_code}: "
|
||||
f"{body.decode('utf-8', 'replace')[:1000]}"
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
if line[:5] == "data:":
|
||||
print(line)
|
||||
l.append(line)
|
||||
if len(l) < 2:
|
||||
raise RuntimeError(
|
||||
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
|
||||
f"Check the server log on {url} for the real failure."
|
||||
)
|
||||
|
||||
null = None
|
||||
tmpres = eval(l[-2][5:])
|
||||
tmpres = tmpres["messages"][-1]
|
||||
tmp_task = []
|
||||
if tmpres.get("tool_calls", []) == []:
|
||||
yield {"status": []}
|
||||
else:
|
||||
for tool_call in tmpres.get("tool_calls", []):
|
||||
try:
|
||||
agentname, task_text = extract_name_task(str(tool_call["args"]))
|
||||
print("agentname", agentname)
|
||||
print("task_text:", task_text)
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, f"总控智能体给{agentname}派活,内容为:" + task_text, d_agent_thread_id, [agent_name, agentname])
|
||||
tmp_task.append([agentname, task_text])
|
||||
except:
|
||||
pass
|
||||
yield {"status": tmp_task}
|
||||
|
||||
|
||||
async def special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
|
||||
"""使用 httpx 异步请求的 special_agent"""
|
||||
thread_id = d_agent_thread_id[agent_name]
|
||||
l = []
|
||||
token = get_token("ji")
|
||||
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
payload = {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": new_message
|
||||
}
|
||||
],
|
||||
"additional_kwargs": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"recursion_limit": 1000
|
||||
},
|
||||
"context": {
|
||||
"agent_name": agent_name,
|
||||
"model_name": "deepseek-chat",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "medium",
|
||||
"thinking_enabled": true,
|
||||
"is_plan_mode": false,
|
||||
"subagent_enabled": false,
|
||||
"thread_id": thread_id
|
||||
},
|
||||
"stream_mode": [
|
||||
"messages-tuple",
|
||||
"values",
|
||||
],
|
||||
"stream_subgraphs": true,
|
||||
"stream_resumable": true,
|
||||
"assistant_id": "lead_agent",
|
||||
"on_disconnect": "continue",
|
||||
"excluded_tools": ["web_search", "ask_clarification", "present_files", "view_image"],
|
||||
"skill_stop_names": skill_stop_names,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as response:
|
||||
print(f"状态码: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
body = await response.aread()
|
||||
raise RuntimeError(
|
||||
f"upstream {url} returned HTTP {response.status_code}: "
|
||||
f"{body.decode('utf-8', 'replace')[:1000]}"
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
if line[:5] == "data:":
|
||||
print(line)
|
||||
l.append(line)
|
||||
if len(l) < 2:
|
||||
raise RuntimeError(
|
||||
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
|
||||
f"Check the server log on {url} for the real failure."
|
||||
)
|
||||
|
||||
null = None
|
||||
tmpres = eval(l[-2][5:])
|
||||
tmpres = tmpres["messages"][-1]
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + tmpres["content"], d_agent_thread_id, [agent_name])
|
||||
yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
|
||||
|
||||
async def agent_init(agent_names, main_agent_name):
|
||||
"""异步的 agent_init,同步函数调用使用 to_thread"""
|
||||
agent_names = [i.lower() for i in agent_names]
|
||||
# 使用 to_thread 异步运行同步的 create_main_agent
|
||||
tmpd = await asyncio.to_thread(create_main_agent, main_agent_name, agent_names)
|
||||
message = """
|
||||
你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活
|
||||
注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,你只关心于完成总控控制协调智能体分配的活即可
|
||||
"""
|
||||
# 使用 to_thread 异步运行同步的 Broadcast_message
|
||||
await asyncio.to_thread(Broadcast_message, message, tmpd, [main_agent_name])
|
||||
return main_agent_name, tmpd
|
||||
|
||||
|
||||
from fastapi import FastAPI, HTTPException,Body
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncio
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI()
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
import time
|
||||
@app.post("/generate_mul_agent")
|
||||
async def generate_stream(payload: dict = Body(...)):
|
||||
"""
|
||||
根据 payload 中的 agent_type 字段判断调用 leader_agent 还是 special_agent
|
||||
|
||||
payload 结构示例:
|
||||
{
|
||||
"agent_type": "leader", # 或 "special"
|
||||
"d_agent_thread_id": {...},
|
||||
"agent_name": "xxx",
|
||||
"new_message": "xxx",
|
||||
"skill_stop_names": [...]
|
||||
}
|
||||
"""
|
||||
# 从 payload 中提取参数
|
||||
agent_type = payload.get("agent_type", "leader") # 默认为 leader
|
||||
d_agent_thread_id = payload.get("d_agent_thread_id", {})
|
||||
agent_name = payload.get("agent_name", "")
|
||||
new_message = payload.get("new_message", "")
|
||||
skill_stop_names = payload.get("skill_stop_names", [])
|
||||
|
||||
# 流式响应生成器
|
||||
async def stream_generator():
|
||||
# 根据 agent_type 选择调用的函数
|
||||
if agent_type == "leader":
|
||||
async_generator = leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
|
||||
else: # special
|
||||
async_generator = special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
|
||||
|
||||
# 异步迭代生成器
|
||||
async for chunk in async_generator:
|
||||
if isinstance(chunk, dict):
|
||||
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"{chunk}"
|
||||
|
||||
# 使用 SSE 流式响应
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/agent_init")
|
||||
async def init_agents(payload: dict = Body(...)):
|
||||
"""
|
||||
初始化智能体接口
|
||||
payload 结构示例:
|
||||
{
|
||||
"agent_names": ["agent1", "agent2"],
|
||||
"main_agent_name": "main-agent"
|
||||
}
|
||||
"""
|
||||
agent_names = payload.get("agent_names", [])
|
||||
main_agent_name = payload.get("main_agent_name", "main-agent")
|
||||
|
||||
# 调用异步的 agent_init
|
||||
name, thread_id_dict = await agent_init(agent_names, main_agent_name)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"main_agent_name": name,
|
||||
"thread_ids": thread_id_dict
|
||||
}
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("testapi:app", host="0.0.0.0", port=8095, workers=1,limit_concurrency=1000) # 多进程提升并发能力
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
OUT.py 的调用链 demo —— 跑通 agent_init → leader 派活 → 各 special agent 干活 这一整套流程。
|
||||
|
||||
前置条件:
|
||||
1. DeerFlow 后端在 http://127.0.0.1:8001 在跑
|
||||
2. OUT.py 已经 `python -m uvicorn OUT:app --host 0.0.0.0 --port 8095` 起好
|
||||
3. 下面 SUB_AGENT_NAMES 里的 agent 已经在 DeerFlow 里创建好(/api/agents 能查到)
|
||||
|
||||
直接 `python demo_call.py` 即可。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
# ============ 改这里 ============
|
||||
OUT_BASE = "http://127.0.0.1:8095"
|
||||
|
||||
# 已经在 DeerFlow 里建好的子 agent 名字 (会被 OUT.py 强制 lowercase)
|
||||
SUB_AGENT_NAMES = [
|
||||
"us-intelligence-gathering-agent",
|
||||
"taiwan-intelligence-gathering-agent",
|
||||
]
|
||||
|
||||
# 总控 agent 名字 —— OUT.py 会现场创建它,所以填一个新名字即可 (hyphen-case)
|
||||
MAIN_AGENT_NAME = "demo-coordinator"
|
||||
|
||||
# 用户提问 —— 总控会基于此派活
|
||||
USER_QUESTION = "对比中美两国 2024 年在 AI 监管政策上的差异"
|
||||
|
||||
# leader 阶段命中即停的 skill 名字单 (OUT.py 用 agent_orchestration 作为派活信号)
|
||||
SKILL_STOP_NAMES = ["agent_orchestration"]
|
||||
# ================================
|
||||
|
||||
|
||||
async def call_agent_init() -> tuple[str, dict[str, str]]:
|
||||
"""步骤 1: 初始化 —— 拿回 thread_ids 字典 {agent_name → thread_id}."""
|
||||
print(">>> [1/3] POST /agent_init")
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
r = await client.post(
|
||||
f"{OUT_BASE}/agent_init",
|
||||
json={
|
||||
"agent_names": SUB_AGENT_NAMES,
|
||||
"main_agent_name": MAIN_AGENT_NAME,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
main_name = body["main_agent_name"]
|
||||
thread_ids = body["thread_ids"]
|
||||
print(f" main_agent_name = {main_name}")
|
||||
print(f" thread_ids = {json.dumps(thread_ids, ensure_ascii=False, indent=2)}")
|
||||
return main_name, thread_ids
|
||||
|
||||
|
||||
async def stream_generate(payload: dict) -> object | None:
|
||||
"""通用 SSE 消费:边收边打印,最后返回 OUT.py 自己 yield 的 {'status': ...} 里的 status 字段."""
|
||||
final_status = None
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", f"{OUT_BASE}/generate_mul_agent", json=payload) as resp:
|
||||
print(f" HTTP {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
err = await resp.aread()
|
||||
raise RuntimeError(f"OUT.py /generate_mul_agent failed: {err.decode('utf-8', 'replace')[:500]}")
|
||||
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
# OUT.py 把 DeerFlow 的原始 SSE 流直接转发,自己最后会再 yield 一行
|
||||
# `data: {"status": ...}\n\n`。两边都以 `data:` 开头,我们都尝试 JSON parse
|
||||
# 一下,能解析成 {"status": ...} 的就是 OUT.py 的终态。
|
||||
if line.startswith("data:"):
|
||||
body_text = line[5:].strip()
|
||||
try:
|
||||
obj = json.loads(body_text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(obj, dict) and "status" in obj:
|
||||
final_status = obj["status"]
|
||||
# 想看上游 DeerFlow 的完整流,把下面打开即可
|
||||
# else:
|
||||
# print(" raw:", line[:200])
|
||||
return final_status
|
||||
|
||||
|
||||
async def call_leader(main_agent_name: str, thread_ids: dict[str, str]) -> list[list[str]]:
|
||||
"""步骤 2: leader 阶段 —— 总控读完用户问题,产出派活清单."""
|
||||
print(f"\n>>> [2/3] POST /generate_mul_agent (leader, agent={main_agent_name})")
|
||||
status = await stream_generate(
|
||||
{
|
||||
"agent_type": "leader",
|
||||
"d_agent_thread_id": thread_ids,
|
||||
"agent_name": main_agent_name,
|
||||
"new_message": USER_QUESTION,
|
||||
"skill_stop_names": SKILL_STOP_NAMES,
|
||||
}
|
||||
)
|
||||
dispatch = status or []
|
||||
print(f" 派活清单 = {json.dumps(dispatch, ensure_ascii=False, indent=2)}")
|
||||
return dispatch
|
||||
|
||||
|
||||
async def call_specials(thread_ids: dict[str, str], dispatch: list[list[str]]) -> None:
|
||||
"""步骤 3: 把派活清单里每一项交给对应子 agent 跑."""
|
||||
if not dispatch:
|
||||
print("\n>>> [3/3] 总控没派出任何任务,直接结束。")
|
||||
return
|
||||
for idx, item in enumerate(dispatch, 1):
|
||||
agent_name, task_text = item[0], item[1]
|
||||
print(f"\n>>> [3/3] ({idx}/{len(dispatch)}) POST /generate_mul_agent (special, agent={agent_name})")
|
||||
print(f" task = {task_text[:120]}{'...' if len(task_text) > 120 else ''}")
|
||||
result = await stream_generate(
|
||||
{
|
||||
"agent_type": "special",
|
||||
"d_agent_thread_id": thread_ids,
|
||||
"agent_name": agent_name,
|
||||
"new_message": task_text,
|
||||
"skill_stop_names": [],
|
||||
}
|
||||
)
|
||||
print(f" 完成: {result}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
main_agent_name, thread_ids = await call_agent_init()
|
||||
dispatch = await call_leader(main_agent_name, thread_ids)
|
||||
await call_specials(thread_ids, dispatch)
|
||||
print("\n✅ demo 跑完")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,10 +1,10 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -32,12 +32,15 @@ from app.gateway.routers import (
|
||||
auth,
|
||||
channels,
|
||||
feedback,
|
||||
intent,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
memory,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -247,7 +250,7 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||
|
||||
async def _run_db_migrations() -> None:
|
||||
"""Run Alembic migrations at startup so schema changes are applied automatically."""
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
@@ -260,51 +263,51 @@ async def _run_db_migrations() -> None:
|
||||
logger.info("Persistence backend=memory — skipping Alembic migrations")
|
||||
return
|
||||
|
||||
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
|
||||
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
|
||||
return
|
||||
|
||||
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
|
||||
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
|
||||
|
||||
def _run_sync() -> None:
|
||||
try:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", migrations_dir)
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
command.upgrade(cfg, "head")
|
||||
except BaseException as exc:
|
||||
result_queue.put(exc)
|
||||
else:
|
||||
result_queue.put(None)
|
||||
|
||||
try:
|
||||
try:
|
||||
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
|
||||
except ValueError:
|
||||
timeout_s = 60.0
|
||||
|
||||
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
|
||||
worker.start()
|
||||
try:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||
while result_queue.empty():
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise asyncio.TimeoutError
|
||||
await asyncio.sleep(0.2)
|
||||
result = result_queue.get_nowait()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
|
||||
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
|
||||
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
|
||||
timeout_s,
|
||||
)
|
||||
return
|
||||
if result is not None:
|
||||
raise result
|
||||
logger.info("Database migrations applied (head)")
|
||||
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
|
||||
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
|
||||
return
|
||||
|
||||
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
|
||||
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
|
||||
|
||||
def _run_sync() -> None:
|
||||
try:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", migrations_dir)
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
command.upgrade(cfg, "head")
|
||||
except BaseException as exc:
|
||||
result_queue.put(exc)
|
||||
else:
|
||||
result_queue.put(None)
|
||||
|
||||
try:
|
||||
try:
|
||||
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
|
||||
except ValueError:
|
||||
timeout_s = 60.0
|
||||
|
||||
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
|
||||
worker.start()
|
||||
try:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||
while result_queue.empty():
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise asyncio.TimeoutError
|
||||
await asyncio.sleep(0.2)
|
||||
result = result_queue.get_nowait()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
|
||||
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
|
||||
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
|
||||
timeout_s,
|
||||
)
|
||||
return
|
||||
if result is not None:
|
||||
raise result
|
||||
logger.info("Database migrations applied (head)")
|
||||
except Exception:
|
||||
logger.exception("Database migration failed — schema may be out of date")
|
||||
|
||||
@@ -860,6 +863,14 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||
app.include_router(runs.router)
|
||||
|
||||
# --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
|
||||
# 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
|
||||
# 文档见 frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1:/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
||||
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint.
|
||||
|
||||
@@ -60,6 +60,10 @@ class AgentsListResponse(BaseModel):
|
||||
class AgentCreateRequest(BaseModel):
|
||||
"""Request body for creating a custom agent."""
|
||||
|
||||
id: str | None = Field(
|
||||
default=None,
|
||||
description="Optional explicit storage id. Must match the storage id pattern. If omitted, a UUID is generated.",
|
||||
)
|
||||
name: str = Field(..., description="Display name; Chinese and duplicate names are allowed")
|
||||
description: str = Field(default="", description="Agent description")
|
||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||
@@ -411,13 +415,19 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
||||
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
||||
_require_agents_api_enabled()
|
||||
name = _validate_display_name(request.name)
|
||||
agent_id = uuid4().hex
|
||||
store = get_agent_store(http_request)
|
||||
|
||||
if request.id:
|
||||
agent_id = _validate_storage_id(request.id)
|
||||
if await store.get_any(agent_id) is not None:
|
||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||
else:
|
||||
agent_id = uuid4().hex
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
|
||||
try:
|
||||
store = get_agent_store(http_request)
|
||||
if agent_dir.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Generated agent id '{agent_id}' already exists")
|
||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -10,6 +11,8 @@ from pydantic import BaseModel
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +97,68 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
|
||||
return None
|
||||
|
||||
|
||||
def collect_outputs_listing(outputs_dir: Path) -> list[dict]:
|
||||
"""Walk an outputs directory and return artifact listing entries.
|
||||
|
||||
Each entry has ``path`` (virtual ``mnt/user-data/outputs/...``), ``name``,
|
||||
``size_bytes``, ``mime_type``, and ``modified_at`` (ISO 8601 UTC). Symlinks
|
||||
that escape the outputs root are dropped to keep the listing scoped to the
|
||||
thread. The result is sorted by ``modified_at`` descending (newest first).
|
||||
"""
|
||||
if not outputs_dir.exists() or not outputs_dir.is_dir():
|
||||
return []
|
||||
|
||||
outputs_root = outputs_dir.resolve()
|
||||
files: list[dict] = []
|
||||
for entry in outputs_root.rglob("*"):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
try:
|
||||
resolved = entry.resolve()
|
||||
resolved.relative_to(outputs_root)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
relative = resolved.relative_to(outputs_root).as_posix()
|
||||
mime_type, _ = mimetypes.guess_type(resolved.name)
|
||||
stat = resolved.stat()
|
||||
files.append(
|
||||
{
|
||||
"path": f"mnt/user-data/outputs/{relative}",
|
||||
"name": resolved.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"mime_type": mime_type,
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
files.sort(key=lambda f: f["modified_at"], reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts",
|
||||
summary="List Artifact Files",
|
||||
description="List all files under the thread's outputs directory (/mnt/user-data/outputs/). Returns an empty list if the thread has no outputs.",
|
||||
)
|
||||
@require_permission("threads", "read", owner_check=True)
|
||||
async def list_artifacts(thread_id: str, request: Request) -> dict:
|
||||
"""列出 thread 在 ``outputs/`` 下生成的产物文件。
|
||||
|
||||
圆桌 Step 3(``HighFidelityReport``)对每个**子席位** thread_id 调用本接口,
|
||||
展示各智能体写入沙箱 ``mnt/user-data/outputs/`` 的文件;协调智能体 thread
|
||||
通常无独立交付文件。
|
||||
|
||||
遍历路径::
|
||||
|
||||
.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/
|
||||
|
||||
返回 ``{ "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }``,
|
||||
目录不存在时 ``files`` 为空数组(非 404)。
|
||||
"""
|
||||
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
|
||||
return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Get Artifact File",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""圆桌规划 · 第一步「任务理解」Gateway 路由。
|
||||
|
||||
前端页面:``/page/workspace/roundtable/planning`` Step 1
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/intent.ts``
|
||||
|
||||
职责
|
||||
----
|
||||
在独立 thread 上运行内置智能体 ``roundtable-intent``(SOUL 见
|
||||
``.deer-flow/agents/roundtable-intent/SOUL.md``),与用户进行 2–5 轮澄清,
|
||||
最终输出结构化意图 ``{objective, constraints, assumptions}``,供 Step 2
|
||||
总控协调智能体作为初始 prompt。
|
||||
|
||||
与 ``multi_agent.py`` 的关系
|
||||
---------------------------
|
||||
- **复用**同一套 loopback 工具:``_stream_upstream``、``_run_payload``、
|
||||
``_parse_last_messages`` 等,保证 SSE 透传 + 末尾状态帧的解析逻辑一致。
|
||||
- **不复用** ``/init`` 的协调智能体创建逻辑:intent 是长期注册的内置 agent,
|
||||
只需 ``POST /api/intent/init`` 创建 thread 即可。
|
||||
|
||||
SSE 协议(与前端 ``streamIntent`` 对齐)
|
||||
--------------------------------------
|
||||
1. 透传 LangGraph ``/api/threads/{id}/runs/stream`` 的全部 ``data:`` 行
|
||||
(含 ``messages-tuple`` 增量,供 UI 流式打字)。
|
||||
2. 流结束后追加**一条** JSON 状态帧(``data: {...}\\n\\n``),字段:
|
||||
|
||||
``status`` — 终态枚举:
|
||||
- ``asking``:本轮无 ``ask_clarification``,模型仍在追问或闲聊;
|
||||
- ``clarification``:本轮以澄清工具结束,需用户选选项/自定义输入;
|
||||
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
|
||||
- ``error``:上游或网关异常。
|
||||
|
||||
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。
|
||||
|
||||
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
|
||||
``question``、``clarification_type``、``clarification_context``、
|
||||
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
|
||||
|
||||
终态判定优先级(实现细节,勿随意调整)
|
||||
------------------------------------
|
||||
1. **``[INTENT_READY]`` 优先**:从最新消息向前扫描 AIMessage,正则匹配
|
||||
SOUL 规定的 ``[INTENT_READY]\\n```json {...} `````;即使最后一条消息仍带
|
||||
过期的 ``ask_clarification`` tool_call,也返回 ``done``(避免前端卡在澄清 UI)。
|
||||
2. 否则看最后一条带 ``tool_calls`` 的 AIMessage 是否含 ``ask_clarification``。
|
||||
3. 否则 ``asking``。
|
||||
|
||||
工具白名单
|
||||
----------
|
||||
``excluded_tools`` 屏蔽除 ``ask_clarification`` 外的一切工具,防止模型偏离
|
||||
澄清任务去写文件、派活等(SOUL 文字约束不可靠,硬排除更稳)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_find_dispatch_message,
|
||||
_find_trailing_ai_text,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||
|
||||
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
|
||||
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
|
||||
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
|
||||
_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[str, Any]:
|
||||
"""创建任务理解会话用的空 thread。
|
||||
|
||||
请求体:``{ "model": "deepseek-chat" }``(可选,当前 init 未把 model 写入 thread,
|
||||
实际模型在 ``/stream`` 的每次 run 里指定)。
|
||||
|
||||
响应::
|
||||
|
||||
{ "status": "success", "agent_id": "roundtable-intent", "thread_id": "<uuid>" }
|
||||
|
||||
与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位
|
||||
thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。
|
||||
"""
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
# trust_env=False: this client only talks to the same-process gateway at
|
||||
# 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide
|
||||
# on the dev machine) makes httpx route loopback calls through an upstream
|
||||
# proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per
|
||||
# request before falling back. Symptom in logs: `headers_in=42.160s`.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) 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:
|
||||
"""Drive one turn of the intent dialogue via SSE.
|
||||
|
||||
Body: ``{thread_id, message, model?}``. Streams upstream LangGraph frames
|
||||
through transparently, then appends one status frame:
|
||||
|
||||
{
|
||||
"status": "asking" | "clarification" | "done" | "error",
|
||||
"content": "<final AI text>",
|
||||
"summary": {...} | null, // only when status == "done"
|
||||
// when status == "clarification":
|
||||
"question", "clarification_type", "clarification_context",
|
||||
"options", "allow_custom", "allow_multiple"
|
||||
}
|
||||
"""
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="message is required")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=message,
|
||||
# Intent agent only ever needs ask_clarification. Block
|
||||
# everything else so the model can't drift into solutioning
|
||||
# (e.g. saving a design doc with write_file) — the model
|
||||
# tends to ignore SOUL prose, hard exclusion is reliable.
|
||||
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
"ls",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"str_replace",
|
||||
"memory",
|
||||
"hindsight_recall",
|
||||
"hindsight_reflect",
|
||||
"hindsight_retain",
|
||||
"task",
|
||||
"write_todos",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
||||
captured: list[str] = []
|
||||
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)
|
||||
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
|
||||
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
|
||||
summary: dict[str, Any] | None = None
|
||||
ready_text: str = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
msg_text = _flatten_content(msg.get("content", ""))
|
||||
m = _INTENT_READY_PATTERN.search(msg_text)
|
||||
if m:
|
||||
try:
|
||||
summary = json.loads(m.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("intent summary JSON parse failed: %s", exc)
|
||||
summary = None
|
||||
ready_text = msg_text
|
||||
break
|
||||
if summary and ready_text:
|
||||
final_frame = {
|
||||
"status": "done",
|
||||
"content": ready_text,
|
||||
"summary": summary,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
|
||||
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
|
||||
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
|
||||
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
display_content = follow_up or content or question
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "clarification",
|
||||
"content": display_content,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# --- 终态分支 3:普通 AI 回复,继续多轮对话 ---
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": "asking",
|
||||
"content": content,
|
||||
"summary": None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("intent stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,917 @@
|
||||
"""圆桌规划 · Step 2「多智能体研讨」Gateway 路由。
|
||||
|
||||
前端页面:``RoundtablePlanningPage`` Step 2
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/multi-agent.ts``
|
||||
|
||||
本模块把原 ``newpython/OUT.py`` 演示编排进 FastAPI Gateway,通过 **HTTP loopback**
|
||||
(``127.0.0.1`` + 调用方 ``Authorization``)复用既有 ``/api/threads``、
|
||||
``/api/agents``、``/api/threads/{id}/runs/stream``,不再硬编码 token。
|
||||
|
||||
对外接口
|
||||
--------
|
||||
1. ``POST /api/multi-agent/init``(SSE)
|
||||
- 为每个研讨席位(``agent_names``)并行创建 thread,按完成顺序推送
|
||||
``seat_ready``;
|
||||
- 动态创建**当次会话**的总控协调智能体(``main_agent_name``,如
|
||||
``roundtable-coordinator-xxx``),写入席位列表 SOUL + ``agent_orchestration`` skill;
|
||||
- 结束帧 ``init_done`` 携带 ``thread_ids: { agent_name: thread_id, ... }``,
|
||||
含协调智能体自身 thread。
|
||||
|
||||
2. ``POST /api/multi-agent/run/stream``(SSE)
|
||||
- ``agent_type=leader``:总控一轮——可能派活、可能澄清、可能共识(空 dispatch);
|
||||
- ``agent_type=special``:单席位子智能体执行一轮交付。
|
||||
|
||||
跨席位「广播」语义
|
||||
------------------
|
||||
通过 ``_append_thread_message`` 向其它 thread 的 checkpoint ``messages`` 追加
|
||||
**只读上下文**的 human 消息(非触发 run),使各席位 LangGraph 状态里能看到
|
||||
总控派活/用户干预/子 agent 完成摘要。前端编排循环仍靠显式多次 ``/run/stream``
|
||||
驱动各 agent 真正推理。
|
||||
|
||||
Leader 终态帧(前端 ``isFinalStatusFrame``)
|
||||
-------------------------------------------
|
||||
- ``status: [[agent_name, task], ...]`` — 派活列表,前端并行 special run;
|
||||
- ``status: []`` — 无 tool_calls,视为共识,可进 Step 3;
|
||||
- ``status: "clarification"`` — 总控 ``ask_clarification``,前端暂停编排展示澄清条;
|
||||
- ``status: "子智能体 X 完成..."`` — special 完成(字符串状态);
|
||||
- ``status: "error"`` — 失败。
|
||||
|
||||
共享导出
|
||||
--------
|
||||
``intent.py`` / ``recommend.py`` 从此模块 import 底层流式与消息解析函数,
|
||||
勿在 intent/recommend 中重复实现 loopback 逻辑。
|
||||
|
||||
相关文档:``frontend-web/docs/multi-agent-backend-dev.md``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback 辅助函数(Gateway → Gateway,同进程)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _loopback_base(request: Request) -> str:
|
||||
"""当前请求的 scheme+host,用于 httpx 打回本机 Gateway。"""
|
||||
return f"{request.url.scheme}://{request.url.netloc}"
|
||||
|
||||
|
||||
def _auth_headers(request: Request) -> dict[str, str]:
|
||||
token = request.headers.get("authorization")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
||||
return {"Authorization": token, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
|
||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
|
||||
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["thread_id"]
|
||||
|
||||
|
||||
async def _get_agent(client: httpx.AsyncClient, base: str, headers: dict[str, str], agent_id: str) -> dict[str, Any]:
|
||||
resp = await client.get(f"{base}/api/agents/{agent_id}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _create_agent(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
model: str | None,
|
||||
skills: list[str] | None,
|
||||
soul: str,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[bool, dict[str, Any] | str]:
|
||||
payload: dict[str, Any] = {"name": name, "description": description, "soul": soul}
|
||||
if agent_id is not None:
|
||||
payload["id"] = agent_id
|
||||
if model is not None:
|
||||
payload["model"] = model
|
||||
if skills is not None:
|
||||
payload["skills"] = skills
|
||||
resp = await client.post(f"{base}/api/agents", json=payload, headers=headers)
|
||||
if resp.status_code == 201:
|
||||
return True, resp.json()
|
||||
return False, resp.text
|
||||
|
||||
|
||||
async def _append_thread_message(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
thread_id: str,
|
||||
content: str,
|
||||
) -> None:
|
||||
"""向指定 thread 的 checkpoint 追加一条 human 消息(广播上下文,不触发 LLM run)。
|
||||
|
||||
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
|
||||
前端对该席位 thread 发起的 ``/run/stream``。
|
||||
"""
|
||||
state_url = f"{base}/api/threads/{thread_id}/state"
|
||||
get_resp = await client.get(state_url, headers=headers)
|
||||
if get_resp.status_code != 200:
|
||||
logger.warning("get state %s failed: %s %s", thread_id, get_resp.status_code, get_resp.text[:200])
|
||||
return
|
||||
state = get_resp.json()
|
||||
values = state.setdefault("values", {})
|
||||
messages = values.setdefault("messages", [])
|
||||
if not isinstance(messages, list):
|
||||
messages = []
|
||||
values["messages"] = messages
|
||||
messages.append({"type": "human", "content": content})
|
||||
post_resp = await client.post(state_url, json=state, headers=headers)
|
||||
if post_resp.status_code >= 400:
|
||||
logger.warning("update state %s failed: %s %s", thread_id, post_resp.status_code, post_resp.text[:200])
|
||||
|
||||
|
||||
async def _broadcast(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
message: str,
|
||||
agent_threads: dict[str, str],
|
||||
skip: list[str],
|
||||
) -> None:
|
||||
"""并行向 ``agent_threads`` 中除 ``skip`` 外的所有 thread 追加同一条 human 消息。"""
|
||||
targets = [tid for name, tid in agent_threads.items() if name not in skip]
|
||||
if not targets:
|
||||
return
|
||||
await asyncio.gather(
|
||||
*(_append_thread_message(client, base, headers, tid, message) for tid in targets),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
|
||||
|
||||
# Mirrors the upstream agent-id validator in the LangGraph runtime. Reject early
|
||||
# so callers get a clear 400 here instead of a stream that aborts with HTTP 422
|
||||
# from the loopback /api/threads/.../runs/stream endpoint.
|
||||
_AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def _validate_agent_id(value: str, field: str) -> str:
|
||||
if not _AGENT_ID_PATTERN.match(value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"{field} '{value}' is not a valid agent id. "
|
||||
f"Must match ^[A-Za-z0-9_-]+$ (use the hyphen-case id, not the display name)."
|
||||
),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _extract_dispatch(tool_call: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Return ``(agent_name, task)`` parsed from a leader tool call.
|
||||
|
||||
Two formats are accepted:
|
||||
|
||||
1. **Structured** — ``{"name": "agent_orchestration", "args": {"agent_name": ..., "task": ...}}``.
|
||||
This is what most LLMs emit when told to "use the agent_orchestration skill",
|
||||
and the form the roundtable coordinator's SOUL prompt elicits.
|
||||
|
||||
2. **Legacy bash argv** — ``{"name": "bash", "args": {"command": '... .py "X" "Y" ...'}}``.
|
||||
Kept as a fallback so older skills that dispatch through bash still work.
|
||||
"""
|
||||
name = (tool_call.get("name") or "").lower()
|
||||
args = tool_call.get("args")
|
||||
|
||||
# Format 1: structured agent_orchestration call.
|
||||
if name == "agent_orchestration" and isinstance(args, dict):
|
||||
agent_name = str(args.get("agent_name") or "").strip()
|
||||
task = str(args.get("task") or "").strip()
|
||||
if agent_name and task:
|
||||
return _strip_display_suffix(agent_name), task
|
||||
|
||||
# Format 2: legacy bash + .py "X" "Y".
|
||||
match = _TOOL_CALL_PATTERN.search(str(args))
|
||||
if match:
|
||||
return _strip_display_suffix(match.group(1)), match.group(2)
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
def _strip_display_suffix(agent_name: str) -> str:
|
||||
"""Tolerate models that paste the human-readable suffix into agent_name.
|
||||
|
||||
The coordinator SOUL lists each seat as ``agent_name: roundtable-x(中文):...``
|
||||
so weaker models sometimes echo the entire ``roundtable-x(中文)`` label
|
||||
back as the dispatch target. Strip everything from the first non-id
|
||||
character onward so we recover the bare hyphen-case id; if what's left
|
||||
still violates the id pattern we hand it back untouched and let the
|
||||
upstream validator reject it.
|
||||
"""
|
||||
head = re.split(r"[^A-Za-z0-9_-]", agent_name, maxsplit=1)[0]
|
||||
return head or agent_name
|
||||
|
||||
|
||||
def _flatten_content(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
parts.append(str(item.get("text", "")))
|
||||
else:
|
||||
parts.append(str(item))
|
||||
return "".join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _run_payload(
|
||||
*,
|
||||
agent_name: str,
|
||||
model_name: str,
|
||||
thread_id: str,
|
||||
new_message: str,
|
||||
excluded_tools: list[str],
|
||||
skill_stop_names: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
- ``context.agent_name``:本次 run 使用的 agent(内置 id 或 init 创建的 coordinator id)。
|
||||
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
||||
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
||||
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
||||
"""
|
||||
return {
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": [{"type": "text", "text": new_message}],
|
||||
"additional_kwargs": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {"recursion_limit": 1000},
|
||||
"context": {
|
||||
"agent_name": agent_name,
|
||||
"model_name": model_name,
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "medium",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": False,
|
||||
"subagent_enabled": False,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
"stream_mode": ["messages-tuple", "values"],
|
||||
"stream_subgraphs": True,
|
||||
"stream_resumable": True,
|
||||
"assistant_id": "lead_agent",
|
||||
"on_disconnect": "continue",
|
||||
"excluded_tools": excluded_tools,
|
||||
"skill_stop_names": skill_stop_names,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""流式初始化圆桌 Step 2 所需的 thread 映射与协调智能体。
|
||||
|
||||
请求体::
|
||||
|
||||
{
|
||||
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
|
||||
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
|
||||
"model": "deepseek-chat" // 可选,写入 coordinator agent
|
||||
}
|
||||
|
||||
SSE 事件(每条 ``data: {...}\\n\\n``)::
|
||||
|
||||
seat_ready — 某一席位 thread 已创建(``asyncio.as_completed`` 顺序,先完先发)
|
||||
coordinator_ready — 总控 agent + thread 已就绪
|
||||
init_done — ``thread_ids`` 全量映射,前端存 ``threadIds`` 并开始编排
|
||||
error — 失败详情(同步校验失败则直接 HTTP 4xx,不进 SSE)
|
||||
|
||||
协调智能体 SOUL 在运行时根据各席位 ``GET /api/agents/{id}`` 的 name/description 拼接,
|
||||
强调 ``agent_name`` 必须用英文 id(``roundtable-intelligence``),禁止带中文括号后缀。
|
||||
"""
|
||||
agent_names_in = payload.get("agent_names") or []
|
||||
agent_names: list[str] = [str(name).lower() for name in agent_names_in]
|
||||
main_agent_name: str = str(payload.get("main_agent_name") or "main-agent").lower()
|
||||
model: str | None = payload.get("model")
|
||||
|
||||
if not agent_names:
|
||||
raise HTTPException(status_code=400, detail="agent_names is required")
|
||||
|
||||
for name in agent_names:
|
||||
_validate_agent_id(name, "agent_names entry")
|
||||
_validate_agent_id(main_agent_name, "main_agent_name")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
def _frame(payload: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
# Per-seat prepare task — same body as before, but we no longer
|
||||
# await them with asyncio.gather. Instead, asyncio.as_completed
|
||||
# surfaces them in REAL completion order so we can emit a
|
||||
# seat_ready frame the moment each finishes.
|
||||
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
|
||||
info = await _get_agent(client, base, headers, sub_id)
|
||||
display_name = str(info.get("name") or sub_id)
|
||||
description = info.get("description", "") or ""
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return sub_id.lower(), thread_id, display_name, description
|
||||
|
||||
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
|
||||
prepared: list[tuple[str, str, str, str]] = []
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
sid, thread_id, display_name, description = await fut
|
||||
prepared.append((sid, thread_id, display_name, description))
|
||||
yield _frame({
|
||||
"event": "seat_ready",
|
||||
"agent_name": sid,
|
||||
"thread_id": thread_id,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
})
|
||||
except Exception:
|
||||
# Cancel any still-pending sibling so we don't leak tasks
|
||||
# past the AsyncClient close.
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
|
||||
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
|
||||
descriptions: list[str] = [
|
||||
f"- agent_name: {sid}({display_name}):{description}"
|
||||
for sid, _, display_name, description in prepared
|
||||
]
|
||||
|
||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),"
|
||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||
"#可调度智能体:\n"
|
||||
+ "\n".join(descriptions)
|
||||
)
|
||||
|
||||
coord_thread = await _create_thread(client, base, headers)
|
||||
agent_threads[main_agent_name] = coord_thread
|
||||
ok, result = await _create_agent(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
name=main_agent_name,
|
||||
description="你是总控控制协调智能体,负责协调其他智能体干活",
|
||||
model=model,
|
||||
skills=["agent_orchestration"],
|
||||
soul=soul,
|
||||
agent_id=main_agent_name,
|
||||
)
|
||||
if not ok and isinstance(result, str) and "exists" not in result:
|
||||
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
|
||||
return
|
||||
|
||||
yield _frame({
|
||||
"event": "coordinator_ready",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_id": coord_thread,
|
||||
})
|
||||
yield _frame({
|
||||
"event": "init_done",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_ids": agent_threads,
|
||||
})
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent init unexpected failure")
|
||||
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _stream_upstream(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
thread_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> AsyncIterator[tuple[str | None, list[str] | None]]:
|
||||
"""消费 ``/api/threads/{thread_id}/runs/stream``,逐行 yield 原始 SSE。
|
||||
|
||||
产出元组:
|
||||
- ``(line, None)`` — 可原样转发给浏览器(含 ``event:`` / ``data:`` 行);
|
||||
- ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表,
|
||||
供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。
|
||||
|
||||
日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。
|
||||
"""
|
||||
|
||||
url = f"{base}/api/threads/{thread_id}/runs/stream"
|
||||
captured: list[str] = []
|
||||
t_send = time.perf_counter()
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
t_headers = time.perf_counter()
|
||||
if resp.status_code != 200:
|
||||
body_text = (await resp.aread()).decode("utf-8", "replace")[:1000]
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"upstream {url} returned HTTP {resp.status_code}: {body_text}",
|
||||
)
|
||||
agent_for_log = (body.get("context") or {}).get("agent_name") or thread_id
|
||||
logger.info(
|
||||
"[upstream-timing] %s headers_in=%.3fs",
|
||||
agent_for_log,
|
||||
t_headers - t_send,
|
||||
)
|
||||
first_line = True
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if first_line:
|
||||
first_line = False
|
||||
logger.info(
|
||||
"[upstream-timing] %s first_line=%.3fs (LangGraph runtime startup + LLM first byte)",
|
||||
agent_for_log,
|
||||
time.perf_counter() - t_headers,
|
||||
)
|
||||
yield line, None
|
||||
if line.startswith("data:"):
|
||||
captured.append(line)
|
||||
yield None, captured
|
||||
|
||||
|
||||
def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]]:
|
||||
"""Return the full ``messages`` list from the most recent ``values`` snapshot.
|
||||
|
||||
The upstream sends the final ``values`` frame second-to-last (the very last
|
||||
line is the ``end`` event with ``null``). We tolerate trailing non-JSON
|
||||
frames (e.g. ``data: null``) by scanning backwards for the first frame that
|
||||
successfully parses as a state object with a ``messages`` key.
|
||||
"""
|
||||
if not captured:
|
||||
raise HTTPException(status_code=502, detail="upstream produced no data lines")
|
||||
|
||||
for raw_line in reversed(captured):
|
||||
raw = raw_line[5:].strip() if raw_line.startswith("data:") else raw_line.strip()
|
||||
if not raw or raw == "null":
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict) and "messages" in parsed:
|
||||
messages = parsed.get("messages") or []
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
|
||||
raise HTTPException(status_code=502, detail="no parseable values frame in upstream stream")
|
||||
|
||||
|
||||
def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str = "") -> str:
|
||||
"""Return the text content of the latest AIMessage with no tool_calls.
|
||||
|
||||
Used by the clarification branch: ClarificationMiddleware ends the run
|
||||
after emitting the structured ToolMessage, but the model often produces a
|
||||
richer human-readable follow-up AIMessage right before the interrupt.
|
||||
Prefer that text over the brief preamble that accompanied the
|
||||
ask_clarification tool_call. When the only trailing AIMessage matches the
|
||||
preamble (``exclude_text``), return empty so the caller can fall back to
|
||||
the question itself.
|
||||
"""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
if msg.get("tool_calls"):
|
||||
continue
|
||||
text = _flatten_content(msg.get("content", "")).strip()
|
||||
if not text:
|
||||
continue
|
||||
if exclude_text and text == exclude_text.strip():
|
||||
continue
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Locate the latest AIMessage that carries dispatch tool calls.
|
||||
|
||||
The very last message can be a skill-stop ToolMessage injected by
|
||||
``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification
|
||||
ToolMessage. Either way, the actionable tool_calls live on the AIMessage
|
||||
*before* it, so we walk backwards until we hit one with ``tool_calls``.
|
||||
Returns the latest AIMessage when none of them carry tool_calls so the
|
||||
caller can still extract its text content.
|
||||
"""
|
||||
last_ai: dict[str, Any] = {}
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
if not last_ai:
|
||||
last_ai = msg
|
||||
if msg.get("tool_calls"):
|
||||
return msg
|
||||
return last_ai
|
||||
|
||||
|
||||
async def _leader_run(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
agent_threads: dict[str, str],
|
||||
agent_name: str,
|
||||
new_message: str,
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||
|
||||
时序要点:
|
||||
1. **预广播**(与 LLM 并行):把用户/综合消息写入其它席位 thread,skip 总控自身;
|
||||
2. 跑总控 thread 的 stream,yield 每一行原始 SSE;
|
||||
3. 解析 messages → 澄清 / 空 dispatch(共识)/ 派活列表;
|
||||
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
|
||||
|
||||
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
|
||||
pre_broadcast_task = asyncio.create_task(
|
||||
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
|
||||
)
|
||||
thread_id = agent_threads[agent_name]
|
||||
effective_stop_names = list({"agent_orchestration", *skill_stop_names})
|
||||
body = _run_payload(
|
||||
agent_name=agent_name,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=new_message,
|
||||
excluded_tools=["web_search"],
|
||||
skill_stop_names=effective_stop_names,
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s payload_built=%.3fs (broadcast running in background)",
|
||||
agent_name,
|
||||
t_payload_built - t0,
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
first_line_ts: float | None = None
|
||||
line_count = 0
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
if first_line_ts is None:
|
||||
first_line_ts = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
|
||||
agent_name,
|
||||
first_line_ts - t_payload_built,
|
||||
first_line_ts - t0,
|
||||
)
|
||||
line_count += 1
|
||||
yield line
|
||||
elif done is not None:
|
||||
captured = done
|
||||
t_stream_done = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
|
||||
agent_name,
|
||||
t_stream_done - (first_line_ts or t_payload_built),
|
||||
line_count,
|
||||
t_stream_done - t0,
|
||||
)
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Pre-broadcast must finish before we yield the final status frame on any
|
||||
# branch — the seats that get dispatched next rely on having seen the
|
||||
# original user message. The upstream LLM stream usually takes several
|
||||
# seconds, so the broadcast has typically already completed and this
|
||||
# await is effectively free.
|
||||
t_before_broadcast_await = time.perf_counter()
|
||||
try:
|
||||
await pre_broadcast_task
|
||||
except Exception as exc:
|
||||
logger.warning("pre-broadcast failed: %s", exc)
|
||||
t_after_broadcast_await = time.perf_counter()
|
||||
logger.info(
|
||||
"[leader-timing] %s pre_broadcast_await=%.3fs (>0 means broadcast was still in flight after LLM finished)",
|
||||
agent_name,
|
||||
t_after_broadcast_await - t_before_broadcast_await,
|
||||
)
|
||||
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
leader_text = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
|
||||
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=leader_text)
|
||||
yield {
|
||||
"status": "clarification",
|
||||
"content": follow_up or question or leader_text,
|
||||
"question": question,
|
||||
"clarification_type": str(cargs.get("clarification_type") or ""),
|
||||
"clarification_context": str(cargs.get("context") or ""),
|
||||
"options": cargs.get("options") or [],
|
||||
"allow_custom": bool(cargs.get("allow_custom", True)),
|
||||
"allow_multiple": resolve_allow_multiple(cargs),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
return
|
||||
|
||||
if not tool_calls:
|
||||
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
|
||||
yield {"status": [], "content": leader_text, "agent_name": agent_name}
|
||||
return
|
||||
|
||||
# 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
|
||||
# sets and we don't care about ordering, so serializing them just
|
||||
# multiplied latency before the frontend could start a sub-agent run.
|
||||
dispatched: list[list[str]] = []
|
||||
dispatch_broadcasts: list[asyncio.Task[None]] = []
|
||||
for tool_call in tool_calls:
|
||||
try:
|
||||
sub_name, task_text = _extract_dispatch(tool_call)
|
||||
if not sub_name:
|
||||
continue
|
||||
dispatch_broadcasts.append(
|
||||
asyncio.create_task(
|
||||
_broadcast(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
f"总控智能体给{sub_name}派活,内容为:" + task_text,
|
||||
agent_threads,
|
||||
[agent_name, sub_name],
|
||||
)
|
||||
)
|
||||
)
|
||||
dispatched.append([sub_name, task_text])
|
||||
except Exception as exc:
|
||||
logger.warning("dispatch failed: %s", exc)
|
||||
if dispatch_broadcasts:
|
||||
t_before_dispatch = time.perf_counter()
|
||||
await asyncio.gather(*dispatch_broadcasts, return_exceptions=True)
|
||||
logger.info(
|
||||
"[leader-timing] %s dispatch_broadcasts_await=%.3fs count=%d (gates how soon the next sub agent can start)",
|
||||
agent_name,
|
||||
time.perf_counter() - t_before_dispatch,
|
||||
len(dispatch_broadcasts),
|
||||
)
|
||||
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
|
||||
|
||||
|
||||
async def _special_run(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
agent_threads: dict[str, str],
|
||||
agent_name: str,
|
||||
new_message: str,
|
||||
skill_stop_names: list[str],
|
||||
model_name: str,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
- ``excluded_tools`` 禁用派活/澄清等,避免席位抢总控活;
|
||||
- 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
|
||||
- **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
|
||||
同时尽量让前端先收到结果关闭 loading。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
thread_id = agent_threads[agent_name]
|
||||
body = _run_payload(
|
||||
agent_name=agent_name,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=new_message,
|
||||
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"],
|
||||
skill_stop_names=skill_stop_names,
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
"[special-timing] %s payload_built=%.3fs",
|
||||
agent_name,
|
||||
t_payload_built - t0,
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
first_line_ts: float | None = None
|
||||
line_count = 0
|
||||
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||
if line is not None:
|
||||
if first_line_ts is None:
|
||||
first_line_ts = time.perf_counter()
|
||||
logger.info(
|
||||
"[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
|
||||
agent_name,
|
||||
first_line_ts - t_payload_built,
|
||||
first_line_ts - t0,
|
||||
)
|
||||
line_count += 1
|
||||
yield line
|
||||
elif done is not None:
|
||||
captured = done
|
||||
t_stream_done = time.perf_counter()
|
||||
logger.info(
|
||||
"[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
|
||||
agent_name,
|
||||
t_stream_done - (first_line_ts or t_payload_built),
|
||||
line_count,
|
||||
t_stream_done - t0,
|
||||
)
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Sub-agent's final deliverable is the latest AIMessage's text content.
|
||||
last_msg: dict[str, Any] = {}
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
last_msg = msg
|
||||
break
|
||||
content = _flatten_content(last_msg.get("content", ""))
|
||||
# Kick the "子智能体完成" broadcast off in the background and yield the
|
||||
# status frame first — the next leader cycle that depends on this
|
||||
# broadcast having landed only starts after a fresh /run/stream request
|
||||
# from the frontend, which is many seconds away (full leader LLM call).
|
||||
# Awaiting after the yield ensures the task finishes before the AsyncClient
|
||||
# closes; the SSE connection stays open a bit longer but the frontend has
|
||||
# already consumed the result.
|
||||
t_before_post = time.perf_counter()
|
||||
post_broadcast_task = asyncio.create_task(
|
||||
_broadcast(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
|
||||
agent_threads,
|
||||
[agent_name],
|
||||
)
|
||||
)
|
||||
yield {
|
||||
"status": f"子智能体{agent_name}完成{new_message}工作",
|
||||
"content": content,
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
try:
|
||||
await post_broadcast_task
|
||||
except Exception as exc:
|
||||
logger.warning("post-broadcast failed: %s", exc)
|
||||
logger.info(
|
||||
"[special-timing] %s post_broadcast_total=%.3fs (broadcast time after yield — keeps SSE open this long)",
|
||||
agent_name,
|
||||
time.perf_counter() - t_before_post,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/run/stream — leader / special 单轮 SSE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/run/stream")
|
||||
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""驱动总控或子智能体的一轮 LangGraph run。
|
||||
|
||||
请求体::
|
||||
|
||||
{
|
||||
"agent_type": "leader" | "special",
|
||||
"d_agent_thread_id": { "<agent_id>": "<thread_id>", ... }, // init_done.thread_ids
|
||||
"agent_name": "<本次运行的 agent_id>",
|
||||
"new_message": "<用户消息或派活 task 文本>",
|
||||
"model": "deepseek-chat",
|
||||
"skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration
|
||||
}
|
||||
|
||||
响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。
|
||||
客户端应使用 AbortSignal 支持「挂起协商」时取消 in-flight 请求。
|
||||
"""
|
||||
agent_type: str = str(payload.get("agent_type") or "leader").lower()
|
||||
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
|
||||
agent_name: str = str(payload.get("agent_name") or "").lower()
|
||||
new_message: str = str(payload.get("new_message") or "")
|
||||
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
|
||||
model_name: str = str(payload.get("model") or "deepseek-chat")
|
||||
|
||||
if not agent_name or agent_name not in agent_threads:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"agent_name '{agent_name}' is missing from d_agent_thread_id",
|
||||
)
|
||||
_validate_agent_id(agent_name, "agent_name")
|
||||
for key in agent_threads:
|
||||
_validate_agent_id(key, "d_agent_thread_id key")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
if agent_type == "leader":
|
||||
gen = _leader_run(
|
||||
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
|
||||
)
|
||||
else:
|
||||
gen = _special_run(
|
||||
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
|
||||
)
|
||||
async for chunk in gen:
|
||||
if isinstance(chunk, dict):
|
||||
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield chunk if chunk.endswith("\n") else chunk + "\n"
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err_frame = {
|
||||
"status": "error",
|
||||
"content": f"[{exc.status_code}] {exc.detail}",
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent stream unexpected failure")
|
||||
err_frame = {
|
||||
"status": "error",
|
||||
"content": f"unexpected: {exc!r}",
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""圆桌规划 · Step 1→2 之间「智能体推荐」Gateway 路由。
|
||||
|
||||
前端页面:推荐弹窗 ``RecommendAgentsDialog``
|
||||
前端模块:``frontend-web/src/roundtable-planning/api/recommend.ts``
|
||||
|
||||
职责
|
||||
----
|
||||
根据 Step 1 的 ``IntentSummary`` 与前端过滤后的候选池,驱动内置
|
||||
``roundtable-recommender``(SOUL 见 ``.deer-flow/agents/roundtable-recommender/``),
|
||||
输出 2–6 个 ``{agent_id, reason}`` 推荐席位。
|
||||
|
||||
设计为**无状态单次调用**:
|
||||
- **不暴露** ``/init``:每次请求内部 ``_create_thread``,推荐 agent 不需要历史轮次。
|
||||
- 候选池过滤由前端完成(剔除 ``roundtable-intent``、``roundtable-recommender``、
|
||||
``roundtable-coordinator-*``),本路由只做校验与 clamp。
|
||||
|
||||
SSE 协议
|
||||
--------
|
||||
1. 透传上游 LangGraph 流(前端用 ``parseRecommendStreamContent`` 把正文拆成
|
||||
``[RECOMMEND_READY]`` 前的 ``rationale`` 与 JSON 里的 ``picks``,实现卡片增量渲染)。
|
||||
2. 末尾状态帧::
|
||||
|
||||
{ "status": "done"|"asking"|"error", "content": "<全文>", "picks": [...]|null }
|
||||
|
||||
- ``done``:``picks`` 经 ``_validate_picks`` 后 ≥2 条且 id 均在候选池内;
|
||||
- ``asking``:未匹配 ``[RECOMMEND_READY]`` 或校验后不足 2 条(前端走 FALLBACK 默认 6 席);
|
||||
- ``error``:HTTP/未捕获异常。
|
||||
|
||||
与 SOUL 的契约
|
||||
--------------
|
||||
模型须在自然语言思路后输出::
|
||||
|
||||
[RECOMMEND_READY]
|
||||
```json
|
||||
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_parse_last_messages,
|
||||
_run_payload,
|
||||
_stream_upstream,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/recommend", tags=["recommend"])
|
||||
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
|
||||
# 从模型最终 AIMessage 全文提取 picks;非贪婪 ``.*?`` 只匹配第一个 json 块。
|
||||
_RECOMMEND_READY_PATTERN = re.compile(
|
||||
r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# 与 SOUL / 前端 ``RecommendAgentsDialog`` 一致:少于 2 无法成桌,多于 6 由网关截断。
|
||||
# 前端确认按钮另限制 2≤N≤8(用户可手动多选),后端此处只负责模型输出校验。
|
||||
_MIN_PICKS = 2
|
||||
_MAX_PICKS = 6
|
||||
|
||||
|
||||
def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
|
||||
"""Render the intent + candidate pool into the single user message the
|
||||
recommender agent reads. Keep the format close to what the SOUL example
|
||||
shows so the model has a clean pattern to mirror."""
|
||||
objective = str(intent.get("objective") or "").strip() or "(未提供)"
|
||||
constraints = intent.get("constraints") or []
|
||||
assumptions = intent.get("assumptions") or []
|
||||
|
||||
constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)"
|
||||
assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)"
|
||||
|
||||
candidate_lines: list[str] = []
|
||||
for c in candidates:
|
||||
cid = str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
cname = str(c.get("name") or "").strip()
|
||||
cdesc = str(c.get("description") or "").strip()
|
||||
if not cid:
|
||||
continue
|
||||
# Format: "- <id> (<name>): <description>" — matches the SOUL example.
|
||||
suffix = f" ({cname})" if cname else ""
|
||||
desc_part = f": {cdesc}" if cdesc else ""
|
||||
candidate_lines.append(f"- {cid}{suffix}{desc_part}")
|
||||
|
||||
candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)"
|
||||
|
||||
return (
|
||||
f"任务目标:{objective}\n"
|
||||
f"约束:{constraint_lines}\n"
|
||||
f"关键假设:{assumption_lines}\n\n"
|
||||
f"候选智能体池:\n{candidate_block}\n\n"
|
||||
f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。"
|
||||
)
|
||||
|
||||
|
||||
def _validate_picks(
|
||||
raw_picks: Any,
|
||||
candidate_ids: set[str],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter the model's picks against the candidate pool and clamp to 2-6.
|
||||
|
||||
Drops picks whose ``agent_id`` isn't in the candidate set (guards against
|
||||
model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the
|
||||
model over-picked. Returns ``[]`` if validation leaves us with fewer than
|
||||
``_MIN_PICKS`` valid picks — the caller treats that as "fall back to
|
||||
defaults".
|
||||
"""
|
||||
if not isinstance(raw_picks, list):
|
||||
return []
|
||||
|
||||
picked: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_picks:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = str(item.get("agent_id") or "").strip()
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
if not aid or aid not in candidate_ids or aid in seen:
|
||||
continue
|
||||
picked.append({"agent_id": aid, "reason": reason})
|
||||
seen.add(aid)
|
||||
if len(picked) >= _MAX_PICKS:
|
||||
break
|
||||
|
||||
if len(picked) < _MIN_PICKS:
|
||||
return []
|
||||
return picked
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||
"""Drive one recommendation turn via SSE.
|
||||
|
||||
Body::
|
||||
|
||||
{
|
||||
"intent": {"objective": "...", "constraints": [...], "assumptions": [...]},
|
||||
"candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...],
|
||||
"model": "deepseek-chat" # optional
|
||||
}
|
||||
|
||||
Stream: transparent upstream LangGraph frames, followed by one status
|
||||
frame::
|
||||
|
||||
{
|
||||
"status": "done" | "asking" | "error",
|
||||
"content": "<final AI text>",
|
||||
"picks": [{"agent_id": "...", "reason": "..."}] | null
|
||||
}
|
||||
|
||||
``done`` means picks were parsed and validated; ``asking`` means the
|
||||
marker was missing or validation left fewer than 2 valid picks (caller
|
||||
should apply its fallback list); ``error`` is upstream failure.
|
||||
"""
|
||||
intent = payload.get("intent") or {}
|
||||
candidates = payload.get("candidates") or []
|
||||
model_name = str(payload.get("model") or "deepseek-chat")
|
||||
|
||||
if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="intent.objective is required")
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
raise HTTPException(status_code=400, detail="candidates must be a non-empty array")
|
||||
|
||||
candidate_ids = {
|
||||
str(c.get("agent_id") or c.get("id") or "").strip()
|
||||
for c in candidates
|
||||
if isinstance(c, dict)
|
||||
}
|
||||
candidate_ids.discard("")
|
||||
if not candidate_ids:
|
||||
raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id")
|
||||
|
||||
prompt = _build_prompt(intent, candidates)
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=RECOMMENDER_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=prompt,
|
||||
# 推荐只做判断:禁止澄清、派活、文件等,避免跑题或二次追问。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"ask_clarification",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
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_content = ""
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
text = _flatten_content(msg.get("content", "")).strip()
|
||||
if text:
|
||||
last_ai_content = text
|
||||
break
|
||||
|
||||
# 只扫描「最后一条非空 AIMessage」:推荐 agent 单轮输出,无多轮 READY 问题。
|
||||
picks: list[dict[str, str]] = []
|
||||
match = _RECOMMEND_READY_PATTERN.search(last_ai_content)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("recommend picks JSON parse failed: %s", exc)
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
picks = _validate_picks(parsed.get("picks"), candidate_ids)
|
||||
|
||||
# asking ≠ 模型在追问用户,而是「网关未能产出合法 picks」,由前端 fallback。
|
||||
status_value = "done" if picks else "asking"
|
||||
final_frame: dict[str, Any] = {
|
||||
"status": status_value,
|
||||
"content": last_ai_content,
|
||||
"picks": picks if picks else None,
|
||||
}
|
||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("recommend stream unexpected failure")
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -54,6 +54,9 @@ class RunCreateRequest(BaseModel):
|
||||
after_seconds: float | None = Field(default=None, description="Delayed execution")
|
||||
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
|
||||
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
|
||||
excluded_tools: list[str] | None = Field(default=None, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
|
||||
skill_stop_names: list[str] | None = Field(default=None, description="Skill names to stop execution when invoked (e.g. ['newsletter-generation'])")
|
||||
skill_stop_message: str | None = Field(default=None, description="Custom message to display when skill stop is triggered")
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
|
||||
@@ -163,6 +163,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"max_concurrent_subagents",
|
||||
"agent_name",
|
||||
"is_bootstrap",
|
||||
"excluded_tools",
|
||||
"skill_stop_names",
|
||||
"skill_stop_message",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -435,7 +438,15 @@ async def start_run(
|
||||
# The ``context`` field is a custom extension for the langgraph-compat layer
|
||||
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
||||
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
||||
merge_run_context_overrides(config, getattr(body, "context", None))
|
||||
body_context = dict(getattr(body, "context", None) or {})
|
||||
# Promote whitelisted top-level body fields into the context map so
|
||||
# callers can pass them either nested under ``context`` or as flat
|
||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||
for top_level_key in ("excluded_tools", "skill_stop_names", "skill_stop_message"):
|
||||
value = getattr(body, top_level_key, None)
|
||||
if value is not None and top_level_key not in body_context:
|
||||
body_context[top_level_key] = value
|
||||
merge_run_context_overrides(config, body_context or None)
|
||||
|
||||
stream_modes = normalize_stream_modes(body.stream_mode)
|
||||
|
||||
|
||||
@@ -312,6 +312,37 @@ def _build_middlewares(
|
||||
|
||||
middlewares.append(SetupAgentApprovalMiddleware())
|
||||
|
||||
# SkillStopMiddleware — intercepts skill invocations and halts execution
|
||||
# when a configured skill is about to run. Request-level options
|
||||
# (skill_stop_names / skill_stop_message) take precedence; static
|
||||
# config in ``app_config.skill_stop`` is the fallback.
|
||||
skill_stop_names = cfg.get("skill_stop_names")
|
||||
skill_stop_message = cfg.get("skill_stop_message")
|
||||
if skill_stop_names:
|
||||
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
|
||||
|
||||
middlewares.append(
|
||||
SkillStopMiddleware(
|
||||
skill_names=skill_stop_names,
|
||||
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
|
||||
stop_message=skill_stop_message,
|
||||
stop_on_match=True,
|
||||
)
|
||||
)
|
||||
logger.info("SkillStopMiddleware enabled (from request): %s", skill_stop_names)
|
||||
elif resolved_app_config.skill_stop.enabled and resolved_app_config.skill_stop.skill_names:
|
||||
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
|
||||
|
||||
middlewares.append(
|
||||
SkillStopMiddleware(
|
||||
skill_names=resolved_app_config.skill_stop.skill_names,
|
||||
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
|
||||
stop_message=resolved_app_config.skill_stop.stop_message,
|
||||
stop_on_match=resolved_app_config.skill_stop.stop_on_match,
|
||||
)
|
||||
)
|
||||
logger.info("SkillStopMiddleware enabled (from config): %s", resolved_app_config.skill_stop.skill_names)
|
||||
|
||||
# ClarificationMiddleware should always be last
|
||||
middlewares.append(ClarificationMiddleware())
|
||||
return middlewares
|
||||
@@ -346,6 +377,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||
writing_artifact_path = None
|
||||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||||
requested_excluded_tools = cfg.get("excluded_tools") or None
|
||||
|
||||
try:
|
||||
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
|
||||
@@ -416,6 +448,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
model_name=model_name,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
+ [setup_agent],
|
||||
@@ -442,6 +475,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
groups=agent_config.tool_groups if agent_config else None,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
),
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
|
||||
+67
-64
@@ -6,6 +6,8 @@ from collections.abc import Callable
|
||||
from hashlib import sha256
|
||||
from typing import override
|
||||
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
context = args.get("context")
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
options = self._normalize_options(args.get("options", []))
|
||||
|
||||
# Type-specific icons
|
||||
type_icons = {
|
||||
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
# Add options in a cleaner format
|
||||
if options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
}
|
||||
for i, option in enumerate(options, 1):
|
||||
label = option.get("label") or option.get("id") or ""
|
||||
message_parts.append(f" {i}. {label}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||
options = raw_options
|
||||
|
||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||
if isinstance(options, str):
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
options = [options]
|
||||
|
||||
if options is None:
|
||||
values: list[object] = []
|
||||
elif isinstance(options, list):
|
||||
values = options
|
||||
else:
|
||||
values = [options]
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
if isinstance(value, dict):
|
||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||
label = str(raw_label).strip() if raw_label is not None else ""
|
||||
if not label:
|
||||
continue
|
||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||
option = {
|
||||
"id": str(raw_id).strip() or f"option-{index}",
|
||||
"label": label,
|
||||
}
|
||||
description = value.get("description")
|
||||
if description is not None and str(description).strip():
|
||||
option["description"] = str(description).strip()
|
||||
normalized.append(option)
|
||||
else:
|
||||
label = str(value).strip()
|
||||
if label:
|
||||
normalized.append({"id": f"option-{index}", "label": label})
|
||||
|
||||
return normalized
|
||||
|
||||
def _build_payload(self, args: dict) -> dict:
|
||||
"""Build structured clarification data for rich clients."""
|
||||
return {
|
||||
"question": str(args.get("question", "")).strip(),
|
||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||
"context": args.get("context"),
|
||||
"options": self._normalize_options(args.get("options", [])),
|
||||
"allow_custom": True,
|
||||
"allow_multiple": resolve_allow_multiple(args),
|
||||
}
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
"""Middleware for intercepting skill calls and stopping execution when specific skills are invoked."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command, interrupt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_name_task(text: str) -> tuple[str, str]:
|
||||
"""Extract `agent_name` and `task_content` from a bash invocation that triggers
|
||||
a sub-agent via ``python3 scripts/agent_orchestration.py "<agent>" "<task>"``.
|
||||
|
||||
Returns empty strings when no match is found so the caller can treat the
|
||||
absence of a match as "not a target skill call".
|
||||
"""
|
||||
try:
|
||||
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return "", ""
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
class SkillStopMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SkillStopMiddleware(AgentMiddleware[SkillStopMiddlewareState]):
|
||||
"""Intercepts skill file reads and stops execution when specific skills are invoked.
|
||||
|
||||
When the model reads a file from a skill directory that matches one of the
|
||||
configured skill names, this middleware:
|
||||
1. Intercepts the tool call before execution
|
||||
2. Returns a Command that stops execution (goes to END)
|
||||
3. Optionally returns a message indicating which skill was triggered
|
||||
|
||||
This is useful for implementing "skill triggers" that pause the agent
|
||||
when certain skills are about to be used.
|
||||
"""
|
||||
|
||||
state_schema = SkillStopMiddlewareState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skill_names: list[str],
|
||||
skills_root: str = "/mnt/skills",
|
||||
stop_message: str | None = None,
|
||||
stop_on_match: bool = True,
|
||||
):
|
||||
"""Initialize the skill stop middleware.
|
||||
|
||||
Args:
|
||||
skill_names: List of skill names to intercept (e.g., ["newsletter-generation", "podcast-generation"]).
|
||||
skills_root: Root directory where skills are located (default: "/mnt/skills").
|
||||
stop_message: Optional message to return when stopping. If None, a default message is used.
|
||||
stop_on_match: If True, stop when skill matches. If False, continue execution (for logging only).
|
||||
"""
|
||||
super().__init__()
|
||||
self._skill_names = set(skill_names)
|
||||
self._skills_root = skills_root.rstrip("/")
|
||||
self._stop_message = stop_message
|
||||
self._stop_on_match = stop_on_match
|
||||
|
||||
def _is_target_skill_call(self, tool_call: dict[str, Any]) -> str | None:
|
||||
"""Return the matched skill name if this tool call invokes one of the
|
||||
configured target skills, otherwise ``None``.
|
||||
|
||||
Two invocation patterns are recognized:
|
||||
|
||||
1. **Structured tool call** — the model emits a tool call whose ``name``
|
||||
literally matches one of the configured skill names (e.g. the
|
||||
multi-agent leader calling
|
||||
``agent_orchestration({"agent_name": "...", "task": "..."})``).
|
||||
This is the natural form most LLMs produce when told to "use a
|
||||
skill", and it's the form that drives the roundtable dispatch.
|
||||
|
||||
2. **Bash + argv** — the legacy form where the lead agent reaches into
|
||||
a skill by running ``python3 scripts/<skill-name>.py "<arg1>" "<arg2>"``.
|
||||
Kept for backward compatibility with skills that use bash for
|
||||
dispatch.
|
||||
"""
|
||||
name = tool_call.get("name") or ""
|
||||
lowered_targets = {s.lower() for s in self._skill_names}
|
||||
|
||||
# Pattern 1: structured tool call whose tool name IS the skill name.
|
||||
if name and (name in self._skill_names or name.lower() in lowered_targets):
|
||||
logger.info("SkillStopMiddleware: matched structured skill call name=%s", name)
|
||||
return name
|
||||
|
||||
# Pattern 2: bash invocation with argv-style skill name.
|
||||
if name == "bash" and "args" in tool_call:
|
||||
skill_name, _ = extract_name_task(str(tool_call["args"]))
|
||||
if skill_name and (skill_name in self._skill_names or skill_name.lower() in lowered_targets):
|
||||
logger.info("SkillStopMiddleware: matched bash skill call name=%s", skill_name)
|
||||
return skill_name
|
||||
|
||||
return None
|
||||
|
||||
def _handle_skill_stop(self, request: ToolCallRequest, skill_name: str) -> Command:
|
||||
"""Build the stop response — an interrupt that includes a ToolMessage
|
||||
describing which skill was triggered."""
|
||||
logger.info("Skill stop triggered: skill_name=%s", skill_name)
|
||||
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
message_content = self._stop_message or f"⏹️ Skill '{skill_name}' triggered. Execution stopped."
|
||||
|
||||
tool_message = ToolMessage(
|
||||
id=f"skill_stop:{tool_call_id}",
|
||||
content=message_content,
|
||||
tool_call_id=tool_call_id,
|
||||
name="skill_stop",
|
||||
)
|
||||
|
||||
return interrupt(
|
||||
{
|
||||
"messages": [tool_message],
|
||||
"skill_name": skill_name,
|
||||
}
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
matched_skill = self._is_target_skill_call(request.tool_call)
|
||||
if matched_skill:
|
||||
if self._stop_on_match:
|
||||
return self._handle_skill_stop(request, matched_skill)
|
||||
logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
matched_skill = self._is_target_skill_call(request.tool_call)
|
||||
if matched_skill:
|
||||
if self._stop_on_match:
|
||||
return self._handle_skill_stop(request, matched_skill)
|
||||
logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
|
||||
return await handler(request)
|
||||
@@ -22,6 +22,7 @@ from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
|
||||
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
|
||||
from deerflow.config.skill_stop_config import SkillStopConfig
|
||||
from deerflow.config.skills_config import SkillsConfig
|
||||
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
|
||||
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
|
||||
@@ -117,6 +118,8 @@ class AppConfig(BaseModel):
|
||||
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||
excluded_tools: list[str] = Field(default_factory=list, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
|
||||
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
||||
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user