Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xhy
This commit is contained in:
commit
f0ebe81879
156 files changed
+16412
-11687
No files matched your search
+4
-1
@@ -10,6 +10,8 @@ frontend-web/.vscode/
|
|||||||
|
|
||||||
# Frontend local/runtime artifacts
|
# Frontend local/runtime artifacts
|
||||||
frontend-web/.env
|
frontend-web/.env
|
||||||
|
frontend-web/.env.local
|
||||||
|
frontend-web/.env.*.local
|
||||||
frontend-web/node_modules/
|
frontend-web/node_modules/
|
||||||
frontend-web/.pnpm-store/
|
frontend-web/.pnpm-store/
|
||||||
frontend-web/tsconfig.tsbuildinfo
|
frontend-web/tsconfig.tsbuildinfo
|
||||||
@@ -24,7 +26,7 @@ frontend-web/src.zip
|
|||||||
frontend-web/src9.zip
|
frontend-web/src9.zip
|
||||||
frontend-web/src-v1.zip
|
frontend-web/src-v1.zip
|
||||||
frontend-web/src-*.rar
|
frontend-web/src-*.rar
|
||||||
|
frontend-web/dist
|
||||||
# Frontend build artifacts
|
# Frontend build artifacts
|
||||||
frontend-web/dist/
|
frontend-web/dist/
|
||||||
frontend-web/dist-scheduled-check/
|
frontend-web/dist-scheduled-check/
|
||||||
@@ -78,3 +80,4 @@ hermes-agent-main/
|
|||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
deploy
|
deploy
|
||||||
|
.history
|
||||||
@@ -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_ 开头的变量)
|
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
|
||||||
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
|
#
|
||||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
# ┌─────────────────┬──────────────────────────────────────────────────┐
|
||||||
|
# │ 命令 │ 生效文件 │
|
||||||
|
# ├─────────────────┼──────────────────────────────────────────────────┤
|
||||||
|
# │ pnpm dev │ .env.development + .env.development.local │
|
||||||
|
# │ pnpm build │ .env.production + .env.production.local │
|
||||||
|
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
|
||||||
|
# └─────────────────┴──────────────────────────────────────────────────┘
|
||||||
|
#
|
||||||
|
# 推荐:
|
||||||
|
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
|
||||||
|
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
|
||||||
|
# - 内网打包部署:改 .env.production 后再执行 pnpm build
|
||||||
|
#
|
||||||
|
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
|
||||||
|
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
|
||||||
|
|
||||||
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
# --- 开发环境(见 .env.development)---
|
||||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||||
|
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||||
|
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离)
|
# --- 生产打包(见 .env.production)---
|
||||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||||
|
|
||||||
|
# 可选
|
||||||
|
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||||
|
# VITE_STATIC_WEBSITE_ONLY=
|
||||||
|
# VITE_GITHUB_OAUTH_TOKEN=
|
||||||
@@ -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
@@ -0,0 +1,265 @@
|
|||||||
|
# 多智能体协调 API 接口文档
|
||||||
|
|
||||||
|
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
|
||||||
|
路由前缀:`/api/multi-agent`
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
提供一个"总控 + 多个子智能体"的协调编排接口。
|
||||||
|
|
||||||
|
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
|
||||||
|
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
|
||||||
|
|
||||||
|
调用方按下面顺序使用:
|
||||||
|
|
||||||
|
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
|
||||||
|
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
|
||||||
|
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
|
||||||
|
|
||||||
|
|
||||||
|
## 1. 初始化多智能体集群
|
||||||
|
|
||||||
|
### `POST /api/multi-agent/init`
|
||||||
|
|
||||||
|
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
|
||||||
|
"main_agent_name": "main-controller",
|
||||||
|
"model": "deepseek-chat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
|
||||||
|
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
|
||||||
|
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
|
||||||
|
|
||||||
|
#### 响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"main_agent_name": "main-controller",
|
||||||
|
"thread_ids": {
|
||||||
|
"main-controller": "thread-uuid-xxx",
|
||||||
|
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
|
||||||
|
"us-intelligence-gathering-agent": "thread-uuid-zzz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
|
||||||
|
|
||||||
|
#### 错误
|
||||||
|
|
||||||
|
| 状态码 | 触发条件 |
|
||||||
|
|---|---|
|
||||||
|
| `400` | `agent_names` 为空 |
|
||||||
|
| `401` | 未携带 `Authorization` 头 |
|
||||||
|
| `500` | 协调智能体创建失败且不是"已存在"错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 流式调度
|
||||||
|
|
||||||
|
### `POST /api/multi-agent/run/stream`
|
||||||
|
|
||||||
|
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
|
||||||
|
|
||||||
|
#### 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_type": "leader",
|
||||||
|
"d_agent_thread_id": {
|
||||||
|
"main-controller": "thread-uuid-xxx",
|
||||||
|
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
|
||||||
|
},
|
||||||
|
"agent_name": "main-controller",
|
||||||
|
"new_message": "请分析特朗普的支持率",
|
||||||
|
"skill_stop_names": [],
|
||||||
|
"model": "deepseek-chat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
|
||||||
|
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
|
||||||
|
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
|
||||||
|
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
|
||||||
|
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
|
||||||
|
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
|
||||||
|
|
||||||
|
##### a. 透传上游事件
|
||||||
|
|
||||||
|
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
|
||||||
|
|
||||||
|
##### b. 最后一帧:调度状态
|
||||||
|
|
||||||
|
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
|
||||||
|
|
||||||
|
**`agent_type == "leader"`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
|
||||||
|
|
||||||
|
**`agent_type == "special"`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"status": "子智能体xxx完成yyy工作"}
|
||||||
|
```
|
||||||
|
|
||||||
|
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
|
||||||
|
|
||||||
|
#### 副作用(重要)
|
||||||
|
|
||||||
|
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
|
||||||
|
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
|
||||||
|
|
||||||
|
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
|
||||||
|
|
||||||
|
#### 错误
|
||||||
|
|
||||||
|
| 状态码 | 触发条件 |
|
||||||
|
|---|---|
|
||||||
|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
|
||||||
|
| `401` | 未携带 `Authorization` 头 |
|
||||||
|
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 前端调用流程示例
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 1. 拿 token
|
||||||
|
const loginResp = await fetch("/api/v1/auth/login/username", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "yourname" }),
|
||||||
|
});
|
||||||
|
const { access_token } = await loginResp.json();
|
||||||
|
const authHeaders = {
|
||||||
|
Authorization: `Bearer ${access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 初始化
|
||||||
|
const initResp = await fetch("/api/multi-agent/init", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_names: [
|
||||||
|
"taiwan-intelligence-gathering-agent",
|
||||||
|
"us-intelligence-gathering-agent",
|
||||||
|
],
|
||||||
|
main_agent_name: "main-controller",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { thread_ids } = await initResp.json();
|
||||||
|
|
||||||
|
// 3. 流式调用 leader
|
||||||
|
async function streamLeader(message: string, model = "deepseek-chat") {
|
||||||
|
const resp = await fetch("/api/multi-agent/run/stream", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_type: "leader",
|
||||||
|
d_agent_thread_id: thread_ids,
|
||||||
|
agent_name: "main-controller",
|
||||||
|
new_message: message,
|
||||||
|
skill_stop_names: [],
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const reader = resp.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
let dispatched: [string, string][] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split("\n");
|
||||||
|
buf = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(line.slice(5).trim());
|
||||||
|
if (Array.isArray(payload.status)) {
|
||||||
|
dispatched = payload.status;
|
||||||
|
}
|
||||||
|
// 其余事件按 LangGraph SDK 协议自行渲染
|
||||||
|
} catch {
|
||||||
|
/* 非 JSON 帧,可忽略或交给现有渲染器 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 对派活清单逐个调用 special
|
||||||
|
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
|
||||||
|
return fetch("/api/multi-agent/run/stream", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_type: "special",
|
||||||
|
d_agent_thread_id: thread_ids,
|
||||||
|
agent_name: agentName,
|
||||||
|
new_message: task,
|
||||||
|
skill_stop_names: [],
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// 同样按 SSE 解析
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
|
||||||
|
const dispatched = await streamLeader("请分析特朗普的支持率");
|
||||||
|
for (const [agentName, task] of dispatched) {
|
||||||
|
await streamSpecial(agentName, task);
|
||||||
|
}
|
||||||
|
const next = await streamLeader("根据上面收集到的信息出最终结论");
|
||||||
|
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 注意事项
|
||||||
|
|
||||||
|
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
|
||||||
|
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
|
||||||
|
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
|
||||||
|
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
|
||||||
|
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
|
||||||
|
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 相关接口(底层依赖)
|
||||||
|
|
||||||
|
本接口内部转发以下 Gateway 原生接口,了解细节可查:
|
||||||
|
|
||||||
|
| 接口 | 路由 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
|
||||||
|
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
|
||||||
|
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
|
||||||
|
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
|
||||||
|
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
|
||||||
|
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# 多智能体圆桌研讨 · 后端开发文档
|
||||||
|
|
||||||
|
本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 功能定位
|
||||||
|
|
||||||
|
把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。
|
||||||
|
|
||||||
|
对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 涉及的后端文件
|
||||||
|
|
||||||
|
```
|
||||||
|
offline-backend-20260512/backend/
|
||||||
|
├── app/gateway/
|
||||||
|
│ ├── app.py # 注册 multi_agent.router
|
||||||
|
│ └── routers/
|
||||||
|
│ └── multi_agent.py # 本功能唯一新增的 router
|
||||||
|
└── .deer-flow/agents/
|
||||||
|
├── roundtable-intelligence/ # 6 个内置子智能体目录
|
||||||
|
│ ├── config.yaml # ├── id / name / description
|
||||||
|
│ └── SOUL.md # └── 角色 prompt
|
||||||
|
├── roundtable-environment/
|
||||||
|
├── roundtable-solution-design/
|
||||||
|
├── roundtable-risk-review/
|
||||||
|
├── roundtable-execution-plan/
|
||||||
|
└── roundtable-summary/
|
||||||
|
```
|
||||||
|
|
||||||
|
**零代码改动构造内置 agent**:6 个目录通过既有的 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 接口总览
|
||||||
|
|
||||||
|
路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer <JWT>`。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/multi-agent/init` | 一次性创建协调智能体 + 6 个子席位线程,返回 `thread_ids` 映射 |
|
||||||
|
| POST | `/api/multi-agent/run/stream` | SSE 流式驱动一次 leader 或 special 调用 |
|
||||||
|
|
||||||
|
字段细节见 `multi-agent-api.md`,这里不重复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 内部实现详解
|
||||||
|
|
||||||
|
### 4.1 `/init` 内部流程
|
||||||
|
|
||||||
|
```
|
||||||
|
[阶段1] 并行准备 6 个子智能体席位 (asyncio.gather)
|
||||||
|
├─ GET /api/agents/{id} × 6 → 取 name + description
|
||||||
|
└─ POST /api/threads × 6 → 建新 thread
|
||||||
|
|
||||||
|
[阶段2] 创建协调智能体
|
||||||
|
├─ POST /api/threads → 协调智能体的 thread
|
||||||
|
└─ POST /api/agents (with model + skills=[agent_orchestration] + soul)
|
||||||
|
└─ soul 里包含可调度的 6 个 agent_name + description 列表
|
||||||
|
```
|
||||||
|
|
||||||
|
**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
soul = (
|
||||||
|
"#核心定位:\n"
|
||||||
|
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n"
|
||||||
|
"#调度方法:\n"
|
||||||
|
"通过使用agent_orchestration技能调用其他智能体干活,...\n"
|
||||||
|
"#可调度智能体:\n"
|
||||||
|
f"{descriptions}" # ← 6 个子席位的 name+description
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。
|
||||||
|
|
||||||
|
### 4.2 `/run/stream` 内部流程
|
||||||
|
|
||||||
|
接收 `agent_type` 后走两条分支:
|
||||||
|
|
||||||
|
#### `agent_type = "leader"` (协调智能体)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather)
|
||||||
|
2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream
|
||||||
|
- excluded_tools: ["web_search"]
|
||||||
|
- context.model_name = 用户传的 model
|
||||||
|
3. 流式透传 LangGraph 事件给前端
|
||||||
|
4. 流结束后从最后一帧 values 取最末一条 message
|
||||||
|
5. 解析 message.tool_calls (agent_orchestration 技能调用):
|
||||||
|
- 用 _extract_name_task() 提取 [agent_name, task_text]
|
||||||
|
- 对每个 [agent_name, task]:
|
||||||
|
broadcast「总控给 X 派活,内容为: ...」到所有非派活双方
|
||||||
|
6. 末尾追加状态帧:
|
||||||
|
{
|
||||||
|
"status": [["agent_name", "task"], ...], // 空数组 = 共识达成
|
||||||
|
"content": "<leader 最终文本>",
|
||||||
|
"agent_name": "<coordinator>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `agent_type = "special"` (子智能体)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 拼 RunCreateRequest,转发到 POST /api/threads/{sub_agent}/runs/stream
|
||||||
|
- excluded_tools: ["web_search","ask_clarification","present_files","view_image"]
|
||||||
|
2. 流式透传 LangGraph 事件
|
||||||
|
3. 流结束后从最后一帧 values 取交付内容 (content)
|
||||||
|
4. broadcast「子智能体 X 完成 Y 工作,交付内容为: ...」到所有非自己线程
|
||||||
|
5. 末尾追加状态帧:
|
||||||
|
{
|
||||||
|
"status": "子智能体 X 完成 Y 工作",
|
||||||
|
"content": "<交付内容>",
|
||||||
|
"agent_name": "<sub agent>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 _broadcast 机制
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _broadcast(client, base, headers, message, agent_threads, skip):
|
||||||
|
targets = [tid for name, tid in agent_threads.items() if name not in skip]
|
||||||
|
await asyncio.gather(*(_append_thread_message(...) for tid in targets))
|
||||||
|
```
|
||||||
|
|
||||||
|
`_append_thread_message` 对每个目标 thread:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/threads/{id}/state # 拿当前 messages 列表
|
||||||
|
state.values.messages.append({"type":"human","content":...})
|
||||||
|
POST /api/threads/{id}/state # 写回完整 state
|
||||||
|
```
|
||||||
|
|
||||||
|
这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。
|
||||||
|
|
||||||
|
**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 6 个内置 agent SOUL 设计要点
|
||||||
|
|
||||||
|
每个 `roundtable-*` 的 SOUL.md 遵循统一结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
# {席位名}
|
||||||
|
|
||||||
|
## 角色定位
|
||||||
|
你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度,
|
||||||
|
你只在被派到任务时发言,不需要主动推动议程。
|
||||||
|
|
||||||
|
## 核心职责
|
||||||
|
- ...具体职责...
|
||||||
|
|
||||||
|
## 输出风格
|
||||||
|
- 每次发言以 {emoji} 开头
|
||||||
|
- 第一句直接给结论
|
||||||
|
- 不超过 4 句
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
- 只产出 {本席位输出}
|
||||||
|
- 不替其他席位做 {其他席位输出}
|
||||||
|
- 任务结束后停下,等待总控派下一个任务
|
||||||
|
|
||||||
|
## 示例输出
|
||||||
|
> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
| 席位 | agent_id | emoji | 核心职责 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险预警 |
|
||||||
|
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 量化承载力 |
|
||||||
|
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
|
||||||
|
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
|
||||||
|
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源排布 |
|
||||||
|
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 + 盖印 |
|
||||||
|
|
||||||
|
**为什么不让子智能体自己调度**:所有 SOUL 都明文写了"只在被点到时发言,由总控协调智能体派活"。子智能体不装 `agent_orchestration` 技能。只有协调智能体装。这是单调度源,避免多智能体相互抢调度权造成的死循环。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键设计决策
|
||||||
|
|
||||||
|
### 6.1 为什么用 HTTP loopback 而不是内部函数调用
|
||||||
|
|
||||||
|
`multi_agent.py` 调用 `/api/threads`、`/api/agents`、`/api/threads/.../runs/stream` 都是用 `httpx` 走 `http://localhost:8001/...` 自身循环回来,而不是直接 `from app.gateway.services import ...`。
|
||||||
|
|
||||||
|
**好处**:
|
||||||
|
- 自动复用 Gateway 的鉴权 + CSRF + permission 中间件,不用再手写
|
||||||
|
- 复用既有路由的请求验证(Pydantic)和错误响应
|
||||||
|
- `Authorization` 头透传,被调端自然知道是哪个用户在调
|
||||||
|
|
||||||
|
**代价**: 每次调用都有一次额外的 TCP+JWT 解析开销(~10-50ms)。但因为 init 已经做了并行优化(A)、broadcast 已经省掉(C),目前总体耗时是可接受的。
|
||||||
|
|
||||||
|
如未来需要进一步优化,可改为直接调用 `app/gateway/services.py` 里的内部函数,绕过 HTTP 一层。
|
||||||
|
|
||||||
|
### 6.2 为什么协调智能体每次新建
|
||||||
|
|
||||||
|
前端在每次进入 step 2 时生成 `roundtable-coordinator-{Date.now().toString(36)}`,作为 `main_agent_name`。
|
||||||
|
|
||||||
|
**好处**:
|
||||||
|
- 不同会话/不同用户/同一用户不同任务之间完全隔离
|
||||||
|
- 协调智能体的对话历史就是本次研讨的全部上下文,不会被上次任务污染
|
||||||
|
- SOUL 不会被"上次的可调度列表"覆盖(虽然现在 6 个席位是固定的)
|
||||||
|
|
||||||
|
**代价**: 每次都要在 `agents` 表插一行,`.deer-flow/agents/<id>/` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 description 以 "你是总控控制协调智能体" 开头的 agent 自动 GC)。
|
||||||
|
|
||||||
|
### 6.3 子智能体席位为什么固定 6 个
|
||||||
|
|
||||||
|
- 圆桌的 6 个角色定位是产品级强约束(情报/环境/方案/风险/执行/总结)
|
||||||
|
- 写在后端 SOUL.md 一处维护,跟其他内置 agent 同等地位
|
||||||
|
- 前端只需要把 6 个 hyphen-case 名作为 `agent_names` 传过来即可
|
||||||
|
|
||||||
|
如果后续要支持"用户自选席位",在前端加 mapping 面板让用户从 `GET /api/agents` 里挑,不动后端。
|
||||||
|
|
||||||
|
### 6.4 为什么 SSE 末尾还要追加一个 `{status:...}` 帧
|
||||||
|
|
||||||
|
LangGraph 原生 SSE 里没有"调度结果"这种语义,前端只能从最后一帧 values 里去翻 tool_calls。如果由前端解析,前后端协议会绑死 LangGraph 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": [["agent_name", "task"], ...] | "<完成提示>",
|
||||||
|
"content": "<最终 AI 文本>",
|
||||||
|
"agent_name": "<本轮 agent>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 性能特征
|
||||||
|
|
||||||
|
### 7.1 已应用的优化
|
||||||
|
|
||||||
|
| ID | 优化 | 收益 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s |
|
||||||
|
| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s |
|
||||||
|
|
||||||
|
应用前 `/init` 耗时 **3-8s**,应用后 **0.5-1.2s**。
|
||||||
|
|
||||||
|
### 7.2 仍然存在的开销点
|
||||||
|
|
||||||
|
| 调用 | 单次耗时 | 主因 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/agents/{id}` | 50-300ms | 每次触发 `_sync_legacy_agents()` 扫整个 `.deer-flow/agents/` 目录(目前 30+ 项) |
|
||||||
|
| `POST /api/threads` | 100-200ms | LangGraph thread 表插入 + 默认 state 初始化 |
|
||||||
|
| `POST /api/agents` | 100-300ms | 文件系统写入 SOUL.md + config.yaml + agents 表 |
|
||||||
|
| `GET/POST /state` | 50-150ms × 2 | checkpointer 读写 |
|
||||||
|
| `POST /runs/stream` 启动 | 50-100ms | RunManager + StreamBridge 初始化 |
|
||||||
|
| LLM 推理 | **数百毫秒~数十秒** | 取决于 model + 上下文长度 |
|
||||||
|
|
||||||
|
**LLM 推理才是绝对大头**,基础设施开销在十几个百分点以内。
|
||||||
|
|
||||||
|
### 7.3 未来可探索
|
||||||
|
|
||||||
|
- **B**(已识别未做): broadcast 跳过 GET-state,直接 POST 新建态。但 POST 需要构造完整的 state shape,改造成本不小。
|
||||||
|
- **D**(已识别未做): loopback HTTP → 内部函数调用。代码改动大,但能省掉 ~20-30 次 HTTP 解析开销。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 依赖与扩展点
|
||||||
|
|
||||||
|
### 8.1 `agent_orchestration` 技能
|
||||||
|
|
||||||
|
协调智能体强依赖此技能,SOUL 里硬编码:
|
||||||
|
|
||||||
|
```
|
||||||
|
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
|
||||||
|
```
|
||||||
|
|
||||||
|
技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
|
||||||
|
```
|
||||||
|
|
||||||
|
**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。
|
||||||
|
|
||||||
|
### 8.2 模型选择
|
||||||
|
|
||||||
|
前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。
|
||||||
|
|
||||||
|
- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置)
|
||||||
|
- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`)
|
||||||
|
|
||||||
|
前端允许"逐席位覆盖模型",由前端在 special 调用时按 `agentConfigs[seat].model` 传入。后端不感知"席位"概念,只看到 `model` 字段。
|
||||||
|
|
||||||
|
### 8.3 自定义新席位
|
||||||
|
|
||||||
|
如果要加第 7 个席位(比如"成本测算"):
|
||||||
|
|
||||||
|
1. `.deer-flow/agents/roundtable-cost/` 加 `config.yaml` + `SOUL.md`
|
||||||
|
2. 前端 `RoundtablePlanningPage.tsx` 的 `ROUNDTABLE_ROLE_TEMPLATES` + `AGENT_NAME_TO_ROLE` 加映射
|
||||||
|
3. 圆桌可视化(圆圈环形图)加一个气泡 + 头像
|
||||||
|
|
||||||
|
**后端代码完全不需要改**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 故障排查
|
||||||
|
|
||||||
|
| 现象 | 可能原因 | 排查 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/init` 500 "create coordinator agent failed" | `agent_orchestration` 技能不存在 / config.yaml 写入失败 | 看后端日志 `multi_agent` logger;检查 `skills/` 目录 |
|
||||||
|
| `/run/stream` 502 "upstream returned HTTP 4xx" | JWT 过期 / 转发到上游时鉴权挂了 | 前端检查 Authorization 头是否带上 |
|
||||||
|
| leader 永远 `status: []` 立即结束 | 协调智能体没产生 `tool_calls`,直接给文本回答 | 协调智能体 SOUL 不够强、或模型不支持工具调用,换 model |
|
||||||
|
| special 内容为空 | 子智能体被 `excluded_tools` 限制到无可用工具 / 模型无文本输出 | 看上游 `runs/stream` 的 values 帧 messages.last.content |
|
||||||
|
| 进入 step 2 卡住转圈 | `/init` 超时或失败 | F12 Network 看 `/api/multi-agent/init` 状态码与响应体 |
|
||||||
|
|
||||||
|
### 服务端日志关键字
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# multi_agent 路由的日志
|
||||||
|
grep "multi_agent" .runtime/logs/*.log
|
||||||
|
|
||||||
|
# 派活解析失败
|
||||||
|
grep "dispatch failed" .runtime/logs/*.log
|
||||||
|
|
||||||
|
# 广播失败(通常无害)
|
||||||
|
grep "update state .* failed" .runtime/logs/*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试入口
|
||||||
|
|
||||||
|
后端目前没有针对本功能的单测。建议补:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/tests/test_multi_agent.py
|
||||||
|
- test_init_creates_threads_for_all_subagents
|
||||||
|
- test_init_creates_coordinator_with_orchestration_skill
|
||||||
|
- test_init_returns_400_when_agent_names_empty
|
||||||
|
- test_stream_leader_emits_status_frame_with_content
|
||||||
|
- test_stream_special_emits_status_frame_with_content
|
||||||
|
- test_extract_name_task_parses_orchestration_skill_output
|
||||||
|
- test_broadcast_skips_listed_agents
|
||||||
|
```
|
||||||
|
|
||||||
|
可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 路径速查
|
||||||
|
|
||||||
|
| 资源 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| 路由实现 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
|
||||||
|
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py`(`include_router(multi_agent.router)`) |
|
||||||
|
| 6 个内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` |
|
||||||
|
| `agent_orchestration` 技能定义 | `skills/public/agent_orchestration/`(若存在) |
|
||||||
|
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||||
|
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||||
|
| 前端 API 速查 | `frontend-web/docs/multi-agent-api.md` |
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现
|
||||||
|
|
||||||
|
> Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。
|
||||||
|
>
|
||||||
|
> 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与边界
|
||||||
|
|
||||||
|
### ✅ 要做的事
|
||||||
|
- 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位
|
||||||
|
- 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream`
|
||||||
|
- 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper)
|
||||||
|
- 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration`
|
||||||
|
|
||||||
|
### ❌ 不做的事
|
||||||
|
- 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样)
|
||||||
|
- 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可)
|
||||||
|
- 不做时间线(timelineEntries)的后端持久化(前端临时状态够用)
|
||||||
|
- 不做"评分/置信度"等假指标
|
||||||
|
- 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据流概览
|
||||||
|
|
||||||
|
```
|
||||||
|
[Step 1 入口]
|
||||||
|
↓
|
||||||
|
前端:POST /api/intent/init
|
||||||
|
← {agent_id: "roundtable-intent", thread_id: "..."}
|
||||||
|
|
||||||
|
用户输入第一句话:"帮我规划一个生产系统安全改造方案"
|
||||||
|
↓
|
||||||
|
前端:POST /api/intent/stream (SSE)
|
||||||
|
← (流式 AI 文本) "好的,预算大致是多少?"
|
||||||
|
← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续
|
||||||
|
|
||||||
|
用户回:"500 万左右"
|
||||||
|
↓
|
||||||
|
前端再次 POST /api/intent/stream
|
||||||
|
← (流式) "明白,还有什么时间约束吗?"
|
||||||
|
← {status: "asking", ...}
|
||||||
|
|
||||||
|
...对话进行几轮 ...
|
||||||
|
|
||||||
|
用户:可以了
|
||||||
|
↓
|
||||||
|
前端再 POST /api/intent/stream
|
||||||
|
← (流式) "好,我理解的任务是: <汇总>"
|
||||||
|
← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2
|
||||||
|
|
||||||
|
[Step 1 完成 → Step 2 init]
|
||||||
|
前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 接口设计
|
||||||
|
|
||||||
|
跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。
|
||||||
|
|
||||||
|
### 3.1 `POST /api/intent/init`
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应 200**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"agent_id": "roundtable-intent",
|
||||||
|
"thread_id": "<langgraph_thread_id>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
1. 鉴权(透传 `Authorization`)
|
||||||
|
2. 调上游 `POST /api/threads` 建 1 个 thread
|
||||||
|
3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**)
|
||||||
|
|
||||||
|
**与 step 2 `/init` 的区别**:
|
||||||
|
- 没有"协调智能体"概念,只有 1 个 agent
|
||||||
|
- 没有 6 个 sub 席位,只有 1 个 thread
|
||||||
|
- 不需要传 `agent_names` 数组
|
||||||
|
- `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的)
|
||||||
|
|
||||||
|
### 3.2 `POST /api/intent/stream`
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thread_id": "<from /init>",
|
||||||
|
"message": "用户这一轮要说的话",
|
||||||
|
"model": "deepseek-chat" // 可选
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:`text/event-stream`,SSE 流式。
|
||||||
|
|
||||||
|
**SSE 帧组成**:
|
||||||
|
1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量
|
||||||
|
2. 流末追加一个**状态帧**(跟 step 2 同样模式):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "asking" | "done" | "error",
|
||||||
|
"content": "<本轮 AI 文本>",
|
||||||
|
"summary": {
|
||||||
|
"objective": "...", // 仅 status=done 时有: 任务目标
|
||||||
|
"constraints": ["...", ...], // 仅 status=done 时有: 约束清单
|
||||||
|
"assumptions": ["...", ...] // 仅 status=done 时有: 关键假设
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`status` 取值**:
|
||||||
|
- `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框
|
||||||
|
- `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent
|
||||||
|
- `"error"` —— 上游异常,`content` 里是错误信息
|
||||||
|
|
||||||
|
**判定 done/asking 的简单策略**(后端实现):
|
||||||
|
- 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧
|
||||||
|
- 否则一律是 `asking`
|
||||||
|
|
||||||
|
> 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> [INTENT_READY]
|
||||||
|
> ```json
|
||||||
|
> {"objective": "...", "constraints": ["..."], "assumptions": ["..."]}
|
||||||
|
> ```
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 内置智能体配置
|
||||||
|
|
||||||
|
**位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/`
|
||||||
|
|
||||||
|
### 4.1 `config.yaml`
|
||||||
|
```yaml
|
||||||
|
id: roundtable-intent
|
||||||
|
name: 任务理解智能体
|
||||||
|
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `SOUL.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 任务理解智能体
|
||||||
|
|
||||||
|
## 角色定位
|
||||||
|
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
|
||||||
|
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。
|
||||||
|
|
||||||
|
## 核心职责
|
||||||
|
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
|
||||||
|
- 不要追问已经清楚的事
|
||||||
|
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
|
||||||
|
- 信息足够后,立即停止追问,产出结构化汇总
|
||||||
|
|
||||||
|
## 信息充分性判定
|
||||||
|
当满足以下条件时,认为信息已充分:
|
||||||
|
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
|
||||||
|
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
|
||||||
|
3. 没有明显的歧义点
|
||||||
|
|
||||||
|
## 信息汇总产出
|
||||||
|
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):
|
||||||
|
|
||||||
|
```
|
||||||
|
[INTENT_READY]
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "用一句话写出用户最终要做的事",
|
||||||
|
"constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...],
|
||||||
|
"assumptions": ["假设 1", "假设 2", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。
|
||||||
|
|
||||||
|
## 输出风格
|
||||||
|
- 一次回复不超过 3 句话
|
||||||
|
- 提问要具体,不要"还有什么补充吗"这种空问
|
||||||
|
- 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总)
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
- 不调用 agent_orchestration(这是 step 2 的协调智能体的事)
|
||||||
|
- 不写文件、不做计算、不联网
|
||||||
|
- 用户消息歧义大时,优先调 ask_clarification 工具引导
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 注册方式
|
||||||
|
|
||||||
|
跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 后端实现要点
|
||||||
|
|
||||||
|
### 5.1 新建 router 文件
|
||||||
|
**位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""任务理解智能体接口 - Step 1 的极简包装。
|
||||||
|
|
||||||
|
跟 multi_agent.py 同构但简化:
|
||||||
|
- 没有 broadcast(单 thread,无需广播)
|
||||||
|
- 没有派活逻辑
|
||||||
|
- 没有 leader/special 分支,只有一种调用方式
|
||||||
|
- 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import httpx, json, re, asyncio, logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
||||||
|
|
||||||
|
# 复用 multi_agent.py 里已有的工具函数
|
||||||
|
from app.gateway.routers.multi_agent import (
|
||||||
|
_loopback_base, _auth_headers,
|
||||||
|
_create_thread, _stream_upstream, _parse_last_messages,
|
||||||
|
_flatten_content, _run_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
INTENT_AGENT_ID = "roundtable-intent"
|
||||||
|
|
||||||
|
_INTENT_READY_PATTERN = re.compile(
|
||||||
|
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/init")
|
||||||
|
async def init_intent(request: Request, payload: dict = Body(...)) -> dict:
|
||||||
|
base = _loopback_base(request)
|
||||||
|
headers = _auth_headers(request)
|
||||||
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
|
thread_id = await _create_thread(client, base, headers)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"agent_id": INTENT_AGENT_ID,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream")
|
||||||
|
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
|
||||||
|
thread_id = str(payload.get("thread_id") or "").strip()
|
||||||
|
message = str(payload.get("message") or "").strip()
|
||||||
|
model_name = str(payload.get("model") or "deepseek-chat")
|
||||||
|
if not thread_id or not message:
|
||||||
|
raise HTTPException(status_code=400, detail="thread_id and message required")
|
||||||
|
|
||||||
|
base = _loopback_base(request)
|
||||||
|
headers = _auth_headers(request)
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
|
try:
|
||||||
|
body = _run_payload(
|
||||||
|
agent_name=INTENT_AGENT_ID,
|
||||||
|
model_name=model_name,
|
||||||
|
thread_id=thread_id,
|
||||||
|
new_message=message,
|
||||||
|
excluded_tools=["web_search", "present_files",
|
||||||
|
"view_image", "agent_orchestration"],
|
||||||
|
skill_stop_names=[],
|
||||||
|
)
|
||||||
|
captured = []
|
||||||
|
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
|
||||||
|
if line is not None:
|
||||||
|
yield (line if line.endswith("\n") else line + "\n")
|
||||||
|
elif done is not None:
|
||||||
|
captured = done
|
||||||
|
|
||||||
|
messages = _parse_last_messages(captured)
|
||||||
|
last_ai = {}
|
||||||
|
for m in reversed(messages):
|
||||||
|
if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||||
|
last_ai = m
|
||||||
|
break
|
||||||
|
content = _flatten_content(last_ai.get("content", ""))
|
||||||
|
|
||||||
|
# 检测 [INTENT_READY] 标记
|
||||||
|
match = _INTENT_READY_PATTERN.search(content)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
summary = json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
summary = None
|
||||||
|
frame = {"status": "done", "content": content, "summary": summary}
|
||||||
|
else:
|
||||||
|
frame = {"status": "asking", "content": content}
|
||||||
|
yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
|
||||||
|
except HTTPException as exc:
|
||||||
|
yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("intent stream failure")
|
||||||
|
yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
gen(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 在 `app/gateway/app.py` 注册
|
||||||
|
```python
|
||||||
|
from app.gateway.routers import intent
|
||||||
|
app.include_router(intent.router)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端实现要点
|
||||||
|
|
||||||
|
### 6.1 新增 API helper
|
||||||
|
**位置**:`frontend-web/src/roundtable-planning/api/intent.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
export interface IntentInitResponse {
|
||||||
|
status: "success";
|
||||||
|
agent_id: string;
|
||||||
|
thread_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentSummary {
|
||||||
|
objective: string;
|
||||||
|
constraints: string[];
|
||||||
|
assumptions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamIntentRequest {
|
||||||
|
threadId: string;
|
||||||
|
message: string;
|
||||||
|
model?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onTextDelta?: (text: string, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamIntentResult {
|
||||||
|
status: "asking" | "done" | "error";
|
||||||
|
content: string;
|
||||||
|
summary: IntentSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
|
||||||
|
// 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
|
||||||
|
// {status, content, summary?} 格式。
|
||||||
|
// ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染
|
||||||
|
|
||||||
|
替换 `handleSendMessage`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||||
|
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 1 && !intentThreadId) {
|
||||||
|
initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id));
|
||||||
|
}
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!inputText.trim() || !intentThreadId) return;
|
||||||
|
const userText = inputText.trim();
|
||||||
|
// (push user msg to interactiveMessages 略)
|
||||||
|
setInputText("");
|
||||||
|
|
||||||
|
// 占位 agent 气泡
|
||||||
|
const agentMsgId = pushAgentPlaceholder("任务理解智能体");
|
||||||
|
|
||||||
|
const res = await streamIntent({
|
||||||
|
threadId: intentThreadId,
|
||||||
|
message: userText,
|
||||||
|
model: DEFAULT_MODEL,
|
||||||
|
onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta),
|
||||||
|
});
|
||||||
|
|
||||||
|
finalizeAgentMsg(agentMsgId, res.content);
|
||||||
|
if (res.status === "done" && res.summary) {
|
||||||
|
setIntentReady(res.summary);
|
||||||
|
triggerToast("✅ 任务理解完成,可以进入圆桌研讨");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 "进入 step 2"按钮的联动
|
||||||
|
|
||||||
|
- 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`)
|
||||||
|
- 改造:只有 `intentReady !== null` 时该按钮才 enabled
|
||||||
|
- 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const buildInitialIntent = useCallback(() => {
|
||||||
|
if (intentReady) {
|
||||||
|
return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||||
|
}
|
||||||
|
// fallback: 旧逻辑保留兜底
|
||||||
|
const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
|
||||||
|
const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨";
|
||||||
|
const constraints = `预算硬约束:${budgetLimit} 万元`;
|
||||||
|
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||||
|
}, [interactiveMessages, budgetLimit, intentReady]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实施清单
|
||||||
|
|
||||||
|
| 优先级 | 项 | 文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` |
|
||||||
|
| P0 | 新 router | `backend/app/gateway/routers/intent.py` |
|
||||||
|
| P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) |
|
||||||
|
| P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||||
|
| P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) |
|
||||||
|
| P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 |
|
||||||
|
| P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) |
|
||||||
|
| P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 与 step 2 的衔接
|
||||||
|
|
||||||
|
```
|
||||||
|
step 1 输出: intentReady.objective + intentReady.constraints
|
||||||
|
↓
|
||||||
|
step 2 入口: buildInitialIntent() 拼成 leader 的 new_message
|
||||||
|
↓
|
||||||
|
step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了),
|
||||||
|
除非模型遇到完全意料之外的歧义
|
||||||
|
↓
|
||||||
|
研讨更聚焦,减少 step2 阶段的 clarification 轮次
|
||||||
|
```
|
||||||
|
|
||||||
|
副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 路径速查
|
||||||
|
|
||||||
|
| 资源 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) |
|
||||||
|
| 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` |
|
||||||
|
| 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
|
||||||
|
| 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
|
||||||
|
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
|
||||||
|
| 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 验收清单
|
||||||
|
|
||||||
|
- [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id
|
||||||
|
- [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout)
|
||||||
|
- [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary
|
||||||
|
- [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态
|
||||||
|
- [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话)
|
||||||
|
- [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# 多智能体圆桌 · Step 3 · 接口与对接说明
|
||||||
|
|
||||||
|
> Step 3(`/page/roundtable/planning` 的"最终方案展示"页)对应 `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx`。当前页面是产品早期的"展厅效果",绝大多数内容是硬编码 mock。本文档梳理**真正需要的数据**、**用哪些接口**、**前后端怎么对接**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 改造方向(在做什么,不做什么)
|
||||||
|
|
||||||
|
### ✅ 保留并对接真实数据
|
||||||
|
|
||||||
|
| 区块 | 现状 | 改造后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 标题栏 + 生成时间 | 硬编码"2026-05-21 11:01:12" | 用本地 `Date.now()` 或 init/最后一次 leader 完成时刻 |
|
||||||
|
| "已达成共识"徽章 | 静态显示 | step2 自然收敛(`leaderRes.dispatched.length === 0`)后才显示 |
|
||||||
|
| **总控最终结论** | 完全是 mock 文本 | 取最后一次 `leaderRes.content`(已经在 step2 拿到了) |
|
||||||
|
| **各席位最终交付(折叠卡片)** | 当前 step3 没渲染 | 取每个 sub 线程的最后一条 AIMessage 文本 |
|
||||||
|
| **生成的报告文件清单** | 5 张假"档案" | 真实列出 `outputs/` 目录里的所有产出(markdown / pdf / csv 等) |
|
||||||
|
| **文件下载** | 假 toast | 用现有 `GET /api/threads/{tid}/artifacts/{path}` |
|
||||||
|
| "重新起草新规划" | 调用 `setCurrentStep(1)` 重置 | **保留**,含义就是回到 step1 重新开始 |
|
||||||
|
|
||||||
|
### ❌ 不做(直接删 / 不接口)
|
||||||
|
|
||||||
|
| 区块 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| 4 张 metric 卡片(设计全要素通过率 99.4% / Tier III+ / 终审共识度 42 轮研商 / 90.3分)| 全是按 `budgetLimit` 编出来的"展厅指标",没有真实数据源 |
|
||||||
|
| 三阶段推进路线图(0-24h / 24-60h / 60-72h 三张卡)| 假设业务必然是 72 小时交付,写死场景,不通用 |
|
||||||
|
| "采纳理由与最终裁定判词"段 | 整段都是模板话术,没接口能算出"90.3 分总评" |
|
||||||
|
| "12 轮极限对抗研商" 数据 | 当前没记录"轮次"语义,临时就用 leader 实际轮数代替即可,不值得为它出接口 |
|
||||||
|
| "签发起草成果并投产运行" 按钮 | 无对应业务动作(不是要部署、不是要审批),删掉 |
|
||||||
|
| "执行摘要" 段写死的 3 个 bullet | 模板话术,跟实际任务无关 |
|
||||||
|
|
||||||
|
最终 step3 页面只保留四块:**头部状态 + 总控结论 + 席位交付列表 + 文件下载清单**,再加底部"重新起草"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据来源全景
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────┐
|
||||||
|
│ step2 全程结束时,前端手里已经有的本地状态 │
|
||||||
|
├───────────────────────────────────────────────────────────┤
|
||||||
|
│ coordinatorName: "roundtable-coordinator-xxx" │
|
||||||
|
│ threadIds: { [agent_name]: thread_id, ... } │ ← /init 返回
|
||||||
|
│ step2RoundtableDialogues: [...所有气泡, 含每个 sub 最终 │
|
||||||
|
│ 交付的 content...] │
|
||||||
|
│ leaderRes.content (最后一次 leader 的 final text) │
|
||||||
|
└───────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
后端权威数据(需要时从后端拉):
|
||||||
|
- 每个 sub 线程的完整 state → GET /api/threads/{tid}/state
|
||||||
|
- 单个 outputs 文件内容/下载 → GET /api/threads/{tid}/artifacts/{path}
|
||||||
|
- outputs 目录文件清单 ❗暂无 → 新增接口(见 §3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键判断**:席位最终交付文本 + 总控结论文本 **已经在 step2 内存里**,无需重新调接口。文件清单是**目前唯一缺失**的数据源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 接口需求
|
||||||
|
|
||||||
|
### 3.1 新增(**必需**):列出某 thread 的产出文件
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/multi-agent/sessions/artifacts?thread_id=<sub_thread_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
或者更通用、放进 artifacts 路由下:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/threads/{thread_id}/artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么需要**:现有 artifacts endpoint 只能"按已知路径下载文件",没有"列出目录"功能。Step 3 需要知道 sub-agent 写到 `/mnt/user-data/outputs/` 下哪些文件,才能在前端列出可下载清单。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thread_id": "<sub_thread_id>",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "mnt/user-data/outputs/方案设计报告_生产系统安全改造技术方案.md",
|
||||||
|
"name": "方案设计报告_生产系统安全改造技术方案.md",
|
||||||
|
"size_bytes": 12480,
|
||||||
|
"mime_type": "text/markdown",
|
||||||
|
"modified_at": "2026-05-23T14:32:30.123Z"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现建议**:
|
||||||
|
- 列 `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` 下的文件
|
||||||
|
- 鉴权复用 `@require_permission("threads", "read", owner_check=True)`
|
||||||
|
- 路径要做 traversal 保护(虚拟路径仅限 `mnt/user-data/outputs/`)
|
||||||
|
|
||||||
|
### 3.2 新增(**可选,体感优化**):一次性聚合 step3 数据
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/multi-agent/sessions/summary
|
||||||
|
Body: {
|
||||||
|
"main_agent_name": "roundtable-coordinator-xxx",
|
||||||
|
"thread_ids": { "<agent_name>": "<thread_id>", ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么可选**:不加这个接口的话,前端需要为每个席位发一次 `GET /state` + 一次 `GET /artifacts`(共 7-13 次 round-trip)。加了这个接口,**一次返回所有数据**。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"main_agent_name": "roundtable-coordinator-xxx",
|
||||||
|
"coordinator": {
|
||||||
|
"thread_id": "...",
|
||||||
|
"final_text": "...", // 最后一条 AIMessage content
|
||||||
|
"dispatch_rounds": 3 // leader 派活的轮数(可选展示)
|
||||||
|
},
|
||||||
|
"seats": [
|
||||||
|
{
|
||||||
|
"agent_name": "roundtable-intelligence",
|
||||||
|
"thread_id": "...",
|
||||||
|
"display_name": "情报收集",
|
||||||
|
"final_text": "...", // 最后一条 AIMessage content
|
||||||
|
"files": [ // 该席位写到 outputs/ 的文件
|
||||||
|
{ "path": "...", "name": "...", "size_bytes": ..., "mime_type": "..." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**取舍**:
|
||||||
|
- 优点:1 次请求 vs 13 次;体验更顺
|
||||||
|
- 缺点:等于把 3.1 + N 次 state 读放到一个 endpoint,实现稍复杂
|
||||||
|
- 推荐:**先做 3.1,3.2 暂缓**;前端用现有 endpoint 串起来够用
|
||||||
|
|
||||||
|
### 3.3 复用(**无需改动**)
|
||||||
|
|
||||||
|
| Endpoint | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/threads/{tid}/state` | 取席位 / 总控线程的完整状态,提取最后一条 AIMessage content |
|
||||||
|
| `GET /api/threads/{tid}/artifacts/{path}` | 点击文件名预览或下载(已有 `?download=true` 支持) |
|
||||||
|
|
||||||
|
> Note: 这两个 endpoint 已经在 `app.gateway.routers.threads` / `app.gateway.routers.artifacts` 里实现,前端 `fetch-client` 也有现成调用方式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前后端对接流程
|
||||||
|
|
||||||
|
### 4.1 进入 step3 时
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 触发条件:runOrchestration 自然结束(leaderRes.dispatched.length === 0
|
||||||
|
// 且无 clarification),用户点"达成共识生成全案成果"按钮 / 自动跳转。
|
||||||
|
|
||||||
|
// 前端手里已有:threadIds, coordinatorName, 最后的 leaderRes
|
||||||
|
// 1. 总控结论文本 — 已在内存,直接用
|
||||||
|
const consensusText = lastLeaderRes.content;
|
||||||
|
|
||||||
|
// 2. 各席位交付 — 已在 step2RoundtableDialogues 里,过滤"子智能体交付"的卡片
|
||||||
|
const seatDeliveries = step2RoundtableDialogues
|
||||||
|
.filter(d => d.confidence === "子智能体交付")
|
||||||
|
.map(d => ({ sender: d.sender, content: d.content }));
|
||||||
|
|
||||||
|
// 3. 文件清单 — 调新接口
|
||||||
|
const filesPerSeat = await Promise.all(
|
||||||
|
Object.entries(threadIds)
|
||||||
|
.filter(([name]) => name !== coordinatorName)
|
||||||
|
.map(async ([name, tid]) => ({
|
||||||
|
agentName: name,
|
||||||
|
files: (await fetch(`/api/threads/${tid}/artifacts`).then(r => r.json())).files,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 用户点击文件时
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 在线预览(mime 是 markdown/text/pdf 自动 inline)
|
||||||
|
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}`);
|
||||||
|
|
||||||
|
// 强制下载
|
||||||
|
window.open(`/api/threads/${threadId}/artifacts/${encodeURIComponent(filePath)}?download=true`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 "重新起草新规划" 按钮
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 跟现在一样,纯前端:清掉 dialogues + 跳 step1
|
||||||
|
setCurrentStep(1);
|
||||||
|
setInteractiveMessages(...); // 已有逻辑
|
||||||
|
setStep2RoundtableDialogues([]);
|
||||||
|
// 不需要调任何接口
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 改造前端的具体文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` | **大改**。删除四张 metric 卡 / 三阶段路线图 / 论证要件 / 裁定判词;新增结论卡 + 席位折叠列表 + 文件清单 |
|
||||||
|
| `frontend-web/src/roundtable-planning/api/multi-agent.ts` | 新增 `listSessionArtifacts(threadId)` helper(封装 §3.1 接口) |
|
||||||
|
| `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 把 `threadIds` / `coordinatorName` / `lastLeaderContent` 传进 `HighFidelityReport` 的 props(目前没传) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 改造后端的具体文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | 新增 `GET /api/threads/{tid}/artifacts`(不带 path,列目录) |
|
||||||
|
| `offline-backend-20260512/backend/CLAUDE.md` | 在 artifacts 路由表里增加新条目说明 |
|
||||||
|
| `offline-backend-20260512/backend/tests/test_artifacts_list.py` | 列目录新接口的单测(覆盖:空目录返回 [];traversal 路径返回 403;不存在 thread 返回 404)|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实施优先级
|
||||||
|
|
||||||
|
| 优先级 | 项 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 | 新增 `GET /api/threads/{tid}/artifacts` 列文件接口 | 必需,没它前端无法列文件 |
|
||||||
|
| P0 | step3 大改:删 mock 区块、加结论/席位/文件三块 | 主要价值改造 |
|
||||||
|
| P1 | 前端 helper `listSessionArtifacts` | 封装 fetch + 类型 |
|
||||||
|
| P2 | 单测 `test_artifacts_list.py` | 上线后跟进 |
|
||||||
|
| P3 | `POST /api/multi-agent/sessions/summary` 聚合接口 | 13 次→1 次,体感优化,**非必需** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 显式不做的事 ❌
|
||||||
|
|
||||||
|
1. **不做"PDF 导出"**:后端没有现成 PDF 渲染管线,要做就是单独的项目。当前页面那个 `导出方案 PDF` 按钮直接删掉,或者保留按钮但点击只触发 `window.print()`(让浏览器自己生成 PDF)。
|
||||||
|
2. **不做"安全分享"**:项目里已有 `/api/thread-shares`(per-thread 分享)和 `/api/public/shares`(公开页面),但那是针对单个 thread 的,不是多 thread 会话的。要做"多 thread 整合分享"是新功能,远超 step3 的范围。
|
||||||
|
3. **不做"指标雷达图"**(什么 90.3 分总评):没有真实数据源,强行加只会变成下一个 mock。
|
||||||
|
4. **不做"签发投产"**:纯展示按钮,不接业务系统,删。
|
||||||
|
5. **不做"轮次/对抗次数"显示**:除非前端记一下 `dispatch_count`,否则后端没现成统计。如果真要展示,用现成的"派活了几次"代替"对抗 N 轮"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收清单
|
||||||
|
|
||||||
|
step3 改造完成后应该能:
|
||||||
|
|
||||||
|
- [ ] 看到当前 session 的总控最终结论,markdown 渲染
|
||||||
|
- [ ] 看到 6 个席位各自的最终交付内容(默认折叠,点击展开)
|
||||||
|
- [ ] 看到所有 sub-agent 写到 outputs 的文件列表,含文件名、大小、类型 icon
|
||||||
|
- [ ] 点击文件名能在线预览(markdown / pdf / 图片)
|
||||||
|
- [ ] 点击下载按钮能下载(带正确文件名)
|
||||||
|
- [ ] 点"重新起草新规划"能回到 step1 且 step2 内存清空
|
||||||
|
- [ ] 页面不再包含任何来自 `budgetLimit` 的"假指标"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 路径速查
|
||||||
|
|
||||||
|
| 资源 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| Step 3 组件 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
|
||||||
|
| 父页面 (state 持有者) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||||
|
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
|
||||||
|
| 后端 artifacts 路由 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
|
||||||
|
| 后端 multi-agent 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
|
||||||
|
| 物理 outputs 目录 | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# 浅色红色 UI 规范对齐 — 实施计划
|
||||||
|
|
||||||
|
> 依据:`H:\页面设计\UI设计规范\浅色红色设计规范-前端用.pdf`(共 104 页)
|
||||||
|
> 范围:**仅对齐 Light 模式(红色主题)**,**不改动图表部分**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 重要约束(必读)
|
||||||
|
|
||||||
|
### 1. 主题切换必须保留
|
||||||
|
项目使用 `next-themes` 实现 Light/Dark 切换:
|
||||||
|
|
||||||
|
- `:root` → Light 模式(红色品牌,对应本规范)
|
||||||
|
- `.dark` → Dark 模式(蓝色品牌,**不在本次范围内**)
|
||||||
|
- 切换组件入口:`src/components/theme-provider.tsx`
|
||||||
|
|
||||||
|
**所有改动只能动 `:root` 块和 light-only 选择器,禁止动 `.dark` 块和 `dark:` 前缀样式。**
|
||||||
|
|
||||||
|
### 2. 不动图表
|
||||||
|
本次跳过 `chart-1` ~ `chart-5` 颜色变量和所有图表组件。
|
||||||
|
|
||||||
|
### 3. 同一 token 双模式都用时如何改
|
||||||
|
若变量同时被 light/dark 使用(如 `--radius`、按钮 `h-*`、`--primary-foreground`),改动后需在 Dark 模式下回归检查,确保没有破坏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改动总目标对照
|
||||||
|
|
||||||
|
| 类别 | UI 规范 | 现状 | 差异 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 主品牌红 | `#F5222D` | `#d92121` (`oklch(0.52 0.19 27)`) | 偏差 |
|
||||||
|
| 深色品牌 | `#CF1322` / `#A8071A` | 未使用 | 缺失 |
|
||||||
|
| 圆角-按钮 | 2px | 12px (`--radius: 0.75rem`) | 严重偏大 |
|
||||||
|
| 圆角-卡片 | 4px | 12px | 严重偏大 |
|
||||||
|
| 按钮高度(正常)| 32px | 36px (`h-9`) | 偏大 4px |
|
||||||
|
| 按钮高度(小)| 24px | 32px (`h-8`) | 偏大 8px |
|
||||||
|
| 输入框高度 | 32px | 36px (`h-9`) | 偏大 4px |
|
||||||
|
| 中文字体 | NotoSansSChinese | 系统字体 | 未引入 |
|
||||||
|
| 数字字体 | D-DIN | 系统字体 | 未引入 |
|
||||||
|
| 间距体系 | 8px 基准(4/8/12/16/24/32/40/56)| Tailwind 默认 | 基本一致 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分步实施计划
|
||||||
|
|
||||||
|
### Step 1 — 颜色变量对齐(Light 模式)
|
||||||
|
|
||||||
|
**文件**:`src/styles/globals.css`
|
||||||
|
**只动 `:root` 块(第 242-277 行),保持 `.dark` 块原样**
|
||||||
|
|
||||||
|
#### 1.1 主色映射
|
||||||
|
|
||||||
|
| 角色 | 规范值 | 用途 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 主色 | `#F5222D` | `--primary`、`--ring`、`--sidebar-primary` |
|
||||||
|
| 主色 hover | `#CF1322` | 按钮 hover 状态(新增辅助变量) |
|
||||||
|
| 主色 active | `#A8071A` | 按钮按下状态 |
|
||||||
|
| 主色淡 1 | `#FEEDED` | `--accent`(极淡红,背景) |
|
||||||
|
| 主色淡 2 | `#FDDADC` | hover bg、tag 背景 |
|
||||||
|
| 主色淡 3 | `#FCB5B9` | 浅边框、selected 态 |
|
||||||
|
| 危险色 | `#F5222D` | `--destructive` |
|
||||||
|
|
||||||
|
#### 1.2 新增工程变量(建议加在 `:root` 内)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 红色主题阶梯色(规范色板)*/
|
||||||
|
--brand-50: #FEFAFA;
|
||||||
|
--brand-100: #FEF6F6;
|
||||||
|
--brand-200: #FEEDED;
|
||||||
|
--brand-300: #FDDADC;
|
||||||
|
--brand-400: #FCB5B9;
|
||||||
|
--brand-500: #FA9196;
|
||||||
|
--brand-600: #F86C73;
|
||||||
|
--brand-700: #F74750;
|
||||||
|
--brand-800: #F5222D; /* ← 主色 */
|
||||||
|
--brand-900: #CF1322; /* ← hover */
|
||||||
|
--brand-950: #A8071A; /* ← active */
|
||||||
|
|
||||||
|
/* 辅色(规范)*/
|
||||||
|
--accent-orange: #FF9900;
|
||||||
|
--accent-green: #2FD69A;
|
||||||
|
--accent-cyan: #45C3F3;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 `--workspace-brand` 同步
|
||||||
|
|
||||||
|
`:root` 内:`--workspace-brand: #F5222D;`(从 `#d92121` 改)
|
||||||
|
`.dark` 内:**不动**(保持 `#3b82f6` 蓝色)
|
||||||
|
|
||||||
|
#### 1.4 验收
|
||||||
|
- 切到 Light 模式:所有按钮、链接、选中态使用规范红
|
||||||
|
- 切到 Dark 模式:依然是原蓝色品牌(无变化)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — 圆角规范化
|
||||||
|
|
||||||
|
**文件**:`src/styles/globals.css`(`--radius` 是全局变量,light/dark 共用)
|
||||||
|
|
||||||
|
#### 2.1 改动方案(推荐)
|
||||||
|
|
||||||
|
按规范:按钮 2px、卡片/页面框 4px。但当前许多组件依赖 `rounded-md` / `rounded-lg`,全局把 `--radius` 改成 4px 会引起视觉跳变。
|
||||||
|
|
||||||
|
**采用细粒度策略:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--radius: 0.25rem; /* 4px → 卡片/页面框 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: 2px; /* 按钮、tag */
|
||||||
|
--radius-md: calc(var(--radius) - 1px); /* 3px → 输入框 */
|
||||||
|
--radius-lg: var(--radius); /* 4px → 卡片 */
|
||||||
|
--radius-xl: calc(var(--radius) + 4px); /* 8px → 弹窗 */
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px); /* 12px → 大卡片 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 例外保留(设计感样式)
|
||||||
|
以下组件**圆角不改**,保持设计稿原貌:
|
||||||
|
- `.workspace-user-bubble`(聊天气泡,圆角 `1.6rem 1.6rem 0.5rem 1.6rem`)
|
||||||
|
- `.workspace-assistant-card`(圆角 `1.75rem`)
|
||||||
|
- `.gemini-gradient-border`(圆角 `1.5rem`)
|
||||||
|
|
||||||
|
#### 2.3 验收
|
||||||
|
- 普通按钮变为近直角(2px)
|
||||||
|
- 卡片维持温和圆角(4px)
|
||||||
|
- 聊天气泡、AI 卡片不变形
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — 按钮组件尺寸对齐
|
||||||
|
|
||||||
|
**文件**:`src/components/ui/button.tsx`
|
||||||
|
|
||||||
|
#### 3.1 改 size 变体(规范:小 24px / 正常 32px)
|
||||||
|
|
||||||
|
| size | 规范 | 当前 | 改后 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `sm` | 24px | h-8 (32px) | `h-6 px-2 text-xs` |
|
||||||
|
| `default` | 32px | h-9 (36px) | `h-8 px-3 text-sm` |
|
||||||
|
| `lg` | — | h-10 (40px) | 保留 `h-10`(用于 Banner/登录场景)|
|
||||||
|
|
||||||
|
#### 3.2 圆角
|
||||||
|
- 默认按钮:`rounded-sm`(2px)
|
||||||
|
|
||||||
|
#### 3.3 主按钮配色
|
||||||
|
- bg:`bg-primary`
|
||||||
|
- hover:`hover:bg-[var(--brand-900)]`(深红 `#CF1322`)
|
||||||
|
- active:`active:bg-[var(--brand-950)]`(更深 `#A8071A`)
|
||||||
|
- disabled:`disabled:opacity-50`(保留)
|
||||||
|
|
||||||
|
#### 3.4 次要按钮 / 文本按钮
|
||||||
|
- 次要按钮(`outline` variant):边框 `border-primary`,文字 `text-primary`
|
||||||
|
- 文本按钮(`ghost` variant):仅文字色 `text-primary`,hover 背景 `--brand-200`
|
||||||
|
|
||||||
|
#### 3.5 验收
|
||||||
|
检查所有调用 `<Button>` 的页面:
|
||||||
|
- 工具栏按钮(之前 h-9 → 现在 h-8)
|
||||||
|
- 弹窗确认/取消(之前 h-9 → 现在 h-8)
|
||||||
|
- Landing 主 CTA 按钮(保留 `lg`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — 输入框尺寸对齐
|
||||||
|
|
||||||
|
#### 4.1 `src/components/ui/input.tsx`
|
||||||
|
- `h-9` → `h-8`(32px)
|
||||||
|
- `rounded-md`(沿用 3px)
|
||||||
|
- 边框:`border-input`(变量已是浅灰,不变)
|
||||||
|
- focus:`focus-visible:ring-[2px] focus-visible:ring-primary/35`
|
||||||
|
|
||||||
|
#### 4.2 `src/components/ui/textarea.tsx`
|
||||||
|
- 最小高度建议 `min-h-16`
|
||||||
|
- 圆角 `rounded-md`
|
||||||
|
|
||||||
|
#### 4.3 `src/components/ui/select.tsx`
|
||||||
|
- `size: default` → `h-8`
|
||||||
|
- `size: sm` → `h-6`
|
||||||
|
|
||||||
|
#### 4.4 验收
|
||||||
|
- 搜索框、表单输入、下拉框高度一致(32px)
|
||||||
|
- 与按钮对齐时高度一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — 字体引入
|
||||||
|
|
||||||
|
#### 5.1 引入字体文件
|
||||||
|
在 `frontend-web/public/fonts/` 下放置:
|
||||||
|
- `NotoSansSC-Regular.woff2` / `Medium.woff2` / `Bold.woff2`
|
||||||
|
- `D-DIN-Regular.woff2` / `Bold.woff2`
|
||||||
|
|
||||||
|
> 若需在线方式,可用 Google Fonts CDN:
|
||||||
|
> `https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap`
|
||||||
|
|
||||||
|
#### 5.2 `globals.css` 顶部添加 `@font-face`
|
||||||
|
|
||||||
|
```css
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Sans SC';
|
||||||
|
src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
/* Medium / Bold 同上 */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'D-DIN';
|
||||||
|
src: url('/fonts/D-DIN-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.3 修改 `--font-sans`(globals.css 第 74 行)
|
||||||
|
|
||||||
|
```css
|
||||||
|
--font-sans:
|
||||||
|
'Noto Sans SC', ui-sans-serif, system-ui, sans-serif,
|
||||||
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
|
||||||
|
--font-numeric: 'D-DIN', 'Noto Sans SC', sans-serif;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4 数字字体工具类
|
||||||
|
新增 `.font-numeric { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }`
|
||||||
|
|
||||||
|
仪表盘、表格中重要数字加 `className="font-numeric"`。
|
||||||
|
|
||||||
|
#### 5.5 验收
|
||||||
|
- 中文文本回退到 Noto Sans SC
|
||||||
|
- Dark 模式同样生效(字体是全局变量)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6 — Badge / 状态标签色对齐
|
||||||
|
|
||||||
|
**文件**:`src/components/ui/badge.tsx`
|
||||||
|
|
||||||
|
规范状态色(参考 PDF 第 67 页):
|
||||||
|
|
||||||
|
| 状态 | 颜色定位 | 推荐 token |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| 未开始 | 中性灰 | `bg-muted text-muted-foreground` |
|
||||||
|
| 等待中 | 蓝/青 | `bg-blue-50 text-blue-600`(保留) |
|
||||||
|
| 进行中 | 主红 | `bg-[var(--brand-200)] text-primary` |
|
||||||
|
| 已完成 | 绿 | `bg-emerald-50 text-emerald-600` |
|
||||||
|
| 已失败 | 红/橙 | `bg-red-50 text-red-600` |
|
||||||
|
| 待更新 | 黄/橙 | `bg-amber-50 text-amber-700` |
|
||||||
|
|
||||||
|
需要新增 `variant`:`waiting`、`processing`、`success`、`failed`、`pending`。
|
||||||
|
|
||||||
|
圆角统一 `rounded-sm`,padding `px-2 py-0.5`,字号 `text-xs`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7 — 全局间距 & 阴影规范化
|
||||||
|
|
||||||
|
#### 7.1 间距
|
||||||
|
规范基于 8px 网格(4 / 8 / 12 / 16 / 24 / 32 / 40 / 56)。Tailwind 默认 4px 步进(`p-1`=4, `p-2`=8...)已兼容。
|
||||||
|
|
||||||
|
**只需 review**:检查页面是否有 `p-2.5`、`gap-1.5` 等非 8 的倍数,统一改成 8 的倍数。
|
||||||
|
|
||||||
|
#### 7.2 阴影
|
||||||
|
规范阴影仅用于"标题+操作"和"卡片新增"两类。其余卡片保持平。
|
||||||
|
|
||||||
|
新增工具类(可选):
|
||||||
|
```css
|
||||||
|
.shadow-card { box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); }
|
||||||
|
.shadow-card-hover { box-shadow: 0 4px 12px 0 rgba(0,0,0,0.08); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8 — 验收清单
|
||||||
|
|
||||||
|
每步完成后检查:
|
||||||
|
|
||||||
|
- [ ] **Light 模式**视觉对齐规范(参考 PDF 关键页:5、7、10、36、66)
|
||||||
|
- [ ] **Dark 模式**完全无变化(切换主题验证)
|
||||||
|
- [ ] 首页 / Workspace / 聊天 / 设置弹窗 → 按钮和输入框高度统一
|
||||||
|
- [ ] 主题切换按钮点击 → 颜色平滑切换,无错位/闪烁
|
||||||
|
- [ ] `/landing` 强制 Dark 模式仍正常
|
||||||
|
- [ ] 控制台无 CSS 变量未定义警告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行顺序建议
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 (颜色变量) ─┐
|
||||||
|
├→ Step 3 (按钮) ─┐
|
||||||
|
Step 2 (圆角) ─┘ ├→ Step 6 (Badge) ─→ Step 7 (间距/阴影) ─→ Step 8 (验收)
|
||||||
|
│
|
||||||
|
Step 4 (输入框) ──┤
|
||||||
|
Step 5 (字体) ────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
并行可行:Step 1 + Step 2 完成后,Step 3/4/5 可同时推进。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
1. **圆角从 12px → 4px 是大幅度视觉变化**,建议先在分支上做、配截图比对再合并
|
||||||
|
2. 字体文件体积较大(NotoSansSC 全字符集 ~10MB),建议子集化或仅引入需要的字重
|
||||||
|
3. `--radius-sm` 改后可能影响 dropdown menu / popover 等小组件外观,需逐个检查
|
||||||
|
4. Dark 模式下的红色 `--workspace-brand` 已是蓝色,**不要把蓝色也改成 #F5222D**
|
||||||
+12
-1
@@ -1,7 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en-US">
|
<html lang="en-US" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("strategy-theme");
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.classList.remove("dark", "light");
|
||||||
|
if (t === "light") return;
|
||||||
|
root.classList.add("dark");
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CM 助手</title>
|
<title>CM 助手</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/gsap": "^3.0.0",
|
"@types/gsap": "^3.0.0",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<NextThemesProvider
|
<NextThemesProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
enableSystem
|
storageKey="strategy-theme"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem={false}
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: HelpCircle,
|
icon: HelpCircle,
|
||||||
children: [
|
children: [
|
||||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||||
|
{
|
||||||
|
id: "qa-roundtable",
|
||||||
|
label: "圆桌规划",
|
||||||
|
icon: Bot,
|
||||||
|
path: "/page/roundtable/planning",
|
||||||
|
},
|
||||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||||
@@ -99,6 +105,12 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: List,
|
icon: List,
|
||||||
path: "/page/strategy/light-app/notes",
|
path: "/page/strategy/light-app/notes",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "light-app-note-qa",
|
||||||
|
label: "笔记问答",
|
||||||
|
icon: MessageSquareCode,
|
||||||
|
path: "/page/strategy/light-app/note-qa",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||||
|
|||||||
@@ -19,21 +19,307 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
useAdminAddMemoryEntry,
|
useAdminAddMemoryEntry,
|
||||||
|
useAdminAddV1Fact,
|
||||||
useAdminDeleteMemoryEntry,
|
useAdminDeleteMemoryEntry,
|
||||||
|
useAdminDeleteV1Fact,
|
||||||
useAdminReplaceMemoryEntry,
|
useAdminReplaceMemoryEntry,
|
||||||
|
useAdminUpdateV1Fact,
|
||||||
|
useAdminUpdateV1Section,
|
||||||
useUserMemory,
|
useUserMemory,
|
||||||
} from "@/core/admin/hooks";
|
} from "@/core/admin/hooks";
|
||||||
import { useAllUsers } from "@/core/auth/hooks";
|
import { useAllUsers } from "@/core/auth/hooks";
|
||||||
|
|
||||||
function getEntries(data: Record<string, unknown>): string[] {
|
export function getEntries(data: Record<string, unknown>): string[] {
|
||||||
const raw = (data as { entries?: unknown }).entries;
|
const raw = (data as { entries?: unknown }).entries;
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V1 只读视图 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CTX_LABELS: Record<string, string> = {
|
||||||
|
workContext: "工作背景",
|
||||||
|
personalContext: "个人信息",
|
||||||
|
topOfMind: "当前重点",
|
||||||
|
};
|
||||||
|
const HISTORY_LABELS: Record<string, string> = {
|
||||||
|
recentMonths: "近期",
|
||||||
|
earlierContext: "早期背景",
|
||||||
|
longTermBackground: "长期背景",
|
||||||
|
};
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
preference: "bg-blue-100 text-blue-700",
|
||||||
|
knowledge: "bg-green-100 text-green-700",
|
||||||
|
context: "bg-yellow-100 text-yellow-700",
|
||||||
|
behavior: "bg-purple-100 text-purple-700",
|
||||||
|
goal: "bg-orange-100 text-orange-700",
|
||||||
|
correction: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
|
||||||
|
|
||||||
|
function extractSummary(val: unknown): string {
|
||||||
|
if (typeof val === "string") return val;
|
||||||
|
if (val && typeof val === "object" && "summary" in val)
|
||||||
|
return String((val as Record<string, unknown>).summary ?? "");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个摘要字段的行内编辑
|
||||||
|
function V1SectionField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onSave: (v: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (draft.trim() === value.trim()) { setEditing(false); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try { await onSave(draft.trim()); setEditing(false); }
|
||||||
|
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||||
|
{!editing && (
|
||||||
|
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单条事实的行内编辑
|
||||||
|
function V1FactItem({
|
||||||
|
fact,
|
||||||
|
agent,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
fact: Record<string, unknown>;
|
||||||
|
agent: string | null;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
|
||||||
|
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
|
||||||
|
const [draftConfidence, setDraftConfidence] = useState(
|
||||||
|
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
|
||||||
|
);
|
||||||
|
const updateFact = useAdminUpdateV1Fact(userId);
|
||||||
|
const deleteFact = useAdminDeleteV1Fact(userId);
|
||||||
|
|
||||||
|
const factId = String(fact.id ?? "");
|
||||||
|
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
|
||||||
|
const category = String(fact.category ?? "context");
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
await updateFact.mutateAsync({
|
||||||
|
factId,
|
||||||
|
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
|
||||||
|
agent,
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try { await deleteFact.mutateAsync({ factId, agent }); }
|
||||||
|
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded border p-2 space-y-1 group">
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={draftCategory} onValueChange={setDraftCategory}>
|
||||||
|
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">置信度</span>
|
||||||
|
<input
|
||||||
|
type="number" min={0} max={100} value={draftConfidence}
|
||||||
|
onChange={(e) => setDraftConfidence(Number(e.target.value))}
|
||||||
|
className="w-16 h-7 rounded border px-2 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
|
||||||
|
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
|
||||||
|
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
|
||||||
|
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增事实表单
|
||||||
|
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [category, setCategory] = useState("context");
|
||||||
|
const [confidence, setConfidence] = useState(80);
|
||||||
|
const addFact = useAdminAddV1Fact(userId);
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
try {
|
||||||
|
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
|
||||||
|
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
|
||||||
|
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
|
||||||
|
<Plus className="size-3" />新增事实
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border p-3 space-y-2">
|
||||||
|
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={category} onValueChange={setCategory}>
|
||||||
|
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">置信度</span>
|
||||||
|
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
|
||||||
|
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function V1BucketView({
|
||||||
|
data,
|
||||||
|
agent,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
agent: string | null;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const updateSection = useAdminUpdateV1Section(userId);
|
||||||
|
|
||||||
|
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
|
||||||
|
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
|
||||||
|
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
|
||||||
|
|
||||||
|
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
|
||||||
|
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
|
||||||
|
|
||||||
|
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
|
||||||
|
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 用户画像字段 */}
|
||||||
|
<div className="rounded-md border p-3 space-y-3">
|
||||||
|
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
|
||||||
|
{allCtxFields.map((field) => (
|
||||||
|
<V1SectionField
|
||||||
|
key={field}
|
||||||
|
label={CTX_LABELS[field] ?? field}
|
||||||
|
value={extractSummary(ctxRaw[field])}
|
||||||
|
onSave={(summary) =>
|
||||||
|
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 历史摘要字段 */}
|
||||||
|
<div className="rounded-md border p-3 space-y-3">
|
||||||
|
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
|
||||||
|
{allHistoryFields.map((field) => (
|
||||||
|
<V1SectionField
|
||||||
|
key={field}
|
||||||
|
label={HISTORY_LABELS[field] ?? field}
|
||||||
|
value={extractSummary(historyRaw[field])}
|
||||||
|
onSave={(summary) =>
|
||||||
|
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 事实记录 */}
|
||||||
|
<div className="rounded-md border p-3 space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
|
||||||
|
{factsRaw.map((fact, i) => (
|
||||||
|
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
|
||||||
|
))}
|
||||||
|
<V1AddFactForm agent={agent} userId={userId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||||
|
|
||||||
function MemoryEntryList({
|
export function MemoryEntryList({
|
||||||
entries,
|
entries,
|
||||||
userId,
|
userId,
|
||||||
agent,
|
agent,
|
||||||
@@ -204,7 +490,7 @@ function MemoryEntryList({
|
|||||||
|
|
||||||
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
||||||
|
|
||||||
function UserMemoryPanel({ userId }: { userId: string }) {
|
export function UserMemoryPanel({ userId }: { userId: string }) {
|
||||||
const { data: memory, isLoading } = useUserMemory(userId);
|
const { data: memory, isLoading } = useUserMemory(userId);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -217,11 +503,76 @@ function UserMemoryPanel({ userId }: { userId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isV1 = memory?.version === "v1";
|
||||||
const globalBucket = memory?.buckets.find((b) => b.name === "global");
|
const globalBucket = memory?.buckets.find((b) => b.name === "global");
|
||||||
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
||||||
|
|
||||||
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
// ── V1 只读视图 ──────────────────────────────────────────────────────────
|
||||||
|
if (isV1) {
|
||||||
|
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||||
|
const currentBucket = currentAgent
|
||||||
|
? agentBuckets.find((b) => b.name === currentAgent)
|
||||||
|
: globalBucket;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="border-b px-4 pt-4">
|
||||||
|
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
|
||||||
|
V1 模式:记忆由系统自动提取,不支持手动管理
|
||||||
|
</div>
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="global">用户记忆</TabsTrigger>
|
||||||
|
{agentBuckets.length > 0 && (
|
||||||
|
<TabsTrigger value="agent">
|
||||||
|
Agent 记忆
|
||||||
|
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||||
|
{agentBuckets.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{agentBuckets.length > 0 && (
|
||||||
|
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||||||
|
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
|
||||||
|
<SelectTrigger className="h-7 w-48 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{agentBuckets.map((b) => (
|
||||||
|
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||||||
|
{b.display_name || b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
|
||||||
|
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||||
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||||
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
||||||
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
||||||
@@ -284,16 +635,10 @@ function UserMemoryPanel({ userId }: { userId: string }) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
|
||||||
— MEMORY.md
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{currentAgent && (
|
{currentAgent && (
|
||||||
<MemoryEntryList
|
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
|
||||||
entries={agentEntries}
|
|
||||||
userId={userId}
|
|
||||||
agent={currentAgent}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { AdminMessage } from "@/core/admin/api";
|
||||||
|
import {
|
||||||
|
useUserMemory,
|
||||||
|
useUserThreadMessages,
|
||||||
|
useUserThreads,
|
||||||
|
} from "@/core/admin/hooks";
|
||||||
|
import type { UserListItem } from "@/core/auth/api";
|
||||||
|
import { useAllUsers } from "@/core/auth/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getEntries,
|
||||||
|
MemoryEntryList,
|
||||||
|
V1BucketView,
|
||||||
|
} from "./admin-memory-page";
|
||||||
|
|
||||||
|
// ── Helpers (shared with AdminUserActivityPage) ─────────────────────────────
|
||||||
|
|
||||||
|
function extractText(content: unknown): string {
|
||||||
|
if (content == null) return "";
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((block) => {
|
||||||
|
if (typeof block === "string") return block;
|
||||||
|
if (block && typeof block === "object") {
|
||||||
|
const text = (block as Record<string, unknown>).text;
|
||||||
|
if (typeof text === "string") return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
if (typeof content === "object") {
|
||||||
|
const inner = (content as Record<string, unknown>).content;
|
||||||
|
if (inner !== undefined) return extractText(inner);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleOf(message: AdminMessage): "user" | "assistant" | null {
|
||||||
|
const type = (message.type ?? "").toLowerCase();
|
||||||
|
if (type === "human") return "user";
|
||||||
|
if (type === "ai" || type === "aimessagechunk") return "assistant";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UserInfoPanel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UserInfoPanel({ user }: { user: UserListItem }) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<dl className="grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 text-sm">
|
||||||
|
<dt className="text-muted-foreground font-medium">邮箱</dt>
|
||||||
|
<dd className="break-all">{user.email}</dd>
|
||||||
|
<dt className="text-muted-foreground font-medium">角色</dt>
|
||||||
|
<dd>
|
||||||
|
{user.system_role === "admin" ? (
|
||||||
|
<Badge variant="default">管理员</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">普通用户</Badge>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground font-medium">Skills</dt>
|
||||||
|
<dd className="tabular-nums">{user.skill_count}</dd>
|
||||||
|
<dt className="text-muted-foreground font-medium">Agents</dt>
|
||||||
|
<dd className="tabular-nums">{user.agent_count}</dd>
|
||||||
|
<dt className="text-muted-foreground font-medium">User ID</dt>
|
||||||
|
<dd className="font-mono text-xs text-muted-foreground break-all">{user.id}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MessageRow / MemoryBucketView (audit sub-components) ────────────────────
|
||||||
|
|
||||||
|
function MessageRow({ message }: { message: AdminMessage }) {
|
||||||
|
const role = roleOf(message);
|
||||||
|
if (!role) return null;
|
||||||
|
const text = extractText(message.content).trim();
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1",
|
||||||
|
role === "user" ? "items-start" : "items-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{role === "user" ? "用户提问" : "AI 回答"}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
|
||||||
|
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UserAuditPanel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UserAuditPanel({ userId }: { userId: string }) {
|
||||||
|
const [threadId, setThreadId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||||
|
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||||
|
userId,
|
||||||
|
threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* Thread list */}
|
||||||
|
<div className="flex w-60 shrink-0 flex-col border-r">
|
||||||
|
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
|
||||||
|
会话列表{threads ? `(${threads.length})` : ""}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{threadsLoading && (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!threadsLoading && (threads?.length ?? 0) === 0 && (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
该用户暂无会话
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col p-1">
|
||||||
|
{(threads ?? []).map((thread) => (
|
||||||
|
<button
|
||||||
|
key={thread.thread_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setThreadId(thread.thread_id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
|
||||||
|
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm">
|
||||||
|
{thread.display_name || "未命名会话"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{fmtTime(thread.updated_at)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="p-4">
|
||||||
|
{!threadId && (
|
||||||
|
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
|
||||||
|
<MessageSquareIcon className="size-6" />
|
||||||
|
请从左侧选择一个会话
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{threadId && messagesLoading && (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{threadId && !messagesLoading && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{(messages ?? []).map((m, i) => (
|
||||||
|
<MessageRow key={m.id ?? i} message={m} />
|
||||||
|
))}
|
||||||
|
{(messages ?? []).every((m) => !roleOf(m)) && (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
该会话暂无问答记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AgentMemoryPanel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function bucketLabel(name: string, displayName?: string): string {
|
||||||
|
if (name === "default") return "默认智能体";
|
||||||
|
return displayName || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentMemoryPanel({ userId }: { userId: string }) {
|
||||||
|
const { data: memory, isLoading } = useUserMemory(userId);
|
||||||
|
// 默认选中"默认助手"("default" 对应全局记忆文件)
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<string>("default");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> 加载中…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isV1 = memory?.version === "v1";
|
||||||
|
|
||||||
|
// 非 global、非 default 的其他智能体 bucket
|
||||||
|
const otherBuckets = (memory?.buckets ?? []).filter(
|
||||||
|
(b) => b.name !== "global" && b.name !== "default",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前选中的 bucket
|
||||||
|
const currentBucket = (memory?.buckets ?? []).find(
|
||||||
|
(b) => b.name === selectedAgent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// "default" 对应 agent=null(全局记忆文件),与 _resolve_agent_name 的哨兵逻辑一致
|
||||||
|
const bucketAgentParam = selectedAgent === "default" ? null : selectedAgent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 智能体下拉选择器 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm shrink-0">智能体:</span>
|
||||||
|
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||||
|
<SelectTrigger className="h-8 w-48" size="sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* 默认助手始终显示,对应全局记忆文件 */}
|
||||||
|
<SelectItem value="default">默认助手</SelectItem>
|
||||||
|
{otherBuckets.map((b) => (
|
||||||
|
<SelectItem key={b.name} value={b.name}>
|
||||||
|
{bucketLabel(b.name, b.display_name)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!isV1 && currentBucket && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{getEntries(currentBucket.data).length} 条
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前智能体记忆内容 */}
|
||||||
|
{!currentBucket ? (
|
||||||
|
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||||
|
暂无记忆数据
|
||||||
|
</div>
|
||||||
|
) : isV1 ? (
|
||||||
|
<V1BucketView
|
||||||
|
data={currentBucket.data}
|
||||||
|
agent={bucketAgentParam}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MemoryEntryList
|
||||||
|
entries={getEntries(currentBucket.data)}
|
||||||
|
userId={userId}
|
||||||
|
agent={bucketAgentParam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AdminUserHub (main) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AdminUserHub() {
|
||||||
|
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [userQuery, setUserQuery] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const needle = userQuery.trim().toLowerCase();
|
||||||
|
const list = users ?? [];
|
||||||
|
return needle
|
||||||
|
? list.filter((u) => u.email.toLowerCase().includes(needle))
|
||||||
|
: list;
|
||||||
|
}, [users, userQuery]);
|
||||||
|
|
||||||
|
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
|
||||||
|
|
||||||
|
function selectUser(id: string) {
|
||||||
|
setSelectedUserId(id);
|
||||||
|
setActiveTab("info");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col">
|
||||||
|
<div className="border-b px-6 py-4">
|
||||||
|
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
管理用户信息、审计历史问答、编辑用户记忆。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* Left: user list */}
|
||||||
|
<div className="flex w-64 shrink-0 flex-col border-r">
|
||||||
|
<div className="relative p-2">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={userQuery}
|
||||||
|
onChange={(e) => setUserQuery(e.target.value)}
|
||||||
|
placeholder="搜索用户邮箱"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{usersLoading && (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!usersLoading && filteredUsers.length === 0 && (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
无用户
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col p-1">
|
||||||
|
{filteredUsers.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectUser(u.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
|
||||||
|
u.id === selectedUserId
|
||||||
|
? "bg-accent font-medium"
|
||||||
|
: "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{u.email}</span>
|
||||||
|
{u.system_role === "admin" && (
|
||||||
|
<Badge variant="outline" className="shrink-0 text-xs">
|
||||||
|
管理员
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: tabs */}
|
||||||
|
{!selectedUserId ? (
|
||||||
|
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||||
|
从左侧选择一个用户
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||||
|
className="flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="border-b px-4 pt-3 shrink-0">
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||||
|
<TabsTrigger value="memory">记忆管理</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="info" className="flex-1 overflow-auto p-0">
|
||||||
|
{selectedUser && <UserInfoPanel user={selectedUser} />}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="audit"
|
||||||
|
className="flex min-h-0 flex-1 flex-col p-0"
|
||||||
|
>
|
||||||
|
<UserAuditPanel key={selectedUserId} userId={selectedUserId} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="memory"
|
||||||
|
className="flex min-h-0 flex-1 flex-col p-0"
|
||||||
|
>
|
||||||
|
<AgentMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
<BotIcon className="h-5 w-5" />
|
<BotIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="truncate text-base">
|
<CardTitle className="truncate text-base" title={agent.name}>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{agent.model && (
|
{agent.model && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
|
BrainCircuitIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
@@ -134,6 +135,7 @@ export function InputBox({
|
|||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
|
memory_injection_enabled?: boolean;
|
||||||
};
|
};
|
||||||
extraHeader?: React.ReactNode;
|
extraHeader?: React.ReactNode;
|
||||||
isNewThread?: boolean;
|
isNewThread?: boolean;
|
||||||
@@ -147,6 +149,7 @@ export function InputBox({
|
|||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
|
memory_injection_enabled?: boolean;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||||
@@ -285,6 +288,14 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [context, onContextChange]);
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
|
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||||
|
const handleMemoryInjectionToggle = useCallback(() => {
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
memory_injection_enabled: !memoryInjectionEnabled,
|
||||||
|
});
|
||||||
|
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (message: PromptInputMessage) => {
|
async (message: PromptInputMessage) => {
|
||||||
if (status === "streaming") {
|
if (status === "streaming") {
|
||||||
@@ -590,6 +601,26 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
memoryInjectionEnabled
|
||||||
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
|
: "记忆注入已关闭:本次对话不注入记忆"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PromptInputButton
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
|
memoryInjectionEnabled
|
||||||
|
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
|
||||||
|
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
|
)}
|
||||||
|
onClick={handleMemoryInjectionToggle}
|
||||||
|
>
|
||||||
|
<BrainCircuitIcon className="size-3" />
|
||||||
|
<span className="text-xs font-normal">记忆</span>
|
||||||
|
</PromptInputButton>
|
||||||
|
</Tooltip>
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import {
|
||||||
|
normalizeSearchResults,
|
||||||
|
resolveSearchQuery,
|
||||||
|
type SearchResultItem,
|
||||||
|
} from "@/core/messages/search-results";
|
||||||
import {
|
import {
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
@@ -216,15 +221,21 @@ function ToolCall({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const artifacts = useArtifactsOptional();
|
const artifacts = useArtifactsOptional();
|
||||||
|
|
||||||
if (name === "web_search") {
|
const searchItems = normalizeSearchResults(result);
|
||||||
|
if (name === "web_search" || searchItems.length > 0) {
|
||||||
|
const query = resolveSearchQuery(args, result);
|
||||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
if (typeof args.query === "string") {
|
if (query) {
|
||||||
label = t.toolCalls.searchOnWebFor(args.query);
|
label =
|
||||||
|
name === "web_search"
|
||||||
|
? t.toolCalls.searchOnWebFor(query)
|
||||||
|
: t.toolCalls.searchFor(query);
|
||||||
}
|
}
|
||||||
const items = normalizeSearchResults(result);
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||||
{items.length > 0 && <WebSearchResultsList results={items} />}
|
{searchItems.length > 0 && (
|
||||||
|
<WebSearchResultsList results={searchItems} />
|
||||||
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
} else if (name === "image_search") {
|
} else if (name === "image_search") {
|
||||||
@@ -452,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
|
|||||||
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||||
name: string;
|
name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
result?: string;
|
result?: string | Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||||
@@ -484,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
};
|
};
|
||||||
const toolCallId = tool_call.id;
|
const toolCallId = tool_call.id;
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
const toolCallResult = findToolCallResult(toolCallId, messages);
|
const toolCallResult = findToolCallResult(
|
||||||
if (toolCallResult) {
|
toolCallId,
|
||||||
try {
|
messages,
|
||||||
const json = JSON.parse(toolCallResult);
|
tool_call.name,
|
||||||
step.result = json;
|
);
|
||||||
} catch {
|
if (toolCallResult !== undefined) {
|
||||||
|
if (typeof toolCallResult === "string") {
|
||||||
|
try {
|
||||||
|
step.result = JSON.parse(toolCallResult) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
} catch {
|
||||||
|
step.result = toolCallResult;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
step.result = toolCallResult;
|
step.result = toolCallResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,32 +522,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
return steps;
|
return steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchItem = {
|
function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
snippet?: string;
|
|
||||||
content?: string;
|
|
||||||
published_date?: string;
|
|
||||||
publishedDate?: string;
|
|
||||||
date?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeSearchResults(
|
|
||||||
result: string | Record<string, unknown> | undefined,
|
|
||||||
): WebSearchItem[] {
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
return result as WebSearchItem[];
|
|
||||||
}
|
|
||||||
if (result && typeof result === "object") {
|
|
||||||
const nested = (result as { results?: unknown }).results;
|
|
||||||
if (Array.isArray(nested)) {
|
|
||||||
return nested as WebSearchItem[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||||
@@ -548,16 +544,16 @@ function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResultCard({ item }: { item: WebSearchItem }) {
|
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const content = item.snippet ?? item.content;
|
const content = item.snippet ?? item.content;
|
||||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||||
let source: string | undefined;
|
let sourceLabel: string | undefined = item.source;
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
try {
|
try {
|
||||||
source = new URL(item.url).hostname.replace(/^www\./, "");
|
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||||
} catch {
|
} catch {
|
||||||
source = undefined;
|
sourceLabel = sourceLabel ?? item.url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -572,10 +568,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
|
|||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(source || date) && (
|
{(sourceLabel || date) && (
|
||||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
{source && <span className="truncate">{source}</span>}
|
{sourceLabel && <span className="truncate">{sourceLabel}</span>}
|
||||||
{source && date && <span>·</span>}
|
{sourceLabel && date && <span>·</span>}
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -591,10 +587,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
|
|||||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
<DialogTitle className="pr-6 text-base leading-relaxed">
|
||||||
{item.title ?? "检索结果"}
|
{item.title ?? "检索结果"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{(source || date) && (
|
{(sourceLabel || date) && (
|
||||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||||
{source && <span>{source}</span>}
|
{sourceLabel && <span>{sourceLabel}</span>}
|
||||||
{source && date && <span>·</span>}
|
{sourceLabel && date && <span>·</span>}
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -64,6 +64,111 @@ export function MessageList({
|
|||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
const messages = thread.messages;
|
const messages = thread.messages;
|
||||||
|
|
||||||
|
// Track which clarification messages are still awaiting an answer.
|
||||||
|
// Pending = clarification tool messages that appear after the last human message
|
||||||
|
// (no user reply has been sent for them yet).
|
||||||
|
const pendingClarificationIds = useMemo(() => {
|
||||||
|
let lastHumanIndex = -1;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i]?.type === "human") {
|
||||||
|
lastHumanIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = lastHumanIndex + 1; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg && msg.id && msg.type === "tool" && msg.name === "ask_clarification") {
|
||||||
|
ids.push(msg.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const [clarificationSelections, setClarificationSelections] = useState<
|
||||||
|
Map<string, ClarificationSelection>
|
||||||
|
>(new Map());
|
||||||
|
const [submittingClarification, setSubmittingClarification] = useState(false);
|
||||||
|
|
||||||
|
const handleClarificationSelectionChange = useCallback(
|
||||||
|
(id: string, next: ClarificationSelection) => {
|
||||||
|
setClarificationSelections((prev) => {
|
||||||
|
const map = new Map(prev);
|
||||||
|
map.set(id, next);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmitAllClarifications = useCallback(async () => {
|
||||||
|
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let index = 0; index < pendingClarificationIds.length; index++) {
|
||||||
|
const id = pendingClarificationIds[index]!;
|
||||||
|
const message = messages.find((msg) => msg.id === id);
|
||||||
|
const payload = message ? getClarificationPayload(message) : null;
|
||||||
|
const selection = clarificationSelections.get(id) ?? {
|
||||||
|
selectedIds: [],
|
||||||
|
custom: "",
|
||||||
|
};
|
||||||
|
const options = (payload?.options ?? []).filter(
|
||||||
|
(option) => typeof option.label === "string" && option.label.trim(),
|
||||||
|
);
|
||||||
|
const selectedLabels = selection.selectedIds
|
||||||
|
.map(
|
||||||
|
(sid) =>
|
||||||
|
options.find((option) => (option.id || option.label) === sid)?.label,
|
||||||
|
)
|
||||||
|
.filter((label): label is string => Boolean(label && label.trim()));
|
||||||
|
const trimmedCustom = selection.custom.trim();
|
||||||
|
let part = "";
|
||||||
|
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||||
|
part = `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||||
|
} else if (selectedLabels.length > 0) {
|
||||||
|
part = `我选择:${selectedLabels.join("、")}`;
|
||||||
|
} else if (trimmedCustom) {
|
||||||
|
part = trimmedCustom;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pendingClarificationIds.length > 1) {
|
||||||
|
const question = payload?.question?.trim();
|
||||||
|
parts.push(
|
||||||
|
question
|
||||||
|
? `问题${index + 1}「${question}」:${part}`
|
||||||
|
: `问题${index + 1}:${part}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const combined =
|
||||||
|
pendingClarificationIds.length > 1
|
||||||
|
? `针对你提出的多个问题,依次回答:\n${parts.map((p) => `- ${p}`).join("\n")}`
|
||||||
|
: parts[0]!;
|
||||||
|
setSubmittingClarification(true);
|
||||||
|
try {
|
||||||
|
await onClarificationSubmit(combined);
|
||||||
|
} finally {
|
||||||
|
setSubmittingClarification(false);
|
||||||
|
}
|
||||||
|
}, [pendingClarificationIds, messages, clarificationSelections, onClarificationSubmit]);
|
||||||
|
|
||||||
|
const totalPending = pendingClarificationIds.length;
|
||||||
|
const hasAnyClarificationAnswer = useMemo(() => {
|
||||||
|
return pendingClarificationIds.some((id) => {
|
||||||
|
const sel = clarificationSelections.get(id);
|
||||||
|
return (sel?.selectedIds.length ?? 0) > 0 || (sel?.custom.trim() ?? "").length > 0;
|
||||||
|
});
|
||||||
|
}, [pendingClarificationIds, clarificationSelections]);
|
||||||
|
|
||||||
if (thread.isThreadLoading && messages.length === 0) {
|
if (thread.isThreadLoading && messages.length === 0) {
|
||||||
return <MessageListSkeleton />;
|
return <MessageListSkeleton />;
|
||||||
}
|
}
|
||||||
@@ -89,14 +194,34 @@ export function MessageList({
|
|||||||
} else if (group.type === "assistant:clarification") {
|
} else if (group.type === "assistant:clarification") {
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
|
const messageId = message.id ?? "";
|
||||||
|
const pendingIndex = pendingClarificationIds.indexOf(messageId);
|
||||||
|
const isPending = pendingIndex >= 0;
|
||||||
|
const isLastPending =
|
||||||
|
isPending && pendingIndex === totalPending - 1;
|
||||||
return (
|
return (
|
||||||
<div key={group.id} className="w-full">
|
<div key={group.id} className="w-full">
|
||||||
<ClarificationCard
|
<ClarificationCard
|
||||||
fallbackContent={extractContentFromMessage(message)}
|
fallbackContent={extractContentFromMessage(message)}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
message={message}
|
message={message}
|
||||||
onSubmit={onClarificationSubmit}
|
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
|
isPending={isPending}
|
||||||
|
pendingIndex={isPending ? pendingIndex : undefined}
|
||||||
|
pendingCount={isPending ? totalPending : undefined}
|
||||||
|
isLastPending={isLastPending}
|
||||||
|
selection={
|
||||||
|
clarificationSelections.get(messageId) ?? {
|
||||||
|
selectedIds: [],
|
||||||
|
custom: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSelectionChange={(next) =>
|
||||||
|
handleClarificationSelectionChange(messageId, next)
|
||||||
|
}
|
||||||
|
submitting={submittingClarification}
|
||||||
|
canSubmit={hasAnyClarificationAnswer}
|
||||||
|
onSubmitAll={handleSubmitAllClarifications}
|
||||||
/>
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -289,6 +414,11 @@ type ClarificationPayload = {
|
|||||||
allow_custom?: boolean;
|
allow_custom?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClarificationSelection = {
|
||||||
|
selectedIds: string[];
|
||||||
|
custom: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SetupAgentApprovalPayload = {
|
export type SetupAgentApprovalPayload = {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -447,19 +577,32 @@ function ClarificationCard({
|
|||||||
fallbackContent,
|
fallbackContent,
|
||||||
isLoading,
|
isLoading,
|
||||||
message,
|
message,
|
||||||
onSubmit,
|
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
|
isPending,
|
||||||
|
pendingIndex,
|
||||||
|
pendingCount,
|
||||||
|
isLastPending,
|
||||||
|
selection,
|
||||||
|
onSelectionChange,
|
||||||
|
submitting,
|
||||||
|
canSubmit,
|
||||||
|
onSubmitAll,
|
||||||
}: {
|
}: {
|
||||||
fallbackContent: string;
|
fallbackContent: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
message: unknown;
|
message: unknown;
|
||||||
onSubmit?: (answer: string) => void | Promise<void>;
|
|
||||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
|
isPending: boolean;
|
||||||
|
pendingIndex?: number;
|
||||||
|
pendingCount?: number;
|
||||||
|
isLastPending: boolean;
|
||||||
|
selection: ClarificationSelection;
|
||||||
|
onSelectionChange: (next: ClarificationSelection) => void;
|
||||||
|
submitting: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
onSubmitAll: () => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const payload = getClarificationPayload(message);
|
const payload = getClarificationPayload(message);
|
||||||
const [customAnswer, setCustomAnswer] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -479,27 +622,36 @@ function ClarificationCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = false;
|
const disabled = !isPending || submitting;
|
||||||
|
const multiPending = (pendingCount ?? 0) > 1;
|
||||||
|
|
||||||
async function submitAnswer(answer: string) {
|
function toggleOption(optionId: string) {
|
||||||
const trimmed = answer.trim();
|
if (disabled) {
|
||||||
if (!trimmed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
const next = selection.selectedIds.includes(optionId)
|
||||||
try {
|
? selection.selectedIds.filter((id) => id !== optionId)
|
||||||
await onSubmit?.(trimmed);
|
: [...selection.selectedIds, optionId];
|
||||||
setSubmitted(true);
|
onSelectionChange({ ...selection, selectedIds: next });
|
||||||
} finally {
|
}
|
||||||
setSubmitting(false);
|
|
||||||
|
function updateCustom(value: string) {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
onSelectionChange({ ...selection, custom: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-muted-foreground text-xs font-medium">
|
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
|
||||||
需要你的确认
|
<span>需要你的确认</span>
|
||||||
|
{multiPending && pendingIndex !== undefined && pendingCount ? (
|
||||||
|
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]">
|
||||||
|
{pendingIndex + 1} / {pendingCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{payload.context ? (
|
{payload.context ? (
|
||||||
<p className="text-muted-foreground text-sm leading-6">
|
<p className="text-muted-foreground text-sm leading-6">
|
||||||
@@ -513,49 +665,84 @@ function ClarificationCard({
|
|||||||
|
|
||||||
{options.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
<div className="mt-4 grid gap-2">
|
<div className="mt-4 grid gap-2">
|
||||||
{options.map((option) => (
|
<div className="text-muted-foreground text-xs">
|
||||||
<Button
|
可多选(再次点击取消选择)
|
||||||
key={option.id || option.label}
|
</div>
|
||||||
type="button"
|
{options.map((option) => {
|
||||||
variant="outline"
|
const optionId = option.id || option.label;
|
||||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
const isSelected = selection.selectedIds.includes(optionId);
|
||||||
disabled={disabled}
|
return (
|
||||||
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
<Button
|
||||||
>
|
key={optionId}
|
||||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
type="button"
|
||||||
<span>{option.label}</span>
|
variant={isSelected ? "default" : "outline"}
|
||||||
{option.description ? (
|
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
disabled={disabled}
|
||||||
{option.description}
|
onClick={() => toggleOption(optionId)}
|
||||||
</span>
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{option.description ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-normal leading-5",
|
||||||
|
isSelected
|
||||||
|
? "text-primary-foreground/80"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{isSelected ? (
|
||||||
|
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{payload.allow_custom !== false ? (
|
{payload.allow_custom !== false ? (
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={customAnswer}
|
value={selection.custom}
|
||||||
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
|
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
placeholder={
|
||||||
|
options.length > 0
|
||||||
|
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||||
|
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||||
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="min-h-20 resize-none"
|
className="min-h-20 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end">
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
isLastPending ? (
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
{multiPending ? (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
将一起提交以上 {pendingCount} 个问题的回答
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled || !customAnswer.trim()}
|
disabled={!canSubmit || submitting}
|
||||||
onClick={() => void submitAnswer(customAnswer)}
|
onClick={() => void onSubmitAll()}
|
||||||
>
|
>
|
||||||
<SendIcon className="h-4 w-4" />
|
<SendIcon className="h-4 w-4" />
|
||||||
发送回答
|
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||||
|
选择后等待最后一题统一提交
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
FileJson,
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -63,6 +64,15 @@ export function RecentChatList() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [collapsed, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
const next = !collapsed;
|
||||||
|
setCollapsed(next);
|
||||||
|
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
|
||||||
|
};
|
||||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
||||||
useParams<{
|
useParams<{
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
@@ -150,7 +160,9 @@ export function RecentChatList() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visibleThreads = threads.filter(
|
const visibleThreads = threads.filter(
|
||||||
(thread) => thread.metadata?.thread_type !== "scheduler",
|
(thread) =>
|
||||||
|
thread.metadata?.thread_type !== "scheduler" &&
|
||||||
|
thread.metadata?.thread_type !== "bootstrap",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (visibleThreads.length === 0) {
|
if (visibleThreads.length === 0) {
|
||||||
@@ -159,12 +171,24 @@ export function RecentChatList() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||||
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase">
|
<SidebarGroupLabel
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||||
? t.sidebar.recentChats
|
onClick={toggleCollapsed}
|
||||||
: t.sidebar.demoChats}
|
>
|
||||||
|
<span>
|
||||||
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||||
|
? t.sidebar.recentChats
|
||||||
|
: t.sidebar.demoChats}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
<SidebarGroupContent
|
||||||
|
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||||
|
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
{visibleThreads.map((thread) => {
|
{visibleThreads.map((thread) => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuButton,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
formatRelative,
|
||||||
|
STATUS_LABELS,
|
||||||
|
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||||
|
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
||||||
|
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
|
||||||
|
|
||||||
|
const AI_WRITING_PATH = "/page/workspace/ai-writing";
|
||||||
|
|
||||||
|
function openSession(
|
||||||
|
session: AIWritingSessionSummary,
|
||||||
|
isOnPage: boolean,
|
||||||
|
router: ReturnType<typeof useRouter>,
|
||||||
|
) {
|
||||||
|
if (isOnPage) {
|
||||||
|
// 已在 AI 写作页面,直接通过自定义事件通知面板
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("ai-writing:open-session", { detail: session }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 不在该页面,把 session 存入 sessionStorage 后跳转
|
||||||
|
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
|
||||||
|
router.push(AI_WRITING_PATH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentWritingList() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { sessions, loading, refresh } = useAIWritingSessions();
|
||||||
|
const [collapsed, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
const next = !collapsed;
|
||||||
|
setCollapsed(next);
|
||||||
|
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
if (!loading && sessions.length === 0) return null;
|
||||||
|
|
||||||
|
const isOnPage = pathname === AI_WRITING_PATH;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||||
|
<SidebarGroupLabel
|
||||||
|
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
>
|
||||||
|
<span>最近写作</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent
|
||||||
|
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||||
|
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SidebarMenu>
|
||||||
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
|
{loading && sessions.length === 0 ? (
|
||||||
|
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||||
|
) : (
|
||||||
|
sessions.slice(0, 10).map((session) => {
|
||||||
|
const { label, color } = STATUS_LABELS[session.status] ?? {
|
||||||
|
label: session.status,
|
||||||
|
color: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={session.id} className="group/side-menu-item">
|
||||||
|
<SidebarMenuButton
|
||||||
|
className={
|
||||||
|
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||||
|
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||||
|
}
|
||||||
|
onClick={() => openSession(session, isOnPage, router)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start min-w-0 w-full">
|
||||||
|
<span className="text-[13px] truncate w-full leading-snug">
|
||||||
|
{session.title}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<span className={cn("text-[10px] font-medium", color)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">·</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatRelative(session.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
|
import { MoonIcon, SunIcon } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||||
|
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
|
|||||||
|
|
||||||
export function AppearanceSettingsPage() {
|
export function AppearanceSettingsPage() {
|
||||||
const { t, locale, changeLocale } = useI18n();
|
const { t, locale, changeLocale } = useI18n();
|
||||||
const { theme, setTheme, systemTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
const currentTheme = theme;
|
||||||
const [settings, setSettings] = useLocalSettings();
|
const [settings, setSettings] = useLocalSettings();
|
||||||
|
|
||||||
const themeOptions = useMemo(
|
const themeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
|
||||||
id: "system",
|
|
||||||
label: t.settings.appearance.system,
|
|
||||||
description: t.settings.appearance.systemDescription,
|
|
||||||
icon: MonitorSmartphoneIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "light",
|
id: "light",
|
||||||
label: t.settings.appearance.light,
|
label: t.settings.appearance.light,
|
||||||
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
|
|||||||
t.settings.appearance.darkDescription,
|
t.settings.appearance.darkDescription,
|
||||||
t.settings.appearance.light,
|
t.settings.appearance.light,
|
||||||
t.settings.appearance.lightDescription,
|
t.settings.appearance.lightDescription,
|
||||||
t.settings.appearance.system,
|
|
||||||
t.settings.appearance.systemDescription,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
|
|||||||
title={t.settings.appearance.themeTitle}
|
title={t.settings.appearance.themeTitle}
|
||||||
description={t.settings.appearance.themeDescription}
|
description={t.settings.appearance.themeDescription}
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 lg:grid-cols-3">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
{themeOptions.map((option) => (
|
{themeOptions.map((option) => (
|
||||||
<ThemePreviewCard
|
<ThemePreviewCard
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -76,8 +69,7 @@ export function AppearanceSettingsPage() {
|
|||||||
label={option.label}
|
label={option.label}
|
||||||
description={option.description}
|
description={option.description}
|
||||||
active={currentTheme === option.id}
|
active={currentTheme === option.id}
|
||||||
mode={option.id as "system" | "light" | "dark"}
|
mode={option.id as "light" | "dark"}
|
||||||
systemTheme={systemTheme}
|
|
||||||
onSelect={(value) => setTheme(value)}
|
onSelect={(value) => setTheme(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -122,7 +114,7 @@ export function AppearanceSettingsPage() {
|
|||||||
<div className="text-sm font-medium">回答速度指示器</div>
|
<div className="text-sm font-medium">回答速度指示器</div>
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||||
在生成回答时显示一个小胶囊,实时估算当前 token/s
|
在生成回答时显示一个小胶囊,实时估算当前 token/s
|
||||||
并用绿到红的渐变条直观反映快慢。默认关闭。
|
并用绿到红的渐变条直观反映快慢。默认开启。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -144,19 +136,16 @@ function ThemePreviewCard({
|
|||||||
description,
|
description,
|
||||||
active,
|
active,
|
||||||
mode,
|
mode,
|
||||||
systemTheme,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
mode: "system" | "light" | "dark";
|
mode: "light" | "dark";
|
||||||
systemTheme?: string;
|
onSelect: (mode: "light" | "dark") => void;
|
||||||
onSelect: (mode: "system" | "light" | "dark") => void;
|
|
||||||
}) {
|
}) {
|
||||||
const previewMode =
|
const previewMode = mode;
|
||||||
mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
useReplaceMemoryEntry,
|
useReplaceMemoryEntry,
|
||||||
} from "@/core/memory/hooks";
|
} from "@/core/memory/hooks";
|
||||||
import type {
|
import type {
|
||||||
|
MemoryFact,
|
||||||
MemorySearchMode,
|
MemorySearchMode,
|
||||||
MemoryTarget,
|
MemoryTarget,
|
||||||
MemoryV2Config,
|
MemoryV2Config,
|
||||||
@@ -54,6 +56,10 @@ export function MemorySettingsPage() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const m = t.settings.memory;
|
const m = t.settings.memory;
|
||||||
|
|
||||||
|
// 先读配置拿到 version,再决定渲染哪套 UI
|
||||||
|
const { configResponse } = useMemoryConfig();
|
||||||
|
const memoryVersion = configResponse?.version ?? "v2";
|
||||||
|
|
||||||
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
|
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
|
||||||
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
|
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
|
||||||
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
|
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
|
||||||
@@ -211,7 +217,33 @@ export function MemorySettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : !memory ? (
|
) : !memory ? (
|
||||||
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
|
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
|
||||||
|
) : memoryVersion === "v1" ? (
|
||||||
|
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{m.workNotesAgentLabel}:
|
||||||
|
</span>
|
||||||
|
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||||
|
<SelectTrigger className="h-8 w-44" size="sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">
|
||||||
|
{m.workNotesDefaultAgent}
|
||||||
|
</SelectItem>
|
||||||
|
{experts.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<MemoryV1View memory={memory} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
// V2 模式:USER.md / MEMORY.md 条目编辑
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{renderEntrySection(
|
{renderEntrySection(
|
||||||
"user",
|
"user",
|
||||||
@@ -251,13 +283,17 @@ export function MemorySettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<div className="mt-8">
|
{memoryVersion !== "v1" && (
|
||||||
<MemoryConfigPanel />
|
<>
|
||||||
</div>
|
<div className="mt-8">
|
||||||
|
<MemoryConfigPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<MemorySearchPanel />
|
<MemorySearchPanel />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 新增 / 编辑条目对话框 */}
|
{/* 新增 / 编辑条目对话框 */}
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -415,6 +451,152 @@ function MemoryConfigPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CTX_LABELS: Record<string, string> = {
|
||||||
|
workContext: "工作背景",
|
||||||
|
personalContext: "个人信息",
|
||||||
|
topOfMind: "当前重点",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HISTORY_LABELS: Record<string, string> = {
|
||||||
|
recentMonths: "近期",
|
||||||
|
earlierContext: "早期背景",
|
||||||
|
longTermBackground: "长期背景",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
preference: "bg-blue-100 text-blue-700",
|
||||||
|
knowledge: "bg-green-100 text-green-700",
|
||||||
|
context: "bg-yellow-100 text-yellow-700",
|
||||||
|
behavior: "bg-purple-100 text-purple-700",
|
||||||
|
goal: "bg-orange-100 text-orange-700",
|
||||||
|
correction: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||||
|
function MemoryV1View({
|
||||||
|
memory,
|
||||||
|
}: {
|
||||||
|
memory: {
|
||||||
|
facts: MemoryFact[];
|
||||||
|
user_context: Record<string, string>;
|
||||||
|
history: Record<string, string>;
|
||||||
|
memory_usage: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||||
|
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 说明横幅 */}
|
||||||
|
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="facts">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="facts">
|
||||||
|
事实记录
|
||||||
|
{memory.facts.length > 0 && (
|
||||||
|
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||||
|
{memory.facts.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 事实记录 Tab */}
|
||||||
|
<TabsContent value="facts" className="mt-3">
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-medium">事实记录</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||||
|
</div>
|
||||||
|
{memory.facts.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
暂无事实记录,对话后系统会自动提取。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{memory.facts.map((fact) => (
|
||||||
|
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||||
|
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{fact.category}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{Math.round(fact.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||||
|
{fact.sourceError && (
|
||||||
|
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||||
|
避免:{fact.sourceError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 用户画像 & 历史摘要 Tab */}
|
||||||
|
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||||
|
{/* 用户画像 (userContext) */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-2">
|
||||||
|
<h3 className="text-base font-medium">用户画像</h3>
|
||||||
|
{ctxEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||||
|
) : (
|
||||||
|
<dl className="space-y-2">
|
||||||
|
{ctxEntries.map(([key, value]) => (
|
||||||
|
<div key={key} className="text-sm">
|
||||||
|
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||||
|
{CTX_LABELS[key] ?? key}
|
||||||
|
</dt>
|
||||||
|
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 历史摘要 (history) */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-2">
|
||||||
|
<h3 className="text-base font-medium">历史摘要</h3>
|
||||||
|
{historyEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||||
|
) : (
|
||||||
|
<dl className="space-y-2">
|
||||||
|
{historyEntries.map(([key, value]) => (
|
||||||
|
<div key={key} className="text-sm">
|
||||||
|
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||||
|
{HISTORY_LABELS[key] ?? key}
|
||||||
|
</dt>
|
||||||
|
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 一行开关控件。 */
|
/** 一行开关控件。 */
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
LockIcon,
|
LockIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PinOffIcon,
|
PinOffIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
@@ -18,7 +21,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
|||||||
import {
|
import {
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
|
useReconcileCustomSkills,
|
||||||
useSetSkillPinned,
|
useSetSkillPinned,
|
||||||
useSetSkillPublished,
|
useSetSkillPublished,
|
||||||
useSkills,
|
useSkills,
|
||||||
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||||
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
||||||
|
|
||||||
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const staleCount = staleVisible.length;
|
const staleCount = staleVisible.length;
|
||||||
const archivedCount = archivedVisible.length;
|
const archivedCount = archivedVisible.length;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||||
|
// Filter / search / tag changes may shrink the result set — pull the page
|
||||||
|
// back into range without forcing a reset on every keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
const pagedSkills = useMemo(
|
||||||
|
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
|
[filteredSkills, page],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateSkill = () => {
|
const handleCreateSkill = () => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
router.push("/?mode=skill");
|
router.push("/?mode=skill");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRescan = async () => {
|
||||||
|
try {
|
||||||
|
const result = await reconcileMutation.mutateAsync();
|
||||||
|
if (result.adopted.length > 0) {
|
||||||
|
toast.success(
|
||||||
|
t.settings.skills.rescanSkillsAdopted.replace(
|
||||||
|
"{count}",
|
||||||
|
String(result.adopted.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.info(t.settings.skills.rescanSkillsNoop);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<TrendingUpIcon className="size-4" />
|
<TrendingUpIcon className="size-4" />
|
||||||
{t.settings.skills.goToEvolution}
|
{t.settings.skills.goToEvolution}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleRescan()}
|
||||||
|
disabled={
|
||||||
|
reconcileMutation.isPending ||
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||||
|
}
|
||||||
|
title={t.settings.skills.rescanSkills}
|
||||||
|
>
|
||||||
|
{reconcileMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{reconcileMutation.isPending
|
||||||
|
? t.settings.skills.rescanSkillsRunning
|
||||||
|
: t.settings.skills.rescanSkills}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
)}
|
)}
|
||||||
{!isLoading && !error &&
|
{!isLoading && !error &&
|
||||||
filteredSkills.length > 0 &&
|
filteredSkills.length > 0 &&
|
||||||
filteredSkills.map((skill) => (
|
pagedSkills.map((skill) => (
|
||||||
<SkillRow
|
<SkillRow
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
skill={skill}
|
skill={skill}
|
||||||
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!isLoading && !error && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
{t.settings.skills.paginationPrev}
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t.settings.skills.paginationPageOf
|
||||||
|
.replace("{page}", String(page))
|
||||||
|
.replace("{total}", String(totalPages))}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
{t.settings.skills.paginationNext}
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<InstallSkillDialog
|
<InstallSkillDialog
|
||||||
open={installOpen}
|
open={installOpen}
|
||||||
onClose={() => setInstallOpen(false)}
|
onClose={() => setInstallOpen(false)}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
|
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||||
|
|
||||||
import { SidebarCollapseButton } from "./workspace-header";
|
import { SidebarCollapseButton } from "./workspace-header";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -107,6 +107,30 @@ export function WorkspaceNavChatList({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={pathname.startsWith("/page/workspace/ai-writing")}
|
||||||
|
asChild
|
||||||
|
className={NAV_ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<Link href="/page/workspace/ai-writing">
|
||||||
|
<PenLine />
|
||||||
|
<span>AI写作</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
|
||||||
|
asChild
|
||||||
|
className={NAV_ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<Link href="/page/workspace/roundtable/planning">
|
||||||
|
<Users />
|
||||||
|
<span>多智能体规划</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||||
|
|||||||
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/users")}
|
onClick={() => navigate("/page/workspace/admin")}
|
||||||
>
|
>
|
||||||
<UsersIcon />
|
<UsersIcon />
|
||||||
用户管理
|
用户中心
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/memory")}
|
|
||||||
>
|
|
||||||
<BrainIcon />
|
|
||||||
记忆管理
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
|
|||||||
定时任务管理
|
定时任务管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
navigate("/page/workspace/admin/user-activity")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HistoryIcon />
|
|
||||||
用户活动审计
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { RecentChatList } from "./recent-chat-list";
|
import { RecentChatList } from "./recent-chat-list";
|
||||||
|
import { RecentWritingList } from "./recent-writing-list";
|
||||||
import { WorkspaceHeader } from "./workspace-header";
|
import { WorkspaceHeader } from "./workspace-header";
|
||||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||||
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||||
{isSidebarOpen && <RecentChatList />}
|
{isSidebarOpen && <RecentChatList />}
|
||||||
|
{isSidebarOpen && <RecentWritingList />}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
{!compactChrome && (
|
{!compactChrome && (
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useTheme as useNextTheme } from 'next-themes';
|
||||||
|
|
||||||
// 主题类型
|
// 主题类型
|
||||||
export type ThemeType = 'dark' | 'light';
|
export type ThemeType = 'dark' | 'light';
|
||||||
@@ -75,12 +82,24 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'strategy-theme';
|
||||||
|
|
||||||
|
function readPersistedTheme(): ThemeType {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return saved === 'light' ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
// 主题上下文
|
// 主题上下文
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
themeConfig: ThemeConfig;
|
themeConfig: ThemeConfig;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
setTheme: (theme: ThemeType) => void;
|
setTheme: (theme: ThemeType) => void;
|
||||||
|
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
|
||||||
|
mounted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||||
const [theme, setThemeState] = useState<ThemeType>(() => {
|
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||||
// 从 localStorage 读取保存的主题
|
const [mounted, setMounted] = useState(false);
|
||||||
const saved = localStorage.getItem('strategy-theme');
|
|
||||||
return (saved as ThemeType) || 'dark';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 切换主题
|
|
||||||
const toggleTheme = () => {
|
|
||||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置特定主题
|
|
||||||
const setTheme = (newTheme: ThemeType) => {
|
|
||||||
setThemeState(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存到 localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('strategy-theme', theme);
|
setMounted(true);
|
||||||
// 更新文档类名
|
}, []);
|
||||||
document.documentElement.classList.remove('dark', 'light');
|
|
||||||
document.documentElement.classList.add(theme);
|
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
|
||||||
}, [theme]);
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const current = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const legacy = localStorage.getItem('theme');
|
||||||
|
|
||||||
|
if (current !== 'light' && current !== 'dark') {
|
||||||
|
const fallback =
|
||||||
|
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
|
||||||
|
setNextTheme(fallback);
|
||||||
|
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||||
|
setNextTheme(legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacy) {
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
}
|
||||||
|
}, [mounted, setNextTheme]);
|
||||||
|
|
||||||
|
const theme: ThemeType = mounted
|
||||||
|
? resolvedTheme === 'light'
|
||||||
|
? 'light'
|
||||||
|
: 'dark'
|
||||||
|
: readPersistedTheme();
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (newTheme: ThemeType) => {
|
||||||
|
setNextTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
const themeConfig = themes[theme];
|
const themeConfig = themes[theme];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
|
<ThemeContext.Provider
|
||||||
|
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface AdminMemoryBucket {
|
|||||||
|
|
||||||
export interface AdminMemoryResponse {
|
export interface AdminMemoryResponse {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
version: "v1" | "v2";
|
||||||
buckets: AdminMemoryBucket[];
|
buckets: AdminMemoryBucket[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,3 +119,72 @@ export async function deleteAdminMemoryEntry(
|
|||||||
"Failed to delete memory entry",
|
"Failed to delete memory entry",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updateAdminV1Section(
|
||||||
|
userId: string,
|
||||||
|
sectionType: "user" | "history",
|
||||||
|
field: string,
|
||||||
|
summary: string,
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
return parse(
|
||||||
|
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/sections`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
|
||||||
|
}),
|
||||||
|
"Failed to update V1 section",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAdminV1Fact(
|
||||||
|
userId: string,
|
||||||
|
content: string,
|
||||||
|
category: string,
|
||||||
|
confidence: number,
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
return parse(
|
||||||
|
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content, category, confidence, agent }),
|
||||||
|
}),
|
||||||
|
"Failed to add V1 fact",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminV1Fact(
|
||||||
|
userId: string,
|
||||||
|
factId: string,
|
||||||
|
updates: { content?: string; category?: string; confidence?: number },
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
return parse(
|
||||||
|
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...updates, agent }),
|
||||||
|
}),
|
||||||
|
"Failed to update V1 fact",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminV1Fact(
|
||||||
|
userId: string,
|
||||||
|
factId: string,
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (agent) params.set("agent", agent);
|
||||||
|
const qs = params.toString() ? `?${params}` : "";
|
||||||
|
return parse(
|
||||||
|
await apiFetch(
|
||||||
|
`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
"Failed to delete V1 fact",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
addAdminMemoryEntry,
|
addAdminMemoryEntry,
|
||||||
|
addAdminV1Fact,
|
||||||
deleteAdminMemoryEntry,
|
deleteAdminMemoryEntry,
|
||||||
|
deleteAdminV1Fact,
|
||||||
getUserMemory,
|
getUserMemory,
|
||||||
listUserThreadMessages,
|
listUserThreadMessages,
|
||||||
listUserThreads,
|
listUserThreads,
|
||||||
replaceAdminMemoryEntry,
|
replaceAdminMemoryEntry,
|
||||||
|
updateAdminV1Fact,
|
||||||
|
updateAdminV1Section,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||||
@@ -70,3 +74,66 @@ export function useAdminDeleteMemoryEntry(userId: string) {
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V1 CRUD hooks ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAdminUpdateV1Section(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
sectionType,
|
||||||
|
field,
|
||||||
|
summary,
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
sectionType: "user" | "history";
|
||||||
|
field: string;
|
||||||
|
summary: string;
|
||||||
|
agent: string | null;
|
||||||
|
}) => updateAdminV1Section(userId, sectionType, field, summary, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminAddV1Fact(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
content,
|
||||||
|
category,
|
||||||
|
confidence,
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
confidence: number;
|
||||||
|
agent: string | null;
|
||||||
|
}) => addAdminV1Fact(userId, content, category, confidence, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminUpdateV1Fact(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
factId,
|
||||||
|
updates,
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
factId: string;
|
||||||
|
updates: { content?: string; category?: string; confidence?: number };
|
||||||
|
agent: string | null;
|
||||||
|
}) => updateAdminV1Fact(userId, factId, updates, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminDeleteV1Fact(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ factId, agent }: { factId: string; agent: string | null }) =>
|
||||||
|
deleteAdminV1Fact(userId, factId, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -446,7 +446,14 @@ export const enUS: Translations = {
|
|||||||
"Manage the configuration and enabled status of the agent skills.",
|
"Manage the configuration and enabled status of the agent skills.",
|
||||||
createSkill: "Create skill",
|
createSkill: "Create skill",
|
||||||
installSkill: "Install skill",
|
installSkill: "Install skill",
|
||||||
|
rescanSkills: "Rescan skills directory",
|
||||||
|
rescanSkillsRunning: "Scanning…",
|
||||||
|
rescanSkillsAdopted: "Adopted {count} new skill(s)",
|
||||||
|
rescanSkillsNoop: "No new skills found on disk",
|
||||||
goToEvolution: "Skill evolution",
|
goToEvolution: "Skill evolution",
|
||||||
|
paginationPrev: "Prev",
|
||||||
|
paginationNext: "Next",
|
||||||
|
paginationPageOf: "Page {page} of {total}",
|
||||||
emptyTitle: "No agent skill yet",
|
emptyTitle: "No agent skill yet",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||||
|
|||||||
@@ -381,7 +381,14 @@ export interface Translations {
|
|||||||
description: string;
|
description: string;
|
||||||
createSkill: string;
|
createSkill: string;
|
||||||
installSkill: string;
|
installSkill: string;
|
||||||
|
rescanSkills: string;
|
||||||
|
rescanSkillsRunning: string;
|
||||||
|
rescanSkillsAdopted: string;
|
||||||
|
rescanSkillsNoop: string;
|
||||||
goToEvolution: string;
|
goToEvolution: string;
|
||||||
|
paginationPrev: string;
|
||||||
|
paginationNext: string;
|
||||||
|
paginationPageOf: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: string;
|
emptyButton: string;
|
||||||
|
|||||||
@@ -427,7 +427,14 @@ export const zhCN: Translations = {
|
|||||||
description: "管理 Agent Skill 配置和启用状态。",
|
description: "管理 Agent Skill 配置和启用状态。",
|
||||||
createSkill: "新建技能",
|
createSkill: "新建技能",
|
||||||
installSkill: "安装技能",
|
installSkill: "安装技能",
|
||||||
|
rescanSkills: "重新扫描技能目录",
|
||||||
|
rescanSkillsRunning: "扫描中…",
|
||||||
|
rescanSkillsAdopted: "已收纳 {count} 个新技能",
|
||||||
|
rescanSkillsNoop: "没有发现需要收纳的新技能",
|
||||||
goToEvolution: "自我进化",
|
goToEvolution: "自我进化",
|
||||||
|
paginationPrev: "上一页",
|
||||||
|
paginationNext: "下一页",
|
||||||
|
paginationPageOf: "第 {page} / {total} 页",
|
||||||
emptyTitle: "还没有技能",
|
emptyTitle: "还没有技能",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
|
// Memory 数据模型。对应后端 /api/memory/v2 系列端点。支持 V1 / V2 两个版本。
|
||||||
|
|
||||||
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
|
/** V1 记忆中的单条事实。 */
|
||||||
|
export interface MemoryFact {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
confidence: number;
|
||||||
|
createdAt: string;
|
||||||
|
/** 纠错类事实:被纠正的原错误描述(category=correction 时可能有值)。 */
|
||||||
|
sourceError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记忆数据视图 —— V1/V2 共用结构。
|
||||||
|
*
|
||||||
|
* - V2 模式: user_entries / memory_entries / user_usage / memory_usage 有值
|
||||||
|
* - V1 模式: facts / user_context / memory_usage 有值
|
||||||
|
*/
|
||||||
export interface MemoryV2Data {
|
export interface MemoryV2Data {
|
||||||
|
// V2 字段
|
||||||
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
|
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
|
||||||
user_entries: string[];
|
user_entries: string[];
|
||||||
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
|
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
|
||||||
@@ -10,17 +26,27 @@ export interface MemoryV2Data {
|
|||||||
user_usage: string;
|
user_usage: string;
|
||||||
/** MEMORY.md 字符用量。 */
|
/** MEMORY.md 字符用量。 */
|
||||||
memory_usage: string;
|
memory_usage: string;
|
||||||
|
|
||||||
|
// V1 字段
|
||||||
|
/** V1 facts 列表,按置信度降序。 */
|
||||||
|
facts: MemoryFact[];
|
||||||
|
/** V1 userContext 摘要。 */
|
||||||
|
user_context: Record<string, string>;
|
||||||
|
/** V1 history 历史摘要。 */
|
||||||
|
history: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 记忆条目所属类别。 */
|
/** 记忆条目所属类别。 */
|
||||||
export type MemoryTarget = "user" | "memory";
|
export type MemoryTarget = "user" | "memory";
|
||||||
|
|
||||||
/** 生效记忆配置 + 用户可改字段白名单。 */
|
/** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
|
||||||
export interface MemoryV2ConfigResponse {
|
export interface MemoryV2ConfigResponse {
|
||||||
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
|
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
|
||||||
config: MemoryV2Config;
|
config: MemoryV2Config;
|
||||||
/** 用户可在运行时覆盖的字段 (点号路径)。 */
|
/** 用户可在运行时覆盖的字段 (点号路径)。 */
|
||||||
writable_fields: string[];
|
writable_fields: string[];
|
||||||
|
/** 当前记忆版本:v1 或 v2。 */
|
||||||
|
version: "v1" | "v2";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
|
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* Normalizes retrieval payloads from web_search, intranet skills, MCP tools, etc.
|
||||||
|
* into a single card-friendly shape for the workspace chat UI.
|
||||||
|
*
|
||||||
|
* Parsing is intentionally tolerant: known field aliases first, then key-name
|
||||||
|
* heuristics, then longest-string fallback for body text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SearchResultItem = {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
snippet?: string;
|
||||||
|
content?: string;
|
||||||
|
source?: string;
|
||||||
|
published_date?: string;
|
||||||
|
publishedDate?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_LIST_KEYS = [
|
||||||
|
"results",
|
||||||
|
"data",
|
||||||
|
"items",
|
||||||
|
"hits",
|
||||||
|
"documents",
|
||||||
|
"records",
|
||||||
|
"entries",
|
||||||
|
"matches",
|
||||||
|
"chunks",
|
||||||
|
"sources",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WRAPPER_KEYS = ["result", "payload", "response", "output", "data"] as const;
|
||||||
|
|
||||||
|
const NESTED_META_KEYS = ["metadata", "meta", "attrs", "attributes", "properties", "extra"] as const;
|
||||||
|
|
||||||
|
/** Keys ignored when guessing content from arbitrary string fields. */
|
||||||
|
const SKIP_HEURISTIC_KEYS = new Set([
|
||||||
|
"id",
|
||||||
|
"uuid",
|
||||||
|
"recuuid",
|
||||||
|
"score",
|
||||||
|
"relevance_score",
|
||||||
|
"rank",
|
||||||
|
"index",
|
||||||
|
"type",
|
||||||
|
"status",
|
||||||
|
"ok",
|
||||||
|
"success",
|
||||||
|
"error",
|
||||||
|
"query",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TITLE_ALIASES = [
|
||||||
|
"title",
|
||||||
|
"m_title",
|
||||||
|
"name",
|
||||||
|
"heading",
|
||||||
|
"headline",
|
||||||
|
"subject",
|
||||||
|
"document_title",
|
||||||
|
"doc_title",
|
||||||
|
"file_name",
|
||||||
|
"filename",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTENT_ALIASES = [
|
||||||
|
"content",
|
||||||
|
"content_preview",
|
||||||
|
"snippet",
|
||||||
|
"page_content",
|
||||||
|
"body",
|
||||||
|
"text",
|
||||||
|
"summary",
|
||||||
|
"abstract",
|
||||||
|
"description",
|
||||||
|
"desc",
|
||||||
|
"excerpt",
|
||||||
|
"preview",
|
||||||
|
"passage",
|
||||||
|
"paragraph",
|
||||||
|
"chunk",
|
||||||
|
"chunk_text",
|
||||||
|
"document",
|
||||||
|
"doc_content",
|
||||||
|
"answer",
|
||||||
|
"highlight",
|
||||||
|
"context",
|
||||||
|
"message",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const URL_ALIASES = ["url", "href", "link", "uri", "source_url", "web_url", "page_url"] as const;
|
||||||
|
|
||||||
|
const SOURCE_ALIASES = [
|
||||||
|
"source",
|
||||||
|
"source1",
|
||||||
|
"source_name",
|
||||||
|
"origin",
|
||||||
|
"site",
|
||||||
|
"site_name",
|
||||||
|
"publisher",
|
||||||
|
"domain",
|
||||||
|
"from",
|
||||||
|
"channel",
|
||||||
|
"database",
|
||||||
|
"knowledge_base",
|
||||||
|
"kb",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DATE_ALIASES = [
|
||||||
|
"published_date",
|
||||||
|
"publishedDate",
|
||||||
|
"publish_date",
|
||||||
|
"date",
|
||||||
|
"m_publish",
|
||||||
|
"published_at",
|
||||||
|
"publish_time",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"time",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TITLE_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:title|name|heading|headline|subject)(?:$|_)/i;
|
||||||
|
const CONTENT_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:content|snippet|summary|abstract|body|text|preview|passage|excerpt|desc|description|chunk|paragraph|document|answer|highlight|context|message)(?:$|_)/i;
|
||||||
|
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||||
|
const SOURCE_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||||
|
const DATE_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||||
|
|
||||||
|
function pickString(...values: unknown[]): string | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(value) || /^www\./i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNestedRecords(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const layers: Record<string, unknown>[] = [record];
|
||||||
|
for (const key of NESTED_META_KEYS) {
|
||||||
|
const nested = asRecord(record[key]);
|
||||||
|
if (nested) {
|
||||||
|
layers.push(nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFromLayers(
|
||||||
|
layers: Record<string, unknown>[],
|
||||||
|
aliases: readonly string[],
|
||||||
|
): string | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const value = pickString(layer[alias]);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickByKeyPattern(
|
||||||
|
layers: Record<string, unknown>[],
|
||||||
|
pattern: RegExp,
|
||||||
|
options?: { excludeUrl?: boolean; maxLength?: number },
|
||||||
|
): string | undefined {
|
||||||
|
for (const layer of layers) {
|
||||||
|
for (const [key, value] of Object.entries(layer)) {
|
||||||
|
if (SKIP_HEURISTIC_KEYS.has(key.toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!pattern.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = pickString(value);
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (options?.excludeUrl && looksLikeUrl(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (options?.maxLength !== undefined && text.length > options.maxLength) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLongestContentFallback(
|
||||||
|
layers: Record<string, unknown>[],
|
||||||
|
reserved: Set<string>,
|
||||||
|
): string | undefined {
|
||||||
|
let best: string | undefined;
|
||||||
|
for (const layer of layers) {
|
||||||
|
for (const [key, value] of Object.entries(layer)) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
if (SKIP_HEURISTIC_KEYS.has(lower)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TITLE_KEY_PATTERN.test(key) || URL_KEY_PATTERN.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = pickString(value);
|
||||||
|
if (!text || text.length < 8) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (reserved.has(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (looksLikeUrl(text) && text.length < 120) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!best || text.length > best.length) {
|
||||||
|
best = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickShortTitleFallback(
|
||||||
|
layers: Record<string, unknown>[],
|
||||||
|
reserved: Set<string>,
|
||||||
|
): string | undefined {
|
||||||
|
let best: string | undefined;
|
||||||
|
for (const layer of layers) {
|
||||||
|
for (const [key, value] of Object.entries(layer)) {
|
||||||
|
if (URL_KEY_PATTERN.test(key) || CONTENT_KEY_PATTERN.test(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = pickString(value);
|
||||||
|
if (!text || text.length > 200 || text.length < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (reserved.has(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (looksLikeUrl(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!best || text.length < best.length) {
|
||||||
|
best = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||||
|
const record = asRecord(raw);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = collectNestedRecords(record);
|
||||||
|
|
||||||
|
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
|
||||||
|
|
||||||
|
let title =
|
||||||
|
pickFromLayers(layers, TITLE_ALIASES) ??
|
||||||
|
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
|
||||||
|
if (!title && indexLabel) {
|
||||||
|
title = `结果 ${indexLabel}`;
|
||||||
|
}
|
||||||
|
let content =
|
||||||
|
pickFromLayers(layers, CONTENT_ALIASES) ??
|
||||||
|
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
|
||||||
|
let url =
|
||||||
|
pickFromLayers(layers, URL_ALIASES) ??
|
||||||
|
pickByKeyPattern(layers, URL_KEY_PATTERN);
|
||||||
|
let source =
|
||||||
|
pickFromLayers(layers, SOURCE_ALIASES) ??
|
||||||
|
pickByKeyPattern(layers, SOURCE_KEY_PATTERN, {
|
||||||
|
excludeUrl: true,
|
||||||
|
maxLength: 120,
|
||||||
|
});
|
||||||
|
const date =
|
||||||
|
pickFromLayers(layers, DATE_ALIASES) ??
|
||||||
|
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||||
|
|
||||||
|
const reserved = new Set<string>();
|
||||||
|
for (const value of [title, content, url, source, date]) {
|
||||||
|
if (value) {
|
||||||
|
reserved.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
content = pickLongestContentFallback(layers, reserved);
|
||||||
|
if (content) {
|
||||||
|
reserved.add(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!title) {
|
||||||
|
title = pickShortTitleFallback(layers, reserved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
|
||||||
|
if (!title && !content && !url && !indexLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
snippet: content,
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
published_date: date,
|
||||||
|
publishedDate: date,
|
||||||
|
date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRawResults(payload: unknown, depth = 0): unknown[] | null {
|
||||||
|
if (depth > 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload.length > 0 ? payload : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = asRecord(payload);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of RESULT_LIST_KEYS) {
|
||||||
|
const nested = record[key];
|
||||||
|
if (Array.isArray(nested) && nested.length > 0) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of WRAPPER_KEYS) {
|
||||||
|
const nested = record[key];
|
||||||
|
const found = extractRawResults(nested, depth + 1);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResultPayload(
|
||||||
|
result: string | Record<string, unknown> | undefined,
|
||||||
|
): unknown {
|
||||||
|
if (result === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof result === "string") {
|
||||||
|
const trimmed = result.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the tool return value looks like a retrieval hit list. */
|
||||||
|
export function hasSearchResultPayload(
|
||||||
|
result: string | Record<string, unknown> | undefined,
|
||||||
|
): boolean {
|
||||||
|
return normalizeSearchResults(result).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSearchResults(
|
||||||
|
result: string | Record<string, unknown> | undefined,
|
||||||
|
): SearchResultItem[] {
|
||||||
|
const payload = parseResultPayload(result);
|
||||||
|
const rawList = extractRawResults(payload);
|
||||||
|
if (!rawList) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: SearchResultItem[] = [];
|
||||||
|
for (const raw of rawList) {
|
||||||
|
const item = normalizeSearchResultItem(raw);
|
||||||
|
if (item) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSearchQuery(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
result: string | Record<string, unknown> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (typeof args.query === "string" && args.query.trim()) {
|
||||||
|
return args.query.trim();
|
||||||
|
}
|
||||||
|
const payload = asRecord(parseResultPayload(result));
|
||||||
|
if (!payload) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const queryKeys = ["query", "q", "keyword", "keywords", "search_query"] as const;
|
||||||
|
for (const key of queryKeys) {
|
||||||
|
const value = payload[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
|
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
||||||
|
|
||||||
interface GenericMessageGroup<T = string> {
|
interface GenericMessageGroup<T = string> {
|
||||||
type: T;
|
type: T;
|
||||||
id: string | undefined;
|
id: string | undefined;
|
||||||
@@ -141,21 +143,63 @@ export function groupMessages<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractTextFromMessage(message: Message) {
|
export function extractTextFromMessage(message: Message) {
|
||||||
if (typeof message.content === "string") {
|
const payload = extractToolResultPayload(message);
|
||||||
return (
|
if (typeof payload === "string") {
|
||||||
splitInlineReasoningFromAIMessage(message)?.content ??
|
return payload;
|
||||||
message.content.trim()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (payload && typeof payload === "object") {
|
||||||
return message.content
|
try {
|
||||||
.map((content) => (content.type === "text" ? content.text : ""))
|
return JSON.stringify(payload);
|
||||||
.join("\n")
|
} catch {
|
||||||
.trim();
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
|
||||||
|
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
|
||||||
|
*/
|
||||||
|
export function extractToolResultPayload(
|
||||||
|
message: Message,
|
||||||
|
): string | Record<string, unknown> | undefined {
|
||||||
|
const { content } = message;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
return trimmed || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||||
|
return content as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const textParts: string[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (!part || typeof part !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const block = part as Record<string, unknown>;
|
||||||
|
if (block.type === "text" && typeof block.text === "string") {
|
||||||
|
textParts.push(block.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.type === "json" && block.json && typeof block.json === "object") {
|
||||||
|
return block.json as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (hasSearchResultPayload(block)) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const joined = textParts.join("\n").trim();
|
||||||
|
return joined || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||||
|
|
||||||
function splitInlineReasoning(content: string) {
|
function splitInlineReasoning(content: string) {
|
||||||
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
export function findToolCallResult(
|
||||||
|
toolCallId: string,
|
||||||
|
messages: Message[],
|
||||||
|
toolName?: string,
|
||||||
|
): string | Record<string, unknown> | undefined {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||||||
const content = extractTextFromMessage(message);
|
continue;
|
||||||
if (content) {
|
}
|
||||||
return content;
|
const payload = extractToolResultPayload(message);
|
||||||
|
if (payload !== undefined) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||||||
|
if (toolName) {
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== "tool" || message.name !== toolName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const payload = extractToolResultPayload(message);
|
||||||
|
if (payload !== undefined && hasSearchResultPayload(payload)) {
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
|
|||||||
|
|
||||||
export async function createScheduledTask(data: {
|
export async function createScheduledTask(data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
schedule: string;
|
schedule: string;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
@@ -203,6 +204,7 @@ export async function updateScheduledTask(
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
schedule: string;
|
schedule: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -329,6 +331,7 @@ export async function adminUpdateScheduledTask(
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
data: Partial<{
|
data: Partial<{
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
schedule: string;
|
schedule: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ScheduledTaskSubscription = {
|
|||||||
export type ScheduledTask = {
|
export type ScheduledTask = {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
schedule_text: string;
|
schedule_text: string;
|
||||||
cron_expr: string;
|
cron_expr: string;
|
||||||
@@ -27,6 +28,8 @@ export type ScheduledTask = {
|
|||||||
scheduler_thread_id: string;
|
scheduler_thread_id: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
published_at?: string | null;
|
published_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
role?: ScheduledTaskRole;
|
role?: ScheduledTaskRole;
|
||||||
_subscription?: ScheduledTaskSubscription | null;
|
_subscription?: ScheduledTaskSubscription | null;
|
||||||
creator_user_id?: string | null;
|
creator_user_id?: string | null;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streamSpeedMeterEnabled: false,
|
streamSpeedMeterEnabled: true,
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
@@ -29,7 +29,7 @@ export interface LocalSettings {
|
|||||||
display: {
|
display: {
|
||||||
/**
|
/**
|
||||||
* Whether to render a small token/s speed meter while a streaming
|
* Whether to render a small token/s speed meter while a streaming
|
||||||
* AI reply is in flight. Default off; controlled by the user from
|
* AI reply is in flight. Default on; controlled by the user from
|
||||||
* the appearance settings page.
|
* the appearance settings page.
|
||||||
*/
|
*/
|
||||||
streamSpeedMeterEnabled: boolean;
|
streamSpeedMeterEnabled: boolean;
|
||||||
|
|||||||
@@ -135,6 +135,22 @@ export async function installSkillUpload(
|
|||||||
return response.json() as Promise<InstallSkillResponse>;
|
return response.json() as Promise<InstallSkillResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReconcileCustomSkillsResponse {
|
||||||
|
adopted: string[];
|
||||||
|
already_registered: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileCustomSkills(): Promise<ReconcileCustomSkillsResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/reconcile-custom`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to reconcile skills directory");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<ReconcileCustomSkillsResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetSkillPublishedResponse {
|
export interface SetSkillPublishedResponse {
|
||||||
name: string;
|
name: string;
|
||||||
owner_user_id: string | null;
|
owner_user_id: string | null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getSkill,
|
getSkill,
|
||||||
getSkillContent,
|
getSkillContent,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
|
reconcileCustomSkills,
|
||||||
setSkillDetail,
|
setSkillDetail,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
@@ -95,6 +96,16 @@ export function useInstallSkillUpload() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useReconcileCustomSkills() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => reconcileCustomSkills(),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSetSkillPublished() {
|
export function useSetSkillPublished() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export function useThreadStream({
|
|||||||
: context.mode === "thinking"
|
: context.mode === "thinking"
|
||||||
? "low"
|
? "low"
|
||||||
: undefined),
|
: undefined),
|
||||||
|
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
writing_artifact_path?: string;
|
writing_artifact_path?: string;
|
||||||
|
memory_injection_enabled?: boolean;
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
function filenameFromContentDisposition(header: string | null): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||||
|
if (star) {
|
||||||
|
const raw = star[1].replace(/["']/g, "").trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||||
|
return plain ? plain[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFallbackFilename(title: string | undefined): string {
|
||||||
|
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
||||||
|
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "导出 Word 失败" }));
|
||||||
|
const detail = error.detail;
|
||||||
|
if (typeof detail === "string") throw new Error(detail);
|
||||||
|
throw new Error("导出 Word 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const filename =
|
||||||
|
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||||
|
fallbackFilename;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
|
||||||
|
export async function downloadAIWritingDocx(options: {
|
||||||
|
title?: string;
|
||||||
|
draftMarkdown: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const draftMarkdown = options.draftMarkdown.trim();
|
||||||
|
if (!draftMarkdown) {
|
||||||
|
throw new Error("文档内容为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: options.title?.trim() || undefined,
|
||||||
|
draft_markdown: draftMarkdown,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
|||||||
interface AIWritingFormProps {
|
interface AIWritingFormProps {
|
||||||
onSubmit: (req: AIWritingRequest) => void;
|
onSubmit: (req: AIWritingRequest) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
|
||||||
|
hideAdvancedFields?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelOption {
|
interface ModelOption {
|
||||||
@@ -31,7 +33,14 @@ const INPUT_CLASS =
|
|||||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||||
'focus-visible:ring-ring disabled:opacity-50';
|
'focus-visible:ring-ring disabled:opacity-50';
|
||||||
|
|
||||||
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
const DEFAULT_KEYWORD_COUNT = 6;
|
||||||
|
const DEFAULT_AUDIENCE = '普通读者';
|
||||||
|
|
||||||
|
export function AIWritingForm({
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
hideAdvancedFields = false,
|
||||||
|
}: AIWritingFormProps) {
|
||||||
const [intent, setIntent] = useState('');
|
const [intent, setIntent] = useState('');
|
||||||
const [articleType, setArticleType] = useState('analysis');
|
const [articleType, setArticleType] = useState('analysis');
|
||||||
const [audience, setAudience] = useState('普通读者');
|
const [audience, setAudience] = useState('普通读者');
|
||||||
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!intent.trim()) return;
|
if (!intent.trim()) return;
|
||||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||||
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
|
const keywordCount = hideAdvancedFields
|
||||||
|
? DEFAULT_KEYWORD_COUNT
|
||||||
|
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
|
||||||
onSubmit({
|
onSubmit({
|
||||||
userIntent: intent.trim(),
|
userIntent: intent.trim(),
|
||||||
articleType,
|
articleType,
|
||||||
targetAudience: audience.trim() || '普通读者',
|
targetAudience: hideAdvancedFields
|
||||||
|
? DEFAULT_AUDIENCE
|
||||||
|
: audience.trim() || DEFAULT_AUDIENCE,
|
||||||
wordCountTarget: wordCount,
|
wordCountTarget: wordCount,
|
||||||
keywordCount,
|
keywordCount,
|
||||||
writingMode: strictMode ? 'strict' : 'loose',
|
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||||
modelName: modelName || undefined,
|
modelName: modelName || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{!hideAdvancedFields && (
|
||||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
<>
|
||||||
<input
|
<div className="space-y-1.5">
|
||||||
type="number"
|
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||||
min={1}
|
<input
|
||||||
max={10}
|
type="number"
|
||||||
step={1}
|
min={1}
|
||||||
className={INPUT_CLASS}
|
max={10}
|
||||||
placeholder="6"
|
step={1}
|
||||||
value={keywordCountInput}
|
className={INPUT_CLASS}
|
||||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
placeholder="6"
|
||||||
onBlur={handleKeywordCountBlur}
|
value={keywordCountInput}
|
||||||
disabled={disabled}
|
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||||
/>
|
onBlur={handleKeywordCountBlur}
|
||||||
<p className="text-[11px] text-muted-foreground/70">
|
disabled={disabled}
|
||||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
/>
|
||||||
</p>
|
<p className="text-[11px] text-muted-foreground/70">
|
||||||
</div>
|
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="strict-mode"
|
id="strict-mode"
|
||||||
checked={strictMode}
|
checked={strictMode}
|
||||||
onChange={(e) => setStrictMode(e.target.checked)}
|
onChange={(e) => setStrictMode(e.target.checked)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||||
value={audience}
|
value={audience}
|
||||||
onChange={(e) => setAudience(e.target.value)}
|
onChange={(e) => setAudience(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{models.length > 0 && (
|
{models.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
in_progress: { label: '进行中', color: 'text-blue-500' },
|
in_progress: { label: '进行中', color: 'text-blue-500' },
|
||||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||||
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|||||||
error: { label: '错误', color: 'text-destructive' },
|
error: { label: '错误', color: 'text-destructive' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatRelative(isoStr: string): string {
|
export function formatRelative(isoStr: string): string {
|
||||||
const ms = Date.now() - new Date(isoStr).getTime();
|
const ms = Date.now() - new Date(isoStr).getTime();
|
||||||
const mins = Math.floor(ms / 60000);
|
const mins = Math.floor(ms / 60000);
|
||||||
if (mins < 1) return '刚刚';
|
if (mins < 1) return '刚刚';
|
||||||
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
|
|||||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionItemProps {
|
export interface SessionItemProps {
|
||||||
session: AIWritingSessionSummary;
|
session: AIWritingSessionSummary;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
@@ -33,7 +33,7 @@ interface SessionItemProps {
|
|||||||
onRename: (title: string) => void;
|
onRename: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(session.title);
|
const [editValue, setEditValue] = useState(session.title);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
error: '出错了',
|
error: '出错了',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AIWritingPanel() {
|
export function AIWritingPanel({
|
||||||
|
hideAdvancedFields = false,
|
||||||
|
}: {
|
||||||
|
hideAdvancedFields?: boolean;
|
||||||
|
} = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setArtifact } = useCanvasGraphContext();
|
const { setArtifact } = useCanvasGraphContext();
|
||||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||||
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
|
|||||||
reset();
|
reset();
|
||||||
}, [reset]);
|
}, [reset]);
|
||||||
|
|
||||||
|
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
|
||||||
|
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
|
||||||
|
const handleSelectHistoryRef = useRef(handleSelectHistory);
|
||||||
|
useEffect(() => {
|
||||||
|
handleSelectHistoryRef.current = handleSelectHistory;
|
||||||
|
}, [handleSelectHistory]);
|
||||||
|
|
||||||
|
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = sessionStorage.getItem('ai-writing:pending-session');
|
||||||
|
if (!raw) return;
|
||||||
|
sessionStorage.removeItem('ai-writing:pending-session');
|
||||||
|
try {
|
||||||
|
const session = JSON.parse(raw) as AIWritingSessionSummary;
|
||||||
|
handleSelectHistoryRef.current(session);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // 仅挂载时执行一次
|
||||||
|
|
||||||
|
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
|
||||||
|
useEffect(() => {
|
||||||
|
function onOpenSession(e: Event) {
|
||||||
|
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
|
||||||
|
if (session) handleSelectHistoryRef.current(session);
|
||||||
|
}
|
||||||
|
window.addEventListener('ai-writing:open-session', onOpenSession);
|
||||||
|
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||||
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
|
|||||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||||
{/* 未开始时显示表单 */}
|
{/* 未开始时显示表单 */}
|
||||||
{isIdle && (
|
{isIdle && (
|
||||||
<AIWritingForm onSubmit={startWriting} />
|
<AIWritingForm
|
||||||
|
onSubmit={startWriting}
|
||||||
|
hideAdvancedFields={hideAdvancedFields}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 意图解析:流式 → 结果卡片承接。
|
{/* 意图解析:流式 → 结果卡片承接。
|
||||||
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
|
|||||||
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
|
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
|
||||||
)}
|
)}
|
||||||
{state.parsedIntent && !state.intentStreamingText && (
|
{state.parsedIntent && !state.intentStreamingText && (
|
||||||
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} />
|
<IntentResultCard
|
||||||
|
intent={state.parsedIntent}
|
||||||
|
articleTypes={articleTypes}
|
||||||
|
hideTargetAudience={hideAdvancedFields}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 运行中或完成后显示时间线 */}
|
{/* 运行中或完成后显示时间线 */}
|
||||||
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
|
|||||||
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
|
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
|
||||||
</span>
|
</span>
|
||||||
</ReqRow>
|
</ReqRow>
|
||||||
<ReqRow label="目标读者">
|
{!hideAdvancedFields && (
|
||||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
<ReqRow label="目标读者">
|
||||||
</ReqRow>
|
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||||
|
</ReqRow>
|
||||||
|
)}
|
||||||
<ReqRow label="目标字数">
|
<ReqRow label="目标字数">
|
||||||
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||||||
</ReqRow>
|
</ReqRow>
|
||||||
<ReqRow label="检索关键词数量">
|
{!hideAdvancedFields && (
|
||||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
<>
|
||||||
</ReqRow>
|
<ReqRow label="检索关键词数量">
|
||||||
<ReqRow label="写作模式">
|
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||||
<span className="text-muted-foreground">
|
</ReqRow>
|
||||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
<ReqRow label="写作模式">
|
||||||
</span>
|
<span className="text-muted-foreground">
|
||||||
</ReqRow>
|
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||||
|
</span>
|
||||||
|
</ReqRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{state.request.modelName && (
|
{state.request.modelName && (
|
||||||
<ReqRow label="写作模型">
|
<ReqRow label="写作模型">
|
||||||
<span className="text-muted-foreground">{state.request.modelName}</span>
|
<span className="text-muted-foreground">{state.request.modelName}</span>
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
|
|||||||
interface IntentResultCardProps {
|
interface IntentResultCardProps {
|
||||||
intent: ParsedIntent;
|
intent: ParsedIntent;
|
||||||
articleTypes: ArticleType[];
|
articleTypes: ArticleType[];
|
||||||
|
hideTargetAudience?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
|
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
|
||||||
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
|
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
|
||||||
*/
|
*/
|
||||||
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
|
export function IntentResultCard({
|
||||||
|
intent,
|
||||||
|
articleTypes,
|
||||||
|
hideTargetAudience = false,
|
||||||
|
}: IntentResultCardProps) {
|
||||||
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
|
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
|
||||||
const typeLabel =
|
const typeLabel =
|
||||||
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
|
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
|
||||||
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2.5 space-y-1.5 text-xs">
|
<div className="px-3 py-2.5 space-y-1.5 text-xs">
|
||||||
<Row label="文章类型" value={typeLabel} />
|
<Row label="文章类型" value={typeLabel} />
|
||||||
<Row label="目标读者" value={intent.targetAudience} />
|
{!hideTargetAudience && (
|
||||||
|
<Row label="目标读者" value={intent.targetAudience} />
|
||||||
|
)}
|
||||||
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
|
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
|
||||||
{intent.refinedIntent && (
|
{intent.refinedIntent && (
|
||||||
<Row label="精炼意图" value={intent.refinedIntent} multiline />
|
<Row label="精炼意图" value={intent.refinedIntent} multiline />
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
<WritingToolbar editor={editor} />
|
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
|
||||||
|
|
||||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||||
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
Quote,
|
Quote,
|
||||||
Code,
|
Code,
|
||||||
Pilcrow,
|
Pilcrow,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +25,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||||
|
import { downloadAIWritingDocx } from "../../api/writing-export";
|
||||||
|
|
||||||
interface ToolbarButtonProps {
|
interface ToolbarButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -60,11 +65,35 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
|
|||||||
|
|
||||||
interface WritingToolbarProps {
|
interface WritingToolbarProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
|
/** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */
|
||||||
|
markdown?: string;
|
||||||
|
/** 文档标题,用作 .docx 文件名与封面 */
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WritingToolbar({ editor }: WritingToolbarProps) {
|
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
|
const canExport = !exporting && !!markdown?.trim();
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!canExport) return;
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await downloadAIWritingDocx({
|
||||||
|
title,
|
||||||
|
draftMarkdown: markdown ?? "",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[writing-export] failed", err);
|
||||||
|
toast.error("导出 Word 失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
||||||
{/* Block type */}
|
{/* Block type */}
|
||||||
@@ -173,8 +202,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) {
|
|||||||
<Strikethrough className="h-3.5 w-3.5" />
|
<Strikethrough className="h-3.5 w-3.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
{/* Model selector — pushed to the right */}
|
{/* Right side: export + model selector */}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleExport();
|
||||||
|
}}
|
||||||
|
disabled={!canExport}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
导出 Word
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||||
|
|
||||||
<CanvasModelSelector />
|
<CanvasModelSelector />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
|
|||||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||||
|
|
||||||
export default function AIWritingPage() {
|
export default function AIWritingPage({
|
||||||
|
hideAdvancedFields = false,
|
||||||
|
}: {
|
||||||
|
hideAdvancedFields?: boolean;
|
||||||
|
} = {}) {
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
|
|||||||
>
|
>
|
||||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<AIWritingPanel />
|
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Markdown → Word(.docx) 导出工具。
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。
|
||||||
|
* - 文档结构:封面标题(无 H1 时补) → 正文。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AlignmentType,
|
||||||
|
Document,
|
||||||
|
ExternalHyperlink,
|
||||||
|
HeadingLevel,
|
||||||
|
LevelFormat,
|
||||||
|
Packer,
|
||||||
|
Paragraph,
|
||||||
|
TextRun,
|
||||||
|
} from "docx";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
|
/** 标题层级(H1~H3) */
|
||||||
|
type HeadingLevelNum = 1 | 2 | 3;
|
||||||
|
|
||||||
|
interface ParsedHeading {
|
||||||
|
level: HeadingLevelNum;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: "heading"; level: HeadingLevelNum; text: string }
|
||||||
|
| { kind: "paragraph"; text: string }
|
||||||
|
| { kind: "blockquote"; text: string }
|
||||||
|
| { kind: "code"; code: string }
|
||||||
|
| { kind: "list"; ordered: boolean; items: string[] };
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RE_HEADING = /^(#{1,3})\s+(.*)$/;
|
||||||
|
const RE_FENCE = /^```/;
|
||||||
|
const RE_QUOTE = /^>\s?/;
|
||||||
|
const RE_UL = /^[-*]\s+/;
|
||||||
|
const RE_OL = /^\d+\.\s+/;
|
||||||
|
const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/;
|
||||||
|
|
||||||
|
function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } {
|
||||||
|
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
const headings: ParsedHeading[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RE_FENCE.test(line)) {
|
||||||
|
const buf: string[] = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !RE_FENCE.test(lines[i])) {
|
||||||
|
buf.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < lines.length) i++; // 跳过闭合 ```
|
||||||
|
blocks.push({ kind: "code", code: buf.join("\n") });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hm = RE_HEADING.exec(line);
|
||||||
|
if (hm) {
|
||||||
|
const level = hm[1].length as HeadingLevelNum;
|
||||||
|
const text = hm[2].trim();
|
||||||
|
headings.push({ level, text });
|
||||||
|
blocks.push({ kind: "heading", level, text });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RE_QUOTE.test(line)) {
|
||||||
|
const buf: string[] = [];
|
||||||
|
while (i < lines.length && RE_QUOTE.test(lines[i])) {
|
||||||
|
buf.push(lines[i].replace(RE_QUOTE, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ kind: "blockquote", text: buf.join("\n") });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RE_UL.test(line)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && RE_UL.test(lines[i])) {
|
||||||
|
items.push(lines[i].replace(RE_UL, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ kind: "list", ordered: false, items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RE_OL.test(line)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && RE_OL.test(lines[i])) {
|
||||||
|
items.push(lines[i].replace(RE_OL, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ kind: "list", ordered: true, items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 段落:合并相邻非空、非块起始行
|
||||||
|
const buf: string[] = [line];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) {
|
||||||
|
buf.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ kind: "paragraph", text: buf.join("\n") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocks, headings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 行内格式 → TextRun / Hyperlink
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */
|
||||||
|
const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g;
|
||||||
|
|
||||||
|
type InlineChild = TextRun | ExternalHyperlink;
|
||||||
|
|
||||||
|
function parseInline(text: string): InlineChild[] {
|
||||||
|
if (!text) return [new TextRun({ text: "" })];
|
||||||
|
|
||||||
|
const out: InlineChild[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
RE_INLINE.lastIndex = 0;
|
||||||
|
|
||||||
|
while ((m = RE_INLINE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
out.push(new TextRun({ text: text.slice(last, m.index) }));
|
||||||
|
}
|
||||||
|
const tok = m[0];
|
||||||
|
if (tok.startsWith("**")) {
|
||||||
|
out.push(new TextRun({ text: tok.slice(2, -2), bold: true }));
|
||||||
|
} else if (tok.startsWith("`")) {
|
||||||
|
out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" }));
|
||||||
|
} else if (tok.startsWith("[")) {
|
||||||
|
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok);
|
||||||
|
if (linkMatch) {
|
||||||
|
out.push(
|
||||||
|
new ExternalHyperlink({
|
||||||
|
link: linkMatch[2],
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: linkMatch[1],
|
||||||
|
color: "0563C1",
|
||||||
|
underline: {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out.push(new TextRun({ text: tok }));
|
||||||
|
}
|
||||||
|
} else if (tok.startsWith("*") || tok.startsWith("_")) {
|
||||||
|
out.push(new TextRun({ text: tok.slice(1, -1), italics: true }));
|
||||||
|
}
|
||||||
|
last = RE_INLINE.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
out.push(new TextRun({ text: text.slice(last) }));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Block → Paragraph[]
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DOCX_HEADING: Record<HeadingLevelNum, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||||
|
1: HeadingLevel.HEADING_1,
|
||||||
|
2: HeadingLevel.HEADING_2,
|
||||||
|
3: HeadingLevel.HEADING_3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function blockToParagraphs(b: Block): Paragraph[] {
|
||||||
|
switch (b.kind) {
|
||||||
|
case "heading": {
|
||||||
|
return [
|
||||||
|
new Paragraph({
|
||||||
|
heading: DOCX_HEADING[b.level],
|
||||||
|
children: parseInline(b.text),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "paragraph": {
|
||||||
|
return [
|
||||||
|
new Paragraph({
|
||||||
|
children: parseInline(b.text),
|
||||||
|
spacing: { line: 360, after: 120 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "blockquote": {
|
||||||
|
return [
|
||||||
|
new Paragraph({
|
||||||
|
children: parseInline(b.text),
|
||||||
|
indent: { left: 480 },
|
||||||
|
spacing: { line: 360, after: 120 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "code": {
|
||||||
|
return b.code.split("\n").map(
|
||||||
|
(line) =>
|
||||||
|
new Paragraph({
|
||||||
|
children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "list": {
|
||||||
|
return b.items.map(
|
||||||
|
(item) =>
|
||||||
|
new Paragraph({
|
||||||
|
numbering: {
|
||||||
|
reference: b.ordered ? "ol" : "ul",
|
||||||
|
level: 0,
|
||||||
|
},
|
||||||
|
children: parseInline(item),
|
||||||
|
spacing: { line: 320 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 文件名工具
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
// Windows 非法字符 + 控制字符 → 下划线
|
||||||
|
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackFilename(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFilename(title: string | undefined): string {
|
||||||
|
const base = title ? sanitizeFilename(title) : "";
|
||||||
|
return `${base || fallbackFilename()}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 主入口
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 markdown 文档导出为 .docx 并触发浏览器下载。
|
||||||
|
*
|
||||||
|
* @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown)
|
||||||
|
* @param title 文档标题,用作文件名与封面(空则用时间戳兜底)
|
||||||
|
*/
|
||||||
|
export async function exportMarkdownToDocx(
|
||||||
|
markdown: string,
|
||||||
|
title?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { blocks, headings } = parseMarkdown(markdown ?? "");
|
||||||
|
|
||||||
|
// 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1
|
||||||
|
const coverText =
|
||||||
|
(title && title.trim()) ||
|
||||||
|
headings.find((h) => h.level === 1)?.text ||
|
||||||
|
"未命名文档";
|
||||||
|
|
||||||
|
const children: Paragraph[] = [];
|
||||||
|
|
||||||
|
// 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复)
|
||||||
|
const hasFirstH1 = headings[0]?.level === 1;
|
||||||
|
if (!hasFirstH1) {
|
||||||
|
children.push(
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { before: 240, after: 240 },
|
||||||
|
children: [new TextRun({ text: coverText, bold: true, size: 36 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正文
|
||||||
|
for (const b of blocks) {
|
||||||
|
children.push(...blockToParagraphs(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
creator: "DeerFlow AI 写作",
|
||||||
|
title: coverText,
|
||||||
|
styles: {
|
||||||
|
default: {
|
||||||
|
document: {
|
||||||
|
run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
numbering: {
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
reference: "ul",
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
level: 0,
|
||||||
|
format: LevelFormat.BULLET,
|
||||||
|
text: "•",
|
||||||
|
alignment: AlignmentType.LEFT,
|
||||||
|
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "ol",
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
level: 0,
|
||||||
|
format: LevelFormat.DECIMAL,
|
||||||
|
text: "%1.",
|
||||||
|
alignment: AlignmentType.LEFT,
|
||||||
|
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await Packer.toBlob(doc);
|
||||||
|
saveAs(blob, resolveFilename(title));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminUserHub } from "@/components/workspace/admin/admin-user-hub";
|
||||||
|
|
||||||
|
export default function AdminUserHubPage() {
|
||||||
|
return <AdminUserHub />;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||||
@@ -35,14 +36,33 @@ export default function AgentChatPage() {
|
|||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [showFollowups, setShowFollowups] = useState(false);
|
const [showFollowups, setShowFollowups] = useState(false);
|
||||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { agent_id } = useParams<{
|
const { agent_id } = useParams<{
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { agent } = useAgent(agent_id);
|
// If ChatPage stashed an opening message for this agent, skip the landing
|
||||||
|
// screen on first render so the user doesn't see a flash of the welcome UI
|
||||||
|
// before the auto-sent message takes over.
|
||||||
|
const hasPendingForThisAgent = () => {
|
||||||
|
if (typeof window === "undefined" || !agent_id) return false;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||||
|
if (!raw) return false;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed?.agentId === agent_id && !!parsed?.text;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [hasStartedConversation, setHasStartedConversation] = useState(
|
||||||
|
hasPendingForThisAgent,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
|
||||||
|
const agentDisplayName =
|
||||||
|
agent?.name ?? (isAgentLoading ? t.common.loading : "");
|
||||||
|
|
||||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||||
useThreadChat();
|
useThreadChat();
|
||||||
@@ -51,14 +71,10 @@ export default function AgentChatPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
setHasStartedConversation(false);
|
setHasStartedConversation(hasPendingForThisAgent());
|
||||||
}
|
}
|
||||||
}, [isNewThread, threadId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isNewThread, threadId, agent_id]);
|
||||||
// Auto-submit a message that the user typed on ChatPage right before
|
|
||||||
// selecting this agent. ChatPage stashed it in sessionStorage so we don't
|
|
||||||
// depend on router state (the next/navigation shim doesn't carry it).
|
|
||||||
const autoSubmitDoneRef = useRef(false);
|
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
@@ -104,28 +120,25 @@ export default function AgentChatPage() {
|
|||||||
[sendMessage, threadId, agent_id],
|
[sendMessage, threadId, agent_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ChatPage stashes the opening message in sessionStorage when the user picks
|
||||||
|
// an agent on the landing screen, then redirects here. Replay it once so the
|
||||||
|
// user doesn't have to re-type the question on the agent chat page.
|
||||||
|
const pendingMessageHandled = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoSubmitDoneRef.current) return;
|
if (pendingMessageHandled.current) return;
|
||||||
if (!isNewThread || hasStartedConversation) return;
|
if (!agent_id || !isNewThread || hasStartedConversation) return;
|
||||||
let raw: string | null = null;
|
let pending: { agentId?: string; text?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
raw = sessionStorage.getItem("pendingAgentMessage");
|
const raw = sessionStorage.getItem("pendingAgentMessage");
|
||||||
|
if (raw) pending = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
pending = null;
|
||||||
}
|
}
|
||||||
if (!raw) return;
|
if (!pending || pending.agentId !== agent_id || !pending.text) return;
|
||||||
try {
|
pendingMessageHandled.current = true;
|
||||||
const parsed = JSON.parse(raw) as { agentId?: string; text?: string };
|
sessionStorage.removeItem("pendingAgentMessage");
|
||||||
if (parsed.agentId !== agent_id || !parsed.text) {
|
handleSubmit({ text: pending.text, files: [] });
|
||||||
return;
|
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
|
||||||
}
|
|
||||||
autoSubmitDoneRef.current = true;
|
|
||||||
sessionStorage.removeItem("pendingAgentMessage");
|
|
||||||
handleSubmit({ text: parsed.text, files: [] });
|
|
||||||
} catch {
|
|
||||||
sessionStorage.removeItem("pendingAgentMessage");
|
|
||||||
}
|
|
||||||
}, [agent_id, hasStartedConversation, handleSubmit, isNewThread]);
|
|
||||||
|
|
||||||
const handleClarificationSubmit = useCallback(
|
const handleClarificationSubmit = useCallback(
|
||||||
(answer: string) => {
|
(answer: string) => {
|
||||||
@@ -143,12 +156,7 @@ export default function AgentChatPage() {
|
|||||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||||
: undefined;
|
: undefined;
|
||||||
// AgentChatPage skips the centered welcome/landing state entirely — when the
|
const isLandingState = isNewThread && !hasStartedConversation;
|
||||||
// user reaches this page they've already committed to an agent (either by
|
|
||||||
// selecting one on the homepage or by visiting the agent's URL), so we drop
|
|
||||||
// them straight into the conversation layout: messages area on top, input at
|
|
||||||
// the bottom. The agent badge in the header still identifies the agent.
|
|
||||||
const isLandingState = false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread }}>
|
||||||
@@ -165,8 +173,11 @@ export default function AgentChatPage() {
|
|||||||
{/* Agent badge */}
|
{/* Agent badge */}
|
||||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||||
<span className="text-xs font-medium">
|
<span
|
||||||
{agent?.name ?? agent_id}
|
className="max-w-[200px] truncate text-xs font-medium"
|
||||||
|
title={agentDisplayName}
|
||||||
|
>
|
||||||
|
{agentDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,8 +223,8 @@ export default function AgentChatPage() {
|
|||||||
"relative w-full",
|
"relative w-full",
|
||||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||||
isLandingState
|
isLandingState
|
||||||
? "max-w-(--container-width-sm)"
|
? "max-w-(--container-width-md)"
|
||||||
: "max-w-(--container-width-md)",
|
: "max-w-(--container-width-lg)",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||||
@@ -228,25 +239,32 @@ export default function AgentChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputBox
|
<div className="w-full -translate-y-4">
|
||||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
<InputBox
|
||||||
isNewThread={isNewThread}
|
className="w-full"
|
||||||
threadId={threadId}
|
isNewThread={isLandingState}
|
||||||
autoFocus={isNewThread}
|
threadId={threadId}
|
||||||
status={
|
autoFocus={isLandingState}
|
||||||
thread.error
|
status={
|
||||||
? "error"
|
thread.error
|
||||||
: thread.isLoading
|
? "error"
|
||||||
? "streaming"
|
: thread.isLoading
|
||||||
: "ready"
|
? "streaming"
|
||||||
}
|
: "ready"
|
||||||
context={settings.context}
|
}
|
||||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
context={settings.context}
|
||||||
onContextChange={(context) => setSettings("context", context)}
|
extraHeader={
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
isLandingState && (
|
||||||
onSubmit={handleSubmit}
|
<AgentWelcome agent={agent} agentName={agentDisplayName} />
|
||||||
onStop={handleStop}
|
)
|
||||||
/>
|
}
|
||||||
|
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||||
|
onContextChange={(context) => setSettings("context", context)}
|
||||||
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onStop={handleStop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||||
{t.common.notAvailableInDemoMode}
|
{t.common.notAvailableInDemoMode}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BookOpen, Plus, Send } from "lucide-react";
|
||||||
|
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function LightAppNoteQAPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
const [question, setQuestion] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!question.trim()) return;
|
||||||
|
setQuestion("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative h-full w-full overflow-hidden",
|
||||||
|
isDark ? "bg-slate-950" : "bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 背景光晕 */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: isDark
|
||||||
|
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
|
||||||
|
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主内容 */}
|
||||||
|
<div className="relative flex h-full flex-col items-center justify-center -translate-y-10">
|
||||||
|
<div className="flex w-full max-w-[520px] flex-col items-center gap-4 px-6">
|
||||||
|
|
||||||
|
{/* 笔记图标 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
|
||||||
|
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BookOpen className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 笔记标题 */}
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-semibold tracking-tight",
|
||||||
|
isDark ? "text-slate-100" : "text-slate-900",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
笔记01
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 新增来源按钮 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
|
||||||
|
isDark
|
||||||
|
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
|
||||||
|
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
新增来源
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 问答输入框 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-3 flex w-full items-center gap-3 rounded-full px-5 py-3.5",
|
||||||
|
isDark
|
||||||
|
? "bg-slate-800 ring-1 ring-white/10"
|
||||||
|
: "bg-white shadow-sm ring-1 ring-black/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0",
|
||||||
|
isDark ? "text-slate-500" : "text-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="开始问答"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 bg-transparent text-sm outline-none",
|
||||||
|
isDark
|
||||||
|
? "text-slate-200 placeholder:text-slate-500"
|
||||||
|
: "text-slate-700 placeholder:text-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{question.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||||
|
isDark
|
||||||
|
? "bg-sky-500 text-white hover:bg-sky-400"
|
||||||
|
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
|
||||||
|
)}
|
||||||
|
aria-label="发送"
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ export default function NewAgentPage() {
|
|||||||
if (threadPrepared) {
|
if (threadPrepared) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await getAPIClient().threads.create({ threadId });
|
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
|
||||||
setThreadPrepared(true);
|
setThreadPrepared(true);
|
||||||
}, [threadId, threadPrepared]);
|
}, [threadId, threadPrepared]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import WorkspaceRoutes from "./WorkspaceRoutes";
|
|||||||
import StrategyRoutes from "./StrategyRoutes";
|
import StrategyRoutes from "./StrategyRoutes";
|
||||||
import Header from "@/strategy-components/Header";
|
import Header from "@/strategy-components/Header";
|
||||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||||
|
import RoundtableRoutes from "./RoundtableRoutes";
|
||||||
|
|
||||||
function PageLayout() {
|
function PageLayout() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
@@ -43,6 +44,7 @@ function PageLayout() {
|
|||||||
/>
|
/>
|
||||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||||
|
<Route path="roundtable/*" element={<RoundtableRoutes />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||||
|
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||||
|
|
||||||
|
export default function RoundtableRoutes() {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-hidden">
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Navigate to="planning" replace />} />
|
||||||
|
<Route path="planning" element={<RoundtablePlanningPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="planning" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ function displayUsername(value?: string | null): string {
|
|||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
name: "",
|
name: "",
|
||||||
|
description: "",
|
||||||
schedule: "",
|
schedule: "",
|
||||||
timezone: "Asia/Shanghai",
|
timezone: "Asia/Shanghai",
|
||||||
prompt: "",
|
prompt: "",
|
||||||
@@ -116,9 +117,13 @@ export default function ScheduledTasksPage() {
|
|||||||
|
|
||||||
const sortedTasks = useMemo(
|
const sortedTasks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...tasks].sort((a, b) =>
|
[...tasks].sort((a, b) => {
|
||||||
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
|
// Newest task first. Fall back to next_run_at so tasks that lack a
|
||||||
),
|
// created_at (older rows / subscribed tasks) still order consistently.
|
||||||
|
const aKey = a.created_at ?? a.next_run_at ?? "";
|
||||||
|
const bKey = b.created_at ?? b.next_run_at ?? "";
|
||||||
|
return String(bKey).localeCompare(String(aKey));
|
||||||
|
}),
|
||||||
[tasks],
|
[tasks],
|
||||||
);
|
);
|
||||||
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||||
@@ -162,6 +167,7 @@ export default function ScheduledTasksPage() {
|
|||||||
setEditing(task);
|
setEditing(task);
|
||||||
setForm({
|
setForm({
|
||||||
name: task.name,
|
name: task.name,
|
||||||
|
description: task.description ?? "",
|
||||||
schedule: task.schedule_text,
|
schedule: task.schedule_text,
|
||||||
timezone: task.timezone,
|
timezone: task.timezone,
|
||||||
prompt: task.prompt,
|
prompt: task.prompt,
|
||||||
@@ -190,6 +196,7 @@ export default function ScheduledTasksPage() {
|
|||||||
if (creating) {
|
if (creating) {
|
||||||
const task = await actions.create.mutateAsync({
|
const task = await actions.create.mutateAsync({
|
||||||
name: form.name || "定时任务",
|
name: form.name || "定时任务",
|
||||||
|
description: form.description.trim() || null,
|
||||||
schedule: cronSchedule,
|
schedule: cronSchedule,
|
||||||
timezone: form.timezone || "Asia/Shanghai",
|
timezone: form.timezone || "Asia/Shanghai",
|
||||||
prompt: form.prompt,
|
prompt: form.prompt,
|
||||||
@@ -202,6 +209,7 @@ export default function ScheduledTasksPage() {
|
|||||||
taskId: editing.task_id,
|
taskId: editing.task_id,
|
||||||
data: {
|
data: {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
|
description: form.description.trim() || null,
|
||||||
schedule: cronSchedule,
|
schedule: cronSchedule,
|
||||||
timezone: form.timezone,
|
timezone: form.timezone,
|
||||||
prompt: form.prompt,
|
prompt: form.prompt,
|
||||||
@@ -324,9 +332,11 @@ export default function ScheduledTasksPage() {
|
|||||||
onClick={() => setSelectedTaskId(task.task_id)}
|
onClick={() => setSelectedTaskId(task.task_id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="font-medium leading-snug break-all">
|
||||||
<div className="truncate font-medium">{task.name}</div>
|
{task.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
{isRunningNow && (
|
{isRunningNow && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -351,7 +361,7 @@ export default function ScheduledTasksPage() {
|
|||||||
{task.schedule_text} · {task.timezone}
|
{task.schedule_text} · {task.timezone}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={task.enabled ? "default" : "secondary"}>
|
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
|
||||||
{task.enabled ? "启用" : "暂停"}
|
{task.enabled ? "启用" : "暂停"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -748,11 +758,11 @@ export default function ScheduledTasksPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="min-w-0">
|
||||||
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
<Input
|
<Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -760,6 +770,14 @@ export default function ScheduledTasksPage() {
|
|||||||
}
|
}
|
||||||
placeholder="任务名称"
|
placeholder="任务名称"
|
||||||
/>
|
/>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-16"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((value) => ({ ...value, description: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="任务描述(可选,将显示在公开页标题下方)"
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-muted-foreground text-xs">执行频率</p>
|
<p className="text-muted-foreground text-xs">执行频率</p>
|
||||||
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
|
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
|
||||||
@@ -780,15 +798,20 @@ export default function ScheduledTasksPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||||
<SelectValue placeholder="选择执行智能体" />
|
<SelectValue placeholder="选择执行智能体" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent
|
||||||
|
position="popper"
|
||||||
|
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||||
|
>
|
||||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<SelectItem key={agent.name} value={agent.name}>
|
<SelectItem key={agent.id} value={agent.id}>
|
||||||
{agent.name}
|
<span className="block truncate">
|
||||||
{agent.description ? ` — ${agent.description}` : ""}
|
{agent.name}
|
||||||
|
{agent.description ? ` — ${agent.description}` : ""}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
|
|||||||
import TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||||
|
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||||
|
|
||||||
export default function StrategyRoutes() {
|
export default function StrategyRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
|
|||||||
{/* 轻应用 */}
|
{/* 轻应用 */}
|
||||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||||
|
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||||
|
|
||||||
{/* 对话 */}
|
{/* 对话 */}
|
||||||
<Route path="chats" element={<ChatsPage />} />
|
<Route path="chats" element={<ChatsPage />} />
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ import SkillsPage from "./SkillsPage";
|
|||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||||
import AdminMemoryPage from "./AdminMemoryPage";
|
import AdminMemoryPage from "./AdminMemoryPage";
|
||||||
|
import AdminUserHubPage from "./AdminUserHubPage";
|
||||||
import TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||||
|
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||||
|
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||||
|
|
||||||
export function WorkspaceLayout({
|
export function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
@@ -177,6 +181,26 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="ai-writing"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<AIWritingPage hideAdvancedFields />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="roundtable"
|
||||||
|
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="roundtable/planning"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<RoundtablePlanningPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="scheduled"
|
path="scheduled"
|
||||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||||
@@ -197,18 +221,32 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Unified admin user hub */}
|
||||||
<Route
|
<Route
|
||||||
path="admin/users"
|
path="admin"
|
||||||
element={
|
element={
|
||||||
getStoredAuth()?.system_role === "admin" ? (
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
<WorkspaceLayout {...layoutProps}>
|
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||||
<UsersPage />
|
<AdminUserHubPage />
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/page/workspace/chats/new" replace />
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Legacy redirects */}
|
||||||
|
<Route
|
||||||
|
path="admin/users"
|
||||||
|
element={<Navigate to="/page/workspace/admin" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/user-activity"
|
||||||
|
element={<Navigate to="/page/workspace/admin" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/memory"
|
||||||
|
element={<Navigate to="/page/workspace/admin" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="admin/scheduled-tasks"
|
path="admin/scheduled-tasks"
|
||||||
element={
|
element={
|
||||||
@@ -221,30 +259,6 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="admin/user-activity"
|
|
||||||
element={
|
|
||||||
getStoredAuth()?.system_role === "admin" ? (
|
|
||||||
<WorkspaceLayout {...layoutProps}>
|
|
||||||
<AdminUserActivityPage />
|
|
||||||
</WorkspaceLayout>
|
|
||||||
) : (
|
|
||||||
<Navigate to="/page/workspace/chats/new" replace />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/memory"
|
|
||||||
element={
|
|
||||||
getStoredAuth()?.system_role === "admin" ? (
|
|
||||||
<WorkspaceLayout {...layoutProps}>
|
|
||||||
<AdminMemoryPage />
|
|
||||||
</WorkspaceLayout>
|
|
||||||
) : (
|
|
||||||
<Navigate to="/page/workspace/chats/new" replace />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
||||||
|
* built-in agent and no per-call coordinator: one /init creates a thread
|
||||||
|
* tied to the built-in `roundtable-intent` agent, and each /stream POST is
|
||||||
|
* one user-turn of the clarification dialogue. The terminal status frame
|
||||||
|
* carries either `asking` (more turns needed) or `done` (structured summary
|
||||||
|
* available — hand off to Step 2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IntentInitResponse {
|
||||||
|
status: "success";
|
||||||
|
agent_id: string;
|
||||||
|
thread_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fresh thread for an intent-clarification session.
|
||||||
|
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
|
||||||
|
*/
|
||||||
|
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`init intent failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as IntentInitResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured task summary the intent agent emits when it judges the user's
|
||||||
|
* task is fully specified. The frontend feeds this into Step 2's initial
|
||||||
|
* leader prompt.
|
||||||
|
*/
|
||||||
|
export interface IntentSummary {
|
||||||
|
/** One-line statement of what the user wants accomplished. */
|
||||||
|
objective: string;
|
||||||
|
/** Hard constraints (budget / time / compliance / scope). At least 1 entry. */
|
||||||
|
constraints: string[];
|
||||||
|
/** Optional assumptions the agent is making on the user's behalf. */
|
||||||
|
assumptions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same phases as streamMultiAgent so the UI can reuse one phase-label mapper.
|
||||||
|
* `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`;
|
||||||
|
* `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or
|
||||||
|
* similar tool call (which usually means the run is about to interrupt and
|
||||||
|
* the user-visible text won't grow further this turn).
|
||||||
|
*/
|
||||||
|
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
|
||||||
|
|
||||||
|
export interface StreamIntentRequest {
|
||||||
|
threadId: string;
|
||||||
|
message: string;
|
||||||
|
model?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
|
||||||
|
onTextDelta?: (text: string, messageId: string) => void;
|
||||||
|
/**
|
||||||
|
* Fires whenever the inferred SSE phase changes. The UI uses this to flip
|
||||||
|
* the placeholder bubble's caption from "调度中" → "已连接,等待响应" →
|
||||||
|
* "正在输出" so users see what's happening during the inevitable LLM
|
||||||
|
* first-token wait.
|
||||||
|
*/
|
||||||
|
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamIntentResult {
|
||||||
|
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */
|
||||||
|
status: "asking" | "done" | "error";
|
||||||
|
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
|
||||||
|
content: string;
|
||||||
|
/** Populated only when status === "done". */
|
||||||
|
summary: IntentSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognise the terminal status frame the backend appends after the upstream
|
||||||
|
* LangGraph stream finishes. Must carry `status` and `content`; `summary`
|
||||||
|
* is optional.
|
||||||
|
*/
|
||||||
|
function isFinalIntentFrame(value: unknown): value is {
|
||||||
|
status: "asking" | "done" | "error";
|
||||||
|
content?: string;
|
||||||
|
summary?: IntentSummary | null;
|
||||||
|
} {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
if (!("status" in value) || !("content" in value)) return false;
|
||||||
|
const status = (value as { status: unknown }).status;
|
||||||
|
return status === "asking" || status === "done" || status === "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuse the same delta-extraction shape as multi-agent.ts so AI text streams
|
||||||
|
* smoothly into the frontend bubble. Returns null for tool / value / tool
|
||||||
|
* result frames.
|
||||||
|
*/
|
||||||
|
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||||
|
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||||
|
const chunk = parsed[0];
|
||||||
|
if (!chunk || typeof chunk !== "object") return null;
|
||||||
|
const obj = chunk as Record<string, unknown>;
|
||||||
|
const type = String(obj.type ?? "").toLowerCase();
|
||||||
|
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||||||
|
const content = obj.content;
|
||||||
|
let text = "";
|
||||||
|
if (typeof content === "string") {
|
||||||
|
text = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
for (const item of content) {
|
||||||
|
if (typeof item === "string") text += item;
|
||||||
|
else if (item && typeof item === "object") {
|
||||||
|
const block = item as Record<string, unknown>;
|
||||||
|
const blockText = block.text;
|
||||||
|
if (typeof blockText === "string") text += blockText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!text) return null;
|
||||||
|
const id = typeof obj.id === "string" ? obj.id : "";
|
||||||
|
return { text, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an SSE connection to /api/intent/stream and resolve when the upstream
|
||||||
|
* stream completes. Each call is one user turn; reuse the same threadId
|
||||||
|
* across calls to keep the dialogue history.
|
||||||
|
*/
|
||||||
|
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
thread_id: req.threadId,
|
||||||
|
message: req.message,
|
||||||
|
model: req.model,
|
||||||
|
}),
|
||||||
|
signal: req.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`intent/stream failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let lastEvent = "";
|
||||||
|
|
||||||
|
// Single source of truth for the inferred SSE phase. The helper only fires
|
||||||
|
// the callback when the phase actually changes (or when a tool name is
|
||||||
|
// provided, since the same `tool_calling` phase may surface a new tool).
|
||||||
|
let currentPhase: IntentStreamPhase = "connecting";
|
||||||
|
req.onPhaseChange?.("connecting");
|
||||||
|
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
|
||||||
|
if (next === currentPhase && !toolName) return;
|
||||||
|
currentPhase = next;
|
||||||
|
req.onPhaseChange?.(next, toolName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
lastEvent = line.slice(6).trim();
|
||||||
|
// First `event: metadata` = the upstream LangGraph run was accepted.
|
||||||
|
// Flip out of "connecting" so the bubble label changes from
|
||||||
|
// "调度中" → "已连接,等待响应".
|
||||||
|
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||||||
|
setPhase("connected");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (!payload) continue;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinalIntentFrame(parsed)) {
|
||||||
|
result.status = parsed.status;
|
||||||
|
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||||
|
if (parsed.summary && typeof parsed.summary === "object") {
|
||||||
|
result.summary = parsed.summary as IntentSummary;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastEvent === "messages") {
|
||||||
|
// Phase inference from LangGraph messages-tuple frames. Tool calls
|
||||||
|
// (typically ask_clarification) flip phase to tool_calling; AI text
|
||||||
|
// chunks flip to streaming. Everything else leaves phase alone.
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
const chunk = parsed[0];
|
||||||
|
if (chunk && typeof chunk === "object") {
|
||||||
|
const chunkObj = chunk as Record<string, unknown>;
|
||||||
|
const chunkType = String(chunkObj.type ?? "").toLowerCase();
|
||||||
|
const toolCalls = chunkObj.tool_calls;
|
||||||
|
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||||
|
const content = chunkObj.content;
|
||||||
|
const hasToolCall =
|
||||||
|
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
|
||||||
|
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
||||||
|
if (hasToolCall) {
|
||||||
|
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||||
|
? (toolCalls[0] as Record<string, unknown>)
|
||||||
|
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||||
|
? (toolCallChunks[0] as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||||
|
setPhase("tool_calling", toolName);
|
||||||
|
} else if (
|
||||||
|
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||||
|
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||||
|
) {
|
||||||
|
setPhase("streaming");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = extractAiTextDelta(parsed);
|
||||||
|
if (delta) req.onTextDelta?.(delta.text, delta.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
export type ThreadIdMap = Record<string, string>;
|
||||||
|
|
||||||
|
export interface SeatReadyEvent {
|
||||||
|
agentName: string;
|
||||||
|
threadId: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinatorReadyEvent {
|
||||||
|
mainAgentName: string;
|
||||||
|
threadId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitMultiAgentRequest {
|
||||||
|
agentNames: string[];
|
||||||
|
mainAgentName: string;
|
||||||
|
model?: string;
|
||||||
|
/** Fires the moment each sub-agent's thread is ready (real completion order). */
|
||||||
|
onSeatReady?: (event: SeatReadyEvent) => void;
|
||||||
|
/** Fires when the coordinator's thread + agent have been created. */
|
||||||
|
onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitMultiAgentResponse {
|
||||||
|
status: "success";
|
||||||
|
main_agent_name: string;
|
||||||
|
thread_ids: ThreadIdMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
|
||||||
|
* callbacks as events arrive so the UI can light up roles individually, and
|
||||||
|
* resolves with the final {main_agent_name, thread_ids} once init_done lands.
|
||||||
|
* If the server emits an "error" frame mid-stream, the promise rejects.
|
||||||
|
*/
|
||||||
|
export async function initMultiAgent(
|
||||||
|
payload: InitMultiAgentRequest,
|
||||||
|
): Promise<InitMultiAgentResponse> {
|
||||||
|
// Timing telemetry for /api/multi-agent/init — enabled by default so we can
|
||||||
|
// see where time goes when users report "Step 2 init feels slow". Disable
|
||||||
|
// with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools.
|
||||||
|
const timingEnabled =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? false
|
||||||
|
: (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false;
|
||||||
|
const t0 = performance.now();
|
||||||
|
let tFirstByte = 0;
|
||||||
|
let tCoord = 0;
|
||||||
|
let seatCount = 0;
|
||||||
|
const tlog = (label: string) => {
|
||||||
|
if (!timingEnabled) return;
|
||||||
|
const elapsed = (performance.now() - t0).toFixed(0);
|
||||||
|
console.debug(`[init-timing] +${elapsed}ms ${label}`);
|
||||||
|
};
|
||||||
|
tlog("fetch.start");
|
||||||
|
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_names: payload.agentNames,
|
||||||
|
main_agent_name: payload.mainAgentName,
|
||||||
|
model: payload.model,
|
||||||
|
}),
|
||||||
|
signal: payload.signal,
|
||||||
|
});
|
||||||
|
tlog(`fetch.headers status=${res.status}`);
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let mainAgentName = "";
|
||||||
|
let threadIds: ThreadIdMap | null = null;
|
||||||
|
let aborted = false;
|
||||||
|
let errorDetail: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (tFirstByte === 0) {
|
||||||
|
tFirstByte = performance.now();
|
||||||
|
tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`);
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (!data) continue;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object") continue;
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const event = typeof obj.event === "string" ? obj.event : "";
|
||||||
|
switch (event) {
|
||||||
|
case "seat_ready": {
|
||||||
|
seatCount += 1;
|
||||||
|
tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`);
|
||||||
|
payload.onSeatReady?.({
|
||||||
|
agentName: String(obj.agent_name ?? ""),
|
||||||
|
threadId: String(obj.thread_id ?? ""),
|
||||||
|
displayName: String(obj.display_name ?? ""),
|
||||||
|
description: String(obj.description ?? ""),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "coordinator_ready": {
|
||||||
|
tCoord = performance.now();
|
||||||
|
tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`);
|
||||||
|
payload.onCoordinatorReady?.({
|
||||||
|
mainAgentName: String(obj.main_agent_name ?? ""),
|
||||||
|
threadId: String(obj.thread_id ?? ""),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "init_done": {
|
||||||
|
tlog(`init_done seats=${seatCount}`);
|
||||||
|
mainAgentName = String(obj.main_agent_name ?? "");
|
||||||
|
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
errorDetail = String(obj.detail ?? "unknown init error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unknown event — ignore for forward-compat.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
|
||||||
|
if (!aborted) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
|
||||||
|
|
||||||
|
if (errorDetail) {
|
||||||
|
throw new Error(`init multi-agent failed: ${errorDetail}`);
|
||||||
|
}
|
||||||
|
if (aborted) {
|
||||||
|
throw new DOMException("init aborted", "AbortError");
|
||||||
|
}
|
||||||
|
if (!threadIds || !mainAgentName) {
|
||||||
|
throw new Error("init multi-agent stream ended without init_done");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
main_agent_name: mainAgentName,
|
||||||
|
thread_ids: threadIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamAgentType = "leader" | "special";
|
||||||
|
|
||||||
|
export type LeaderDispatch = [string, string];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level phases the SSE stream goes through. Used by the UI to show
|
||||||
|
* "what the backend is doing" while the user waits — most of the time before
|
||||||
|
* a first token is upstream/LLM latency, which feels like a stall otherwise.
|
||||||
|
*
|
||||||
|
* - `connecting` — fetch sent, no frames yet
|
||||||
|
* - `connected` — got the `event: metadata` frame (run_id assigned)
|
||||||
|
* - `streaming` — first AI text chunk arrived; tokens flowing
|
||||||
|
* - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
|
||||||
|
* present_files); usually means the run is about to interrupt
|
||||||
|
* - `tool_result` — a tool result message came back; brief, may go back to streaming
|
||||||
|
*/
|
||||||
|
export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
|
||||||
|
|
||||||
|
export interface StreamMultiAgentRequest {
|
||||||
|
agentType: StreamAgentType;
|
||||||
|
dAgentThreadId: ThreadIdMap;
|
||||||
|
agentName: string;
|
||||||
|
newMessage: string;
|
||||||
|
model?: string;
|
||||||
|
skillStopNames?: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Fires for every non-status data frame the upstream emits. */
|
||||||
|
onUpstreamFrame?: (frame: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
|
||||||
|
* `text` is the incremental delta (concatenate to rebuild the message);
|
||||||
|
* `messageId` is stable per AI message so callers can group deltas.
|
||||||
|
*/
|
||||||
|
onTextDelta?: (text: string, messageId: string) => void;
|
||||||
|
/**
|
||||||
|
* Fires whenever the inferred SSE phase changes. The UI can use this to
|
||||||
|
* show progressive status messages during the inevitable first-token wait.
|
||||||
|
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
|
||||||
|
*/
|
||||||
|
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClarificationPayload {
|
||||||
|
/** Raw question text from the model's ask_clarification args. */
|
||||||
|
question: string;
|
||||||
|
/** Optional category hint from the model (e.g. "missing_info"). */
|
||||||
|
clarificationType: string;
|
||||||
|
/** Free-text reason the model needs clarification. */
|
||||||
|
context: string;
|
||||||
|
/** Suggested answer chips (may be empty). */
|
||||||
|
options: unknown[];
|
||||||
|
/** Whether the user is allowed to type a custom answer. */
|
||||||
|
allowCustom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamMultiAgentResult {
|
||||||
|
/** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
|
||||||
|
dispatched: LeaderDispatch[];
|
||||||
|
/** For special runs: the completion status string. */
|
||||||
|
message: string | null;
|
||||||
|
/** Final assistant text emitted by the underlying agent. */
|
||||||
|
content: string;
|
||||||
|
/** Echoed agent_name of the agent that produced this run. */
|
||||||
|
agentName: string | null;
|
||||||
|
/**
|
||||||
|
* Populated when the leader called ask_clarification — the run ended without
|
||||||
|
* dispatching, and the model is asking the user a question instead. When
|
||||||
|
* present, callers should render `content` (or `clarification.question` as
|
||||||
|
* a fallback) as a question and collect a user reply rather than treating
|
||||||
|
* an empty `dispatched` as consensus.
|
||||||
|
*/
|
||||||
|
clarification: ClarificationPayload | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify the final status frame our backend appends after the LangGraph
|
||||||
|
* stream. Required keys are `status` (array for leader, string for special)
|
||||||
|
* and `content` (final assistant text); `agent_name` is also expected.
|
||||||
|
*/
|
||||||
|
function isFinalStatusFrame(value: unknown): value is {
|
||||||
|
status: LeaderDispatch[] | string;
|
||||||
|
content?: string;
|
||||||
|
agent_name?: string;
|
||||||
|
question?: string;
|
||||||
|
clarification_type?: string;
|
||||||
|
clarification_context?: string;
|
||||||
|
options?: unknown[];
|
||||||
|
allow_custom?: boolean;
|
||||||
|
} {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
if (!("status" in value) || !("content" in value)) return false;
|
||||||
|
const status = (value as { status: unknown }).status;
|
||||||
|
if (typeof status === "string") return true;
|
||||||
|
if (Array.isArray(status)) {
|
||||||
|
return status.every(
|
||||||
|
(item) =>
|
||||||
|
Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an AI text delta from a LangGraph `messages-tuple` data frame.
|
||||||
|
* Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
|
||||||
|
* {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
|
||||||
|
* structured content blocks. Returns null when the frame is not an AI text
|
||||||
|
* chunk (e.g., tool calls, tool results, value snapshots).
|
||||||
|
*/
|
||||||
|
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||||
|
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||||
|
const chunk = parsed[0];
|
||||||
|
if (!chunk || typeof chunk !== "object") return null;
|
||||||
|
const obj = chunk as Record<string, unknown>;
|
||||||
|
const type = String(obj.type ?? "").toLowerCase();
|
||||||
|
// Accept any AI-shaped message chunk LangChain might emit across versions:
|
||||||
|
// "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
|
||||||
|
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||||||
|
const content = obj.content;
|
||||||
|
let text = "";
|
||||||
|
if (typeof content === "string") {
|
||||||
|
text = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
for (const item of content) {
|
||||||
|
if (typeof item === "string") text += item;
|
||||||
|
else if (item && typeof item === "object") {
|
||||||
|
const block = item as Record<string, unknown>;
|
||||||
|
const blockText = block.text;
|
||||||
|
if (typeof blockText === "string") text += blockText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!text) return null;
|
||||||
|
const id = typeof obj.id === "string" ? obj.id : "";
|
||||||
|
return { text, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
|
||||||
|
* stream completes. The final frame (`{status: ...}`) is interpreted; every
|
||||||
|
* other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
|
||||||
|
* its own rendering (we don't try to mimic LangGraph SDK semantics here).
|
||||||
|
*/
|
||||||
|
export async function streamMultiAgent(
|
||||||
|
req: StreamMultiAgentRequest,
|
||||||
|
): Promise<StreamMultiAgentResult> {
|
||||||
|
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_type: req.agentType,
|
||||||
|
d_agent_thread_id: req.dAgentThreadId,
|
||||||
|
agent_name: req.agentName,
|
||||||
|
new_message: req.newMessage,
|
||||||
|
model: req.model,
|
||||||
|
skill_stop_names: req.skillStopNames ?? [],
|
||||||
|
}),
|
||||||
|
signal: req.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
// Track the last `event:` line so logging can show which event a `data:`
|
||||||
|
// belongs to. The frontend doesn't actually dispatch by event name — every
|
||||||
|
// data frame goes through the same parser — but the label is invaluable
|
||||||
|
// for diagnosing why a sub-agent stream "looks empty".
|
||||||
|
let lastEvent = "";
|
||||||
|
let frameSeq = 0;
|
||||||
|
|
||||||
|
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
|
||||||
|
const debugEnabled =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
|
||||||
|
|
||||||
|
const result: StreamMultiAgentResult = {
|
||||||
|
dispatched: [],
|
||||||
|
message: null,
|
||||||
|
content: "",
|
||||||
|
agentName: null,
|
||||||
|
clarification: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
|
||||||
|
// value actually changes (the reader loop sees many frames per phase).
|
||||||
|
let currentPhase: StreamPhase = "connecting";
|
||||||
|
req.onPhaseChange?.("connecting");
|
||||||
|
const setPhase = (next: StreamPhase, name?: string) => {
|
||||||
|
if (next === currentPhase && !name) return;
|
||||||
|
currentPhase = next;
|
||||||
|
req.onPhaseChange?.(next, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
buffer += chunk;
|
||||||
|
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
lastEvent = line.slice(6).trim();
|
||||||
|
// First metadata frame = run accepted by upstream. Move phase out
|
||||||
|
// of "connecting" so the UI can switch from "调度中" to "已连接".
|
||||||
|
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||||||
|
setPhase("connected");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (!payload) continue;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
frameSeq += 1;
|
||||||
|
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
|
||||||
|
|
||||||
|
// Phase inference from LangGraph messages-tuple frames. We only flip
|
||||||
|
// to tool_calling/tool_result on chunks that clearly indicate them;
|
||||||
|
// everything else with non-empty AI text means we're streaming.
|
||||||
|
if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
const chunk = parsed[0];
|
||||||
|
if (chunk && typeof chunk === "object") {
|
||||||
|
const chunkObj = chunk as Record<string, unknown>;
|
||||||
|
const chunkType = String(chunkObj.type ?? "").toLowerCase();
|
||||||
|
const toolCalls = chunkObj.tool_calls;
|
||||||
|
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||||
|
const content = chunkObj.content;
|
||||||
|
const hasToolCall =
|
||||||
|
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
|
||||||
|
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
||||||
|
if (chunkType === "tool") {
|
||||||
|
setPhase("tool_result");
|
||||||
|
} else if (hasToolCall) {
|
||||||
|
// Pick the most informative name we can find in either field.
|
||||||
|
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||||
|
? (toolCalls[0] as Record<string, unknown>)
|
||||||
|
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||||
|
? (toolCallChunks[0] as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||||
|
setPhase("tool_calling", toolName);
|
||||||
|
} else if (
|
||||||
|
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
|
||||||
|
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
|
||||||
|
) {
|
||||||
|
setPhase("streaming");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinalStatusFrame(parsed)) {
|
||||||
|
if (Array.isArray(parsed.status)) {
|
||||||
|
result.dispatched = parsed.status;
|
||||||
|
} else {
|
||||||
|
result.message = parsed.status;
|
||||||
|
// "clarification" status carries the leader's ask_clarification
|
||||||
|
// payload. Extract the structured fields so the page can render
|
||||||
|
// a question (or chip choices) instead of mistaking the run for
|
||||||
|
// a consensus.
|
||||||
|
if (parsed.status === "clarification") {
|
||||||
|
result.clarification = {
|
||||||
|
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||||
|
clarificationType:
|
||||||
|
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
|
||||||
|
context:
|
||||||
|
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||||
|
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||||
|
allowCustom: parsed.allow_custom !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof parsed.content === "string") {
|
||||||
|
result.content = parsed.content;
|
||||||
|
}
|
||||||
|
if (typeof parsed.agent_name === "string") {
|
||||||
|
result.agentName = parsed.agent_name;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = extractAiTextDelta(parsed);
|
||||||
|
if (delta) {
|
||||||
|
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
|
||||||
|
req.onTextDelta?.(delta.text, delta.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onUpstreamFrame?.(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionArtifactFile {
|
||||||
|
/** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */
|
||||||
|
path: string;
|
||||||
|
/** Bare filename (basename of `path`). */
|
||||||
|
name: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
/** MIME type guessed from the extension, or null when unknown. */
|
||||||
|
mimeType: string | null;
|
||||||
|
/** ISO 8601 UTC timestamp of last modification. */
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionArtifactsResponse {
|
||||||
|
threadId: string;
|
||||||
|
files: SessionArtifactFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List every file written to `/mnt/user-data/outputs/` for a thread. Used by
|
||||||
|
* the Step 3 final-report view to surface a thread's produced artifacts so the
|
||||||
|
* user can preview/download them via {@link buildArtifactDownloadUrl}.
|
||||||
|
*
|
||||||
|
* Returns an empty `files` array when the thread produced no artifacts (the
|
||||||
|
* backend treats a missing outputs directory as empty, not 404).
|
||||||
|
*/
|
||||||
|
export async function listSessionArtifacts(
|
||||||
|
threadId: string,
|
||||||
|
options: { signal?: AbortSignal } = {},
|
||||||
|
): Promise<SessionArtifactsResponse> {
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
|
||||||
|
{ method: "GET", signal: options.signal },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`list artifacts failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
thread_id: string;
|
||||||
|
files: Array<{
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size_bytes: number;
|
||||||
|
mime_type: string | null;
|
||||||
|
modified_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
threadId: json.thread_id,
|
||||||
|
files: (json.files ?? []).map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
name: f.name,
|
||||||
|
sizeBytes: f.size_bytes,
|
||||||
|
mimeType: f.mime_type,
|
||||||
|
modifiedAt: f.modified_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a URL that previews (inline) or downloads (attachment) an artifact
|
||||||
|
* file. The `path` must be a virtual path returned by
|
||||||
|
* {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`).
|
||||||
|
*/
|
||||||
|
export function buildArtifactDownloadUrl(
|
||||||
|
threadId: string,
|
||||||
|
virtualPath: string,
|
||||||
|
options: { download?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/");
|
||||||
|
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
|
||||||
|
return options.download ? `${base}?download=true` : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Download, FileText, Loader2, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
|
||||||
|
|
||||||
|
interface ArtifactPreviewModalProps {
|
||||||
|
/** The thread that owns the artifact. Both fetch and download URLs use it. */
|
||||||
|
threadId: string;
|
||||||
|
/** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */
|
||||||
|
path: string;
|
||||||
|
/** Bare filename — shown in the modal header. */
|
||||||
|
name: string;
|
||||||
|
/** Mime type from the listing; drives the renderer choice. */
|
||||||
|
mimeType: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKDOWN_COMPONENTS = {
|
||||||
|
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
|
||||||
|
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
|
||||||
|
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
|
||||||
|
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
|
||||||
|
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
|
||||||
|
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
|
||||||
|
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
|
||||||
|
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
|
||||||
|
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||||
|
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||||
|
code: ({ children, className }: any) => {
|
||||||
|
const isBlock = /language-/.test(className ?? "");
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<pre className="bg-slate-100 text-slate-800 rounded-md p-3 my-2 overflow-x-auto text-[12px] font-mono">
|
||||||
|
<code>{children}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[12px] font-mono">{children}</code>;
|
||||||
|
},
|
||||||
|
blockquote: ({ children }: any) => (
|
||||||
|
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
|
||||||
|
),
|
||||||
|
a: ({ children, href }: any) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
|
||||||
|
th: ({ children }: any) => (
|
||||||
|
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
|
||||||
|
),
|
||||||
|
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
|
||||||
|
hr: () => <hr className="my-3 border-slate-200" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isMarkdown(mimeType: string | null, name: string): boolean {
|
||||||
|
if (mimeType === "text/markdown") return true;
|
||||||
|
return /\.(md|markdown)$/i.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
|
||||||
|
threadId,
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
mimeType,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Esc closes the modal. Attached once per mount.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Fetch the artifact body via the same URL the original download link used.
|
||||||
|
// The GET endpoint already returns text/* as decoded UTF-8, so res.text()
|
||||||
|
// gives us a renderable string directly.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setContent(null);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const url = buildArtifactDownloadUrl(threadId, path);
|
||||||
|
const res = await apiFetch(url, { method: "GET", signal: ctrl.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
if (!cancelled) setContent(text);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled || (err as Error).name === "AbortError") return;
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
ctrl.abort();
|
||||||
|
};
|
||||||
|
}, [threadId, path]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}, [threadId, path]);
|
||||||
|
|
||||||
|
const renderAsMarkdown = isMarkdown(mimeType, name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-6"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Only close on outer-overlay click — clicks inside the modal panel
|
||||||
|
// bubble up here too, so check the target.
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="shrink-0 px-5 py-3.5 border-b border-slate-150 flex items-center justify-between gap-3 bg-gradient-to-r from-slate-50 to-white">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span className="text-sm font-bold text-slate-800 truncate" title={name}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 text-[11px] font-bold px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition"
|
||||||
|
title="下载原文件"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||||
|
<span className="text-xs">正在加载文件内容…</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-rose-600 text-xs py-6 text-center">
|
||||||
|
加载失败:{error}
|
||||||
|
</div>
|
||||||
|
) : content === null ? null : renderAsMarkdown ? (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-xs text-slate-700 font-mono">{content}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Printer,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildArtifactDownloadUrl,
|
||||||
|
listSessionArtifacts,
|
||||||
|
type SessionArtifactFile,
|
||||||
|
type ThreadIdMap,
|
||||||
|
} from "@/roundtable-planning/api/multi-agent";
|
||||||
|
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text-ish files we render in an in-app preview modal. Other types (pdf,
|
||||||
|
* images, html) keep the original window.open behaviour so the browser's
|
||||||
|
* native viewer handles them.
|
||||||
|
*/
|
||||||
|
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
|
||||||
|
const mime = file.mimeType ?? "";
|
||||||
|
if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("text/")) return true;
|
||||||
|
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeatDelivery {
|
||||||
|
/** Display name of the seat (e.g. "情报收集"). */
|
||||||
|
sender: string;
|
||||||
|
/** Avatar color hint inherited from the dialogue card. Unused for layout right now. */
|
||||||
|
avatarType: string;
|
||||||
|
/** The seat's final markdown delivery from Step 2. */
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighFidelityReportProps {
|
||||||
|
threadIds: ThreadIdMap | null;
|
||||||
|
coordinatorName: string | null;
|
||||||
|
/** The most recent leader-agent text — rendered as the final consensus. */
|
||||||
|
consensusText: string;
|
||||||
|
/** True once leader.dispatched === [] was observed (natural convergence). */
|
||||||
|
hasConsensus: boolean;
|
||||||
|
/** Each sub-agent's final delivery, filtered out of step2 dialogues. */
|
||||||
|
seatDeliveries: SeatDelivery[];
|
||||||
|
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
|
||||||
|
seatNameMap: Record<string, string>;
|
||||||
|
/** Resets state and navigates back to Step 1. */
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeatArtifacts {
|
||||||
|
agentName: string;
|
||||||
|
displayName: string;
|
||||||
|
threadId: string;
|
||||||
|
files: SessionArtifactFile[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeneratedAt(d: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownComponents = {
|
||||||
|
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
|
||||||
|
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
|
||||||
|
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
|
||||||
|
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
|
||||||
|
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
|
||||||
|
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
|
||||||
|
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
|
||||||
|
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
|
||||||
|
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||||
|
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||||
|
code: ({ children, className }: any) => {
|
||||||
|
const isBlock = /language-/.test(className ?? "");
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
|
||||||
|
<code>{children}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
blockquote: ({ children }: any) => (
|
||||||
|
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
|
||||||
|
),
|
||||||
|
a: ({ children, href }: any) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
|
||||||
|
th: ({ children }: any) => (
|
||||||
|
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
|
||||||
|
),
|
||||||
|
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
|
||||||
|
hr: () => <hr className="my-2 border-slate-200" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||||
|
threadIds,
|
||||||
|
coordinatorName,
|
||||||
|
consensusText,
|
||||||
|
hasConsensus,
|
||||||
|
seatDeliveries,
|
||||||
|
seatNameMap,
|
||||||
|
onReset,
|
||||||
|
}) => {
|
||||||
|
// Build the per-seat list (excludes the coordinator). useMemo so the file
|
||||||
|
// fetch effect doesn't re-fire on every parent render.
|
||||||
|
const seatThreadEntries = useMemo(() => {
|
||||||
|
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
|
||||||
|
return Object.entries(threadIds)
|
||||||
|
.filter(([name]) => !coordinatorName || name !== coordinatorName)
|
||||||
|
.map(([agentName, threadId]) => ({
|
||||||
|
agentName,
|
||||||
|
threadId,
|
||||||
|
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
|
||||||
|
}));
|
||||||
|
}, [threadIds, coordinatorName, seatNameMap]);
|
||||||
|
|
||||||
|
// Map sender display name → its delivery content for the accordion rows.
|
||||||
|
// Built once per seatDeliveries change; later entries with the same sender
|
||||||
|
// win so the *latest* delivery wins.
|
||||||
|
const deliveryByDisplay = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const d of seatDeliveries) m.set(d.sender, d.content);
|
||||||
|
return m;
|
||||||
|
}, [seatDeliveries]);
|
||||||
|
|
||||||
|
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
|
||||||
|
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
// Active preview target — non-null means the modal is open. We carry the
|
||||||
|
// owning threadId so the modal can fetch + build download URLs without
|
||||||
|
// needing a second prop drill.
|
||||||
|
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
|
||||||
|
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
|
||||||
|
|
||||||
|
// Fetch artifact listings for every seat thread in parallel. Re-runs when
|
||||||
|
// the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set
|
||||||
|
// changes (rare — only on a true re-init).
|
||||||
|
useEffect(() => {
|
||||||
|
if (seatThreadEntries.length === 0) {
|
||||||
|
setArtifactsBySeat([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({
|
||||||
|
...s,
|
||||||
|
files: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
setArtifactsBySeat(initial);
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
seatThreadEntries.map(async (seat) => {
|
||||||
|
try {
|
||||||
|
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
|
||||||
|
return { seat, files: res.files, error: null as string | null };
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") {
|
||||||
|
return { seat, files: [] as SessionArtifactFile[], error: null };
|
||||||
|
}
|
||||||
|
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
setArtifactsBySeat(
|
||||||
|
results.map(({ seat, files, error }) => ({
|
||||||
|
...seat,
|
||||||
|
files,
|
||||||
|
loading: false,
|
||||||
|
error,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
abort.abort();
|
||||||
|
};
|
||||||
|
}, [seatThreadEntries, reloadKey]);
|
||||||
|
|
||||||
|
const totalFileCount = useMemo(
|
||||||
|
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
|
||||||
|
[artifactsBySeat],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSeat = useCallback((key: string) => {
|
||||||
|
setExpandedSeats((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePreview = useCallback(
|
||||||
|
(threadId: string, file: SessionArtifactFile) => {
|
||||||
|
// Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
|
||||||
|
// in our in-app modal so the user can read the content without leaving
|
||||||
|
// the planning page. Binary or active content (pdf / png / html) still
|
||||||
|
// hands off to the browser's native viewer via the original tab.
|
||||||
|
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
|
||||||
|
setPreviewing({ threadId, file });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const url = buildArtifactDownloadUrl(threadId, file.path);
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
(threadId: string, file: SessionArtifactFile) => {
|
||||||
|
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
||||||
|
<h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{hasConsensus && (
|
||||||
|
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm">
|
||||||
|
● 已达成共识
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg">
|
||||||
|
生成时间:{generatedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== "undefined") window.print();
|
||||||
|
}}
|
||||||
|
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<Printer className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
打印 / 另存 PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReloadKey((k) => k + 1)}
|
||||||
|
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
刷新文件清单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Section 1: Coordinator consensus conclusion */}
|
||||||
|
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||||
|
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||||
|
总控最终结论
|
||||||
|
</h4>
|
||||||
|
{consensusText ? (
|
||||||
|
<div className="text-xs text-slate-700 leading-relaxed">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
|
{consensusText}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-slate-400 italic">
|
||||||
|
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2: Per-seat deliveries (collapsible) */}
|
||||||
|
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||||
|
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-primary" />
|
||||||
|
各席位最终交付
|
||||||
|
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||||
|
{seatDeliveries.length}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{seatDeliveries.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-slate-400 italic">
|
||||||
|
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{seatDeliveries.map((seat, idx) => {
|
||||||
|
const key = `${seat.sender}-${idx}`;
|
||||||
|
const expanded = expandedSeats.has(key);
|
||||||
|
return (
|
||||||
|
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSeat(key)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||||
|
{seat.content.length} 字
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
|
{seat.content || "(空)"}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 3: Generated files per seat */}
|
||||||
|
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
||||||
|
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4 text-primary" />
|
||||||
|
生成的报告文件
|
||||||
|
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||||
|
{totalFileCount}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{artifactsBySeat.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{artifactsBySeat.map((seat) => (
|
||||||
|
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
|
||||||
|
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||||
|
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{seat.loading ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||||
|
<span>正在查询 outputs 目录…</span>
|
||||||
|
</div>
|
||||||
|
) : seat.error ? (
|
||||||
|
<div className="px-3 py-3 text-[11px] text-rose-600">
|
||||||
|
读取失败:{seat.error}
|
||||||
|
</div>
|
||||||
|
) : seat.files.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
|
||||||
|
该席位未产生输出文件
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{seat.files.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.path}
|
||||||
|
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-[10px] font-mono text-slate-400">
|
||||||
|
{formatBytes(f.sizeBytes)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreview(seat.threadId, f)}
|
||||||
|
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||||
|
title="预览"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(seat.threadId, f)}
|
||||||
|
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||||
|
title="下载"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">
|
||||||
|
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
重新起草新规划
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewing && (
|
||||||
|
<ArtifactPreviewModal
|
||||||
|
threadId={previewing.threadId}
|
||||||
|
path={previewing.file.path}
|
||||||
|
name={previewing.file.name}
|
||||||
|
mimeType={previewing.file.mimeType}
|
||||||
|
onClose={() => setPreviewing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface ScrollToBottomButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
/** Label shown next to the icon. Defaults to "回到底部". */
|
||||||
|
label?: string;
|
||||||
|
/** Tailwind classes appended to the default positioning. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pill-shaped floating button shown when the user has scrolled up away from
|
||||||
|
* the bottom of a streamed conversation. Click → smooth-scroll back to the
|
||||||
|
* bottom and resume auto-follow.
|
||||||
|
*
|
||||||
|
* Positioning is `absolute` — render inside a `relative` wrapper so it
|
||||||
|
* floats over the scroll container's lower-center without scrolling along
|
||||||
|
* with content.
|
||||||
|
*/
|
||||||
|
export const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({
|
||||||
|
onClick,
|
||||||
|
label = "回到底部",
|
||||||
|
className = "",
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`absolute left-1/2 -translate-x-1/2 bottom-3 z-10 bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-[11px] font-bold px-3 py-1.5 rounded-full shadow-lg shadow-slate-900/10 flex items-center gap-1 transition active:scale-95 ${className}`}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type DependencyList, type RefObject } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-scroll a scrollable container while the user is at (or near) the
|
||||||
|
* bottom; stop following as soon as the user scrolls up, and resume when
|
||||||
|
* the user clicks the floating "回到底部" button (which calls `scrollToBottom`).
|
||||||
|
*
|
||||||
|
* Why a custom hook: the page has two streamed-message lists (Step 1 chat,
|
||||||
|
* Step 2 roundtable) with identical scroll-follow semantics, and reading
|
||||||
|
* `scrollHeight` after every state change inside the component would mean
|
||||||
|
* duplicating the scroll listener / auto-scroll / pause logic per list.
|
||||||
|
*/
|
||||||
|
export function useAutoScroll<T extends HTMLElement>(
|
||||||
|
ref: RefObject<T | null>,
|
||||||
|
deps: DependencyList,
|
||||||
|
options: {
|
||||||
|
/** How close to the bottom (px) still counts as "at bottom". */
|
||||||
|
threshold?: number;
|
||||||
|
} = {},
|
||||||
|
): {
|
||||||
|
/** True when the user is at the bottom — drives the button's visibility. */
|
||||||
|
atBottom: boolean;
|
||||||
|
/** Force-scroll to the bottom and resume auto-follow. */
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
} {
|
||||||
|
const threshold = options.threshold ?? 32;
|
||||||
|
// Ref mirrors state so the deps-effect can read the latest value without
|
||||||
|
// re-subscribing when atBottom flips. State is for the button's render.
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
|
||||||
|
// Track scroll position. The handler runs for BOTH user-initiated scrolls
|
||||||
|
// and programmatic `scrollTo` calls — after a programmatic scroll to the
|
||||||
|
// bottom, the resulting onScroll naturally leaves atBottom=true, so the
|
||||||
|
// auto-follow effect can keep firing on subsequent deps changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
const isAtBottom = distanceFromBottom <= threshold;
|
||||||
|
if (atBottomRef.current !== isAtBottom) {
|
||||||
|
atBottomRef.current = isAtBottom;
|
||||||
|
setAtBottom(isAtBottom);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
// Initial measurement so we don't start in a stale state if the container
|
||||||
|
// mounted with content already overflowing.
|
||||||
|
onScroll();
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, [ref, threshold]);
|
||||||
|
|
||||||
|
// Auto-scroll on deps change — but only if the user hasn't scrolled away.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!atBottomRef.current) return;
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||||
|
// Optimistically mark as at-bottom so the next deps change resumes
|
||||||
|
// auto-follow without waiting for the smooth-scroll's onScroll to land.
|
||||||
|
atBottomRef.current = true;
|
||||||
|
setAtBottom(true);
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
return { atBottom, scrollToBottom };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
|
||||||
|
export { HighFidelityReport } from "./components/HighFidelityReport";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,217 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */
|
||||||
|
.roundtable-planning {
|
||||||
|
--rt-primary: var(--workspace-brand, #f5222d);
|
||||||
|
--rt-primary-hover: var(--brand-900, #cf1322);
|
||||||
|
--rt-primary-active: var(--brand-950, #a8071a);
|
||||||
|
--rt-primary-soft: var(--brand-200, #feeded);
|
||||||
|
--rt-page-bg: #fef6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundtable-planning ::-webkit-scrollbar-thumb {
|
||||||
|
background: #fcb5b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundtable-planning ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #fa9196;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb {
|
||||||
|
background: #fddadc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式:跟随全局 ThemeContext(html.dark)或组件 .roundtable-planning--dark */
|
||||||
|
.dark .roundtable-planning,
|
||||||
|
.roundtable-planning--dark {
|
||||||
|
--rt-page-bg: var(--background);
|
||||||
|
--rt-primary: var(--primary);
|
||||||
|
--rt-primary-hover: var(--primary);
|
||||||
|
--rt-primary-soft: var(--accent);
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning ::-webkit-scrollbar-thumb,
|
||||||
|
.roundtable-planning--dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklch, var(--primary) 45%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.roundtable-planning--dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in oklch, var(--primary) 65%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .custom-scrollbar-inner::-webkit-scrollbar-thumb,
|
||||||
|
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklch, var(--primary) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面底与浅色专用背景 */
|
||||||
|
.dark .roundtable-planning.bg-\[\#fef6f6\],
|
||||||
|
.roundtable-planning--dark.bg-\[\#fef6f6\] {
|
||||||
|
background-color: var(--background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表面:白底卡片 → card */
|
||||||
|
.dark .roundtable-planning .bg-white,
|
||||||
|
.roundtable-planning--dark .bg-white {
|
||||||
|
background-color: var(--card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .bg-slate-50,
|
||||||
|
.dark .roundtable-planning .bg-slate-50\/20,
|
||||||
|
.dark .roundtable-planning .bg-slate-50\/30,
|
||||||
|
.roundtable-planning--dark .bg-slate-50,
|
||||||
|
.roundtable-planning--dark .bg-slate-50\/20,
|
||||||
|
.roundtable-planning--dark .bg-slate-50\/30 {
|
||||||
|
background-color: color-mix(in oklch, var(--muted) 80%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .from-slate-50,
|
||||||
|
.roundtable-planning--dark .from-slate-50 {
|
||||||
|
--tw-gradient-from: var(--muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .to-white,
|
||||||
|
.roundtable-planning--dark .to-white {
|
||||||
|
--tw-gradient-to: var(--card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌浅红底 → accent */
|
||||||
|
.dark .roundtable-planning [class*="brand-200"],
|
||||||
|
.roundtable-planning--dark [class*="brand-200"] {
|
||||||
|
background-color: color-mix(in oklch, var(--accent) 55%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .border-red-100,
|
||||||
|
.dark .roundtable-planning .border-red-100\/50,
|
||||||
|
.dark .roundtable-planning .border-red-200,
|
||||||
|
.dark .roundtable-planning .border-red-200\/50,
|
||||||
|
.dark .roundtable-planning .border-red-200\/85,
|
||||||
|
.roundtable-planning--dark .border-red-100,
|
||||||
|
.roundtable-planning--dark .border-red-100\/50,
|
||||||
|
.roundtable-planning--dark .border-red-200,
|
||||||
|
.roundtable-planning--dark .border-red-200\/50,
|
||||||
|
.roundtable-planning--dark .border-red-200\/85 {
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .border-slate-100,
|
||||||
|
.dark .roundtable-planning .border-slate-200,
|
||||||
|
.dark .roundtable-planning .border-slate-200\/50,
|
||||||
|
.dark .roundtable-planning .border-\[\#e2e8f0\],
|
||||||
|
.roundtable-planning--dark .border-slate-100,
|
||||||
|
.roundtable-planning--dark .border-slate-200,
|
||||||
|
.roundtable-planning--dark .border-slate-200\/50,
|
||||||
|
.roundtable-planning--dark .border-\[\#e2e8f0\] {
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字 */
|
||||||
|
.dark .roundtable-planning .text-slate-900,
|
||||||
|
.dark .roundtable-planning .text-slate-800,
|
||||||
|
.dark .roundtable-planning .text-slate-700,
|
||||||
|
.dark .roundtable-planning .text-\[\#334155\],
|
||||||
|
.roundtable-planning--dark .text-slate-900,
|
||||||
|
.roundtable-planning--dark .text-slate-800,
|
||||||
|
.roundtable-planning--dark .text-slate-700,
|
||||||
|
.roundtable-planning--dark .text-\[\#334155\] {
|
||||||
|
color: var(--foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .text-slate-600,
|
||||||
|
.dark .roundtable-planning .text-slate-500,
|
||||||
|
.dark .roundtable-planning .text-slate-400,
|
||||||
|
.roundtable-planning--dark .text-slate-600,
|
||||||
|
.roundtable-planning--dark .text-slate-500,
|
||||||
|
.roundtable-planning--dark .text-slate-400 {
|
||||||
|
color: var(--muted-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .hover\:text-slate-800:hover,
|
||||||
|
.roundtable-planning--dark .hover\:text-slate-800:hover {
|
||||||
|
color: var(--foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .hover\:bg-slate-50:hover,
|
||||||
|
.roundtable-planning--dark .hover\:bg-slate-50:hover {
|
||||||
|
background-color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏 */
|
||||||
|
.dark .roundtable-planning > header,
|
||||||
|
.roundtable-planning--dark > header {
|
||||||
|
background-color: var(--card) !important;
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 步骤条容器 */
|
||||||
|
.dark .roundtable-planning header .rounded-2xl.border,
|
||||||
|
.roundtable-planning--dark header .rounded-2xl.border {
|
||||||
|
background-color: var(--muted) !important;
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 次要按钮(白底) */
|
||||||
|
.dark .roundtable-planning header button.bg-white,
|
||||||
|
.roundtable-planning--dark header button.bg-white {
|
||||||
|
background-color: var(--secondary) !important;
|
||||||
|
color: var(--foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入区 focus ring */
|
||||||
|
.dark .roundtable-planning .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within,
|
||||||
|
.roundtable-planning--dark .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within {
|
||||||
|
--tw-ring-color: color-mix(in oklch, var(--primary) 25%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 渐变横幅 */
|
||||||
|
.dark .roundtable-planning .from-\[var\(--brand-200\)\],
|
||||||
|
.roundtable-planning--dark .from-\[var\(--brand-200\)\] {
|
||||||
|
--tw-gradient-from: color-mix(in oklch, var(--accent) 40%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .roundtable-planning .border-primary,
|
||||||
|
.roundtable-planning--dark .border-2.border-primary {
|
||||||
|
border-color: color-mix(in oklch, var(--primary) 50%, var(--border)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分隔线 */
|
||||||
|
.dark .roundtable-planning .bg-slate-200,
|
||||||
|
.roundtable-planning--dark .bg-slate-200 {
|
||||||
|
background-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom elegant scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-inner::-webkit-scrollbar-thumb {
|
||||||
|
background: #fddadc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-scrollbar-inner::-webkit-scrollbar-thumb,
|
||||||
|
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklch, var(--primary) 35%, var(--border));
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Local-only draft storage for /page/roundtable/planning.
|
||||||
|
*
|
||||||
|
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
|
||||||
|
* roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
|
||||||
|
* it; cross-device sync is out of scope (would need a backend). The Step 2
|
||||||
|
* back-end LangGraph threads are reused on load, so list/preview/download of
|
||||||
|
* artifacts in Step 3 continue to work as long as the user hasn't deleted the
|
||||||
|
* threads server-side.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||||
|
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "roundtable-planning:drafts:v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of every piece of UI state needed to resume a session. We capture
|
||||||
|
* shapes loosely (`unknown[]`) for the chat / role lists because their inline
|
||||||
|
* types in the page component are wide and would force a refactor we don't
|
||||||
|
* want as part of the persistence feature.
|
||||||
|
*/
|
||||||
|
export interface DraftStep1Snapshot {
|
||||||
|
intentThreadId: string | null;
|
||||||
|
intentReady: IntentSummary | null;
|
||||||
|
interactiveMessages: unknown[];
|
||||||
|
timelineEntries: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftStep2Snapshot {
|
||||||
|
threadIds: ThreadIdMap | null;
|
||||||
|
coordinatorName: string | null;
|
||||||
|
step2RoundtableDialogues: unknown[];
|
||||||
|
lastLeaderContent: string;
|
||||||
|
hasConsensus: boolean;
|
||||||
|
consensusPercentage: number;
|
||||||
|
budgetLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftRecord {
|
||||||
|
id: string;
|
||||||
|
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
/** Furthest step the user had reached when this snapshot was taken. */
|
||||||
|
furthestStep: 1 | 2 | 3;
|
||||||
|
step1: DraftStep1Snapshot;
|
||||||
|
step2: DraftStep2Snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBrowser(): boolean {
|
||||||
|
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAll(): DraftRecord[] {
|
||||||
|
if (!isBrowser()) return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed as DraftRecord[];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[drafts] failed to parse localStorage payload:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(records: DraftRecord[]): void {
|
||||||
|
if (!isBrowser()) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
|
||||||
|
} catch (err) {
|
||||||
|
// Quota errors are the realistic failure mode — surface but don't throw,
|
||||||
|
// since the user can keep working with their in-memory state.
|
||||||
|
console.warn("[drafts] failed to write localStorage payload:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List drafts newest first. */
|
||||||
|
export function listDrafts(): DraftRecord[] {
|
||||||
|
return readAll()
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDraft(id: string): DraftRecord | null {
|
||||||
|
return readAll().find((d) => d.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create-or-update a draft. If `id` is given and exists, that record is
|
||||||
|
* overwritten (updatedAt refreshed). Otherwise a new record is inserted.
|
||||||
|
* Returns the (re-)saved record so callers can read back the new id.
|
||||||
|
*/
|
||||||
|
export function upsertDraft(
|
||||||
|
payload: Omit<DraftRecord, "createdAt" | "updatedAt" | "id"> & { id?: string },
|
||||||
|
): DraftRecord {
|
||||||
|
const records = readAll();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (payload.id) {
|
||||||
|
const idx = records.findIndex((d) => d.id === payload.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const existing = records[idx];
|
||||||
|
const updated: DraftRecord = {
|
||||||
|
...existing,
|
||||||
|
name: payload.name,
|
||||||
|
furthestStep: payload.furthestStep,
|
||||||
|
step1: payload.step1,
|
||||||
|
step2: payload.step2,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
records[idx] = updated;
|
||||||
|
writeAll(records);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: DraftRecord = {
|
||||||
|
id: payload.id ?? newId(),
|
||||||
|
name: payload.name,
|
||||||
|
furthestStep: payload.furthestStep,
|
||||||
|
step1: payload.step1,
|
||||||
|
step2: payload.step2,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
records.push(created);
|
||||||
|
writeAll(records);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDraft(id: string): void {
|
||||||
|
const records = readAll();
|
||||||
|
const next = records.filter((d) => d.id !== id);
|
||||||
|
if (next.length !== records.length) writeAll(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
|
||||||
|
* updated record so callers can refresh local UI state.
|
||||||
|
*/
|
||||||
|
export function renameDraft(id: string, name: string): DraftRecord | null {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const records = readAll();
|
||||||
|
const idx = records.findIndex((d) => d.id === id);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const updated: DraftRecord = {
|
||||||
|
...records[idx],
|
||||||
|
name: trimmed,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
records[idx] = updated;
|
||||||
|
writeAll(records);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a short task-summary name from whatever the user has supplied so far.
|
||||||
|
* Preference order: intent agent's structured objective → first user message
|
||||||
|
* in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
|
||||||
|
* the dropdown rows stay scannable.
|
||||||
|
*/
|
||||||
|
export function deriveDraftName(args: {
|
||||||
|
intentReady?: IntentSummary | null;
|
||||||
|
interactiveMessages?: unknown[];
|
||||||
|
}): string {
|
||||||
|
const MAX = 40;
|
||||||
|
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
|
||||||
|
|
||||||
|
const objective = args.intentReady?.objective?.trim();
|
||||||
|
if (objective) return truncate(objective);
|
||||||
|
|
||||||
|
if (Array.isArray(args.interactiveMessages)) {
|
||||||
|
for (const m of args.interactiveMessages) {
|
||||||
|
if (!m || typeof m !== "object") continue;
|
||||||
|
const obj = m as Record<string, unknown>;
|
||||||
|
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
|
||||||
|
return truncate(obj.content.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
|
||||||
|
}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import React, { useState } from 'react';
|
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
|
||||||
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
|
import { useState } from "react";
|
||||||
import { NotificationDialog } from '@/components/workspace/notification-dialog';
|
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
|
||||||
import { useUnreadCount } from '@/core/notifications';
|
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useUnreadCount } from "@/core/notifications";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
|
|
||||||
|
const iconButtonClass = (isDark: boolean) =>
|
||||||
|
cn(
|
||||||
|
"size-8 shrink-0 rounded-sm",
|
||||||
|
isDark
|
||||||
|
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||||||
|
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||||
|
);
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === "dark";
|
||||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
const { data: unreadCount = 0 } = useUnreadCount();
|
const { data: unreadCount = 0 } = useUnreadCount();
|
||||||
|
|
||||||
@@ -16,72 +28,110 @@ const Header: React.FC = () => {
|
|||||||
open={notificationOpen}
|
open={notificationOpen}
|
||||||
onOpenChange={setNotificationOpen}
|
onOpenChange={setNotificationOpen}
|
||||||
/>
|
/>
|
||||||
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
|
<header
|
||||||
isDark
|
className={cn(
|
||||||
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
|
"theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||||
: 'bg-red-600 border-b border-red-700'
|
isDark
|
||||||
}`}>
|
? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
|
||||||
{/* 左侧 - 搜索框 */}
|
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
|
||||||
<div className="flex items-center gap-4">
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-4" />
|
||||||
|
|
||||||
</div>
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={iconButtonClass(isDark)}
|
||||||
|
title={isDark ? "切换到红白主题" : "切换到深色主题"}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<Palette className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* 右侧 - 用户信息和通知 */}
|
<Button
|
||||||
<div className="flex items-center gap-4">
|
type="button"
|
||||||
{/* 主题切换按钮 */}
|
variant="ghost"
|
||||||
<button
|
size="icon-sm"
|
||||||
onClick={toggleTheme}
|
onClick={() => setNotificationOpen(true)}
|
||||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
className={cn(iconButtonClass(isDark), "relative")}
|
||||||
isDark
|
title="通知"
|
||||||
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
|
>
|
||||||
: 'hover:bg-red-500 text-white'
|
<Bell className="size-4" />
|
||||||
}`}
|
{unreadCount > 0 && (
|
||||||
title={isDark ? '切换到红白主题' : '切换到深色主题'}
|
<span
|
||||||
>
|
className={cn(
|
||||||
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
|
||||||
</button>
|
isDark
|
||||||
|
? "bg-cyan-500 text-white"
|
||||||
|
: "bg-white tabular-nums text-[var(--brand-900)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* 通知 */}
|
<div
|
||||||
<button
|
className={cn(
|
||||||
type="button"
|
"mx-1 h-8 w-px",
|
||||||
onClick={() => setNotificationOpen(true)}
|
isDark ? "bg-blue-800/30" : "bg-white/30",
|
||||||
className={`relative p-2 rounded-lg transition-colors ${
|
)}
|
||||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
aria-hidden
|
||||||
}`}
|
/>
|
||||||
title="通知"
|
|
||||||
>
|
<Button
|
||||||
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
|
type="button"
|
||||||
{unreadCount > 0 && (
|
variant="ghost"
|
||||||
<span
|
className={cn(
|
||||||
className={`absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
|
"h-8 gap-2 rounded-sm px-2",
|
||||||
isDark ? 'bg-cyan-500' : 'bg-white text-red-600'
|
isDark
|
||||||
}`}
|
? "text-slate-200 hover:bg-slate-800/50"
|
||||||
|
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-sm",
|
||||||
|
isDark
|
||||||
|
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||||
|
: "bg-white",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
<User
|
||||||
</span>
|
className={cn(
|
||||||
)}
|
"size-4",
|
||||||
</button>
|
isDark ? "text-white" : "text-[var(--brand-900)]",
|
||||||
|
)}
|
||||||
{/* 分隔线 */}
|
/>
|
||||||
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
|
</div>
|
||||||
|
<div className="hidden text-left sm:block">
|
||||||
{/* 用户信息 */}
|
<p className="text-sm leading-tight font-medium text-white">
|
||||||
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
|
张策略
|
||||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
</p>
|
||||||
}`}>
|
<p
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
className={cn(
|
||||||
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white'
|
"text-xs leading-tight",
|
||||||
}`}>
|
isDark ? "text-blue-400/60" : "text-white/75",
|
||||||
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
|
)}
|
||||||
</div>
|
>
|
||||||
<div className="text-left">
|
策略分析师
|
||||||
<p className="text-sm font-medium text-white">张策略</p>
|
</p>
|
||||||
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>策略分析师</p>
|
</div>
|
||||||
</div>
|
<ChevronDown
|
||||||
<ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} />
|
className={cn(
|
||||||
</button>
|
"size-4 shrink-0",
|
||||||
</div>
|
isDark ? "text-slate-500" : "text-white/80",
|
||||||
</header>
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -240,8 +240,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--workspace-brand: #d92121;
|
--workspace-brand: #f5222d;
|
||||||
--workspace-brand-foreground: #ffffff;
|
--workspace-brand-foreground: #ffffff;
|
||||||
|
--brand-200: #feeded;
|
||||||
|
--brand-900: #cf1322;
|
||||||
|
--brand-950: #a8071a;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.22 0.02 25);
|
--foreground: oklch(0.22 0.02 25);
|
||||||
@@ -811,3 +814,8 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
outline-color: var(--ring);
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [sourceIdentifierPlugin(), react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: [
|
||||||
{
|
{
|
||||||
|
|||||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user