Merge branch 'dhk-1' into 'master'

Dhk 1

See merge request knowledge-base-bj/deerflow-server!47
This commit is contained in:
董宏凯 committed 2026-05-23 09:42:11 +00:00
commit 267ce81be2
214 files changed
+44681 -11088

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ frontend-web/.vscode/
# Frontend local/runtime artifacts
frontend-web/.env
frontend-web/.env.local
frontend-web/.env.*.local
frontend-web/node_modules/
frontend-web/.pnpm-store/
frontend-web/tsconfig.tsbuildinfo
-9
View File
@@ -1,9 +0,0 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
-20
View File
@@ -1,20 +0,0 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/75261c35-a558-4ef8-9501-e8b7f9c98bfb
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Gemini Style Assistant",
"description": "A clean, modern AI assistant interface inspired by Google Gemini with centered search, suggestion pills, and a minimalist sidebar.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
-35
View File
@@ -1,35 +0,0 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}
-238
View File
@@ -1,238 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
MessageSquare,
Bot,
Wrench,
Brain,
ListChecks,
Settings,
Menu,
Mic,
Plus,
Sparkles,
ArrowRight,
User,
History,
LayoutGrid,
Languages,
Code2,
Music,
Database,
Search
} from 'lucide-react';
export default function App() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const navItems = [
{ icon: MessageSquare, label: '对话', active: true },
{ icon: Bot, label: '智能体' },
{ icon: Wrench, label: '技能' },
{ icon: Brain, label: '记忆' },
{ icon: ListChecks, label: '任务管理' },
];
const recentChats = [
'CM助手初次问候交谈',
'Untitled',
'Untitled',
'Untitled',
'帮我写一个500字的文案',
'帮我生成一个写论文的实例',
];
const suggestions = [
{ icon: Code2, label: 'Build an Android app', color: 'text-green-600', bg: 'bg-green-50' },
{ icon: Languages, label: 'Convert text to speech', color: 'text-blue-600', bg: 'bg-blue-50' },
{ icon: Music, label: 'Generate music', color: 'text-purple-600', bg: 'bg-purple-50' },
{ icon: Database, label: 'Add database and auth', color: 'text-cyan-600', bg: 'bg-cyan-50' },
];
return (
<div className="flex h-screen w-full bg-white font-sans text-gray-900 overflow-hidden">
{/* Sidebar */}
<motion.aside
initial={false}
animate={{ width: sidebarOpen ? 280 : 0, opacity: sidebarOpen ? 1 : 0 }}
className="relative bg-gray-50 border-r border-gray-100 flex flex-col h-full"
>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-2 px-2 py-1.5 bg-red-50 text-red-700 rounded-lg cursor-pointer">
<Plus size={18} />
<span className="font-medium text-sm">新对话</span>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-2 space-y-1">
<div className="space-y-1 pb-4">
{navItems.map((item) => (
<div
key={item.label}
className={`flex items-center gap-3 px-3 py-2 rounded-xl cursor-pointer transition-colors ${
item.active ? 'bg-white shadow-sm font-medium' : 'hover:bg-gray-100 text-gray-600'
}`}
>
<item.icon size={18} />
<span className="text-sm">{item.label}</span>
</div>
))}
</div>
<div className="pt-4 border-t border-gray-200">
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
最近对话
</h3>
{recentChats.map((chat, idx) => (
<div
key={idx}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg cursor-pointer truncate"
>
{chat}
</div>
))}
</div>
</nav>
<div className="p-4 border-t border-gray-200">
<div className="flex items-center gap-3 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-xl cursor-pointer">
<Settings size={18} />
<span className="text-sm">设置和更多</span>
</div>
</div>
</motion.aside>
{/* Main Content */}
<main className="flex-1 flex flex-col relative h-full overflow-y-auto">
{/* Top bar */}
<header className="absolute top-0 w-full p-4 flex items-center justify-between z-10">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors"
>
<Menu size={20} />
</button>
<div className="flex items-center gap-2">
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors">
<Settings size={20} />
</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-400 to-purple-400 flex items-center justify-center text-white shadow-sm ring-2 ring-white cursor-pointer overflow-hidden">
<User size={16} />
</div>
</div>
</header>
{/* Center Canvas */}
<div className="flex-1 flex flex-col items-center justify-center px-4 max-w-4xl mx-auto w-full pt-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12 relative"
>
<div className="absolute -top-16 left-1/2 -translate-x-1/2 opacity-10 blur-2xl pointer-events-none">
<Sparkles size={120} className="text-blue-500 animate-pulse" />
</div>
<h1 className="text-5xl font-display font-medium tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-500 mb-4 px-4">
Build your ideas with Gemini
</h1>
<Sparkles className="absolute -right-8 top-0 text-blue-200 opacity-50" size={48} />
</motion.div>
{/* Search/Input Area */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="w-full max-w-2xl relative group"
>
<div className="gemini-gradient-border gemini-shadow rounded-3xl bg-white p-2">
<div className="flex flex-col p-4 space-y-4">
<textarea
placeholder="Describe an app and let Gemini do the rest"
className="w-full bg-transparent resize-none focus:outline-none text-lg text-gray-700 min-h-[80px]"
/>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<button className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
<Mic size={20} />
</button>
<button className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
<Plus size={20} />
</button>
</div>
<button className="flex items-center gap-2 px-5 py-2.5 bg-gray-50 hover:bg-gray-100 text-gray-700 rounded-full border border-gray-200 transition-all font-medium text-sm group/btn shadow-sm">
<Sparkles size={16} className="text-blue-500" />
I'm feeling lucky
<ArrowRight size={14} className="opacity-0 -translate-x-2 group-hover/btn:opacity-100 group-hover/btn:translate-x-0 transition-all" />
</button>
</div>
</div>
</div>
</motion.div>
{/* Suggestion Chips */}
<div className="mt-8 flex flex-wrap justify-center gap-3">
{suggestions.map((suggestion, idx) => (
<motion.button
key={suggestion.label}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + idx * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`flex items-center gap-2 px-4 py-2.5 rounded-2xl border border-gray-100 bg-white hover:bg-gray-50 shadow-sm transition-all text-sm font-medium group hover:border-gray-200 text-gray-700`}
>
<suggestion.icon className={`w-4 h-4 ${suggestion.color}`} />
{suggestion.label}
</motion.button>
))}
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex items-center justify-center p-2 rounded-full border border-gray-100 bg-white hover:bg-gray-50 text-gray-400 shadow-sm transition-all"
>
<ArrowRight size={16} />
</motion.button>
</div>
</div>
{/* Footer info/links */}
<footer className="w-full max-w-4xl mx-auto px-4 py-12 mt-auto">
<div className="rounded-3xl border border-gray-100 bg-gray-50/50 p-8 flex items-center justify-between group hover:bg-gray-50 transition-colors">
<div>
<h2 className="text-2xl font-display font-medium text-gray-800 mb-1">
Discover and remix app ideas
</h2>
<p className="text-gray-500 text-sm italic">
(Gallery view pending implementation)
</p>
</div>
<button className="flex items-center gap-2 px-6 py-2.5 bg-white border border-gray-200 rounded-xl hover:shadow-md transition-all font-medium text-gray-700">
Browse the app gallery
<ArrowRight size={18} />
</button>
</div>
</footer>
{/* Floating Right Bar (from target image) */}
<div className="absolute right-6 top-1/2 -translate-y-1/2 flex flex-col gap-4">
<button className="p-2.5 bg-white shadow-lg border border-gray-100 rounded-xl hover:scale-110 transition-all text-gray-400">
<LayoutGrid size={22} />
</button>
<button className="p-2.5 bg-white shadow-lg border border-gray-100 rounded-xl hover:scale-110 transition-all text-gray-400">
<Languages size={22} />
</button>
</div>
</main>
</div>
);
}
-71
View File
@@ -1,71 +0,0 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Outfit", "Inter", sans-serif;
}
@layer base {
body {
@apply bg-white text-gray-900 antialiased;
}
}
@layer utilities {
.gemini-shadow {
box-shadow: 0 0 20px rgba(66, 133, 244, 0.08),
0 0 40px rgba(219, 68, 55, 0.04),
0 0 60px rgba(244, 180, 0, 0.04),
0 0 80px rgba(15, 157, 88, 0.04);
}
.gemini-gradient-border {
position: relative;
background: white;
border-radius: 1.5rem; /* rounded-3xl */
z-index: 0;
overflow: hidden;
}
.gemini-gradient-border::before {
content: '';
position: absolute;
width: 150%;
height: 150%;
top: -25%;
left: -25%;
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 90deg,
#4285F4 120deg,
#DB4437 150deg,
#F4B400 180deg,
#0F9D58 210deg,
transparent 240deg
);
z-index: -2;
animation: rotate 3s linear infinite;
filter: blur(15px);
}
.gemini-gradient-border::after {
content: '';
position: absolute;
inset: 1.5px; /* The "thickness" of the glow border */
background: white;
border-radius: calc(1.5rem - 1px);
z-index: -1;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@400;500;600&display=swap');
-10
View File
@@ -1,10 +0,0 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
-22
View File
@@ -1,22 +0,0 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});
+12
View File
@@ -0,0 +1,12 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,7 @@
# 复制为 .env.development.local,仅本机生效,不提交 Git
# 直连本机 Gateway(需后端 CORS_ORIGINS 包含 http://127.0.0.1:5173 等)
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
+28 -7
View File
@@ -1,9 +1,30 @@
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量)
#
# ┌─────────────────┬──────────────────────────────────────────────────┐
# │ 命令 │ 生效文件 │
# ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.local │
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘
#
# 推荐:
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.production 后再执行 pnpm build
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离)
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
# VITE_GITHUB_OAUTH_TOKEN=
+12
View File
@@ -0,0 +1,12 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,5 @@
# 复制为 .env.production.local,打包前临时覆盖生产地址(不提交 Git)
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
@@ -1,916 +0,0 @@
# AI 写作 — 后端开发文档
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
>
> **配套文档**:
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
---
## 一、产品定位与技术架构
### 产品定位
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
### 技术栈
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
- **业务数据**:MySQL `ai_writing_sessions` 表
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
### 与主 Agent 的关系
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
---
## 二、目录结构
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── routers/
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
│
└── packages/harness/deerflow/
├── agents/ai_writing/
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
│ ├── state.py # AIWritingState TypedDict(121 行)
│ │
│ ├── nodes/ # ── Graph 节点 ──
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
│ │ ├── intent_parser.py # 节点 1:意图解析
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
│ │ ├── writer_outline.py # 节点 3:大纲规划
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
│ │
│ ├── prompts/ # ── Prompt 模板 ──
│ │ ├── __init__.py
│ │ ├── researcher_prompts.py
│ │ ├── writer_prompts.py
│ │ └── editor_prompts.py
│ │
│ └── search/ # ── 素材检索 provider ──
│ ├── base.py # SearchResult / SearchProvider 接口
│ ├── duckduckgo_search.py
│ ├── internet_search.py # 互联网检索(默认)
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
│ └── demo_search.py # 离线 demo 数据
│
└── persistence/ai_writing_sessions/
├── __init__.py # AIWritingSessionRepository 工厂
├── model.py # ORM:AIWritingSessionRow
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
```
---
## 三、Graph 拓扑(最重要)
### StateGraph 节点 + 边
```
START
│
↓
┌─────────────────┐
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
└────────┬────────┘
↓
┌─────────────────┐
│ researcher │ ← (re_search 回到这里)
└────────┬────────┘
↓ (after_researcher: ⼟材 ready)
┌──────────────────────┐
│ pause_material │ ⚙️ PAUSE-1:素材确认
└─┬──────────┬─────────┘
│ confirm │ re_search
↓ └→ researcher
┌─────────────────┐
│ writer_outline │ ← (re_outline 回到这里)
└────────┬────────┘
↓
┌──────────────────────┐
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
└─┬──────────┬─────────┘
│ confirm │ re_outline
↓ └→ writer_outline
┌─────────────────┐
│ writer_draft │ ← (素材补充后回到这里)
└─┬───────────────┘
↓ (after_writer_draft)
┌──────┴────────────────┐
↓ ↓
┌──────────────────┐ ┌────────────────────┐
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
│ help(素材不足)│ │ │
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
│ supplement │ del/ │ │ │ to_editor
│↓ │ loose │ │ user_revise
research writer_ │ final ↓
draft │ ize writer_draft
│↓
done
│
(to_editor)│
↓
┌─────────────────┐
│ editor │ 3 维度并行流式审核
└────────┬────────┘
↓ (after_review)
┌──────────────────────┐
│ pause_review │ ⚙️ PAUSE-4:审核确认
└─┬──────────┬─────────┘
│ confirm │ revise
│↓ └→ writer_draft
done
```
### 节点定义位置一览
| 节点名 | 实现文件 | 类型 | 流式? |
|---|---|---|---|
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
### 条件路由函数(graph.py)
| 函数 | 在哪个节点之后 | 路由依据 |
|---|---|---|
| `after_researcher` | researcher | 是否找到素材 |
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
| `after_section_help_pause` | pause_section_help | status |
| `after_draft_pause` | pause_draft | done / revising / reviewing |
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
| `after_review_pause` | pause_review | done / writing_draft |
---
## 四、State 定义(state.py)
```python
class AIWritingState(TypedDict):
# —— 写作配置(启动时填充)——
user_intent: str
article_type: str
target_audience: str
word_count_target: int
keyword_count: int # 1-10,默认 6
writing_mode: str # "strict" | "loose"
# —— Agent 产出 ——
material_package: Optional[MaterialPackage] # 素材收集专家
current_outline: Optional[Outline] # 作家
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
current_draft: Optional[DraftArticle] # 最新草稿
review_result: Optional[ReviewResult] # 编辑
# —— 用户干预 ——
last_user_intervention: Optional[UserInterventionPayload]
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
# —— 流程控制 ——
revision_count: int
max_revisions: int # 默认 3
status: WritingStatus
# —— SSE 推送队列 ——
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
```
### State 字段的特殊语义
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
---
## 五、路由层(app/gateway/routers/ai_writing.py)
### 14 个接口一览
| HTTP | 路径 | 用途 | 鉴权 |
|---|---|---|---|
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
```python
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件(供重连重投)
_session_model_names: dict[str, str] # 用户选的模型
_session_interventions: dict[str, list] # 累计干预历史
_session_repos: dict[str, repo] # DB 仓库引用
_session_user_ids: dict[str, str] # 用户 id
_session_article_types: dict[str, list] # 动态文章类型列表
_session_error_flags: dict[str, bool]
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
```
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
### `_stream_graph_segment`(graph 推进函数)
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
5. 任何异常 → 推 `error` 事件 → 关闭 queue
6. finally 段:更新 DB 最终状态、清理所有模块字典
### SSE 生成器 `_sse_generator`
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
**关键逻辑**:
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
- 收到 None 哨兵 → 关闭流
### `_recover_session`(跨 worker 恢复)
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
1. 从 checkpointer 读出 state snapshot
2. 重建 `_event_queues[sid]` 队列
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
返回三种状态:
- `"interrupted"` — 图停在干预点,已重建队列
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
---
## 六、Graph 与持久化(graph.py)
### Lazy 单例 + 自动降级
```python
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
_async_checkpointer_singleton = None # checkpointer 单例
async def get_ai_writing_graph():
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
global AI_WRITING_GRAPH
if AI_WRITING_GRAPH is not None:
return AI_WRITING_GRAPH
checkpointer = await _get_async_checkpointer()
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
return AI_WRITING_GRAPH
```
### Checkpointer 优先级(_get_async_checkpointer)
```
1. config.yaml 的 checkpointer 段
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
└── type: memory → InMemorySaver
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
```
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
### Shutdown
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
调用顺序(详见 `app/gateway/app.py` lifespan):
1. 停清理任务
2. `wait_for_inflight_graph_tasks(30s)`
3. `shutdown_ai_writing_graph()`
---
## 七、节点详解(nodes/)
### `nodes/_utils.py`(公共工具)
| 函数 / 类 | 用途 |
|---|---|
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
| `count_words(text)` | 中文 + 英文混合计字数 |
### `nodes/intent_parser.py` — 意图解析
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
- 输出:更新 state 的对应字段,推 `step_done` 事件
### `nodes/researcher.py` — 素材检索
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
- 用 `search/*` 的 provider 并行检索
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
### `nodes/writer_outline.py` — 大纲规划
- 输入:`user_intent` + `material_package`
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
- 解析最终 JSON → `current_outline`,推 `outline_ready`
### `nodes/writer_draft.py` — 草稿生成(最复杂)
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
### `nodes/editor.py` — 编辑审核(3 维度并行)
- 3 个维度(**fact / logic / language**)并行调 LLM
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
- 三个都完成后聚合成 `review_result`,推 `review_ready`
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
---
## 八、持久化(persistence/ai_writing_sessions/)
### `model.py` — ORM
```python
class AIWritingSessionRow(Base):
__tablename__ = "ai_writing_sessions"
id: Mapped[str] # = session_id(uuid)
user_id: Mapped[str | None] # NULL = no-auth 模式
title: Mapped[str] # 从 user_intent 截取前 50 字
user_intent: Mapped[str | None]
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
draft_title: Mapped[str | None]
draft_markdown: Mapped[str | None]
completed_interventions: Mapped[str | None] # JSON 字符串
review_result: Mapped[str | None] # JSON 字符串
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
created_at / updated_at: BeijingDateTime
```
### `sql.py` — Repository
| 方法 | 用途 |
|---|---|
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
| `list(user_id, limit)` | 列出当前用户的会话 |
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
| `delete(session_id, user_id)` | 删除(带权限校验) |
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
### Alembic 迁移
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
---
## 九、定期清理(ai_writing_cleanup.py)
### 设计目标
- 删超过 `retention_days`(默认 7)的旧会话
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
- **绝对不删正在写的会话**(活跃保护)
### 关键函数
```python
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
```
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
执行流程:
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
5. 循环直到本批 < batch_size
### Cron 调度
`cleanup_scheduler_loop()` 常驻后台任务:
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
- `asyncio.sleep` 到下次触发时间
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
### Admin 手动触发
`POST /api/ai-writing/admin/cleanup`:
- 鉴权:`system_role == "admin"`
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
---
## 十、配置(config.yaml)
```yaml
# Checkpointer:graph 状态持久化
checkpointer:
type: sqlite # sqlite | postgres | memory
connection_string: .deer-flow/data/checkpoints.db
# AI 写作子系统
ai_writing:
# ── 清理调度 ─────────────────────────────────────────────
cleanup:
enabled: true
retention_days: 7
cron: "0 3 * * *" # 每天凌晨 3 点
delete_only_finished: false
batch_size: 500
# ── 素材收集专家(researcher)专用配置 ──────────────────
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
# 取值范围 [1, 100],非法值或缺省回退 20。
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
max_materials: 20
# ── 素材检索 provider ───────────────────────────────────
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
search_provider: internet
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
ddg_region: "cn-zh"
# intranet 模式:对接内网知识库 ES 检索接口
# 接口契约(POST):
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
```
### 配置项与代码的对应关系
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|---|---|---|---|
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
### 配置如何传到节点
```
config.yaml
└─ ai_writing: { ... }
│
▼ _ai_writing_config() 取出整个 dict
routers/ai_writing.py:_make_config()
│ 注入到 config.configurable.ai_writing_config
▼
nodes/researcher.py
cfg = config.get("configurable", {})
ai_writing_config = cfg.get("ai_writing_config", {})
keyword_model = ai_writing_config.get("keyword_model")
rank_model = ai_writing_config.get("rank_model")
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
```
### 切换内网检索的最小步骤
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
### 备注
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
---
## 十一、SSE 事件协议(后端 → 前端契约)
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
### 推出事件的位置
| 事件 | 推出位置 |
|---|---|
| `connected` | `_sse_generator` 入口 |
| `step_start` / `step_done` | 节点内部主动 emit |
| `research_progress` | `researcher_node` 内的 `_emit` |
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
| `resumed` | pause 节点 return 时塞进 `progress_events` |
| `notice` | 节点内手动推(非致命告警) |
| `done` | `_stream_graph_segment` 正常结束 |
| `error` | `_stream_graph_segment` except 分支 |
### 节点如何流式推 chunk
通过 `config.configurable.sse_queue`:
```python
async def writer_outline_node(state, config):
sse_queue = config.get("configurable", {}).get("sse_queue")
sse_session_id = config.get("configurable", {}).get("sse_session_id")
async for chunk in llm.astream(prompt):
if sse_queue:
await sse_queue.put(("outline_chunk", {
"session_id": sse_session_id,
"text": chunk.content,
"is_thinking": False,
}))
return {"current_outline": parsed, ...}
```
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
---
## 十二、扩展指南:常见开发任务
### 任务 1:新增一个 Graph 节点
**例**:在 `editor` 之后加一个「术语统一」节点。
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
```python
async def terminology_node(state, config):
# ... 读 current_draft 改术语
return {
"current_draft": new_draft,
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
}
```
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
```python
from .nodes.terminology import terminology_node
g.add_node("terminology", terminology_node)
# 改 editor 后的边
g.add_edge("editor", "terminology")
g.add_edge("terminology", "pause_review")
```
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
### 任务 2:新增一个用户干预点
1. **写 pause 节点**:在 `graph.py` 顶部加:
```python
async def pause_xxx(state):
user_input = interrupt({
"pause_point": "xxx_confirm",
"message": "...",
"context_field": state["..."],
})
action = user_input.get("action", "confirm")
if action == "confirm":
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
# ... 其他分支
```
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
```python
class ResumeRequest(BaseModel):
# ...
new_field: Optional[str] = None
```
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
```python
intervention = {
# ...
"newField": body.new_field,
}
```
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
5. **前端配合**:见前端文档
### 任务 3:新增一种素材检索 provider
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
```python
class MyProvider(SearchProvider):
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
...
```
2. 配置 `config.yaml`:
```yaml
ai_writing:
search:
provider: my_pkg.search:MyProvider
# provider 特定的配置...
```
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
### 任务 4:修改 LLM Prompt
所有 prompt 集中在 `agents/ai_writing/prompts/`:
- `researcher_prompts.py` — 检索专家
- `writer_prompts.py` — 大纲 + 草稿
- `editor_prompts.py` — 审核
改 prompt 后**强烈建议**:
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
2. 跑现有测试确认拓扑兼容
### 任务 5:扩展持久化字段
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
1. `model.py` 加列:
```python
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
```
2. **必须**新建 alembic 迁移:
```bash
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
```
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
---
## 十三、容错策略(关键设计原则)
### 「写作流程能完整继续」契约
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
### 落实位置
| 失败点 | 兜底位置 | 行为 |
|---|---|---|
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
| DB `update(review)` 失败 | 同上 | 同上 |
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
### 容器优雅关闭
lifespan shutdown 顺序:
1. 停 cleanup scheduler
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
3. 超时未结束的 task `cancel()` + 2s ack 等待
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
---
## 十四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|---|---|---|
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
跑全部:
```bash
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
```
期望 **51 个测试全过**。
---
## 十五、关键调试 / 排查命令
### 看 graph 当前状态
```python
# 进 python shell
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
print(snap.values, snap.next, snap.tasks)
```
### 直接读 sqlite checkpointer
```bash
sqlite3 /app/.deer-flow/data/checkpoints.db
.tables # 看表(checkpoints / checkpoint_writes 等)
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
WHERE thread_id = '<session_id>' LIMIT 5;
```
### 查 MySQL 业务行
```sql
SELECT id, status, draft_title, updated_at,
JSON_LENGTH(transcript) AS transcript_size
FROM ai_writing_sessions
WHERE id = '<session_id>';
```
### 查内存中的活跃会话(调试时手动加)
```python
# 临时在路由加一个 admin 接口
@router.get("/admin/inflight")
async def admin_inflight():
return {
"event_queues": list(_event_queues.keys()),
"inflight_tasks": len(_inflight_graph_tasks),
"pending_pause": list(_session_pending_pause.keys()),
}
```
### 日志关键字
```bash
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
```
期望看到(启动时):
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
警告:
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
---
## 十六、性能与限制
### 单 session 资源占用
- 内存:~50KB(state + progress_events 累计)
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
### 并发上限
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
### 长会话风险
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
### 优化方向
- progress_events 节流(聚合 chunk 事件)
- transcript 增量保存(diff 而非全量覆盖)
- 节点级 LLM 调用加 retry(tenacity)
---
## 十七、与主 Agent 的关系
| 维度 | 主 Agent | AI 写作 |
|---|---|---|
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
| Middleware | 18 层中间件 | 无(直接节点链) |
| State | `ThreadState` | `AIWritingState` |
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
---
## 十八、未来 RFC 方向
| 主题 | 描述 |
|---|---|
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
| 多模态素材 | 支持图片素材输入和图文混合输出 |
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
---
## 附录 A:相关文件速查
| 文件 | 行数 | 用途 |
|---|---|---|
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
---
## 附录 B:常见踩坑
| 现象 | 根因 | 解决 |
|---|---|---|
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -1,726 +0,0 @@
# AI 写作助手后端接口与 Prompt 协议文档
## 1. 文档目标
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
---
## 2. 本轮后端职责
后端负责:
- 接收当前线程信息
- 接收用户选区文本
- 接收改写动作
- 选取必要上下文
- 调用模型生成候选改写结果
- 返回纯净候选文本
后端本轮不负责:
- 直接编辑 Tiptap JSON
- 直接操作前端编辑器选区
- 直接替换前端文档内容
- 做复杂富文本 patch
- 做 tracked changes
---
## 3. 设计原则
### 3.1 纯文本协议优先
首版只让后端处理:
- 选区文本
- 前后文文本
- 动作类型
- 风格参数
不让后端直接理解完整富文本 JSON。
### 3.2 前端负责应用,后端负责生成
- 前端负责选区获取
- 前端负责候选结果展示
- 前端负责替换 / 插入
- 后端只负责“给出候选文本”
### 3.3 结果必须可直接落文档
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
- “当然可以,以下是润色版本:”
- “我帮你改写如下:”
- “下面是优化后的内容:”
这类内容必须禁止。
---
## 4. 推荐接口设计
## 4.1 主接口
推荐新增统一接口:
```text
POST /api/writing/rewrite
```
由 `action` 决定具体重写类型。
## 4.2 为什么推荐统一接口
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
- 前端判断分支增多
- 埋点与异常处理分散
- 后续新增动作不方便扩展
统一接口更适合本轮需求。
---
## 5. 请求体定义
建议请求体如下:
```json
{
"threadId": "string",
"documentId": "string",
"selection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"action": "polish",
"tone": "professional",
"surroundingContext": {
"before": "选区前文",
"after": "选区后文"
}
}
```
---
## 6. 字段说明
### 6.1 `threadId`
用途:
- 关联当前 DeerFlow 线程
- 必要时取线程上下文或会话元数据
### 6.2 `documentId`
用途:
- 标识当前右侧文档
- 首版可选
- 如果当前系统还没有独立文档 ID,可先允许为空
### 6.3 `selection.from` / `selection.to`
用途:
- 帮助前后端联调排查
- 首版后端通常不需要真正使用偏移量
- 建议保留,便于日志定位
### 6.4 `selection.text`
用途:
- 这是本轮最核心输入
- 后端实际重写的主文本
### 6.5 `action`
允许值:
- `polish`
- `expand`
- `shorten`
- `tone`
### 6.6 `tone`
仅当 `action = tone` 时使用。
建议允许值:
- `professional`
- `casual`
- `readable`
- `academic`
- `expressive`
### 6.7 `surroundingContext.before`
用途:
- 提供选区前文
- 帮助模型理解语气、主语、叙事连续性
建议:
- 控制长度
- 不要塞太多无关上下文
### 6.8 `surroundingContext.after`
用途:
- 提供选区后文
- 帮助模型在扩写、改语气时与后续内容衔接
---
## 7. 返回体定义
建议返回体如下:
```json
{
"output": "AI 改写后的候选文本",
"originalSelection": {
"from": 120,
"to": 260,
"text": "待改写文本"
},
"metadata": {
"action": "polish",
"tone": null,
"model": "your-model-name"
}
}
```
---
## 8. 返回字段要求
## 8.1 `output`
必须:
- 只包含候选正文
- 尽量不夹带解释语
- 能直接被前端替换或插入
## 8.2 `originalSelection`
作用:
- 前端可以做校验
- 联调时方便确认是针对哪段文本返回的
## 8.3 `metadata`
作用:
- 便于埋点
- 便于日志记录
- 便于问题追踪
---
## 9. 错误响应规范
建议统一错误结构:
```json
{
"error": {
"code": "WRITING_REWRITE_FAILED",
"message": "rewrite failed"
}
}
```
建议错误码:
- `WRITING_REWRITE_FAILED`
- `WRITING_INVALID_ACTION`
- `WRITING_INVALID_TONE`
- `WRITING_EMPTY_SELECTION`
- `WRITING_MODEL_UNAVAILABLE`
- `WRITING_CONTEXT_TOO_LARGE`
---
## 10. 动作语义定义
## 10.1 `polish`
目标:
- 保持原意
- 改善表达
- 提升通顺度、自然度和可读性
不应:
- 引入新事实
- 改写成完全不同内容
## 10.2 `expand`
目标:
- 保持主旨
- 扩充细节
- 增强画面感、解释性或完整度
不应:
- 无限制发散
- 添加明显脱离主题的新内容
## 10.3 `shorten`
目标:
- 压缩篇幅
- 保留关键信息
- 减少重复表达
不应:
- 丢失关键论点
- 压缩到语义残缺
## 10.4 `tone`
目标:
- 不改变原意
- 调整语言风格
不应:
- 改写事实
- 改写逻辑结构到完全不同
---
## 11. Prompt 设计原则
## 11.1 通用系统约束
所有改写类 prompt 都应包含以下约束:
- 直接输出最终可用正文
- 不输出标题,除非原文本身就是标题改写
- 不输出解释
- 不输出分析过程
- 不输出“以下是结果”之类前缀
- 保持与上下文语气一致
## 11.2 上下文使用原则
前后文只用于理解,不要求模型重写前后文。
明确告诉模型:
- 只改写选中文本
- 前后文仅用于参考衔接
## 11.3 长度控制原则
- `polish`:长度与原文接近
- `expand`:长度可明显增加,但不要失控
- `shorten`:长度明显缩短,但要保留关键信息
- `tone`:长度尽量与原文接近
---
## 12. Prompt 模板建议
## 12.1 润色模板
```text
你是一名中文写作编辑助手。
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
要求:
1. 保留原文事实和核心意思。
2. 不要添加原文没有的新事实。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出润色后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.2 扩写模板
```text
你是一名中文写作编辑助手。
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
要求:
1. 保持原文主旨不变。
2. 增加内容时要自然,不要跑题。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出扩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.3 缩写模板
```text
你是一名中文写作编辑助手。
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
要求:
1. 不要丢掉核心信息。
2. 去掉重复、冗余和拖沓表达。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出缩写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
## 12.4 改语气模板
```text
你是一名中文写作编辑助手。
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
要求:
1. 保持原文信息不变。
2. 只调整表达风格,不改变事实和逻辑。
3. 不要输出解释,不要输出前缀,不要输出分析过程。
4. 直接输出改写后的正文。
前文参考:
{before}
选中文本:
{selection}
后文参考:
{after}
```
---
## 13. Tone 映射建议
建议前端传标准值,后端映射中文标签:
- `professional` -> 专业、正式
- `casual` -> 口语化、轻松自然
- `readable` -> 更易读、更清晰
- `academic` -> 学术、严谨
- `expressive` -> 更有文采、更有表现力
---
## 14. 上下文截取策略
## 14.1 推荐策略
前端传:
- 选区前固定长度文本
- 选区后固定长度文本
例如:
- 前文 200 到 500 字
- 后文 200 到 500 字
### 原因
- 足够帮助模型理解文气和衔接
- 不会让请求太重
## 14.2 不建议
- 把整篇长文全量塞进去
- 把整个线程消息都塞进改写接口
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
---
## 15. LangGraph 接入建议
## 15.1 推荐接入方式
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
建议二选一:
### 方案 A
新增专用 router:
- `POST /api/writing/rewrite`
内部调用现有模型层 / agent 层。
### 方案 B
新增专用 graph node 或 tool:
- `rewrite_selection`
再由 router 调用该能力。
## 15.2 更推荐方案
推荐:
- 对外暴露统一 router
- 内部走已有模型封装或 LangGraph 节点
这样前端接口更稳定,后续后端内部重构不会影响前端。
---
## 16. 后端模块拆解
## 16.1 模块 A:接口层
负责:
- 请求校验
- 错误响应
- 参数标准化
需要完成:
- 统一 rewrite API
- action / tone 校验
- 空选区校验
## 16.2 模块 B:上下文拼装层
负责:
- 拼装 before / selection / after
- 必要时提取线程相关信息
需要完成:
- 上下文长度控制
- 清洗空白和异常字符
## 16.3 模块 C:Prompt 层
负责:
- 选择模板
- 填充 action 和 tone
需要完成:
- 4 类动作模板
- tone 映射
## 16.4 模块 D:模型调用层
负责:
- 走现有模型配置体系
- 执行大模型请求
需要完成:
- 统一调用入口
- 模型不可用错误处理
## 16.5 模块 E:结果清洗层
负责:
- 去掉解释性前缀
- 去掉多余包裹
- 标准化返回
需要完成:
- 输出清洗
- metadata 填充
---
## 17. 返回结果清洗建议
即使 prompt 已经约束,模型仍可能输出:
- “以下是润色后的内容:”
- “当然,这里是改写结果:”
- “优化如下:”
建议后端加一层轻量清洗:
- 去掉常见说明性前缀
- 去掉包裹性 markdown 代码块
- 去掉多余首尾空白
注意:
- 清洗必须保守
- 不要误删正文
---
## 18. 日志与观测建议
建议记录:
- `threadId`
- `documentId`
- `action`
- `tone`
- `selection_length`
- `before_length`
- `after_length`
- `model_name`
- `latency_ms`
- `success / fail`
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
---
## 19. 后端测试清单
## 19.1 单元测试
- action 校验
- tone 校验
- 空选区校验
- prompt 选择逻辑
- 输出清洗逻辑
## 19.2 集成测试
- `polish`
- `expand`
- `shorten`
- `tone-professional`
- `tone-casual`
- `tone-readable`
- `tone-academic`
- `tone-expressive`
## 19.3 边界测试
- 选区过短
- 选区过长
- 前后文为空
- 多段文本带换行
- 模型超时
- 模型不可用
---
## 20. 联调约束
## 20.1 前后端必须统一
- `action` 枚举值
- `tone` 枚举值
- 错误码
- 返回体字段名
## 20.2 联调重点
- 多段落文本中换行保留是否正确
- AI 返回内容是否适合直接落文档
- 超时和失败时前端是否能优雅重试
## 20.3 联调建议顺序
1. 先 mock 返回固定文本
2. 再接真实模型
3. 先验证单段落
4. 再验证多段落
5. 最后验证大段文本
---
## 21. 优先级建议
### P0
- 统一 rewrite API
- 4 类动作支持
- 输出纯净正文
- 错误结构标准化
### P1
- 输出清洗增强
- 埋点与日志
- 上下文截断策略优化
### P2
- 更细粒度风格控制
- 结果质量二次校验
---
## 22. 完成定义
后端满足以下条件即可视为本轮完成:
- 前端能通过统一接口发起改写
- 4 类动作均可稳定返回候选文本
- 返回文本基本可直接用于替换或插入
- 多段落文本能稳定处理
- 错误结构规范统一
---
## 23. 结论
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
最稳妥的路线是:
- 统一接口
- 统一动作枚举
- 统一 prompt 模板
- 统一错误结构
- 统一输出约束
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
@@ -1,703 +0,0 @@
# AI 写作助手前端开发任务清单
## 1. 文档目标
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
目标是让前端团队按模块并行推进,最终完成:
- 保留左侧聊天区
- 右侧升级为 `Tiptap` 写作编辑器
- 支持选区浮层
- 支持润色 / 扩写 / 缩写 / 改语气
- 支持替换 / 插入
- 支持标题 / 列表 / 引用 / 代码块工具条
- UI 参考豆包 + Novel + open-canvas
---
## 2. 本轮前端改造范围
### 2.1 保留不动
- 左侧聊天主链路
- 现有线程消息流
- 现有模型选择器
- 现有输入框的 `writing_mode` 总入口
### 2.2 重点改造
- 右侧 Artifact 面板承载方式
- 新增独立写作工作区
- 富文本编辑器内核
- 选区浮层
- AI 改写结果预览
- 文档级工具条
---
## 3. 推荐目录结构
建议新增独立模块目录:
```text
frontend-web/src/open-canvas-writing/
routes/
WritingRoutes.tsx
pages/
WritingWorkspacePage.tsx
components/
WritingShell.tsx
WritingHeader.tsx
WritingToolbar.tsx
WritingEditorPane.tsx
WritingBubbleMenu.tsx
WritingAiMenu.tsx
WritingRewritePreview.tsx
WritingStatusBar.tsx
WritingEmptyState.tsx
editor/
TiptapEditor.tsx
TiptapExtensions.ts
commands.ts
selection.ts
context/
WritingEditorContext.tsx
hooks/
useWritingEditor.ts
useWritingSelection.ts
useWritingRewrite.ts
useWritingToolbar.ts
api/
rewrite.ts
documents.ts
types/
document.ts
rewrite.ts
utils/
apply-rewrite.ts
markdown.ts
editor-style.ts
```
---
## 4. 现有代码落点
## 4.1 需要接入的现有文件
- `frontend-web/src/pages/PageRoutes.tsx`
- 增加新写作路由入口
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
- 当前双栏布局参考来源
- 后续决定是否复用布局容器,或在新页面中重建双栏
- `frontend-web/src/components/workspace/input-box.tsx`
- 保留 `writing_mode`
- 后续可在切换写作模式时跳转到新写作页面
- `frontend-web/src/components/workspace/artifacts/context.tsx`
- 仅保留给旧 Artifact 逻辑
- 不要继续承载写作编辑器状态
## 4.2 不建议继续沿用的方式
- 不要把编辑器主状态存成 `string[] artifacts`
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
- 不要让 AI 改写结果先回聊天,再由用户手动复制
---
## 5. 任务分阶段拆解
## 阶段 A:路由与页面壳
### 目标
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
### 任务
- 新增 `WritingRoutes.tsx`
- 新增 `WritingWorkspacePage.tsx`
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
- 确认新页面进入方式
- 确定是:
- 通过左侧聊天上下文进入
- 通过 `writing_mode` 入口进入
- 或二者都支持
### 交付物
- 新写作页面能被路由访问
- 页面有基础左右布局和空态
### 验收
- 打开 `page/writing` 能进入工作区
- 页面不报错
---
## 阶段 B:编辑器底座
### 目标
把右侧正式替换成富文本编辑器。
### 任务
- 安装 Tiptap 依赖
- 建立 `TiptapEditor.tsx`
- 建立 `TiptapExtensions.ts`
- 配置节点:
- paragraph
- heading
- bulletList
- orderedList
- listItem
- blockquote
- codeBlock
- hardBreak
- 配置 marks:
- bold
- italic
- underline
- strike
- code
- 建立编辑器初始化逻辑
- 建立编辑器销毁逻辑
- 定义最小文档状态
### 交付物
- 可编辑正文区域
- 初始文档可加载
- 内容可读可写
### 验收
- 可以直接输入正文
- 可以换行、加标题、切换列表
- 不出现内容错位和明显闪烁
---
## 阶段 C:主工具栏
### 目标
右侧具备正式写作工具栏。
### 任务
- 新增 `WritingToolbar.tsx`
- 新增 `useWritingToolbar.ts`
- 实现按钮:
- 正文
- H1
- H2
- H3
- 有序列表
- 无序列表
- 引用
- 代码块
- 加粗
- 斜体
- 下划线
- 删除线
- 处理 active 状态
- 处理不可用状态
- 处理光标块状态联动
### 交付物
- 主工具栏可用
### 验收
- 工具条按钮能正确反映当前块状态
- 操作后编辑器内容正确变化
---
## 阶段 D:选区状态与浮层
### 目标
实现豆包式选区浮层基础能力。
### 任务
- 新增 `useWritingSelection.ts`
- 新增 `selection.ts`
- 新增 `WritingBubbleMenu.tsx`
- 基于 Tiptap 监听 selection 变化
- 过滤掉空选区
- 处理多段连续选区
- 浮层按钮:
- AI 改写
- 加粗
- 斜体
- 标题
- 复制
- 处理滚动定位
- 处理窗口尺寸变化
### 交付物
- 选中内容后出现浮层
### 验收
- 单段可触发
- 多段可触发
- 浮层位置稳定
---
## 阶段 E:AI 改写菜单
### 目标
把选区浮层接到 AI 改写工作流。
### 任务
- 新增 `WritingAiMenu.tsx`
- 新增 `useWritingRewrite.ts`
- 新增 `api/rewrite.ts`
- 实现一级动作:
- 润色
- 扩写
- 缩写
- 改语气
- 实现改语气二级菜单:
- 更专业
- 更口语化
- 更易读
- 更学术
- 更有文采
- 封装请求入参
- 增加 loading 态
- 增加 error 态
- 增加 retry 入口
### 交付物
- AI 改写菜单和请求链路
### 验收
- 4 个动作都能发请求
- 改语气的二级菜单可正常展开
- 请求失败时有反馈
---
## 阶段 F:候选结果预览
### 目标
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
### 任务
- 新增 `WritingRewritePreview.tsx`
- 新增 `apply-rewrite.ts`
- 显示原文与候选内容
- 提供按钮:
- 替换
- 插入
- 重试
- 取消
- 用 transaction 应用结果
- 处理应用成功后的焦点与滚动
### 交付物
- 改写结果预览卡片或面板
### 验收
- 替换准确
- 插入准确
- 取消后文档保持原样
---
## 阶段 G:文档状态与容器体验
### 目标
补齐正式产品的壳层体验。
### 任务
- 新增 `WritingShell.tsx`
- 新增 `WritingHeader.tsx`
- 新增 `WritingStatusBar.tsx`
- 新增 `WritingEmptyState.tsx`
- 补充文档标题区域
- 补充文档状态提示
- 补充空态和加载态
### 交付物
- 页面完整壳层
### 验收
- 页面不是裸编辑器
- 有完整的文档工作区感
---
## 阶段 H:视觉打磨
### 目标
让整体观感接近成熟产品。
### 任务
- 调整右侧阅读宽度
- 调整字体大小和行高
- 调整段距和标题层级
- 调整工具栏阴影、边框、圆角
- 调整浮层动效
- 调整 AI 预览卡片质感
- 优化深浅色模式兼容
### 交付物
- 完整视觉优化
### 验收
- 明显优于当前版本
- 有豆包 / Novel 风格的编辑器质感
---
## 6. 组件级开发清单
## 6.1 `WritingWorkspacePage.tsx`
职责:
- 页面级容器
- 获取线程 / 文档上下文
- 组织左右布局
必须完成:
- 页面初始化
- 与左侧聊天协同
- 右侧编辑区挂载
## 6.2 `WritingShell.tsx`
职责:
- 写作工作区总壳层
- 布局标题区、工具栏、正文区、状态区
必须完成:
- 自适应布局
- 桌面端优先
- 窄屏降级
## 6.3 `TiptapEditor.tsx`
职责:
- 包装 Tiptap EditorContent
- 负责 editor 实例初始化
必须完成:
- `onUpdate`
- `onSelectionUpdate`
- 内容同步
## 6.4 `WritingToolbar.tsx`
职责:
- 顶部主工具栏
必须完成:
- 所有基础格式化命令
- 当前状态高亮
## 6.5 `WritingBubbleMenu.tsx`
职责:
- 选区浮层
必须完成:
- 跟随选区
- 基础快捷操作
- AI 改写入口
## 6.6 `WritingAiMenu.tsx`
职责:
- AI 改写动作菜单
必须完成:
- 一级菜单
- 改语气二级菜单
- 动作触发
## 6.7 `WritingRewritePreview.tsx`
职责:
- 展示候选内容
必须完成:
- 替换
- 插入
- 重试
- 取消
---
## 7. Hook 级开发清单
## 7.1 `useWritingEditor.ts`
负责:
- editor 生命周期
- 初始内容装载
- 内容变更同步
## 7.2 `useWritingSelection.ts`
负责:
- 当前选区抽取
- 选区文本缓存
- 多段落纯文本拼接
## 7.3 `useWritingRewrite.ts`
负责:
- 改写请求
- loading / success / error
- 结果缓存
- 重试
## 7.4 `useWritingToolbar.ts`
负责:
- toolbar active 状态
- editor command 映射
---
## 8. API 对接清单
前端需向后端约定以下接口能力:
- `POST /api/writing/rewrite`
前端请求内容至少包括:
- `threadId`
- `documentId`
- `selection.from`
- `selection.to`
- `selection.text`
- `action`
- `tone`
- `surroundingContext.before`
- `surroundingContext.after`
前端需处理的返回字段:
- `output`
- `originalSelection`
- `metadata`
---
## 9. 状态管理建议
建议新增独立 Context:
- `WritingEditorContext.tsx`
建议持有:
- 当前文档内容
- 当前 editor 实例
- 当前选区
- 当前改写请求状态
- 当前候选结果
- 当前 UI 面板状态
不建议继续复用旧 Artifact Context 的原因:
- 旧 Context 关注文件列表和文件路径
- 新编辑器关注选区、结构、候选结果、应用动作
- 关注点完全不同
---
## 10. 前端联调检查点
## 10.1 与后端联调前
- 先用 mock 数据打通浮层和预览
- 先验证替换 / 插入逻辑
## 10.2 与后端联调时
- 检查多段落选区拼接结果
- 检查 AI 返回内容是否带说明性前缀
- 检查替换后位置是否准确
## 10.3 联调后
- 验证大段文本性能
- 验证撤销 / 重做
- 验证快速连续触发是否会状态错乱
---
## 11. 前端测试清单
## 11.1 功能测试
- 输入正文
- 插入标题
- 切换列表
- 插入引用
- 插入代码块
- 加粗 / 斜体 / 下划线 / 删除线
- 选区浮层显示
- 选区浮层隐藏
- AI 改写菜单打开
- 改语气二级菜单打开
- 改写结果替换
- 改写结果插入
- 改写结果取消
## 11.2 场景测试
- 单段短文本润色
- 多段文本润色
- 多段文本扩写
- 多段文本缩写
- 多段文本改语气
- 连续两次改写同一段
- 快速切换线程后重新进入
## 11.3 回归测试
- 左侧聊天是否正常
- 模型切换是否正常
- `writing_mode` 是否正常
- 原有 artifact 模块是否仍可使用
---
## 12. 任务优先级
### P0
- 路由与页面壳
- Tiptap 接入
- 主工具栏
- 选区浮层
- 改写菜单
- 替换 / 插入
### P1
- 页面头部与状态
- 优雅 loading / error / retry
- 多段落稳定性
- UI 打磨
### P2
- 空态优化
- 动效优化
- 快捷键支持
---
## 13. 建议分工
### 前端 1 号
负责:
- 路由与页面壳
- Tiptap 接入
- 主工具栏
### 前端 2 号
负责:
- 选区状态
- BubbleMenu
- AI 菜单
### 前端 3 号
负责:
- 候选结果预览
- 替换 / 插入
- 状态栏与页面体验
### 联调负责人
负责:
- 前后端请求协议
- 错误态规范
- 多段落测试和回归
---
## 14. 前端完成定义
满足以下条件可视为前端完成:
- 右侧能作为正式编辑器独立使用
- 选区后可直接发起 AI 改写
- 改写后可替换或插入
- 视觉上明显优于当前版本
- 左侧聊天和现有系统不被破坏
---
## 15. 下一步建议
前端团队可以按以下顺序开工:
1. 先起页面和路由
2. 再接 Tiptap 与主工具栏
3. 再做选区浮层
4. 再接 AI 菜单和结果预览
5. 最后做视觉打磨
这个顺序风险最小,也最容易在中途验收。
@@ -1,457 +0,0 @@
# AI 写作助手源码与实现映射表
## 1. 文档目标
本文档用于把以下几类信息对应起来:
- 豆包 AI 写作助手里要借鉴的交互
- Novel / open-canvas / Tiptap 中可参考的实现思路
- DeerFlow 当前项目里已经存在的落点
- 本次新建模块应该落到哪里
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
---
## 2. 使用方式
开发时建议按以下顺序看:
1. 先看“目标能力”
2. 再看“参考来源”
3. 再看“DeerFlow 当前落点”
4. 最后看“本次目标文件”
这样可以避免出现两种常见问题:
- 直接照搬外部项目结构,结果和你们现有架构冲突
- 只看自己项目,不知道成熟产品交互应该怎么落
---
## 3. 总体映射
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
| --- | --- | --- | --- | --- |
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
---
## 4. 豆包功能到 DeerFlow 的映射
## 4.1 滑词 / 选区浮层
### 豆包里的体验
- 选中文本后出现轻量浮层
- 浮层里有:
- AI 改写
- 文本样式
- 复制
### DeerFlow 当前状态
- 右侧还没有正式的选区级工具层
### 本次实现方式
- 使用 Tiptap 的 `BubbleMenu`
- 再叠加自定义菜单逻辑
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
---
## 4.2 多段落改写
### 豆包里的体验
- 不是只改单句
- 能对连续几段一起润色、扩写、缩写、改语气
### DeerFlow 当前状态
- 现有右侧编辑区没有正式的多段落选区改写模型
### 本次实现方式
- 前端提取连续选区纯文本
- 前端附带前后文
- 后端返回候选纯文本
- 前端决定替换或插入
### 本次落点
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
---
## 4.3 替换 / 插入
### 豆包里的体验
- 改写结果不是直接覆盖原文
- 用户可选择:
- 替换
- 插入
### DeerFlow 当前状态
- 当前没有候选文本应用层
### 本次实现方式
- 建立候选结果预览区
- 基于 Tiptap transaction 应用文本
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
---
## 4.4 标题 / 列表 / 引用 / 代码块工具条
### 豆包里的体验
- 右侧本身就是正式文档编辑器
- 可以快速切换文档结构
### DeerFlow 当前状态
- 当前右侧偏预览,不是正式写作编辑器
### 本次实现方式
- 使用 Tiptap 节点和命令
- 在主工具栏中集中承载
### 本次落点
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
---
## 5. Novel 参考映射
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
- 编辑器主容器的产品感
- 工具栏和空态的组织方式
- 正文阅读宽度、排版、留白
## 5.2 对应到 DeerFlow 的实现位置
- 页面壳:`WritingShell.tsx`
- 顶部文档区:`WritingHeader.tsx`
- 编辑容器:`TiptapEditor.tsx`
- 工具栏:`WritingToolbar.tsx`
- 空态:`WritingEmptyState.tsx`
## 5.3 不建议直接照搬的部分
- Next.js 页面结构
- 依赖其服务端 AI 调用方式
- 与 Vercel AI SDK 深绑定的部分
---
## 6. open-canvas 参考映射
## 6.1 主要借鉴对象
open-canvas 这次最值得借鉴的不是页面外观,而是:
- AI 工作流组织方式
- 改写结果应用节奏
- 编辑器和聊天协作的模式
## 6.2 借鉴重点
### 借鉴 1:独立工作区思想
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
### 借鉴 2:画布状态独立维护
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
### 借鉴 3:AI 输出先预览,再应用
对应 DeerFlow:
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
---
## 7. Tiptap 参考映射
## 7.1 Tiptap 在本项目中的职责
- 作为右侧正式富文本编辑器内核
- 提供结构节点与 marks
- 提供 BubbleMenu 能力
- 提供 transaction 应用能力
## 7.2 建议直接参考的实现点
- `EditorContent` 的封装模式
- `BubbleMenu` 的接入方式
- heading / list / blockquote / codeBlock 扩展配置
- `editor.chain().focus()` 命令调用模式
- selection 变化监听
## 7.3 DeerFlow 本次落点
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
---
## 8. DeerFlow 现有文件到新模块的映射
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
### 当前职责
- 汇总页面路由
### 本次作用
- 挂入新的写作助手路由
### 后续动作
- 引入 `WritingRoutes`
- 增加 `page/writing/*`
---
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
### 当前职责
- 左聊天 + 右 Artifact 双栏布局
### 本次作用
- 提供双栏工作区组织思路
- 可复用布局比例和 panel 交互经验
### 不建议直接复用的部分
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
### 本次目标替代
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
---
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
### 当前职责
- 管理文件型 artifact 列表、选中项和开关状态
### 本次作用
- 仅作为旧 Artifact 模块保留
### 本次目标替代
- `WritingEditorContext.tsx`
### 原因
- 写作编辑器需要的不是文件路径状态
- 而是文档状态、选区状态、候选结果状态、工具层状态
---
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
### 当前职责
- 聊天输入区
- 已有 `writing_mode` 入口
### 本次作用
- 保留为用户进入写作模式的现有入口
### 本次改造建议
- 写作模式打开时,跳转或唤起新的写作工作区
- 不再只停留在“生成 Markdown 文件”这个层面
---
## 9. 新模块间的依赖关系
## 9.1 页面层
- `WritingRoutes.tsx`
- `WritingWorkspacePage.tsx`
- `WritingShell.tsx`
职责:
- 页面路由
- 双栏或工作区布局
- 上下文装配
## 9.2 编辑器层
- `TiptapEditor.tsx`
- `TiptapExtensions.ts`
- `commands.ts`
- `selection.ts`
职责:
- 编辑器内核
- 节点和 marks
- 基础命令
- 选区抽取
## 9.3 交互层
- `WritingToolbar.tsx`
- `WritingBubbleMenu.tsx`
- `WritingAiMenu.tsx`
- `WritingRewritePreview.tsx`
职责:
- 工具栏
- 浮层
- AI 菜单
- 候选内容应用
## 9.4 状态层
- `WritingEditorContext.tsx`
- `useWritingEditor.ts`
- `useWritingSelection.ts`
- `useWritingRewrite.ts`
- `useWritingToolbar.ts`
职责:
- 主状态
- 选区状态
- 改写状态
- 工具栏状态
## 9.5 API 层
- `api/rewrite.ts`
- `api/documents.ts`
职责:
- 对接后端写作改写接口
- 对接文档加载与保存接口
---
## 10. 能力到文件的精确映射
| 能力 | 新文件 | 说明 |
| --- | --- | --- |
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
---
## 11. 开发时的参考顺序
建议开发顺序如下:
1. 先看 `PageRoutes.tsx`,确定路由接法
2. 再看 `chat-box.tsx`,理解当前左右布局
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
4. 再接 `TiptapEditor.tsx`
5. 再做 `WritingToolbar.tsx`
6. 再做 `WritingBubbleMenu.tsx`
7. 再做 `WritingAiMenu.tsx`
8. 最后做 `WritingRewritePreview.tsx`
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
---
## 12. 明确哪些是“参考”,哪些是“目标”
### 12.1 参考
- 豆包:交互体验参考
- Novel:页面和编辑器质感参考
- open-canvas:AI 工作流参考
- Tiptap:编辑器技术实现参考
### 12.2 目标
目标不是复刻任何一个外部项目,而是:
- 在 DeerFlow 现有架构中
- 用 React + Vite + 现有线程体系
- 做出一个更成熟的 AI 写作编辑器模式
---
## 13. 结论
这份映射表的核心作用,是避免团队在开发时迷路。
真正推荐的姿势是:
- 用豆包定义目标体验
- 用 Novel 定义页面质感
- 用 open-canvas 定义 AI 工作流
- 用 Tiptap 作为编辑器内核
- 用 DeerFlow 现有路由、线程、模型体系做宿主
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
File diff suppressed because it is too large. Load diff
@@ -1,645 +0,0 @@
# AI 写作后端新路线设计草案
## 1. 目标
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
- 不影响现有同事正在开发的功能
- 不强耦合现有 DeerFlow 通用对话主链路
- 可独立部署、独立调试、独立回滚
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
---
## 2. 推荐建设方式
优先建议:
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
建议目录:
```text
offline-backend-20260512/
apps/
ai_writing_orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
如果当前后端没有 `apps/` 结构,也可以采用:
```text
offline-backend-20260512/
ai-writing-orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
---
## 3. 技术路线建议
推荐组合:
- 编排框架:`LangGraph`
- 模型调用:接你们内网模型网关
- 检索层:接内网知识库 / 文档库 / 搜索服务
- 状态存储:先用数据库或 Redis + DB,后续再演进
- 事件输出:SSE 或流式 HTTP
推荐原因:
- `LangGraph` 适合状态机和多节点回退
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
- 后续加人工确认节点和 checkpoint 会更自然
---
## 4. 分层设计
建议按四层拆分。
## 4.1 API 层
职责:
- 接收前端请求
- 鉴权
- 参数校验
- 返回 workflow 信息
- 提供事件流出口
建议文件:
```text
api/
routes/
workflows.py
workflow_events.py
schemas/
workflow_request.py
workflow_response.py
```
## 4.2 Application 层
职责:
- 组织用例
- 调度 workflow service
- 管理人工确认动作
- 处理重试和终止逻辑
建议文件:
```text
application/
services/
workflow_service.py
workflow_event_service.py
human_action_service.py
use_cases/
create_workflow.py
start_workflow.py
confirm_outline.py
rewrite_section.py
approve_final.py
```
## 4.3 Domain 层
职责:
- 定义工作流状态机
- 定义 Agent 输入输出协议
- 定义 artifact 与评分模型
建议文件:
```text
domain/
models/
workflow.py
workflow_stage.py
workflow_event.py
artifact.py
review_result.py
agents/
researcher.py
outliner.py
writer.py
editor.py
supervisor.py
graphs/
writing_graph.py
policies/
retry_policy.py
quality_policy.py
```
## 4.4 Infrastructure 层
职责:
- 访问模型网关
- 访问检索服务
- 访问数据库
- 发送事件
- 缓存与日志
建议文件:
```text
infrastructure/
llm/
model_client.py
retrieval/
intranet_search_client.py
knowledge_base_client.py
persistence/
workflow_repository.py
artifact_repository.py
streaming/
sse_publisher.py
logging/
workflow_logger.py
```
---
## 5. 工作流最小闭环
第一版建议只做如下状态机:
1. `collect_materials`
2. `generate_outline`
3. `await_outline_confirmation`
4. `write_draft`
5. `review_draft`
6. `rewrite_if_needed`
7. `complete`
终止规则:
- 编辑评分达标则完成
- 否则进入重写
- 最多重写 2 次
- 超过次数则标记 `human_intervention_required`
---
## 6. Agent 模块设计
## 6.1 Researcher
输入:
- 主题
- 用户要求
- 检索范围
- 可用资料源
输出:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
程序职责:
- 先做检索
- 再把检索结果裁剪后送给模型
- 输出必须结构化
## 6.2 Outliner
输入:
- 用户要求
- 研究结果
输出:
- `title_candidates`
- `outline_sections`
- `key_points_per_section`
约束:
- 提纲优先,不写正文
## 6.3 Writer
输入:
- 已确认提纲
- 素材卡片
- 风格要求
输出:
- `draft_sections`
- `section_source_refs`
- `full_draft_markdown`
建议:
- 按章节分段生成,不要一次长文直出
## 6.4 Editor
输入:
- 初稿
- 提纲
- 素材
- 用户要求
输出:
- `issues`
- `severity`
- `score`
- `rewrite_instructions`
- `decision`
规则:
- 先查事实,再查结构,再查文风
## 6.5 Supervisor
输入:
- 各阶段结果
- 重试计数
- 评分结果
输出:
- 下一跳节点
- 是否需要人工确认
- 是否结束
---
## 7. Prompt 组织建议
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
建议目录:
```text
prompts/
researcher/
system.txt
output_schema.json
outliner/
system.txt
output_schema.json
writer/
system.txt
output_schema.json
editor/
system.txt
output_schema.json
supervisor/
system.txt
```
原则:
- 每个 Agent 一套职责
- 每个 Agent 一套输出 schema
- Prompt 版本可管理
- 后续支持 A/B 调参
---
## 8. 弱模型适配策略
## 8.1 先检索后生成
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
## 8.2 短上下文任务化
每个节点只给弱模型一小段明确任务:
- 研究归纳
- 提纲整理
- 单章节写作
- 单章节审稿
## 8.3 结构化输出
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
## 8.4 章节级重写
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
## 8.5 程序接管质量门槛
例如:
- 资料引用数不足,不进入下一步
- 提纲章节数过少,直接重生
- 编辑评分低于阈值,自动重写
---
## 9. 数据模型建议
## 9.1 Workflow
建议字段:
- `id`
- `status`
- `current_stage`
- `user_id`
- `thread_id`
- `input_payload`
- `current_artifact_id`
- `retry_count`
- `created_at`
- `updated_at`
## 9.2 Workflow Artifact
建议字段:
- `id`
- `workflow_id`
- `artifact_type`
- `stage`
- `version`
- `title`
- `content`
- `metadata`
## 9.3 Workflow Event
建议字段:
- `id`
- `workflow_id`
- `event_type`
- `stage`
- `agent_name`
- `payload`
- `created_at`
## 9.4 Human Action
建议字段:
- `workflow_id`
- `action_type`
- `payload`
- `operator`
- `created_at`
---
## 10. API 草案
## 10.1 创建任务
`POST /api/ai-writing/workflows`
请求:
```json
{
"topic": "主题",
"articleType": "行业分析",
"audience": "管理层",
"tone": "专业克制",
"targetLength": 3000,
"constraints": ["避免口语化"],
"referenceScope": ["内网知识库", "项目复盘文档"]
}
```
返回:
```json
{
"workflowId": "wf_xxx",
"status": "idle"
}
```
## 10.2 启动任务
`POST /api/ai-writing/workflows/{id}/start`
## 10.3 查询任务
`GET /api/ai-writing/workflows/{id}`
## 10.4 订阅事件
`GET /api/ai-writing/workflows/{id}/events`
返回事件流示例:
```json
{
"type": "stage_changed",
"stage": "generating_outline",
"message": "正在生成提纲"
}
```
## 10.5 确认提纲
`POST /api/ai-writing/workflows/{id}/confirm-outline`
## 10.6 指定章节重写
`POST /api/ai-writing/workflows/{id}/rewrite-section`
## 10.7 确认终稿
`POST /api/ai-writing/workflows/{id}/approve-final`
---
## 11. LangGraph 实现建议
## 11.1 推荐 graph 节点
- `load_input`
- `retrieve_materials`
- `normalize_materials`
- `generate_outline`
- `wait_for_outline_confirmation`
- `write_sections`
- `merge_sections`
- `review_draft`
- `rewrite_sections`
- `publish_final_artifact`
## 11.2 推荐 state
建议 state 字段:
- `workflow_id`
- `input_payload`
- `materials`
- `outline`
- `outline_confirmed`
- `draft_sections`
- `merged_draft`
- `review_result`
- `rewrite_targets`
- `final_article`
- `retry_count`
- `events`
## 11.3 条件边
建议条件边逻辑:
- 提纲未确认 -> 等待用户
- 编辑通过 -> 完成
- 编辑不通过且可重试 -> 重写
- 编辑不通过且超限 -> 人工介入
---
## 12. 可观测性要求
第一版就建议带上以下能力:
- 每个 workflow 的事件日志
- 每个 Agent 的输入输出摘要
- 每次重试原因
- 每个阶段耗时
- 每次模型调用 token 与错误日志
后续排问题会非常依赖这些数据。
---
## 13. 测试建议
## 13.1 单元测试
覆盖:
- stage 流转
- retry policy
- quality policy
- 事件构造
## 13.2 集成测试
覆盖:
- 创建任务到完成的主链路
- 提纲确认链路
- 审稿失败后重写链路
- 人工介入链路
## 13.3 Prompt 回归集
建议建立 10-20 个固定写作样例,做回归:
- 短文
- 长文
- 事实密集型文章
- 宣传型文章
- 分析型文章
---
## 14. 开发顺序建议
## Step 1:先做 domain 与 API 契约
先定:
- stage 枚举
- event 枚举
- artifact 结构
- human action payload
## Step 2:先做 fake orchestrator
先不接真实模型,只模拟事件流,把前后端联调跑通。
## Step 3:接 Researcher + Outliner
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
## Step 4:接 Writer + Editor
再接初稿和审稿重写。
## Step 5:接可观测性和缓存
最后再补生产化能力。
---
## 15. 风险控制建议
### 风险 1:直接复用现有线程流,导致协议污染
建议:
- workflow 用独立 route 和独立 event schema
### 风险 2:写作结果与右侧 artifact 模型不兼容
建议:
- 后端统一输出标准 markdown artifact
### 风险 3:弱模型频繁输出不合法 JSON
建议:
- 每个节点加 schema repair
- 加轻量重试
- 尽量减少复杂嵌套输出
### 风险 4:研究资料太多导致上下文爆炸
建议:
- 检索后先裁剪、摘要、分桶
---
## 16. 最终建议
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
建议 MVP 只保留:
1. Researcher
2. Outliner
3. Writer
4. Editor
5. Supervisor
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
@@ -1,806 +0,0 @@
# AI 写作执行方案
## 1. 总体原则
本项目的最终落地原则:
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
3. 编排框架采用 `LangGraph`
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
6. 前后端通过 `SSE` 传递工作流事件
7. 第一版只做最小闭环,不做过多花哨扩展
一句话说明:
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
---
## 2. 项目边界
## 2.1 前端边界
继续使用当前页面:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
前端做的事情:
- 左侧增加“写作工作流模式”
- 展示多 Agent 执行进度与中间产物
- 提供提纲确认和指定章节重写
- 将初稿、终稿同步到右侧 artifact
前端不做的事情:
- 不重写右侧编辑器
- 不重写现有普通 chat 流程
- 不把 workflow 状态散落在多个旧 context 里
## 2.2 后端边界
后端采用独立新路线:
推荐目录:
```text
offline-backend-20260512/
apps/
ai_writing_orchestrator/
api/
application/
domain/
infrastructure/
prompts/
tests/
```
后端做的事情:
- 实现写作 workflow 状态机
- 实现 A/B/C Agent 与 Supervisor
- 接内网模型与内网检索
- 输出标准事件流和文章 artifact
后端不做的事情:
- 不直接改现有 DeerFlow 主流程
- 不把工作流逻辑塞到现有通用 thread 协议里
## 2.3 资料搜集边界
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
第一版要求:
- 研究员可以调用互联网搜索能力收集公开资料
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
第二版预留:
- 后续替换或并行接入内网搜索接口
- 后续接入内部知识库、文档库、FAQ、项目复盘库
- 前端和工作流层不因检索源变化而改协议
统一设计要求:
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
- `writing_graph` 只读取标准化后的素材结果
- API、事件流、artifact 结构不区分互联网资料和内网资料
推荐检索抽象:
```text
infrastructure/
retrieval/
base.py
internet_search_client.py
intranet_search_client.py
retriever_router.py
```
建议接口:
```python
class RetrievalProvider(Protocol):
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
class RetrievedDocument(BaseModel):
title: str
url: str | None
source_type: str
snippet: str
published_at: str | None = None
fetched_at: str
reliability: str | None = None
```
第一版路由策略:
- 默认 `internet`
- 配置项预留 `intranet`、`hybrid`
例如:
```yaml
retrieval:
provider: internet
top_k: 8
timeout_seconds: 15
```
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
---
## 3. 第一版产品目标
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
1. 输入主题、体裁、读者、风格、篇幅
2. 研究员搜集资料并输出素材卡片
3. 提纲 Agent 生成提纲
4. 用户确认提纲
5. Writer 生成初稿
6. Editor 给出问题清单与评分
7. 不达标则重写一次或两次
8. 终稿写入右侧 artifact
第一版成功标准:
- 不是“生成得像最强模型一样好”
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
## 3.1 第一版研究员目标
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
第一版输入:
- 用户主题
- 用户写作要求
- 检索源配置,第一版默认 `internet`
第一版输出至少包含:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
每条素材最少要带:
- 来源标题
- 来源 URL
- 摘要
- 可信度标签
- 被引用的原始片段或整理后的事实点
第一版 Researcher 的程序流程建议:
1. 根据主题自动生成 3-5 个检索 query
2. 调用互联网检索接口拿回候选文档
3. 做去重、裁剪、初筛
4. 让模型将结果整理成结构化素材卡片
5. 将结构化结果返回给 Outliner 和 Writer
第一版先不做:
- 深度网页 crawling
- 多轮自动追问式深研究
- 内网知识库接入
- 复杂引用格式生成
---
## 4. 最终工作流
## 4.1 Agent 角色
### `Researcher`
职责:
- 从配置的检索源检索信息
- 产出结构化素材卡片
- 标注来源、可信度、风险点
第一版说明:
- 默认走互联网公开资料检索
- 必须预留后续切换到内网检索源的接口
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
第一版检索源字段建议:
- `source_type = "internet"`
- 后续预留 `source_type = "intranet" | "hybrid"`
输出:
- `material_cards`
- `facts`
- `quotes`
- `examples`
- `risks`
- `source_refs`
### `Outliner`
职责:
- 根据用户要求和研究结果生成提纲
输出:
- `title_candidates`
- `outline_sections`
- `key_points_per_section`
### `Writer`
职责:
- 基于提纲和素材写初稿
- 按章节写,不一次生成整篇长文
输出:
- `draft_sections`
- `section_source_refs`
- `full_draft_markdown`
### `Editor`
职责:
- 检查事实、结构、文风
- 给出评分与重写建议
输出:
- `issues`
- `severity`
- `score`
- `rewrite_instructions`
- `decision`
### `Supervisor`
职责:
- 负责流程调度
- 负责通过 / 重写 / 人工介入判断
## 4.2 状态机
第一版统一采用:
- `idle`
- `collecting_materials`
- `generating_outline`
- `await_outline_confirmation`
- `writing_draft`
- `reviewing_draft`
- `rewriting_draft`
- `completed`
- `failed`
- `human_intervention_required`
## 4.3 流程闭环
最小闭环固定为:
1. `Research`
2. `Outline`
3. `HumanConfirmOutline`
4. `Draft`
5. `Review`
6. `RewriteIfNeeded`
7. `Complete`
重试规则:
- 最多重写 2 次
- 达标则完成
- 超限则进入人工介入
## 4.4 LangGraph 推荐节点
第一版推荐节点:
- `load_input`
- `build_search_queries`
- `retrieve_materials`
- `normalize_materials`
- `generate_outline`
- `wait_for_outline_confirmation`
- `write_sections`
- `merge_sections`
- `review_draft`
- `rewrite_sections`
- `publish_final_artifact`
其中和资料搜集直接相关的节点说明:
### `build_search_queries`
职责:
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
### `retrieve_materials`
职责:
- 调用检索 provider 获取候选文档
- 第一版默认走 `internet_search_client`
- 后续可切到 `intranet_search_client`
### `normalize_materials`
职责:
- 做去重
- 做摘要裁剪
- 做来源字段统一
- 输出标准 `RetrievedDocument[]`
---
## 5. 前端最终做法
## 5.1 目录建议
在现有 `open-canvas` 下新增:
```text
frontend-web/src/open-canvas/
workflow/
api/
writing-workflow.ts
components/
WritingWorkflowEntryForm.tsx
WritingWorkflowTimeline.tsx
WritingWorkflowEventList.tsx
WritingWorkflowArtifactDrawer.tsx
WritingWorkflowConfirmCard.tsx
WritingWorkflowHeader.tsx
CanvasWorkflowPanel.tsx
contexts/
WritingWorkflowContext.tsx
hooks/
useWritingWorkflow.ts
useWritingWorkflowEvents.ts
types/
workflow.ts
```
## 5.2 左侧改造策略
`CanvasChatPanel` 只承担模式切换与容器职责:
- `chat` 模式:沿用现有普通聊天
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
新面板负责:
- 写作任务表单
- 进度时间线
- 事件列表
- 中间产物抽屉
- 提纲确认卡片
## 5.3 右侧衔接策略
继续复用现有 `CanvasArtifactPanel`。
同步规则:
- 提纲:可选同步到右侧预览
- 初稿:同步到右侧
- 重写稿:覆盖当前草稿
- 终稿:作为最终 artifact
## 5.4 前端状态设计
新增:
- `workflowMode`
- `workflowRunId`
- `workflowStage`
- `workflowStatus`
- `workflowEvents`
- `workflowArtifacts`
- `workflowPendingAction`
注意:
- workflow 状态放进新的 `WritingWorkflowContext`
- 不塞进现有 `CanvasGraphContext`
## 5.5 前端显示的资料信息
第一版左侧至少要能展示研究员产出的资料摘要:
- 来源标题
- 来源域名或 URL
- 资料摘要
- 可信度标签
- 是否被提纲或正文引用
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
注意:
- 第一版显示公开互联网来源即可
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
---
## 6. 后端最终做法
## 6.1 分层
推荐分层:
```text
api/
application/
domain/
infrastructure/
prompts/
tests/
```
## 6.2 核心模块
建议核心文件:
```text
domain/
models/
workflow.py
workflow_stage.py
workflow_event.py
artifact.py
review_result.py
agents/
researcher.py
outliner.py
writer.py
editor.py
supervisor.py
graphs/
writing_graph.py
policies/
retry_policy.py
quality_policy.py
```
## 6.3 检索层实现要求
第一版检索层必须独立成基础设施模块,不允许:
- 在 Agent prompt 中直接拼接“去网上找资料”
- 在 graph 节点中散落请求第三方搜索的代码
- 把互联网检索和内网检索混在一个无抽象的函数里
建议结构:
```text
infrastructure/
retrieval/
base.py
internet_search_client.py
intranet_search_client.py
retriever_router.py
material_normalizer.py
```
建议职责:
- `internet_search_client.py`
- 第一版实际使用
- 对接互联网搜索 API 或爬取网关
- `intranet_search_client.py`
- 第一版只保留接口和空实现或 mock
- 后续接企业内部接口
- `retriever_router.py`
- 根据配置选择 provider
- `material_normalizer.py`
- 统一清洗和标准化结果
## 6.4 Prompt 管理
统一独立存放:
```text
prompts/
researcher/
system.txt
output_schema.json
outliner/
system.txt
output_schema.json
writer/
system.txt
output_schema.json
editor/
system.txt
output_schema.json
supervisor/
system.txt
```
## 6.5 输出约束
每个 Agent 都必须:
- 优先输出 JSON / Pydantic schema
- 失败时允许轻量 repair 和一次重试
- 避免自由散文式输出
## 6.6 Researcher 的实现细则
Researcher 推荐由两部分组成:
1. 程序化检索
2. 模型化整理
推荐伪流程:
```python
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
queries = build_queries(topic, article_type)
raw_docs = []
for query in queries:
raw_docs.extend(provider.search(query=query, top_k=5))
docs = normalize_materials(raw_docs)
docs = dedupe_materials(docs)
docs = rank_materials(docs)
return llm_extract_material_cards(docs)
```
第一版建议:
- 先把 query 生成逻辑做简单
- `top_k` 先控制在 5-10
- 先做轻量去重和摘要裁剪
- 不做很重的网页全文抓取
---
## 7. 前后端协议
## 7.1 API
第一版统一提供:
- `POST /api/ai-writing/workflows`
- `POST /api/ai-writing/workflows/{id}/start`
- `GET /api/ai-writing/workflows/{id}`
- `GET /api/ai-writing/workflows/{id}/events`
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
- `POST /api/ai-writing/workflows/{id}/approve-final`
## 7.2 SSE 事件
统一事件类型:
- `workflow_started`
- `stage_changed`
- `agent_started`
- `agent_output_chunk`
- `artifact_generated`
- `human_confirmation_required`
- `retry_scheduled`
- `workflow_completed`
- `workflow_failed`
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
## 7.3 配置项补充
建议增加以下配置项:
```yaml
retrieval:
provider: internet
top_k: 8
timeout_seconds: 15
enable_content_fetch: false
workflow:
max_revisions: 2
models:
researcher: your-default-model
outliner: your-default-model
writer: your-default-model
editor: your-default-model
```
说明:
- 第一版 `provider=internet`
- 第二版可切成 `intranet` 或 `hybrid`
---
## 8. 必须做 / 延后做 / 不建议现在做
## 8.1 必须做
### 架构
- 独立后端路线 `ai-writing-orchestrator`
- 前端独立 `workflow/` 目录
- LangGraph 状态机
- `Researcher / Outliner / Writer / Editor / Supervisor`
- 独立检索抽象层,第一版先落互联网 provider
### 协议
- 标准 workflow 状态枚举
- 标准 SSE 事件枚举
- 标准 artifact 结构
- JSON / Pydantic 输出约束
### 功能
- 启动写作任务
- 研究阶段
- 提纲阶段
- 提纲确认
- 初稿生成
- 审稿与最多 2 次重写
- 终稿同步到右侧
- 素材卡片展示
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
### UI
- 模式切换
- 时间线
- 事件列表
- 提纲确认卡片
- 右侧终稿显示
- 左侧素材卡片视图
## 8.2 延后做
- 章节级精准重写
- 初稿多版本树
- FactChecker
- StyleEditor
- ComplianceReviewer
- 自动评分看板
- 素材引用可视化高亮
- 更复杂的人机协作节点
- 内网知识库正式接入
- 混合检索 `hybrid`
- 网页全文抓取与深摘要
## 8.3 不建议现在做
- 直接接入现有 DeerFlow 主 agent 包
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
- 一开始就做 5 个以上 Agent
- 一开始就做很复杂的文章版本管理
- 一开始就把评分阈值和 prompt 写死得非常细
## 9. 最终开发顺序
## Step 1:先定契约
先一起拍板:
- stage 枚举
- event 枚举
- artifact 结构
- confirm / rewrite 的 payload
- retrieval provider 接口
- RetrievedDocument 结构
## Step 2:先做 fake workflow
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
## Step 3:先接互联网版 Researcher + Outliner
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
这一阶段要求:
- `Researcher` 先走互联网检索
- `retriever_router` 可配置
- `intranet_search_client` 先留接口空壳
## Step 4:接 Writer + Editor
再补初稿与审稿重写闭环。
## Step 5:接右侧 artifact
把初稿和终稿稳定写入右侧。
## Step 6:补生产化
- 缓存
- 日志
- 错误回放
- 配置化 prompt
## 10. 推荐任务拆分
## 前端
- `CanvasChatPanel` 模式切换
- `workflow/` 目录与类型
- 时间线 / 事件列表 / 确认卡片
- SSE 订阅 hook
- 右侧 artifact 映射
- 素材卡片列表展示
- 来源字段展示规范
## 后端
- workflow API
- LangGraph graph
- 5 个 Agent 模块
- Prompt 目录
- SSE 输出
- 仓储与日志
- `RetrievalProvider` 抽象
- `internet_search_client`
- `intranet_search_client` 预留接口
- `material_normalizer`
## Prompt / 算法
- Researcher prompt
- Outliner prompt
- Writer prompt
- Editor prompt
- JSON schema
- 失败样本回归
## 联调
- mock SSE
- 真模型接入
- artifact 同步校验
- 提纲确认链路验证
- 互联网资料来源字段联调
- 素材卡片到提纲引用链路联调
---
## 11. 开发过程中的约束
开发过程中统一采用下面这套口径:
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
- 技术底座:`LangGraph`
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
- 后端方式:独立 `ai-writing-orchestrator`
- 检索方式:第一版 `internet`,接口预留 `intranet`
- 通信方式:`SSE`
- 输出方式:`JSON Schema / Pydantic`
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
给开发团队的统一口径:
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
@@ -1,538 +0,0 @@
# AI 写作 — 前端开发文档
> **本文档目的**:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
>
> **配套文档**:
> - `ai-writing-说明.md` — 故障排查与运维向解决方案
> - `ai-writing-assistant-backend-api.md` — 后端 API 详细字段定义
> - `ai-writing-assistant-source-map.md` — 旧版前端结构索引(已合并到本文档)
---
## 一、产品定位
AI 写作是 DeerFlow 的「**杂志社协作写作**」子产品,由 LangGraph 后端驱动 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章。用户通过 **4 个干预点**(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
### UI 入口
- 路由:`/canvas/ai-writing`(在 `WorkspaceRoutes.tsx` 注册)
- 入口页:`src/open-canvas/pages/AIWritingPage.tsx`
- 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
---
## 二、目录结构
```
frontend-web/src/open-canvas/
├── pages/
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
│
├── api/ # ── 后端 API 适配层 ──
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
│ └── ai-writing-article-types.ts # 文章类型 CRUD
│
├── hooks/ # ── 状态管理 ──
│ ├── useAIWriting.ts # 写作主状态机(最重要)
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
│ └── useArticleTypes.ts # 文章类型选项 hook
│
├── components/ai-writing/ # ── UI 组件 ──
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
│ │
│ ├── AIWritingTimeline.tsx # 时间线主容器
│ ├── TimelineStep.tsx # 单条进度事件
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
│ │
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
│ ├── MaterialsCard.tsx # 素材确认
│ ├── MaterialItem.tsx # 单个素材项
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
│ ├── OutlineSection.tsx # 大纲单章节
│ ├── ReviewCard.tsx # 审核确认
│ ├── IssueList.tsx # 审核意见列表
│ ├── ScoreBar.tsx # 评分进度条
│ │
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
│ ├── DraftStreamingView.tsx # 草稿流式打字机
│ ├── ReviewStreamingView.tsx # 审核流式打字机
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
│ │
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
│
└── types/
└── ai-writing.ts # 全部类型定义(重要参考)
```
---
## 三、数据流总览
```
┌─────────────────────────────────────────────────────────────────┐
│ AIWritingPanel │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ useAIWriting() 主状态 hook │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
│ │ │ ↓ 返回 session_id │ │ │
│ │ │ │ │ │
│ │ │ subscribeToProgress(sessionId) │ │ │
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
│ │ │ ─── handleEvent reducer → setState │ │ │
│ │ │ │ │ │
│ │ │ submitIntervention() │ │ │
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
│ │ │ │ │ │
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 渲染: │
│ ├ AIWritingForm(idle 状态) │
│ ├ AIWritingTimeline(运行中) │
│ │ ├ TimelineStep × N(progressEvents) │
│ │ └ CompletedInterventionCard × N(已完成的干预) │
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 四、状态机(useAIWriting.ts)
### 状态字段(完整定义见 `types/ai-writing.ts`)
```typescript
interface AIWritingState {
// —— 核心标识 ——
sessionId: string | null; // 后端返回的会话 id
status: WritingStatus; // 当前阶段(13 种状态之一)
isPaused: boolean; // 是否在干预点等待用户
error: string | null;
// —— 配置 ——
request: AIWritingRequest | null; // 用户提交的写作请求
// —— 累计数据 ——
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
outline: AIOutline | null; // 大纲(大纲就绪后填充)
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
// —— 干预 ——
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
// —— 流式视图状态 ——
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
outlineStreamingText: string;
outlineThinkingText: string;
researchProgress: string;
streamingReviewItems: StreamingReviewItem[];
revisionSummary: RevisionSummary | null;
pendingRevisionContext: { /* ... */ } | null;
}
```
### 状态转移图(写作状态机)
```
┌──────┐
│ idle │ ←─────────────────────────────────────┐
└──┬───┘ │
│ startWriting() │
↓ │
┌─────────────────┐ │
│ researching │ │
└──┬──────────────┘ │
↓ materials_ready │
┌─────────────────────────────┐ │
│ awaiting_material_confirm │ ← await_user │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_outline │ ← (re_search 路径) │
└──┬──────────────┘ │
↓ outline_ready │
┌─────────────────────────────┐ │
│ awaiting_outline_confirm │ │
└──┬──────────────────────────┘ │
↓ submit(confirm) │
┌─────────────────┐ │
│ writing_draft │ ← (re_outline 路径) │
└──┬──────────────┘ │
│ │
┌───────┴────────┐ │
↓ ↓ │
awaiting_section_ awaiting_draft_confirm │
help (素材不足) └─┬───┬───┬──────────────┐ │
│ (supplement) │ │ │ to_editor │ │
│ ↑回到 research │ │ ↓ │ │
│ │ │ reviewing │ │
│ │ │ └─┬──────┐ │ │
│ │ │ ↓ ↓ │ │
│ │ │ awaiting_review_confirm │
│ │ │ └─┬────────┐ │
│ │ │ ↓ submit ↓ │
│ │ │ revising done ───────────────┘
│ │ │ │
│ │ │ └─→ writing_draft (循环修订)
│ │ │
│ │ │ user_revise
│ │ └─→ revising
│ │
│ │ finalize
│ └─→ done
│
└──→ (delete/loose) writing_draft 继续
```
### handleEvent reducer(核心)
`useAIWriting.ts` 里的 `handleEvent` 是个**纯 reducer**,根据 SSE 事件类型更新状态:
| 事件类型 | 主要影响的状态字段 |
|---|---|
| `connected` | 仅记录到 progressEvents |
| `step_start` | 切到对应 `status`(如「编辑」→ reviewing) |
| `step_done` | 仅记录 |
| `research_progress` | `researchProgress`(实时检索进度文案) |
| `materials_ready` | `materials`、清空 researchProgress |
| `outline_chunk` | `outlineStreamingText` / `outlineThinkingText`(打字机) |
| `outline_ready` | `outline`、清空流式文本 |
| `draft_chunk` | `streamingDraftSections`(按章节拼接打字机) |
| `draft_ready` | `currentDraftMarkdown` / `currentDraftTitle` |
| `review_chunk` | `streamingReviewItems[i].streamingText`(逐条打字机) |
| `review_item_done` | `streamingReviewItems[i]` 标记 isDone + 结构化结果 |
| `review_ready` | `reviewResult` |
| `await_user` | `currentPause = event`,`isPaused = true`,**弹出 InterventionCard** |
| `resumed` | `currentPause = null` |
| `notice` | 仅记录(非致命告警) |
| `done` | `status = 'done'` |
| `error` | `status = 'error'`,`error = event.message` |
---
## 五、API 层(api/)
### 1. `api/ai-writing.ts` — 核心写作 API
```typescript
// 启动写作 → 返回 session_id
export async function startWritingSession(req: AIWritingRequest): Promise<string>
// 订阅 SSE 进度流 → 返回取消订阅函数
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
): () => void
// 提交用户干预 → 让 graph 续推
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void>
```
**关键实现细节**:
- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换
- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略
- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream
- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
- 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
### 2. `api/ai-writing-sessions.ts` — 历史会话
```typescript
// CRUD
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
deleteAIWritingSession(id) : Promise<void>
// 时间线持久化(fire-and-forget,永不抛错)
saveAIWritingTranscript(id, transcript) : Promise<boolean>
```
**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。
### 3. `api/ai-writing-article-types.ts` — 文章类型管理
| 函数 | 用途 |
|---|---|
| `listArticleTypes()` | 表单下拉框选项 |
| `createArticleType()` | 管理员新增 |
| `updateArticleType()` | 管理员编辑 |
| `deleteArticleType()` | 管理员删除 |
---
## 六、组件树详解
### 顶层:`AIWritingPanel`(457 行,最大)
负责:
1. 调用 `useAIWriting()` 拿主状态
2. 调用 `useAIWritingSessions()` 拿历史列表
3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容
5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧
6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板
### 干预卡:`InterventionCard`(663 行,第二大)
按 `pausePoint` 渲染**不同的卡片**:
| pausePoint | 卡片 | 用户能做的 |
|---|---|---|
| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 |
| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 |
| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 |
| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
### 时间线:`AIWritingTimeline`
迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。
### 流式视图:`{Outline,Draft,Review}StreamingView`
- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `<think>...</think>` 折叠显示
- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机
- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
### 历史回显:`AIWritingHistoryView`
只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。
---
## 七、SSE 事件协议(前后端契约)
后端通过 SSE 推 `event: <type>\ndata: <json>\n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。
### 解析流程
```
ReadableStream → TextDecoder
→ 按 "\n\n" 分帧
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
→ JSON.parse(data)
→ normalize(parsed)(snake → camel)
→ onEvent(normalized)
```
### 事件载荷字段(不完全列表)
| 字段 | 出现在哪些事件 | 用途 |
|---|---|---|
| `type` | 全部 | 事件类别 |
| `sessionId` | 全部 | 标识 |
| `message` | 大多数 | 显示给用户的文案 |
| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
| `materials` | materials_ready | 素材数组 |
| `outline` | outline_ready | 大纲对象 |
| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 |
| `reviewResult` | review_ready | 完整审核结果 |
| `pausePoint` | await_user | 哪个干预点 |
| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) |
| `blockedSections` | await_user (section_help) | 素材不足的章节列表 |
| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 |
---
## 八、扩展指南:常见开发任务
### 新增一个干预点
1. **前端类型扩展**:
- 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case
- 在 `WritingStatus` 加对应的 `awaiting_xxx`
- 在 `CompletedIntervention.pausePoint` 加 case
2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑
3. **后端配合**(详见后端开发文档):
- `graph.py` 加 `pause_xxx_confirm` 节点
- graph 拓扑加这个节点的边
4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到
### 新增一个 SSE 事件类型
1. `types/ai-writing.ts`:
- `ProgressEventType` 联合类型加新值
- `ProgressEvent` 接口加新字段(如果有载荷)
2. `api/ai-writing.ts`:
- `SSE_EVENT_TYPES` 数组加新值(不加会被忽略)
- `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize)
3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新
4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
### 修改一个流式打字机的行为
每个 streaming view 内部都是独立的:
- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段
- 渲染在 `*StreamingView.tsx`
- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支
### 新增草稿到右侧画布的逻辑
`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。
---
## 九、容错与边界处理
### 已落实的容错(不会阻断写作)
- ✅ `saveAIWritingTranscript` 失败仅 `console.warn`
- ✅ 历史会话列表加载失败仅 `console.warn`
- ✅ 重命名/删除会话失败静默吞掉
- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断
- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
### 已知边界(可能影响 UI 体验)
- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新
- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制
- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理
---
## 十、调试技巧
### 看 SSE 原始事件
浏览器 DevTools → Network → 找 `/api/ai-writing/<id>/stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
### 查当前 hook 状态
在浏览器 console 里:
```js
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
// 或在 useAIWriting 里临时加:
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
// 然后在 console 里 __aiwriting 看
```
### 模拟事件
开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。
### 看后端 transcript
```
GET /api/ai-writing/sessions/<id>
```
返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。
---
## 十一、性能注意点
### 大数组渲染
- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过)
- `streamingDraftSections` 章节多时也是潜在瓶颈
### transcript PUT 频率
- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听)
- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大
- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存
### 浏览器内存
- progressEvents 不主动清理 → 长会话可能占用几十 MB
- 完成一篇文章后建议 `reset()` 清空状态
---
## 十二、相关后端接口(速查)
| 接口 | 用途 | 前端调用方 |
|---|---|---|
| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` |
| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` |
| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` |
| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 |
| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` |
| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` |
| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` |
| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` |
| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` |
| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 |
---
## 十三、未来优化方向
| 项目 | 说明 | 工作量 |
|---|---|---|
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
---
## 附录 A:所有类型一览
完整定义在 `types/ai-writing.ts`,主要类型:
```typescript
// 业务实体
AIMaterial // 单个素材
AIOutline // 大纲
OutlineSection // 大纲单章节
ReviewIssue // 审核问题
ReviewResult // 完整审核结果
BlockedSection // 素材不足的章节
SectionDecision // 章节求助决策
// 流程
WritingStatus // 13 种状态
ProgressEventType // 17 种事件类型
ProgressEvent // SSE 事件载荷
UserInterventionPayload // 干预提交体
AIWritingRequest // 启动写作的请求
CompletedIntervention // 已完成的干预记录
// 流式视图
StreamingDraftSection
StreamingReviewItem
RevisionSummary
// Hook 状态
AIWritingState // useAIWriting 的完整状态
```
---
## 附录 B:常见踩坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 |
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 |
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` |
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 |
---
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
@@ -1,472 +0,0 @@
# AI 写作前端改造清单
## 1. 目标
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
当前核心文件:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
---
## 2. 改造原则
- 不改右侧主编辑器交互心智
- 不复制新页面
- 不破坏现有普通 chat 模式
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
- 所有 workflow 能力都应允许逐步灰度
---
## 3. 推荐前端结构
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
```text
frontend-web/src/open-canvas/
workflow/
api/
writing-workflow.ts
components/
WritingWorkflowEntryForm.tsx
WritingWorkflowTimeline.tsx
WritingWorkflowEventList.tsx
WritingWorkflowArtifactDrawer.tsx
WritingWorkflowConfirmCard.tsx
WritingWorkflowHeader.tsx
contexts/
WritingWorkflowContext.tsx
hooks/
useWritingWorkflow.ts
useWritingWorkflowEvents.ts
types/
workflow.ts
utils/
workflow-event.ts
```
说明:
- `open-canvas` 原有聊天、artifact、editor 目录不重构
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
---
## 4. 状态设计
## 4.1 新增的核心状态
建议新增:
- `workflowMode: "chat" | "writing_pipeline"`
- `workflowRunId: string | null`
- `workflowStage`
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
- `workflowEvents: WritingWorkflowEvent[]`
- `workflowArtifacts: WritingWorkflowArtifact[]`
- `workflowPendingAction: WritingPendingAction | null`
## 4.2 推荐类型定义
建议在 `workflow/types/workflow.ts` 中先定义:
```ts
export type WritingWorkflowStage =
| "idle"
| "collecting_materials"
| "generating_outline"
| "await_outline_confirmation"
| "writing_draft"
| "reviewing_draft"
| "rewriting_draft"
| "completed"
| "failed";
export type WritingWorkflowEventType =
| "workflow_started"
| "stage_changed"
| "agent_started"
| "agent_output_chunk"
| "artifact_generated"
| "human_confirmation_required"
| "retry_scheduled"
| "workflow_completed"
| "workflow_failed";
```
第一版把类型先收紧,后续再扩展。
---
## 5. 组件拆分建议
## 5.1 左侧主面板改造
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
建议改造为:
- `chat` 模式继续渲染现有消息列表 + 输入框
- `writing_pipeline` 模式渲染新的工作流视图
建议拆分出:
- `CanvasChatConversation.tsx`
- `CanvasChatInputBox.tsx`
- `CanvasWorkflowPanel.tsx`
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
## 5.2 新增组件职责
### `WritingWorkflowEntryForm.tsx`
负责采集:
- 主题
- 文章类型
- 目标读者
- 语气风格
- 目标篇幅
- 是否需要引用资料
- 禁写项
- 补充说明
### `WritingWorkflowTimeline.tsx`
负责显示阶段:
- 研究中
- 提纲生成中
- 等待确认提纲
- 正在写初稿
- 正在审稿
- 正在重写
- 已完成
### `WritingWorkflowEventList.tsx`
负责显示细粒度事件:
- 哪个 Agent 开始执行
- 当前在处理哪一步
- 当前产生了什么中间结果
- 是否重试
### `WritingWorkflowArtifactDrawer.tsx`
负责展示中间产物:
- 素材卡片
- 提纲
- 初稿
- 编辑意见
- 重写稿
### `WritingWorkflowConfirmCard.tsx`
负责展示用户确认点:
- 确认提纲
- 驳回提纲并附带修改意见
- 继续写稿
- 指定重写章节
---
## 6. Context 与 Hook 改造建议
## 6.1 `CanvasGraphContext`
当前它只维护:
- `messages`
- `artifact`
- `streamingArtifact`
- `isStreaming`
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
推荐做法:
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
- 新增 `WritingWorkflowContext`
## 6.2 `WritingWorkflowContext`
建议承载:
- 当前 workflow 运行状态
- 中间产物列表
- 待确认操作
- workflow 事件缓存
- 启动 / 取消 / 确认 / 重写等 action
## 6.3 `useCanvasThreadStream`
当前这个 hook 已经负责:
- 创建 thread
- 提交消息
- 接收 thread stream
- 将 `update_artifact` 同步到右侧
建议不要把“多 Agent workflow 状态机”也硬塞进去。
推荐方案:
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
- 新增 `useWritingWorkflow`
- 新增 `useWritingWorkflowEvents`
分工如下:
- `useCanvasThreadStream`:普通对话流
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
- `useWritingWorkflowEvents`:订阅 workflow 事件流
---
## 7. API 接入建议
## 7.1 前端 API 文件
新增:
- `workflow/api/writing-workflow.ts`
建议暴露方法:
- `createWorkflow(payload)`
- `startWorkflow(id)`
- `getWorkflow(id)`
- `subscribeWorkflowEvents(id, handlers)`
- `confirmOutline(id, payload)`
- `rewriteSection(id, payload)`
- `approveFinal(id)`
## 7.2 事件流形式
优先级建议:
1. `SSE`
2. 流式 HTTP chunk
3. 轮询兜底
理由:
- 事件驱动比整段消息流更适合 workflow UI
- 可以更方便映射到时间线和事件列表
---
## 8. 与右侧 Artifact 的衔接
## 8.1 现有优势
当前 `CanvasArtifactPanel` 已具备:
- 流式展示内容
- 最终 artifact 渲染
- 右侧编辑体验成熟
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
## 8.2 建议接法
工作流各阶段结果按两类处理:
- 中间产物:进入左侧 `workflowArtifacts`
- 可阅读正文:同步一份到右侧 `artifact`
推荐映射:
- 提纲:可选同步到右侧,供用户预览
- 初稿:同步到右侧
- 重写稿:覆盖或新增版本到右侧
- 终稿:作为最终右侧主版本
## 8.3 版本策略
建议右侧版本保留:
- `outline`
- `draft_v1`
- `draft_v2`
- `final`
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
---
## 9. 页面交互流程
## 9.1 用户首次进入
- 默认仍是现有 chat 模式
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
## 9.2 启动写作工作流
- 用户填写写作任务表单
- 点击“开始生成”
- 左侧切换到时间线 + 事件流视图
- 右侧进入 loading 或素材 / 提纲预览态
## 9.3 等待提纲确认
- 时间线停在 `await_outline_confirmation`
- 左侧出现确认卡片
- 用户可执行:
- 通过提纲
- 要求调整提纲
- 追加要求
## 9.4 生成初稿与审稿
- 左侧显示 Agent 事件
- 右侧展示初稿内容
- 审稿意见在左侧展示,同时高亮需要重写的章节
## 9.5 完成
- 左侧显示“终稿已完成”
- 右侧进入可继续局部 AI 编辑状态
---
## 10. 分步开发清单
## Step 1:类型与假数据
目标:
- 先把 workflow 的类型、mock 数据、组件骨架搭好
任务:
- 新建 `workflow/types/workflow.ts`
- 新建 `workflow/components/*`
- 用本地 mock 事件驱动时间线和事件流
验收:
- 不接后端也能在页面中演示完整流程
## Step 2:模式切换
目标:
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
任务:
- 拆出 `CanvasWorkflowPanel`
- 增加模式切换 UI
- 保证旧 chat 不回归
验收:
- 现有功能不受影响
- 新模式可进入表单页
## Step 3:接入 workflow API
目标:
- 从 mock 切到真实 API
任务:
- 接 `create/start/get/events`
- 实现基础错误态和重试态
验收:
- 前后端可联调跑一条主链路
## Step 4:接入确认节点
目标:
- 支持提纲确认和指定章节重写
任务:
- 做确认卡片
- 做动作回传
验收:
- 用户能在流程中介入
## Step 5:打通右侧版本
目标:
- 让 workflow 产物稳定进入右侧编辑器
任务:
- 约定 artifact 映射
- 接入版本切换或当前版本覆盖
验收:
- 终稿稳定显示在右侧
---
## 11. 建议修改点位
优先涉及文件:
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
建议新增,不建议重写原有:
- `open-canvas/workflow/api/writing-workflow.ts`
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
- `open-canvas/workflow/components/*`
---
## 12. 联调约定
前后端需要尽早统一:
- workflow id 命名
- stage 枚举
- event type 枚举
- artifact type 枚举
- user action payload
- 错误码与可恢复动作
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
---
## 13. 最终建议
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
第一版建议先保证三件事:
1. 左侧能看见阶段进展
2. 用户能确认提纲
3. 右侧能稳定收到初稿和终稿
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
@@ -1,718 +0,0 @@
# AI 写作弱模型多 Agent 编排需求文档
## 1. 背景与目标
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
- 结构松散,长文容易跑题
- 素材不足,内容空泛
- 事实依据弱,容易幻觉
- 润色能力有限,语言风格不统一
- 一次生成失败后缺少可控的纠偏机制
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
建议的角色分工:
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
- `Agent B 作家`:基于选题、素材和结构写初稿
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
---
## 2. 本次建设边界
### 2.1 前端边界
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
当前相关入口:
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
前端本期只做以下增强:
- 在左侧聊天区增加“写作工作流模式”
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
### 2.2 后端边界
后端必须单开新路线,不影响其他同事当前功能开发。
建议原则:
- 不直接改现有 DeerFlow 主流程
- 不把多 Agent 逻辑硬塞进当前通用对话链路
- 以独立服务或独立模块形式建设
- 通过清晰 API 与当前前端页面对接
推荐落地位置:
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
---
## 3. 为什么多 Agent 对弱模型更合适
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
- 选题理解
- 信息检索
- 信息筛选
- 结构规划
- 长文写作
- 自我校验
- 风格润色
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
- 降低单次上下文复杂度
- 降低一次性长输出失败率
- 把“写得不好”变成“某一步做得不好”,便于重试
- 可以在关键节点插入规则校验与人工确认
- 可以用程序补足模型短板,而不是完全依赖模型智力
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
---
## 4. 可借鉴的开源项目
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
### 4.1 第一优先级:LangGraph
推荐原因:
- 天然适合把写作流程建模成状态图
- 支持多步骤、条件分支、重试、人工介入
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
- 对“弱模型 + 强流程控制”很友好
可借鉴点:
- `StateGraph` 的状态驱动编排
- 每一步单独定义输入、输出、失败处理
- 可视化地表达“通过 / 打回 / 重试 / 终止”
参考:
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
### 4.2 第二优先级:STORM
推荐原因:
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
- 对“先研究后写作”的思路非常契合
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
这和我们的目标高度一致,尤其适合借鉴:
- 提纲先行
- 素材引用先行
- 分模块写作
- 后置润色
参考:
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
### 4.3 第三优先级:Deep Research Agent
推荐原因:
- 明确支持本地模型与云模型混用
- 已经包含研究、写作、质量校验、缓存、重试等能力
- 很适合借鉴“研究阶段如何约束弱模型”
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
特别适合借鉴:
- 素材缓存
- 分阶段质量评分
- 节点失败重试
- 研究结果结构化存储
参考:
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
### 4.4 可选借鉴:CrewAI
推荐原因:
- 角色扮演表达自然
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
- Flow + Crew 的组织方式适合做 demo 和快速验证
不建议作为首选底座的原因:
- 更偏“Agent 角色编排体验”
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
适合借鉴:
- 角色定义写法
- Task 模板
- 多角色协作 prompt 结构
参考:
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
### 4.5 不建议新项目优先采用:AutoGen
原因:
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
- 官方建议新项目优先采用 `Microsoft Agent Framework`
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
参考:
- [microsoft/autogen](https://github.com/microsoft/autogen)
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
### 4.6 结论
综合本项目需求,建议技术选型优先级如下:
1. `LangGraph` 作为主编排框架
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
---
## 5. 目标产品形态
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
理想交互流程:
1. 用户输入主题、用途、风格、读者、篇幅要求
2. 系统先进入“研究阶段”
3. 系统给出资料卡片与候选提纲
4. 用户确认提纲或调整
5. 系统生成初稿
6. 严苛编辑给出问题清单
7. 系统自动重写问题段落
8. 系统输出终稿到右侧编辑区
9. 用户可继续局部改写、扩写、精简
核心体验目标:
- 左侧像“编辑部工作台”
- 右侧像“成文编辑器”
- 用户知道系统现在进行到哪一步
- 用户可以在关键节点接管,而不是只能等待最终结果
---
## 6. 多 Agent 流程设计
## 6.1 建议的最小可用流程 MVP
第一版不要一上来做很复杂,建议先做 4 步闭环:
1. `Research`
- 输入:主题、用户要求、内网资料源
- 输出:素材卡片列表、可引用事实列表、风险点列表
2. `Outline`
- 输入:用户要求 + 素材卡片
- 输出:结构化提纲
3. `Draft`
- 输入:提纲 + 素材卡片
- 输出:初稿
4. `ReviewAndRewrite`
- 输入:初稿 + 用户要求 + 素材卡片
- 输出:问题清单、改写稿、终稿
MVP 终止条件:
- 编辑评分达标则结束
- 不达标最多重写 2 次
- 超过次数则返回“需要人工介入”
## 6.2 第二阶段增强流程
在 MVP 跑通后,再增加:
- `Planner`:根据文章类型动态规划流程
- `SectionWriter`:按章节分别写作,避免长文崩坏
- `FactChecker`:逐段核对素材来源
- `StyleEditor`:统一语气、标题风格、句式节奏
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
## 6.3 建议的状态机
建议将流程状态固定为:
- `idle`
- `collecting_materials`
- `generating_outline`
- `await_outline_confirmation`
- `writing_draft`
- `reviewing_draft`
- `rewriting_draft`
- `completed`
- `failed`
- `human_intervention_required`
这样前端和后端都可以围绕统一状态通信。
---
## 7. Agent 角色定义建议
## 7.1 Agent A 研究员
职责:
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
- 输出结构化素材,而不是长篇自然语言
- 对每条素材标注来源、可信度、适用角度
输出格式建议:
- `facts[]`
- `quotes[]`
- `examples[]`
- `risks[]`
- `source_refs[]`
约束建议:
- 禁止直接生成整篇文章
- 不做风格化表达
- 每条结论必须可回溯到来源
## 7.2 Agent B 作家
职责:
- 严格基于提纲和素材写稿
- 按章节写,而不是一次写完整篇
- 对每段附带素材引用 ID,便于后续审核
约束建议:
- 无素材不发挥
- 每段只服务一个小节目标
- 段落长度控制
- 输出结构化草稿数据,而不仅是纯文本
## 7.3 Agent C 严苛编辑
职责:
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
- 输出明确的“问题清单”和“改写指令”
- 必要时打回指定章节,而不是整篇推翻
输出格式建议:
- `issues[]`
- `severity`
- `rewrite_instructions[]`
- `accept/reject`
编辑原则建议:
- 优先挑事实问题,再挑结构问题,最后才是文风问题
- 问题描述必须可执行,不能只说“写得不够好”
## 7.4 Supervisor
职责:
- 负责流程启动、状态流转、失败重试、记录日志
- 判断是否需要回到上一步
- 控制最大重试次数
- 对外提供统一 API 与流式事件
---
## 8. 前端需求
## 8.1 保持当前页面结构不变
继续沿用当前左右布局:
- 左侧:聊天 + 流程进度 + 中间产物
- 右侧:最终文章编辑与局部 AI 改写
不建议再复制一套新页面,以免造成维护分叉。
## 8.2 左侧新增 UI 模块
建议在 `CanvasChatPanel` 基础上新增:
- 写作任务表单
- 工作流阶段时间线
- Agent 执行日志面板
- 中间产物抽屉
- 人工确认卡片
建议新增的前端概念:
- `workflowMode`: `chat | writing_pipeline`
- `workflowStage`
- `workflowRunId`
- `workflowEvents[]`
- `workflowArtifacts[]`
## 8.3 需要展示的中间产物
至少支持展示:
- 素材卡片
- 提纲
- 初稿
- 编辑问题单
- 重写结果
- 最终定稿
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
## 8.4 人工介入节点
至少提供两个确认点:
- 提纲确认
- 初稿确认或编辑后重跑
后续可扩展:
- 指定重写某个章节
- 锁定某段不允许再改
- 替换某条素材后重新生成
---
## 9. 后端需求
## 9.1 架构原则
后端新路线必须满足:
- 与现有通用对话链路解耦
- 与现有 Agent 运行时解耦
- 独立维护 Prompt、状态机、数据结构
- 可以单独部署、单独回滚、单独压测
## 9.2 推荐模块划分
建议后端拆分为以下模块:
- `workflow_orchestrator`
- `agent_registry`
- `material_retriever`
- `outline_generator`
- `draft_writer`
- `editor_reviewer`
- `rewrite_engine`
- `workflow_store`
- `stream_event_adapter`
## 9.3 API 建议
建议最小 API:
- `POST /api/ai-writing/workflows`
- 创建写作任务
- `POST /api/ai-writing/workflows/{id}/start`
- 启动流程
- `GET /api/ai-writing/workflows/{id}`
- 查询当前状态
- `GET /api/ai-writing/workflows/{id}/events`
- 获取事件流或轮询事件
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
- 用户确认提纲
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
- 指定章节重写
- `POST /api/ai-writing/workflows/{id}/approve-final`
- 确认终稿
## 9.4 流式事件建议
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
- `workflow_started`
- `stage_changed`
- `agent_started`
- `agent_output_chunk`
- `artifact_generated`
- `human_confirmation_required`
- `retry_scheduled`
- `workflow_completed`
- `workflow_failed`
前端只要订阅事件,就能把左侧工作台做出来。
---
## 10. 弱模型优化策略
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
## 10.1 强约束输出格式
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
原因:
- 便于程序消费
- 便于后续 Agent 接续
- 便于失败重试
- 便于质量评估
## 10.2 长文拆成章节生成
不要让弱模型一次生成整篇长文,建议:
- 先出提纲
- 再按章节逐段写
- 最后统一润色
## 10.3 把检索和写作分离
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
## 10.4 程序兜底而不是模型兜底
程序需要承担:
- 去重
- 引用格式化
- 章节拼接
- 长度控制
- 评分阈值判断
- 重试路由
## 10.5 小步快跑、失败可回退
每步都要能独立失败、独立重试、独立回放。
## 10.6 引入可量化评分
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
- 相关性
- 事实性
- 结构完整度
- 语言流畅度
- 风格一致性
系统据此决定是否进入下一步。
---
## 11. 分阶段实施计划
## Phase 1:需求冻结与技术验证
目标:
- 确认文章类型范围
- 确认内网资料来源
- 选定主框架
- 跑通最小多 Agent demo
交付物:
- 技术选型说明
- 角色与状态机设计
- 1 个主题的端到端 demo
验收标准:
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
## Phase 2:后端新路线搭建
目标:
- 建立独立 orchestrator
- 建立 workflow state store
- 建立事件流输出
- 建立可配置 prompt 模板
交付物:
- 独立服务目录
- 基础 API
- 状态机运行骨架
验收标准:
- 不依赖现有主流程改造即可独立跑通
## Phase 3:前端接入当前页面
目标:
- 在 `CanvasChatPanel` 接入工作流模式
- 增加阶段展示与人工确认
- 中间产物可视化
交付物:
- 左侧工作流时间线
- 中间产物展示面板
- 流程控制按钮
验收标准:
- 用户可以在当前页面完成一次全流程写作
## Phase 4:质量优化
目标:
- 强化素材检索质量
- 提高提纲质量
- 引入章节级重写
- 引入评分与回退机制
交付物:
- 质量评估规则
- 重试策略
- Prompt 调优包
验收标准:
- 弱模型效果明显优于单轮直出
## Phase 5:生产化
目标:
- 加缓存
- 加日志
- 加监控
- 加失败告警
- 加审计记录
交付物:
- 可观测性方案
- 运行日志与回放
- 配置中心或 prompt 管理机制
验收标准:
- 可长期维护并可定位问题
---
## 12. 团队协作与隔离方案
为了不影响其他同事,建议采用以下协作原则:
- 新后端目录独立开发
- 新接口使用独立路由前缀
- 前端只在 `open-canvas` 内增量开发
- 不修改现有公共线程协议,必要时增加新事件类型
- Prompt、Agent 配置、评分规则独立存放
推荐分工:
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
---
## 13. 风险与规避
### 风险 1:Agent 太多,成本和耗时暴涨
规避:
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
- 长文按章节写,失败只重写局部
### 风险 2:弱模型在自由对话场景中失控
规避:
- 全流程结构化
- 每步只做一件事
- 强约束输入输出格式
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
规避:
- 先把资料源治理好
- 对素材做来源、可信度、去重处理
### 风险 4:前后端协议频繁变化影响联调
规避:
- 先定义事件模型和 artifact 模型
- 先 mock 数据,再并行开发
### 风险 5:影响现有功能稳定性
规避:
- 新路线、新接口、新状态机
- 不侵入现有主链路
---
## 14. 推荐结论
本项目推荐的总体策略:
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
3. 主框架优先采用 `LangGraph`
4. 流程设计借鉴 `STORM`
5. 质量控制借鉴 `Deep Research Agent`
6. 角色设计与任务模板可参考 `CrewAI`
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
一句话总结:
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
---
## 15. 参考链接
- [LangGraph](https://github.com/langchain-ai/langgraph)
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
- [STORM](https://github.com/stanford-oval/storm)
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
- [CrewAI](https://github.com/crewAIInc/crewAI)
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
- [AutoGen](https://github.com/microsoft/autogen)
-501
View File
@@ -1,501 +0,0 @@
# AI 写作 — 迁移到 LangGraph Server 的分步实现方案
> **文档目的**:彻底解决 AI 写作在多 worker 部署下"SSE 时断时续 / resume 偶发 404"的根因,参照主聊天(`/workspace/chats/new`)的实现,把 AI 写作的 graph 注册成 LangGraph Server 上的一个 assistant,让 Server 接管线程、checkpoint、SSE、interrupt/resume 的全部协议。
>
> **配套文档**:[ai-writing-说明.md](./ai-writing-说明.md) 里描述了为什么会出问题、当前已落地的兜底方案,以及方案 B/C/F 三条"绕过"路径。本文档是其中"长期方案"的具体实现路线 —— 不是绕过多 worker,而是从架构层面让它**天然不受 worker 数影响**。
>
> **更新日期**:2026-05-20
---
## 一、为什么走这条路
主聊天和 AI 写作功能形态几乎一致(多步流式、可中断/恢复、有持久化),但**主聊天完全不受多 worker 影响**。差别在于:
| 维度 | 主聊天(现状) | AI 写作(现状) | AI 写作(迁移后) |
|---|---|---|---|
| 前端流接入 | `useStream` (SDK) | `fetch` + 自写 `EventSource` | `useStream` (SDK) |
| 后端运行时 | **LangGraph Server**(独立进程,端口 2024 / 经 nginx 走 `/api/langgraph`) | 嵌在 FastAPI Gateway 路由里直接跑 | **LangGraph Server**(同主聊天) |
| 线程状态 | Server 自带持久化 checkpointer | 自管 sqlite + 一堆模块级 dict | Server 自带持久化 checkpointer |
| SSE 通道 | Server ↔ SDK 协议层 | 自写 `asyncio.Queue` per session_id | Server ↔ SDK 协议层 |
| 多 worker | 任何 worker 都能查 Server | per-worker 内存 dict 隔离 | 任何 worker 都能查 Server |
| Interrupt/Resume | Server 协议(`Command(resume=...)`)| 自写 `_session_pending_pause` + `/resume` 路由 | Server 协议 |
**核心收益**:迁完后 `_event_queues`、`_session_pending_pause`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_article_types`、`_session_error_flags`、`_session_pending_pause`、`_inflight_graph_tasks`、`_recover_session()`、自管 sqlite checkpointer 这一整套"per-process 内存状态"全部可以删掉 —— 不是"绕过"问题,是**让问题消失**。
**代价**:节点要改造(流式 chunk 改走 LangGraph 原生 `StreamWriter`/state update)、前端干预卡的交互要重写、业务表写入路径要从"路由里写"挪到"节点里写"。约 **6 人日**核心改造 + 50% buffer,建议按 **9 人日**排期。
---
## 二、现状盘点:要删 / 要留 / 要改
### 2.1 要删的(迁完即可移除)
| 位置 | 内容 | 备注 |
|---|---|---|
| `app/gateway/routers/ai_writing.py` | `POST /start`、`GET /{id}/stream`、`POST /{id}/resume`、`GET /{id}/state` | 由 LangGraph Server 的 `runs.stream / threads.create / runs.wait` 替代 |
| 同上 | `_event_queues`、`_session_pending_pause`、`_session_model_names`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_error_flags`、`_session_article_types` 这 8 个模块级 dict | 状态全部交给 LangGraph Server |
| 同上 | `_recover_session()`、`_inflight_graph_tasks`、`_spawn_graph_task()`、`wait_for_inflight_graph_tasks()` | 跨 worker 恢复 / drain 都由 Server 内建机制处理 |
| 同上 | `_make_config()` 里注入 `sse_queue` / `sse_session_id` 的逻辑 | 流式输出改走 LangGraph 原生通道 |
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()`、`AI_WRITING_GRAPH` 单例、`_async_checkpointer_singleton` | checkpoint 由 langgraph.json 的全局 checkpointer 接管 |
| `app/gateway/ai_writing_cleanup.py` | "联动清理 sqlite checkpointer thread" 那一段 | 改成调 `langgraph_sdk` 的 `threads.delete()` |
| `offline-backend-20260512/config.yaml` | `checkpointer:` 段里 AI 写作专用的 sqlite 路径(如果跟主 checkpointer 不同) | 共用主配置 |
### 2.2 要保留的
| 位置 | 内容 | 理由 |
|---|---|---|
| `packages/harness/deerflow/persistence/ai_writing_sessions/` | MySQL 业务表(标题、最终 draft、completed_interventions、transcript、article_type、status…) | 列表页 / 历史回显 / 全文搜索都依赖业务表,**不能让 LangGraph checkpoint 取代它** —— checkpoint 是流程状态,业务表是产品形态 |
| `GET/PATCH/DELETE /api/ai-writing/sessions[/{id}]` | 列表 / 重命名 / 删除 | 只查业务表,不碰 graph |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 时间线持久化 | 维持锦上添花的回显方案 |
| `GET/POST/PATCH/DELETE /api/ai-writing/article-types[/{id}]` | 文章类型 CRUD | 纯业务路由,跟 graph 无关 |
| `POST /api/ai-writing/admin/cleanup` | admin 清理入口 | 路由保留,内部实现改 |
| 4 个 interrupt 暂停点 + 5 个节点 + 全部 state 字段 | 业务逻辑零改动 | `pause_material_confirm` / `pause_outline_confirm` / `pause_section_help` / `pause_draft_confirm` / `pause_review_confirm` 用的就是 LangGraph 原生 `interrupt()`,直接吃迁移红利 |
### 2.3 要改的
| 位置 | 改造点 |
|---|---|
| `graph.py` | 删自管 checkpointer,导出 `make_ai_writing_graph(config) → CompiledStateGraph`,由 Server 注入 checkpointer |
| `langgraph.json` | `graphs` 里增加 `"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"` |
| 各 node(`researcher.py` / `writer_outline.py` / `writer_draft.py` / `editor.py`) | 流式输出从 `sse_queue.put_nowait(...)` 改成 LangGraph 原生 `get_stream_writer()` 推 custom 事件 |
| 各 node | DB 写入(草稿、审核结果、status)从「路由里调 repo」改成「节点里通过 `RunnableConfig` 拿 `thread_id` 调 repo」 |
| 各 node | "创建 session 行"从 `/start` 路由里挪到 `intent_parser_node`(或前端 `onCreated` 回调里调一个轻量级 upsert API) |
| `langgraph_auth.py` | 给 `ai_writing` assistant 加授权规则(保证只能访问自己的 thread) |
| 前端 `frontend-web/src/open-canvas/` | 完全弃用现有 `api/ai-writing.ts` 的 `fetch + EventSource`,新写 `useAIWritingStream` hook(基于 `@langchain/langgraph-sdk/react` 的 `useStream`) |
| 前端 4 个干预卡 | 提交回调从 `POST /resume` 改为 `thread.submit(new Command({ resume: payload }))` |
| 前端"启动写作"按钮 | 从 `POST /start` + 拿 session_id + 建 EventSource 改成 `thread.submit({...args})` 触发 `onCreated` 回调 |
| `tests/test_ai_writing_session_recovery.py` | 整体删除 —— 跨 worker 恢复由 LangGraph Server 接管,不再是 AI 写作自己的事 |
| 新增 `tests/test_ai_writing_assistant.py` | 通过 LangGraph SDK in-process 跑一遍 graph,覆盖 interrupt / resume / custom stream / state 持久化 |
---
## 三、分步实施
### 阶段 0:可行性预演(0.5 d)
**目标**:在不动现有代码的前提下,验证"把 ai_writing graph 注册到 LangGraph Server 并通过 SDK 跑通"这条路完全可行。
1. 临时在 `langgraph.json` 的 `graphs` 里加一条 `"ai_writing_probe": "deerflow.agents.ai_writing.graph:build_ai_writing_graph"`(直接复用现有 `build_ai_writing_graph`,不传 checkpointer 让 Server 注入)。
2. 起 `make dev`,确认 LangGraph Server 把 `ai_writing_probe` 列入 assistant 列表。
3. 用 Python SDK 客户端(`langgraph_sdk.get_client()`):
```python
from langgraph_sdk import get_client
client = get_client(url="http://localhost:8001/api/langgraph")
thread = await client.threads.create()
async for chunk in client.runs.stream(
thread["thread_id"], "ai_writing_probe",
input={"user_intent": "测试", "article_type": "analysis", ...},
stream_mode=["values", "messages-tuple", "custom"],
): print(chunk)
# 跑到 pause_material_confirm 应该收到 interrupt 事件
await client.runs.stream(thread["thread_id"], "ai_writing_probe",
command={"resume": {"action": "confirm", "approvedMaterialIds": [...]}})
```
4. 验证:interrupt 能正确暂停、`Command(resume=...)` 能续跑、state 能持久化到 Server checkpoint。
5. 跑通后**回滚 langgraph.json** —— 这一步只是"摸路"。
**通过条件**:4 个 pause 点全部能用 SDK 跑通 interrupt + resume。如果某个 pause 点的 payload 结构跟 SDK 协议不兼容(例如用了非 JSON 可序列化的对象),阶段 0 就要解决,否则会在阶段 2 翻车。
---
### 阶段 1:graph 注册为 assistant(0.5 d)
**改动文件**:`packages/harness/deerflow/agents/ai_writing/graph.py`、`offline-backend-20260512/backend/langgraph.json`
1. `graph.py` 顶部新增:
```python
from langchain_core.runnables import RunnableConfig
def make_ai_writing_graph(config: RunnableConfig | None = None):
"""LangGraph Server 入口:不接收 checkpointer,由 Server 注入。"""
g = StateGraph(AIWritingState)
# ... 跟现在 build_ai_writing_graph 完全一样的节点 / 边定义 ...
return g.compile() # 注意:不传 checkpointer
```
2. 旧的 `build_ai_writing_graph(checkpointer=None)`、`get_ai_writing_graph()`、`_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()` 全部**先标 `@deprecated` 暂留** —— 阶段 7 再删,避免阶段 2/3 出错时无法快速回退。
3. `langgraph.json`:
```json
{
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
}
}
```
4. `checkpointer.path` 已经指向 `make_checkpointer`(看 `langgraph.json:15-17`),AI 写作直接共享主 checkpointer 即可,**不要**重新搞一套。
**通过条件**:启动 LangGraph Server 后 `GET /api/langgraph/assistants` 能列出 `ai_writing`,state schema 字段齐全。
---
### 阶段 2:节点改造 —— 流式输出走原生通道(1.5 d)
**核心问题**:现在的 `writer_draft_node` / `writer_outline_node` / `researcher_node` 通过 `config["configurable"]["sse_queue"]` 把 LLM token chunk 推给前端。LangGraph Server 不会再注入 `sse_queue`,必须改用原生流通道。
**选型**:用 `langgraph.config.get_stream_writer()` 推 `custom` 事件(保留现有"非聊天消息、自定义协议"的形态)。
> 不选 `messages-tuple` 模式的原因:草稿生成不是聊天消息流,而是大段 markdown;硬塞成 `AIMessage` 会让前端渲染逻辑变得很别扭。custom 通道改动最小、契合度最高。
**改造模板**(每个节点都按这个模板改):
```python
# 旧:
from .nodes._utils import push_sse_event
async def writer_draft_node(state, config):
sse_queue = config["configurable"].get("sse_queue")
sse_queue.put_nowait({"event": "progress", "data": {...}})
async for chunk in llm.astream(...):
sse_queue.put_nowait({"event": "draft_chunk", "data": {"text": chunk}})
# 新:
from langgraph.config import get_stream_writer
async def writer_draft_node(state, config):
writer = get_stream_writer() # 注意:必须在 async 函数体内调,不能模块级缓存
writer({"type": "progress", "agent": "writer", "message": "开始写作"})
async for chunk in llm.astream(...):
writer({"type": "draft_chunk", "text": chunk})
```
**业务表写入改造**:现在 `writer_draft_node` 收尾时通过 `config["configurable"]["sse_session_id"]` + `_session_repos[sid]` 调 `repo.save_draft()`。新方案:
```python
async def writer_draft_node(state, config):
thread_id = config["configurable"]["thread_id"] # LangGraph Server 注入
# 从 thread metadata / state 里拿到 user_id(在阶段 4 写入)
user_id = state.get("_user_id") or config["configurable"].get("user_id")
repo = get_ai_writing_session_store() # 进程内全局,无 per-session 字典
try:
await repo.save_draft(thread_id, user_id, state["current_draft"])
except Exception:
logger.exception("save_draft failed (non-fatal)")
```
**注意事项**:
- `get_stream_writer()` 在子图 / 工具函数里会拿到当前 graph 的 writer,**不要**在模块 import 时缓存。
- 节点要兼容"测试场景下没有 LangGraph runtime":包一层 `try: writer = get_stream_writer() except ...`,单测里 writer 是 no-op。
- progress_events 字段保留写入 state(现在节点都已经 return `{"progress_events": [...]}`),这样 history / 回放仍然能在 state 里看到完整轨迹;custom stream 只是为了**实时推送**。
**通过条件**:手动跑一次 `make dev`,用 SDK 客户端从前一阶段验证脚本流式拿到 `draft_chunk` 事件。
---
### 阶段 3:前端接入 LangGraph SDK(1 d)
**核心改动**:`frontend-web/src/open-canvas/api/ai-writing.ts` 整文件作废,改写 `frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`。
**新 hook 骨架**(参考 `frontend-web/src/core/threads/hooks.ts:207` 现有主聊天写法):
```ts
import { useStream } from "@langchain/langgraph-sdk/react";
import { Command } from "@langchain/langgraph-sdk";
import { getAPIClient } from "@/core/api/api-client";
interface AIWritingValues {
user_intent: string;
article_type: string;
status: string;
material_package?: MaterialPackage;
current_outline?: Outline;
current_draft?: DraftArticle;
review_result?: ReviewResult;
progress_events: any[];
}
export function useAIWritingStream(sessionId: string | "new") {
const stream = useStream<AIWritingValues, { CustomEventType: AIWritingCustomEvent }>({
client: getAPIClient(),
assistantId: "ai_writing",
threadId: sessionId === "new" ? undefined : sessionId,
reconnectOnMount: true,
onCreated(meta) {
// 后端会用同一个 thread_id 做 ai_writing_sessions.id;通知 UI 写入 URL
navigateTo(`/workspace/ai-writing/${meta.thread_id}`);
},
onCustomEvent(event) {
// 节点 push 来的 draft_chunk / progress / notice
pushUIEvent(event);
},
});
return stream;
}
```
**4 个干预卡的提交逻辑**(旧 → 新):
```ts
// 旧(自写 fetch):
await fetch(`/api/ai-writing/${sessionId}/resume`, {
method: "POST",
body: JSON.stringify({ action: "confirm", approvedMaterialIds: [...] }),
});
// 新(SDK 原生 Command):
stream.submit(undefined, {
command: { resume: { action: "confirm", approvedMaterialIds: [...] } },
});
```
**启动写作**:
```ts
// 旧:POST /start → 返回 session_id → 建 EventSource
// 新:
stream.submit({
user_intent, article_type, target_audience, word_count_target, keyword_count, writing_mode,
}, {
config: { configurable: { model_name } },
metadata: { feature: "ai_writing", article_type }, // 走业务表筛选
});
```
**列表页 / 历史回显**:仍然走 `GET /api/ai-writing/sessions` 业务 API,**无改动**。点进某条历史记录时:
- 拿到 `sessionId` (== LangGraph `thread_id`)
- `useAIWritingStream(sessionId)` 自动调 `client.threads.getState()` 拉最新 state,渲染最后一次草稿 / 干预点 / interrupt 等待
- 已结束的会话 (`status == "done"`) 直接读业务表的 `draft_markdown` 字段,**不挂 SDK stream**(省 Server 资源)
**通过条件**:手动跑完一个完整流程(启动 → 4 次干预 → 拿到草稿),过程中刷新页面 / 切 worker,进度都不丢。
---
### 阶段 4:业务表写入解耦(0.5 d)
**问题**:现在 `/start` 路由先 INSERT 一行 `ai_writing_sessions(id=session_id, ...)`,再启动 graph。迁移后前端用 `stream.submit(...)` 直接触发 LangGraph 建 thread + 跑 graph,业务表行没人建 → 列表页查不到。
**两个方案二选一**:
**方案 4A(推荐)**:节点里建业务表行
- `intent_parser_node` 开头:`await repo.upsert_session(thread_id, user_id, user_intent, article_type, status="researching")`
- 优点:单一数据源(节点),不依赖前端做"双写"。
- 缺点:用户从前端点"启动" → 看到列表里出现这条 → 之间有 200~500ms 延迟(intent_parser 跑完才入库),需要前端 onCreated 后乐观插入一条临时行。
**方案 4B**:前端 onCreated 时调一个轻量 upsert API
- 新增 `POST /api/ai-writing/sessions/{thread_id}/init`:只插业务表行,不碰 graph。
- 前端 `onCreated(meta)` 回调里立刻 fire-and-forget 调它。
- 优点:列表瞬间出现新行。
- 缺点:前端要多调一次 API,且失败要兜底;多了一条"双方都能建行"的路径。
**推荐 4A** —— 单一数据源比 UI 响应更重要,200ms 延迟用 loading skeleton 即可。
**用户身份传递**:节点要拿到 `user_id` 写入业务表。两条路:
- LangGraph Server 已有 `langgraph_auth.py` 注入 auth context,在节点里 `config["configurable"].get("langgraph_auth_user")` 取(具体字段名以现有实现为准)。
- 或者前端 `submit()` 时 metadata 里带上 `user_id`,节点从 `config["configurable"]["metadata"]` 拿。
**通过条件**:从启动到列表显示新行,延迟 < 1s;多用户场景下不会出现"A 用户的 thread 落到 B 的业务表行"。
---
### 阶段 5:transcript 持久化(0.2 d)
**当前**:前端 `PUT /api/ai-writing/sessions/{id}/transcript` 全量覆盖 `ai_writing_sessions.transcript` 列。
**改造选择**:
- **维持现状**(推荐):transcript 是前端组装的时间线 blob,让前端继续 fire-and-forget PUT 即可,跟迁移正交。
- 或者完全弃用 transcript 列,直接从 `client.threads.getHistory()` + `getState()` 还原 —— 工作量大且需要重写回显逻辑,**不推荐第一版做**。
**通过条件**:原 PUT 接口照常工作;新建会话也能落库。
---
### 阶段 6:admin 清理改造(0.3 d)
**改动文件**:`app/gateway/ai_writing_cleanup.py`
```python
# 旧:
saver = get_ai_writing_checkpointer()
await saver.adelete_thread(sid) # 自管 sqlite
# 新:
from langgraph_sdk import get_client
lg_client = get_client(url=internal_langgraph_url())
await lg_client.threads.delete(sid)
```
**活跃保护**:旧逻辑是看 `sid in _event_queues`。新逻辑:
```python
state = await lg_client.threads.get_state(sid)
is_active = (
state.get("next") # graph 还有未执行的节点
or state.get("tasks") # 当前有 pending task
or state.get("metadata", {}).get("interrupted") # 卡在 interrupt 上
)
if is_active:
skipped_active += 1
continue
```
**通过条件**:admin cleanup 接口照常返回 `deleted_db / deleted_checkpoints / skipped_active`,且能正确跳过仍在 interrupt 等待用户的会话。
---
### 阶段 7:删冗余代码(0.3 d)
按 §2.1 列表逐项删除。建议**单独开一个 PR**,跟阶段 1~6 解耦:
- 前 6 个 PR 全部上线、灰度验证至少 1 周
- 确认旧代码路径再无流量后,再走清理 PR
- 这样万一灰度阶段需要回滚,旧路径还在
**通过条件**:`make test` 全绿,`grep -r "_event_queues\|_session_pending_pause\|_recover_session" app/ packages/` 零命中。
---
### 阶段 8:测试 + 灰度(1 d)
**新增测试**:
- `tests/test_ai_writing_assistant.py` —— 用 `langgraph_sdk` 客户端打 LangGraph Server,覆盖:
- 完整流程:start → 4 个 interrupt → resume → done
- state 持久化:跑到 pause_outline 后重建客户端,能 `getState` 看到 outline
- custom stream:能收到 `draft_chunk` 等事件
- 错误兜底:节点抛错时 status 落 error、业务表 status 同步
- 保留 `tests/test_portable_long_text.py` / `tests/test_ai_writing_transcript_endpoint.py` / `tests/test_ai_writing_session_repo.py` —— 这几个跟业务表强相关,不受迁移影响。
**删除测试**:
- `tests/test_ai_writing_session_recovery.py` —— 跨 worker 恢复改由 LangGraph Server 提供,不再是 AI 写作自己的能力。
- `tests/test_ai_writing_cleanup.py` 里关于"drain + checkpointer 降级 + SSE 序列化"的几个用例 —— 这些机制已删,对应用例失效。
**灰度策略**:
- **前端开关**:`VITE_AI_WRITING_BACKEND` = `legacy | langgraph`,默认 `legacy`
- `legacy` → 走老的 `fetch + EventSource + /resume`
- `langgraph` → 走新 `useAIWritingStream`
- **后端**:旧路由(`/start /stream /resume /state`)保留两个发版周期,逐步下线
- **数据兼容**:业务表 schema 完全不变,两条路径都用同一张表
- **回滚成本**:改回 `VITE_AI_WRITING_BACKEND=legacy` 重新部署即可,无 DB migration
**通过条件**:
- 灰度环境跑 100+ 写作流程,对比新旧两条路径的成功率、SSE 中断率、resume 成功率
- 新路径在 worker=4 部署下 SSE 中断率 = 0(旧路径基线约 5~15%)
---
## 四、关键设计决策与权衡
### 4.1 为什么不让 LangGraph checkpoint 替代业务表?
LangGraph checkpoint 存的是"图执行状态",业务表存的是"产品形态"。两者职责不同:
- **会话列表筛选/排序**:业务表 `WHERE user_id = ? AND status = ? ORDER BY updated_at` —— LangGraph checkpoint 没法这样查(它是 `(thread_id, checkpoint_ns)` 主键)
- **全文搜索 draft**:业务表 `LIKE '%关键词%'` —— checkpoint 是序列化 blob
- **transcript 自定义 UI 协议**:前端组装的时间线 JSON,跟 graph state 不一一对应
**结论**:两者**并存**,`thread_id` 作为外键。这是主聊天的做法(`threads_meta` + LangGraph checkpoint),AI 写作迁完后也是同样的形态。
### 4.2 为什么 graph 节点里直接调 repo,不发到外面消费?
考虑过"节点只 emit custom event,路由层订阅事件后写业务表" —— 但这又把状态分散回路由层了。
直接在节点里调 `await repo.save_draft(...)`:
- 节点拿得到 `thread_id`(== session_id == business id),主键一致
- `repo` 是单例(DB 连接池),不依赖 per-session 字典
- 写入失败包 try/except 即可(草稿落 state 比落 DB 更关键,DB 失败不阻断流程)
### 4.3 为什么用 custom stream 而不是 messages-tuple?
writer_draft 输出是大段 markdown,强行包成 `AIMessage` 后:
- 前端要把它从消息列表里拎出来当草稿渲染(混杂)
- 大纲 / 审核结果是结构化 JSON,更不像聊天消息
- progress_events / notice / draft_chunk / outline_chunk 几种事件类型,全塞 message 通道得发明 metadata convention
custom stream 是为这种"非聊天但需要流式 push"场景设计的,匹配度更高。
### 4.4 在 Server 进程里跑 graph,资源够用吗?
LangGraph Server 是独立 Python 进程,跟 Gateway 共享同一台机器但不共享内存。新增一个 assistant 的常驻开销 ~10 MB(graph 编译后的 schema 缓存),每个并发会话峰值 ~50 MB(LLM client + state)。
实测建议:在阶段 0 之后跑一次 10 个并发的压测,观察 Server RSS 增量。若一台机器 4 GB 内存,理论支撑 60~80 并发 AI 写作,足够 50 人规模团队使用。
---
## 五、风险清单
| # | 风险 | 缓解 |
|---|---|---|
| R1 | LangGraph Server 升级版本时 `interrupt()` / `Command(resume=...)` 协议有 breaking change | 在 `pyproject.toml` 里 pin `langgraph` minor 版本;升级前先在阶段 0 那个 probe 跑一遍冒烟 |
| R2 | 前端 `useStream` 的 `reconnectOnMount` 行为跟自写 EventSource 不完全一致,刷新页面后某些情况下不会自动续接 SSE | 阶段 3 手动测刷新场景;如不达预期,配合 `client.threads.getState()` 拉一次 state 兜底 |
| R3 | 节点里 `get_stream_writer()` 在单测里没有 LangGraph runtime → 抛错 | 节点 wrap `try: writer = get_stream_writer() except: writer = lambda x: None` |
| R4 | 多用户场景下 `langgraph_auth.py` 没对 ai_writing assistant 做隔离,可能跨用户拿到别人的 thread state | 阶段 4 一并配置 auth;新增专项测试 |
| R5 | `client.threads.delete()` 是异步删,admin 清理脚本调完后立即查 state 还能短暂查到 → `skipped_active` 误判 | 删之前先 lock thread 或调用 `client.runs.cancel()` 强制收敛 |
| R6 | LangGraph Server 自带 checkpoint 仍然是 sqlite(`langgraph.json` 里配的),在容器重启时跟 AI 写作原有的 `.deer-flow/data/checkpoints.db` 是同一个文件 → schema 共用 | 阶段 1 之前先确认主 checkpointer 配置路径,避免双开 sqlite 文件锁竞争 |
| R7 | 现有用户在迁移窗口期发起的会话,灰度切到新路径后无法续跑(state 在旧 sqlite,新路径查不到) | 迁移前 1 小时禁止新会话;或写一个一次性脚本把旧 checkpointer 的 thread 复制到主 checkpointer |
---
## 六、工时与里程碑
| 阶段 | 工时 | 可独立交付 |
|---|---|---|
| 阶段 0:可行性预演 | 0.5 d | ✅ |
| 阶段 1:graph 注册 | 0.5 d | ❌(后端只生效,前端没接) |
| 阶段 2:节点改造 | 1.5 d | ❌ |
| 阶段 3:前端接入 | 1 d | ✅(带开关上灰度) |
| 阶段 4:业务表写入解耦 | 0.5 d | ❌(跟阶段 3 合并发) |
| 阶段 5:transcript | 0.2 d | ✅ |
| 阶段 6:admin 清理改造 | 0.3 d | ✅ |
| 阶段 7:删冗余 | 0.3 d | ✅(独立 PR,灰度后再发) |
| 阶段 8:测试 + 灰度 | 1 d | ✅ |
| **核心工时** | **5.8 d** | |
| **+ 50% buffer** | **8.7 d ≈ 9 d** | |
**建议排期**:
- 第 1 周:阶段 0 + 1 + 2(后端完整、本地能跑通)
- 第 2 周:阶段 3 + 4 + 5 + 6(前端 + 业务集成)
- 第 3 周:阶段 8(测试 + 灰度)
- 灰度 1~2 周后:阶段 7(删冗余)
---
## 七、回滚预案
每个阶段都设计为可独立回滚:
| 场景 | 回滚动作 |
|---|---|
| 阶段 3 上线后发现 SSE 不稳 | 前端开关 `VITE_AI_WRITING_BACKEND=legacy`,重新部署,0 dataloss |
| 阶段 7 删冗余代码后发现新路径有问题 | 从 git revert 删除 commit,恢复旧路由代码;业务表/checkpoint 不受影响 |
| 阶段 1 注册 assistant 后 LangGraph Server 起不来 | 从 `langgraph.json` 移除 `ai_writing` 那一行,重启 Server,回到迁移前状态 |
**数据兼容承诺**:
- 业务表 schema 完全不变
- `thread_id` (== `ai_writing_sessions.id`) 含义保持一致
- transcript 列含义不变
**最坏回滚**:把 `VITE_AI_WRITING_BACKEND` 改回 `legacy`、`langgraph.json` 删 `ai_writing` 那行,回到迁移前的"方案 C/F/B 路径"。已经走完的会话留在新 checkpoint 里成"孤儿",但业务表里的草稿和审核结果**完整无损**。
---
## 八、迁移成功后能享受的"免费红利"
1. **完整 history API**:`client.threads.getHistory(thread_id)` 拿到每一步 checkpoint,做"回到某一步重试"非常便宜
2. **time travel**:用户能从任意 checkpoint 分叉重写
3. **跟主聊天共享基础设施**:升级 LangGraph 版本时一并升级,不用维护两套 SSE / checkpoint 协议
4. **天然支持水平扩容**:哪天上 K8s 多副本部署,AI 写作跟主聊天一样开箱即用
5. **天然支持 IM 渠道接入**:`app/channels/` 那套 Feishu / Slack 实现是 `langgraph-sdk` 的,迁完后 AI 写作也能在 IM 里用,零额外代码
---
## 九、相关文件索引(迁移前后参照)
### 现状(迁前)
- `app/gateway/routers/ai_writing.py:148-176` —— 8 个模块级 dict + drain task
- `packages/harness/deerflow/agents/ai_writing/graph.py:370-538` —— 自管 checkpointer
- `frontend-web/src/open-canvas/api/ai-writing.ts` —— 自写 fetch + EventSource
### 参照(主聊天迁后形态)
- `frontend-web/src/core/threads/hooks.ts:207` —— `useStream` 用法
- `frontend-web/src/core/api/api-client.ts` —— LangGraph SDK Client 初始化
- `offline-backend-20260512/backend/langgraph.json` —— assistant 注册位置
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/` —— graph 工厂参考写法
- `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py` —— 共享 checkpointer 入口
### 目标(迁后)
- 新增:`packages/harness/deerflow/agents/ai_writing/graph.py:make_ai_writing_graph`
- 新增:`frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`
- 删除:`app/gateway/routers/ai_writing.py` 中的 `/start /stream /resume /state` 路由 + 8 个 dict
- 删除:`frontend-web/src/open-canvas/api/ai-writing.ts` 中的 `startWriting / streamSession / resume` 全部 fetch 调用
- 简化:`app/gateway/ai_writing_cleanup.py`(改用 SDK client 删 thread)
---
## 十、决策摘要(给 PM / 维护者)
> **TL;DR**
> 1. 主聊天不受多 worker 影响,是因为它把状态托管给了独立的 LangGraph Server 进程,前端直接通过 SDK 跟 Server 通信。AI 写作要做同样的事。
> 2. 改造范围:后端 graph 注册 + 节点流通道改造 + 前端 hook 换 SDK + 业务表写入解耦。**核心约 6 人日,含 buffer 9 人日**。
> 3. 业务表、节点逻辑、4 个干预点、UI 形态全部保留,**用户感知层面零变化**。
> 4. 改造完一次性获得"刷新不丢进度、多 worker 不裂、跟主聊天共享基础设施、IM 渠道免费支持"等长期红利。
> 5. 建议跟"方案 C"并行:先按 [ai-writing-说明.md](./ai-writing-说明.md) 上方案 C 应急止血,本方案作为长期改造按周排期。两者不冲突,方案 C 上线即受益,本方案完工后可下线方案 C 的双进程配置。
-787
View File
@@ -1,787 +0,0 @@
# AI 写作 — 故障排查与解决方案说明
> **本文档目的**:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。
>
> **适用读者**:维护 AI 写作功能的前端/后端开发者、内网部署运维。
>
> **更新日期**:2026-05-20
---
## 一、问题背景
### 起因
内网 Docker 部署后,AI 写作出现两类报错:
| 接口 | 报错 | 频率 |
|---|---|---|
| `PUT /api/ai-writing/sessions/{id}/transcript` | HTTP 500 | 经常 |
| `POST /api/ai-writing/{id}/resume` | HTTP 4xx(用户记忆为 400,实际可能是 404) | 经常,且没有 Docker 重启时也发生 |
### 这两个接口的作用
#### 1. `PUT /sessions/{id}/transcript` — 对话时间线持久化
- **职责**:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
- **调用时机**:前端在写作过程中**反复 PUT**(事件累计、干预完成、草稿生成等节点全量覆盖)
- **存到哪**:MySQL 的 `ai_writing_sessions.transcript` 列(LONGTEXT)
- **用途**:用户**关闭页面后再回来 / 刷新 / 看历史会话**时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
- **定位**:**纯锦上添花** — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整
#### 2. `POST /{id}/resume` — 写作流程的核心控制接口
- **职责**:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
- **调用时机**:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
- **传什么**:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
- **定位**:**流程主干** — 它失败,用户就**真的卡死**在干预点
---
## 二、根因分析
### 根因 1:`transcript` 列容量不足 + 路由层无兜底
- MySQL `TEXT` 列上限 64 KB
- 用户写作过程中累计 `progressEvents`(含每个流式 chunk)+ `completedInterventions` 后,JSON blob 经常超过 64 KB
- `session.commit()` 抛 `DataError: Data too long for column 'transcript'`
- 原路由没有 try/except,异常直接冒泡成 HTTP 500
### 根因 2:内存中的会话状态在多 worker 部署下隔离
后端用了大量模块级 dict 维护会话状态:
```python
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件
_session_model_names: dict[str, str] # 用户选的模型
_session_interventions: dict[str, list] # 累计干预历史
_session_repos: dict[str, repo] # DB 仓库引用
_session_user_ids: dict[str, str] # 用户 id
_session_article_types: dict[str, list] # 文章类型列表
```
**uvicorn / gunicorn 多 worker 是多进程共享 socket**,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:
```
T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
graph 推进任务跑在 worker A
T1: GET /stream → 落 worker B,没有 QueueA 的引用
→ 触发 _recover_session,从 sqlite 重建 QueueB
→ SSE 推 QueueB 但事件都在 QueueA 里
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态
```
**结果**:SSE 时断时续、resume 偶发 404「会话状态已丢失」。
### 根因 3:Graph 用了内存 checkpointer
原代码:
```python
# ai_writing/graph.py
checkpointer = MemorySaver()
```
进程内的 `MemorySaver` 重启即丢,**容器重启或单 worker 进程被回收**后所有进行中会话都拿不到。
### 根因 4:路径配错
`config.yaml` 里 sqlite 路径写成了 Windows 绝对路径 `D:\cursor-work\...`,Linux 容器里根本无法解析。
---
## 三、已落地的修复(已合入 dhk-1 分支)
### 修复 1:`transcript` 列改 LONGTEXT + 路由兜底
| 文件 | 改动 |
|---|---|
| `packages/harness/deerflow/persistence/types.py` | 新增 `PortableLongText` 类型(MySQL→LONGTEXT,SQLite/PG→TEXT) |
| `packages/harness/deerflow/persistence/ai_writing_sessions/model.py` | `transcript` 列改成 `PortableLongText` |
| `packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py` | Alembic 迁移:MySQL 上 `ALTER ... type_=LONGTEXT()` |
| `app/gateway/routers/ai_writing.py` | `save_transcript` 整段 try/except 包裹,**任何异常都返回 `{success: false, reason}`,永远不抛 5xx** |
**结果**:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 `console.warn`,写作流程不阻断。
### 修复 2:Checkpointer 改为持久化 SQLite
| 文件 | 改动 |
|---|---|
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `MemorySaver()` → 懒加载 `AsyncSqliteSaver` |
| 同上 | 新增 `get_ai_writing_graph()` / `get_ai_writing_checkpointer()` / `shutdown_ai_writing_graph()` |
| `offline-backend-20260512/config.yaml` | sqlite 路径改成相对路径 `.deer-flow/data/checkpoints.db` |
**关键容错**:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会**自动降级 InMemorySaver**,写作流程能跑完,但跨重启不可恢复。
### 修复 3:跨 worker / 进程重启会话恢复
| 文件 | 改动 |
|---|---|
| `app/gateway/routers/ai_writing.py` | 新增 `_recover_session(session_id, request)` |
当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 `_event_queues`、重新注入 `await_user` 事件。
**效果**:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,**resume 接口本身能跑通**(但 SSE 事件流仍隔离 — 见下文剩余风险)。
### 修复 4:多层失败兜底,保证写作流程能继续
| 改动 | 作用 |
|---|---|
| `_sse(event, data)` 加 `default=str` + notice 帧降级 | JSON 序列化失败不会让 SSE 断开 |
| 所有 DB 写入(草稿/审核/干预/status)已包 try/except | DB 异常吞掉,graph 继续推 |
| 文章类型加载失败 → 退回 `[]` | intent_parser 仍能跑 |
| `/start` 加 503 兜底 | graph 构建失败时给前端可读错误而不是 500 |
### 修复 5:定期清理 + admin 触发接口
| 文件 | 改动 |
|---|---|
| `app/gateway/ai_writing_cleanup.py` | 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread) |
| `packages/harness/deerflow/persistence/ai_writing_sessions/sql.py` | 新增 `list_older_than()` / `delete_by_ids()` |
| `app/gateway/app.py` | lifespan 启动/停止清理任务 |
| `app/gateway/routers/ai_writing.py` | 新增 `POST /api/ai-writing/admin/cleanup` 手动触发接口(admin only) |
| `config.yaml` | 新增 `ai_writing.cleanup` 段(默认每天凌晨 3 点跑,保留 7 天) |
**关键安全机制**:清理永远跳过 `_event_queues` 里仍在跑的会话(**活跃保护**),即使 admin 手动传 `retention_days: 0` 也不会秒杀正在写作的用户。
### 修复 6:优雅关闭 — In-flight Drain
| 文件 | 改动 |
|---|---|
| `app/gateway/routers/ai_writing.py` | 新增 `_inflight_graph_tasks` + `_spawn_graph_task()` + `wait_for_inflight_graph_tasks(timeout)` |
| `app/gateway/app.py` | lifespan shutdown 顺序:取消清理 → **等 in-flight 写作 task 收尾(30s 超时)** → 关 checkpointer connection |
容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。
### 修复 7:多 worker 配置陷阱检测
| 文件 | 改动 |
|---|---|
| `app/gateway/app.py` | lifespan startup 检测 `WEB_CONCURRENCY > 1` 或 `/proc` 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志 |
下次启动容器看 docker logs 第一屏就能看到强提醒。
---
## 四、当前状态评估
### transcript 500
✅ **彻底修复**,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。
### resume 报错
⚠️ **大幅缓解**:
- 接口本身(在 worker > 1 下)能跑通
- sqlite 共享让 worker B 也能恢复 worker A 创建的 session
❌ **未彻底解决**:
- SSE 事件流仍 per-worker 隔离
- 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
- 但**草稿、审核结果**都落到 MySQL,刷新 `/sessions/{id}` 总能看到最终成品
### 「写作流程能不能跑完」
✅ **能**。最坏情况下:
- 用户感受:进度断断续续,需要刷新几次
- 数据保证:草稿和审核都落 MySQL,**写出来的内容不会丢**
---
## 五、剩余风险
### 风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要
**症状**:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。
**为什么不能用 `--workers 1` 解决**:业务团队反馈其他业务功能需要多 worker。
**解决方案见第六章**。
### 风险 B:容器优雅关闭时 in-flight 写作进度
**当前缓解**:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。
**完美方案**(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。
### 风险 C:节点级别 LLM 抽风
某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。
**当前缓解**:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。
**改进方向**(不在本次修复范围):
- 给节点加 `tenacity.retry` 重试装饰器
- 节点抛错时把已生成的部分塞进 progress_events
### 风险 D:跨 worker 恢复时降级丢失
worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:
| 数据 | 默认值 | 影响 |
|---|---|---|
| `_session_model_names` | `""` | 退回默认模型 |
| `_session_interventions` | `[]` | 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全) |
| `_session_article_types` | `[]` | 没影响(graph 早过了 intent_parser) |
**改进方向**:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。
---
## 六、Worker > 1 限制下的解决方案路径
### 三条路径对比
| 方案 | 是否引入新依赖 | 代码改动 | 运维改动 | 适用场景 |
|---|---|---|---|---|
| **B. Redis Pub/Sub 共享状态** | ⚠️ 需 Redis | ~150 行 | docker-compose 加 redis | 已有 Redis 或愿意上 |
| **C. AI 写作独立单 worker 进程** | 无 | ~30 行 | docker-compose 加一个容器 / 进程 + nginx 转发 | 不想上 Redis |
| **F. 多容器单 worker + nginx sticky** | 无 | ~5 行(nginx config) | 把 1 个多 worker 容器拆成 N 个单 worker 容器 | 已经在做容器编排 |
### 方案 B:Redis Pub/Sub(最彻底)
**改造点**:
```
之前(per-worker memory dict) 之后(Redis 共享)
───────────────────────── ─────────────────────
_event_queues[sid] = asyncio.Queue() → Redis Pub/Sub channel "ai_writing:events:{sid}"
_session_pending_pause → Redis Hash "ai_writing:pause"
_session_model_names → Redis Hash "ai_writing:meta:model"
_session_interventions → Redis Hash + JSON
_session_repos / _session_user_ids → Redis Hash
```
**关键设计**:
1. `/start` 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
2. `/stream` 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
3. `/resume` 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」
**优点**:根治。任何 worker 数、任何部署形态都能跑。
**缺点**:
- 引入 Redis 依赖(运维负担)
- 代码改动较大(约 150 行)
- 需要处理 Redis 断连 / 分布式锁释放等边缘情况
### 方案 C:AI 写作独立服务(次推荐)
**做法**:在同一个容器里多起一个 uvicorn 进程,专门处理 `/api/ai-writing/*`,**显式 `--workers 1`**。
```bash
# 主 gateway(多 worker,处理其他业务)
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4
# AI 写作专用单 worker
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1
```
nginx 配置:
```nginx
location /api/ai-writing/ {
proxy_pass http://localhost:8002;
proxy_buffering off;
proxy_read_timeout 3600s;
}
location / {
proxy_pass http://localhost:8001;
}
```
**优点**:
- 零代码改动
- 不引入新依赖
- AI 写作彻底单 worker,问题根除
**缺点**:
- 容器内多一个进程
- 内存占用 +1 份 Python runtime(约 200 MB)
### 方案 F:多容器单 worker + nginx sticky
**做法**:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 `ip_hash` 做 sticky。
```yaml
# docker-compose.yml
services:
gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }
nginx:
# upstream 配 ip_hash,让同一个客户端粘到同一个容器
```
```nginx
upstream gateway_pool {
ip_hash;
server gateway-1:8001;
server gateway-2:8001;
server gateway-3:8001;
server gateway-4:8001;
}
```
**优点**:零代码改动,标准做法
**缺点**:
- 改动 docker-compose
- 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)
### 推荐顺序
1. **方案 C(AI 写作独立单 worker 进程)** — 改动最小、风险最低、立刻可上
2. **方案 F(多容器 sticky)** — 如果你们已经在做多容器编排
3. **方案 B(Redis)** — 长期方案,等真正需要水平扩容时再上
---
## 六-A、方案 C 在 Docker 容器内的详细实施指南
> **目标**:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
>
> **基础设施**:本项目镜像基于 `python:3.12-slim-bookworm`,用 `uv` 管理依赖,Dockerfile 位于 `offline-backend-20260512/backend/Dockerfile`,默认入口是
>
> ```
> CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
> ```
### 总览:单容器双 uvicorn 进程
```
┌─────────────── nginx (容器外或前置) ───────────────┐
│ │
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
└────────────────────────────────────────────────────┘
▲
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
```
两进程都用 **同一个 `app.gateway.app:app`**,区别只在:
- `8002` 单 worker,专门接 `/api/ai-writing/*`
- `8001` 多 worker,接其他所有路由
- 容器内用 `supervisord` 同时托管这两个 uvicorn
### 步骤 1:在镜像里加 supervisord
编辑 `offline-backend-20260512/backend/Dockerfile`,给 **runtime stage**(第 67 行 `FROM python:3.12-slim-bookworm` 之后)补一段:
```dockerfile
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
RUN apt-get update \
&& apt-get install -y --no-install-recommends supervisor \
&& rm -rf /var/lib/apt/lists/*
```
如果内网无法访问 debian 源,可用现有的 `APT_MIRROR` build-arg:
```bash
docker build \
--build-arg APT_MIRROR=mirrors.aliyun.com \
-f offline-backend-20260512/backend/Dockerfile \
-t deerflow-gateway:dual .
```
### 步骤 2:写 supervisord 配置
新建 `offline-backend-20260512/backend/deploy/supervisord.conf`:
```ini
[supervisord]
nodaemon=true ; 容器主进程,必须前台
user=root
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
[program:gateway-main]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8001
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
[program:gateway-aiwriting]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8002
--workers 1
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
**几个关键点**:
- `nodaemon=true` 让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程
- `stopasgroup=true` + `killasgroup=true` 确保 uvicorn 多 worker 模式下所有子进程一起退
- `stopwaitsecs=35` 必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL
- `%(ENV_GATEWAY_MAIN_WORKERS)s` 让主进程 worker 数走环境变量,下面会传
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage `COPY --from=builder /app/backend ./backend` 之后加):
```dockerfile
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
```
### 步骤 3:把 Dockerfile 入口换成 supervisord
把 Dockerfile **runtime stage 最后一行** 改成:
```dockerfile
# 之前
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
# 之后
EXPOSE 8001 8002 2024
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
```
> **`dev` stage 不用改**:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
### 步骤 4:暴露端口 / 挂卷 / 环境变量
启动容器时:
```bash
docker run -d --name deerflow-gateway \
-p 8001:8001 \
-p 8002:8002 \
-e GATEWAY_MAIN_WORKERS=4 \
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
-v /your/host/path/.deer-flow:/app/.deer-flow \
-v /your/host/path/config.yaml:/app/config.yaml:ro \
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
deerflow-gateway:dual
```
**两个进程必须共享 `.deer-flow` 卷** —— 因为 `checkpoints.db` 在里面(`offline-backend-20260512/config.yaml` 里配的是相对路径 `.deer-flow/data/checkpoints.db`)。两个进程跑在同一个容器内,天然共享文件系统,**这一步不用做任何额外配置**,只要确认数据卷挂出去就行。
> ⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
### 步骤 5:nginx 路由
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
```nginx
upstream gateway_main { server gateway-host:8001; }
upstream gateway_aiwriting { server gateway-host:8002; }
server {
listen 80;
server_name deerflow.example.com;
# SSE 长连接必须关 buffering
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
# AI 写作 → 单 worker 后端
location /api/ai-writing/ {
proxy_pass http://gateway_aiwriting;
proxy_read_timeout 3600s; # graph 跑得久,给足时间
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 其它一切 API → 多 worker 后端
location /api/ {
proxy_pass http://gateway_main;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端静态资源不变
location / {
proxy_pass http://frontend:5174;
}
}
```
**`proxy_buffering off` 是必须的** —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 `frontend-web` 里 AI 写作相关的 `fetch`/`EventSource` 改成 `:8002`,但更推荐 **始终走 nginx**,对前端透明。
### 步骤 6:如果你用 docker-compose
完整 compose 示例:
```yaml
services:
gateway:
image: deerflow-gateway:dual
container_name: deerflow-gateway
restart: unless-stopped
environment:
GATEWAY_MAIN_WORKERS: "4"
DEER_FLOW_CONFIG_PATH: /app/config.yaml
ports:
- "8001:8001"
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
volumes:
- ./data/deer-flow:/app/.deer-flow
- ./config.yaml:/app/config.yaml:ro
- ./extensions_config.json:/app/extensions_config.json:ro
healthcheck:
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
nginx:
image: nginx:1.27-alpine
depends_on: [gateway]
ports: ["80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
```
`stop_grace_period: 45s` 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
### 步骤 7:验证(按这个清单逐项过)
```bash
# 1) 两个进程都起来了
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
# 期望看到:
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
# 3) 8001 不再警告多 worker
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
# 4) 8002 进程能看到 ai_writing 持久化日志
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
# 5) /api/ai-writing/* 走 8002,其他走 8001
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
```
### 步骤 8:监控两个进程的健康
supervisord 自带 `unix_http_server` + `supervisorctl`,但对容器化场景更直接的是看 docker logs:
```bash
# 实时看两个进程的日志
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
# 进程级状态
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
# 期望:
# gateway-main RUNNING pid 7, uptime 0:05:12
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
```
### 资源开销(实测预估)
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|---|---|---|---|
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
**结论**:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
### 常见坑位
1. **`stop_grace_period` 没改大 → 重启时 in-flight 写作被秒杀**
表现:每次 `docker compose restart` 都有用户报告「写到一半没了」。修复见步骤 6。
2. **8002 没暴露 / nginx 没改 → 所有请求还是落 8001**
表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
3. **两路用了不同的 `config.yaml`**
表现:8001 跟 8002 行为不一致。务必让两个进程**只读同一个文件**(在 compose 里只挂一次卷)。
4. **`.deer-flow` 卷没挂 → 8002 重启后会话全丢**
表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 `-v` 必须有。
5. **AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸**
表现:`gateway-aiwriting` 状态在 RUNNING/EXITED/BACKOFF 间反复。
修复:先 `supervisorctl tail gateway-aiwriting stderr` 看真正原因(多半是 sqlite 权限或配置文件路径),再修,**别盲目调 `autorestart=false`**。
6. **想做 K8s 部署但还想用方案 C**
不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
### 回滚方案
如果上线后发现问题,回滚只需要 1 步:
```bash
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
```
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),**不会丢任何用户数据**,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
---
## 七、运维操作清单
### 部署前验证
```bash
# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
docker exec <gateway> ls -la /app/.deer-flow/data/
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)
# 2) 确认 alembic 迁移跑完了
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
# 应该看到包含 20260519_02 的 head
# 3) 确认 transcript 列已扩容
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
"SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
# Type 列应该是 longtext
# 4) 查 worker 数
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告
```
### 启动时关注的日志
```bash
docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'
```
期望看到:
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
- `ai_writing_cleanup: 已启动 cron=...` — 清理任务起来了
警告:
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 配置警告
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 初始化失败,需立刻排查
### 用户报告「会话丢失」时的排查
```bash
# 1) 查 sqlite 里这个 session 的快照
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
\"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""
# 2) 查 MySQL 里这条会话
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
"SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"
# 3) 看 gateway 日志里这个 session 的活动轨迹
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50
# 4) 看是否有 recover_session 调用
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20
# 5) 看是否有节点异常
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20
```
### 手动触发清理(admin only)
```bash
# 用 admin 账号登录后,取 cookie,调清理接口
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
-H 'Content-Type: application/json' \
-H 'Cookie: <admin session cookie>' \
-d '{"retention_days": 7}'
```
返回:
```json
{
"deleted_db": 12,
"deleted_checkpoints": 12,
"skipped_active": 1,
"elapsed_seconds": 0.234,
"params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
}
```
`skipped_active` > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。
---
## 八、相关文件索引
### 后端
- `offline-backend-20260512/backend/app/gateway/routers/ai_writing.py` — 路由 + 内存状态字典 + drain
- `offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py` — 定期清理
- `offline-backend-20260512/backend/app/gateway/app.py` — lifespan / 多 worker 检测
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py` — Graph 定义 + 持久化 checkpointer
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/` — MySQL repo
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py` — `PortableLongText`
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py` — LONGTEXT 迁移
- `offline-backend-20260512/config.yaml` — `checkpointer:` + `ai_writing.cleanup:` 配置
### 前端
- `frontend-web/src/open-canvas/pages/AIWritingPage.tsx` — 页面顶部有快速排查注释
- `frontend-web/src/open-canvas/api/ai-writing-sessions.ts` — transcript 保存(已做 fire-and-forget 容错)
- `frontend-web/src/open-canvas/api/ai-writing.ts` — resume / stream
### 测试
- `tests/test_ai_writing_cleanup.py` — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
- `tests/test_ai_writing_session_recovery.py` — 跨 worker 会话恢复(8 个测试)
- `tests/test_ai_writing_session_repo.py` — Repo(7 个测试)
- `tests/test_ai_writing_transcript_endpoint.py` — transcript 兜底(5 个测试)
- `tests/test_portable_long_text.py` — LONGTEXT 列类型(5 个测试)
总测试:**51 个,全过**。
---
## 九、变更历史
| 日期 | Commit | 内容 |
|---|---|---|
| 2026-05-19 | c3d0c82 | 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复) |
| 2026-05-20 | (本次) | checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测 |
---
## 十、决策摘要(给未来的你/同事)
> **如果你现在第一次接手这个问题**,按这个清单往下走:
>
> 1. ✅ **transcript 500 不用管** — 已彻底修复
> 2. ⚠️ **resume 报错的根因是多 worker** — sqlite 持久化已减轻,但 SSE 事件流仍隔离
> 3. ⚠️ **写作能跑完,但用户体验断断续续** — 草稿数据安全,刷新就能看到
> 4. 🎯 **要彻底解决,从方案 C / F / B 选一个**(建议从 C 开始)
> 5. 🚨 **务必确认 `.deer-flow` 数据卷挂出来了** — 否则 sqlite 持久化白做
> 6. 📊 **关注的日志关键字**:`ai_writing:`、`checkpointer`、`AsyncSqliteSaver`、`WEB_CONCURRENCY`
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
File diff suppressed because it is too large. Load diff
+265
View File
@@ -0,0 +1,265 @@
# 多智能体协调 API 接口文档
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
路由前缀:`/api/multi-agent`
## 概览
提供一个"总控 + 多个子智能体"的协调编排接口。
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
调用方按下面顺序使用:
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
## 1. 初始化多智能体集群
### `POST /api/multi-agent/init`
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
#### 请求体
```json
{
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
"main_agent_name": "main-controller",
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
#### 响应(200)
```json
{
"status": "success",
"main_agent_name": "main-controller",
"thread_ids": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
"us-intelligence-gathering-agent": "thread-uuid-zzz"
}
}
```
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_names` 为空 |
| `401` | 未携带 `Authorization` 头 |
| `500` | 协调智能体创建失败且不是"已存在"错误 |
---
## 2. 流式调度
### `POST /api/multi-agent/run/stream`
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
#### 请求体
```json
{
"agent_type": "leader",
"d_agent_thread_id": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
},
"agent_name": "main-controller",
"new_message": "请分析特朗普的支持率",
"skill_stop_names": [],
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
#### 响应
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
##### a. 透传上游事件
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
##### b. 最后一帧:调度状态
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
**`agent_type == "leader"`:**
```
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
```
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
**`agent_type == "special"`:**
```
data: {"status": "子智能体xxx完成yyy工作"}
```
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
#### 副作用(重要)
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
| `401` | 未携带 `Authorization` 头 |
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
---
## 3. 前端调用流程示例
```ts
// 1. 拿 token
const loginResp = await fetch("/api/v1/auth/login/username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "yourname" }),
});
const { access_token } = await loginResp.json();
const authHeaders = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
};
// 2. 初始化
const initResp = await fetch("/api/multi-agent/init", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_names: [
"taiwan-intelligence-gathering-agent",
"us-intelligence-gathering-agent",
],
main_agent_name: "main-controller",
model: "deepseek-chat",
}),
});
const { thread_ids } = await initResp.json();
// 3. 流式调用 leader
async function streamLeader(message: string, model = "deepseek-chat") {
const resp = await fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "leader",
d_agent_thread_id: thread_ids,
agent_name: "main-controller",
new_message: message,
skill_stop_names: [],
model,
}),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
let dispatched: [string, string][] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const payload = JSON.parse(line.slice(5).trim());
if (Array.isArray(payload.status)) {
dispatched = payload.status;
}
// 其余事件按 LangGraph SDK 协议自行渲染
} catch {
/* 非 JSON 帧,可忽略或交给现有渲染器 */
}
}
}
return dispatched;
}
// 4. 对派活清单逐个调用 special
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
return fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "special",
d_agent_thread_id: thread_ids,
agent_name: agentName,
new_message: task,
skill_stop_names: [],
model,
}),
});
// 同样按 SSE 解析
}
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
const dispatched = await streamLeader("请分析特朗普的支持率");
for (const [agentName, task] of dispatched) {
await streamSpecial(agentName, task);
}
const next = await streamLeader("根据上面收集到的信息出最终结论");
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
```
---
## 4. 注意事项
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
---
## 5. 相关接口(底层依赖)
本接口内部转发以下 Gateway 原生接口,了解细节可查:
| 接口 | 路由 | 用途 |
|---|---|---|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
@@ -0,0 +1,359 @@
# 多智能体圆桌研讨 · 后端开发文档
本文档面向需要维护或扩展"多智能体圆桌研讨"功能后端的开发者。前端 API 速查请看 `multi-agent-api.md`,此文档关注**实现细节、设计取舍、性能特征**。
---
## 1. 功能定位
把"任务目标 + 用户约束"丢给一个**协调智能体**,由它通过 `agent_orchestration` 技能调度 6 个固定职能的子智能体(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述)协同产出方案,直到协调智能体认为信息足够、停止派活并输出最终结论为止。
对应前端页面:`/page/roundtable/planning` 的 **Step 2 多智能体研讨**。
---
## 2. 涉及的后端文件
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── app.py # 注册 multi_agent.router
│ └── routers/
│ └── multi_agent.py # 本功能唯一新增的 router
└── .deer-flow/agents/
├── roundtable-intelligence/ # 6 个内置子智能体目录
│ ├── config.yaml # ├── id / name / description
│ └── SOUL.md # └── 角色 prompt
├── roundtable-environment/
├── roundtable-solution-design/
├── roundtable-risk-review/
├── roundtable-execution-plan/
└── roundtable-summary/
```
**零代码改动构造内置 agent**:6 个目录通过既有的 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。
---
## 3. 接口总览
路由前缀 `/api/multi-agent`,所有接口需要 `Authorization: Bearer <JWT>`。
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | `/api/multi-agent/init` | 一次性创建协调智能体 + 6 个子席位线程,返回 `thread_ids` 映射 |
| POST | `/api/multi-agent/run/stream` | SSE 流式驱动一次 leader 或 special 调用 |
字段细节见 `multi-agent-api.md`,这里不重复。
---
## 4. 内部实现详解
### 4.1 `/init` 内部流程
```
[阶段1] 并行准备 6 个子智能体席位 (asyncio.gather)
├─ GET /api/agents/{id} × 6 → 取 name + description
└─ POST /api/threads × 6 → 建新 thread
[阶段2] 创建协调智能体
├─ POST /api/threads → 协调智能体的 thread
└─ POST /api/agents (with model + skills=[agent_orchestration] + soul)
└─ soul 里包含可调度的 6 个 agent_name + description 列表
```
**协调智能体 SOUL 是动态拼出来的**(`multi_agent.py:216-224`):
```python
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,...\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,...\n"
"#可调度智能体:\n"
f"{descriptions}" # ← 6 个子席位的 name+description
)
```
由于 `main_agent_name` 由前端每次新建(`roundtable-coordinator-{timestamp36}`),所以协调智能体本身是一次性的、用完即弃。
### 4.2 `/run/stream` 内部流程
接收 `agent_type` 后走两条分支:
#### `agent_type = "leader"` (协调智能体)
```
1. broadcast「总控收到用户消息」到所有非自己线程 (asyncio.gather)
2. 拼 RunCreateRequest,转发到 POST /api/threads/{coordinator}/runs/stream
- excluded_tools: ["web_search"]
- context.model_name = 用户传的 model
3. 流式透传 LangGraph 事件给前端
4. 流结束后从最后一帧 values 取最末一条 message
5. 解析 message.tool_calls (agent_orchestration 技能调用):
- 用 _extract_name_task() 提取 [agent_name, task_text]
- 对每个 [agent_name, task]:
broadcast「总控给 X 派活,内容为: ...」到所有非派活双方
6. 末尾追加状态帧:
{
"status": [["agent_name", "task"], ...], // 空数组 = 共识达成
"content": "<leader 最终文本>",
"agent_name": "<coordinator>"
}
```
#### `agent_type = "special"` (子智能体)
```
1. 拼 RunCreateRequest,转发到 POST /api/threads/{sub_agent}/runs/stream
- excluded_tools: ["web_search","ask_clarification","present_files","view_image"]
2. 流式透传 LangGraph 事件
3. 流结束后从最后一帧 values 取交付内容 (content)
4. broadcast「子智能体 X 完成 Y 工作,交付内容为: ...」到所有非自己线程
5. 末尾追加状态帧:
{
"status": "子智能体 X 完成 Y 工作",
"content": "<交付内容>",
"agent_name": "<sub agent>"
}
```
### 4.3 _broadcast 机制
```python
async def _broadcast(client, base, headers, message, agent_threads, skip):
targets = [tid for name, tid in agent_threads.items() if name not in skip]
await asyncio.gather(*(_append_thread_message(...) for tid in targets))
```
`_append_thread_message` 对每个目标 thread:
```
GET /api/threads/{id}/state # 拿当前 messages 列表
state.values.messages.append({"type":"human","content":...})
POST /api/threads/{id}/state # 写回完整 state
```
这是**为了让每个子线程的对话历史里都能看到全局上下文**(其他席位说了什么、协调智能体怎么派活的)。子智能体下次被调用时,模型自然能基于这份"群消息回放"做判断。
**关键不变量**:`skip` 列表通常包含发起方和接收方,避免发起方收到回声、接收方收到重复消息。
---
## 5. 6 个内置 agent SOUL 设计要点
每个 `roundtable-*` 的 SOUL.md 遵循统一结构:
```
# {席位名}
## 角色定位
你是"多智能体圆桌研讨"会议的 **{席位}** 席位。会议由"总控协调智能体"统一调度,
你只在被派到任务时发言,不需要主动推动议程。
## 核心职责
- ...具体职责...
## 输出风格
- 每次发言以 {emoji} 开头
- 第一句直接给结论
- 不超过 4 句
## 边界
- 只产出 {本席位输出}
- 不替其他席位做 {其他席位输出}
- 任务结束后停下,等待总控派下一个任务
## 示例输出
> ...
```
| 席位 | agent_id | emoji | 核心职责 |
|---|---|---|---|
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险预警 |
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 量化承载力 |
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源排布 |
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 + 盖印 |
**为什么不让子智能体自己调度**:所有 SOUL 都明文写了"只在被点到时发言,由总控协调智能体派活"。子智能体不装 `agent_orchestration` 技能。只有协调智能体装。这是单调度源,避免多智能体相互抢调度权造成的死循环。
---
## 6. 关键设计决策
### 6.1 为什么用 HTTP loopback 而不是内部函数调用
`multi_agent.py` 调用 `/api/threads`、`/api/agents`、`/api/threads/.../runs/stream` 都是用 `httpx` 走 `http://localhost:8001/...` 自身循环回来,而不是直接 `from app.gateway.services import ...`。
**好处**:
- 自动复用 Gateway 的鉴权 + CSRF + permission 中间件,不用再手写
- 复用既有路由的请求验证(Pydantic)和错误响应
- `Authorization` 头透传,被调端自然知道是哪个用户在调
**代价**: 每次调用都有一次额外的 TCP+JWT 解析开销(~10-50ms)。但因为 init 已经做了并行优化(A)、broadcast 已经省掉(C),目前总体耗时是可接受的。
如未来需要进一步优化,可改为直接调用 `app/gateway/services.py` 里的内部函数,绕过 HTTP 一层。
### 6.2 为什么协调智能体每次新建
前端在每次进入 step 2 时生成 `roundtable-coordinator-{Date.now().toString(36)}`,作为 `main_agent_name`。
**好处**:
- 不同会话/不同用户/同一用户不同任务之间完全隔离
- 协调智能体的对话历史就是本次研讨的全部上下文,不会被上次任务污染
- SOUL 不会被"上次的可调度列表"覆盖(虽然现在 6 个席位是固定的)
**代价**: 每次都要在 `agents` 表插一行,`.deer-flow/agents/<id>/` 下生成新目录。长期会累积大量协调智能体目录,**未来需要清理脚本**(标记 description 以 "你是总控控制协调智能体" 开头的 agent 自动 GC)。
### 6.3 子智能体席位为什么固定 6 个
- 圆桌的 6 个角色定位是产品级强约束(情报/环境/方案/风险/执行/总结)
- 写在后端 SOUL.md 一处维护,跟其他内置 agent 同等地位
- 前端只需要把 6 个 hyphen-case 名作为 `agent_names` 传过来即可
如果后续要支持"用户自选席位",在前端加 mapping 面板让用户从 `GET /api/agents` 里挑,不动后端。
### 6.4 为什么 SSE 末尾还要追加一个 `{status:...}` 帧
LangGraph 原生 SSE 里没有"调度结果"这种语义,前端只能从最后一帧 values 里去翻 tool_calls。如果由前端解析,前后端协议会绑死 LangGraph 内部数据形状,迁移成本高。所以后端在流末尾追加一个**协议层的总结帧**:
```json
{
"status": [["agent_name", "task"], ...] | "<完成提示>",
"content": "<最终 AI 文本>",
"agent_name": "<本轮 agent>"
}
```
前端 `streamMultiAgent` helper 通过 `isFinalStatusFrame()` 识别该帧(必须同时有 `status` + `content` 键),与其他 LangGraph 帧区分。
---
## 7. 性能特征
### 7.1 已应用的优化
| ID | 优化 | 收益 |
|---|---|---|
| **A** | `/init` 阶段 1 并行化(`asyncio.gather`) | 阶段 1 从 2-4s 降到 0.3-0.7s |
| **C** | 删除 `/init` 末尾的 intro 广播(SOUL 已含定位) | 阶段 3 整段消失,省 1-2s |
应用前 `/init` 耗时 **3-8s**,应用后 **0.5-1.2s**。
### 7.2 仍然存在的开销点
| 调用 | 单次耗时 | 主因 |
|---|---|---|
| `GET /api/agents/{id}` | 50-300ms | 每次触发 `_sync_legacy_agents()` 扫整个 `.deer-flow/agents/` 目录(目前 30+ 项) |
| `POST /api/threads` | 100-200ms | LangGraph thread 表插入 + 默认 state 初始化 |
| `POST /api/agents` | 100-300ms | 文件系统写入 SOUL.md + config.yaml + agents 表 |
| `GET/POST /state` | 50-150ms × 2 | checkpointer 读写 |
| `POST /runs/stream` 启动 | 50-100ms | RunManager + StreamBridge 初始化 |
| LLM 推理 | **数百毫秒~数十秒** | 取决于 model + 上下文长度 |
**LLM 推理才是绝对大头**,基础设施开销在十几个百分点以内。
### 7.3 未来可探索
- **B**(已识别未做): broadcast 跳过 GET-state,直接 POST 新建态。但 POST 需要构造完整的 state shape,改造成本不小。
- **D**(已识别未做): loopback HTTP → 内部函数调用。代码改动大,但能省掉 ~20-30 次 HTTP 解析开销。
---
## 8. 依赖与扩展点
### 8.1 `agent_orchestration` 技能
协调智能体强依赖此技能,SOUL 里硬编码:
```
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
```
技能的实际派活产物是一个形如 `python3 scripts/agent_orchestration.py "agent_name" "task"` 的 shell 调用文本,后端用正则提取:
```python
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
```
**若该技能被删除/改名**,leader 流程会拿不到 `tool_calls`,直接走"无派活=共识达成"分支。注意 `skills/` 下必须保留此技能。
### 8.2 模型选择
前端通过 `GET /api/models` 拿到列表,在 `model` 字段传 `Model.name` (与 `config.yaml.models[].name` 对应)。
- `/init` 的 `model` 字段 → 协调智能体的默认模型 (写进 agents 配置)
- `/run/stream` 的 `model` 字段 → 该次具体运行用什么模型 (写进 `context.model_name`)
前端允许"逐席位覆盖模型",由前端在 special 调用时按 `agentConfigs[seat].model` 传入。后端不感知"席位"概念,只看到 `model` 字段。
### 8.3 自定义新席位
如果要加第 7 个席位(比如"成本测算"):
1. `.deer-flow/agents/roundtable-cost/` 加 `config.yaml` + `SOUL.md`
2. 前端 `RoundtablePlanningPage.tsx` 的 `ROUNDTABLE_ROLE_TEMPLATES` + `AGENT_NAME_TO_ROLE` 加映射
3. 圆桌可视化(圆圈环形图)加一个气泡 + 头像
**后端代码完全不需要改**。
---
## 9. 故障排查
| 现象 | 可能原因 | 排查 |
|---|---|---|
| `/init` 500 "create coordinator agent failed" | `agent_orchestration` 技能不存在 / config.yaml 写入失败 | 看后端日志 `multi_agent` logger;检查 `skills/` 目录 |
| `/run/stream` 502 "upstream returned HTTP 4xx" | JWT 过期 / 转发到上游时鉴权挂了 | 前端检查 Authorization 头是否带上 |
| leader 永远 `status: []` 立即结束 | 协调智能体没产生 `tool_calls`,直接给文本回答 | 协调智能体 SOUL 不够强、或模型不支持工具调用,换 model |
| special 内容为空 | 子智能体被 `excluded_tools` 限制到无可用工具 / 模型无文本输出 | 看上游 `runs/stream` 的 values 帧 messages.last.content |
| 进入 step 2 卡住转圈 | `/init` 超时或失败 | F12 Network 看 `/api/multi-agent/init` 状态码与响应体 |
### 服务端日志关键字
```bash
# multi_agent 路由的日志
grep "multi_agent" .runtime/logs/*.log
# 派活解析失败
grep "dispatch failed" .runtime/logs/*.log
# 广播失败(通常无害)
grep "update state .* failed" .runtime/logs/*.log
```
---
## 10. 测试入口
后端目前没有针对本功能的单测。建议补:
```
backend/tests/test_multi_agent.py
- test_init_creates_threads_for_all_subagents
- test_init_creates_coordinator_with_orchestration_skill
- test_init_returns_400_when_agent_names_empty
- test_stream_leader_emits_status_frame_with_content
- test_stream_special_emits_status_frame_with_content
- test_extract_name_task_parses_orchestration_skill_output
- test_broadcast_skips_listed_agents
```
可用 `httpx.MockTransport` 拦截 loopback 请求,不依赖真实 Gateway。
---
## 11. 路径速查
| 资源 | 路径 |
|---|---|
| 路由实现 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py`(`include_router(multi_agent.router)`) |
| 6 个内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` |
| `agent_orchestration` 技能定义 | `skills/public/agent_orchestration/`(若存在) |
| 前端 API helper | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端 API 速查 | `frontend-web/docs/multi-agent-api.md` |
+310
View File
@@ -0,0 +1,310 @@
# 浅色红色 UI 规范对齐 — 实施计划
> 依据:`H:\页面设计\UI设计规范\浅色红色设计规范-前端用.pdf`(共 104 页)
> 范围:**仅对齐 Light 模式(红色主题)**,**不改动图表部分**
---
## ⚠️ 重要约束(必读)
### 1. 主题切换必须保留
项目使用 `next-themes` 实现 Light/Dark 切换:
- `:root` → Light 模式(红色品牌,对应本规范)
- `.dark` → Dark 模式(蓝色品牌,**不在本次范围内**)
- 切换组件入口:`src/components/theme-provider.tsx`
**所有改动只能动 `:root` 块和 light-only 选择器,禁止动 `.dark` 块和 `dark:` 前缀样式。**
### 2. 不动图表
本次跳过 `chart-1` ~ `chart-5` 颜色变量和所有图表组件。
### 3. 同一 token 双模式都用时如何改
若变量同时被 light/dark 使用(如 `--radius`、按钮 `h-*`、`--primary-foreground`),改动后需在 Dark 模式下回归检查,确保没有破坏。
---
## 改动总目标对照
| 类别 | UI 规范 | 现状 | 差异 |
|------|--------|------|------|
| 主品牌红 | `#F5222D` | `#d92121` (`oklch(0.52 0.19 27)`) | 偏差 |
| 深色品牌 | `#CF1322` / `#A8071A` | 未使用 | 缺失 |
| 圆角-按钮 | 2px | 12px (`--radius: 0.75rem`) | 严重偏大 |
| 圆角-卡片 | 4px | 12px | 严重偏大 |
| 按钮高度(正常)| 32px | 36px (`h-9`) | 偏大 4px |
| 按钮高度(小)| 24px | 32px (`h-8`) | 偏大 8px |
| 输入框高度 | 32px | 36px (`h-9`) | 偏大 4px |
| 中文字体 | NotoSansSChinese | 系统字体 | 未引入 |
| 数字字体 | D-DIN | 系统字体 | 未引入 |
| 间距体系 | 8px 基准(4/8/12/16/24/32/40/56)| Tailwind 默认 | 基本一致 |
---
## 分步实施计划
### Step 1 — 颜色变量对齐(Light 模式)
**文件**:`src/styles/globals.css`
**只动 `:root` 块(第 242-277 行),保持 `.dark` 块原样**
#### 1.1 主色映射
| 角色 | 规范值 | 用途 |
|------|--------|------|
| 主色 | `#F5222D` | `--primary`、`--ring`、`--sidebar-primary` |
| 主色 hover | `#CF1322` | 按钮 hover 状态(新增辅助变量) |
| 主色 active | `#A8071A` | 按钮按下状态 |
| 主色淡 1 | `#FEEDED` | `--accent`(极淡红,背景) |
| 主色淡 2 | `#FDDADC` | hover bg、tag 背景 |
| 主色淡 3 | `#FCB5B9` | 浅边框、selected 态 |
| 危险色 | `#F5222D` | `--destructive` |
#### 1.2 新增工程变量(建议加在 `:root` 内)
```css
/* 红色主题阶梯色(规范色板)*/
--brand-50: #FEFAFA;
--brand-100: #FEF6F6;
--brand-200: #FEEDED;
--brand-300: #FDDADC;
--brand-400: #FCB5B9;
--brand-500: #FA9196;
--brand-600: #F86C73;
--brand-700: #F74750;
--brand-800: #F5222D; /* ← 主色 */
--brand-900: #CF1322; /* ← hover */
--brand-950: #A8071A; /* ← active */
/* 辅色(规范)*/
--accent-orange: #FF9900;
--accent-green: #2FD69A;
--accent-cyan: #45C3F3;
```
#### 1.3 `--workspace-brand` 同步
`:root` 内:`--workspace-brand: #F5222D;`(从 `#d92121` 改)
`.dark` 内:**不动**(保持 `#3b82f6` 蓝色)
#### 1.4 验收
- 切到 Light 模式:所有按钮、链接、选中态使用规范红
- 切到 Dark 模式:依然是原蓝色品牌(无变化)
---
### Step 2 — 圆角规范化
**文件**:`src/styles/globals.css`(`--radius` 是全局变量,light/dark 共用)
#### 2.1 改动方案(推荐)
按规范:按钮 2px、卡片/页面框 4px。但当前许多组件依赖 `rounded-md` / `rounded-lg`,全局把 `--radius` 改成 4px 会引起视觉跳变。
**采用细粒度策略:**
```css
:root {
--radius: 0.25rem; /* 4px → 卡片/页面框 */
}
@theme inline {
--radius-sm: 2px; /* 按钮、tag */
--radius-md: calc(var(--radius) - 1px); /* 3px → 输入框 */
--radius-lg: var(--radius); /* 4px → 卡片 */
--radius-xl: calc(var(--radius) + 4px); /* 8px → 弹窗 */
--radius-2xl: calc(var(--radius) + 8px); /* 12px → 大卡片 */
}
```
#### 2.2 例外保留(设计感样式)
以下组件**圆角不改**,保持设计稿原貌:
- `.workspace-user-bubble`(聊天气泡,圆角 `1.6rem 1.6rem 0.5rem 1.6rem`)
- `.workspace-assistant-card`(圆角 `1.75rem`)
- `.gemini-gradient-border`(圆角 `1.5rem`)
#### 2.3 验收
- 普通按钮变为近直角(2px)
- 卡片维持温和圆角(4px)
- 聊天气泡、AI 卡片不变形
---
### Step 3 — 按钮组件尺寸对齐
**文件**:`src/components/ui/button.tsx`
#### 3.1 改 size 变体(规范:小 24px / 正常 32px)
| size | 规范 | 当前 | 改后 |
|------|------|------|------|
| `sm` | 24px | h-8 (32px) | `h-6 px-2 text-xs` |
| `default` | 32px | h-9 (36px) | `h-8 px-3 text-sm` |
| `lg` | — | h-10 (40px) | 保留 `h-10`(用于 Banner/登录场景)|
#### 3.2 圆角
- 默认按钮:`rounded-sm`(2px)
#### 3.3 主按钮配色
- bg:`bg-primary`
- hover:`hover:bg-[var(--brand-900)]`(深红 `#CF1322`)
- active:`active:bg-[var(--brand-950)]`(更深 `#A8071A`)
- disabled:`disabled:opacity-50`(保留)
#### 3.4 次要按钮 / 文本按钮
- 次要按钮(`outline` variant):边框 `border-primary`,文字 `text-primary`
- 文本按钮(`ghost` variant):仅文字色 `text-primary`,hover 背景 `--brand-200`
#### 3.5 验收
检查所有调用 `<Button>` 的页面:
- 工具栏按钮(之前 h-9 → 现在 h-8)
- 弹窗确认/取消(之前 h-9 → 现在 h-8)
- Landing 主 CTA 按钮(保留 `lg`)
---
### Step 4 — 输入框尺寸对齐
#### 4.1 `src/components/ui/input.tsx`
- `h-9` → `h-8`(32px)
- `rounded-md`(沿用 3px)
- 边框:`border-input`(变量已是浅灰,不变)
- focus:`focus-visible:ring-[2px] focus-visible:ring-primary/35`
#### 4.2 `src/components/ui/textarea.tsx`
- 最小高度建议 `min-h-16`
- 圆角 `rounded-md`
#### 4.3 `src/components/ui/select.tsx`
- `size: default` → `h-8`
- `size: sm` → `h-6`
#### 4.4 验收
- 搜索框、表单输入、下拉框高度一致(32px)
- 与按钮对齐时高度一致
---
### Step 5 — 字体引入
#### 5.1 引入字体文件
在 `frontend-web/public/fonts/` 下放置:
- `NotoSansSC-Regular.woff2` / `Medium.woff2` / `Bold.woff2`
- `D-DIN-Regular.woff2` / `Bold.woff2`
> 若需在线方式,可用 Google Fonts CDN:
> `https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap`
#### 5.2 `globals.css` 顶部添加 `@font-face`
```css
@font-face {
font-family: 'Noto Sans SC';
src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* Medium / Bold 同上 */
@font-face {
font-family: 'D-DIN';
src: url('/fonts/D-DIN-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
```
#### 5.3 修改 `--font-sans`(globals.css 第 74 行)
```css
--font-sans:
'Noto Sans SC', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-numeric: 'D-DIN', 'Noto Sans SC', sans-serif;
```
#### 5.4 数字字体工具类
新增 `.font-numeric { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }`
仪表盘、表格中重要数字加 `className="font-numeric"`。
#### 5.5 验收
- 中文文本回退到 Noto Sans SC
- Dark 模式同样生效(字体是全局变量)
---
### Step 6 — Badge / 状态标签色对齐
**文件**:`src/components/ui/badge.tsx`
规范状态色(参考 PDF 第 67 页):
| 状态 | 颜色定位 | 推荐 token |
|------|---------|-----------|
| 未开始 | 中性灰 | `bg-muted text-muted-foreground` |
| 等待中 | 蓝/青 | `bg-blue-50 text-blue-600`(保留) |
| 进行中 | 主红 | `bg-[var(--brand-200)] text-primary` |
| 已完成 | 绿 | `bg-emerald-50 text-emerald-600` |
| 已失败 | 红/橙 | `bg-red-50 text-red-600` |
| 待更新 | 黄/橙 | `bg-amber-50 text-amber-700` |
需要新增 `variant`:`waiting`、`processing`、`success`、`failed`、`pending`。
圆角统一 `rounded-sm`,padding `px-2 py-0.5`,字号 `text-xs`。
---
### Step 7 — 全局间距 & 阴影规范化
#### 7.1 间距
规范基于 8px 网格(4 / 8 / 12 / 16 / 24 / 32 / 40 / 56)。Tailwind 默认 4px 步进(`p-1`=4, `p-2`=8...)已兼容。
**只需 review**:检查页面是否有 `p-2.5`、`gap-1.5` 等非 8 的倍数,统一改成 8 的倍数。
#### 7.2 阴影
规范阴影仅用于"标题+操作"和"卡片新增"两类。其余卡片保持平。
新增工具类(可选):
```css
.shadow-card { box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); }
.shadow-card-hover { box-shadow: 0 4px 12px 0 rgba(0,0,0,0.08); }
```
---
### Step 8 — 验收清单
每步完成后检查:
- [ ] **Light 模式**视觉对齐规范(参考 PDF 关键页:5、7、10、36、66)
- [ ] **Dark 模式**完全无变化(切换主题验证)
- [ ] 首页 / Workspace / 聊天 / 设置弹窗 → 按钮和输入框高度统一
- [ ] 主题切换按钮点击 → 颜色平滑切换,无错位/闪烁
- [ ] `/landing` 强制 Dark 模式仍正常
- [ ] 控制台无 CSS 变量未定义警告
---
## 执行顺序建议
```
Step 1 (颜色变量) ─┐
├→ Step 3 (按钮) ─┐
Step 2 (圆角) ─┘ ├→ Step 6 (Badge) ─→ Step 7 (间距/阴影) ─→ Step 8 (验收)
│
Step 4 (输入框) ──┤
Step 5 (字体) ────┘
```
并行可行:Step 1 + Step 2 完成后,Step 3/4/5 可同时推进。
---
## 风险提示
1. **圆角从 12px → 4px 是大幅度视觉变化**,建议先在分支上做、配截图比对再合并
2. 字体文件体积较大(NotoSansSC 全字符集 ~10MB),建议子集化或仅引入需要的字重
3. `--radius-sm` 改后可能影响 dropdown menu / popover 等小组件外观,需逐个检查
4. Dark 模式下的红色 `--workspace-brand` 已是蓝色,**不要把蓝色也改成 #F5222D**
+12 -1
View File
@@ -1,7 +1,18 @@
<!doctype html>
<html lang="en-US">
<html lang="en-US" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<script>
(function () {
try {
var t = localStorage.getItem("strategy-theme");
var root = document.documentElement;
root.classList.remove("dark", "light");
if (t === "light") return;
root.classList.add("dark");
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CM 助手</title>
</head>
+3 -1
View File
@@ -25,7 +25,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
enableSystem
storageKey="strategy-theme"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
>
@@ -72,6 +72,12 @@ const menuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "qa-roundtable",
label: "圆桌规划",
icon: Bot,
path: "/page/roundtable/planning",
},
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -99,6 +105,12 @@ const menuItems: MenuItem[] = [
icon: List,
path: "/page/strategy/light-app/notes",
},
{
id: "light-app-note-qa",
label: "笔记问答",
icon: MessageSquareCode,
path: "/page/strategy/light-app/note-qa",
},
],
},
{ id: "light-app-square", label: "应用广场", icon: Layout },
@@ -463,7 +463,7 @@ interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
result?: string | Record<string, unknown>;
}
type CoTStep = CoTReasoningStep | CoTToolCallStep;
@@ -495,12 +495,22 @@ function convertToSteps(messages: Message[]): CoTStep[] {
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages);
if (toolCallResult) {
try {
const json = JSON.parse(toolCallResult);
step.result = json;
} catch {
const toolCallResult = findToolCallResult(
toolCallId,
messages,
tool_call.name,
);
if (toolCallResult !== undefined) {
if (typeof toolCallResult === "string") {
try {
step.result = JSON.parse(toolCallResult) as Record<
string,
unknown
>;
} catch {
step.result = toolCallResult;
}
} else {
step.result = toolCallResult;
}
}
@@ -1,6 +1,7 @@
"use client";
import {
ChevronDown,
Download,
FileJson,
FileText,
@@ -63,6 +64,15 @@ export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
};
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
useParams<{
thread_id: string;
@@ -161,12 +171,24 @@ export function RecentChatList() {
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase">
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[800px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
function openSession(
session: AIWritingSessionSummary,
isOnPage: boolean,
router: ReturnType<typeof useRouter>,
) {
if (isOnPage) {
// 已在 AI 写作页面,直接通过自定义事件通知面板
window.dispatchEvent(
new CustomEvent("ai-writing:open-session", { detail: session }),
);
} else {
// 不在该页面,把 session 存入 sessionStorage 后跳转
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
router.push(AI_WRITING_PATH);
}
}
export function RecentWritingList() {
const router = useRouter();
const pathname = usePathname();
const { sessions, loading, refresh } = useAIWritingSessions();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem("sidebar:recent-writing-collapsed") === "true",
);
const toggleCollapsed = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem("sidebar:recent-writing-collapsed", String(next));
};
useEffect(() => {
void refresh();
}, [refresh]);
if (!loading && sessions.length === 0) return null;
const isOnPage = pathname === AI_WRITING_PATH;
return (
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>最近写作</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && sessions.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
sessions.slice(0, 10).map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<SidebarMenuItem key={session.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openSession(session, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{session.title}
</span>
<div className="flex items-center gap-1 mt-0.5">
<span className={cn("text-[10px] font-medium", color)}>
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(session.updated_at)}
</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,9 +1,10 @@
"use client";
import { MonitorSmartphoneIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import {
Select,
SelectContent,
@@ -27,18 +28,12 @@ const languageOptions: { value: Locale; label: string }[] = [
export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
const { theme, setTheme } = useTheme();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
{
id: "system",
label: t.settings.appearance.system,
description: t.settings.appearance.systemDescription,
icon: MonitorSmartphoneIcon,
},
{
id: "light",
label: t.settings.appearance.light,
@@ -57,8 +52,6 @@ export function AppearanceSettingsPage() {
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
t.settings.appearance.system,
t.settings.appearance.systemDescription,
],
);
@@ -68,7 +61,7 @@ export function AppearanceSettingsPage() {
title={t.settings.appearance.themeTitle}
description={t.settings.appearance.themeDescription}
>
<div className="grid gap-3 lg:grid-cols-3">
<div className="grid gap-3 lg:grid-cols-2">
{themeOptions.map((option) => (
<ThemePreviewCard
key={option.id}
@@ -76,8 +69,7 @@ export function AppearanceSettingsPage() {
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "system" | "light" | "dark"}
systemTheme={systemTheme}
mode={option.id as "light" | "dark"}
onSelect={(value) => setTheme(value)}
/>
))}
@@ -144,19 +136,16 @@ function ThemePreviewCard({
description,
active,
mode,
systemTheme,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "system" | "light" | "dark";
systemTheme?: string;
onSelect: (mode: "system" | "light" | "dark") => void;
mode: "light" | "dark";
onSelect: (mode: "light" | "dark") => void;
}) {
const previewMode =
mode === "system" ? (systemTheme === "dark" ? "dark" : "light") : mode;
const previewMode = mode;
return (
<button
type="button"
@@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list";
import { RecentWritingList } from "./recent-writing-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -45,6 +46,7 @@ export function WorkspaceSidebar({
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
+60 -23
View File
@@ -1,4 +1,11 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
import { useTheme as useNextTheme } from 'next-themes';
// 主题类型
export type ThemeType = 'dark' | 'light';
@@ -75,12 +82,24 @@ const themes: Record<ThemeType, ThemeConfig> = {
},
};
const STORAGE_KEY = 'strategy-theme';
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'dark';
}
const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'light' ? 'light' : 'dark';
}
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
themeConfig: ThemeConfig;
toggleTheme: () => void;
setTheme: (theme: ThemeType) => void;
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
mounted: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@@ -91,34 +110,52 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeType>(() => {
// 从 localStorage 读取保存的主题
const saved = localStorage.getItem('strategy-theme');
return (saved as ThemeType) || 'dark';
});
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false);
// 切换主题
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
// 设置特定主题
const setTheme = (newTheme: ThemeType) => {
setThemeState(newTheme);
};
// 保存到 localStorage
useEffect(() => {
localStorage.setItem('strategy-theme', theme);
// 更新文档类名
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}, [theme]);
setMounted(true);
}, []);
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
useEffect(() => {
if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (current !== 'light' && current !== 'dark') {
const fallback =
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
setNextTheme(fallback);
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy);
}
if (legacy) {
localStorage.removeItem('theme');
}
}, [mounted, setNextTheme]);
const theme: ThemeType = mounted
? resolvedTheme === 'light'
? 'light'
: 'dark'
: readPersistedTheme();
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
};
const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme);
};
const themeConfig = themes[theme];
return (
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
<ThemeContext.Provider
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
>
{children}
</ThemeContext.Provider>
);
@@ -277,9 +277,14 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const layers = collectNestedRecords(record);
const indexLabel = pickString(record.index, record.idx, record.rank, record.no);
let title =
pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
if (!title && indexLabel) {
title = `结果 ${indexLabel}`;
}
let content =
pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
@@ -313,7 +318,8 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
title = pickShortTitleFallback(layers, reserved);
}
if (!title && !content && !url) {
// 内网检索可能只有 index + 空 title/content_preview,仍展示占位卡片
if (!title && !content && !url && !indexLabel) {
return null;
}
+81 -15
View File
@@ -1,5 +1,7 @@
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { hasSearchResultPayload } from "@/core/messages/search-results";
interface GenericMessageGroup<T = string> {
type: T;
id: string | undefined;
@@ -141,21 +143,63 @@ export function groupMessages<T>(
}
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return (
splitInlineReasoningFromAIMessage(message)?.content ??
message.content.trim()
);
const payload = extractToolResultPayload(message);
if (typeof payload === "string") {
return payload;
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => (content.type === "text" ? content.text : ""))
.join("\n")
.trim();
if (payload && typeof payload === "object") {
try {
return JSON.stringify(payload);
} catch {
return "";
}
}
return "";
}
/**
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
*/
export function extractToolResultPayload(
message: Message,
): string | Record<string, unknown> | undefined {
const { content } = message;
if (typeof content === "string") {
const trimmed = content.trim();
return trimmed || undefined;
}
if (content && typeof content === "object" && !Array.isArray(content)) {
return content as Record<string, unknown>;
}
if (Array.isArray(content)) {
const textParts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") {
continue;
}
const block = part as Record<string, unknown>;
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
continue;
}
if (block.type === "json" && block.json && typeof block.json === "object") {
return block.json as Record<string, unknown>;
}
if (hasSearchResultPayload(block)) {
return block;
}
}
const joined = textParts.join("\n").trim();
return joined || undefined;
}
return undefined;
}
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
@@ -335,15 +379,37 @@ export function hasSubagent(message: AIMessage) {
return false;
}
export function findToolCallResult(toolCallId: string, messages: Message[]) {
export function findToolCallResult(
toolCallId: string,
messages: Message[],
toolName?: string,
): string | Record<string, unknown> | undefined {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
const content = extractTextFromMessage(message);
if (content) {
return content;
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
continue;
}
const payload = extractToolResultPayload(message);
if (payload !== undefined) {
return payload;
}
}
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
if (toolName) {
for (const message of messages) {
if (message.type !== "tool" || message.name !== toolName) {
continue;
}
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
continue;
}
const payload = extractToolResultPayload(message);
if (payload !== undefined && hasSearchResultPayload(payload)) {
return payload;
}
}
}
return undefined;
}
@@ -19,6 +19,8 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
disabled?: boolean;
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
hideAdvancedFields?: boolean;
}
interface ModelOption {
@@ -31,7 +33,14 @@ const INPUT_CLASS =
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const DEFAULT_KEYWORD_COUNT = 6;
const DEFAULT_AUDIENCE = '普通读者';
export function AIWritingForm({
onSubmit,
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
@@ -110,14 +119,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
const keywordCount = hideAdvancedFields
? DEFAULT_KEYWORD_COUNT
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount,
keywordCount,
writingMode: strictMode ? 'strict' : 'loose',
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
modelName: modelName || undefined,
});
}
@@ -173,53 +186,57 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
{!hideAdvancedFields && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
max={10}
step={1}
className={INPUT_CLASS}
placeholder="6"
value={keywordCountInput}
onChange={(e) => setKeywordCountInput(e.target.value)}
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
</>
)}
{models.length > 0 && (
<div className="space-y-1.5">
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
@@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
error: { label: '错误', color: 'text-destructive' },
};
function formatRelative(isoStr: string): string {
export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
@@ -25,7 +25,7 @@ function formatRelative(isoStr: string): string {
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
interface SessionItemProps {
export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
@@ -33,7 +33,7 @@ interface SessionItemProps {
onRename: (title: string) => void;
}
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -65,7 +65,11 @@ const STATUS_LABEL: Record<string, string> = {
error: '出错了',
};
export function AIWritingPanel() {
export function AIWritingPanel({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
@@ -193,6 +197,35 @@ export function AIWritingPanel() {
reset();
}, [reset]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
useEffect(() => {
handleSelectHistoryRef.current = handleSelectHistory;
}, [handleSelectHistory]);
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
useEffect(() => {
const raw = sessionStorage.getItem('ai-writing:pending-session');
if (!raw) return;
sessionStorage.removeItem('ai-writing:pending-session');
try {
const session = JSON.parse(raw) as AIWritingSessionSummary;
handleSelectHistoryRef.current(session);
} catch { /* ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
useEffect(() => {
function onOpenSession(e: Event) {
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
if (session) handleSelectHistoryRef.current(session);
}
window.addEventListener('ai-writing:open-session', onOpenSession);
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -342,7 +375,10 @@ export function AIWritingPanel() {
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 未开始时显示表单 */}
{isIdle && (
<AIWritingForm onSubmit={startWriting} />
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
/>
)}
{/* 意图解析:流式 → 结果卡片承接。
@@ -353,7 +389,11 @@ export function AIWritingPanel() {
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} />
<IntentResultCard
intent={state.parsedIntent}
articleTypes={articleTypes}
hideTargetAudience={hideAdvancedFields}
/>
)}
{/* 运行中或完成后显示时间线 */}
@@ -484,20 +524,26 @@ export function AIWritingPanel() {
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
</span>
</ReqRow>
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
{!hideAdvancedFields && (
<ReqRow label="目标读者">
<span className="text-muted-foreground">{state.request.targetAudience}</span>
</ReqRow>
)}
<ReqRow label="目标字数">
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
</ReqRow>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
{!hideAdvancedFields && (
<>
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
</>
)}
{state.request.modelName && (
<ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span>
@@ -6,13 +6,18 @@ import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
hideTargetAudience?: boolean;
}
/**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
export function IntentResultCard({
intent,
articleTypes,
hideTargetAudience = false,
}: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
@@ -28,7 +33,9 @@ export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps
</div>
<div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} />
{!hideTargetAudience && (
<Row label="目标读者" value={intent.targetAudience} />
)}
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline />
@@ -103,7 +103,11 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() {
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
@@ -128,7 +132,7 @@ export default function AIWritingPage() {
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
</ResizablePanel>
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BookOpen, Plus, Send } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
export default function LightAppNoteQAPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [question, setQuestion] = useState("");
const handleSubmit = () => {
if (!question.trim()) return;
setQuestion("");
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
isDark ? "bg-slate-950" : "bg-white",
)}
>
{/* 背景光晕 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background: isDark
? "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(59,130,246,0.10) 0%, rgba(139,92,246,0.06) 50%, transparent 70%)"
: "radial-gradient(ellipse 60% 50% at 50% 46%, rgba(147,197,253,0.38) 0%, rgba(165,180,252,0.18) 50%, transparent 70%)",
}}
/>
{/* 主内容 */}
<div className="relative flex h-full flex-col items-center justify-center -translate-y-10">
<div className="flex w-full max-w-[520px] flex-col items-center gap-4 px-6">
{/* 笔记图标 */}
<div
className={cn(
"flex h-12 w-10 shrink-0 items-center justify-center rounded shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-6 w-6" />
</div>
{/* 笔记标题 */}
<h1
className={cn(
"text-xl font-semibold tracking-tight",
isDark ? "text-slate-100" : "text-slate-900",
)}
>
笔记01
</h1>
{/* 新增来源按钮 */}
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1 text-sm font-medium transition-colors",
isDark
? "bg-sky-500/20 text-sky-300 hover:bg-sky-500/30"
: "bg-sky-100 text-sky-600 hover:bg-sky-200",
)}
>
<Plus className="h-3.5 w-3.5" />
新增来源
</button>
{/* 问答输入框 */}
<div
className={cn(
"mt-3 flex w-full items-center gap-3 rounded-full px-5 py-3.5",
isDark
? "bg-slate-800 ring-1 ring-white/10"
: "bg-white shadow-sm ring-1 ring-black/5",
)}
>
<Plus
className={cn(
"h-4 w-4 shrink-0",
isDark ? "text-slate-500" : "text-slate-400",
)}
/>
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
placeholder="开始问答"
className={cn(
"min-w-0 flex-1 bg-transparent text-sm outline-none",
isDark
? "text-slate-200 placeholder:text-slate-500"
: "text-slate-700 placeholder:text-slate-400",
)}
/>
{question.trim() && (
<button
type="button"
onClick={handleSubmit}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-sky-500 text-white hover:bg-sky-400"
: "bg-[#F5222D] text-white hover:bg-[#CF1322]",
)}
aria-label="发送"
>
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -8,6 +8,7 @@ import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
/>
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
</Routes>
</main>
</div>
@@ -0,0 +1,16 @@
import { Navigate, Route, Routes } from "react-router-dom";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export default function RoundtableRoutes() {
return (
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="planning" replace />} />
<Route path="planning" element={<RoundtablePlanningPage />} />
<Route path="*" element={<Navigate to="planning" replace />} />
</Routes>
</div>
);
}
@@ -26,6 +26,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
export default function StrategyRoutes() {
return (
@@ -41,6 +42,7 @@ export default function StrategyRoutes() {
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
+1 -1
View File
@@ -183,7 +183,7 @@ export default function WorkspaceRoutes({
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage />
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
@@ -0,0 +1,233 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type ThreadIdMap = Record<string, string>;
export interface InitMultiAgentRequest {
agentNames: string[];
mainAgentName: string;
model?: string;
}
export interface InitMultiAgentResponse {
status: "success";
main_agent_name: string;
thread_ids: ThreadIdMap;
}
export async function initMultiAgent(
payload: InitMultiAgentRequest,
): Promise<InitMultiAgentResponse> {
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,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
return (await res.json()) as InitMultiAgentResponse;
}
export type StreamAgentType = "leader" | "special";
export type LeaderDispatch = [string, string];
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;
}
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;
}
/**
* 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;
} {
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 };
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();
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);
if (isFinalStatusFrame(parsed)) {
if (Array.isArray(parsed.status)) {
result.dispatched = parsed.status;
} else {
result.message = parsed.status;
}
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;
}
@@ -0,0 +1,498 @@
import React from "react";
import {
ShieldCheck,
Layers,
Clipboard,
Star,
CheckCircle,
Download,
Share2,
Sparkles
} from "lucide-react";
interface HighFidelityReportProps {
budgetLimit: number;
consensusPercentage: number;
setCurrentStep: (step: number) => void;
setStep1MessageHistory: (history: any) => void;
setStep2RoundtableDialogues: (dialogues: any) => void;
triggerToast: (msg: string) => void;
}
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
budgetLimit,
consensusPercentage,
setCurrentStep,
setStep1MessageHistory,
setStep2RoundtableDialogues,
triggerToast
}) => {
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Output title block */}
<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">
<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">
生成时间:2026-05-21 11:01:12
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
id="report-download-pdf-btn"
onClick={() => triggerToast("📥 架构方案白皮书 PDF 已开始准备传输...")}
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"
>
<Download className="w-3.5 h-3.5 text-slate-500" />
导出方案 PDF
</button>
<button
id="report-share-btn"
onClick={() => triggerToast("🔗 方案安全访问及沙盒校验链接已复制到剪贴板!")}
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"
>
<Share2 className="w-3.5 h-3.5 text-slate-500" />
安全分享
</button>
</div>
</div>
{/* Comprehensive high fidelity report details */}
<div className="p-6 space-y-6">
{/* Dashboard Metric Overview Rows */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Metric 1 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-slate-400 block mb-1">设计全要素通过率</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-slate-900 font-mono">
{budgetLimit >= 500 ? "99.4%" : budgetLimit > 400 ? "98.2%" : "96.5%"}
</span>
<span className="text-[9px] font-black text-emerald-600 bg-emerald-50 px-1.5 py-0.2 rounded-full">合规通过</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">安全拦截红线保障 0 出圈、0 超限</p>
</div>
{/* Metric 2 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-primary block mb-1">安全冗余评估能级</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-primary font-mono">
{budgetLimit >= 540 ? "Tier III+" : budgetLimit > 410 ? "Tier II" : "Tier I"}
</span>
<span className="text-[9px] font-black text-[var(--brand-900)] bg-[var(--brand-200)] px-1.5 py-0.2 rounded-full">异地容灾</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">多路容错算力热备与热切换防线</p>
</div>
{/* Metric 3 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-slate-400 block mb-1">研讨总核销资金预算</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-slate-800 font-mono">
{Math.round(budgetLimit * 0.925 * 10) / 10}万
</span>
<span className="text-[9px] font-black text-emerald-600 bg-emerald-50 px-1.5 py-0.2 rounded-full font-sans">
省 {Math.round(budgetLimit * 0.075 * 10) / 10}w
</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">包含逻辑容器、极端微物理灾灾备购置</p>
</div>
{/* Metric 4 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-slate-400 block mb-1">终审共识度评分</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-slate-900 font-mono">42 轮研商</span>
<span className="text-[9px] font-black text-amber-600 bg-amber-50 px-1.5 py-0.2 rounded-full">
共识率 {consensusPercentage}%
</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">多智能体委员会滚动合议完美收敛</p>
</div>
</div>
{/* Section I: 执行摘要 (Executive Summary) */}
<div className="bg-gradient-to-br from-[var(--brand-200)]/30 to-[var(--brand-200)]/10 border border-red-100 rounded-2xl p-5 hover:shadow-md transition">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="w-4.5 h-4.5 text-[var(--brand-900)]" />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
执行摘要 / Executive Summary
</h4>
</div>
<div className="text-xs text-slate-755 leading-relaxed space-y-2 font-medium">
<p className="flex items-start gap-1.5">
<span className="text-primary text-md leading-none">•</span>
<span>本路线规划方案在总核销支出预算限制 ≤ <b className="text-slate-900 font-bold">{budgetLimit}万(主备案并轨共耗约 {Math.round(budgetLimit * 0.925 * 10) / 10}万元)</b>、核心编制队伍限制 ≤ <b className="text-slate-900 font-bold">30人</b>、绝对周转上线规定 <b className="text-slate-900 font-bold">72小时</b> 内交付的目标约束下制定。最终裁决收敛并采纳包含【信息补全】、【主干推进】与【风险收敛】三阶段非对称推进的稳健路径。</span>
</p>
<p className="flex items-start gap-1.5">
<span className="text-primary text-md leading-none">•</span>
<span>经多角色 AI 官群及人类指挥官深度澄清和在沙盒拓扑中演算测试,采用本规划方案,在安全拦截(合规绿码)、运行负载及高灾备级余度三大维度上均符合最苛刻的可落地标准。</span>
</p>
<p className="flex items-start gap-1.5">
<span className="text-primary text-md leading-none">•</span>
<span>项目具有明确的关键里程碑控制链、主备熔断备份计划,可执行确凿性最优,推荐自批复时点起立即启动第一阶段工作。</span>
</p>
</div>
</div>
{/* Section II: 方案详细大纲 (Detailed Core Outline) */}
<div className="bg-white border border-slate-150 rounded-2xl p-5 hover:shadow-md transition space-y-6">
<div className="flex items-center gap-2 border-b border-slate-100 pb-3 mb-1">
<Layers className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
一、最终规划方案内容大纲
</h4>
</div>
<div className="space-y-6 text-xs text-slate-750 font-medium leading-relaxed">
{/* Item 1 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[var(--brand-200)] text-primary font-bold font-mono text-[10.5px]">1</span>
任务背景与目标 / Target Scope
</h5>
<p className="pl-6.5 text-slate-500 leading-normal">
围绕给定预算上限(限额:{budgetLimit}万)与资源约束(人数:≤30人,时限:72小时),建立一套主备、高容错、防崩溃的多模态适应性部署方案。通过多智能体委员会多轮博弈对抗演炼,彻底击碎不确定性风险,平滑过渡并投产运行。
</p>
</div>
{/* Item 2 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[var(--brand-200)] text-[var(--brand-900)] font-bold font-mono text-[10.5px]">2</span>
约束条件与关键假设 / Ground Hypotheses
</h5>
<div className="pl-6.5 grid grid-cols-1 md:grid-cols-2 gap-3 mt-1.5 text-slate-500">
<div className="p-3 bg-slate-50 border border-slate-150 rounded-xl space-y-1">
<span className="font-black text-slate-800 text-[10.5px] block">📌 指向性硬约束:</span>
<ul className="list-disc pl-4 space-y-1 text-[10px]">
<li>预算开销指示极限: <b className="text-slate-850">≤ {budgetLimit}万</b></li>
<li>人力池最大可用总额: <b className="text-slate-850">≤ 30人</b></li>
<li>全投产周转交付极限: <b className="text-slate-850">≤ 72小时</b></li>
</ul>
</div>
<div className="p-3 bg-slate-50 border border-slate-150 rounded-xl space-y-1">
<span className="font-black text-slate-800 text-[10.5px] block">🔮 关键假设依据:</span>
<ul className="list-disc pl-4 space-y-1 text-[10px]">
<li>阶段一能够在24h内快捷、高配合度、无阻碍补全关键参数</li>
<li>灾灾备系统对逻辑双微高可用(云原生 K3s)具有高度适配性</li>
<li>极端环境历史数据具有一定参照性,各微服务配合维持稳定</li>
</ul>
</div>
</div>
</div>
{/* Item 3 (Interactive roadmap timeline pipeline mockup) */}
<div className="space-y-3">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-purple-50 text-purple-600 font-bold font-mono text-[10.5px]">3</span>
三阶段推进方案路线图 / Three-Phase Integration Roadmap
</h5>
<p className="pl-6.5 text-slate-500 pb-1">
项目设计采用分段非对称递进开发,首尾相扣,利用熔断和回滚阀门最大化减少交付风险:
</p>
<div className="pl-6.5 grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Phase 1 card */}
<div className="bg-slate-50 border border-slate-150 rounded-2xl p-4 flex flex-col justify-between hover:border-red-200 transition relative">
<div className="absolute top-2.5 right-3 px-2 py-0.5 rounded-full bg-[var(--brand-200)] text-primary border border-red-100 font-mono font-bold text-[9px]">
0 - 24h
</div>
<div>
<span className="font-black text-[11.5px] tracking-tight block mb-2 text-primary">
阶段一:系统信息补全
</span>
<ul className="text-[10px] text-slate-500 space-y-1.5 leading-snug">
<li className="flex items-start gap-1">
<span className="text-primary">•</span>
<span>补缺所有缺失现状对齐指标</span>
</li>
<li className="flex items-start gap-1">
<span className="text-primary">•</span>
<span>拉网厘清核心需求物理缺失</span>
</li>
<li className="flex items-start gap-1">
<span className="text-primary">•</span>
<span>与一线用户对齐约束边界</span>
</li>
</ul>
</div>
<div className="border-t border-slate-150 pt-3 mt-4 flex items-center justify-between text-[9px]">
<span className="text-slate-400 font-bold">交付物件:</span>
<span className="text-slate-700 bg-slate-200/50 px-1.5 py-0.5 rounded font-bold font-sans">《范围基准对齐白皮书》</span>
</div>
</div>
{/* Phase 2 card */}
<div className="bg-slate-50 border border-slate-150 rounded-2xl p-4 flex flex-col justify-between hover:border-purple-200 transition relative">
<div className="absolute top-2.5 right-3 px-2 py-0.5 rounded-full bg-purple-50 text-purple-600 border border-purple-100 font-mono font-bold text-[9px]">
24 - 60h
</div>
<div>
<span className="font-black text-[11.5px] tracking-tight block mb-2 text-purple-650">
阶段二:主任务核心递进
</span>
<ul className="text-[10px] text-slate-500 space-y-1.5 leading-snug">
<li className="flex items-start gap-1">
<span className="text-purple-500">•</span>
<span>5个核心子模块并行极速构建</span>
</li>
<li className="flex items-start gap-1">
<span className="text-purple-500">•</span>
<span>多智能体拓扑动态推演和收敛</span>
</li>
<li className="flex items-start gap-1">
<span className="text-purple-500">•</span>
<span>自习惯里程碑路径重排与分配</span>
</li>
</ul>
</div>
<div className="border-t border-slate-150 pt-3 mt-4 flex items-center justify-between text-[9px]">
<span className="text-slate-400 font-bold">交付物件:</span>
<span className="text-slate-700 bg-slate-200/50 px-1.5 py-0.5 rounded font-bold font-sans">《容器级物理架构拓扑》</span>
</div>
</div>
{/* Phase 3 card */}
<div className="bg-slate-50 border border-slate-150 rounded-2xl p-4 flex flex-col justify-between hover:border-emerald-200 transition relative">
<div className="absolute top-2.5 right-3 px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100 font-mono font-bold text-[9px]">
60 - 72h
</div>
<div>
<span className="font-black text-[11.5px] tracking-tight block mb-2 text-emerald-650">
阶段三:极端风险安全收敛
</span>
<ul className="text-[10px] text-slate-500 space-y-1.5 leading-snug">
<li className="flex items-start gap-1">
<span className="text-emerald-500">•</span>
<span>一键拉闸灾灾备崩溃性沙盒论证</span>
</li>
<li className="flex items-start gap-1">
<span className="text-emerald-500">•</span>
<span>安全审核拦截与物理封签加签</span>
</li>
<li className="flex items-start gap-1">
<span className="text-emerald-500">•</span>
<span>打包大案报告完美交付投产运行</span>
</li>
</ul>
</div>
<div className="border-t border-slate-150 pt-3 mt-4 flex items-center justify-between text-[9px]">
<span className="text-slate-400 font-bold">交付物件:</span>
<span className="text-slate-700 bg-slate-200/50 px-1.5 py-0.5 rounded font-bold font-sans">《投产交付大白皮书》</span>
</div>
</div>
</div>
</div>
{/* Item 4 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-50 text-amber-600 font-bold font-mono text-[10.5px]">4</span>
资源配置计划 / Budget & Resource Allocation
</h5>
<div className="pl-6.5 text-slate-500 space-y-2 leading-relaxed">
<p>
主备案并轨总体支出已精精控制在限额内(耗资约 <b className="text-slate-900 font-black">{Math.round(budgetLimit * 0.925 * 10) / 10}万元</b>,含容器、中继微灾备硬件耗销:42%,情报库与政策规则授权:23%,防崩溃校验与极端测试:17%,极客安全风控硬预留:10.5%,剩余资金安全冗余)。
</p>
<div className="flex flex-wrap gap-2 pt-1 font-bold text-[10px]">
<span className="bg-slate-100 px-2.5 py-1 rounded-lg border border-slate-150 text-slate-700">
总限额极限: <b>{budgetLimit}万元</b>
</span>
<span className="bg-[var(--brand-200)] text-[var(--brand-900)] px-2.5 py-1 rounded-lg border border-red-200">
军官与执行队伍编制: <b>28人(合规限度)</b>
</span>
<button
onClick={() => triggerToast("📅 正在基于实时72小时主过程进行甘特图渲染...")}
className="text-primary hover:underline flex items-center gap-1 cursor-pointer font-black"
>
[在线查看项目开发实施甘特图 timeline]
</button>
</div>
</div>
</div>
{/* Item 5 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-50 text-red-650 font-bold font-mono text-[10.5px]">5</span>
风险识别与安全应对 / Secondary Risk Management
</h5>
<p className="pl-6.5 text-slate-500 leading-normal">
通过多智能体委员会滚动博弈对抗和风险拦截验证,提前判定并锁定「需求过载细节波动」、「物理接机到货故障」、「通信源不完整滞缓」等 3 项主要隐忧。全部预案、补偿基金及冗余措施已完成,详见右侧面板。
</p>
</div>
{/* Item 6 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-50 text-emerald-650 font-bold font-mono text-[10.5px]">6</span>
关键里程碑控制链与下一步工作 / Execution Advice
</h5>
<div className="pl-6.5 text-slate-500 space-y-2 mt-2 leading-relaxed">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 text-[9.5px] font-medium">
<div className="p-2 bg-slate-50 border border-slate-150 rounded-xl flex items-start gap-2">
<span className="h-4.5 w-4.5 rounded bg-[var(--brand-200)]0 text-white flex items-center justify-center shrink-0 font-bold font-mono text-[9px]">M1</span>
<div>
<b className="text-slate-800 block text-[10.5px]">【T+12h 状态核验】</b>
<span>拉网对齐所有缺失要素,未达标熔断顺延</span>
</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-150 rounded-xl flex items-start gap-2">
<span className="h-4.5 w-4.5 rounded bg-purple-500 text-white flex items-center justify-center shrink-0 font-bold font-mono text-[9px]">M2</span>
<div>
<b className="text-slate-800 block text-[10.5px]">【T+24h 双活就绪】</b>
<span>物理容器拉通,注入最极限崩溃测试演习</span>
</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-150 rounded-xl flex items-start gap-2">
<span className="h-4.5 w-4.5 rounded bg-emerald-500 text-white flex items-center justify-center shrink-0 font-bold font-mono text-[9px]">M3</span>
<div>
<b className="text-slate-800 block text-[10.5px]">【T+60h 安全审计】</b>
<span>突发拔网接管成功,获得整装绿码授权</span>
</div>
</div>
</div>
<p className="text-[10.5px] text-slate-700 bg-amber-50/60 p-2.5 rounded-lg border border-amber-100 leading-normal font-medium mt-1.5">
💡 <b>行动意见:</b> 指挥官裁可此成果后,项目立即固化加锁。每日安排每12小时高维配合对齐站会,强力扣死这 3 颗 M-Class 螺丝钉。
</p>
</div>
</div>
</div>
</div>
{/* Section III: 论证依据支撑库 (Supporting Evidence Pieces) */}
<div className="bg-white border border-slate-150 rounded-2xl p-5 hover:shadow-md transition space-y-4">
<div className="flex items-center gap-2 border-b border-slate-100 pb-3 mb-1">
<Clipboard className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
二、论证依据要件归档情况
</h4>
</div>
<p className="text-xs text-slate-400 font-medium">
本报告方案具有无可挑剔的底层输入支撑。点击各要件名称,可通过云控制台查看对应沙盒隔离归档详情:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 pt-1 text-xs">
{[
{ num: "01", name: "用户需求研商访谈纪要.txt", size: "14.2 KB", color: "border-red-100 bg-[var(--brand-200)]/20 text-[var(--brand-900)]" },
{ num: "02", name: "指标现状及运行分析报告.docx", size: "2.4 MB", color: "border-teal-100 bg-teal-50/20 text-teal-750" },
{ num: "03", name: "典型物理拓扑经验 (2份).pdf", size: "4.8 MB", color: "border-purple-100 bg-purple-50/20 text-purple-750" },
{ num: "04", name: "安全极端防范策略库.json", size: "128 KB", color: "border-amber-100 bg-amber-50/20 text-amber-700" },
{ num: "05", name: "全行业合规防灾法规准则.pdf", size: "1.9 MB", color: "border-red-100 bg-red-50/20 text-red-750" }
].map((doc, index) => (
<div
key={index}
onClick={() => triggerToast(`正在预览论证原始要件: 《${doc.name}》...`)}
className={`p-3 border rounded-xl flex flex-col justify-between hover:scale-[1.02] active:scale-95 transition cursor-pointer select-none ${doc.color}`}
>
<div>
<b className="font-mono text-[9px] block mb-1 opacity-70">FILE-{doc.num}</b>
<span className="font-bold tracking-tight text-[11px] leading-snug block line-clamp-2 min-h-[32px]">
{doc.name}
</span>
</div>
<div className="flex items-center justify-between text-[9px] opacity-75 mt-3 border-t border-slate-200/50 pt-1.5 font-mono">
<span>附件档案</span>
<span>{doc.size}</span>
</div>
</div>
))}
</div>
</div>
{/* Section IV: 采纳采定理由陈述 (Final Ruling Statement / Stamp Board) */}
<div className="bg-gradient-to-r from-amber-50/40 via-yellow-50/30 to-amber-50/20 border border-amber-200/70 rounded-2xl p-5 hover:shadow-md transition">
<div className="flex items-center gap-2 mb-2.5">
<Star className="w-4.5 h-4.5 text-amber-500 fill-amber-400" />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
三、采纳理由与最终裁定判词
</h4>
</div>
<div className="text-xs text-slate-800 leading-relaxed font-semibold">
<p>
经由专家委员会团队大模型多视角滚动共计 <b className="text-slate-900 font-extrabold">12 轮</b> 极限对抗研商、冲突演炼得出:在「工期匹配度」(92%)、「预算开支控制度」(90%)、「极端容灾抗体余温」(88%) 与「总价值安全收益率」(91%) 四维雷达对决合围精算测定中,本主案 A 指标总合分最高(<b>90.3分</b>)。相比其他预算爆限、对外部相关配合条件依赖极重的僵硬路线,本规划方案在限时给定的 {budgetLimit}万元 条件内,取得了最高确凿的可执行概率。特此裁决:<b>给予最高优先级采纳,盖印发布!</b>
</p>
</div>
</div>
{/* Footer Bottom Actions Box */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-1 text-slate-400 text-xs font-medium">
<svg className="w-4 h-4 text-slate-400 animate-pulse shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<span className="line-clamp-1">全案终审已经盖印下发。若再次起草新路线会商,可点左侧清归。</span>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<button
id="step3-reset-btn"
onClick={() => {
setCurrentStep(1);
setStep1MessageHistory([
{
id: 1,
sender: "理解专家 (Gemini)",
avatarType: "blue",
content: "您好!我是多智能体理解与转化专家。请问我们本次需要进行什么业务路线图的顶层研商?(您可以选择下方预设模板一键录入任务环境)",
time: "10:00:00"
}
]);
setStep2RoundtableDialogues([
{
id: 1,
sender: "总结陈述智能体 (Gemini 2.5 Pro)",
avatarType: "blue",
content: "各位专家好,本论证轮主要针对“任务目标72小时极速打通投产”及“总预算在500万内开销”做可行度分析与冗余设计研商。请各抒己见。",
time: "10:01:05",
confidence: "主席初始化"
}
]);
triggerToast("✨ 指导系统成功完成归档,重新开始新一轮规划会商");
}}
className="flex-1 md:flex-none 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 font-sans"
>
重新起草新规划
</button>
<button
id="step3-sign-btn"
onClick={() => {
triggerToast("🚀 [最高指令授权签批]:协同全案已加签封存下发!核心路由组和灾灾备机制已就绪保障投产!");
}}
className="flex-1 md:flex-none bg-primary hover:bg-[var(--brand-900)] text-white text-[10.5px] font-black px-5 py-3 rounded-2xl shadow-lg shadow-primary/20 active:scale-95 transition cursor-pointer select-none flex items-center gap-1.5 justify-center"
>
<Sparkles className="w-3.5 h-3.5 text-red-200" />
签发起草成果并投产运行
</button>
</div>
</div>
</div>
</div>
);
};
@@ -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));
}
+119 -69
View File
@@ -1,12 +1,24 @@
import React, { useState } from 'react';
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
import { NotificationDialog } from '@/components/workspace/notification-dialog';
import { useTheme } from '../contexts/ThemeContext';
import { useUnreadCount } from '@/core/notifications';
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
import { useState } from "react";
const Header: React.FC = () => {
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { Button } from "@/components/ui/button";
import { useUnreadCount } from "@/core/notifications";
import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
const iconButtonClass = (isDark: boolean) =>
cn(
"size-8 shrink-0 rounded-sm",
isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
const Header = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
@@ -16,72 +28,110 @@ const Header: React.FC = () => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
isDark
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
: 'bg-red-600 border-b border-red-700'
}`}>
{/* 左侧 - 搜索框 */}
<div className="flex items-center gap-4">
<header
className={cn(
"theme-transition flex h-16 items-center justify-between border-b px-4",
isDark
? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggleTheme}
className={iconButtonClass(isDark)}
title={isDark ? "切换到红白主题" : "切换到深色主题"}
>
{isDark ? (
<Palette className="size-4" />
) : (
<Moon className="size-4" />
)}
</Button>
{/* 右侧 - 用户信息和通知 */}
<div className="flex items-center gap-4">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className={`p-2 rounded-lg transition-all duration-200 ${
isDark
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
: 'hover:bg-red-500 text-white'
}`}
title={isDark ? '切换到红白主题' : '切换到深色主题'}
>
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setNotificationOpen(true)}
className={cn(iconButtonClass(isDark), "relative")}
title="通知"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-sm px-1 text-[10px] leading-none font-medium",
isDark
? "bg-cyan-500 text-white"
: "bg-white tabular-nums text-[var(--brand-900)]",
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
{/* 通知 */}
<button
type="button"
onClick={() => setNotificationOpen(true)}
className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}
title="通知"
>
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
{unreadCount > 0 && (
<span
className={`absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? 'bg-cyan-500' : 'bg-white text-red-600'
}`}
<div
className={cn(
"mx-1 h-8 w-px",
isDark ? "bg-blue-800/30" : "bg-white/30",
)}
aria-hidden
/>
<Button
type="button"
variant="ghost"
className={cn(
"h-8 gap-2 rounded-sm px-2",
isDark
? "text-slate-200 hover:bg-slate-800/50"
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-sm",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white",
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* 分隔线 */}
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
{/* 用户信息 */}
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white'
}`}>
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">张策略</p>
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>策略分析师</p>
</div>
<ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} />
</button>
</div>
</header>
<User
className={cn(
"size-4",
isDark ? "text-white" : "text-[var(--brand-900)]",
)}
/>
</div>
<div className="hidden text-left sm:block">
<p className="text-sm leading-tight font-medium text-white">
张策略
</p>
<p
className={cn(
"text-xs leading-tight",
isDark ? "text-blue-400/60" : "text-white/75",
)}
>
策略分析师
</p>
</div>
<ChevronDown
className={cn(
"size-4 shrink-0",
isDark ? "text-slate-500" : "text-white/80",
)}
/>
</Button>
</div>
</header>
</>
);
};
+9 -1
View File
@@ -240,8 +240,11 @@
}
:root {
--workspace-brand: #d92121;
--workspace-brand: #f5222d;
--workspace-brand-foreground: #ffffff;
--brand-200: #feeded;
--brand-900: #cf1322;
--brand-950: #a8071a;
--radius: 0.75rem;
--background: oklch(1 0 0);
--foreground: oklch(0.22 0.02 25);
@@ -811,3 +814,8 @@
transform: rotate(360deg);
}
}
* {
border-color: var(--border);
outline-color: var(--ring);
}
+483
View File
@@ -0,0 +1,483 @@
"""
测试流式接口的完整脚本
API流程:
1. 登录获取token
2. 创建thread(可选,也可以使用无状态接口)
3. 调用流式接口
两种方式:
- 方式1: POST /api/runs/stream (无状态,自动创建thread)
- 方式2: POST /api/threads/{thread_id}/runs/stream (需要先创建thread)
"""
import requests
import json
import asyncio
import httpx # 异步 HTTP 客户端
# ============ 配置区域 ============
BASE_URL = "http://127.0.0.1:8001"
LOGIN_URL = f"{BASE_URL}/api/v1/auth/login/username"
THREADS_URL = f"{BASE_URL}/api/threads"
RUNS_STREAM_URL = f"{BASE_URL}/api/runs/stream" # 无状态流式接口
# ============ 登录获取token ============
def get_token(username: str) -> str:
"""登录获取token"""
response = requests.post(
LOGIN_URL,
json={"username": username},
headers={"Content-Type": "application/json"}
)
# print(f"登录状态码: {response.status_code}")
data = response.json()
# print(f"登录响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
# 根据实际返回结构调整
token = data.get("token") or data.get("access_token") or data.get("data", {}).get("token")
return token
# ============ 方式2: 先创建thread,再调用流式接口 ============
def create_thread(token: str) -> str:
"""创建thread,返回thread_id"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
response = requests.post(
THREADS_URL,
json={}, # 空body,使用默认值
headers=headers
)
print(f"创建thread状态码: {response.status_code}")
data = response.json()
print(f"创建thread响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
return data.get("thread_id")
def get_agent_name_description(agent_name="Taiwan-intelligence-gathering-agent"):
url = f"{BASE_URL}/api/agents/{agent_name}"
token=get_token("ji")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
response = requests.get(
url,
headers=headers,
)
print(response.json())
return response.json()["name"].lower(),"agent_name:"+response.json()["name"].lower()+","+response.json()["description"]
# ============ 创建智能体 ============
def create_agent(
name: str,
description: str = "",
model: str | None = None,
tool_groups: list[str] | None = None,
skills: list[str] | None = None,
soul: str = "",
):
"""
创建一个新的智能体
Args:
name: 智能体名称(必须是 hyphen-case,如 my-agent)
description: 智能体描述
model: 可选的模型覆盖(如 gpt-4, claude-3-opus 等)
tool_groups: 工具组白名单(如 ["web", "file"])
skills: 技能白名单(如 ["skill1", "skill2"])
soul: SOUL.md 内容(智能体人格/身份描述)
Returns:
创建结果
"""
TOKEN =get_token("ji")
url = f"{BASE_URL}/api/agents"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}"
}
payload = {
"name": name,
"description": description,
"soul": soul
}
# 添加可选字段
if model is not None:
payload["model"] = model
if tool_groups is not None:
payload["tool_groups"] = tool_groups
if skills is not None:
payload["skills"] = skills
print(f"请求: POST {url}")
print(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
response = requests.post(url, json=payload, headers=headers)
print(f"状态码: {response.status_code}")
if response.status_code == 201:
result = response.json()
print(f"✓ 智能体创建成功!")
print(f"响应: {json.dumps(result, indent=2, ensure_ascii=False)}")
return result
else:
print(f"✗ 创建失败: {response.text}")
return f"✗ 创建失败: {response.text}"
def create_main_agent(zhihui_name,agent_names):
d={}
token=get_token("ji")
l=[]
for i in agent_names:
# print(i)
name,description=get_agent_name_description(i)
thread_id_tmp=create_thread(token)
d[name]=thread_id_tmp
l.append(description)
name="你是总控控制协调智能体,负责协调其他智能体干活"
miaoshu=f"""#核心定位:
用途:跟用用户的任务目标,调用其他智能体干活,只负责协调汇总,当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
#调度方法:
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
#可调度智能体:
{l}"""
thread_id_tmp=create_thread(token)
engname=zhihui_name
d[engname]=thread_id_tmp
tmp_skills=["agent_orchestration"]
tmpres=create_agent(
name=engname,
description=name,
model="glm-5", # 指定模型
skills=tmp_skills, # 关联技能
soul=miaoshu)
# print("创建智能体响应:",tmpres)
if "失败" in tmpres and "exists" not in tmpres:
return tmpres
print(f"创建协调智能体成功,可以协调列表:{l}")
return d
import re
def extract_name_task(text="python3 scripts/agent_orchestration.py \"Us-intelligence-gathering-agent\" \"收集和分析美国人民对特朗普的看法,包括支持率和反对率、不同群体的观点差异、主要支持理由和反对理由等\""):
try:
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
match = re.search(pattern, text)
if match:
agent_name = match.group(1)
task_content = match.group(2)
return agent_name,task_content
except:
return "",""
def update_agent_message(thread_id,new_content):
token=get_token("ji")
url = f"{BASE_URL}/api/threads/{thread_id}/state"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
tmp = requests.get(
url,
headers=headers
).json()
# print(tmp)
if "messages" in tmp["values"]:
tmp["values"]["messages"].append({"type": "human", "content": new_content})
else:
tmp["values"]["messages"]=[]
tmp["values"]["messages"].append({"type": "human", "content": new_content})
url = f"{BASE_URL}/api/threads/{thread_id}/state"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
response = requests.post(
url,
json=tmp,
headers=headers,
stream=True
)
del tmp
print(f"线程{thread_id}更新状态码: {response.status_code}")
def Broadcast_message(message,d_agent_thread_id,pingbi_agent_name):
for key in d_agent_thread_id:
if key not in pingbi_agent_name:
update_agent_message(d_agent_thread_id[key],message)
print("广播消息成功:",key,message)
null=None
true=True
false=False
async def leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
"""使用 httpx 异步请求的 leader_agent"""
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, "总控智能体收到用户消息:" + new_message, d_agent_thread_id, [agent_name])
thread_id = d_agent_thread_id[agent_name]
l = []
token = get_token("ji")
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
payload = {
"input": {
"messages": [
{
"type": "human",
"content": [
{
"type": "text",
"text": new_message
}
],
"additional_kwargs": {}
}
]
},
"config": {
"recursion_limit": 1000
},
"context": {
"agent_name": agent_name,
"model_name": "deepseek-chat",
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": true,
"is_plan_mode": false,
"subagent_enabled": false,
"thread_id": thread_id
},
"stream_mode": [
"messages-tuple",
"values",
],
"stream_subgraphs": true,
"stream_resumable": true,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": ["web_search"],
"skill_stop_names": skill_stop_names,
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", url, json=payload, headers=headers) as response:
print(f"状态码: {response.status_code}")
if response.status_code != 200:
body = await response.aread()
raise RuntimeError(
f"upstream {url} returned HTTP {response.status_code}: "
f"{body.decode('utf-8', 'replace')[:1000]}"
)
async for line in response.aiter_lines():
if line:
yield line
if line[:5] == "data:":
print(line)
l.append(line)
if len(l) < 2:
raise RuntimeError(
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
f"Check the server log on {url} for the real failure."
)
null = None
tmpres = eval(l[-2][5:])
tmpres = tmpres["messages"][-1]
tmp_task = []
if tmpres.get("tool_calls", []) == []:
yield {"status": []}
else:
for tool_call in tmpres.get("tool_calls", []):
try:
agentname, task_text = extract_name_task(str(tool_call["args"]))
print("agentname", agentname)
print("task_text:", task_text)
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, f"总控智能体给{agentname}派活,内容为:" + task_text, d_agent_thread_id, [agent_name, agentname])
tmp_task.append([agentname, task_text])
except:
pass
yield {"status": tmp_task}
async def special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
"""使用 httpx 异步请求的 special_agent"""
thread_id = d_agent_thread_id[agent_name]
l = []
token = get_token("ji")
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
payload = {
"input": {
"messages": [
{
"type": "human",
"content": [
{
"type": "text",
"text": new_message
}
],
"additional_kwargs": {}
}
]
},
"config": {
"recursion_limit": 1000
},
"context": {
"agent_name": agent_name,
"model_name": "deepseek-chat",
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": true,
"is_plan_mode": false,
"subagent_enabled": false,
"thread_id": thread_id
},
"stream_mode": [
"messages-tuple",
"values",
],
"stream_subgraphs": true,
"stream_resumable": true,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": ["web_search", "ask_clarification", "present_files", "view_image"],
"skill_stop_names": skill_stop_names,
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", url, json=payload, headers=headers) as response:
print(f"状态码: {response.status_code}")
if response.status_code != 200:
body = await response.aread()
raise RuntimeError(
f"upstream {url} returned HTTP {response.status_code}: "
f"{body.decode('utf-8', 'replace')[:1000]}"
)
async for line in response.aiter_lines():
if line:
yield line
if line[:5] == "data:":
print(line)
l.append(line)
if len(l) < 2:
raise RuntimeError(
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
f"Check the server log on {url} for the real failure."
)
null = None
tmpres = eval(l[-2][5:])
tmpres = tmpres["messages"][-1]
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + tmpres["content"], d_agent_thread_id, [agent_name])
yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
async def agent_init(agent_names, main_agent_name):
"""异步的 agent_init,同步函数调用使用 to_thread"""
agent_names = [i.lower() for i in agent_names]
# 使用 to_thread 异步运行同步的 create_main_agent
tmpd = await asyncio.to_thread(create_main_agent, main_agent_name, agent_names)
message = """
你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活
注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,你只关心于完成总控控制协调智能体分配的活即可
"""
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, message, tmpd, [main_agent_name])
return main_agent_name, tmpd
from fastapi import FastAPI, HTTPException,Body
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
import asyncio
import uvicorn
app = FastAPI()
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
import time
@app.post("/generate_mul_agent")
async def generate_stream(payload: dict = Body(...)):
"""
根据 payload 中的 agent_type 字段判断调用 leader_agent 还是 special_agent
payload 结构示例:
{
"agent_type": "leader", # 或 "special"
"d_agent_thread_id": {...},
"agent_name": "xxx",
"new_message": "xxx",
"skill_stop_names": [...]
}
"""
# 从 payload 中提取参数
agent_type = payload.get("agent_type", "leader") # 默认为 leader
d_agent_thread_id = payload.get("d_agent_thread_id", {})
agent_name = payload.get("agent_name", "")
new_message = payload.get("new_message", "")
skill_stop_names = payload.get("skill_stop_names", [])
# 流式响应生成器
async def stream_generator():
# 根据 agent_type 选择调用的函数
if agent_type == "leader":
async_generator = leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
else: # special
async_generator = special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
# 异步迭代生成器
async for chunk in async_generator:
if isinstance(chunk, dict):
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
else:
yield f"{chunk}"
# 使用 SSE 流式响应
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
)
@app.post("/agent_init")
async def init_agents(payload: dict = Body(...)):
"""
初始化智能体接口
payload 结构示例:
{
"agent_names": ["agent1", "agent2"],
"main_agent_name": "main-agent"
}
"""
agent_names = payload.get("agent_names", [])
main_agent_name = payload.get("main_agent_name", "main-agent")
# 调用异步的 agent_init
name, thread_id_dict = await agent_init(agent_names, main_agent_name)
return {
"status": "success",
"main_agent_name": name,
"thread_ids": thread_id_dict
}
if __name__ == "__main__":
uvicorn.run("testapi:app", host="0.0.0.0", port=8095, workers=1,limit_concurrency=1000) # 多进程提升并发能力
+483
View File
@@ -0,0 +1,483 @@
"""
测试流式接口的完整脚本
API流程:
1. 登录获取token
2. 创建thread(可选,也可以使用无状态接口)
3. 调用流式接口
两种方式:
- 方式1: POST /api/runs/stream (无状态,自动创建thread)
- 方式2: POST /api/threads/{thread_id}/runs/stream (需要先创建thread)
"""
import requests
import json
import asyncio
import httpx # 异步 HTTP 客户端
# ============ 配置区域 ============
BASE_URL = "http://127.0.0.1:8001"
LOGIN_URL = f"{BASE_URL}/api/v1/auth/login/username"
THREADS_URL = f"{BASE_URL}/api/threads"
RUNS_STREAM_URL = f"{BASE_URL}/api/runs/stream" # 无状态流式接口
# ============ 登录获取token ============
def get_token(username: str) -> str:
"""登录获取token"""
response = requests.post(
LOGIN_URL,
json={"username": username},
headers={"Content-Type": "application/json"}
)
# print(f"登录状态码: {response.status_code}")
data = response.json()
# print(f"登录响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
# 根据实际返回结构调整
token = data.get("token") or data.get("access_token") or data.get("data", {}).get("token")
return token
# ============ 方式2: 先创建thread,再调用流式接口 ============
def create_thread(token: str) -> str:
"""创建thread,返回thread_id"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
response = requests.post(
THREADS_URL,
json={}, # 空body,使用默认值
headers=headers
)
print(f"创建thread状态码: {response.status_code}")
data = response.json()
print(f"创建thread响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
return data.get("thread_id")
def get_agent_name_description(agent_name="Taiwan-intelligence-gathering-agent"):
url = f"{BASE_URL}/api/agents/{agent_name}"
token=get_token("ji")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
response = requests.get(
url,
headers=headers,
)
print(response.json())
return response.json()["name"].lower(),"agent_name:"+response.json()["name"].lower()+","+response.json()["description"]
# ============ 创建智能体 ============
def create_agent(
name: str,
description: str = "",
model: str | None = None,
tool_groups: list[str] | None = None,
skills: list[str] | None = None,
soul: str = "",
):
"""
创建一个新的智能体
Args:
name: 智能体名称(必须是 hyphen-case,如 my-agent)
description: 智能体描述
model: 可选的模型覆盖(如 gpt-4, claude-3-opus 等)
tool_groups: 工具组白名单(如 ["web", "file"])
skills: 技能白名单(如 ["skill1", "skill2"])
soul: SOUL.md 内容(智能体人格/身份描述)
Returns:
创建结果
"""
TOKEN =get_token("ji")
url = f"{BASE_URL}/api/agents"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}"
}
payload = {
"name": name,
"description": description,
"soul": soul
}
# 添加可选字段
if model is not None:
payload["model"] = model
if tool_groups is not None:
payload["tool_groups"] = tool_groups
if skills is not None:
payload["skills"] = skills
print(f"请求: POST {url}")
print(f"请求体: {json.dumps(payload, indent=2, ensure_ascii=False)}")
response = requests.post(url, json=payload, headers=headers)
print(f"状态码: {response.status_code}")
if response.status_code == 201:
result = response.json()
print(f"✓ 智能体创建成功!")
print(f"响应: {json.dumps(result, indent=2, ensure_ascii=False)}")
return result
else:
print(f"✗ 创建失败: {response.text}")
return f"✗ 创建失败: {response.text}"
def create_main_agent(zhihui_name,agent_names):
d={}
token=get_token("ji")
l=[]
for i in agent_names:
# print(i)
name,description=get_agent_name_description(i)
thread_id_tmp=create_thread(token)
d[name]=thread_id_tmp
l.append(description)
name="你是总控控制协调智能体,负责协调其他智能体干活"
miaoshu=f"""#核心定位:
用途:跟用用户的任务目标,调用其他智能体干活,只负责协调汇总,当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
#调度方法:
通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活
#可调度智能体:
{l}"""
thread_id_tmp=create_thread(token)
engname=zhihui_name
d[engname]=thread_id_tmp
tmp_skills=["agent_orchestration"]
tmpres=create_agent(
name=engname,
description=name,
model="glm-5", # 指定模型
skills=tmp_skills, # 关联技能
soul=miaoshu)
# print("创建智能体响应:",tmpres)
if "失败" in tmpres and "exists" not in tmpres:
return tmpres
print(f"创建协调智能体成功,可以协调列表:{l}")
return d
import re
def extract_name_task(text="python3 scripts/agent_orchestration.py \"Us-intelligence-gathering-agent\" \"收集和分析美国人民对特朗普的看法,包括支持率和反对率、不同群体的观点差异、主要支持理由和反对理由等\""):
try:
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
match = re.search(pattern, text)
if match:
agent_name = match.group(1)
task_content = match.group(2)
return agent_name,task_content
except:
return "",""
def update_agent_message(thread_id,new_content):
token=get_token("ji")
url = f"{BASE_URL}/api/threads/{thread_id}/state"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
tmp = requests.get(
url,
headers=headers
).json()
# print(tmp)
if "messages" in tmp["values"]:
tmp["values"]["messages"].append({"type": "human", "content": new_content})
else:
tmp["values"]["messages"]=[]
tmp["values"]["messages"].append({"type": "human", "content": new_content})
url = f"{BASE_URL}/api/threads/{thread_id}/state"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
response = requests.post(
url,
json=tmp,
headers=headers,
stream=True
)
del tmp
print(f"线程{thread_id}更新状态码: {response.status_code}")
def Broadcast_message(message,d_agent_thread_id,pingbi_agent_name):
for key in d_agent_thread_id:
if key not in pingbi_agent_name:
update_agent_message(d_agent_thread_id[key],message)
print("广播消息成功:",key,message)
null=None
true=True
false=False
async def leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
"""使用 httpx 异步请求的 leader_agent"""
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, "总控智能体收到用户消息:" + new_message, d_agent_thread_id, [agent_name])
thread_id = d_agent_thread_id[agent_name]
l = []
token = get_token("ji")
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
payload = {
"input": {
"messages": [
{
"type": "human",
"content": [
{
"type": "text",
"text": new_message
}
],
"additional_kwargs": {}
}
]
},
"config": {
"recursion_limit": 1000
},
"context": {
"agent_name": agent_name,
"model_name": "deepseek-chat",
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": true,
"is_plan_mode": false,
"subagent_enabled": false,
"thread_id": thread_id
},
"stream_mode": [
"messages-tuple",
"values",
],
"stream_subgraphs": true,
"stream_resumable": true,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": ["web_search"],
"skill_stop_names": skill_stop_names,
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", url, json=payload, headers=headers) as response:
print(f"状态码: {response.status_code}")
if response.status_code != 200:
body = await response.aread()
raise RuntimeError(
f"upstream {url} returned HTTP {response.status_code}: "
f"{body.decode('utf-8', 'replace')[:1000]}"
)
async for line in response.aiter_lines():
if line:
yield line
if line[:5] == "data:":
print(line)
l.append(line)
if len(l) < 2:
raise RuntimeError(
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
f"Check the server log on {url} for the real failure."
)
null = None
tmpres = eval(l[-2][5:])
tmpres = tmpres["messages"][-1]
tmp_task = []
if tmpres.get("tool_calls", []) == []:
yield {"status": []}
else:
for tool_call in tmpres.get("tool_calls", []):
try:
agentname, task_text = extract_name_task(str(tool_call["args"]))
print("agentname", agentname)
print("task_text:", task_text)
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, f"总控智能体给{agentname}派活,内容为:" + task_text, d_agent_thread_id, [agent_name, agentname])
tmp_task.append([agentname, task_text])
except:
pass
yield {"status": tmp_task}
async def special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names):
"""使用 httpx 异步请求的 special_agent"""
thread_id = d_agent_thread_id[agent_name]
l = []
token = get_token("ji")
url = f"{BASE_URL}/api/threads/{thread_id}/runs/stream"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
payload = {
"input": {
"messages": [
{
"type": "human",
"content": [
{
"type": "text",
"text": new_message
}
],
"additional_kwargs": {}
}
]
},
"config": {
"recursion_limit": 1000
},
"context": {
"agent_name": agent_name,
"model_name": "deepseek-chat",
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": true,
"is_plan_mode": false,
"subagent_enabled": false,
"thread_id": thread_id
},
"stream_mode": [
"messages-tuple",
"values",
],
"stream_subgraphs": true,
"stream_resumable": true,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": ["web_search", "ask_clarification", "present_files", "view_image"],
"skill_stop_names": skill_stop_names,
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", url, json=payload, headers=headers) as response:
print(f"状态码: {response.status_code}")
if response.status_code != 200:
body = await response.aread()
raise RuntimeError(
f"upstream {url} returned HTTP {response.status_code}: "
f"{body.decode('utf-8', 'replace')[:1000]}"
)
async for line in response.aiter_lines():
if line:
yield line
if line[:5] == "data:":
print(line)
l.append(line)
if len(l) < 2:
raise RuntimeError(
f"upstream stream produced no usable data lines (got {len(l)} line(s)). "
f"Check the server log on {url} for the real failure."
)
null = None
tmpres = eval(l[-2][5:])
tmpres = tmpres["messages"][-1]
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + tmpres["content"], d_agent_thread_id, [agent_name])
yield {"status": f"子智能体{agent_name}完成{new_message}工作"}
async def agent_init(agent_names, main_agent_name):
"""异步的 agent_init,同步函数调用使用 to_thread"""
agent_names = [i.lower() for i in agent_names]
# 使用 to_thread 异步运行同步的 create_main_agent
tmpd = await asyncio.to_thread(create_main_agent, main_agent_name, agent_names)
message = """
你的职责:你只是若干个智能体的其中一员,你们的共同目标是完成一个庞大的任务,总控控制协调智能体会分配特定的活给你,你只需要用你自身的能力来完成这个特定的活
注意:你只是一个子智能体,你不必把你的分析结果存放到文件里面去,你只关心于完成总控控制协调智能体分配的活即可
"""
# 使用 to_thread 异步运行同步的 Broadcast_message
await asyncio.to_thread(Broadcast_message, message, tmpd, [main_agent_name])
return main_agent_name, tmpd
from fastapi import FastAPI, HTTPException,Body
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
import asyncio
import uvicorn
app = FastAPI()
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
import time
@app.post("/generate_mul_agent")
async def generate_stream(payload: dict = Body(...)):
"""
根据 payload 中的 agent_type 字段判断调用 leader_agent 还是 special_agent
payload 结构示例:
{
"agent_type": "leader", # 或 "special"
"d_agent_thread_id": {...},
"agent_name": "xxx",
"new_message": "xxx",
"skill_stop_names": [...]
}
"""
# 从 payload 中提取参数
agent_type = payload.get("agent_type", "leader") # 默认为 leader
d_agent_thread_id = payload.get("d_agent_thread_id", {})
agent_name = payload.get("agent_name", "")
new_message = payload.get("new_message", "")
skill_stop_names = payload.get("skill_stop_names", [])
# 流式响应生成器
async def stream_generator():
# 根据 agent_type 选择调用的函数
if agent_type == "leader":
async_generator = leader_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
else: # special
async_generator = special_agent(d_agent_thread_id, agent_name, new_message, skill_stop_names)
# 异步迭代生成器
async for chunk in async_generator:
if isinstance(chunk, dict):
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
else:
yield f"{chunk}"
# 使用 SSE 流式响应
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
)
@app.post("/agent_init")
async def init_agents(payload: dict = Body(...)):
"""
初始化智能体接口
payload 结构示例:
{
"agent_names": ["agent1", "agent2"],
"main_agent_name": "main-agent"
}
"""
agent_names = payload.get("agent_names", [])
main_agent_name = payload.get("main_agent_name", "main-agent")
# 调用异步的 agent_init
name, thread_id_dict = await agent_init(agent_names, main_agent_name)
return {
"status": "success",
"main_agent_name": name,
"thread_ids": thread_id_dict
}
if __name__ == "__main__":
uvicorn.run("testapi:app", host="0.0.0.0", port=8095, workers=1,limit_concurrency=1000) # 多进程提升并发能力
Binary file not shown.
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
"""
OUT.py 的调用链 demo —— 跑通 agent_init → leader 派活 → 各 special agent 干活 这一整套流程。
前置条件:
1. DeerFlow 后端在 http://127.0.0.1:8001 在跑
2. OUT.py 已经 `python -m uvicorn OUT:app --host 0.0.0.0 --port 8095` 起好
3. 下面 SUB_AGENT_NAMES 里的 agent 已经在 DeerFlow 里创建好(/api/agents 能查到)
直接 `python demo_call.py` 即可。
"""
import asyncio
import json
import httpx
# ============ 改这里 ============
OUT_BASE = "http://127.0.0.1:8095"
# 已经在 DeerFlow 里建好的子 agent 名字 (会被 OUT.py 强制 lowercase)
SUB_AGENT_NAMES = [
"us-intelligence-gathering-agent",
"taiwan-intelligence-gathering-agent",
]
# 总控 agent 名字 —— OUT.py 会现场创建它,所以填一个新名字即可 (hyphen-case)
MAIN_AGENT_NAME = "demo-coordinator"
# 用户提问 —— 总控会基于此派活
USER_QUESTION = "对比中美两国 2024 年在 AI 监管政策上的差异"
# leader 阶段命中即停的 skill 名字单 (OUT.py 用 agent_orchestration 作为派活信号)
SKILL_STOP_NAMES = ["agent_orchestration"]
# ================================
async def call_agent_init() -> tuple[str, dict[str, str]]:
"""步骤 1: 初始化 —— 拿回 thread_ids 字典 {agent_name → thread_id}."""
print(">>> [1/3] POST /agent_init")
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{OUT_BASE}/agent_init",
json={
"agent_names": SUB_AGENT_NAMES,
"main_agent_name": MAIN_AGENT_NAME,
},
)
r.raise_for_status()
body = r.json()
main_name = body["main_agent_name"]
thread_ids = body["thread_ids"]
print(f" main_agent_name = {main_name}")
print(f" thread_ids = {json.dumps(thread_ids, ensure_ascii=False, indent=2)}")
return main_name, thread_ids
async def stream_generate(payload: dict) -> object | None:
"""通用 SSE 消费:边收边打印,最后返回 OUT.py 自己 yield 的 {'status': ...} 里的 status 字段."""
final_status = None
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", f"{OUT_BASE}/generate_mul_agent", json=payload) as resp:
print(f" HTTP {resp.status_code}")
if resp.status_code != 200:
err = await resp.aread()
raise RuntimeError(f"OUT.py /generate_mul_agent failed: {err.decode('utf-8', 'replace')[:500]}")
async for line in resp.aiter_lines():
if not line:
continue
# OUT.py 把 DeerFlow 的原始 SSE 流直接转发,自己最后会再 yield 一行
# `data: {"status": ...}\n\n`。两边都以 `data:` 开头,我们都尝试 JSON parse
# 一下,能解析成 {"status": ...} 的就是 OUT.py 的终态。
if line.startswith("data:"):
body_text = line[5:].strip()
try:
obj = json.loads(body_text)
except json.JSONDecodeError:
continue
if isinstance(obj, dict) and "status" in obj:
final_status = obj["status"]
# 想看上游 DeerFlow 的完整流,把下面打开即可
# else:
# print(" raw:", line[:200])
return final_status
async def call_leader(main_agent_name: str, thread_ids: dict[str, str]) -> list[list[str]]:
"""步骤 2: leader 阶段 —— 总控读完用户问题,产出派活清单."""
print(f"\n>>> [2/3] POST /generate_mul_agent (leader, agent={main_agent_name})")
status = await stream_generate(
{
"agent_type": "leader",
"d_agent_thread_id": thread_ids,
"agent_name": main_agent_name,
"new_message": USER_QUESTION,
"skill_stop_names": SKILL_STOP_NAMES,
}
)
dispatch = status or []
print(f" 派活清单 = {json.dumps(dispatch, ensure_ascii=False, indent=2)}")
return dispatch
async def call_specials(thread_ids: dict[str, str], dispatch: list[list[str]]) -> None:
"""步骤 3: 把派活清单里每一项交给对应子 agent 跑."""
if not dispatch:
print("\n>>> [3/3] 总控没派出任何任务,直接结束。")
return
for idx, item in enumerate(dispatch, 1):
agent_name, task_text = item[0], item[1]
print(f"\n>>> [3/3] ({idx}/{len(dispatch)}) POST /generate_mul_agent (special, agent={agent_name})")
print(f" task = {task_text[:120]}{'...' if len(task_text) > 120 else ''}")
result = await stream_generate(
{
"agent_type": "special",
"d_agent_thread_id": thread_ids,
"agent_name": agent_name,
"new_message": task_text,
"skill_stop_names": [],
}
)
print(f" 完成: {result}")
async def main() -> None:
main_agent_name, thread_ids = await call_agent_init()
dispatch = await call_leader(main_agent_name, thread_ids)
await call_specials(thread_ids, dispatch)
print("\n✅ demo 跑完")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,10 +1,10 @@
import asyncio
import logging
import os
import queue
import sys
import threading
from collections.abc import AsyncGenerator
import asyncio
import logging
import os
import queue
import sys
import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dotenv import load_dotenv
@@ -36,6 +36,7 @@ from app.gateway.routers import (
mcp,
memory,
models,
multi_agent,
notifications,
public_scheduled_tasks,
recommended_questions,
@@ -247,7 +248,7 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
async def _run_db_migrations() -> None:
"""Run Alembic migrations at startup so schema changes are applied automatically."""
from pathlib import Path
from pathlib import Path
from alembic import command
from alembic.config import Config
@@ -260,51 +261,51 @@ async def _run_db_migrations() -> None:
logger.info("Persistence backend=memory — skipping Alembic migrations")
return
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
return
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
def _run_sync() -> None:
try:
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")
except BaseException as exc:
result_queue.put(exc)
else:
result_queue.put(None)
try:
try:
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
except ValueError:
timeout_s = 60.0
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
worker.start()
try:
deadline = asyncio.get_running_loop().time() + timeout_s
while result_queue.empty():
if asyncio.get_running_loop().time() >= deadline:
raise asyncio.TimeoutError
await asyncio.sleep(0.2)
result = result_queue.get_nowait()
except asyncio.TimeoutError:
logger.error(
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
timeout_s,
)
return
if result is not None:
raise result
logger.info("Database migrations applied (head)")
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
return
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
def _run_sync() -> None:
try:
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")
except BaseException as exc:
result_queue.put(exc)
else:
result_queue.put(None)
try:
try:
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
except ValueError:
timeout_s = 60.0
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
worker.start()
try:
deadline = asyncio.get_running_loop().time() + timeout_s
while result_queue.empty():
if asyncio.get_running_loop().time() >= deadline:
raise asyncio.TimeoutError
await asyncio.sleep(0.2)
result = result_queue.get_nowait()
except asyncio.TimeoutError:
logger.error(
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
timeout_s,
)
return
if result is not None:
raise result
logger.info("Database migrations applied (head)")
except Exception:
logger.exception("Database migration failed — schema may be out of date")
@@ -860,6 +861,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
app.include_router(multi_agent.router)
@app.get("/health", tags=["health"])
async def health_check() -> dict:
"""Health check endpoint.
@@ -60,6 +60,10 @@ class AgentsListResponse(BaseModel):
class AgentCreateRequest(BaseModel):
"""Request body for creating a custom agent."""
id: str | None = Field(
default=None,
description="Optional explicit storage id. Must match the storage id pattern. If omitted, a UUID is generated.",
)
name: str = Field(..., description="Display name; Chinese and duplicate names are allowed")
description: str = Field(default="", description="Agent description")
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
@@ -411,13 +415,19 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
_require_agents_api_enabled()
name = _validate_display_name(request.name)
agent_id = uuid4().hex
store = get_agent_store(http_request)
if request.id:
agent_id = _validate_storage_id(request.id)
if await store.get_any(agent_id) is not None:
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
else:
agent_id = uuid4().hex
agent_dir = get_paths().agent_dir(agent_id)
try:
store = get_agent_store(http_request)
if agent_dir.exists():
raise HTTPException(status_code=409, detail=f"Generated agent id '{agent_id}' already exists")
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
_write_agent_config(
agent_id,
@@ -0,0 +1,471 @@
"""Multi-agent orchestration endpoints.
Wraps the legacy ``newpython/OUT.py`` demo into the Gateway. Provides:
* ``POST /api/multi-agent/init`` — create a coordinator agent and per-sub-agent
threads. Returns the ``{agent_name: thread_id}`` map the client passes back
on subsequent calls.
* ``POST /api/multi-agent/run/stream`` — drive a *leader* or *special* agent
via SSE. The leader dispatches work to sub-agents through the
``agent_orchestration`` skill; sub-agents execute and broadcast their result.
Internal calls loop back to the same Gateway over HTTP, reusing the caller's
``Authorization`` header — so the demo's hard-coded ``get_token("ji")`` is gone.
The model used by both the coordinator agent and every streamed run is taken
from the request's ``model`` field.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
# ---------------------------------------------------------------------------
# Loopback helpers
# ---------------------------------------------------------------------------
def _loopback_base(request: Request) -> str:
return f"{request.url.scheme}://{request.url.netloc}"
def _auth_headers(request: Request) -> dict[str, str]:
token = request.headers.get("authorization")
if not token:
raise HTTPException(status_code=401, detail="Missing Authorization header")
return {"Authorization": token, "Content-Type": "application/json"}
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
resp.raise_for_status()
return resp.json()["thread_id"]
async def _get_agent(client: httpx.AsyncClient, base: str, headers: dict[str, str], agent_id: str) -> dict[str, Any]:
resp = await client.get(f"{base}/api/agents/{agent_id}", headers=headers)
resp.raise_for_status()
return resp.json()
async def _create_agent(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
*,
name: str,
description: str,
model: str | None,
skills: list[str] | None,
soul: str,
agent_id: str | None = None,
) -> tuple[bool, dict[str, Any] | str]:
payload: dict[str, Any] = {"name": name, "description": description, "soul": soul}
if agent_id is not None:
payload["id"] = agent_id
if model is not None:
payload["model"] = model
if skills is not None:
payload["skills"] = skills
resp = await client.post(f"{base}/api/agents", json=payload, headers=headers)
if resp.status_code == 201:
return True, resp.json()
return False, resp.text
async def _append_thread_message(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
thread_id: str,
content: str,
) -> None:
state_url = f"{base}/api/threads/{thread_id}/state"
get_resp = await client.get(state_url, headers=headers)
if get_resp.status_code != 200:
logger.warning("get state %s failed: %s %s", thread_id, get_resp.status_code, get_resp.text[:200])
return
state = get_resp.json()
values = state.setdefault("values", {})
messages = values.setdefault("messages", [])
if not isinstance(messages, list):
messages = []
values["messages"] = messages
messages.append({"type": "human", "content": content})
post_resp = await client.post(state_url, json=state, headers=headers)
if post_resp.status_code >= 400:
logger.warning("update state %s failed: %s %s", thread_id, post_resp.status_code, post_resp.text[:200])
async def _broadcast(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
message: str,
agent_threads: dict[str, str],
skip: list[str],
) -> None:
targets = [tid for name, tid in agent_threads.items() if name not in skip]
if not targets:
return
await asyncio.gather(
*(_append_thread_message(client, base, headers, tid, message) for tid in targets),
return_exceptions=True,
)
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
def _extract_name_task(text: str) -> tuple[str, str]:
match = _TOOL_CALL_PATTERN.search(text)
if match:
return match.group(1), match.group(2)
return "", ""
def _flatten_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
parts.append(str(item.get("text", "")))
else:
parts.append(str(item))
return "".join(parts)
return str(content)
def _run_payload(
*,
agent_name: str,
model_name: str,
thread_id: str,
new_message: str,
excluded_tools: list[str],
skill_stop_names: list[str],
) -> dict[str, Any]:
return {
"input": {
"messages": [
{
"type": "human",
"content": [{"type": "text", "text": new_message}],
"additional_kwargs": {},
}
]
},
"config": {"recursion_limit": 1000},
"context": {
"agent_name": agent_name,
"model_name": model_name,
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": True,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": thread_id,
},
"stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True,
"stream_resumable": True,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": excluded_tools,
"skill_stop_names": skill_stop_names,
}
# ---------------------------------------------------------------------------
# Init endpoint
# ---------------------------------------------------------------------------
@router.post("/init")
async def init_multi_agent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
agent_names_in = payload.get("agent_names") or []
agent_names: list[str] = [str(name).lower() for name in agent_names_in]
main_agent_name: str = str(payload.get("main_agent_name") or "main-agent").lower()
model: str | None = payload.get("model")
if not agent_names:
raise HTTPException(status_code=400, detail="agent_names is required")
base = _loopback_base(request)
headers = _auth_headers(request)
async with httpx.AsyncClient(timeout=None) as client:
# Optimization A: fetch every sub-agent's metadata and create its
# thread concurrently. With 6 seats this turns ~12 sequential
# loopback round-trips into one wave of 12 parallel ones.
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str]:
info = await _get_agent(client, base, headers, sub_id)
sub_name = str(info["name"]).lower()
thread_id = await _create_thread(client, base, headers)
return sub_name, thread_id, info.get("description", "") or ""
prepared = await asyncio.gather(*(_prepare_sub_agent(sid) for sid in agent_names))
agent_threads: dict[str, str] = {sub_name: thread_id for sub_name, thread_id, _ in prepared}
descriptions: list[str] = [
f"agent_name:{sub_name},{description}" for sub_name, _, description in prepared
]
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活\n"
"#可调度智能体:\n"
f"{descriptions}"
)
agent_threads[main_agent_name] = await _create_thread(client, base, headers)
ok, result = await _create_agent(
client,
base,
headers,
name=main_agent_name,
description="你是总控控制协调智能体,负责协调其他智能体干活",
model=model,
skills=["agent_orchestration"],
soul=soul,
agent_id=main_agent_name,
)
if not ok and isinstance(result, str) and "exists" not in result:
raise HTTPException(status_code=500, detail=f"create coordinator agent failed: {result}")
# Optimization C: the sub-agent SOULs already declare "你只在被点到时
# 发言,由总控协调智能体派活", so the old intro broadcast (one human
# message per thread, 6 × GET-state + 6 × POST-state) is redundant.
# Skipping it removes the slowest stage of init entirely.
return {
"status": "success",
"main_agent_name": main_agent_name,
"thread_ids": agent_threads,
}
# ---------------------------------------------------------------------------
# Stream endpoint
# ---------------------------------------------------------------------------
async def _stream_upstream(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
thread_id: str,
body: dict[str, Any],
) -> AsyncIterator[tuple[str | None, list[str] | None]]:
"""Yield (raw_line, None) for each SSE line, then ``(None, captured_data_lines)`` once."""
url = f"{base}/api/threads/{thread_id}/runs/stream"
captured: list[str] = []
async with client.stream("POST", url, json=body, headers=headers) as resp:
if resp.status_code != 200:
body_text = (await resp.aread()).decode("utf-8", "replace")[:1000]
raise HTTPException(
status_code=502,
detail=f"upstream {url} returned HTTP {resp.status_code}: {body_text}",
)
async for line in resp.aiter_lines():
if not line:
continue
yield line, None
if line.startswith("data:"):
captured.append(line)
yield None, captured
def _parse_last_message(captured: list[str]) -> dict[str, Any]:
if len(captured) < 2:
raise HTTPException(
status_code=502,
detail=f"upstream produced no usable data lines (got {len(captured)})",
)
raw = captured[-2][5:].strip()
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=502, detail=f"failed to parse upstream frame: {exc}") from exc
messages = parsed.get("messages") or []
if not messages:
return {}
return messages[-1] or {}
async def _leader_run(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_threads: dict[str, str],
agent_name: str,
new_message: str,
skill_stop_names: list[str],
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
await _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
thread_id = agent_threads[agent_name]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search"],
skill_stop_names=skill_stop_names,
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
last_msg = _parse_last_message(captured)
leader_text = _flatten_content(last_msg.get("content", ""))
tool_calls = last_msg.get("tool_calls") or []
if not tool_calls:
yield {"status": [], "content": leader_text, "agent_name": agent_name}
return
dispatched: list[list[str]] = []
for tool_call in tool_calls:
try:
sub_name, task_text = _extract_name_task(str(tool_call.get("args", "")))
if not sub_name:
continue
await _broadcast(
client,
base,
headers,
f"总控智能体给{sub_name}派活,内容为:" + task_text,
agent_threads,
[agent_name, sub_name],
)
dispatched.append([sub_name, task_text])
except Exception as exc:
logger.warning("dispatch failed: %s", exc)
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
async def _special_run(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_threads: dict[str, str],
agent_name: str,
new_message: str,
skill_stop_names: list[str],
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
thread_id = agent_threads[agent_name]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image"],
skill_stop_names=skill_stop_names,
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
last_msg = _parse_last_message(captured)
content = _flatten_content(last_msg.get("content", ""))
await _broadcast(
client,
base,
headers,
f"子智能体{agent_name}完成{new_message}工作,交付内容为:" + content,
agent_threads,
[agent_name],
)
yield {
"status": f"子智能体{agent_name}完成{new_message}工作",
"content": content,
"agent_name": agent_name,
}
@router.post("/run/stream")
async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
agent_type: str = str(payload.get("agent_type") or "leader").lower()
agent_threads: dict[str, str] = payload.get("d_agent_thread_id") or {}
agent_name: str = str(payload.get("agent_name") or "").lower()
new_message: str = str(payload.get("new_message") or "")
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
model_name: str = str(payload.get("model") or "deepseek-chat")
if not agent_name or agent_name not in agent_threads:
raise HTTPException(
status_code=400,
detail=f"agent_name '{agent_name}' is missing from d_agent_thread_id",
)
base = _loopback_base(request)
headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]:
async with httpx.AsyncClient(timeout=None) as client:
try:
if agent_type == "leader":
gen = _leader_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
)
else:
gen = _special_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
)
async for chunk in gen:
if isinstance(chunk, dict):
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
else:
yield chunk if chunk.endswith("\n") else chunk + "\n"
except HTTPException as exc:
logger.warning("multi-agent stream HTTPException: %s %s", exc.status_code, exc.detail)
err_frame = {
"status": "error",
"content": f"[{exc.status_code}] {exc.detail}",
"agent_name": agent_name,
}
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.exception("multi-agent stream unexpected failure")
err_frame = {
"status": "error",
"content": f"unexpected: {exc!r}",
"agent_name": agent_name,
}
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@@ -54,6 +54,9 @@ class RunCreateRequest(BaseModel):
after_seconds: float | None = Field(default=None, description="Delayed execution")
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
excluded_tools: list[str] | None = Field(default=None, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
skill_stop_names: list[str] | None = Field(default=None, description="Skill names to stop execution when invoked (e.g. ['newsletter-generation'])")
skill_stop_message: str | None = Field(default=None, description="Custom message to display when skill stop is triggered")
class RunResponse(BaseModel):
@@ -162,6 +162,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"max_concurrent_subagents",
"agent_name",
"is_bootstrap",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
}
)
@@ -434,7 +437,15 @@ async def start_run(
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None))
body_context = dict(getattr(body, "context", None) or {})
# Promote whitelisted top-level body fields into the context map so
# callers can pass them either nested under ``context`` or as flat
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
for top_level_key in ("excluded_tools", "skill_stop_names", "skill_stop_message"):
value = getattr(body, top_level_key, None)
if value is not None and top_level_key not in body_context:
body_context[top_level_key] = value
merge_run_context_overrides(config, body_context or None)
stream_modes = normalize_stream_modes(body.stream_mode)
+5 -2
View File
@@ -132,8 +132,11 @@ agents_api:
enabled: true
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
# TODO: 临时切到 SQLite,等 192.168.0.123 MySQL 恢复后改回 backend: mysql
backend: sqlite
sqlite_dir: .deer-flow/data
# backend: mysql
# mysql_url: $MYSQL_DATABASE_URL
pool_size: 10
echo_sql: false
@@ -312,6 +312,37 @@ def _build_middlewares(
middlewares.append(SetupAgentApprovalMiddleware())
# SkillStopMiddleware — intercepts skill invocations and halts execution
# when a configured skill is about to run. Request-level options
# (skill_stop_names / skill_stop_message) take precedence; static
# config in ``app_config.skill_stop`` is the fallback.
skill_stop_names = cfg.get("skill_stop_names")
skill_stop_message = cfg.get("skill_stop_message")
if skill_stop_names:
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
middlewares.append(
SkillStopMiddleware(
skill_names=skill_stop_names,
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
stop_message=skill_stop_message,
stop_on_match=True,
)
)
logger.info("SkillStopMiddleware enabled (from request): %s", skill_stop_names)
elif resolved_app_config.skill_stop.enabled and resolved_app_config.skill_stop.skill_names:
from deerflow.agents.middlewares.skill_stop_middleware import SkillStopMiddleware
middlewares.append(
SkillStopMiddleware(
skill_names=resolved_app_config.skill_stop.skill_names,
skills_root=resolved_app_config.skills.container_path or "/mnt/skills",
stop_message=resolved_app_config.skill_stop.stop_message,
stop_on_match=resolved_app_config.skill_stop.stop_on_match,
)
)
logger.info("SkillStopMiddleware enabled (from config): %s", resolved_app_config.skill_stop.skill_names)
# ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware())
return middlewares
@@ -345,6 +376,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = cfg.get("excluded_tools") or None
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
@@ -415,6 +447,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
model_name=model_name,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
)
+ [setup_agent],
@@ -440,6 +473,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
),
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
@@ -0,0 +1,144 @@
"""Middleware for intercepting skill calls and stopping execution when specific skills are invoked."""
import logging
import re
from collections.abc import Callable
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command, interrupt
logger = logging.getLogger(__name__)
def extract_name_task(text: str) -> tuple[str, str]:
"""Extract `agent_name` and `task_content` from a bash invocation that triggers
a sub-agent via ``python3 scripts/agent_orchestration.py "<agent>" "<task>"``.
Returns empty strings when no match is found so the caller can treat the
absence of a match as "not a target skill call".
"""
try:
pattern = r'\.py\s+"([^"]*)"\s+"([^"]*)"'
match = re.search(pattern, text)
if match:
return match.group(1), match.group(2)
return "", ""
except Exception:
return "", ""
class SkillStopMiddlewareState(AgentState):
"""Compatible with the `ThreadState` schema."""
pass
class SkillStopMiddleware(AgentMiddleware[SkillStopMiddlewareState]):
"""Intercepts skill file reads and stops execution when specific skills are invoked.
When the model reads a file from a skill directory that matches one of the
configured skill names, this middleware:
1. Intercepts the tool call before execution
2. Returns a Command that stops execution (goes to END)
3. Optionally returns a message indicating which skill was triggered
This is useful for implementing "skill triggers" that pause the agent
when certain skills are about to be used.
"""
state_schema = SkillStopMiddlewareState
def __init__(
self,
*,
skill_names: list[str],
skills_root: str = "/mnt/skills",
stop_message: str | None = None,
stop_on_match: bool = True,
):
"""Initialize the skill stop middleware.
Args:
skill_names: List of skill names to intercept (e.g., ["newsletter-generation", "podcast-generation"]).
skills_root: Root directory where skills are located (default: "/mnt/skills").
stop_message: Optional message to return when stopping. If None, a default message is used.
stop_on_match: If True, stop when skill matches. If False, continue execution (for logging only).
"""
super().__init__()
self._skill_names = set(skill_names)
self._skills_root = skills_root.rstrip("/")
self._stop_message = stop_message
self._stop_on_match = stop_on_match
def _is_target_skill_call(self, tool_call: dict[str, Any]) -> str | None:
"""Return the matched skill name if this tool call invokes one of the
configured target skills, otherwise ``None``.
Matching today is bash-based: the lead agent reaches into a skill by
running ``python3 scripts/agent_orchestration.py "<skill-name>" "<task>"``,
so we only look at ``bash`` calls and extract the skill name from the
argv-style quoted form.
"""
name = tool_call.get("name") or ""
skill_name = ""
if name == "bash" and "args" in tool_call:
skill_name, _ = extract_name_task(str(tool_call["args"]))
if not skill_name:
return None
lowered_targets = {s.lower() for s in self._skill_names}
if skill_name in self._skill_names or skill_name.lower() in lowered_targets:
logger.info("SkillStopMiddleware: matched skill_name=%s", skill_name)
return skill_name
return None
def _handle_skill_stop(self, request: ToolCallRequest, skill_name: str) -> Command:
"""Build the stop response — an interrupt that includes a ToolMessage
describing which skill was triggered."""
logger.info("Skill stop triggered: skill_name=%s", skill_name)
tool_call_id = request.tool_call.get("id", "")
message_content = self._stop_message or f"⏹️ Skill '{skill_name}' triggered. Execution stopped."
tool_message = ToolMessage(
id=f"skill_stop:{tool_call_id}",
content=message_content,
tool_call_id=tool_call_id,
name="skill_stop",
)
return interrupt(
{
"messages": [tool_message],
"skill_name": skill_name,
}
)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
matched_skill = self._is_target_skill_call(request.tool_call)
if matched_skill:
if self._stop_on_match:
return self._handle_skill_stop(request, matched_skill)
logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
return handler(request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
matched_skill = self._is_target_skill_call(request.tool_call)
if matched_skill:
if self._stop_on_match:
return self._handle_skill_stop(request, matched_skill)
logger.info("Skill detected (not stopping): skill_name=%s", matched_skill)
return await handler(request)
@@ -22,6 +22,7 @@ from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
from deerflow.config.skill_stop_config import SkillStopConfig
from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
@@ -117,6 +118,8 @@ class AppConfig(BaseModel):
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
excluded_tools: list[str] = Field(default_factory=list, description="Tool names to exclude from the main agent (e.g. ['task', 'ask_clarification'])")
skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration")
model_config = ConfigDict(extra="allow")
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
@@ -0,0 +1,31 @@
"""Configuration for skill stop middleware."""
from pydantic import BaseModel, Field
class SkillStopConfig(BaseModel):
"""Configuration for the skill stop middleware.
When enabled, the middleware will intercept skill invocations and stop
execution when a matching skill is about to be used.
"""
enabled: bool = Field(
default=False,
description="Enable skill stop middleware. When True, execution will stop when specified skills are invoked.",
)
skill_names: list[str] = Field(
default_factory=list,
description="List of skill names to intercept. When the model reads a file from these skill directories, execution will stop.",
)
stop_message: str | None = Field(
default=None,
description="Custom message to display when stopping. If None, a default message is used.",
)
stop_on_match: bool = Field(
default=True,
description="If True, stop execution when skill matches. If False, only log the match without stopping.",
)
@@ -39,6 +39,7 @@ def get_available_tools(
model_name: str | None = None,
subagent_enabled: bool = False,
is_scheduled_run: bool = False,
excluded_tools: list[str] | None = None,
*,
app_config: AppConfig | None = None,
) -> list[BaseTool]:
@@ -52,6 +53,9 @@ def get_available_tools(
include_mcp: Whether to include tools from MCP servers (default: True).
model_name: Optional model name to determine if vision tools should be included.
subagent_enabled: Whether to include subagent tools (task, task_status).
excluded_tools: Optional list of tool names to exclude from the result
(applied AFTER all other resolution). Takes precedence over
``app_config.excluded_tools``.
Returns:
List of available tools.
@@ -209,4 +213,19 @@ def get_available_tools(
"Duplicate tool name %r detected and skipped — check your config.yaml and MCP server registrations (issue #1803).",
t.name,
)
# Drop tools the caller (or config) wants excluded. Request-level
# ``excluded_tools`` wins; the config-level list is the static fallback.
effective_excluded = excluded_tools if excluded_tools else getattr(config, "excluded_tools", None) or []
if effective_excluded:
excluded_set = set(effective_excluded)
filtered_tools = [t for t in unique_tools if t.name not in excluded_set]
if len(filtered_tools) < len(unique_tools):
logger.info(
"Excluded %d tool(s) by request/config: %s",
len(unique_tools) - len(filtered_tools),
effective_excluded,
)
unique_tools = filtered_tools
return unique_tools
+10
View File
@@ -0,0 +1,10 @@
window.environment = {
b1ConsumerUrl: "http://127.0.0.1:8001/consumer", // http://10.106.71.194:8013/
// b1ConsumerUrl: "http://localhost:8004/consumer", // http://10.106.71.194:8013/
aImagePath: "https://present.a.sit.4.cn/gene", // https://present.a.uat.4.cn present.a.sit.4.cn
g6ImgUrl: "/src/assets",
mapUrl: "https://present.a.sit.4.cn/tiles/{z}/{x}/{y}.jpg",
b1NodeServerUrl: "https://ch1.b.uat.4.cn",
b5ReplyUrl:"https://pg.b.uat.4.cn"
};
@@ -0,0 +1,134 @@
//! moment.js locale configuration
;(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
&& typeof require === 'function' ? factory(require('../moment')) :
typeof define === 'function' && define.amd ? define(['../moment'], factory) :
factory(global.moment)
}(this, (function (moment) { 'use strict';
var symbolMap = {
'1': '١',
'2': '٢',
'3': '٣',
'4': '٤',
'5': '٥',
'6': '٦',
'7': '٧',
'8': '٨',
'9': '٩',
'0': '٠'
}, numberMap = {
'١': '1',
'٢': '2',
'٣': '3',
'٤': '4',
'٥': '5',
'٦': '6',
'٧': '7',
'٨': '8',
'٩': '9',
'٠': '0'
}, pluralForm = function (n) {
return n === 0 ? 0 : n === 1 ? 1 : n === 2 ? 2 : n % 100 >= 3 && n % 100 <= 10 ? 3 : n % 100 >= 11 ? 4 : 5;
}, plurals = {
s : ['أقل من ثانية', 'ثانية واحدة', ['ثانيتان', 'ثانيتين'], '%d ثوان', '%d ثانية', '%d ثانية'],
m : ['أقل من دقيقة', 'دقيقة واحدة', ['دقيقتان', 'دقيقتين'], '%d دقائق', '%d دقيقة', '%d دقيقة'],
h : ['أقل من ساعة', 'ساعة واحدة', ['ساعتان', 'ساعتين'], '%d ساعات', '%d ساعة', '%d ساعة'],
d : ['أقل من يوم', 'يوم واحد', ['يومان', 'يومين'], '%d أيام', '%d يومًا', '%d يوم'],
M : ['أقل من شهر', 'شهر واحد', ['شهران', 'شهرين'], '%d أشهر', '%d شهرا', '%d شهر'],
y : ['أقل من عام', 'عام واحد', ['عامان', 'عامين'], '%d أعوام', '%d عامًا', '%d عام']
}, pluralize = function (u) {
return function (number, withoutSuffix, string, isFuture) {
var f = pluralForm(number),
str = plurals[u][pluralForm(number)];
if (f === 2) {
str = str[withoutSuffix ? 0 : 1];
}
return str.replace(/%d/i, number);
};
}, months = [
'يناير',
'فبراير',
'مارس',
'أبريل',
'مايو',
'يونيو',
'يوليو',
'أغسطس',
'سبتمبر',
'أكتوبر',
'نوفمبر',
'ديسمبر'
];
var ar = moment.defineLocale('ar', {
months : months,
monthsShort : months,
weekdays : 'الأحد_الإثنين_الثلاثاء_الأربعاء_الخميس_الجمعة_السبت'.split('_'),
weekdaysShort : 'أحد_إثنين_ثلاثاء_أربعاء_خميس_جمعة_سبت'.split('_'),
weekdaysMin : 'ح_ن_ث_ر_خ_ج_س'.split('_'),
weekdaysParseExact : true,
longDateFormat : {
LT : 'HH:mm',
LTS : 'HH:mm:ss',
L : 'D/\u200FM/\u200FYYYY',
LL : 'D MMMM YYYY',
LLL : 'D MMMM YYYY HH:mm',
LLLL : 'dddd D MMMM YYYY HH:mm'
},
meridiemParse: /ص|م/,
isPM : function (input) {
return 'م' === input;
},
meridiem : function (hour, minute, isLower) {
if (hour < 12) {
return 'ص';
} else {
return 'م';
}
},
calendar : {
sameDay: '[اليوم عند الساعة] LT',
nextDay: '[غدًا عند الساعة] LT',
nextWeek: 'dddd [عند الساعة] LT',
lastDay: '[أمس عند الساعة] LT',
lastWeek: 'dddd [عند الساعة] LT',
sameElse: 'L'
},
relativeTime : {
future : 'بعد %s',
past : 'منذ %s',
s : pluralize('s'),
ss : pluralize('s'),
m : pluralize('m'),
mm : pluralize('m'),
h : pluralize('h'),
hh : pluralize('h'),
d : pluralize('d'),
dd : pluralize('d'),
M : pluralize('M'),
MM : pluralize('M'),
y : pluralize('y'),
yy : pluralize('y')
},
preparse: function (string) {
return string.replace(/[١٢٣٤٥٦٧٨٩٠]/g, function (match) {
return numberMap[match];
}).replace(/،/g, ',');
},
postformat: function (string) {
return string.replace(/\d/g, function (match) {
return symbolMap[match];
}).replace(/,/g, '،');
},
week : {
dow : 6, // Saturday is the first day of the week.
doy : 12 // The week that contains Jan 12th is the first week of the year.
}
});
return ar;
})));
@@ -0,0 +1,67 @@
//! moment.js locale configuration
;(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
&& typeof require === 'function' ? factory(require('../moment')) :
typeof define === 'function' && define.amd ? define(['../moment'], factory) :
factory(global.moment)
}(this, (function (moment) { 'use strict';
var sq = moment.defineLocale('sq', {
months : 'Janar_Shkurt_Mars_Prill_Maj_Qershor_Korrik_Gusht_Shtator_Tetor_Nëntor_Dhjetor'.split('_'),
monthsShort : 'Jan_Shk_Mar_Pri_Maj_Qer_Kor_Gus_Sht_Tet_Nën_Dhj'.split('_'),
weekdays : 'E Diel_E Hënë_E Martë_E Mërkurë_E Enjte_E Premte_E Shtunë'.split('_'),
weekdaysShort : 'Die_Hën_Mar_Mër_Enj_Pre_Sht'.split('_'),
weekdaysMin : 'D_H_Ma_Më_E_P_Sh'.split('_'),
weekdaysParseExact : true,
meridiemParse: /PD|MD/,
isPM: function (input) {
return input.charAt(0) === 'M';
},
meridiem : function (hours, minutes, isLower) {
return hours < 12 ? 'PD' : 'MD';
},
longDateFormat : {
LT : 'HH:mm',
LTS : 'HH:mm:ss',
L : 'DD/MM/YYYY',
LL : 'D MMMM YYYY',
LLL : 'D MMMM YYYY HH:mm',
LLLL : 'dddd, D MMMM YYYY HH:mm'
},
calendar : {
sameDay : '[Sot në] LT',
nextDay : '[Nesër në] LT',
nextWeek : 'dddd [në] LT',
lastDay : '[Dje në] LT',
lastWeek : 'dddd [e kaluar në] LT',
sameElse : 'L'
},
relativeTime : {
future : 'në %s',
past : '%s më parë',
s : 'disa sekonda',
ss : '%d sekonda',
m : 'një minutë',
mm : '%d minuta',
h : 'një orë',
hh : '%d orë',
d : 'një ditë',
dd : '%d ditë',
M : 'një muaj',
MM : '%d muaj',
y : 'një vit',
yy : '%d vite'
},
dayOfMonthOrdinalParse: /\d{1,2}\./,
ordinal : '%d.',
week : {
dow : 1, // Monday is the first day of the week.
doy : 4 // The week that contains Jan 4th is the first week of the year.
}
});
return sq;
})));
@@ -0,0 +1,93 @@
;(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
&& typeof require === 'function' ? factory(require('../moment')) :
typeof define === 'function' && define.amd ? define(['../moment'], factory) :
factory(global.moment)
}(this, (function (moment) { 'use strict';
var suffixes = {
1: '\'inci',
5: '\'inci',
8: '\'inci',
70: '\'inci',
80: '\'inci',
2: '\'nci',
7: '\'nci',
20: '\'nci',
50: '\'nci',
3: '\'üncü',
4: '\'üncü',
100: '\'üncü',
6: '\'ncı',
9: '\'uncu',
10: '\'uncu',
30: '\'uncu',
60: '\'ıncı',
90: '\'ıncı'
};
var tr = moment.defineLocale('tr', {
months : 'Ocak_Şubat_Mart_Nisan_Mayıs_Haziran_Temmuz_Ağustos_Eylül_Ekim_Kasım_Aralık'.split('_'),
monthsShort : 'Oca_Şub_Mar_Nis_May_Haz_Tem_Ağu_Eyl_Eki_Kas_Ara'.split('_'),
weekdays : 'Pazar_Pazartesi_Salı_Çarşamba_Perşembe_Cuma_Cumartesi'.split('_'),
weekdaysShort : 'Paz_Pts_Sal_Çar_Per_Cum_Cts'.split('_'),
weekdaysMin : 'Pz_Pt_Sa_Ça_Pe_Cu_Ct'.split('_'),
longDateFormat : {
LT : 'HH:mm',
LTS : 'HH:mm:ss',
L : 'DD.MM.YYYY',
LL : 'D MMMM YYYY',
LLL : 'D MMMM YYYY HH:mm',
LLLL : 'dddd, D MMMM YYYY HH:mm'
},
calendar : {
sameDay : '[bugün saat] LT',
nextDay : '[yarın saat] LT',
nextWeek : '[gelecek] dddd [saat] LT',
lastDay : '[dün] LT',
lastWeek : '[geçen] dddd [saat] LT',
sameElse : 'L'
},
relativeTime : {
future : '%s sonra',
past : '%s önce',
s : 'birkaç saniye',
ss : '%d saniye',
m : 'bir dakika',
mm : '%d dakika',
h : 'bir saat',
hh : '%d saat',
d : 'bir gün',
dd : '%d gün',
M : 'bir ay',
MM : '%d ay',
y : 'bir yıl',
yy : '%d yıl'
},
ordinal: function (number, period) {
switch (period) {
case 'd':
case 'D':
case 'Do':
case 'DD':
return number;
default:
if (number === 0) { // special case for zero
return number + '\'ıncı';
}
var a = number % 10,
b = number % 100 - a,
c = number >= 100 ? 100 : null;
return number + (suffixes[a] || suffixes[b] || suffixes[c]);
}
},
week : {
dow : 1, // Monday is the first day of the week.
doy : 7 // The week that contains Jan 7th is the first week of the year.
}
});
return tr;
})));
@@ -0,0 +1 @@
!function(e,a){"object"==typeof exports&&"undefined"!=typeof module?module.exports=a():"function"==typeof define&&define.amd?define(a):e.moment=a()}(this,function(){"use strict";var e,n;function l(){return e.apply(null,arguments)}function _(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function i(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function o(e){return void 0===e}function m(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function u(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function M(e,a){var t,s=[];for(t=0;t<e.length;++t)s.push(a(e[t],t));return s}function h(e,a){return Object.prototype.hasOwnProperty.call(e,a)}function L(e,a){for(var t in a)h(a,t)&&(e[t]=a[t]);return h(a,"toString")&&(e.toString=a.toString),h(a,"valueOf")&&(e.valueOf=a.valueOf),e}function c(e,a,t,s){return Sa(e,a,t,s,!0).utc()}function Y(e){return null==e._pf&&(e._pf={empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1,parsedDateParts:[],meridiem:null,rfc2822:!1,weekdayMismatch:!1}),e._pf}function y(e){if(null==e._isValid){var a=Y(e),t=n.call(a.parsedDateParts,function(e){return null!=e}),s=!isNaN(e._d.getTime())&&a.overflow<0&&!a.empty&&!a.invalidMonth&&!a.invalidWeekday&&!a.weekdayMismatch&&!a.nullInput&&!a.invalidFormat&&!a.userInvalidated&&(!a.meridiem||a.meridiem&&t);if(e._strict&&(s=s&&0===a.charsLeftOver&&0===a.unusedTokens.length&&void 0===a.bigHour),null!=Object.isFrozen&&Object.isFrozen(e))return s;e._isValid=s}return e._isValid}function f(e){var a=c(NaN);return null!=e?L(Y(a),e):Y(a).userInvalidated=!0,a}n=Array.prototype.some?Array.prototype.some:function(e){for(var a=Object(this),t=a.length>>>0,s=0;s<t;s++)if(s in a&&e.call(this,a[s],s,a))return!0;return!1};var d=l.momentProperties=[];function k(e,a){var t,s,n;if(o(a._isAMomentObject)||(e._isAMomentObject=a._isAMomentObject),o(a._i)||(e._i=a._i),o(a._f)||(e._f=a._f),o(a._l)||(e._l=a._l),o(a._strict)||(e._strict=a._strict),o(a._tzm)||(e._tzm=a._tzm),o(a._isUTC)||(e._isUTC=a._isUTC),o(a._offset)||(e._offset=a._offset),o(a._pf)||(e._pf=Y(a)),o(a._locale)||(e._locale=a._locale),0<d.length)for(t=0;t<d.length;t++)o(n=a[s=d[t]])||(e[s]=n);return e}var a=!1;function p(e){k(this,e),this._d=new Date(null!=e._d?e._d.getTime():NaN),this.isValid()||(this._d=new Date(NaN)),!1===a&&(a=!0,l.updateOffset(this),a=!1)}function D(e){return e instanceof p||null!=e&&null!=e._isAMomentObject}function T(e){return e<0?Math.ceil(e)||0:Math.floor(e)}function g(e){var a=+e,t=0;return 0!==a&&isFinite(a)&&(t=T(a)),t}function r(e,a,t){var s,n=Math.min(e.length,a.length),d=Math.abs(e.length-a.length),r=0;for(s=0;s<n;s++)(t&&e[s]!==a[s]||!t&&g(e[s])!==g(a[s]))&&r++;return r+d}function w(e){!1===l.suppressDeprecationWarnings&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+e)}function t(n,d){var r=!0;return L(function(){if(null!=l.deprecationHandler&&l.deprecationHandler(null,n),r){for(var e,a=[],t=0;t<arguments.length;t++){if(e="","object"==typeof arguments[t]){for(var s in e+="\n["+t+"] ",arguments[0])e+=s+": "+arguments[0][s]+", ";e=e.slice(0,-2)}else e=arguments[t];a.push(e)}w(n+"\nArguments: "+Array.prototype.slice.call(a).join("")+"\n"+(new Error).stack),r=!1}return d.apply(this,arguments)},d)}var s,v={};function S(e,a){null!=l.deprecationHandler&&l.deprecationHandler(e,a),v[e]||(w(a),v[e]=!0)}function H(e){return e instanceof Function||"[object Function]"===Object.prototype.toString.call(e)}function b(e,a){var t,s=L({},e);for(t in a)h(a,t)&&(i(e[t])&&i(a[t])?(s[t]={},L(s[t],e[t]),L(s[t],a[t])):null!=a[t]?s[t]=a[t]:delete s[t]);for(t in e)h(e,t)&&!h(a,t)&&i(e[t])&&(s[t]=L({},s[t]));return s}function j(e){null!=e&&this.set(e)}l.suppressDeprecationWarnings=!1,l.deprecationHandler=null,s=Object.keys?Object.keys:function(e){var a,t=[];for(a in e)h(e,a)&&t.push(a);return t};var x={};function O(e,a){var t=e.toLowerCase();x[t]=x[t+"s"]=x[a]=e}function P(e){return"string"==typeof e?x[e]||x[e.toLowerCase()]:void 0}function W(e){var a,t,s={};for(t in e)h(e,t)&&(a=P(t))&&(s[a]=e[t]);return s}var A={};function E(e,a){A[e]=a}function F(e,a,t){var s=""+Math.abs(e),n=a-s.length;return(0<=e?t?"+":"":"-")+Math.pow(10,Math.max(0,n)).toString().substr(1)+s}var z=/(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,J=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,N={},R={};function C(e,a,t,s){var n=s;"string"==typeof s&&(n=function(){return this[s]()}),e&&(R[e]=n),a&&(R[a[0]]=function(){return F(n.apply(this,arguments),a[1],a[2])}),t&&(R[t]=function(){return this.localeData().ordinal(n.apply(this,arguments),e)})}function I(e,a){return e.isValid()?(a=U(a,e.localeData()),N[a]=N[a]||function(s){var e,n,a,d=s.match(z);for(e=0,n=d.lengtLine truncated
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function h(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function d(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n<e.length;++n)s.push(t(e[n],n));return s}function m(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function _(e,t){for(var n in t)m(t,n)&&(e[n]=t[n]);return m(t,"toString")&&(e.toString=t.toString),m(t,"valueOf")&&(e.valueOf=t.valueOf),e}function y(e,t,n,s){return Tt(e,t,n,s,!0).utc()}function g(e){return null==e._pf&&(e._pf={empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1,parsedDateParts:[],meridiem:null,rfc2822:!1,weekdayMismatch:!1}),e._pf}function v(e){if(null==e._isValid){var t=g(e),n=i.call(t.parsedDateParts,function(e){return null!=e}),s=!isNaN(e._d.getTime())&&t.overflow<0&&!t.empty&&!t.invalidMonth&&!t.invalidWeekday&&!t.weekdayMismatch&&!t.nullInput&&!t.invalidFormat&&!t.userInvalidated&&(!t.meridiem||t.meridiem&&n);if(e._strict&&(s=s&&0===t.charsLeftOver&&0===t.unusedTokens.length&&void 0===t.bigHour),null!=Object.isFrozen&&Object.isFrozen(e))return s;e._isValid=s}return e._isValid}function p(e){var t=y(NaN);return null!=e?_(g(t),e):g(t).userInvalidated=!0,t}i=Array.prototype.some?Array.prototype.some:function(e){for(var t=Object(this),n=t.length>>>0,s=0;s<n;s++)if(s in t&&e.call(this,t[s],s,t))return!0;return!1};var r=c.momentProperties=[];function w(e,t){var n,s,i;if(l(t._isAMomentObject)||(e._isAMomentObject=t._isAMomentObject),l(t._i)||(e._i=t._i),l(t._f)||(e._f=t._f),l(t._l)||(e._l=t._l),l(t._strict)||(e._strict=t._strict),l(t._tzm)||(e._tzm=t._tzm),l(t._isUTC)||(e._isUTC=t._isUTC),l(t._offset)||(e._offset=t._offset),l(t._pf)||(e._pf=g(t)),l(t._locale)||(e._locale=t._locale),0<r.length)for(n=0;n<r.length;n++)l(i=t[s=r[n]])||(e[s]=i);return e}var t=!1;function M(e){w(this,e),this._d=new Date(null!=e._d?e._d.getTime():NaN),this.isValid()||(this._d=new Date(NaN)),!1===t&&(t=!0,c.updateOffset(this),t=!1)}function k(e){return e instanceof M||null!=e&&null!=e._isAMomentObject}function S(e){return e<0?Math.ceil(e)||0:Math.floor(e)}function D(e){var t=+e,n=0;return 0!==t&&isFinite(t)&&(n=S(t)),n}function a(e,t,n){var s,i=Math.min(e.length,t.length),r=Math.abs(e.length-t.length),a=0;for(s=0;s<i;s++)(n&&e[s]!==t[s]||!n&&D(e[s])!==D(t[s]))&&a++;return a+r}function Y(e){!1===c.suppressDeprecationWarnings&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+e)}function n(i,r){var a=!0;return _(function(){if(null!=c.deprecationHandler&&c.deprecationHandler(null,i),a){for(var e,t=[],n=0;n<arguments.length;n++){if(e="","object"==typeof arguments[n]){for(var s in e+="\n["+n+"] ",arguments[0])e+=s+": "+arguments[0][s]+", ";e=e.slice(0,-2)}else e=arguments[n];t.push(e)}Y(i+"\nArguments: "+Array.prototype.slice.call(t).join("")+"\n"+(new Error).stack),a=!1}return r.apply(this,arguments)},r)}var s,O={};function T(e,t){null!=c.deprecationHandler&&c.deprecationHandler(e,t),O[e]||(Y(t),O[e]=!0)}function b(e){return e instanceof Function||"[object Function]"===Object.prototype.toString.call(e)}function x(e,t){var n,s=_({},e);for(n in t)m(t,n)&&(u(e[n])&&u(t[n])?(s[n]={},_(s[n],e[n]),_(s[n],t[n])):null!=t[n]?s[n]=t[n]:delete s[n]);for(n in e)m(e,n)&&!m(t,n)&&u(e[n])&&(s[n]=_({},s[n]));return s}function P(e){null!=e&&this.set(e)}c.suppressDeprecationWarnings=!1,c.deprecationHandler=null,s=Object.keys?Object.keys:function(e){var t,n=[];for(t in e)m(e,t)&&n.push(t);return n};var W={};function C(e,t){var n=e.toLowerCase();W[n]=W[n+"s"]=W[t]=e}function H(e){return"string"==typeof e?W[e]||W[e.toLowerCase()]:void 0}function R(e){var t,n,s={};for(n in e)m(e,n)&&(t=H(n))&&(s[t]=e[n]);return s}var U={};function F(e,t){U[e]=t}function L(e,t,n){var s=""+Math.abs(e),i=t-s.length;return(0<=e?n?"+":"":"-")+Math.pow(10,Math.max(0,i)).toString().substr(1)+s}var N=/(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,G=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,V={},E={};function I(e,t,n,s){var i=s;"string"==typeof s&&(i=function(){return this[s]()}),e&&(E[e]=i),t&&(E[t[0]]=function(){return L(i.apply(this,arguments),t[1],t[2])}),n&&(E[n]=function(){return this.localeData().ordinal(i.apply(this,arguments),e)})}function A(e,t){return e.isValid()?(t=j(t,e.localeData()),V[t]=V[t]||function(s){var e,i,t,r=s.match(N);for(e=0,i=r.lengtLine truncated
@@ -0,0 +1 @@
!function(e,a){"object"==typeof exports&&"undefined"!=typeof module?module.exports=a():"function"==typeof define&&define.amd?define(a):e.moment=a()}(this,function(){"use strict";var e,n;function l(){return e.apply(null,arguments)}function _(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function i(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function o(e){return void 0===e}function m(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function u(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function M(e,a){var t,s=[];for(t=0;t<e.length;++t)s.push(a(e[t],t));return s}function h(e,a){return Object.prototype.hasOwnProperty.call(e,a)}function L(e,a){for(var t in a)h(a,t)&&(e[t]=a[t]);return h(a,"toString")&&(e.toString=a.toString),h(a,"valueOf")&&(e.valueOf=a.valueOf),e}function c(e,a,t,s){return Sa(e,a,t,s,!0).utc()}function Y(e){return null==e._pf&&(e._pf={empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1,parsedDateParts:[],meridiem:null,rfc2822:!1,weekdayMismatch:!1}),e._pf}function y(e){if(null==e._isValid){var a=Y(e),t=n.call(a.parsedDateParts,function(e){return null!=e}),s=!isNaN(e._d.getTime())&&a.overflow<0&&!a.empty&&!a.invalidMonth&&!a.invalidWeekday&&!a.weekdayMismatch&&!a.nullInput&&!a.invalidFormat&&!a.userInvalidated&&(!a.meridiem||a.meridiem&&t);if(e._strict&&(s=s&&0===a.charsLeftOver&&0===a.unusedTokens.length&&void 0===a.bigHour),null!=Object.isFrozen&&Object.isFrozen(e))return s;e._isValid=s}return e._isValid}function f(e){var a=c(NaN);return null!=e?L(Y(a),e):Y(a).userInvalidated=!0,a}n=Array.prototype.some?Array.prototype.some:function(e){for(var a=Object(this),t=a.length>>>0,s=0;s<t;s++)if(s in a&&e.call(this,a[s],s,a))return!0;return!1};var d=l.momentProperties=[];function k(e,a){var t,s,n;if(o(a._isAMomentObject)||(e._isAMomentObject=a._isAMomentObject),o(a._i)||(e._i=a._i),o(a._f)||(e._f=a._f),o(a._l)||(e._l=a._l),o(a._strict)||(e._strict=a._strict),o(a._tzm)||(e._tzm=a._tzm),o(a._isUTC)||(e._isUTC=a._isUTC),o(a._offset)||(e._offset=a._offset),o(a._pf)||(e._pf=Y(a)),o(a._locale)||(e._locale=a._locale),0<d.length)for(t=0;t<d.length;t++)o(n=a[s=d[t]])||(e[s]=n);return e}var a=!1;function p(e){k(this,e),this._d=new Date(null!=e._d?e._d.getTime():NaN),this.isValid()||(this._d=new Date(NaN)),!1===a&&(a=!0,l.updateOffset(this),a=!1)}function D(e){return e instanceof p||null!=e&&null!=e._isAMomentObject}function T(e){return e<0?Math.ceil(e)||0:Math.floor(e)}function g(e){var a=+e,t=0;return 0!==a&&isFinite(a)&&(t=T(a)),t}function r(e,a,t){var s,n=Math.min(e.length,a.length),d=Math.abs(e.length-a.length),r=0;for(s=0;s<n;s++)(t&&e[s]!==a[s]||!t&&g(e[s])!==g(a[s]))&&r++;return r+d}function w(e){!1===l.suppressDeprecationWarnings&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+e)}function t(n,d){var r=!0;return L(function(){if(null!=l.deprecationHandler&&l.deprecationHandler(null,n),r){for(var e,a=[],t=0;t<arguments.length;t++){if(e="","object"==typeof arguments[t]){for(var s in e+="\n["+t+"] ",arguments[0])e+=s+": "+arguments[0][s]+", ";e=e.slice(0,-2)}else e=arguments[t];a.push(e)}w(n+"\nArguments: "+Array.prototype.slice.call(a).join("")+"\n"+(new Error).stack),r=!1}return d.apply(this,arguments)},d)}var s,v={};function S(e,a){null!=l.deprecationHandler&&l.deprecationHandler(e,a),v[e]||(w(a),v[e]=!0)}function H(e){return e instanceof Function||"[object Function]"===Object.prototype.toString.call(e)}function b(e,a){var t,s=L({},e);for(t in a)h(a,t)&&(i(e[t])&&i(a[t])?(s[t]={},L(s[t],e[t]),L(s[t],a[t])):null!=a[t]?s[t]=a[t]:delete s[t]);for(t in e)h(e,t)&&!h(a,t)&&i(e[t])&&(s[t]=L({},s[t]));return s}function j(e){null!=e&&this.set(e)}l.suppressDeprecationWarnings=!1,l.deprecationHandler=null,s=Object.keys?Object.keys:function(e){var a,t=[];for(a in e)h(e,a)&&t.push(a);return t};var x={};function O(e,a){var t=e.toLowerCase();x[t]=x[t+"s"]=x[a]=e}function P(e){return"string"==typeof e?x[e]||x[e.toLowerCase()]:void 0}function W(e){var a,t,s={};for(t in e)h(e,t)&&(a=P(t))&&(s[a]=e[t]);return s}var A={};function E(e,a){A[e]=a}function F(e,a,t){var s=""+Math.abs(e),n=a-s.length;return(0<=e?t?"+":"":"-")+Math.pow(10,Math.max(0,n)).toString().substr(1)+s}var z=/(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,J=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,N={},R={};function C(e,a,t,s){var n=s;"string"==typeof s&&(n=function(){return this[s]()}),e&&(R[e]=n),a&&(R[a[0]]=function(){return F(n.apply(this,arguments),a[1],a[2])}),t&&(R[t]=function(){return this.localeData().ordinal(n.apply(this,arguments),e)})}function I(e,a){return e.isValid()?(a=U(a,e.localeData()),N[a]=N[a]||function(s){var e,n,a,d=s.match(z);for(e=0,n=d.lengtLine truncated
@@ -0,0 +1,2 @@
.vis-time-axis{position:relative;overflow:hidden}.vis-time-axis.vis-foreground{top:0;left:0;width:100%}.vis-time-axis.vis-background{position:absolute;top:0;left:0;width:100%;height:100%}.vis-time-axis .vis-text{position:absolute;color:#4d4d4d;padding:3px;overflow:hidden;box-sizing:border-box;white-space:nowrap}.vis-time-axis .vis-text.vis-measure{position:absolute;padding-left:0;padding-right:0;margin-left:0;margin-right:0;visibility:hidden}.vis-time-axis .vis-grid.vis-vertical{position:absolute;border-left:1px solid}.vis-time-axis .vis-grid.vis-vertical-rtl{position:absolute;border-right:1px solid}.vis-time-axis .vis-grid.vis-minor{border-color:#e5e5e5}.vis-time-axis .vis-grid.vis-major{border-color:#bfbfbf}.vis .overlay{position:absolute;top:0;left:0;width:100%;height:100%;z-index:10}.vis-active{box-shadow:0 0 10px #86d5f8}.vis-custom-time{background-color:#6e94ff;width:2px;cursor:move;z-index:1}.vis-custom-time>.vis-custom-time-marker{background-color:inherit;color:#fff;font-size:12px;white-space:nowrap;padding:3px 5px;top:0;cursor:auto;z-index:inherit}.vis-current-time{background-color:#ff7f6e;width:2px;z-index:1;pointer-events:none}.vis-rolling-mode-btn{height:40px;width:40px;position:absolute;top:7px;right:20px;border-radius:50%;font-size:28px;cursor:pointer;opacity:.8;color:#fff;font-weight:700;text-align:center;background:#3876c2}.vis-rolling-mode-btn:before{content:"\26F6"}.vis-rolling-mode-btn:hover{opacity:1}.vis-panel{position:absolute;padding:0;margin:0;box-sizing:border-box}.vis-panel.vis-bottom,.vis-panel.vis-center,.vis-panel.vis-left,.vis-panel.vis-right,.vis-panel.vis-top{border:1px #bfbfbf}.vis-panel.vis-center,.vis-panel.vis-left,.vis-panel.vis-right{border-top-style:solid;border-bottom-style:solid;overflow:hidden}.vis-left.vis-panel.vis-vertical-scroll,.vis-right.vis-panel.vis-vertical-scroll{height:100%;overflow-x:hidden;overflow-y:scroll}.vis-left.vis-panel.vis-vertical-scroll{direction:rtl}.vis-left.vis-panel.vis-vertical-scroll .vis-content,.vis-right.vis-panel.vis-vertical-scroll{direction:ltr}.vis-right.vis-panel.vis-vertical-scroll .vis-content{direction:rtl}.vis-panel.vis-bottom,.vis-panel.vis-center,.vis-panel.vis-top{border-left-style:solid;border-right-style:solid}.vis-background{overflow:hidden}.vis-panel>.vis-content{position:relative}.vis-panel .vis-shadow{position:absolute;width:100%;height:1px;box-shadow:0 0 10px rgba(0,0,0,.8)}.vis-panel .vis-shadow.vis-top{top:-1px;left:0}.vis-panel .vis-shadow.vis-bottom{bottom:-1px;left:0}.vis-graph-group0{fill:#4f81bd;fill-opacity:0;stroke-width:2px;stroke:#4f81bd}.vis-graph-group1{fill:#f79646;fill-opacity:0;stroke-width:2px;stroke:#f79646}.vis-graph-group2{fill:#8c51cf;fill-opacity:0;stroke-width:2px;stroke:#8c51cf}.vis-graph-group3{fill:#75c841;fill-opacity:0;stroke-width:2px;stroke:#75c841}.vis-graph-group4{fill:#ff0100;fill-opacity:0;stroke-width:2px;stroke:#ff0100}.vis-graph-group5{fill:#37d8e6;fill-opacity:0;stroke-width:2px;stroke:#37d8e6}.vis-graph-group6{fill:#042662;fill-opacity:0;stroke-width:2px;stroke:#042662}.vis-graph-group7{fill:#00ff26;fill-opacity:0;stroke-width:2px;stroke:#00ff26}.vis-graph-group8{fill:#f0f;fill-opacity:0;stroke-width:2px;stroke:#f0f}.vis-graph-group9{fill:#8f3938;fill-opacity:0;stroke-width:2px;stroke:#8f3938}.vis-timeline .vis-fill{fill-opacity:.1;stroke:none}.vis-timeline .vis-bar{fill-opacity:.5;stroke-width:1px}.vis-timeline .vis-point{stroke-width:2px;fill-opacity:1}.vis-timeline .vis-legend-background{stroke-width:1px;fill-opacity:.9;fill:#fff;stroke:#c2c2c2}.vis-timeline .vis-outline{stroke-width:1px;fill-opacity:1;fill:#fff;stroke:#e5e5e5}.vis-timeline .vis-icon-fill{fill-opacity:.3;stroke:none}.vis-timeline{position:relative;border:1px solid #bfbfbf;overflow:hidden;padding:0;margin:0;box-sizing:border-box}.vis-loading-screen{width:100%;height:100%;position:absolute;top:0;left:0}.vis [class*=span]{min-height:0;width:auto}.vis-item{position:absolute;color:#1a1a1a;border-color:#97b0f8;border-width:1px;background-color:#d5ddf6;display:inline-block;z-index:1}.vis-item.vis-selected{border-color:#ffc200;background-color:#fff785;z-index:2}.vis-editable.vis-selected{cursor:move}.vis-item.vis-point.vis-selected{background-color:#fff785}.vis-item.vis-box{text-align:center;border-style:solid;border-radius:2px}.vis-item.vis-point{background:none}.vis-item.vis-dot{position:absolute;padding:0;border-width:4px;border-style:solid;border-radius:4px}.vis-item.vis-range{border-style:solid;border-radius:2px;box-sizing:border-box}.vis-item.vis-background{border:none;background-color:rgba(213,221,246,.4);box-sizing:border-box;padding:0;margin:0}.vis-item .vis-item-overflow{position:relative;width:100%;height:100%;padding:0;margin:0;overflow:hidden}.vis-item-visible-frame{white-space:nowrap}.vis-item.vis-range .vis-item-content{position:relative;display:inline-block}.vis-item.vis-background .vis-item-content{position:absolute;display:inline-block}.vis-item.vis-line{padding:0;position:absolute;width:0;border-Line truncated
/*# sourceMappingURL=vis-timeline-graph2d.min.css.map */
@@ -0,0 +1,34 @@
/**
* vis-timeline and vis-graph2d
* https://visjs.github.io/vis-timeline/
*
* Create a fully customizable, interactive timeline with items and ranges.
*
* @version 7.5.1
* @date 2022-03-01T18:26:09.239Z
*
* @copyright (c) 2011-2017 Almende B.V, http://almende.com
* @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs
*
* @license
* vis.js is dual licensed under both
*
* 1. The Apache 2.0 License
* http://www.apache.org/licenses/LICENSE-2.0
*
* and
*
* 2. The MIT License
* http://opensource.org/licenses/MIT
*
* vis.js may be distributed under either license.
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).vis=t.vis||{})}(this,(function(t){var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function n(t){throw new Error('Could not dynamically require "'+t+'". Please configure the dynamicRequireTargets or/and ignoreDynamicRequires option of @rollup/plugin-commonjs appropriately for this require call to work.')}var o={exports:{}};!function(t,e){t.exports=function(){var e,i;function o(){return e.apply(null,arguments)}function r(t){e=t}function s(t){return t instanceof Array||"[object Array]"===Object.prototype.toString.call(t)}function a(t){return null!=t&&"[object Object]"===Object.prototype.toString.call(t)}function l(t,e){return Object.prototype.hasOwnProperty.call(t,e)}function h(t){if(Object.getOwnPropertyNames)return 0===Object.getOwnPropertyNames(t).length;var e;for(e in t)if(l(t,e))return!1;return!0}function u(t){return void 0===t}function d(t){return"number"==typeof t||"[object Number]"===Object.prototype.toString.call(t)}function c(t){return t instanceof Date||"[object Date]"===Object.prototype.toString.call(t)}function p(t,e){var i,n=[];for(i=0;i<t.length;++i)n.push(e(t[i],i));return n}function f(t,e){for(var i in e)l(e,i)&&(t[i]=e[i]);return l(e,"toString")&&(t.toString=e.toString),l(e,"valueOf")&&(t.valueOf=e.valueOf),t}function m(t,e,i,n){return Xi(t,e,i,n,!0).utc()}function v(){return{empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidEra:null,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1,parsedDateParts:[],era:null,meridiem:null,rfc2822:!1,weekdayMismatch:!1}}function g(t){return null==t._pf&&(t._pf=v()),t._pf}function y(t){if(null==t._isValid){var e=g(t),n=i.call(e.parsedDateParts,(function(t){return null!=t})),o=!isNaN(t._d.getTime())&&e.overflow<0&&!e.empty&&!e.invalidEra&&!e.invalidMonth&&!e.invalidWeekday&&!e.weekdayMismatch&&!e.nullInput&&!e.invalidFormat&&!e.userInvalidated&&(!e.meridiem||e.meridiem&&n);if(t._strict&&(o=o&&0===e.charsLeftOver&&0===e.unusedTokens.length&&void 0===e.bigHour),null!=Object.isFrozen&&Object.isFrozen(t))return o;t._isValid=o}return t._isValid}function b(t){var e=m(NaN);return null!=t?f(g(e),t):g(e).userInvalidated=!0,e}i=Array.prototype.some?Array.prototype.some:function(t){var e,i=Object(this),n=i.length>>>0;for(e=0;e<n;e++)if(e in i&&t.call(this,i[e],e,i))return!0;return!1};var _=o.momentProperties=[],w=!1;function k(t,e){var i,n,o;if(u(e._isAMomentObject)||(t._isAMomentObject=e._isAMomentObject),u(e._i)||(t._i=e._i),u(e._f)||(t._f=e._f),u(e._l)||(t._l=e._l),u(e._strict)||(t._strict=e._strict),u(e._tzm)||(t._tzm=e._tzm),u(e._isUTC)||(t._isUTC=e._isUTC),u(e._offset)||(t._offset=e._offset),u(e._pf)||(t._pf=g(e)),u(e._locale)||(t._locale=e._locale),_.length>0)for(i=0;i<_.length;i++)u(o=e[n=_[i]])||(t[n]=o);return t}function x(t){k(this,t),this._d=new Date(null!=t._d?t._d.getTime():NaN),this.isValid()||(this._d=new Date(NaN)),!1===w&&(w=!0,o.updateOffset(this),w=!1)}function D(t){return t instanceof x||null!=t&&null!=t._isAMomentObject}function S(t){!1===o.suppressDeprecationWarnings&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+t)}function C(t,e){var i=!0;return f((function(){if(null!=o.deprecationHandler&&o.deprecationHandler(null,t),i){var n,r,s,a=[];for(r=0;r<arguments.length;r++){if(n="","object"==typeof arguments[r]){for(s in n+="\n["+r+"] ",arguments[0])l(arguments[0],s)&&(n+=s+": "+arguments[0][s]+", ");n=n.slice(0,-2)}else n=arguments[r];a.push(n)}S(t+"\nArguments: "+Array.prototype.slice.call(a).join("")+"\n"+(new Error).stack),i=!1}return e.apply(this,arguments)}),e)}var T,M={};function O(t,e){null!=o.deprecationHandler&&o.deprecationHandler(t,e),M[t]||(S(e),M[t]=!0)}function E(t){return"undefined"!=typeof Function&&t instanceof Function||"[object Function]"===Object.prototype.toString.call(t)}function P(t){var e,i;for(i in t)l(t,i)&&(E(e=t[i])?this[i]=e:this["_"+i]=e);this._config=t,this._dayOfMonthOrdinalParseLenient=new RegExp((this._dayOfMonthOrdinalParse.source||this._ordinalParse.source)+"|"+/\d{1,2}/.source)}function A(t,e){var i,n=f({},t);for(i in e)l(e,i)&&(a(t[i])&&a(e[i])?(n[i]={},f(n[i],t[i]),f(n[i],e[i])):null!=e[i]?n[i]=e[i]:delete n[i]);for(i in t)l(t,i)&&!l(e,i)&&a(t[i])&&(n[i]=f({},n[i]));return n}function I(t){null!=t&&this.set(t)}o.suppressDeprecationWarnings=!1,o.deprecationHandler=null,T=Object.keys?Object.keys:function(t){var e,i=[];for(e in t)l(t,e)&&i.push(e);return i};var L={sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"};function N(t,e,i){var n=this._calendar[t]||this._calendar.sameElse;return E(n)?n.call(e,i):n}function F(t,e,i){var n=""+Math.abs(t),o=e-n.Line truncated
o.version="2.29.1",r(qi),o.fn=hr,o.min=Ji,o.max=Qi,o.now=tn,o.utc=m,o.unix=ur,o.months=gr,o.isDate=c,o.locale=fi,o.invalid=b,o.duration=Mn,o.isMoment=D,o.weekdays=br,o.parseZone=dr,o.localeData=gi,o.isDuration=an,o.monthsShort=yr,o.weekdaysMin=wr,o.defineLocale=mi,o.updateLocale=vi,o.locales=yi,o.weekdaysShort=_r,o.normalizeUnits=ot,o.relativeTimeRounding=os,o.relativeTimeThreshold=rs,o.calendarFormat=zn,o.prototype=hr,o.HTML5_FMT={DATETIME_LOCAL:"YYYY-MM-DDTHH:mm",DATETIME_LOCAL_SECONDS:"YYYY-MM-DDTHH:mm:ss",DATETIME_LOCAL_MS:"YYYY-MM-DDTHH:mm:ss.SSS",DATE:"YYYY-MM-DD",TIME:"HH:mm",TIME_SECONDS:"HH:mm:ss",TIME_MS:"HH:mm:ss.SSS",WEEK:"GGGG-[W]WW",MONTH:"YYYY-MM"},o}()}(o);var r=o.exports;!function(t){function e(t,e,i,n){var o={m:["eine Minute","einer Minute"],h:["eine Stunde","einer Stunde"],d:["ein Tag","einem Tag"],dd:[t+" Tage",t+" Tagen"],w:["eine Woche","einer Woche"],M:["ein Monat","einem Monat"],MM:[t+" Monate",t+" Monaten"],y:["ein Jahr","einem Jahr"],yy:[t+" Jahre",t+" Jahren"]};return e?o[i][0]:o[i][1]}t.defineLocale("de",{months:"Januar_Februar_März_April_Mai_Juni_Juli_August_September_Oktober_November_Dezember".split("_"),monthsShort:"Jan._Feb._März_Apr._Mai_Juni_Juli_Aug._Sep._Okt._Nov._Dez.".split("_"),monthsParseExact:!0,weekdays:"Sonntag_Montag_Dienstag_Mittwoch_Donnerstag_Freitag_Samstag".split("_"),weekdaysShort:"So._Mo._Di._Mi._Do._Fr._Sa.".split("_"),weekdaysMin:"So_Mo_Di_Mi_Do_Fr_Sa".split("_"),weekdaysParseExact:!0,longDateFormat:{LT:"HH:mm",LTS:"HH:mm:ss",L:"DD.MM.YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY HH:mm",LLLL:"dddd, D. MMMM YYYY HH:mm"},calendar:{sameDay:"[heute um] LT [Uhr]",sameElse:"L",nextDay:"[morgen um] LT [Uhr]",nextWeek:"dddd [um] LT [Uhr]",lastDay:"[gestern um] LT [Uhr]",lastWeek:"[letzten] dddd [um] LT [Uhr]"},relativeTime:{future:"in %s",past:"vor %s",s:"ein paar Sekunden",ss:"%d Sekunden",m:e,mm:"%d Minuten",h:e,hh:"%d Stunden",d:e,dd:e,w:e,ww:"%d Wochen",M:e,MM:e,y:e,yy:e},dayOfMonthOrdinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}})}(o.exports),function(t){var e="ene._feb._mar._abr._may._jun._jul._ago._sep._oct._nov._dic.".split("_"),i="ene_feb_mar_abr_may_jun_jul_ago_sep_oct_nov_dic".split("_"),n=[/^ene/i,/^feb/i,/^mar/i,/^abr/i,/^may/i,/^jun/i,/^jul/i,/^ago/i,/^sep/i,/^oct/i,/^nov/i,/^dic/i],o=/^(enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre|ene\.?|feb\.?|mar\.?|abr\.?|may\.?|jun\.?|jul\.?|ago\.?|sep\.?|oct\.?|nov\.?|dic\.?)/i;t.defineLocale("es",{months:"enero_febrero_marzo_abril_mayo_junio_julio_agosto_septiembre_octubre_noviembre_diciembre".split("_"),monthsShort:function(t,n){return t?/-MMM-/.test(n)?i[t.month()]:e[t.month()]:e},monthsRegex:o,monthsShortRegex:o,monthsStrictRegex:/^(enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre)/i,monthsShortStrictRegex:/^(ene\.?|feb\.?|mar\.?|abr\.?|may\.?|jun\.?|jul\.?|ago\.?|sep\.?|oct\.?|nov\.?|dic\.?)/i,monthsParse:n,longMonthsParse:n,shortMonthsParse:n,weekdays:"domingo_lunes_martes_miércoles_jueves_viernes_sábado".split("_"),weekdaysShort:"dom._lun._mar._mié._jue._vie._sáb.".split("_"),weekdaysMin:"do_lu_ma_mi_ju_vi_sá".split("_"),weekdaysParseExact:!0,longDateFormat:{LT:"H:mm",LTS:"H:mm:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY H:mm",LLLL:"dddd, D [de] MMMM [de] YYYY H:mm"},calendar:{sameDay:function(){return"[hoy a la"+(1!==this.hours()?"s":"")+"] LT"},nextDay:function(){return"[mañana a la"+(1!==this.hours()?"s":"")+"] LT"},nextWeek:function(){return"dddd [a la"+(1!==this.hours()?"s":"")+"] LT"},lastDay:function(){return"[ayer a la"+(1!==this.hours()?"s":"")+"] LT"},lastWeek:function(){return"[el] dddd [pasado a la"+(1!==this.hours()?"s":"")+"] LT"},sameElse:"L"},relativeTime:{future:"en %s",past:"hace %s",s:"unos segundos",ss:"%d segundos",m:"un minuto",mm:"%d minutos",h:"una hora",hh:"%d horas",d:"un día",dd:"%d días",w:"una semana",ww:"%d semanas",M:"un mes",MM:"%d meses",y:"un año",yy:"%d años"},dayOfMonthOrdinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4},invalidDate:"Fecha inválida"})}(o.exports),function(t){var e=/(janv\.?|févr\.?|mars|avr\.?|mai|juin|juil\.?|août|sept\.?|oct\.?|nov\.?|déc\.?|janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre)/i,i=[/^janv/i,/^févr/i,/^mars/i,/^avr/i,/^mai/i,/^juin/i,/^juil/i,/^août/i,/^sept/i,/^oct/i,/^nov/i,/^déc/i];t.defineLocale("fr",{months:"janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split("_"),monthsShort:"janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split("_"),monthsRegex:e,monthsShortRegex:e,monthsStrictRegex:/^(janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre)/i,monthsShortStrictRegex:/(janv\.?|févr\.?|mars|avr\.?|mai|juin|juil\.?|août|sept\.?|oct\.?|nov\.?|déc\.?)/i,monthsParse:i,longMonthsParse:i,shortMonthsParse:i,weekdays:"dimanche_lundi_mardi_mercredi_jeudi_vendredi_saLine truncated
/*! Hammer.JS - v2.0.17-rc - 2019-12-16
* http://naver.github.io/egjs
*
* Forked By Naver egjs
* Copyright (c) hammerjs
* Licensed under the MIT license */function Gf(){return(Gf=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var i=arguments[e];for(var n in i)Object.prototype.hasOwnProperty.call(i,n)&&(t[n]=i[n])}return t}).apply(this,arguments)}function Bf(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,t.__proto__=e}function Wf(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}var Vf,Uf="function"!=typeof Object.assign?function(t){if(null==t)throw new TypeError("Cannot convert undefined or null to object");for(var e=Object(t),i=1;i<arguments.length;i++){var n=arguments[i];if(null!=n)for(var o in n)n.hasOwnProperty(o)&&(e[o]=n[o])}return e}:Object.assign,Xf=["","webkit","Moz","MS","ms","o"],qf="undefined"==typeof document?{style:{}}:document.createElement("div"),Zf=Math.round,$f=Math.abs,Kf=Date.now;function Jf(t,e){for(var i,n,o=e[0].toUpperCase()+e.slice(1),r=0;r<Xf.length;){if((n=(i=Xf[r])?i+o:e)in t)return n;r++}}Vf="undefined"==typeof window?{}:window;var Qf=Jf(qf.style,"touchAction"),tm=void 0!==Qf;var em="compute",im="auto",nm="manipulation",om="none",rm="pan-x",sm="pan-y",am=function(){if(!tm)return!1;var t={},e=Vf.CSS&&Vf.CSS.supports;return["auto","manipulation","pan-y","pan-x","pan-x pan-y","none"].forEach((function(i){return t[i]=!e||Vf.CSS.supports("touch-action",i)})),t}(),lm="ontouchstart"in Vf,hm=void 0!==Jf(Vf,"PointerEvent"),um=lm&&/mobile|tablet|ip(ad|hone|od)|android/i.test(navigator.userAgent),dm="touch",cm="mouse",pm=16,fm=24,mm=["x","y"],vm=["clientX","clientY"];function gm(t,e,i){var n;if(t)if(t.forEach)t.forEach(e,i);else if(void 0!==t.length)for(n=0;n<t.length;)e.call(i,t[n],n,t),n++;else for(n in t)t.hasOwnProperty(n)&&e.call(i,t[n],n,t)}function ym(t,e){return"function"==typeof t?t.apply(e&&e[0]||void 0,e):t}function bm(t,e){return t.indexOf(e)>-1}var _m=function(){function t(t,e){this.manager=t,this.set(e)}var e=t.prototype;return e.set=function(t){t===em&&(t=this.compute()),tm&&this.manager.element.style&&am[t]&&(this.manager.element.style[Qf]=t),this.actions=t.toLowerCase().trim()},e.update=function(){this.set(this.manager.options.touchAction)},e.compute=function(){var t=[];return gm(this.manager.recognizers,(function(e){ym(e.options.enable,[e])&&(t=t.concat(e.getTouchAction()))})),function(t){if(bm(t,om))return om;var e=bm(t,rm),i=bm(t,sm);return e&&i?om:e||i?e?rm:sm:bm(t,nm)?nm:im}(t.join(" "))},e.preventDefaults=function(t){var e=t.srcEvent,i=t.offsetDirection;if(this.manager.session.prevented)e.preventDefault();else{var n=this.actions,o=bm(n,om)&&!am.none,r=bm(n,sm)&&!am["pan-y"],s=bm(n,rm)&&!am["pan-x"];if(o){var a=1===t.pointers.length,l=t.distance<2,h=t.deltaTime<250;if(a&&l&&h)return}if(!s||!r)return o||r&&6&i||s&&i&fm?this.preventSrc(e):void 0}},e.preventSrc=function(t){this.manager.session.prevented=!0,t.preventDefault()},t}();function wm(t,e){for(;t;){if(t===e)return!0;t=t.parentNode}return!1}function km(t){var e=t.length;if(1===e)return{x:Zf(t[0].clientX),y:Zf(t[0].clientY)};for(var i=0,n=0,o=0;o<e;)i+=t[o].clientX,n+=t[o].clientY,o++;return{x:Zf(i/e),y:Zf(n/e)}}function xm(t){for(var e=[],i=0;i<t.pointers.length;)e[i]={clientX:Zf(t.pointers[i].clientX),clientY:Zf(t.pointers[i].clientY)},i++;return{timeStamp:Kf(),pointers:e,center:km(e),deltaX:t.deltaX,deltaY:t.deltaY}}function Dm(t,e,i){i||(i=mm);var n=e[i[0]]-t[i[0]],o=e[i[1]]-t[i[1]];return Math.sqrt(n*n+o*o)}function Sm(t,e,i){i||(i=mm);var n=e[i[0]]-t[i[0]],o=e[i[1]]-t[i[1]];return 180*Math.atan2(o,n)/Math.PI}function Cm(t,e){return t===e?1:$f(t)>=$f(e)?t<0?2:4:e<0?8:pm}function Tm(t,e,i){return{x:e/t||0,y:i/t||0}}function Mm(t,e){var i=t.session,n=e.pointers,o=n.length;i.firstInput||(i.firstInput=xm(e)),o>1&&!i.firstMultiple?i.firstMultiple=xm(e):1===o&&(i.firstMultiple=!1);var r=i.firstInput,s=i.firstMultiple,a=s?s.center:r.center,l=e.center=km(n);e.timeStamp=Kf(),e.deltaTime=e.timeStamp-r.timeStamp,e.angle=Sm(a,l),e.distance=Dm(a,l),function(t,e){var i=e.center,n=t.offsetDelta||{},o=t.prevDelta||{},r=t.prevInput||{};1!==e.eventType&&4!==r.eventType||(o=t.prevDelta={x:r.deltaX||0,y:r.deltaY||0},n=t.offsetDelta={x:i.x,y:i.y}),e.deltaX=o.x+(i.x-n.x),e.deltaY=o.y+(i.y-n.y)}(i,e),e.offsetDirection=Cm(e.deltaX,e.deltaY);var h,u,d=Tm(e.deltaTime,e.deltaX,e.deltaY);e.overallVelocityX=d.x,e.overallVelocityY=d.y,e.overallVelocity=$f(d.x)>$f(d.y)?d.x:d.y,e.scale=s?(h=s.pointers,Dm((u=n)[0],u[1],vm)/Dm(h[0],h[1],vm)):1,e.rotation=s?function(t,e){return Sm(e[1],e[0],vm)+Sm(t[1],t[0],vm)}(s.pointers,n):0,e.maxPointers=i.prevInput?e.pointers.length>i.prevInput.maxPointers?e.pointers.length:i.prevInput.maxPointers:e.pointers.length,function(t,e){var i,n,o,r,s=t.lastInterval||e,a=e.timeStamp-s.timeStamp;if(8!==e.eventType&&(a>25||void 0===s.velocity)){var l=e.deltaX-s.deltaX,h=e.deltaY-s.deltaY,u=Tm(a,l,h);n=u.x,o=u.y,i=$f(u.x)>$f(u.y)?u.x:u.y,r=Cm(l,h),t.lastInterval=e}else i=s.velocity,n=s.velocityX,o=s.velocityY,r=s.direction;e.velocityLine truncated
//# sourceMappingURL=vis-timeline-graph2d.min.js.map
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
/**
* vis.js
* https://github.com/almende/vis
*
* A dynamic, browser-based visualization library.
*
* @version 4.20.1-SNAPSHOT
* @date 2017-10-12
*
* @license
* Copyright (C) 2011-2017 Almende B.V, http://almende.com
*
* Vis.js is dual licensed under both
*
* * The Apache 2.0 License
* http://www.apache.org/licenses/LICENSE-2.0
*
* and
*
* * The MIT License
* http://opensource.org/licenses/MIT
*
* Vis.js may be distributed under either license.
*/
"use strict";!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.vis=e():t.vis=e()}(this,function(){return function(t){function e(o){if(i[o])return i[o].exports;var n=i[o]={i:o,l:!1,exports:{}};return t[o].call(n.exports,n,n.exports,e),n.l=!0,n.exports}var i={};return e.m=t,e.c=i,e.d=function(t,i,o){e.o(t,i)||Object.defineProperty(t,i,{configurable:!1,enumerable:!0,get:o})},e.n=function(t){var i=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(i,"a",i),i},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s=123)}([function(t,e,i){e.__esModule=!0,e.default=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}},function(t,e,i){e.__esModule=!0;var o=i(169),n=function(t){return t&&t.__esModule?t:{default:t}}(o);e.default=function(){function t(t,e){for(var i=0;i<e.length;i++){var o=e[i];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),(0,n.default)(t,o.key,o)}}return function(e,i,o){return i&&t(e.prototype,i),o&&t(e,o),e}}()},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}function n(t,e,i,o){var n=!1;!0===o&&(n=null===e[i]&&void 0!==t[i]),n?delete t[i]:t[i]=e[i]}var s=i(77),r=o(s),a=i(29),h=o(a),d=i(8),l=o(d),u=i(6),c=o(u),p=i(9),f=i(157);e.isNumber=function(t){return t instanceof Number||"number"==typeof t},e.recursiveDOMDelete=function(t){if(t)for(;!0===t.hasChildNodes();)e.recursiveDOMDelete(t.firstChild),t.removeChild(t.firstChild)},e.giveRange=function(t,e,i,o){if(e==t)return.5;var n=1/(e-t);return Math.max(0,(o-t)*n)},e.isString=function(t){return t instanceof String||"string"==typeof t},e.isDate=function(t){if(t instanceof Date)return!0;if(e.isString(t)){if(m.exec(t))return!0;if(!isNaN(Date.parse(t)))return!0}return!1},e.randomUUID=function(){return f.v4()},e.assignAllKeys=function(t,e){for(var i in t)t.hasOwnProperty(i)&&"object"!==(0,c.default)(t[i])&&(t[i]=e)},e.fillIfDefined=function(t,i){var o=arguments.length>2&&void 0!==arguments[2]&&arguments[2];for(var s in t)void 0!==i[s]&&(null===i[s]||"object"!==(0,c.default)(i[s])?n(t,i,s,o):"object"===(0,c.default)(t[s])&&e.fillIfDefined(t[s],i[s],o))},e.extend=function(t,e){for(var i=1;i<arguments.length;i++){var o=arguments[i];for(var n in o)o.hasOwnProperty(n)&&(t[n]=o[n])}return t},e.selectiveExtend=function(t,e,i){if(!Array.isArray(t))throw new Error("Array with property names expected as first argument");for(var o=2;o<arguments.length;o++)for(var n=arguments[o],s=0;s<t.length;s++){var r=t[s];n&&n.hasOwnProperty(r)&&(e[r]=n[r])}return e},e.selectiveDeepExtend=function(t,i,o){var s=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(Array.isArray(o))throw new TypeError("Arrays are not supported by deepExtend");for(var r=0;r<t.length;r++){var a=t[r];if(o.hasOwnProperty(a))if(o[a]&&o[a].constructor===Object)void 0===i[a]&&(i[a]={}),i[a].constructor===Object?e.deepExtend(i[a],o[a],!1,s):n(i,o,a,s);else{if(Array.isArray(o[a]))throw new TypeError("Arrays are not supported by deepExtend");n(i,o,a,s)}}return i},e.selectiveNotDeepExtend=function(t,i,o){var s=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(Array.isArray(o))throw new TypeError("Arrays are not supported by deepExtend");for(var r in o)if(o.hasOwnProperty(r)&&-1===t.indexOf(r))if(o[r]&&o[r].constructor===Object)void 0===i[r]&&(i[r]={}),i[r].constructor===Object?e.deepExtend(i[r],o[r]):n(i,o,r,s);else if(Array.isArray(o[r])){i[r]=[];for(var a=0;a<o[r].length;a++)i[r].push(o[r][a])}else n(i,o,r,s);return i},e.deepExtend=function(t,i){var o=arguments.length>2&&void 0!==arguments[2]&&arguments[2],s=arguments.length>3&&void 0!==arguments[3]&&arguments[3];for(var r in i)if(i.hasOwnProperty(r)||!0===o)if(i[r]&&i[r].constructor===Object)void 0===t[r]&&(t[r]={}),t[r].constructor===Object?e.deepExtend(t[r],i[r],o):n(t,i,r,s);else if(Array.isArray(i[r])){t[r]=[];for(var a=0;a<i[r].length;a++)t[r].push(i[r][a])}else n(t,i,r,s);return t},e.equalArray=function(t,e){if(t.length!=e.length)return!1;for(var i=0,o=t.length;i<o;i++)if(t[i]!=e[i])return!1;return!0},e.convert=function(t,i){var o;if(void 0!==t){if(null===t)return null;if(!i)return t;if("string"!=typeof i&&!(i instanceof String))throw new Error("Type must be a string");switch(i){case"boolean":case"Boolean":return Boolean(t);case"number":case"Number":return e.isString(t)&&!isNaN(Date.parse(t))?p(t).valueOf():Number(t.valueOf());case"string":case"String":return String(t);case"Date":if(e.isNumber(t))return new Date(t);if(t instanceof Date)return new Date(t.valueOf());if(p.isMoment(t))return new Date(t.valueOf());if(e.isString(t))return o=m.exec(t),o?new Date(Number(o[1])):p(new Date(t)).toDate();throw new Error("Cannot convert object of type "+e.getType(t)+" to type Date");case"Moment":if(e.isNumber(t))return p(t);if(t instanceof Date)return p(t.valueOf());if(p.isMoment(t))return p(t);ifLine truncated
;r=void 0!==n.indexMatch?" in "+t.printLocation(n.path,e,"")+'Perhaps it was incomplete? Did you mean: "'+n.indexMatch+'"?\n\n':s.distance<=4&&n.distance>s.distance?" in "+t.printLocation(n.path,e,"")+"Perhaps it was misplaced? Matching option found at: "+t.printLocation(s.path,s.closestMatch,""):n.distance<=8?'. Did you mean "'+n.closestMatch+'"?'+t.printLocation(n.path,e):". Did you mean one of these: "+t.print((0,d.default)(i))+t.printLocation(o,e),console.log('%cUnknown option detected: "'+e+'"'+r,g),m=!0}},{key:"findInOptions",value:function(e,i,o){var n=arguments.length>3&&void 0!==arguments[3]&&arguments[3],s=1e9,r="",a=[],h=e.toLowerCase(),d=void 0;for(var l in i){var u=void 0;if(void 0!==i[l].__type__&&!0===n){var c=t.findInOptions(e,i[l],f.copyAndExtendArray(o,l));s>c.distance&&(r=c.closestMatch,a=c.path,s=c.distance,d=c.indexMatch)}else-1!==l.toLowerCase().indexOf(h)&&(d=l),u=t.levenshteinDistance(e,l),s>u&&(r=l,a=f.copyArray(o),s=u)}return{closestMatch:r,path:a,distance:s,indexMatch:d}}},{key:"printLocation",value:function(t,e){for(var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"Problem value found at: \n",o="\n\n"+i+"options = {\n",n=0;n<t.length;n++){for(var s=0;s<n+1;s++)o+=" ";o+=t[n]+": {\n"}for(var r=0;r<t.length+1;r++)o+=" ";o+=e+"\n";for(var a=0;a<t.length+1;a++){for(var h=0;h<t.length-a;h++)o+=" ";o+="}\n"}return o+"\n\n"}},{key:"print",value:function(t){return(0,s.default)(t).replace(/(\")|(\[)|(\])|(,"__type__")/g,"").replace(/(\,)/g,", ")}},{key:"levenshteinDistance",value:function(t,e){if(0===t.length)return e.length;if(0===e.length)return t.length;var i,o=[];for(i=0;i<=e.length;i++)o[i]=[i];var n;for(n=0;n<=t.length;n++)o[0][n]=n;for(i=1;i<=e.length;i++)for(n=1;n<=t.length;n++)e.charAt(i-1)==t.charAt(n-1)?o[i][n]=o[i-1][n-1]:o[i][n]=Math.min(o[i-1][n-1]+1,Math.min(o[i][n-1]+1,o[i-1][n]+1));return o[e.length][t.length]}}]),t}();e.default=y,e.printStyle=g},function(t,e,i){function o(t,e){this.options=null,this.props=null}var n=i(2);o.prototype.setOptions=function(t){t&&n.extend(this.options,t)},o.prototype.redraw=function(){return!1},o.prototype.destroy=function(){},o.prototype._isResized=function(){var t=this.props._previousWidth!==this.props.width||this.props._previousHeight!==this.props.height;return this.props._previousWidth=this.props.width,this.props._previousHeight=this.props.height,t},t.exports=o},function(t,e,i){var o=i(18),n=i(7),s=i(80),r=i(26),a=function(t,e,i){var h,d,l,u=t&a.F,c=t&a.G,p=t&a.S,f=t&a.P,m=t&a.B,v=t&a.W,g=c?n:n[e]||(n[e]={}),y=g.prototype,b=c?o:p?o[e]:(o[e]||{}).prototype;c&&(i=e);for(h in i)(d=!u&&b&&void 0!==b[h])&&h in g||(l=d?b[h]:i[h],g[h]=c&&"function"!=typeof b[h]?i[h]:m&&d?s(l,o):v&&b[h]==l?function(t){var e=function(e,i,o){if(this instanceof t){switch(arguments.length){case 0:return new t;case 1:return new t(e);case 2:return new t(e,i)}return new t(e,i,o)}return t.apply(this,arguments)};return e.prototype=t.prototype,e}(l):f&&"function"==typeof l?s(Function.call,l):l,f&&((g.virtual||(g.virtual={}))[h]=l,t&a.R&&y&&!y[h]&&r(y,h,l)))};a.F=1,a.G=2,a.S=4,a.P=8,a.B=16,a.W=32,a.U=64,a.R=128,t.exports=a},function(t,e){var i=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=i)},function(t,e,i){t.exports={default:i(160),__esModule:!0}},function(t,e,i){var o=i(27),n=i(81),s=i(53),r=Object.defineProperty;e.f=i(21)?Object.defineProperty:function(t,e,i){if(o(t),e=s(e,!0),o(i),n)try{return r(t,e,i)}catch(t){}if("get"in i||"set"in i)throw TypeError("Accessors not supported!");return"value"in i&&(t[e]=i.value),t}},function(t,e,i){t.exports=!i(28)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(t,e){var i={}.hasOwnProperty;t.exports=function(t,e){return i.call(t,e)}},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(6),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=function(){function t(e,i,o){(0,a.default)(this,t),this.body=i,this.labelModule=o,this.setOptions(e),this.top=void 0,this.left=void 0,this.height=void 0,this.width=void 0,this.radius=void 0,this.margin=void 0,this.refreshNeeded=!0,this.boundingBox={top:0,left:0,right:0,bottom:0}}return(0,d.default)(t,[{key:"setOptions",value:function(t){this.options=t}},{key:"_setMargins",value:function(t){this.margin={},this.options.margin&&("object"==(0,s.default)(this.options.margin)?(this.margin.top=this.options.margin.top,this.margin.right=this.options.margin.right,this.margin.bottom=this.options.margin.bottom,this.margin.left=this.options.margin.left):(this.margin.top=this.options.margin,this.margin.right=this.options.margin,this.margin.bottom=this.options.margin,this.margin.left=this.options.margin)),t.adjustSizes(this.margin)}},{key:"_distanceToBorder",value:function(t,e){var i=this.options.borderWidth;return this.resize(t),Math.min(Math.abs(this.width/2/Math.cos(e)),Math.abs(thiLine truncated
;var s=this.options.showMinorLabels&&"none"!==this.options.orientation.axis,r=this.options.showMajorLabels&&"none"!==this.options.orientation.axis;t.minorLabelHeight=s?t.minorCharHeight:0,t.majorLabelHeight=r?t.majorCharHeight:0,t.height=t.minorLabelHeight+t.majorLabelHeight,t.width=e.offsetWidth,t.minorLineHeight=this.body.domProps.root.height-t.majorLabelHeight-("top"==this.options.orientation.axis?this.body.domProps.bottom.height:this.body.domProps.top.height),t.minorLineWidth=1,t.majorLineHeight=t.minorLineHeight+t.majorLabelHeight,t.majorLineWidth=1;var a=e.nextSibling,h=i.nextSibling;return e.parentNode&&e.parentNode.removeChild(e),i.parentNode&&i.parentNode.removeChild(i),e.style.height=this.props.height+"px",this._repaintLabels(),a?o.insertBefore(e,a):o.appendChild(e),h?this.body.dom.backgroundVertical.insertBefore(i,h):this.body.dom.backgroundVertical.appendChild(i),this._isResized()||n},o.prototype._repaintLabels=function(){var t=this.options.orientation.axis,e=r.convert(this.body.range.start,"Number"),i=r.convert(this.body.range.end,"Number"),o=this.body.util.toTime((this.props.minorCharWidth||10)*this.options.maxMinorChars).valueOf(),n=o-d.getHiddenDurationBefore(this.options.moment,this.body.hiddenDates,this.body.range,o);n-=this.body.util.toTime(0).valueOf();var s=new h(new Date(e),new Date(i),n,this.body.hiddenDates,this.options);s.setMoment(this.options.moment),this.options.format&&s.setFormat(this.options.format),this.options.timeAxis&&s.setScale(this.options.timeAxis),this.step=s;var a=this.dom;a.redundant.lines=a.lines,a.redundant.majorTexts=a.majorTexts,a.redundant.minorTexts=a.minorTexts,a.lines=[],a.majorTexts=[],a.minorTexts=[];var l,c,p,f,m,v,g,y,b,_=0,w=void 0,x=0;for(s.start(),l=s.getCurrent(),p=this.body.util.toScreen(l);s.hasNext()&&x<1e3;){switch(x++,f=s.isMajor(),b=s.getClassName(),y=s.getLabelMinor(),l,c=p,s.next(),l=s.getCurrent(),s.isMajor(),p=this.body.util.toScreen(l),v=_,_=p-c,s.scale){case"week":m=!0;break;default:m=_>=.4*v}if(this.options.showMinorLabels&&m){var k=this._repaintMinorText(c,y,t,b);k.style.width=_+"px"}f&&this.options.showMajorLabels?(c>0&&(void 0==w&&(w=c),k=this._repaintMajorText(c,s.getLabelMajor(),t,b)),g=this._repaintMajorLine(c,_,t,b)):m?g=this._repaintMinorLine(c,_,t,b):g&&(g.style.width=parseInt(g.style.width)+_+"px")}if(1e3!==x||u||(console.warn("Something is wrong with the Timeline scale. Limited drawing of grid lines to 1000 lines."),u=!0),this.options.showMajorLabels){var S=this.body.util.toTime(0),D=s.getLabelMajor(S),M=D.length*(this.props.majorCharWidth||10)+10;(void 0==w||M<w)&&this._repaintMajorText(0,D,t,b)}r.forEach(this.dom.redundant,function(t){for(;t.length;){var e=t.pop();e&&e.parentNode&&e.parentNode.removeChild(e)}})},o.prototype._repaintMinorText=function(t,e,i,o){var n=this.dom.redundant.minorTexts.shift();if(!n){var s=document.createTextNode("");n=document.createElement("div"),n.appendChild(s),this.dom.foreground.appendChild(n)}return this.dom.minorTexts.push(n),n.innerHTML=e,n.style.top="top"==i?this.props.majorLabelHeight+"px":"0",this.options.rtl?(n.style.left="",n.style.right=t+"px"):n.style.left=t+"px",n.className="vis-text vis-minor "+o,n},o.prototype._repaintMajorText=function(t,e,i,o){var n=this.dom.redundant.majorTexts.shift();if(!n){var s=document.createElement("div");n=document.createElement("div"),n.appendChild(s),this.dom.foreground.appendChild(n)}return n.childNodes[0].innerHTML=e,n.className="vis-text vis-major "+o,n.style.top="top"==i?"0":this.props.minorLabelHeight+"px",this.options.rtl?(n.style.left="",n.style.right=t+"px"):n.style.left=t+"px",this.dom.majorTexts.push(n),n},o.prototype._repaintMinorLine=function(t,e,i,o){var n=this.dom.redundant.lines.shift();n||(n=document.createElement("div"),this.dom.background.appendChild(n)),this.dom.lines.push(n);var s=this.props;return n.style.top="top"==i?s.majorLabelHeight+"px":this.body.domProps.top.height+"px",n.style.height=s.minorLineHeight+"px",this.options.rtl?(n.style.left="",n.style.right=t-s.minorLineWidth/2+"px",n.className="vis-grid vis-vertical-rtl vis-minor "+o):(n.style.left=t-s.minorLineWidth/2+"px",n.className="vis-grid vis-vertical vis-minor "+o),n.style.width=e+"px",n},o.prototype._repaintMajorLine=function(t,e,i,o){var n=this.dom.redundant.lines.shift();n||(n=document.createElement("div"),this.dom.background.appendChild(n)),this.dom.lines.push(n);var s=this.props;return n.style.top="top"==i?"0":this.body.domProps.top.height+"px",this.options.rtl?(n.style.left="",n.style.right=t-s.majorLineWidth/2+"px",n.className="vis-grid vis-vertical-rtl vis-major "+o):(n.style.left=t-s.majorLineWidth/2+"px",n.className="vis-grid vis-vertical vis-major "+o),n.style.height=s.majorLineHeight+"px",n.style.width=e+"px",n},o.prototype._calculateCharSize=function(){this.dom.measureCharMinor||(this.dom.measureCharMinor=document.createElement("DIV"),this.dom.measureCharMinor.className="vis-text vis-minor vis-measure",this.dom.measureCharMinor.style.position="absolute",thiLine truncated
;var d=m.snapAwayFromHidden(this.body.hiddenDates,a,i,!0),l=m.snapAwayFromHidden(this.body.hiddenDates,h,-i,!0);d==a&&l==h||(a=d,h=l);var u={animation:!1,byUser:!0,event:o};this.setRange(a,h,u),this.startToFront=!1,this.endToFront=!0},n.prototype.move=function(t){var e=this.end-this.start,i=this.start+e*t,o=this.end+e*t;this.start=i,this.end=o},n.prototype.moveTo=function(t){var e=(this.start+this.end)/2,i=e-t,o=this.start-i,n=this.end-i,s={animation:!1,byUser:!0,event:null};this.setRange(o,n,s)},t.exports=n},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}function n(){}var s=i(19),r=o(s),a=i(6),h=o(a),d=i(44),l=i(10),u=i(37),c=i(2),p=i(45),f=i(97),m=i(36),v=i(46);d(n.prototype),n.prototype._create=function(t){function e(t){this.isActive()&&this.emit("mousewheel",t);var e=0,i=0;if("detail"in t&&(i=-1*t.detail),"wheelDelta"in t&&(i=t.wheelDelta),"wheelDeltaY"in t&&(i=t.wheelDeltaY),"wheelDeltaX"in t&&(e=-1*t.wheelDeltaX),"axis"in t&&t.axis===t.HORIZONTAL_AXIS&&(e=-1*i,i=0),"deltaY"in t&&(i=-1*t.deltaY),"deltaX"in t&&(e=t.deltaX),this.options.zoomKey&&!t[this.options.zoomKey])if(t.preventDefault(),this.options.verticalScroll&&Math.abs(i)>=Math.abs(e)){var o=this.props.scrollTop,n=o+i;this.isActive()&&(this._setScrollTop(n),this._redraw(),this.emit("scroll",t))}else if(this.options.horizontalScroll){var s=Math.abs(e)>=Math.abs(i)?e:i,r=s/120*(this.range.end-this.range.start)/20,a=this.range.start+r,h=this.range.end+r,d={animation:!1,byUser:!0,event:t};this.range.setRange(a,h,d)}}function i(t){if(s.options.verticalScroll&&(t.preventDefault(),s.isActive())){var e=-t.target.scrollTop;s._setScrollTop(e),s._redraw(),s.emit("scrollSide",t)}}function o(t){if(t.preventDefault&&t.preventDefault(),!(!t.target.className.indexOf("vis")>-1||a))return t.dataTransfer.dropEffect="move",a=!0,!1}function n(t){t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation();try{var e=JSON.parse(t.dataTransfer.getData("text"));if(!e||!e.content)return}catch(t){return!1}return a=!1,t.center={x:t.clientX,y:t.clientY},"item"!==e.target?s.itemSet._onAddItem(t):s.itemSet._onDropObjectOnItem(t),s.emit("drop",s.getEventProperties(t)),!1}this.dom={},this.dom.container=t,this.dom.root=document.createElement("div"),this.dom.background=document.createElement("div"),this.dom.backgroundVertical=document.createElement("div"),this.dom.backgroundHorizontal=document.createElement("div"),this.dom.centerContainer=document.createElement("div"),this.dom.leftContainer=document.createElement("div"),this.dom.rightContainer=document.createElement("div"),this.dom.center=document.createElement("div"),this.dom.left=document.createElement("div"),this.dom.right=document.createElement("div"),this.dom.top=document.createElement("div"),this.dom.bottom=document.createElement("div"),this.dom.shadowTop=document.createElement("div"),this.dom.shadowBottom=document.createElement("div"),this.dom.shadowTopLeft=document.createElement("div"),this.dom.shadowBottomLeft=document.createElement("div"),this.dom.shadowTopRight=document.createElement("div"),this.dom.shadowBottomRight=document.createElement("div"),this.dom.rollingModeBtn=document.createElement("div"),this.dom.root.className="vis-timeline",this.dom.background.className="vis-panel vis-background",this.dom.backgroundVertical.className="vis-panel vis-background vis-vertical",this.dom.backgroundHorizontal.className="vis-panel vis-background vis-horizontal",this.dom.centerContainer.className="vis-panel vis-center",this.dom.leftContainer.className="vis-panel vis-left",this.dom.rightContainer.className="vis-panel vis-right",this.dom.top.className="vis-panel vis-top",this.dom.bottom.className="vis-panel vis-bottom",this.dom.left.className="vis-content",this.dom.center.className="vis-content",this.dom.right.className="vis-content",this.dom.shadowTop.className="vis-shadow vis-top",this.dom.shadowBottom.className="vis-shadow vis-bottom",this.dom.shadowTopLeft.className="vis-shadow vis-top",this.dom.shadowBottomLeft.className="vis-shadow vis-bottom",this.dom.shadowTopRight.className="vis-shadow vis-top",this.dom.shadowBottomRight.className="vis-shadow vis-bottom",this.dom.rollingModeBtn.className="vis-rolling-mode-btn",this.dom.root.appendChild(this.dom.background),this.dom.root.appendChild(this.dom.backgroundVertical),this.dom.root.appendChild(this.dom.backgroundHorizontal),this.dom.root.appendChild(this.dom.centerContainer),this.dom.root.appendChild(this.dom.leftContainer),this.dom.root.appendChild(this.dom.rightContainer),this.dom.root.appendChild(this.dom.top),this.dom.root.appendChild(this.dom.bottom),this.dom.root.appendChild(this.dom.bottom),this.dom.root.appendChild(this.dom.rollingModeBtn),this.dom.centerContainer.appendChild(this.dom.center),this.dom.leftContainer.appendChild(this.dom.left),this.dom.rightContainer.appendChild(this.dom.right),this.dom.centerContainer.appendChild(this.dom.shadowTop),this.dom.centerContainer.appendChild(this.dom.shadowBottom),this.dom.leftContainer.appendCLine truncated
h.push(t(r.date()-1));break;case"week":h.push("vis-week"+r.format("w")),h.push(i(r)),h.push(t(r.week()));break;case"month":h.push("vis-"+r.format("MMMM").toLowerCase()),h.push(o(r)),h.push(t(r.month()));break;case"year":h.push("vis-year"+r.year()),h.push(function(t){return t.isSame(new Date,"year")?" vis-current-year":""}(r)),h.push(t(r.year()))}return h.filter(String).join(" ")},t.exports=o},function(t,e,i){function o(t,e){this.body=t,this.defaultOptions={rtl:!1,showCurrentTime:!0,moment:r,locales:a,locale:"en"},this.options=n.extend({},this.defaultOptions),this.offset=0,this._create(),this.setOptions(e)}var n=i(2),s=i(16),r=i(9),a=i(98);o.prototype=new s,o.prototype._create=function(){var t=document.createElement("div");t.className="vis-current-time",t.style.position="absolute",t.style.top="0px",t.style.height="100%",this.bar=t},o.prototype.destroy=function(){this.options.showCurrentTime=!1,this.redraw(),this.body=null},o.prototype.setOptions=function(t){t&&n.selectiveExtend(["rtl","showCurrentTime","moment","locale","locales"],this.options,t)},o.prototype.redraw=function(){if(this.options.showCurrentTime){var t=this.body.dom.backgroundVertical;this.bar.parentNode!=t&&(this.bar.parentNode&&this.bar.parentNode.removeChild(this.bar),t.appendChild(this.bar),this.start());var e=this.options.moment((new Date).valueOf()+this.offset),i=this.body.util.toScreen(e),o=this.options.locales[this.options.locale];o||(this.warned||(console.log("WARNING: options.locales['"+this.options.locale+"'] not found. See http://visjs.org/docs/timeline/#Localization"),this.warned=!0),o=this.options.locales.en);var n=o.current+" "+o.time+": "+e.format("dddd, MMMM Do YYYY, H:mm:ss");n=n.charAt(0).toUpperCase()+n.substring(1),this.options.rtl?this.bar.style.right=i+"px":this.bar.style.left=i+"px",this.bar.title=n}else this.bar.parentNode&&this.bar.parentNode.removeChild(this.bar),this.stop();return!1},o.prototype.start=function(){function t(){e.stop();var i=e.body.range.conversion(e.body.domProps.center.width).scale,o=1/i/10;o<30&&(o=30),o>1e3&&(o=1e3),e.redraw(),e.body.emitter.emit("currentTimeTick"),e.currentTimeTimer=setTimeout(t,o)}var e=this;t()},o.prototype.stop=function(){void 0!==this.currentTimeTimer&&(clearTimeout(this.currentTimeTimer),delete this.currentTimeTimer)},o.prototype.setCurrentTime=function(t){var e=n.convert(t,"Date").valueOf(),i=(new Date).valueOf();this.offset=e-i,this.redraw()},o.prototype.getCurrentTime=function(){return new Date((new Date).valueOf()+this.offset)},t.exports=o},function(t,e,i){function o(t,e,i){if(this.groupId=t,this.subgroups={},this.subgroupStack={},this.subgroupStackAll=!1,this.doInnerStack=!1,this.subgroupIndex=0,this.subgroupOrderer=e&&e.subgroupOrder,this.itemSet=i,this.isVisible=null,this.stackDirty=!0,e&&e.nestedGroups&&(this.nestedGroups=e.nestedGroups,0==e.showNested?this.showNested=!1:this.showNested=!0),e&&e.subgroupStack)if("boolean"==typeof e.subgroupStack)this.doInnerStack=e.subgroupStack,this.subgroupStackAll=e.subgroupStack;else for(var o in e.subgroupStack)this.subgroupStack[o]=e.subgroupStack[o],this.doInnerStack=this.doInnerStack||e.subgroupStack[o];this.nestedInGroup=null,this.dom={},this.props={label:{width:0,height:0}},this.className=null,this.items={},this.visibleItems=[],this.itemsInRange=[],this.orderedItems={byStart:[],byEnd:[]},this.checkRangedItems=!1;var n=this;this.itemSet.body.emitter.on("checkRangedItems",function(){n.checkRangedItems=!0}),this._create(),this.setData(e)}var n=i(8),s=function(t){return t&&t.__esModule?t:{default:t}}(n),r=i(2),a=i(100);o.prototype._create=function(){var t=document.createElement("div");this.itemSet.options.groupEditable.order?t.className="vis-label draggable":t.className="vis-label",this.dom.label=t;var e=document.createElement("div");e.className="vis-inner",t.appendChild(e),this.dom.inner=e;var i=document.createElement("div");i.className="vis-group",i["timeline-group"]=this,this.dom.foreground=i,this.dom.background=document.createElement("div"),this.dom.background.className="vis-group",this.dom.axis=document.createElement("div"),this.dom.axis.className="vis-group",this.dom.marker=document.createElement("div"),this.dom.marker.style.visibility="hidden",this.dom.marker.style.position="absolute",this.dom.marker.innerHTML="",this.dom.background.appendChild(this.dom.marker)},o.prototype.setData=function(t){var e,i;if(this.itemSet.options&&this.itemSet.options.groupTemplate?(i=this.itemSet.options.groupTemplate.bind(this),e=i(t,this.dom.inner)):e=t&&t.content,e instanceof Element){for(this.dom.inner.appendChild(e);this.dom.inner.firstChild;)this.dom.inner.removeChild(this.dom.inner.firstChild);this.dom.inner.appendChild(e)}else e instanceof Object?i(t,this.dom.inner):this.dom.inner.innerHTML=void 0!==e&&null!==e?e:this.groupId||"";if(this.dom.label.title=t&&t.title||"",this.dom.inner.firstChild?r.removeClassName(this.dom.inner,"vis-hidden"):r.addClassName(this.dom.inner,"vis-hidden"),t&&t.nestedGroups){this.nestedGroups&&this.nestedGroLine truncated
styles:e.styles||t.options.drawPoints.styles,size:e.size||t.options.drawPoints.size,className:e.className||t.className}}function s(t,e){var i=void 0;return t.options&&t.options.drawPoints&&t.options.drawPoints.onRender&&"function"==typeof t.options.drawPoints.onRender&&(i=t.options.drawPoints.onRender),e.group.options&&e.group.options.drawPoints&&e.group.options.drawPoints.onRender&&"function"==typeof e.group.options.drawPoints.onRender&&(i=e.group.options.drawPoints.onRender),i}var r=i(6),a=function(t){return t&&t.__esModule?t:{default:t}}(r),h=i(14);o.draw=function(t,e,i,o){o=o||0;for(var r=s(i,e),d=0;d<t.length;d++)if(r){var l=r(t[d],e);!0!==l&&"object"!==(void 0===l?"undefined":(0,a.default)(l))||h.drawPoint(t[d].screen_x+o,t[d].screen_y,n(e,l),i.svgElements,i.svg,t[d].label)}else h.drawPoint(t[d].screen_x+o,t[d].screen_y,n(e),i.svgElements,i.svg,t[d].label)},o.drawIcon=function(t,e,i,o,s,r){var a=.5*s,d=h.getSVGElement("rect",r.svgElements,r.svg);d.setAttributeNS(null,"x",e),d.setAttributeNS(null,"y",i-a),d.setAttributeNS(null,"width",o),d.setAttributeNS(null,"height",2*a),d.setAttributeNS(null,"class","vis-outline"),h.drawPoint(e+.5*o,i,n(t),r.svgElements,r.svg)},t.exports=o},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(23),m=o(f),v=function(t){function e(t,i,o){(0,a.default)(this,e);var n=(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o));return n.labelOffset=0,n.selected=!1,n}return(0,p.default)(e,t),(0,d.default)(e,[{key:"setOptions",value:function(t,e,i){this.options=t,void 0===e&&void 0===i||this.setImages(e,i)}},{key:"setImages",value:function(t,e){e&&this.selected?(this.imageObj=e,this.imageObjAlt=t):(this.imageObj=t,this.imageObjAlt=e)}},{key:"switchImages",value:function(t){var e=t&&!this.selected||!t&&this.selected;if(this.selected=t,void 0!==this.imageObjAlt&&e){var i=this.imageObj;this.imageObj=this.imageObjAlt,this.imageObjAlt=i}}},{key:"_resizeImage",value:function(){var t,e;if(!1===this.options.shapeProperties.useImageSize){var i=1,o=1;this.imageObj.width&&this.imageObj.height&&(this.imageObj.width>this.imageObj.height?i=this.imageObj.width/this.imageObj.height:o=this.imageObj.height/this.imageObj.width),t=2*this.options.size*i,e=2*this.options.size*o}else t=this.imageObj.width,e=this.imageObj.height;this.width=t,this.height=e,this.radius=.5*this.width}},{key:"_drawRawCircle",value:function(t,e,i,o){this.initContextForDraw(t,o),t.circle(e,i,o.size),this.performFill(t,o)}},{key:"_drawImageAtPosition",value:function(t,e){if(0!=this.imageObj.width){t.globalAlpha=1,this.enableShadow(t,e);var i=1;!0===this.options.shapeProperties.interpolation&&(i=this.imageObj.width/this.width/this.body.view.scale),this.imageObj.drawImageAtPosition(t,i,this.left,this.top,this.width,this.height),this.disableShadow(t,e)}}},{key:"_drawImageLabel",value:function(t,e,i,o,n){var s,r=0;if(void 0!==this.height){r=.5*this.height;var a=this.labelModule.getTextSize(t,o,n);a.lineCount>=1&&(r+=a.height/2)}s=i+r,this.options.label&&(this.labelOffset=r),this.labelModule.draw(t,e,s,o,n,"hanging")}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(19),s=o(n),r=i(6),a=o(r),h=i(29),d=o(h),l=i(0),u=o(l),c=i(1),p=o(c),f=i(2),m=i(117).default,v=i(48).default,g=i(215).default,y=i(217).default,b=i(218).default,_=i(219).default,w=function(){function t(e,i,o,n){if((0,u.default)(this,t),void 0===i)throw new Error("No body provided");this.options=f.bridgeObject(o),this.globalOptions=o,this.defaultOptions=n,this.body=i,this.id=void 0,this.fromId=void 0,this.toId=void 0,this.selected=!1,this.hover=!1,this.labelDirty=!0,this.baseWidth=this.options.width,this.baseFontSize=this.options.font.size,this.from=void 0,this.to=void 0,this.edgeType=void 0,this.connected=!1,this.labelModule=new m(this.body,this.options,!0),this.setOptions(e)}return(0,p.default)(t,[{key:"setOptions",value:function(e){if(e){t.parseOptions(this.options,e,!0,this.globalOptions),void 0!==e.id&&(this.id=e.id),void 0!==e.from&&(this.fromId=e.from),void 0!==e.to&&(this.toId=e.to),void 0!==e.title&&(this.title=e.title),void 0!==e.value&&(e.value=parseFloat(e.value));var i=[e,this.options,this.defaultOptions];this.chooser=v.choosify("edge",i),this.updateLabelModule(e);var o=this.updateEdgeType();return this._setInteractionWidths(),this.connect(),void 0===e.hidden&&void 0===e.physics||(o=!0),o}}},{key:"getFormattingValues",value:function(){var t=!0===this.options.arrows.to||!0===this.options.arrows.to.enabled,e=!0===this.options.arrows.from||!0===this.options.arrows.from.enabled,i=!0===this.options.arrows.middle||!0===this.options.arrows.middle.enabled,o=this.options.color.inherit,n={toArrow:t,toArrowScale:this.options.arrows.to.scaleFactor,toArrowType:this.options.arrows.to.type,middleArrow:i,middleArrowScalLine truncated
o.prototype.getArmLength=function(){return this.armLength},o.prototype.getCameraLocation=function(){return this.cameraLocation},o.prototype.getCameraRotation=function(){return this.cameraRotation},o.prototype.calculateCameraOrientation=function(){this.cameraLocation.x=this.armLocation.x-this.armLength*Math.sin(this.armRotation.horizontal)*Math.cos(this.armRotation.vertical),this.cameraLocation.y=this.armLocation.y-this.armLength*Math.cos(this.armRotation.horizontal)*Math.cos(this.armRotation.vertical),this.cameraLocation.z=this.armLocation.z+this.armLength*Math.sin(this.armRotation.vertical),this.cameraRotation.x=Math.PI/2-this.armRotation.vertical,this.cameraRotation.y=0,this.cameraRotation.z=-this.armRotation.horizontal;var t=this.cameraRotation.x,e=this.cameraRotation.z,i=this.cameraOffset.x,o=this.cameraOffset.y,n=Math.sin,s=Math.cos;this.cameraLocation.x=this.cameraLocation.x+i*s(e)+o*-n(e)*s(t),this.cameraLocation.y=this.cameraLocation.y+i*n(e)+o*s(e)*s(t),this.cameraLocation.z=this.cameraLocation.z+o*n(t)},t.exports=o},function(t,e,i){function o(t,e,i){this.dataGroup=t,this.column=e,this.graph=i,this.index=void 0,this.value=void 0,this.values=t.getDistinctValues(this.column),this.values.length>0&&this.selectValue(0),this.dataPoints=[],this.loaded=!1,this.onLoadCallback=void 0,i.animationPreload?(this.loaded=!1,this.loadInBackground()):this.loaded=!0}var n=i(12);o.prototype.isLoaded=function(){return this.loaded},o.prototype.getLoadedProgress=function(){for(var t=this.values.length,e=0;this.dataPoints[e];)e++;return Math.round(e/t*100)},o.prototype.getLabel=function(){return this.graph.filterLabel},o.prototype.getColumn=function(){return this.column},o.prototype.getSelectedValue=function(){if(void 0!==this.index)return this.values[this.index]},o.prototype.getValues=function(){return this.values},o.prototype.getValue=function(t){if(t>=this.values.length)throw new Error("Index out of range");return this.values[t]},o.prototype._getDataPoints=function(t){if(void 0===t&&(t=this.index),void 0===t)return[];var e;if(this.dataPoints[t])e=this.dataPoints[t];else{var i={};i.column=this.column,i.value=this.values[t];var o=new n(this.dataGroup.getDataSet(),{filter:function(t){return t[i.column]==i.value}}).get();e=this.dataGroup._getDataPoints(o),this.dataPoints[t]=e}return e},o.prototype.setOnLoadCallback=function(t){this.onLoadCallback=t},o.prototype.selectValue=function(t){if(t>=this.values.length)throw new Error("Index out of range");this.index=t,this.value=this.values[t]},o.prototype.loadInBackground=function(t){void 0===t&&(t=0);var e=this.graph.frame;if(t<this.values.length){void 0===e.progress&&(e.progress=document.createElement("DIV"),e.progress.style.position="absolute",e.progress.style.color="gray",e.appendChild(e.progress));var i=this.getLoadedProgress();e.progress.innerHTML="Loading animation... "+i+"%",e.progress.style.bottom="60px",e.progress.style.left="10px";var o=this;setTimeout(function(){o.loadInBackground(t+1)},10),this.loaded=!1}else this.loaded=!0,void 0!==e.progress&&(e.removeChild(e.progress),e.progress=void 0),this.onLoadCallback&&this.onLoadCallback()},t.exports=o},function(t,e,i){function o(t){this.active=!1,this.dom={container:t},this.dom.overlay=document.createElement("div"),this.dom.overlay.className="vis-overlay",this.dom.container.appendChild(this.dom.overlay),this.hammer=a(this.dom.overlay),this.hammer.on("tap",this._onTapOverlay.bind(this));var e=this;["tap","doubletap","press","pinch","pan","panstart","panmove","panend"].forEach(function(t){e.hammer.on(t,function(t){t.stopPropagation()})}),document&&document.body&&(this.onClick=function(i){n(i.target,t)||e.deactivate()},document.body.addEventListener("click",this.onClick)),void 0!==this.keycharm&&this.keycharm.destroy(),this.keycharm=s(),this.escListener=this.deactivate.bind(this)}function n(t,e){for(;t;){if(t===e)return!0;t=t.parentNode}return!1}var s=i(35),r=i(44),a=i(10),h=i(2);r(o.prototype),o.current=null,o.prototype.destroy=function(){this.deactivate(),this.dom.overlay.parentNode.removeChild(this.dom.overlay),this.onClick&&document.body.removeEventListener("click",this.onClick),this.hammer.destroy(),this.hammer=null},o.prototype.activate=function(){o.current&&o.current.deactivate(),o.current=this,this.active=!0,this.dom.overlay.style.display="none",h.addClassName(this.dom.container,"vis-active"),this.emit("change"),this.emit("activate"),this.keycharm.bind("esc",this.escListener)},o.prototype.deactivate=function(){this.active=!1,this.dom.overlay.style.display="",h.removeClassName(this.dom.container,"vis-active"),this.keycharm.unbind("esc",this.escListener),this.emit("change"),this.emit("deactivate")},o.prototype._onTapOverlay=function(t){this.activate(),t.stopPropagation()},t.exports=o},function(t,e,i){e.en={current:"current",time:"time"},e.en_EN=e.en,e.en_US=e.en,e.it={current:"attuale",time:"tempo"},e.it_IT=e.it,e.it_CH=e.it,e.nl={current:"huidige",time:"tijd"},e.nl_NL=e.nl,e.nl_BE=e.nl,e.de={current:"Aktuelle",time:"ZeiLine truncated
var i=this.options.multiselect?this.getSelection():[];if((t.srcEvent&&t.srcEvent.shiftKey||!1)&&this.options.multiselect){var o=this.itemsData.get(e.id).group,s=void 0;this.options.multiselectPerGroup&&i.length>0&&(s=this.itemsData.get(i[0]).group),this.options.multiselectPerGroup&&void 0!=s&&s!=o||i.push(e.id);var r=n._getItemRange(this.itemsData.get(i,this.itemOptions));if(!this.options.multiselectPerGroup||s==o){i=[];for(var a in this.items)if(this.items.hasOwnProperty(a)){var h=this.items[a],d=h.data.start,l=void 0!==h.data.end?h.data.end:d;!(d>=r.min&&l<=r.max)||this.options.multiselectPerGroup&&s!=this.itemsData.get(h.id).group||h instanceof _||i.push(h.id)}}}else{var u=i.indexOf(e.id);-1==u?i.push(e.id):i.splice(u,1)}this.setSelection(i),this.body.emitter.emit("select",{items:this.getSelection(),event:t})}}},n._getItemRange=function(t){var e=null,i=null;return t.forEach(function(t){(null==i||t.start<i)&&(i=t.start),void 0!=t.end?(null==e||t.end>e)&&(e=t.end):(null==e||t.start>e)&&(e=t.start)}),{min:i,max:e}},n.prototype.itemFromElement=function(t){for(var e=t;e;){if(e.hasOwnProperty("timeline-item"))return e["timeline-item"];e=e.parentNode}return null},n.prototype.itemFromTarget=function(t){return this.itemFromElement(t.target)},n.prototype.itemFromRelatedTarget=function(t){return this.itemFromElement(t.relatedTarget)},n.prototype.groupFromTarget=function(t){var e=t.center?t.center.y:t.clientY,i=this.groupIds;i.length<=0&&this.groupsData&&(i=this.groupsData.getIds({order:this.options.groupOrder}));for(var o=0;o<i.length;o++){var n=i[o],s=this.groups[n],r=s.dom.foreground,a=l.getAbsoluteTop(r);if(e>a&&e<a+r.offsetHeight)return s;if("top"===this.options.orientation.item){if(o===this.groupIds.length-1&&e>a)return s}else if(0===o&&e<a+r.offset)return s}return null},n.itemSetFromTarget=function(t){for(var e=t.target;e;){if(e.hasOwnProperty("timeline-itemset"))return e["timeline-itemset"];e=e.parentNode}return null},n.prototype._cloneItemData=function(t,e){var i=l.extend({},t);return e||(e=this.itemsData.getDataSet()._options.type),void 0!=i.start&&(i.start=l.convert(i.start,e&&e.start||"Date")),void 0!=i.end&&(i.end=l.convert(i.end,e&&e.end||"Date")),i},t.exports=n},function(t,e,i){e.orderByStart=function(t){t.sort(function(t,e){return t.data.start-e.data.start})},e.orderByEnd=function(t){t.sort(function(t,e){return("end"in t.data?t.data.end:t.data.start)-("end"in e.data?e.data.end:e.data.start)})},e.stack=function(t,i,o){if(o)for(var n=0;n<t.length;n++)t[n].top=null;for(var n=0;n<t.length;n++){var s=t[n];if(s.stack&&null===s.top){s.top=i.axis;do{for(var r=null,a=0,h=t.length;a<h;a++){var d=t[a];if(null!==d.top&&d!==s&&d.stack&&e.collision(s,d,i.item,d.options.rtl)){r=d;break}}null!=r&&(s.top=r.top+r.height+i.item.vertical)}while(r)}}},e.substack=function(t,i,o){for(var n=0;n<t.length;n++)t[n].top=null;var s=o.height;for(n=0;n<t.length;n++){var r=t[n];if(r.stack&&null===r.top){r.top=r.baseTop;do{for(var a=null,h=0,d=t.length;h<d;h++){var l=t[h];if(null!==l.top&&l!==r&&e.collision(r,l,i.item,l.options.rtl)){a=l;break}}null!=a&&(r.top=a.top+a.height+i.item.vertical),r.top+r.height>s&&(s=r.top+r.height)}while(a)}}o.height=s-o.top+.5*i.item.vertical},e.nostack=function(t,i,o,n){for(var s=0;s<t.length;s++)if(void 0==t[s].data.subgroup)t[s].top=i.item.vertical;else if(void 0!==t[s].data.subgroup&&n){var r=0;for(var a in o)o.hasOwnProperty(a)&&1==o[a].visible&&o[a].index<o[t[s].data.subgroup].index&&(r+=o[a].height,o[t[s].data.subgroup].top=r);t[s].top=r+.5*i.item.vertical}n||e.stackSubgroups(t,i,o)},e.stackSubgroups=function(t,i,o){for(var n in o)if(o.hasOwnProperty(n)){o[n].top=0;do{var s=null;for(var r in o)if(null!==o[r].top&&r!==n&&o[n].index>o[r].index&&e.collisionByTimes(o[n],o[r])){s=o[r];break}null!=s&&(o[n].top=s.top+s.height)}while(s)}for(var a=0;a<t.length;a++)void 0!==t[a].data.subgroup&&(t[a].top=o[t[a].data.subgroup].top+.5*i.item.vertical)},e.stackSubgroupsWithInnerStack=function(t,i,o){var n=!1,s=[];for(var r in o)o[r].hasOwnProperty("index")?s[o[r].index]=r:s.push(r);for(var a=0;a<s.length;a++)if(r=s[a],o.hasOwnProperty(r)){n=n||o[r].stack,o[r].top=0;for(var h in o)o[h].visible&&o[r].index>o[h].index&&(o[r].top+=o[h].height);for(var d=t[r],l=0;l<d.length;l++)void 0!==d[l].data.subgroup&&(d[l].top=o[d[l].data.subgroup].top+.5*i.item.vertical,o[r].stack&&(d[l].baseTop=d[l].top));n&&o[r].stack&&e.substack(t[r],i,o[r])}},e.collision=function(t,e,i,o){return o?t.right-i.horizontal+.001<e.right+e.width&&t.right+t.width+i.horizontal-.001>e.right&&t.top-i.vertical+.001<e.top+e.height&&t.top+t.height+i.vertical-.001>e.top:t.left-i.horizontal+.001<e.left+e.width&&t.left+t.width+i.horizontal-.001>e.left&&t.top-i.vertical+.001<e.top+e.height&&t.top+t.height+i.vertical-.001>e.top},e.collisionByTimes=function(t,e){return t.start<=e.start&&t.end>=e.start&&t.top<e.top+e.height&&t.top+t.height>e.top||e.start<=t.start&&e.end>=t.start&&e.top<t.top+t.height&&e.top+e.height>t.top}},function(t,e,i){function o(t,e,iLine truncated
f[n]=d[n];var m=i[l].length-a[l]--;i[l][m]=f}for(l in this.groups)this.groups.hasOwnProperty(l)&&(i.hasOwnProperty(l)||(i[l]=new Array(0)));for(l in i)if(i.hasOwnProperty(l))if(0==i[l].length)this.groups.hasOwnProperty(l)&&this._removeGroup(l);else{var v=void 0;void 0!=this.groupsData&&(v=this.groupsData.get(l)),void 0==v&&(v={id:l,content:this.options.defaultGroup+l}),this._updateGroup(v,l),this.groups[l].setItems(i[l])}this.forceGraphUpdate=!0,this.body.emitter.emit("_change",{queue:!0})}},o.prototype.redraw=function(){var t=!1;this.props.width=this.dom.frame.offsetWidth,this.props.height=this.body.domProps.centerContainer.height-this.body.domProps.border.top-this.body.domProps.border.bottom,t=this._isResized()||t;var e=this.body.range.end-this.body.range.start,i=e!=this.lastVisibleInterval;if(this.lastVisibleInterval=e,1==t&&(this.svg.style.width=r.option.asSize(3*this.props.width),this.svg.style.left=r.option.asSize(-this.props.width),-1==(this.options.height+"").indexOf("%")&&1!=this.updateSVGheightOnResize||(this.updateSVGheight=!0)),1==this.updateSVGheight?(this.options.graphHeight!=this.props.height+"px"&&(this.options.graphHeight=this.props.height+"px",this.svg.style.height=this.props.height+"px"),this.updateSVGheight=!1):this.svg.style.height=(""+this.options.graphHeight).replace("px","")+"px",1==t||1==i||1==this.abortedGraphUpdate||1==this.forceGraphUpdate)t=this._updateGraph()||t,this.forceGraphUpdate=!1;else if(0!=this.lastStart){var o=this.body.range.start-this.lastStart,n=this.body.range.end-this.body.range.start;if(0!=this.props.width){var s=this.props.width/n,a=o*s;this.svg.style.left=-this.props.width-a+"px"}}return this.legendLeft.redraw(),this.legendRight.redraw(),t},o.prototype._getSortedGroupIds=function(){var t=[];for(var e in this.groups)if(this.groups.hasOwnProperty(e)){var i=this.groups[e];1!=i.visible||void 0!==this.options.groups.visibility[e]&&1!=this.options.groups.visibility[e]||t.push({id:e,zIndex:i.options.zIndex})}r.insertSort(t,function(t,e){var i=t.zIndex,o=e.zIndex;return void 0===i&&(i=0),void 0===o&&(o=0),i==o?0:i<o?-1:1});for(var o=new Array(t.length),n=0;n<t.length;n++)o[n]=t[n].id;return o},o.prototype._updateGraph=function(){if(a.prepareElements(this.svgElements),0!=this.props.width&&null!=this.itemsData){var t,e,i={},o=this.body.util.toGlobalTime(-this.body.domProps.root.width),n=this.body.util.toGlobalTime(2*this.body.domProps.root.width),s=this._getSortedGroupIds();if(s.length>0){var r={};for(this._getRelevantData(s,r,o,n),this._applySampling(s,r),e=0;e<s.length;e++)this._convertXcoordinates(r[s[e]]);if(this._getYRanges(s,r,i),1==this._updateYAxis(s,i))return a.cleanupElements(this.svgElements),this.abortedGraphUpdate=!0,!0;this.abortedGraphUpdate=!1;var h=void 0;for(e=0;e<s.length;e++)t=this.groups[s[e]],!0===this.options.stack&&"line"===this.options.style&&(void 0!=t.options.excludeFromStacking&&t.options.excludeFromStacking||(void 0!=h&&(this._stack(r[t.id],r[h.id]),1==t.options.shaded.enabled&&"group"!==t.options.shaded.orientation&&("top"==t.options.shaded.orientation&&"group"!==h.options.shaded.orientation?(h.options.shaded.orientation="group",h.options.shaded.groupId=t.id):(t.options.shaded.orientation="group",t.options.shaded.groupId=h.id))),h=t)),this._convertYcoordinates(r[s[e]],t);var d={};for(e=0;e<s.length;e++)if(t=this.groups[s[e]],"line"===t.options.style&&1==t.options.shaded.enabled){var l=r[s[e]];if(null==l||0==l.length)continue;if(d.hasOwnProperty(s[e])||(d[s[e]]=m.calcPath(l,t)),"group"===t.options.shaded.orientation){var u=t.options.shaded.groupId;if(-1===s.indexOf(u)){console.log(t.id+": Unknown shading group target given:"+u);continue}d.hasOwnProperty(u)||(d[u]=m.calcPath(r[u],this.groups[u])),m.drawShading(d[s[e]],t,d[u],this.framework)}else m.drawShading(d[s[e]],t,void 0,this.framework)}for(f.draw(s,r,this.framework),e=0;e<s.length;e++)if(t=this.groups[s[e]],r[s[e]].length>0)switch(t.options.style){case"line":d.hasOwnProperty(s[e])||(d[s[e]]=m.calcPath(r[s[e]],t)),m.draw(d[s[e]],t,this.framework);case"point":case"points":"point"!=t.options.style&&"points"!=t.options.style&&1!=t.options.drawPoints.enabled||v.draw(r[s[e]],t,this.framework)}}}return a.cleanupElements(this.svgElements),!1},o.prototype._stack=function(t,e){var i,o,n,s,r;i=0;for(var a=0;a<t.length;a++){s=void 0,r=void 0;for(var h=i;h<e.length;h++){if(e[h].x===t[a].x){s=e[h],r=e[h],i=h;break}if(e[h].x>t[a].x){r=e[h],s=0==h?r:e[h-1],i=h;break}}void 0===r&&(s=e[e.length-1],r=e[e.length-1]),o=r.x-s.x,n=r.y-s.y,t[a].y=0==o?t[a].orginalY+r.y:t[a].orginalY+n/o*(t[a].x-s.x)+s.y}},o.prototype._getRelevantData=function(t,e,i,o){var n,s,a,h;if(t.length>0)for(s=0;s<t.length;s++){n=this.groups[t[s]];var d=n.getItems();if(1==n.options.sort){var l=function(t,e){return t.getTime()==e.getTime()?0:t<e?-1:1},u=Math.max(0,r.binarySearchValue(d,i,"x","before",l)),c=Math.min(d.length,r.binarySearchValue(d,o,"x","after",l)+1);c<=0&&(c=d.length);var p=new Array(c-u);for(a=u;a<c;a++)h=n.itemsData[a],pLine truncated
d=Math.sqrt(Math.pow(o.screen_x-n.screen_x,2)+Math.pow(o.screen_y-n.screen_y,2)),l=Math.sqrt(Math.pow(n.screen_x-s.screen_x,2)+Math.pow(n.screen_y-s.screen_y,2)),u=Math.sqrt(Math.pow(s.screen_x-r.screen_x,2)+Math.pow(s.screen_y-r.screen_y,2)),v=Math.pow(u,i),y=Math.pow(u,2*i),g=Math.pow(l,i),b=Math.pow(l,2*i),w=Math.pow(d,i),_=Math.pow(d,2*i),c=2*_+3*w*g+b,p=2*y+3*v*g+b,f=3*w*(w+g),f>0&&(f=1/f),m=3*v*(v+g),m>0&&(m=1/m),a={screen_x:(-b*o.screen_x+c*n.screen_x+_*s.screen_x)*f,screen_y:(-b*o.screen_y+c*n.screen_y+_*s.screen_y)*f},h={screen_x:(y*n.screen_x+p*s.screen_x-b*r.screen_x)*m,screen_y:(y*n.screen_y+p*s.screen_y-b*r.screen_y)*m},0==a.screen_x&&0==a.screen_y&&(a=n),0==h.screen_x&&0==h.screen_y&&(h=s),x.push([a.screen_x,a.screen_y]),x.push([h.screen_x,h.screen_y]),x.push([s.screen_x,s.screen_y]);return x},o._linear=function(t){for(var e=[],i=0;i<t.length;i++)e.push([t[i].screen_x,t[i].screen_y]);return e},t.exports=o},function(t,e,i){function o(t,e,i,o){this.body=t,this.defaultOptions={enabled:!1,icons:!0,iconSize:20,iconSpacing:6,left:{visible:!0,position:"top-left"},right:{visible:!0,position:"top-right"}},this.side=i,this.options=r.extend({},this.defaultOptions),this.linegraphOptions=o,this.svgElements={},this.dom={},this.groups={},this.amountOfGroups=0,this._create(),this.framework={svg:this.svg,svgElements:this.svgElements,options:this.options,groups:this.groups},this.setOptions(e)}var n=i(8),s=function(t){return t&&t.__esModule?t:{default:t}}(n),r=i(2),a=i(14),h=i(16);o.prototype=new h,o.prototype.clear=function(){this.groups={},this.amountOfGroups=0},o.prototype.addGroup=function(t,e){1!=e.options.excludeFromLegend&&(this.groups.hasOwnProperty(t)||(this.groups[t]=e),this.amountOfGroups+=1)},o.prototype.updateGroup=function(t,e){this.groups[t]=e},o.prototype.removeGroup=function(t){this.groups.hasOwnProperty(t)&&(delete this.groups[t],this.amountOfGroups-=1)},o.prototype._create=function(){this.dom.frame=document.createElement("div"),this.dom.frame.className="vis-legend",this.dom.frame.style.position="absolute",this.dom.frame.style.top="10px",this.dom.frame.style.display="block",this.dom.textArea=document.createElement("div"),this.dom.textArea.className="vis-legend-text",this.dom.textArea.style.position="relative",this.dom.textArea.style.top="0px",this.svg=document.createElementNS("http://www.w3.org/2000/svg","svg"),this.svg.style.position="absolute",this.svg.style.top="0px",this.svg.style.width=this.options.iconSize+5+"px",this.svg.style.height="100%",this.dom.frame.appendChild(this.svg),this.dom.frame.appendChild(this.dom.textArea)},o.prototype.hide=function(){this.dom.frame.parentNode&&this.dom.frame.parentNode.removeChild(this.dom.frame)},o.prototype.show=function(){this.dom.frame.parentNode||this.body.dom.center.appendChild(this.dom.frame)},o.prototype.setOptions=function(t){var e=["enabled","orientation","icons","left","right"];r.selectiveDeepExtend(e,this.options,t)},o.prototype.redraw=function(){var t=0,e=(0,s.default)(this.groups);e.sort(function(t,e){return t<e?-1:1});for(var i=0;i<e.length;i++){var o=e[i];1!=this.groups[o].visible||void 0!==this.linegraphOptions.visibility[o]&&1!=this.linegraphOptions.visibility[o]||t++}if(0==this.options[this.side].visible||0==this.amountOfGroups||0==this.options.enabled||0==t)this.hide();else{if(this.show(),"top-left"==this.options[this.side].position||"bottom-left"==this.options[this.side].position?(this.dom.frame.style.left="4px",this.dom.frame.style.textAlign="left",this.dom.textArea.style.textAlign="left",this.dom.textArea.style.left=this.options.iconSize+15+"px",this.dom.textArea.style.right="",this.svg.style.left="0px",this.svg.style.right=""):(this.dom.frame.style.right="4px",this.dom.frame.style.textAlign="right",this.dom.textArea.style.textAlign="right",this.dom.textArea.style.right=this.options.iconSize+15+"px",this.dom.textArea.style.left="",this.svg.style.right="0px",this.svg.style.left=""),"top-left"==this.options[this.side].position||"top-right"==this.options[this.side].position)this.dom.frame.style.top=4-Number(this.body.dom.center.style.top.replace("px",""))+"px",this.dom.frame.style.bottom="";else{var n=this.body.domProps.center.height-this.body.domProps.centerContainer.height;this.dom.frame.style.bottom=4+n+Number(this.body.dom.center.style.top.replace("px",""))+"px",this.dom.frame.style.top=""}0==this.options.icons?(this.dom.frame.style.width=this.dom.textArea.offsetWidth+10+"px",this.dom.textArea.style.right="",this.dom.textArea.style.left="",this.svg.style.width="0px"):(this.dom.frame.style.width=this.options.iconSize+15+this.dom.textArea.offsetWidth+10+"px",this.drawLegendIcons());var r="";for(i=0;i<e.length;i++)o=e[i],1!=this.groups[o].visible||void 0!==this.linegraphOptions.visibility[o]&&1!=this.linegraphOptions.visibility[o]||(r+=this.groups[o].content+"<br />");this.dom.textArea.innerHTML=r,this.dom.textArea.style.lineHeight=.75*this.options.iconSize+this.options.iconSpacing+"px"}},o.prototype.drawLegendIcons=function(){if(thLine truncated
value:function(t,e,i,o,n){this.enableShadow(t,e),t.beginPath(),t.arc(i,o,n,0,2*Math.PI,!1),t.stroke(),this.disableShadow(t,e)}},{key:"getDistanceToEdge",value:function(t,e,i,o,n,r,a,h){var d=0;if(this.from!=this.to)d=this._getDistanceToEdge(t,e,i,o,n,r,a);else{var l=this._getCircleData(void 0),u=(0,s.default)(l,3),c=u[0],p=u[1],f=u[2],m=c-n,v=p-r;d=Math.abs(Math.sqrt(m*m+v*v)-f)}return d}},{key:"_getDistanceToLine",value:function(t,e,i,o,n,s){var r=i-t,a=o-e,h=r*r+a*a,d=((n-t)*r+(s-e)*a)/h;d>1?d=1:d<0&&(d=0);var l=t+d*r,u=e+d*a,c=l-n,p=u-s;return Math.sqrt(c*c+p*p)}},{key:"getArrowData",value:function(t,e,i,o,n,r){var a=void 0,h=void 0,d=void 0,l=void 0,u=void 0,c=void 0,p=void 0,f=r.width;if("from"===e?(d=this.from,l=this.to,u=.1,c=r.fromArrowScale,p=r.fromArrowType):"to"===e?(d=this.to,l=this.from,u=-.1,c=r.toArrowScale,p=r.toArrowType):(d=this.to,l=this.from,c=r.middleArrowScale,p=r.middleArrowType),d!=l)if("middle"!==e)if(!0===this.options.smooth.enabled){h=this.findBorderPosition(d,t,{via:i});var m=this.getPoint(Math.max(0,Math.min(1,h.t+u)),i);a=Math.atan2(h.y-m.y,h.x-m.x)}else a=Math.atan2(d.y-l.y,d.x-l.x),h=this.findBorderPosition(d,t);else a=Math.atan2(d.y-l.y,d.x-l.x),h=this.getPoint(.5,i);else{var v=this._getCircleData(t),g=(0,s.default)(v,3),y=g[0],b=g[1],_=g[2];"from"===e?(h=this.findBorderPosition(this.from,t,{x:y,y:b,low:.25,high:.6,direction:-1}),a=-2*h.t*Math.PI+1.5*Math.PI+.1*Math.PI):"to"===e?(h=this.findBorderPosition(this.from,t,{x:y,y:b,low:.6,high:1,direction:1}),a=-2*h.t*Math.PI+1.5*Math.PI-1.1*Math.PI):(h=this._pointOnCircle(y,b,_,.175),a=3.9269908169872414)}"middle"===e&&c<0&&(f*=-1);var w=15*c+3*f;return{point:h,core:{x:h.x-.9*w*Math.cos(a),y:h.y-.9*w*Math.sin(a)},angle:a,length:w,type:p}}},{key:"drawArrowHead",value:function(t,e,i,o,n){t.strokeStyle=this.getColor(t,e,i,o),t.fillStyle=t.strokeStyle,t.lineWidth=e.width,u.draw(t,n),this.enableShadow(t,e),t.fill(),this.disableShadow(t,e)}},{key:"enableShadow",value:function(t,e){!0===e.shadow&&(t.shadowColor=e.shadowColor,t.shadowBlur=e.shadowSize,t.shadowOffsetX=e.shadowX,t.shadowOffsetY=e.shadowY)}},{key:"disableShadow",value:function(t,e){!0===e.shadow&&(t.shadowColor="rgba(0,0,0,0)",t.shadowBlur=0,t.shadowOffsetX=0,t.shadowOffsetY=0)}}]),t}();e.default=c},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(4),a=o(r),h=i(5),d=o(h),l=i(0),u=o(l),c=i(1),p=o(c),f=function(){function t(){(0,u.default)(this,t)}return(0,p.default)(t,null,[{key:"transform",value:function(t,e){t instanceof Array||(t=[t]);for(var i=e.point.x,o=e.point.y,n=e.angle,s=e.length,r=0;r<t.length;++r){var a=t[r],h=a.x*Math.cos(n)-a.y*Math.sin(n),d=a.x*Math.sin(n)+a.y*Math.cos(n);a.x=i+s*h,a.y=o+s*d}}},{key:"drawPath",value:function(t,e){t.beginPath(),t.moveTo(e[0].x,e[0].y);for(var i=1;i<e.length;++i)t.lineTo(e[i].x,e[i].y);t.closePath()}}]),t}(),m=function(t){function e(){return(0,u.default)(this,e),(0,a.default)(this,(e.__proto__||(0,s.default)(e)).apply(this,arguments))}return(0,d.default)(e,t),(0,p.default)(e,null,[{key:"draw",value:function(t,e){var i=[{x:0,y:0},{x:-1,y:.3},{x:-.9,y:0},{x:-1,y:-.3}];f.transform(i,e),f.drawPath(t,i)}}]),e}(f),v=function(){function t(){(0,u.default)(this,t)}return(0,p.default)(t,null,[{key:"draw",value:function(t,e){var i={x:-.4,y:0};f.transform(i,e),t.circle(i.x,i.y,.4*e.length)}}]),t}(),g=function(){function t(){(0,u.default)(this,t)}return(0,p.default)(t,null,[{key:"draw",value:function(t,e){var i=[{x:0,y:.5},{x:0,y:-.5},{x:-.15,y:-.5},{x:-.15,y:.5}];f.transform(i,e),f.drawPath(t,i)}}]),t}(),y=function(){function t(){(0,u.default)(this,t)}return(0,p.default)(t,null,[{key:"draw",value:function(t,e){var i;switch(e.type&&(i=e.type.toLowerCase()),i){case"circle":v.draw(t,e);break;case"bar":g.draw(t,e);break;case"arrow":default:m.draw(t,e)}}}]),t}();e.default=y},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=function(){function t(e,i,o){(0,s.default)(this,t),this.body=e,this.physicsBody=i,this.barnesHutTree,this.setOptions(o),this.randomSeed=5}return(0,a.default)(t,[{key:"setOptions",value:function(t){this.options=t,this.thetaInversed=1/this.options.theta,this.overlapAvoidanceFactor=1-Math.max(0,Math.min(1,this.options.avoidOverlap))}},{key:"seededRandom",value:function(){var t=1e4*Math.sin(this.randomSeed++);return t-Math.floor(t)}},{key:"solve",value:function(){if(0!==this.options.gravitationalConstant&&this.physicsBody.physicsNodeIndices.length>0){var t=void 0,e=this.body.nodes,i=this.physicsBody.physicsNodeIndices,o=i.length,n=this._formBarnesHutTree(e,i);this.barnesHutTree=n;for(var s=0;s<o;s++)t=e[i[s]],t.options.mass>0&&this._getForceContributions(n.root,t)}}},{key:"_getForceContributions",value:function(t,e){this._getForceContribution(t.children.NW,e),this._getForceContribution(t.children.NE,e),this._getForceContribution(t.children.SW,Line truncated
;for(var a in arguments[0])n+=a+": "+arguments[0][a]+", ";n=n.slice(0,-2)}else n=arguments[r];s.push(n)}x(t+"\nArguments: "+Array.prototype.slice.call(s).join("")+"\n"+(new Error).stack),o=!1}return i.apply(this,arguments)},i)}function S(t,i){null!=e.deprecationHandler&&e.deprecationHandler(t,i),Po[t]||(x(i),Po[t]=!0)}function D(t){return t instanceof Function||"[object Function]"===Object.prototype.toString.call(t)}function M(t){var e,i;for(i in t)e=t[i],D(e)?this[i]=e:this["_"+i]=e;this._config=t,this._dayOfMonthOrdinalParseLenient=new RegExp((this._dayOfMonthOrdinalParse.source||this._ordinalParse.source)+"|"+/\d{1,2}/.source)}function C(t,e){var i,n=l({},t);for(i in e)d(e,i)&&(o(t[i])&&o(e[i])?(n[i]={},l(n[i],t[i]),l(n[i],e[i])):null!=e[i]?n[i]=e[i]:delete n[i]);for(i in t)d(t,i)&&!d(e,i)&&o(t[i])&&(n[i]=l({},n[i]));return n}function O(t){null!=t&&this.set(t)}function E(t,e,i){var o=this._calendar[t]||this._calendar.sameElse;return D(o)?o.call(e,i):o}function T(t){var e=this._longDateFormat[t],i=this._longDateFormat[t.toUpperCase()];return e||!i?e:(this._longDateFormat[t]=i.replace(/MMMM|MM|DD|dddd/g,function(t){return t.slice(1)}),this._longDateFormat[t])}function P(){return this._invalidDate}function I(t){return this._ordinal.replace("%d",t)}function N(t,e,i,o){var n=this._relativeTime[i];return D(n)?n(t,e,i,o):n.replace(/%d/i,t)}function R(t,e){var i=this._relativeTime[t>0?"future":"past"];return D(i)?i(e):i.replace(/%s/i,e)}function A(t,e){var i=t.toLowerCase();Lo[i]=Lo[i+"s"]=Lo[e]=t}function z(t){return"string"==typeof t?Lo[t]||Lo[t.toLowerCase()]:void 0}function L(t){var e,i,o={};for(i in t)d(t,i)&&(e=z(i))&&(o[e]=t[i]);return o}function F(t,e){Fo[t]=e}function B(t){var e=[];for(var i in t)e.push({unit:i,priority:Fo[i]});return e.sort(function(t,e){return t.priority-e.priority}),e}function j(t,e,i){var o=""+Math.abs(t),n=e-o.length;return(t>=0?i?"+":"":"-")+Math.pow(10,Math.max(0,n)).toString().substr(1)+o}function H(t,e,i,o){var n=o;"string"==typeof o&&(n=function(){return this[o]()}),t&&(Wo[t]=n),e&&(Wo[e[0]]=function(){return j(n.apply(this,arguments),e[1],e[2])}),i&&(Wo[i]=function(){return this.localeData().ordinal(n.apply(this,arguments),t)})}function W(t){return t.match(/\[[\s\S]/)?t.replace(/^\[|\]$/g,""):t.replace(/\\/g,"")}function Y(t){var e,i,o=t.match(Bo);for(e=0,i=o.length;e<i;e++)Wo[o[e]]?o[e]=Wo[o[e]]:o[e]=W(o[e]);return function(e){var n,s="";for(n=0;n<i;n++)s+=D(o[n])?o[n].call(e,t):o[n];return s}}function G(t,e){return t.isValid()?(e=V(e,t.localeData()),Ho[e]=Ho[e]||Y(e),Ho[e](t)):t.localeData().invalidDate()}function V(t,e){function i(t){return e.longDateFormat(t)||t}var o=5;for(jo.lastIndex=0;o>=0&&jo.test(t);)t=t.replace(jo,i),jo.lastIndex=0,o-=1;return t}function U(t,e,i){an[t]=D(e)?e:function(t,o){return t&&i?i:e}}function q(t,e){return d(an,t)?an[t](e._strict,e._locale):new RegExp(X(t))}function X(t){return Z(t.replace("\\","").replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(t,e,i,o,n){return e||i||o||n}))}function Z(t){return t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function K(t,e){var i,o=e;for("string"==typeof t&&(t=[t]),r(e)&&(o=function(t,i){i[e]=_(t)}),i=0;i<t.length;i++)hn[t[i]]=o}function J(t,e){K(t,function(t,i,o,n){o._w=o._w||{},e(t,o._w,o,n)})}function $(t,e,i){null!=e&&d(hn,t)&&hn[t](e,i._a,i,t)}function Q(t){return tt(t)?366:365}function tt(t){return t%4==0&&t%100!=0||t%400==0}function et(){return tt(this.year())}function it(t,i){return function(o){return null!=o?(nt(this,t,o),e.updateOffset(this,i),this):ot(this,t)}}function ot(t,e){return t.isValid()?t._d["get"+(t._isUTC?"UTC":"")+e]():NaN}function nt(t,e,i){t.isValid()&&!isNaN(i)&&("FullYear"===e&&tt(t.year())?t._d["set"+(t._isUTC?"UTC":"")+e](i,t.month(),ht(i,t.month())):t._d["set"+(t._isUTC?"UTC":"")+e](i))}function st(t){return t=z(t),D(this[t])?this[t]():this}function rt(t,e){if("object"==typeof t){t=L(t);for(var i=B(t),o=0;o<i.length;o++)this[i[o].unit](t[i[o].unit])}else if(t=z(t),D(this[t]))return this[t](e);return this}function at(t,e){return(t%e+e)%e}function ht(t,e){if(isNaN(t)||isNaN(e))return NaN;var i=at(e,12);return t+=(e-i)/12,1===i?tt(t)?29:28:31-i%7%2}function dt(t,e){return t?i(this._months)?this._months[t.month()]:this._months[(this._months.isFormat||_n).test(e)?"format":"standalone"][t.month()]:i(this._months)?this._months:this._months.standalone}function lt(t,e){return t?i(this._monthsShort)?this._monthsShort[t.month()]:this._monthsShort[_n.test(e)?"format":"standalone"][t.month()]:i(this._monthsShort)?this._monthsShort:this._monthsShort.standalone}function ut(t,e,i){var o,n,s,r=t.toLocaleLowerCase();if(!this._monthsParse)for(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[],o=0;o<12;++o)s=u([2e3,o]),this._shortMonthsParse[o]=this.monthsShort(s,"").toLocaleLowerCase(),this._longMonthsParse[o]=this.months(s,"").toLocaleLowerCase();return i?"MMM"===e?(n=yn.call(this._shortMonthsParse,r),-1!==n?n:null):(n=yn.call(this._longMonthsParse,r),-1!==n?n:null):"MMMLine truncated
r-=ho(co(n)),o=b(a/12),a%=12,h.days=r,h.months=a,h.years=o,this}function uo(t){return 4800*t/146097}function co(t){return 146097*t/4800}function po(t){if(!this.isValid())return NaN;var e,i,o=this._milliseconds;if("month"===(t=z(t))||"year"===t)return e=this._days+o/864e5,i=this._months+uo(e),"month"===t?i:i/12;switch(e=this._days+Math.round(co(this._months)),t){case"week":return e/7+o/6048e5;case"day":return e+o/864e5;case"hour":return 24*e+o/36e5;case"minute":return 1440*e+o/6e4;case"second":return 86400*e+o/1e3;case"millisecond":return Math.floor(864e5*e)+o;default:throw new Error("Unknown unit "+t)}}function fo(){return this.isValid()?this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*_(this._months/12):NaN}function mo(t){return function(){return this.as(t)}}function vo(){return Ke(this)}function go(t){return t=z(t),this.isValid()?this[t+"s"]():NaN}function yo(t){return function(){return this.isValid()?this._data[t]:NaN}}function bo(){return b(this.days()/7)}function _o(t,e,i,o,n){return n.relativeTime(e||1,!!i,t,o)}function wo(t,e,i){var o=Ke(t).abs(),n=Ds(o.as("s")),s=Ds(o.as("m")),r=Ds(o.as("h")),a=Ds(o.as("d")),h=Ds(o.as("M")),d=Ds(o.as("y")),l=n<=Ms.ss&&["s",n]||n<Ms.s&&["ss",n]||s<=1&&["m"]||s<Ms.m&&["mm",s]||r<=1&&["h"]||r<Ms.h&&["hh",r]||a<=1&&["d"]||a<Ms.d&&["dd",a]||h<=1&&["M"]||h<Ms.M&&["MM",h]||d<=1&&["y"]||["yy",d];return l[2]=e,l[3]=+t>0,l[4]=i,_o.apply(null,l)}function xo(t){return void 0===t?Ds:"function"==typeof t&&(Ds=t,!0)}function ko(t,e){return void 0!==Ms[t]&&(void 0===e?Ms[t]:(Ms[t]=e,"s"===t&&(Ms.ss=e-1),!0))}function So(t){if(!this.isValid())return this.localeData().invalidDate();var e=this.localeData(),i=wo(this,!t,e);return t&&(i=e.pastFuture(+this,i)),e.postformat(i)}function Do(t){return(t>0)-(t<0)||+t}function Mo(){if(!this.isValid())return this.localeData().invalidDate();var t,e,i,o=Cs(this._milliseconds)/1e3,n=Cs(this._days),s=Cs(this._months);t=b(o/60),e=b(t/60),o%=60,t%=60,i=b(s/12),s%=12;var r=i,a=s,h=n,d=e,l=t,u=o?o.toFixed(3).replace(/\.?0+$/,""):"",c=this.asSeconds();if(!c)return"P0D";var p=c<0?"-":"",f=Do(this._months)!==Do(c)?"-":"",m=Do(this._days)!==Do(c)?"-":"",v=Do(this._milliseconds)!==Do(c)?"-":"";return p+"P"+(r?f+r+"Y":"")+(a?f+a+"M":"")+(h?m+h+"D":"")+(d||l||u?"T":"")+(d?v+d+"H":"")+(l?v+l+"M":"")+(u?v+u+"S":"")}var Co,Oo;Oo=Array.prototype.some?Array.prototype.some:function(t){for(var e=Object(this),i=e.length>>>0,o=0;o<i;o++)if(o in e&&t.call(this,e[o],o,e))return!0;return!1};var Eo=e.momentProperties=[],To=!1,Po={};e.suppressDeprecationWarnings=!1,e.deprecationHandler=null;var Io;Io=Object.keys?Object.keys:function(t){var e,i=[];for(e in t)d(t,e)&&i.push(e);return i};var No={sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},Ro={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},Ao=/\d{1,2}/,zo={future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},Lo={},Fo={},Bo=/(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,jo=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,Ho={},Wo={},Yo=/\d/,Go=/\d\d/,Vo=/\d{3}/,Uo=/\d{4}/,qo=/[+-]?\d{6}/,Xo=/\d\d?/,Zo=/\d\d\d\d?/,Ko=/\d\d\d\d\d\d?/,Jo=/\d{1,3}/,$o=/\d{1,4}/,Qo=/[+-]?\d{1,6}/,tn=/\d+/,en=/[+-]?\d+/,on=/Z|[+-]\d\d:?\d\d/gi,nn=/Z|[+-]\d\d(?::?\d\d)?/gi,sn=/[+-]?\d+(\.\d{1,3})?/,rn=/[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i,an={},hn={},dn=0,ln=1,un=2,cn=3,pn=4,fn=5,mn=6,vn=7,gn=8;H("Y",0,0,function(){var t=this.year();return t<=9999?""+t:"+"+t}),H(0,["YY",2],0,function(){return this.year()%100}),H(0,["YYYY",4],0,"year"),H(0,["YYYYY",5],0,"year"),H(0,["YYYYYY",6,!0],0,"year"),A("year","y"),F("year",1),U("Y",en),U("YY",Xo,Go),U("YYYY",$o,Uo),U("YYYYY",Qo,qo),U("YYYYYY",Qo,qo),K(["YYYYY","YYYYYY"],dn),K("YYYY",function(t,i){i[dn]=2===t.length?e.parseTwoDigitYear(t):_(t)}),K("YY",function(t,i){i[dn]=e.parseTwoDigitYear(t)}),K("Y",function(t,e){e[dn]=parseInt(t,10)}),e.parseTwoDigitYear=function(t){return _(t)+(_(t)>68?1900:2e3)};var yn,bn=it("FullYear",!0);yn=Array.prototype.indexOf?Array.prototype.indexOf:function(t){var e;for(e=0;e<this.length;++e)if(this[e]===t)return e;return-1},H("M",["MM",2],"Mo",function(){return this.month()+1}),H("MMM",0,0,function(t){return this.localeData().monthsShort(this,t)}),H("MMMM",0,0,function(t){return this.localeData().months(this,t)}),A("month","M"),F("month",8),U("M",Xo),U("MM",Xo,Go),U("MMM",function(t,e){return e.monthsShortRegex(t)}),U("MMMM",function(t,e){return e.monthsRegex(t)}),K(["M","MM"],function(t,e){e[ln]=_(t)-1}),K(["MMM","MMMM"],function(t,e,i,o){var nLine truncated
;Math.cos(2*o)>0?(t.textAlign="center",t.textBaseline="top",s.y+=n):Math.sin(2*o)<0?(t.textAlign="right",t.textBaseline="middle"):(t.textAlign="left",t.textBaseline="middle"),t.fillStyle=this.axisColor,t.fillText(i,s.x,s.y)},o.prototype.drawAxisLabelY=function(t,e,i,o,n){void 0===n&&(n=0);var s=this._convert3Dto2D(e);Math.cos(2*o)<0?(t.textAlign="center",t.textBaseline="top",s.y+=n):Math.sin(2*o)>0?(t.textAlign="right",t.textBaseline="middle"):(t.textAlign="left",t.textBaseline="middle"),t.fillStyle=this.axisColor,t.fillText(i,s.x,s.y)},o.prototype.drawAxisLabelZ=function(t,e,i,o){void 0===o&&(o=0);var n=this._convert3Dto2D(e);t.textAlign="right",t.textBaseline="middle",t.fillStyle=this.axisColor,t.fillText(i,n.x-o,n.y)},o.prototype._line3d=function(t,e,i,o){var n=this._convert3Dto2D(e),s=this._convert3Dto2D(i);this._line(t,n,s,o)},o.prototype._redrawAxis=function(){var t,e,i,o,n,s,r,a,h,d,c,f=this._getContext();f.font=24/this.camera.getArmLength()+"px arial";var m,v=.025/this.scale.x,g=.025/this.scale.y,y=5/this.camera.getArmLength(),b=this.camera.getArmRotation().horizontal,_=new u(Math.cos(b),Math.sin(b)),w=this.xRange,x=this.yRange,k=this.zRange;for(f.lineWidth=1,o=void 0===this.defaultXStep,i=new p(w.min,w.max,this.xStep,o),i.start(!0);!i.end();){var S=i.getCurrent();if(this.showGrid?(t=new l(S,x.min,k.min),e=new l(S,x.max,k.min),this._line3d(f,t,e,this.gridColor)):this.showXAxis&&(t=new l(S,x.min,k.min),e=new l(S,x.min+v,k.min),this._line3d(f,t,e,this.axisColor),t=new l(S,x.max,k.min),e=new l(S,x.max-v,k.min),this._line3d(f,t,e,this.axisColor)),this.showXAxis){r=_.x>0?x.min:x.max,m=new l(S,r,k.min);var D=" "+this.xValueLabel(S)+" ";this.drawAxisLabelX(f,m,D,b,y)}i.next()}for(f.lineWidth=1,o=void 0===this.defaultYStep,i=new p(x.min,x.max,this.yStep,o),i.start(!0);!i.end();){var M=i.getCurrent();if(this.showGrid?(t=new l(w.min,M,k.min),e=new l(w.max,M,k.min),this._line3d(f,t,e,this.gridColor)):this.showYAxis&&(t=new l(w.min,M,k.min),e=new l(w.min+g,M,k.min),this._line3d(f,t,e,this.axisColor),t=new l(w.max,M,k.min),e=new l(w.max-g,M,k.min),this._line3d(f,t,e,this.axisColor)),this.showYAxis){s=_.y>0?w.min:w.max,m=new l(s,M,k.min);var C=" "+this.yValueLabel(M)+" ";this.drawAxisLabelY(f,m,C,b,y)}i.next()}if(this.showZAxis){for(f.lineWidth=1,o=void 0===this.defaultZStep,i=new p(k.min,k.max,this.zStep,o),i.start(!0),s=_.x>0?w.min:w.max,r=_.y<0?x.min:x.max;!i.end();){var O=i.getCurrent(),E=new l(s,r,O),T=this._convert3Dto2D(E);e=new u(T.x-y,T.y),this._line(f,T,e,this.axisColor);var P=this.zValueLabel(O)+" ";this.drawAxisLabelZ(f,E,P,5),i.next()}f.lineWidth=1,t=new l(s,r,k.min),e=new l(s,r,k.max),this._line3d(f,t,e,this.axisColor)}if(this.showXAxis){var I,N;f.lineWidth=1,I=new l(w.min,x.min,k.min),N=new l(w.max,x.min,k.min),this._line3d(f,I,N,this.axisColor),I=new l(w.min,x.max,k.min),N=new l(w.max,x.max,k.min),this._line3d(f,I,N,this.axisColor)}this.showYAxis&&(f.lineWidth=1,t=new l(w.min,x.min,k.min),e=new l(w.min,x.max,k.min),this._line3d(f,t,e,this.axisColor),t=new l(w.max,x.min,k.min),e=new l(w.max,x.max,k.min),this._line3d(f,t,e,this.axisColor));var R=this.xLabel;R.length>0&&this.showXAxis&&(c=.1/this.scale.y,s=(w.max+3*w.min)/4,r=_.x>0?x.min-c:x.max+c,n=new l(s,r,k.min),this.drawAxisLabelX(f,n,R,b));var A=this.yLabel;A.length>0&&this.showYAxis&&(d=.1/this.scale.x,s=_.y>0?w.min-d:w.max+d,r=(x.max+3*x.min)/4,n=new l(s,r,k.min),this.drawAxisLabelY(f,n,A,b));var z=this.zLabel;z.length>0&&this.showZAxis&&(h=30,s=_.x>0?w.min:w.max,r=_.y<0?x.min:x.max,a=(k.max+3*k.min)/4,n=new l(s,r,a),this.drawAxisLabelZ(f,n,z,h))},o.prototype._hsv2rgb=function(t,e,i){var o,n,s,r,a,h;switch(r=i*e,a=Math.floor(t/60),h=r*(1-Math.abs(t/60%2-1)),a){case 0:o=r,n=h,s=0;break;case 1:o=h,n=r,s=0;break;case 2:o=0,n=r,s=h;break;case 3:o=0,n=h,s=r;break;case 4:o=h,n=0,s=r;break;case 5:o=r,n=0,s=h;break;default:o=0,n=0,s=0}return"RGB("+parseInt(255*o)+","+parseInt(255*n)+","+parseInt(255*s)+")"},o.prototype._getStrokeWidth=function(t){return void 0!==t?this.showPerspective?1/-t.trans.z*this.dataColor.strokeWidth:-this.eye.z/this.camera.getArmLength()*this.dataColor.strokeWidth:this.dataColor.strokeWidth},o.prototype._redrawBar=function(t,e,i,o,n,s){var r,a=this,h=e.point,d=this.zRange.min,u=[{point:new l(h.x-i,h.y-o,h.z)},{point:new l(h.x+i,h.y-o,h.z)},{point:new l(h.x+i,h.y+o,h.z)},{point:new l(h.x-i,h.y+o,h.z)}],c=[{point:new l(h.x-i,h.y-o,d)},{point:new l(h.x+i,h.y-o,d)},{point:new l(h.x+i,h.y+o,d)},{point:new l(h.x-i,h.y+o,d)}];u.forEach(function(t){t.screen=a._convert3Dto2D(t.point)}),c.forEach(function(t){t.screen=a._convert3Dto2D(t.point)});var p=[{corners:u,center:l.avg(c[0].point,c[2].point)},{corners:[u[0],u[1],c[1],c[0]],center:l.avg(c[1].point,c[0].point)},{corners:[u[1],u[2],c[2],c[1]],center:l.avg(c[2].point,c[1].point)},{corners:[u[2],u[3],c[3],c[2]],center:l.avg(c[3].point,c[2].point)},{corners:[u[3],u[0],c[0],c[3]],center:l.avg(c[0].point,c[3].point)}];e.surfaces=p;for(var f=0;f<p.length;f++){r=p[f];var m=this._conveLine truncated
function tt(t){return t&ve?"cancel":t&fe?"end":t&pe?"move":t&ce?"start":""}function et(t){return t==Lt?"down":t==zt?"up":t==Rt?"left":t==At?"right":""}function it(t,e){var i=e.manager;return i?i.get(t):t}function ot(){Q.apply(this,arguments)}function nt(){ot.apply(this,arguments),this.pX=null,this.pY=null}function st(){ot.apply(this,arguments)}function rt(){Q.apply(this,arguments),this._timer=null,this._input=null}function at(){ot.apply(this,arguments)}function ht(){ot.apply(this,arguments)}function dt(){Q.apply(this,arguments),this.pTime=!1,this.pCenter=!1,this._timer=null,this._input=null,this.count=0}function lt(t,e){return e=e||{},e.recognizers=m(e.recognizers,lt.defaults.preset),new ut(t,e)}function ut(t,e){this.options=ft({},lt.defaults,e||{}),this.options.inputTarget=this.options.inputTarget||t,this.handlers={},this.session={},this.recognizers=[],this.oldCssProps={},this.element=t,this.input=O(this),this.touchAction=new J(this,this.options.touchAction),ct(this,!0),l(this.options.recognizers,function(t){var e=this.add(new t[0](t[1]));t[2]&&e.recognizeWith(t[2]),t[3]&&e.requireFailure(t[3])},this)}function ct(t,e){var i=t.element;if(i.style){var o;l(t.options.cssProps,function(n,s){o=S(i.style,s),e?(t.oldCssProps[o]=i.style[o],i.style[o]=n):i.style[o]=t.oldCssProps[o]||""}),e||(t.oldCssProps={})}}function pt(t,e){var i=s.createEvent("Event");i.initEvent(t,!0,!0),i.gesture=e,e.target.dispatchEvent(i)}var ft,mt=["","webkit","Moz","MS","ms","o"],vt=s.createElement("div"),gt="function",yt=Math.round,bt=Math.abs,_t=Date.now;ft="function"!=typeof Object.assign?function(t){if(t===a||null===t)throw new TypeError("Cannot convert undefined or null to object");for(var e=Object(t),i=1;i<arguments.length;i++){var o=arguments[i];if(o!==a&&null!==o)for(var n in o)o.hasOwnProperty(n)&&(e[n]=o[n])}return e}:Object.assign;var wt=u(function(t,e,i){for(var o=Object.keys(e),n=0;n<o.length;)(!i||i&&t[o[n]]===a)&&(t[o[n]]=e[o[n]]),n++;return t},"extend","Use `assign`."),xt=u(function(t,e){return wt(t,e,!0)},"merge","Use `assign`."),kt=1,St=/mobile|tablet|ip(ad|hone|od)|android/i,Dt="ontouchstart"in n,Mt=S(n,"PointerEvent")!==a,Ct=Dt&&St.test(navigator.userAgent),Ot=25,Et=1,Tt=2,Pt=4,It=8,Nt=1,Rt=2,At=4,zt=8,Lt=16,Ft=Rt|At,Bt=zt|Lt,jt=Ft|Bt,Ht=["x","y"],Wt=["clientX","clientY"];C.prototype={handler:function(){},init:function(){this.evEl&&v(this.element,this.evEl,this.domHandler),this.evTarget&&v(this.target,this.evTarget,this.domHandler),this.evWin&&v(M(this.element),this.evWin,this.domHandler)},destroy:function(){this.evEl&&g(this.element,this.evEl,this.domHandler),this.evTarget&&g(this.target,this.evTarget,this.domHandler),this.evWin&&g(M(this.element),this.evWin,this.domHandler)}};var Yt={mousedown:Et,mousemove:Tt,mouseup:Pt},Gt="mousedown",Vt="mousemove mouseup";c(H,C,{handler:function(t){var e=Yt[t.type];e&Et&&0===t.button&&(this.pressed=!0),e&Tt&&1!==t.which&&(e=Pt),this.pressed&&(e&Pt&&(this.pressed=!1),this.callback(this.manager,e,{pointers:[t],changedPointers:[t],pointerType:"mouse",srcEvent:t}))}});var Ut={pointerdown:Et,pointermove:Tt,pointerup:Pt,pointercancel:It,pointerout:It},qt={2:"touch",3:"pen",4:"mouse",5:"kinect"},Xt="pointerdown",Zt="pointermove pointerup pointercancel";n.MSPointerEvent&&!n.PointerEvent&&(Xt="MSPointerDown",Zt="MSPointerMove MSPointerUp MSPointerCancel"),c(W,C,{handler:function(t){var e=this.store,i=!1,o=t.type.toLowerCase().replace("ms",""),n=Ut[o],s=qt[t.pointerType]||t.pointerType,r="touch"==s,a=w(e,t.pointerId,"pointerId");n&Et&&(0===t.button||r)?a<0&&(e.push(t),a=e.length-1):n&(Pt|It)&&(i=!0),a<0||(e[a]=t,this.callback(this.manager,n,{pointers:e,changedPointers:[t],pointerType:s,srcEvent:t}),i&&e.splice(a,1))}});var Kt={touchstart:Et,touchmove:Tt,touchend:Pt,touchcancel:It},Jt="touchstart",$t="touchstart touchmove touchend touchcancel";c(Y,C,{handler:function(t){var e=Kt[t.type];if(e===Et&&(this.started=!0),this.started){var i=G.call(this,t,e);e&(Pt|It)&&i[0].length-i[1].length==0&&(this.started=!1),this.callback(this.manager,e,{pointers:i[0],changedPointers:i[1],pointerType:"touch",srcEvent:t})}}});var Qt={touchstart:Et,touchmove:Tt,touchend:Pt,touchcancel:It},te="touchstart touchmove touchend touchcancel";c(V,C,{handler:function(t){var e=Qt[t.type],i=U.call(this,t,e);i&&this.callback(this.manager,e,{pointers:i[0],changedPointers:i[1],pointerType:"touch",srcEvent:t})}});var ee=2500,ie=25;c(q,C,{handler:function(t,e,i){var o="touch"==i.pointerType,n="mouse"==i.pointerType;if(!(n&&i.sourceCapabilities&&i.sourceCapabilities.firesTouchEvents)){if(o)X.call(this,e,i);else if(n&&K.call(this,i))return;this.callback(t,e,i)}},destroy:function(){this.touch.destroy(),this.mouse.destroy()}});var oe=S(vt.style,"touchAction"),ne=oe!==a,se="auto",re="manipulation",ae="none",he="pan-x",de="pan-y",le=function(){if(!ne)return!1;var t={},e=n.CSS&&n.CSS.supports;return["auto","manipulation","pan-y","pan-x","pan-x pan-y","none"].forEach(function(i){t[i]=!e||n.CSS.supports("touch-action",i)}),t}();J.protoLine truncated
;this.pixelRatio=(window.devicePixelRatio||1)/(t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1),this.colorPickerCanvas.getContext("2d").setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0)}else{var e=document.createElement("DIV");e.style.color="red",e.style.fontWeight="bold",e.style.padding="10px",e.innerHTML="Error: your browser does not support HTML canvas",this.colorPickerCanvas.appendChild(e)}this.colorPickerDiv.className="vis-color",this.opacityDiv=document.createElement("div"),this.opacityDiv.className="vis-opacity",this.brightnessDiv=document.createElement("div"),this.brightnessDiv.className="vis-brightness",this.arrowDiv=document.createElement("div"),this.arrowDiv.className="vis-arrow",this.opacityRange=document.createElement("input");try{this.opacityRange.type="range",this.opacityRange.min="0",this.opacityRange.max="100"}catch(t){}this.opacityRange.value="100",this.opacityRange.className="vis-range",this.brightnessRange=document.createElement("input");try{this.brightnessRange.type="range",this.brightnessRange.min="0",this.brightnessRange.max="100"}catch(t){}this.brightnessRange.value="100",this.brightnessRange.className="vis-range",this.opacityDiv.appendChild(this.opacityRange),this.brightnessDiv.appendChild(this.brightnessRange);var i=this;this.opacityRange.onchange=function(){i._setOpacity(this.value)},this.opacityRange.oninput=function(){i._setOpacity(this.value)},this.brightnessRange.onchange=function(){i._setBrightness(this.value)},this.brightnessRange.oninput=function(){i._setBrightness(this.value)},this.brightnessLabel=document.createElement("div"),this.brightnessLabel.className="vis-label vis-brightness",this.brightnessLabel.innerHTML="brightness:",this.opacityLabel=document.createElement("div"),this.opacityLabel.className="vis-label vis-opacity",this.opacityLabel.innerHTML="opacity:",this.newColorDiv=document.createElement("div"),this.newColorDiv.className="vis-new-color",this.newColorDiv.innerHTML="new",this.initialColorDiv=document.createElement("div"),this.initialColorDiv.className="vis-initial-color",this.initialColorDiv.innerHTML="initial",this.cancelButton=document.createElement("div"),this.cancelButton.className="vis-button vis-cancel",this.cancelButton.innerHTML="cancel",this.cancelButton.onclick=this._hide.bind(this,!1),this.applyButton=document.createElement("div"),this.applyButton.className="vis-button vis-apply",this.applyButton.innerHTML="apply",this.applyButton.onclick=this._apply.bind(this),this.saveButton=document.createElement("div"),this.saveButton.className="vis-button vis-save",this.saveButton.innerHTML="save",this.saveButton.onclick=this._save.bind(this),this.loadButton=document.createElement("div"),this.loadButton.className="vis-button vis-load",this.loadButton.innerHTML="load last",this.loadButton.onclick=this._loadLast.bind(this),this.frame.appendChild(this.colorPickerDiv),this.frame.appendChild(this.arrowDiv),this.frame.appendChild(this.brightnessLabel),this.frame.appendChild(this.brightnessDiv),this.frame.appendChild(this.opacityLabel),this.frame.appendChild(this.opacityDiv),this.frame.appendChild(this.newColorDiv),this.frame.appendChild(this.initialColorDiv),this.frame.appendChild(this.cancelButton),this.frame.appendChild(this.applyButton),this.frame.appendChild(this.saveButton),this.frame.appendChild(this.loadButton)}},{key:"_bindHammer",value:function(){var t=this;this.drag={},this.pinch={},this.hammer=new l(this.colorPickerCanvas),this.hammer.get("pinch").set({enable:!0}),u.onTouch(this.hammer,function(e){t._moveSelector(e)}),this.hammer.on("tap",function(e){t._moveSelector(e)}),this.hammer.on("panstart",function(e){t._moveSelector(e)}),this.hammer.on("panmove",function(e){t._moveSelector(e)}),this.hammer.on("panend",function(e){t._moveSelector(e)})}},{key:"_generateHueCircle",value:function(){if(!1===this.generated){var t=this.colorPickerCanvas.getContext("2d");void 0===this.pixelRation&&(this.pixelRatio=(window.devicePixelRatio||1)/(t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1)),t.setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0);var e=this.colorPickerCanvas.clientWidth,i=this.colorPickerCanvas.clientHeight;t.clearRect(0,0,e,i);var o=void 0,n=void 0,s=void 0,r=void 0;this.centerCoordinates={x:.5*e,y:.5*i},this.r=.49*e;var a=2*Math.PI/360,h=1/this.r,d=void 0;for(s=0;s<360;s++)for(r=0;r<this.r;r++)o=this.centerCoordinates.x+r*Math.sin(a*s),n=this.centerCoordinates.y+r*Math.cos(a*s),d=c.HSVToRGB(s*(1/360),r*h,1),t.fillStyle="rgb("+d.r+","+d.g+","+d.b+")",t.fillRect(o-.5,n-.5,2,2);t.strokeStyle="rgba(0,0,0,1)",t.circle(this.centerCoordinates.x,this.centerCoordinates.y,this.r),t.stroke(),this.hueCircle=t.getImageData(0,0,e,i)}this.generated=!0}},{key:"_moveSelector",value:function(t){var e=this.colorPickerDiv.getBoundingClientRect(),i=t.center.x-e.left,o=t.center.y-Line truncated
function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=i(2),d=function(){function t(){(0,s.default)(this,t),this.clear(),this.defaultIndex=0,this.groupsArray=[],this.groupIndex=0,this.defaultGroups=[{border:"#2B7CE9",background:"#97C2FC",highlight:{border:"#2B7CE9",background:"#D2E5FF"},hover:{border:"#2B7CE9",background:"#D2E5FF"}},{border:"#FFA500",background:"#FFFF00",highlight:{border:"#FFA500",background:"#FFFFA3"},hover:{border:"#FFA500",background:"#FFFFA3"}},{border:"#FA0A10",background:"#FB7E81",highlight:{border:"#FA0A10",background:"#FFAFB1"},hover:{border:"#FA0A10",background:"#FFAFB1"}},{border:"#41A906",background:"#7BE141",highlight:{border:"#41A906",background:"#A1EC76"},hover:{border:"#41A906",background:"#A1EC76"}},{border:"#E129F0",background:"#EB7DF4",highlight:{border:"#E129F0",background:"#F0B3F5"},hover:{border:"#E129F0",background:"#F0B3F5"}},{border:"#7C29F0",background:"#AD85E4",highlight:{border:"#7C29F0",background:"#D3BDF0"},hover:{border:"#7C29F0",background:"#D3BDF0"}},{border:"#C37F00",background:"#FFA807",highlight:{border:"#C37F00",background:"#FFCA66"},hover:{border:"#C37F00",background:"#FFCA66"}},{border:"#4220FB",background:"#6E6EFD",highlight:{border:"#4220FB",background:"#9B9BFD"},hover:{border:"#4220FB",background:"#9B9BFD"}},{border:"#FD5A77",background:"#FFC0CB",highlight:{border:"#FD5A77",background:"#FFD1D9"},hover:{border:"#FD5A77",background:"#FFD1D9"}},{border:"#4AD63A",background:"#C2FABC",highlight:{border:"#4AD63A",background:"#E6FFE3"},hover:{border:"#4AD63A",background:"#E6FFE3"}},{border:"#990000",background:"#EE0000",highlight:{border:"#BB0000",background:"#FF3333"},hover:{border:"#BB0000",background:"#FF3333"}},{border:"#FF6000",background:"#FF6000",highlight:{border:"#FF6000",background:"#FF6000"},hover:{border:"#FF6000",background:"#FF6000"}},{border:"#97C2FC",background:"#2B7CE9",highlight:{border:"#D2E5FF",background:"#2B7CE9"},hover:{border:"#D2E5FF",background:"#2B7CE9"}},{border:"#399605",background:"#255C03",highlight:{border:"#399605",background:"#255C03"},hover:{border:"#399605",background:"#255C03"}},{border:"#B70054",background:"#FF007E",highlight:{border:"#B70054",background:"#FF007E"},hover:{border:"#B70054",background:"#FF007E"}},{border:"#AD85E4",background:"#7C29F0",highlight:{border:"#D3BDF0",background:"#7C29F0"},hover:{border:"#D3BDF0",background:"#7C29F0"}},{border:"#4557FA",background:"#000EA1",highlight:{border:"#6E6EFD",background:"#000EA1"},hover:{border:"#6E6EFD",background:"#000EA1"}},{border:"#FFC0CB",background:"#FD5A77",highlight:{border:"#FFD1D9",background:"#FD5A77"},hover:{border:"#FFD1D9",background:"#FD5A77"}},{border:"#C2FABC",background:"#74D66A",highlight:{border:"#E6FFE3",background:"#74D66A"},hover:{border:"#E6FFE3",background:"#74D66A"}},{border:"#EE0000",background:"#990000",highlight:{border:"#FF3333",background:"#BB0000"},hover:{border:"#FF3333",background:"#BB0000"}}],this.options={},this.defaultOptions={useDefaultGroups:!0},h.extend(this.options,this.defaultOptions)}return(0,a.default)(t,[{key:"setOptions",value:function(t){var e=["useDefaultGroups"];if(void 0!==t)for(var i in t)if(t.hasOwnProperty(i)&&-1===e.indexOf(i)){var o=t[i];this.add(i,o)}}},{key:"clear",value:function(){this.groups={},this.groupsArray=[]}},{key:"get",value:function(t){var e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=this.groups[t];if(void 0===i&&e)if(!1===this.options.useDefaultGroups&&this.groupsArray.length>0){var o=this.groupIndex%this.groupsArray.length;this.groupIndex++,i={},i.color=this.groups[this.groupsArray[o]],this.groups[t]=i}else{var n=this.defaultIndex%this.defaultGroups.length;this.defaultIndex++,i={},i.color=this.defaultGroups[n],this.groups[t]=i}return i}},{key:"add",value:function(t,e){return this.groups[t]=e,this.groupsArray.push(t),e}}]),t}();e.default=d},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=i(2),d=i(11),l=i(12),u=i(47).default,c=function(){function t(e,i,o,n){var r=this;if((0,s.default)(this,t),this.body=e,this.images=i,this.groups=o,this.layoutEngine=n,this.body.functions.createNode=this.create.bind(this),this.nodesListeners={add:function(t,e){r.add(e.items)},update:function(t,e){r.update(e.items,e.data,e.oldData)},remove:function(t,e){r.remove(e.items)}},this.defaultOptions={borderWidth:1,borderWidthSelected:2,brokenImage:void 0,color:{border:"#2B7CE9",background:"#97C2FC",highlight:{border:"#2B7CE9",background:"#D2E5FF"},hover:{border:"#2B7CE9",background:"#D2E5FF"}},fixed:{x:!1,y:!1},font:{color:"#343434",size:14,face:"arial",background:"none",strokeWidth:0,strokeColor:"#ffffff",align:"center",vadjust:0,multi:!1,bold:{mod:"bold"},boldital:{mod:"bold italic"},ital:{mod:"italic"},mono:{mod:"",size:15,face:"monospace",vadjust:2}},group:void 0,hidden:!1,icon:{face:"FontAwesome",code:void 0,size:50,color:"#Line truncated
}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(24),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"draw",value:function(t,e,i,o,n,s){this._drawShape(t,"square",2,e,i,o,n,s)}},{key:"distanceToBorder",value:function(t,e){return this._distanceToBorder(t,e)}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(24),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"draw",value:function(t,e,i,o,n,s){this._drawShape(t,"hexagon",4,e,i,o,n,s)}},{key:"distanceToBorder",value:function(t,e){return this._distanceToBorder(t,e)}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(24),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"draw",value:function(t,e,i,o,n,s){this._drawShape(t,"star",4,e,i,o,n,s)}},{key:"distanceToBorder",value:function(t,e){return this._distanceToBorder(t,e)}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(23),m=o(f),v=function(t){function e(t,i,o){(0,a.default)(this,e);var n=(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o));return n._setMargins(o),n}return(0,p.default)(e,t),(0,d.default)(e,[{key:"resize",value:function(t,e,i){this.needsRefresh(e,i)&&(this.textSize=this.labelModule.getTextSize(t,e,i),this.width=this.textSize.width+this.margin.right+this.margin.left,this.height=this.textSize.height+this.margin.top+this.margin.bottom,this.radius=.5*this.width)}},{key:"draw",value:function(t,e,i,o,n,s){this.resize(t,o,n),this.left=e-this.width/2,this.top=i-this.height/2,this.enableShadow(t,s),this.labelModule.draw(t,this.left+this.textSize.width/2+this.margin.left,this.top+this.textSize.height/2+this.margin.top,o,n),this.disableShadow(t,s),this.updateBoundingBox(e,i,t,o,n)}},{key:"distanceToBorder",value:function(t,e){return this._distanceToBorder(t,e)}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(24),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"draw",value:function(t,e,i,o,n,s){this._drawShape(t,"triangle",3,e,i,o,n,s)}},{key:"distanceToBorder",value:function(t,e){return this._distanceToBorder(t,e)}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(24),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"draw",value:function(t,e,i,o,n,s){this._drawShape(t,"triangleDown",3,e,i,o,n,s)}},{key:"distanceToBorder",value:function(t,e){return this._distanceToBorder(t,e)}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=i(2),d=i(11),l=i(12),u=i(74).default,c=function(){function t(e,i,o){var n=this;(0,s.default)(this,t),this.body=e,this.images=i,this.groups=o,this.body.functions.createEdge=this.create.bind(this),this.edgesListeners={add:function(t,e){n.add(e.items)},update:function(t,e){n.update(e.items)},remove:function(t,e){n.remove(e.items)}},this.options={},this.defaultOptions={arrows:{to:{enabled:!1,scaleFactor:1,type:"arrow"},middle:{enabled:!1,scaleFactor:1,type:"arrow"},from:{enabled:!1,scaleFactor:1,type:"arrow"}},arrowStrikethrough:!0,color:{color:"#848484",highlight:"#848484",hover:"#848484",inherit:"from",opacity:1},dashes:!1,font:{color:"#343434",size:14,face:"arial",background:"none",strokeWidth:2,strokeColor:"#ffffff",align:"horizontal",multi:!1,vadjust:0,bold:{mod:"bold"},boldital:{mod:"bold italic"},ital:{mod:"italic"},mono:{mod:"",size:15,face:"courier nLine truncated
;s=i<p?-Math.pow(.05*i,2)+Math.pow(.05*p,2):0,0===i?i=.01:s/=i,o=t*s,n=e*s,c[r.id].x-=o,c[r.id].y-=n,c[a.id].x+=o,c[a.id].y+=n}}}]),t}();e.default=h},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=function(){function t(e,i,o){(0,s.default)(this,t),this.body=e,this.physicsBody=i,this.setOptions(o)}return(0,a.default)(t,[{key:"setOptions",value:function(t){this.options=t}},{key:"solve",value:function(){for(var t=void 0,e=void 0,i=this.physicsBody.physicsEdgeIndices,o=this.body.edges,n=void 0,s=void 0,r=void 0,a=0;a<i.length;a++)e=o[i[a]],!0===e.connected&&e.toId!==e.fromId&&void 0!==this.body.nodes[e.toId]&&void 0!==this.body.nodes[e.fromId]&&(void 0!==e.edgeType.via?(t=void 0===e.options.length?this.options.springLength:e.options.length,n=e.to,s=e.edgeType.via,r=e.from,this._calculateSpringForce(n,s,.5*t),this._calculateSpringForce(s,r,.5*t)):(t=void 0===e.options.length?1.5*this.options.springLength:e.options.length,this._calculateSpringForce(e.from,e.to,t)))}},{key:"_calculateSpringForce",value:function(t,e,i){var o=t.x-e.x,n=t.y-e.y,s=Math.max(Math.sqrt(o*o+n*n),.01),r=this.options.springConstant*(i-s)/s,a=o*r,h=n*r;void 0!==this.physicsBody.forces[t.id]&&(this.physicsBody.forces[t.id].x+=a,this.physicsBody.forces[t.id].y+=h),void 0!==this.physicsBody.forces[e.id]&&(this.physicsBody.forces[e.id].x-=a,this.physicsBody.forces[e.id].y-=h)}}]),t}();e.default=h},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=function(){function t(e,i,o){(0,s.default)(this,t),this.body=e,this.physicsBody=i,this.setOptions(o)}return(0,a.default)(t,[{key:"setOptions",value:function(t){this.options=t}},{key:"solve",value:function(){for(var t,e,i,o,n,s,r,a,h=this.body.edges,d=this.physicsBody.physicsEdgeIndices,l=this.physicsBody.physicsNodeIndices,u=this.physicsBody.forces,c=0;c<l.length;c++){var p=l[c];u[p].springFx=0,u[p].springFy=0}for(var f=0;f<d.length;f++)e=h[d[f]],!0===e.connected&&(t=void 0===e.options.length?this.options.springLength:e.options.length,i=e.from.x-e.to.x,o=e.from.y-e.to.y,a=Math.sqrt(i*i+o*o),a=0===a?.01:a,r=this.options.springConstant*(t-a)/a,n=i*r,s=o*r,e.to.level!=e.from.level?(void 0!==u[e.toId]&&(u[e.toId].springFx-=n,u[e.toId].springFy-=s),void 0!==u[e.fromId]&&(u[e.fromId].springFx+=n,u[e.fromId].springFy+=s)):(void 0!==u[e.toId]&&(u[e.toId].x-=.5*n,u[e.toId].y-=.5*s),void 0!==u[e.fromId]&&(u[e.fromId].x+=.5*n,u[e.fromId].y+=.5*s)));r=1;for(var m,v,g=0;g<l.length;g++){var y=l[g];m=Math.min(r,Math.max(-r,u[y].springFx)),v=Math.min(r,Math.max(-r,u[y].springFy)),u[y].x+=m,u[y].y+=v}for(var b=0,_=0,w=0;w<l.length;w++){var x=l[w];b+=u[x].x,_+=u[x].y}for(var k=b/l.length,S=_/l.length,D=0;D<l.length;D++){var M=l[D];u[M].x-=k,u[M].y-=S}}}]),t}();e.default=h},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(120),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"_calculateForces",value:function(t,e,i,o,n){0===t&&(t=.1*Math.random(),e=t),this.overlapAvoidanceFactor<1&&o.shape.radius&&(t=Math.max(.1+this.overlapAvoidanceFactor*o.shape.radius,t-o.shape.radius));var s=o.edges.length+1,r=this.options.gravitationalConstant*n.mass*o.options.mass*s/Math.pow(t,2),a=e*r,h=i*r;this.physicsBody.forces[o.id].x+=a,this.physicsBody.forces[o.id].y+=h}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(3),s=o(n),r=i(0),a=o(r),h=i(1),d=o(h),l=i(4),u=o(l),c=i(5),p=o(c),f=i(121),m=o(f),v=function(t){function e(t,i,o){return(0,a.default)(this,e),(0,u.default)(this,(e.__proto__||(0,s.default)(e)).call(this,t,i,o))}return(0,p.default)(e,t),(0,d.default)(e,[{key:"_calculateForces",value:function(t,e,i,o,n){if(t>0){var s=n.edges.length+1,r=this.options.centralGravity*s*n.options.mass;o[n.id].x=e*r,o[n.id].y=i*r}}}]),e}(m.default);e.default=v},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(8),s=o(n),r=i(6),a=o(r),h=i(0),d=o(h),l=i(1),u=o(l),c=i(2),p=i(76).default,f=i(228).default,m=i(74).default,v=i(47).default,g=function(){function t(e){var i=this;(0,d.default)(this,t),this.body=e,this.clusteredNodes={},this.clusteredEdges={},this.options={},this.defaultOptions={},c.extend(this.options,this.defaultOptions),this.body.emitter.on("_resetData",function(){i.clusteredNodes={},i.clusteredEdges={}})}return(0,u.default)(t,[{key:"clusterByHubsize",value:function(t,e){void 0===t?t=this._getHubSize():"object"===(void 0===t?"undefined":(0,a.default)(t))&&(e=this._checkOptions(t),t=this._getHubSiLine truncated
this.frame.canvas.width!==r&&(this.frame.canvas.width=r,i=!0),this.frame.canvas.height!==a&&(this.frame.canvas.height=a,i=!0)}return!0===i&&(this.body.emitter.emit("resize",{width:Math.round(this.frame.canvas.width/this.pixelRatio),height:Math.round(this.frame.canvas.height/this.pixelRatio),oldWidth:Math.round(o/this.pixelRatio),oldHeight:Math.round(n/this.pixelRatio)}),this._setCameraState()),this.initialized=!0,i}},{key:"getContext",value:function(){return this.frame.canvas.getContext("2d")}},{key:"_determinePixelRatio",value:function(){var t=this.getContext();if(void 0===t)throw new Error("Could not get canvax context");var e=1;return"undefined"!=typeof window&&(e=window.devicePixelRatio||1),e/(t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1)}},{key:"_setPixelRatio",value:function(){this.pixelRatio=this._determinePixelRatio()}},{key:"setTransform",value:function(){var t=this.getContext();if(void 0===t)throw new Error("Could not get canvax context");t.setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0)}},{key:"_XconvertDOMtoCanvas",value:function(t){return(t-this.body.view.translation.x)/this.body.view.scale}},{key:"_XconvertCanvasToDOM",value:function(t){return t*this.body.view.scale+this.body.view.translation.x}},{key:"_YconvertDOMtoCanvas",value:function(t){return(t-this.body.view.translation.y)/this.body.view.scale}},{key:"_YconvertCanvasToDOM",value:function(t){return t*this.body.view.scale+this.body.view.translation.y}},{key:"canvasToDOM",value:function(t){return{x:this._XconvertCanvasToDOM(t.x),y:this._YconvertCanvasToDOM(t.y)}}},{key:"DOMtoCanvas",value:function(t){return{x:this._XconvertDOMtoCanvas(t.x),y:this._YconvertDOMtoCanvas(t.y)}}}]),t}();e.default=u},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(0),s=o(n),r=i(1),a=o(r),h=i(2),d=i(76).default,l=function(){function t(e,i){var o=this;(0,s.default)(this,t),this.body=e,this.canvas=i,this.animationSpeed=1/this.renderRefreshRate,this.animationEasingFunction="easeInOutQuint",this.easingTime=0,this.sourceScale=0,this.targetScale=0,this.sourceTranslation=0,this.targetTranslation=0,this.lockedOnNodeId=void 0,this.lockedOnNodeOffset=void 0,this.touchTime=0,this.viewFunction=void 0,this.body.emitter.on("fit",this.fit.bind(this)),this.body.emitter.on("animationFinished",function(){o.body.emitter.emit("_stopRendering")}),this.body.emitter.on("unlockNode",this.releaseNode.bind(this))}return(0,a.default)(t,[{key:"setOptions",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.options=t}},{key:"fit",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{nodes:[]},e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=void 0,o=void 0;if(void 0!==t.nodes&&0!==t.nodes.length||(t.nodes=this.body.nodeIndices),!0===e){var n=0;for(var s in this.body.nodes)if(this.body.nodes.hasOwnProperty(s)){var r=this.body.nodes[s];!0===r.predefinedPosition&&(n+=1)}if(n>.5*this.body.nodeIndices.length)return void this.fit(t,!1);i=d.getRange(this.body.nodes,t.nodes);o=12.662/(this.body.nodeIndices.length+7.4147)+.0964822;o*=Math.min(this.canvas.frame.canvas.clientWidth/600,this.canvas.frame.canvas.clientHeight/600)}else{this.body.emitter.emit("_resizeNodes"),i=d.getRange(this.body.nodes,t.nodes);var a=1.1*Math.abs(i.maxX-i.minX),h=1.1*Math.abs(i.maxY-i.minY),l=this.canvas.frame.canvas.clientWidth/a,u=this.canvas.frame.canvas.clientHeight/h;o=l<=u?l:u}o>1?o=1:0===o&&(o=1);var c=d.findCenter(i),p={position:c,scale:o,animation:t.animation};this.moveTo(p)}},{key:"focus",value:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(void 0!==this.body.nodes[t]){var i={x:this.body.nodes[t].x,y:this.body.nodes[t].y};e.position=i,e.lockedOnNode=t,this.moveTo(e)}else console.log("Node: "+t+" cannot be found.")}},{key:"moveTo",value:function(t){if(void 0===t)return void(t={});void 0===t.offset&&(t.offset={x:0,y:0}),void 0===t.offset.x&&(t.offset.x=0),void 0===t.offset.y&&(t.offset.y=0),void 0===t.scale&&(t.scale=this.body.view.scale),void 0===t.position&&(t.position=this.getViewPosition()),void 0===t.animation&&(t.animation={duration:0}),!1===t.animation&&(t.animation={duration:0}),!0===t.animation&&(t.animation={}),void 0===t.animation.duration&&(t.animation.duration=1e3),void 0===t.animation.easingFunction&&(t.animation.easingFunction="easeInOutQuad"),this.animateView(t)}},{key:"animateView",value:function(t){if(void 0!==t){this.animationEasingFunction=t.animation.easingFunction,this.releaseNode(),!0===t.locked&&(this.lockedOnNodeId=t.lockedOnNode,this.lockedOnNodeOffset=t.offset),0!=this.easingTime&&this._transitionRedraw(!0),this.sourceScale=this.body.view.scale,this.sourceTranslation=this.body.view.translation,this.targetScale=t.scale,this.body.view.scale=this.targetScale;var e=this.canvas.DOMtoCanvas({x:.5*this.canvas.frame.cLine truncated
;if((e.unselectAll||void 0===e.unselectAll)&&this.unselectAll(),t.nodes)for(i=0;i<t.nodes.length;i++){o=t.nodes[i];var n=this.body.nodes[o];if(!n)throw new RangeError('Node with id "'+o+'" not found');this.selectObject(n,e.highlightEdges)}if(t.edges)for(i=0;i<t.edges.length;i++){o=t.edges[i];var s=this.body.edges[o];if(!s)throw new RangeError('Edge with id "'+o+'" not found');this.selectObject(s)}this.body.emitter.emit("_requestRedraw")}},{key:"selectNodes",value:function(t){var e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(!t||void 0===t.length)throw"Selection must be an array with ids";this.setSelection({nodes:t},{highlightEdges:e})}},{key:"selectEdges",value:function(t){if(!t||void 0===t.length)throw"Selection must be an array with ids";this.setSelection({edges:t})}},{key:"updateSelection",value:function(){for(var t in this.selectionObj.nodes)this.selectionObj.nodes.hasOwnProperty(t)&&(this.body.nodes.hasOwnProperty(t)||delete this.selectionObj.nodes[t]);for(var e in this.selectionObj.edges)this.selectionObj.edges.hasOwnProperty(e)&&(this.body.edges.hasOwnProperty(e)||delete this.selectionObj.edges[e])}},{key:"getClickedItems",value:function(t){for(var e=this.canvas.DOMtoCanvas(t),i=[],o=this.body.nodeIndices,n=this.body.nodes,s=o.length-1;s>=0;s--){var r=n[o[s]],a=r.getItemsOnPoint(e);i.push.apply(i,a)}for(var h=this.body.edgeIndices,d=this.body.edges,l=h.length-1;l>=0;l--){var u=d[h[l]],c=u.getItemsOnPoint(e);i.push.apply(i,c)}return i}}]),t}();e.default=u},function(t,e,i){function o(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var n=i(30),s=o(n),r=i(6),a=o(r),h=i(8),d=o(h),l=i(0),u=o(l),c=i(1),p=o(c),f=i(2),m=i(76).default,v=i(236),g=v.HorizontalStrategy,y=v.VerticalStrategy,b=function(){function t(){(0,u.default)(this,t),this.childrenReference={},this.parentReference={},this.trees={},this.distributionOrdering={},this.levels={},this.distributionIndex={},this.isTree=!1,this.treeIndex=-1}return(0,p.default)(t,[{key:"addRelation",value:function(t,e){void 0===this.childrenReference[t]&&(this.childrenReference[t]=[]),this.childrenReference[t].push(e),void 0===this.parentReference[e]&&(this.parentReference[e]=[]),this.parentReference[e].push(t)}},{key:"checkIfTree",value:function(){for(var t in this.parentReference)if(this.parentReference[t].length>1)return void(this.isTree=!1);this.isTree=!0}},{key:"numTrees",value:function(){return this.treeIndex+1}},{key:"setTreeIndex",value:function(t,e){void 0!==e&&void 0===this.trees[t.id]&&(this.trees[t.id]=e,this.treeIndex=Math.max(e,this.treeIndex))}},{key:"ensureLevel",value:function(t){void 0===this.levels[t]&&(this.levels[t]=0)}},{key:"getMaxLevel",value:function(t){var e=this,i={};return function t(o){if(void 0!==i[o])return i[o];var n=e.levels[o];if(e.childrenReference[o]){var s=e.childrenReference[o];if(s.length>0)for(var r=0;r<s.length;r++)n=Math.max(n,t(s[r]))}return i[o]=n,n}(t)}},{key:"levelDownstream",value:function(t,e){void 0===this.levels[e.id]&&(void 0===this.levels[t.id]&&(this.levels[t.id]=0),this.levels[e.id]=this.levels[t.id]+1)}},{key:"setMinLevelToZero",value:function(t){var e=1e9;for(var i in t)t.hasOwnProperty(i)&&void 0!==this.levels[i]&&(e=Math.min(this.levels[i],e));for(var o in t)t.hasOwnProperty(o)&&void 0!==this.levels[o]&&(this.levels[o]-=e)}},{key:"getTreeSize",value:function(t,e){var i=1e9,o=-1e9,n=1e9,s=-1e9;for(var r in this.trees)if(this.trees.hasOwnProperty(r)&&this.trees[r]===e){var a=t[r];i=Math.min(a.x,i),o=Math.max(a.x,o),n=Math.min(a.y,n),s=Math.max(a.y,s)}return{min_x:i,max_x:o,min_y:n,max_y:s}}},{key:"hasSameParent",value:function(t,e){var i=this.parentReference[t.id],o=this.parentReference[e.id];if(void 0===i||void 0===o)return!1;for(var n=0;n<i.length;n++)for(var s=0;s<o.length;s++)if(i[n]==o[s])return!0;return!1}},{key:"inSameSubNetwork",value:function(t,e){return this.trees[t.id]===this.trees[e.id]}},{key:"getLevels",value:function(){return(0,d.default)(this.distributionOrdering)}},{key:"addToOrdering",value:function(t,e){void 0===this.distributionOrdering[e]&&(this.distributionOrdering[e]=[]);var i=!1,o=this.distributionOrdering[e];for(var n in o)if(o[n]===t){i=!0;break}i||(this.distributionOrdering[e].push(t),this.distributionIndex[t.id]=this.distributionOrdering[e].length-1)}}]),t}(),_=function(){function t(e){(0,u.default)(this,t),this.body=e,this.initialRandomSeed=Math.round(1e6*Math.random()),this.randomSeed=this.initialRandomSeed,this.setPhysics=!1,this.options={},this.optionsBackup={physics:{}},this.defaultOptions={randomSeed:void 0,improvedLayout:!0,hierarchical:{enabled:!1,levelSeparation:150,nodeSpacing:100,treeSpacing:200,blockShifting:!0,edgeMinimization:!0,parentCentralization:!0,direction:"UD",sortMethod:"hubsize"}},f.extend(this.options,this.defaultOptions),this.bindEventListeners()}return(0,p.default)(t,[{key:"bindEventListeners",value:function(){var t=this;this.body.emitter.on("_dataChanged",function(){t.setupHierarchicalLayout()}),this.boLine truncated
;if(2!==o.length)throw new Error("The function for delete does not support two arguments (data, callback)");o(s,function(e){null!==e&&void 0!==e&&"delete"===t.inMode?(t.body.data.edges.getDataSet().remove(e.edges),t.body.data.nodes.getDataSet().remove(e.nodes),t.body.emitter.emit("startSimulation"),t.showManipulatorToolbar()):(t.body.emitter.emit("startSimulation"),t.showManipulatorToolbar())})}else this.body.data.edges.getDataSet().remove(i),this.body.data.nodes.getDataSet().remove(e),this.body.emitter.emit("startSimulation"),this.showManipulatorToolbar()}},{key:"_setup",value:function(){!0===this.options.enabled?(this.guiEnabled=!0,this._createWrappers(),!1===this.editMode?this._createEditButton():this.showManipulatorToolbar()):(this._removeManipulationDOM(),this.guiEnabled=!1)}},{key:"_createWrappers",value:function(){void 0===this.manipulationDiv&&(this.manipulationDiv=document.createElement("div"),this.manipulationDiv.className="vis-manipulation",!0===this.editMode?this.manipulationDiv.style.display="block":this.manipulationDiv.style.display="none",this.canvas.frame.appendChild(this.manipulationDiv)),void 0===this.editModeDiv&&(this.editModeDiv=document.createElement("div"),this.editModeDiv.className="vis-edit-mode",!0===this.editMode?this.editModeDiv.style.display="none":this.editModeDiv.style.display="block",this.canvas.frame.appendChild(this.editModeDiv)),void 0===this.closeDiv&&(this.closeDiv=document.createElement("div"),this.closeDiv.className="vis-close",this.closeDiv.style.display=this.manipulationDiv.style.display,this.canvas.frame.appendChild(this.closeDiv))}},{key:"_getNewTargetNode",value:function(t,e){var i=f.deepExtend({},this.options.controlNodeStyle);i.id="targetNode"+f.randomUUID(),i.hidden=!1,i.physics=!1,i.x=t,i.y=e;var o=this.body.functions.createNode(i);return o.shape.boundingBox={left:t,right:t,top:e,bottom:e},o}},{key:"_createEditButton",value:function(){this._clean(),this.manipulationDOM={},f.recursiveDOMDelete(this.editModeDiv);var t=this.options.locales[this.options.locale],e=this._createButton("editMode","vis-button vis-edit vis-edit-mode",t.edit||this.options.locales.en.edit);this.editModeDiv.appendChild(e),this._bindHammerToDiv(e,this.toggleEditMode.bind(this))}},{key:"_clean",value:function(){this.inMode=!1,!0===this.guiEnabled&&(f.recursiveDOMDelete(this.editModeDiv),f.recursiveDOMDelete(this.manipulationDiv),this._cleanManipulatorHammers()),this._cleanupTemporaryNodesAndEdges(),this._unbindTemporaryUIs(),this._unbindTemporaryEvents(),this.body.emitter.emit("restorePhysics")}},{key:"_cleanManipulatorHammers",value:function(){if(0!=this.manipulationHammers.length){for(var t=0;t<this.manipulationHammers.length;t++)this.manipulationHammers[t].destroy();this.manipulationHammers=[]}}},{key:"_removeManipulationDOM",value:function(){this._clean(),f.recursiveDOMDelete(this.manipulationDiv),f.recursiveDOMDelete(this.editModeDiv),f.recursiveDOMDelete(this.closeDiv),this.manipulationDiv&&this.canvas.frame.removeChild(this.manipulationDiv),this.editModeDiv&&this.canvas.frame.removeChild(this.editModeDiv),this.closeDiv&&this.canvas.frame.removeChild(this.closeDiv),this.manipulationDiv=void 0,this.editModeDiv=void 0,this.closeDiv=void 0}},{key:"_createSeperator",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1;this.manipulationDOM["seperatorLineDiv"+t]=document.createElement("div"),this.manipulationDOM["seperatorLineDiv"+t].className="vis-separator-line",this.manipulationDiv.appendChild(this.manipulationDOM["seperatorLineDiv"+t])}},{key:"_createAddNodeButton",value:function(t){var e=this._createButton("addNode","vis-button vis-add",t.addNode||this.options.locales.en.addNode);this.manipulationDiv.appendChild(e),this._bindHammerToDiv(e,this.addNodeMode.bind(this))}},{key:"_createAddEdgeButton",value:function(t){var e=this._createButton("addEdge","vis-button vis-connect",t.addEdge||this.options.locales.en.addEdge);this.manipulationDiv.appendChild(e),this._bindHammerToDiv(e,this.addEdgeMode.bind(this))}},{key:"_createEditNodeButton",value:function(t){var e=this._createButton("editNode","vis-button vis-edit",t.editNode||this.options.locales.en.editNode);this.manipulationDiv.appendChild(e),this._bindHammerToDiv(e,this.editNode.bind(this))}},{key:"_createEditEdgeButton",value:function(t){var e=this._createButton("editEdge","vis-button vis-edit",t.editEdge||this.options.locales.en.editEdge);this.manipulationDiv.appendChild(e),this._bindHammerToDiv(e,this.editEdgeMode.bind(this))}},{key:"_createDeleteButton",value:function(t){var e;e=this.options.rtl?"vis-button vis-delete-rtl":"vis-button vis-delete";var i=this._createButton("delete",e,t.del||this.options.locales.en.del);this.manipulationDiv.appendChild(i),this._bindHammerToDiv(i,this.deleteSelected.bind(this))}},{key:"_createBackButton",value:function(t){var e=this._createButton("back","vis-button vis-back",t.back||this.options.locales.en.back);this.manipulationDiv.appendChild(e),this._bindHammerToDiv(e,this.shLine truncated
//# sourceMappingURL=vis.map
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+24
View File
@@ -0,0 +1,24 @@
<template>
<router-view />
</template>
<script setup>
// import autofit from '../src/assets/js/autofit'
document.documentElement.setAttribute('theme-mode', 'dark');
// if (typeof autofit !== 'undefined') {
// autofit.init();
// console.log('autofit初始化');
// }
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import request from './request'
export const getUserList = () => {
return request.get('/users')
}
export const getUserById = (id) => {
return request.get(`/users/${id}`)
}
export const createUser = (data) => {
return request.post('/users', data)
}
export const updateUser = (id, data) => {
return request.put(`/users/${id}`, data)
}
export const deleteUser = (id) => {
return request.delete(`/users/${id}`)
}
@@ -0,0 +1,7 @@
export default {
0: "待签收",
10: "待分析",
20: "CEH中",
25: "CEH完成",
23: '已终止',
};
@@ -0,0 +1,12 @@
export const deepCopy = (data) => {
return JSON.parse(JSON.stringify(data));
}
export const uniqueById = (array) => {
return array.reduce((acc, item) => {
if (!acc.find(x => x.id === item.id)) {
acc.push(item);
}
return acc;
}, []);
};
@@ -0,0 +1,4 @@
export default {
0: "本系统创建",
1: "来自于B3",
};
@@ -0,0 +1,5 @@
export default {
1: "一般",
2: "紧急",
3: "加急",
};
@@ -0,0 +1,16 @@
export default {
0: "待签收",
10: "待分析",
20: "分析中",
25: "分析完成已提交",
30: "已分析待审核",
40: "分析审核通过",
50: "分析审核未通过",
60: "行动已下发待签收",
70: "行动已签收待策划",
80: "行动策划已提交",
90: "作战方案已提交",
100: "作战方案待审核",
110: "作战方案已审核",
120: "作战方案未通过",
};
@@ -0,0 +1,36 @@
<template>
<div class="AIChatExtract">
<div class="box">
AIChatExtract
</div>
</div>
</template>
<script>
export default {
data() {
return {
};
},
methods: {
},
watch: {
},
};
</script>
<style lang="less">
.AIChatExtract {
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 1001;
background-color: rgba(0, 0, 0, .2);
.box {
color: #fff;
}
}
</style>
@@ -0,0 +1,702 @@
<template>
<div class="action-overview">
<t-loading :loading="loading" show-overlay>
<t-layout>
<t-aside width="568px"></t-aside>
<t-layout>
<div class="overview-head"></div>
<div class="overview-body">
<div v-if="!loading" ref="graphRef" class="graph-container"></div>
</div>
</t-layout>
<t-aside width="562px">
<div class="graph-map-content">
<div class="collapse-content collapse-content-right flex-center-center cursor-pointer"
v-if="expandRightShow" @click="expandRightChange">
<img src="@/assets/map-icon/expand-logo.svg" alt="" />
</div>
<div class="expand-content" :class="{ 'expand-hidden': expandRightShow }">
<div class="graph-map">
<div class="list-content">
<div class="expand-logo cursor-pointer" @click="expandRightChange">
<img src="../assets/map-icon/expand-logo.svg" alt="" />
</div>
<div class="list-item" v-for="(item, index) in graphList" :key="index">
<div class="item-logo">
<img :src="item.imgUrl" alt="" />
</div>
<div class="item-title">{{ item.title }}</div>
</div>
</div>
</div>
</div>
</div>
</t-aside>
</t-layout>
</t-loading>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue';
import { Graph, Quadratic, register, ExtensionCategory, Circle } from '@antv/g6'
import { requestAxiosService } from '@/service/request-axios-service';
import { deepCopy, uniqueById } from '@/common/chatData';
const props = defineProps({
msg: String,
actionData: Object,
actionId: [String, Number, Object, Boolean, Array]
})
const count = ref(0);
let graph = null;
const graphRef = ref(null);
let treeMapData = {
nodes: [],
edges: [],
}
const service = requestAxiosService;
const loading = ref(false);
import xdLogo from "@/assets/map-icon/xd-logo.svg"
import xdLogoPng from "@/assets/map-icon/xd-logo.png"
import bcLogo from "@/assets/map-icon/boci-logo.svg"
import bcLogoPng from "@/assets/map-icon/boci-logo.png"
import characterLogo from "@/assets/map-icon/character-logo.svg"
import characterLogoPng from "@/assets/map-icon/character-logo.png"
import userSpreadLogo from "@/assets/map-icon/user-spread-logo.svg"
import userSpreadLogoPng from "@/assets/map-icon/user-spread-logo.png"
import audienceLogo from "@/assets/map-icon/audience-logo.svg"
import audienceLogoPng from "@/assets/map-icon/audience-logo.png"
import strategyLogo from "@/assets/map-icon/strategy-logo.png"
const graphList = [
{ imgUrl: xdLogo, title: 'XD' },
{ imgUrl: bcLogo, title: '波次' },
{ imgUrl: characterLogo, title: '人设' },
{ imgUrl: userSpreadLogo, title: '关键传播节点' },
{ imgUrl: audienceLogo, title: '受众群体' },
// { imgUrl: `${environment['g6ImgUrl'] || '/src/assets'}/map-icon/user-logo.svg`, title: '受众目标' },
]
const expandRightShow = ref(false);
onMounted(() => {
// initGraph()
})
const expandRightChange = () => {
expandRightShow.value = !expandRightShow.value;
}
// 查询关键传播节点-未完成
const copRelationingSearch = async (actionId) => {
const parmy = {
actionId,
}
loading.value = true;
const res = await service.get(`${environment['b1ConsumerUrl']}/cop/copRelationing`, parmy);
loading.value = false;
console.log(res);
treeMapData.nodes = [];
treeMapData.edges = [];
let result = res.data;
treeMapData.nodes.push({
id: actionId,
data: {
type: 'action',
bgColor: '#cf1322',
name: '行动'
},
size: 30
});
result?.designs?.forEach((item) => {
treeMapData.nodes.push({
id: item.designId,
data: { type: 'audience', bgColor: '#1569ef', name: item.targetAudience, item: deepCopy(item) },
size: 25
});
treeMapData.edges.push({
source: actionId, target: item.designId, data: { type: 'audience', lineColor: '#23D0FF', name: item.targetAudience }
});
item.designAccount.forEach((aItem) => {
//添加受众目标
// treeMapData.nodes.push({
// id: aItem.id,
// data: { type: 'userList', bgColor: '#1eb07f', name: aItem.accountName, item: deepCopy(aItem) },
// size: 25
// });
// treeMapData.edges.push({
// source: item.designId, target: aItem.id, data: { type: 'userList', lineColor: '#8263FF', name: aItem.accountName }
// });
});
// if (item.strategy) {
// item.strategy.forEach((cItem) => {
// try {
// cItem.extendInfo = JSON.parse(cItem.extendInfo);
// console.log('策略详细信息', cItem.extendInfo)
// } catch (error) {
// console.log('error',error)
// // cItem.namme
// }
// treeMapData.nodes.push({
// id: cItem.id,
// data: { type: 'strategy', bgColor: '#ac77e8', name: cItem.topic, item: deepCopy(cItem) },
// size: 25
// });
// treeMapData.edges.push({
// source: item.designId, target: cItem.id, data: { type: 'strategy', lineColor: '#ac77e8', name: cItem.topic }
// });
// })
// }
});
result?.pathway?.forEach((item) => {
treeMapData.nodes.push({
id: item.pathwayId,
data: { type: 'userSpread', bgColor: '#1eb07f', name: item.accountName, item: deepCopy(item) },
size: 25
});
treeMapData.edges.push({
source: actionId, target: item.pathwayId, data: { type: 'userSpread', lineColor: '#B0DC00', name: item.accountName }
});
// if (item.strategy) {
// item.strategy.forEach((aItem) => {
// treeMapData.nodes.push({
// id: aItem.id,
// data: { type: 'strategy', bgColor: '#ac77e8', name: aItem.topic, item: deepCopy(aItem) },
// size: 25
// });
// treeMapData.edges.push({
// source: item.pathwayId, target: aItem.id, data: { type: 'strategy', lineColor: '#ac77e8', name: aItem.topic }
// });
// })
// }
})
// await graph.value?.clear();
setTimeout(async () => {
await initGraph();
}, 10)
}
// 查询关键传播节点关系图-已完成
const copRelationSearch = async (actionId) => {
console.log('actionData', props.actionData);
const parmy = {
actionId,
}
loading.value = true;
const res = await service.get(`${environment['b1ConsumerUrl']}/cop/copRelations`, parmy);
loading.value = false;
console.log('查询关系图', res);
let result = res.data;
treeMapData.nodes = [];
treeMapData.edges = [];
treeMapData.nodes.push({
id: actionId,
data: {
type: 'action',
bgColor: '#cf1322',
name: '行动'
},
size: 30
})
result.bocis.forEach((item) => {
treeMapData.nodes.push({
id: item.bociId,
data: { type: 'boci', bgColor: '#fff6ea', name: item.bociName, item: deepCopy(item) },
size: 25
})
treeMapData.edges.push({
source: actionId, target: item.bociId, data: { type: 'action', lineColor: '#3683FF', name: item.bociName }
})
item.strategies.forEach((aItem) => {
// treeMapData.nodes.push({
// id: aItem.strategyId,
// data: { type: 'strategy', bgColor: '#ac77e8', name: aItem.targetAudience, item: deepCopy(aItem) },
// size: 25
// })
// treeMapData.edges.push({
// source: item.bociId, target: aItem.strategyId, data: { type: 'strategy', lineColor: '#ac77e8', name: aItem.targetAudience }
// })
aItem.personas.forEach((bItem, bIndex) => {
treeMapData.nodes.push({
id: 'persona' + bIndex + bItem.persona,
data: { type: 'character', bgColor: '#fff6ea', name: bItem.persona, item: deepCopy(bItem) },
size: 25
})
treeMapData.edges.push({
source: item.bociId, target: 'persona' + bIndex + bItem.persona, data: { type: 'character', lineColor: '#8263FF', name: bItem.persona }
})
bItem.pathwayRelations.forEach((cItem) => {
treeMapData.nodes.push({
id: cItem.pathwayId,
data: { type: 'userSpread', bgColor: '#1eb07f', name: cItem.pathwayAccountName, item: deepCopy(cItem) },
size: 25
})
treeMapData.edges.push({
source: 'persona' + bIndex + bItem.persona, target: cItem.pathwayId, data: { type: 'userSpread', lineColor: '#B0DC00', name: cItem.pathwayAccountName }
})
})
bItem.designRelations.forEach((dItem) => {
treeMapData.nodes.push({
id: dItem.designId,
data: { type: 'audience', bgColor: '#1569ef', name: dItem.targetAudience, item: deepCopy(dItem) },
size: 25
})
treeMapData.edges.push({
source: 'persona' + bIndex + bItem.persona, target: dItem.designId, data: { type: 'audience', lineColor: '#23D0FF', name: dItem.targetAudience }
})
})
})
})
});
treeMapData.nodes = uniqueById(treeMapData.nodes)
console.log('查询关系处理', treeMapData);
// await graph.value?.clear();
setTimeout(async () => {
await initGraph();
}, 10)
}
watch(() => props.actionData, (newVal, oldVal) => {
console.log('count changed from-------------', oldVal, 'to', newVal);
if (newVal) {
if (newVal.actionStatus === 25) {
copRelationSearch(newVal.id);
} else if (newVal.actionStatus === 20) {
copRelationingSearch(newVal.id)
}
}
}, { immediate: true });
// 渲染关系图
const initGraph = () => {
console.log('environment', environment)
// 自定义边
class AntLine extends Quadratic {
onCreate() {
const shape = this.shapeMap.key;
shape.animate([{ lineDashOffset: 20 }, { lineDashOffset: 0 }], { // 添加动画
duration: 500,
iterations: Infinity,
})
}
}
register(ExtensionCategory.EDGE, 'ant-line', AntLine);
// 定义自定义节点
class CustomImageCircleNode extends Circle {
render(attributes = this.parsedAttributes, container) {
super.render(attributes, container); // 渲染圆形节点
const [width, height] = this.getSize(attributes);
// 获取节点数据中的图片源
const { type, platform } = attributes;
// console.log('attributes', attributes);
// 如果有图片源,则添加图片元素
if (type === 'action') { // 当节点类型为行动
// console.log('imgUrl', imgUrl, width);
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: xdLogoPng,
},
container
);
this.upsert('subtitle', 'text', {
x: 0,
y: 0, // 节点中心
text: '行动',
fontSize: 16,
fill: '#fff',
textAlign: 'center',
textBaseline: 'middle',
fontWeight: 'bold' // 设置字体加粗
}, container)
} else if (type === 'boci') { // 节点类型为波次
// this.upsert(
// 'node-circle',
// 'rect',
// {
// x: -width / 2, // 图片的x坐标,使其居中
// y: -height / 2, // 图片的y坐标,使其居中
// width: width - 2, // 图片宽度略小于圆形节点
// height: height - 2, // 图片高度略小于圆形节点
// fill: 'transparent',
// stroke: '#fc5',
// lineWidth: 5,
// radius: 50,
// labelBackground: true,
// },
// container
// );
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: bcLogoPng,
},
container
);
this.upsert('subtitle', 'text', {
x: 10,
y: 0, // 节点中心
text: '波次',
fontSize: 16,
fill: '#fff',
textAlign: 'center',
textBaseline: 'middle',
fontWeight: 'bold' // 设置字体加粗
}, container)
} else if (type === 'character') { // 节点类型为人设
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: characterLogoPng,
},
container
);
} else if (type === 'userSpread') { // 节点类型为关键传播节点
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: userSpreadLogoPng,
},
container
);
} else if (type === 'audience') { // 节点类型为受众群体
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: audienceLogoPng,
},
container
);
} else if (type === 'userList') { // 节点类型为受众目标
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: userSpreadLogoPng,
},
container
);
} else if (type === 'strategy') {
this.upsert(
'node-image',
'image',
{
x: -width / 2, // 图片的x坐标,使其居中
y: -height / 2, // 图片的y坐标,使其居中
width: width, // 图片宽度略小于圆形节点
height: height, // 图片高度略小于圆形节点
src: strategyLogo,
},
container
);
}
}
}
// 2. 注册自定义节点
register(ExtensionCategory.NODE, 'custom-image-circle', CustomImageCircleNode);
const data = {
nodes: [
{ id: 'node1', data: { type: 'action', bgColor: '#cf1322', name: '行动1' }, size: 30 },
{ id: 'node2', data: { type: 'boci', bgColor: '#fff6ea', name: '波次1' }, size: 25 },
{ id: 'node3', data: { type: 'boci', bgColor: '#fff6ea', name: '波次2' }, size: 25 },
{ id: 'node4', data: { type: 'boci', bgColor: '#fff6ea', name: '波次3' }, size: 25 },
{ id: 'node5', data: { type: 'character', bgColor: '#fff6ea', name: '人设01' }, size: 25 },
{ id: 'node6', data: { type: 'character', bgColor: '#fff6ea', name: '人设02' }, size: 20 },
{ id: 'node7', data: { type: 'character', bgColor: '#fff6ea', name: '人设03' }, size: 25 },
{ id: 'node8', data: { type: 'character', bgColor: '#fff6ea', name: '人设04' }, size: 20 },
{ id: 'node9', data: { type: 'character', lineColor: '#fff6ea', name: '人设05' }, size: 25 },
{ id: 'node10', data: { type: 'character', bgColor: '#fff6ea', name: '人设06' }, size: 20 },
{ id: 'node11', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node12', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node13', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node14', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node15', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node16', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node17', data: { type: 'userSpread', platform: 'facebook', bgColor: '#1eb07f', name: '关键传播节点01' }, size: 20 },
{ id: 'node18', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
{ id: 'node19', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
{ id: 'node20', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
{ id: 'node21', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
{ id: 'node22', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
{ id: 'node23', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
{ id: 'node24', data: { type: 'audience', platform: 'facebook', bgColor: '#1569ef', name: '关键传播节点01' }, size: 20 },
],
edges: [
{ source: 'node1', target: 'node2', data: { type: 'action', lineColor: '#cf1322', name: '行动1' } },
{ source: 'node2', target: 'node5', data: { type: 'boci', lineColor: '#dd8717', name: '波次1' } },
{ source: 'node5', target: 'node11', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node11', target: 'node18', data: { type: 'userSpread', platform: 'facebook', lineColor: '#1eb07f', name: '关键传播节点01' } },
{ source: 'node2', target: 'node6', data: { type: 'audience', platform: 'facebook', lineColor: '#1569ef', name: '关键传播节点01' } },
{ source: 'node6', target: 'node12', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node12', target: 'node19', data: { type: 'userSpread', platform: 'facebook', lineColor: '#1eb07f', name: '关键传播节点01' } },
{ source: 'node1', target: 'node3', data: { type: 'action', lineColor: '#cf1322', name: '行动1' } },
{ source: 'node3', target: 'node7', data: { type: 'boci', lineColor: '#dd8717', name: '波次1' } },
{ source: 'node7', target: 'node13', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node13', target: 'node20', data: { type: 'userSpread', platform: 'facebook', lineColor: '#1eb07f', name: '关键传播节点01' } },
{ source: 'node3', target: 'node8', data: { type: 'audience', platform: 'facebook', lineColor: '#1569ef', name: '关键传播节点01' } },
{ source: 'node8', target: 'node14', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node14', target: 'node21', data: { type: 'userSpread', platform: 'facebook', lineColor: '#1eb07f', name: '关键传播节点01' } },
{ source: 'node1', target: 'node4', data: { type: 'action', lineColor: '#cf1322', name: '行动1' } },
{ source: 'node4', target: 'node9', data: { type: 'boci', lineColor: '#dd8717', name: '波次1' } },
{ source: 'node9', target: 'node15', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node15', target: 'node22', data: { type: 'userSpread', platform: 'facebook', lineColor: '#1eb07f', name: '关键传播节点01' } },
{ source: 'node4', target: 'node10', data: { type: 'audience', platform: 'facebook', lineColor: '#1569ef', name: '关键传播节点01' } },
{ source: 'node10', target: 'node16', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node16', target: 'node23', data: { type: 'userSpread', platform: 'facebook', lineColor: '#1eb07f', name: '关键传播节点01' } },
{ source: 'node10', target: 'node17', data: { type: 'character', lineColor: '#fff6ea', name: '人设01' } },
{ source: 'node17', target: 'node24', data: { type: 'audience', platform: 'facebook', lineColor: '#1569ef', name: '关键传播节点01' } },
]
}
// const size = (node) => Math.max(...node.style.size);
graph = new Graph({
container: graphRef.value,
// width: 800,
// height: 600,
data: treeMapData,
modes: {
default: ['drag-canvas', 'zoom-canvas', 'drag-node']
},
node: {
type: 'custom-image-circle', // 使用自定义节点类型
style: {
label: true,
labelText: (d) => d.data?.name,
labelFill: '#fff',
labelBackground: false,
// fill: (d) => d.data.bgColor || '#fc5',
fill: 'transparent',
type: (d) => d.data.type,
platform: (d) => d.data.platform,
size: (d) => {
if (d.data.type === 'action') {
return [120, 60]
} else if (d.data.type === 'boci') {
return [130, 40]
} else {
return [60, 70]
}
}
}
},
edge: {
type: 'ant-line',
style: {
label: false,
// labelText: (d) => d.data?.type,
labelBackground: true,
stroke: (d) => d.data?.lineColor || '#fc5', // 边的颜色,可以根据需要调整
lineWidth: 2.5, // 边的宽度,可以根据需要调整
lineDash: [10, 10],
endArrow: true, // 添加箭头
arrowSize: 6, // 箭头大小
},
},
layout: {
// type: 'radial',
// unitRadius: 220,
// linkDistance: 200,
// type: 'd3-force',
// link: 200,
// // collide: { radius: (node) => size(node) },
// // manyBody: { strength: (node) => -4 * size(node) }
animation: false,
// preventOverlap: true
type: 'd3-force',
preventOverlap: true,
nodeSize: 100,
link: {
distance: 200,
strength: 2
},
collide: { radius: 100 },
manyBody: {
strangth: (d) => {
if (d.isLeaf) {
return -50;
}
return -10;
}
}
},
autoFit: 'view',
behaviors: ['drag-canvas', 'drag-element', 'zoom-canvas',]
})
graph.render();
graph.on('node:click', (event) => {
const nodeItem = event.item; // 节点实例
console.log(event.target.id);
const model = graph.getNodeData(event.target.id);
console.log('节点数据:', model.data);
// const model = nodeItem.getModel(); // 数据模型
// console.log('点击了节点:', model.id);
});
// window.addPanel((gui) => {
// const config = {
// lineDash: 10,
// }
// gui.add(config,'lineDash',1,20,1).onChange((lineDash) => {
// graph.setEdge({
// type: 'ant-line',
// style: {
// lineDash: [lineDash,lineDash]
// }
// })
// })
// graph.draw()
// })
}
</script>
<style scoped lang="less">
.action-overview {
z-index: 500;
color: #fff;
position: relative;
.overview-head {
width: 100%;
height: 115px;
}
.overview-body {
// width: 100%;
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: calc(100vh - 115px);
.graph-container {
width: 100%;
height: 100%;
}
}
.graph-map-content {
z-index: 500;
position: fixed;
bottom: 100px;
right: 20px;
.graph-map {
width: 202px;
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
z-index: 500;
border-radius: 4px;
padding: 10px 15px;
padding-top: 30px;
.list-content {
position: relative;
.expand-logo {
width: 20px;
height: 11px;
position: absolute;
top: -27px;
right: -8px;
img {
width: 100%;
}
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.item-logo {
width: 62px;
height: 28px;
img {
height: 28px;
}
}
.item-title {
flex: 1;
font-family: Noto Sans S Chinese;
font-weight: 300;
color: #ffffff;
font-size: 18px;
}
}
}
}
}
.collapse-content {
width: 30px;
height: 40px;
background: #032650cc;
border: 1px solid;
border-color: #2b8ffe99;
border-radius: 4px 0 0 4px;
z-index: 500;
position: absolute;
top: 0;
img {
width: 16px;
}
}
.collapse-content-right {
right: 0;
}
.expand-hidden {
width: 0px !important;
border: 0px !important;
overflow: hidden;
}
.expand-content {
width: 100%;
transition: all 0.5s ease;
float: right;
}
.expand-content-right {
float: right;
}
}
</style>
@@ -0,0 +1,448 @@
<template>
<div class="action-planning">
<!-- 任务详情 -->
<div class="task-details" v-if="flag">
<div class="task-title">
{{ taskData.taskName }}
</div>
<div class="task-content">
<span class="item-label">任务时间:</span>
<span class="item-value">{{
dayjs(taskData.createTime).format("YYYY-MM-DD HH:mm:ss")
}}</span>
<span class="item-tab tab-yellow" style="margin-left: 10px">{{
taskLevel[taskData.taskLevel]
}}</span>
</div>
<div class="task-footer" :class="{ 'text-ellipsis ellipsis3': !allShow }">
<span class="item-label">任务简介:</span> {{ taskData.overview }}
</div>
<div class="allShow" @click="allShow = !allShow">
{{ allShow ? "收起" : "查看全文" }}
</div>
</div>
<!-- 任务行动 -->
<div class="title-content-item margin-bottom-10">任务行动</div>
<div class="action-content">
<div class="action-list">
<div
class="list-item"
:class="{ 'item-active': item.id === actionId }"
v-for="(item, index) in actionList"
:key="index"
@click="actionDataChange(item)"
>
<div :class="'action-status-logo ' + handleBg(item.actionStatus)">
{{ actionStatus[item.actionStatus] }}
</div>
<!-- <div class="item-title">
{{ item.title }}
</div> -->
<div class="item-content text-ellipsis ellipsis2">
{{ item.actionName }}
</div>
<div class="item-footer">
<span class="item-label">行动时间:</span>
<span class="item-value">{{
dayjs(item.createTime).format("YYYY-MM-DD HH:mm:ss")
}}</span>
</div>
</div>
</div>
</div>
<t-dialog
v-model:visible="visible"
header="行动详情"
width="700"
:confirm-on-enter="true"
:on-cancel="handleCancel"
:on-close="handleCancel"
:on-confirm="handleOk"
>
<t-space direction="vertical" style="width: 100%">
<div class="action-details">
<div class="item-head">桃色与军纪舆论裂变攻台军形象</div>
<div class="item-card">
支撑性目的:利用桃色新闻与军纪涣散议题,瓦解台军社会形象与民众支持基础,引发线上网红、大V等对台军……
</div>
<!-- 顶部标签页 -->
<TabsContent
:tabIndex="tabIndex"
:tabsList="tabsList"
:tabStyle="{
textAlign: 'left',
borderBottom: '1px solid rgba(0, 98, 217, 0.4)',
}"
@tabsDataC="tabDataChange"
/>
<div class="narrate-list" v-if="tabIndex === 'narrate'">
<div
class="item-card"
v-for="(item, index) in narrateList"
:key="index"
>
<span class="item-index">{{ index + 1 }}</span
>. {{ item.title }}
</div>
</div>
<div class="behavior-list">
<div
class="item-card"
v-for="(item, index) in behaviorList"
:key="index"
>
<div class="item-title">行为体:{{ item.actor }}</div>
<div class="item-content">预期行为:{{ item.behavior }}</div>
</div>
</div>
</div>
</t-space>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, watch, onUnmounted } from "vue";
import TabsContent from "./views/TabsContent.vue";
import taskStatus from "@/common/taskStatus";
import taskLevel from "@/common/taskLevel";
import sourceTypeDict from "@/common/sourceType";
import actionStatus from "@/common/actionStatus";
import dayjs from "dayjs";
import { requestAxiosService } from "@/service/request-axios-service";
import { useStore } from "vuex";
const flag = ref(false);
const props = defineProps({
msg: String,
taskData: Object,
});
const allShow = ref(false);
const actionList = ref([
{
title: "行动01",
content: "军购透明化对比民生缺口,激青年声讨",
actionStatus: "CEH完成",
actionLogo: "action-completed",
},
{
title: "行动01",
content: "军购透明化对比民生缺口,激青年声讨",
actionStatus: "CEH中",
actionLogo: "action-uncompleted",
},
{
title: "行动01",
content: "军购透明化对比民生缺口,激青年声讨",
actionStatus: "待分析",
actionLogo: "action-pendinganalysis",
},
{
title: "行动01",
content: "军购透明化对比民生缺口,激青年声讨",
actionStatus: "已终止",
actionLogo: "action-failed",
},
{
title: "行动01",
content: "军购透明化对比民生缺口,激青年声讨",
actionStatus: "CEH完成",
actionLogo: "action-completed",
},
]); // 行动列表
const visible = ref(false); // 对话框
const tabIndex = ref("narrate"); // narrate 叙事主题 behavior 预期行为
const tabsList = ref([
{ label: "叙事主题", value: "narrate" },
{ label: "预期行为", value: "behavior" },
]);
const narrateList = ref([
{ title: "台军桃色丑闻频发暴露军纪涣散" },
{ title: "飞行事故频发揭穿台军装备老旧与训练造假" },
]);
const behaviorList = ref([
{
actor: "台军各军种单位",
behavior:
"通过社交媒体或媒体渠道揭露上级贪腐、性丑闻等(如陆军109旅贩毒案后内部爆料)",
},
]);
const actionId = ref(null);
const service = requestAxiosService;
// 获取 Vuex store
const store = useStore();
const emit = defineEmits(["actionDataC"]); // 定义子组件发送给父组件的函数
const actionShowChange = (item) => {
// visible.value = true;
};
const handleOk = () => {
visible.value = false;
};
const handleCancel = () => {
visible.value = false;
};
// 接收tabs页切换
const tabDataChange = (value) => {
tabIndex.value = value;
console.log("tabs", tabIndex.value);
};
const handleBg = (d) => {
const colorList = {
10: "action-pendinganalysis",
20: "action-uncompleted",
25: "action-completed",
23: "action-failed",
};
return colorList[d] || "action-completed";
};
// 查询行动略表
const taskActionSearch = async (taskId) => {
const parmy = {
taskId,
};
const res = await service.get(
`${environment["b1ConsumerUrl"]}/cop/copTaskActions`,
parmy
);
console.log("查询行动列表", res);
let result = res.data;
actionList.value = result.actions;
};
// 查询行动概览
const actionSummarySearch = async () => {
const parmy = {
actionId: actionId.value,
};
const res = await service.get(
`${environment["b1ConsumerUrl"]}/cop/copActionSummary`,
parmy
);
console.log("行动概览", res);
let result = res.data;
store.commit("setActionOverview", result);
};
// cop/queryOverview
const queryOverview = async () => {
const res = await service.get(
`${environment["b1ConsumerUrl"]}/cop/queryOverview`,
""
);
console.log("概览", res);
};
const actionDataChange = async (item) => {
console.log(item);
actionId.value = item.id;
store.commit("setActionOverview", null);
if (item.actionStatus === 25) {
await actionSummarySearch();
}
emit("actionDataC", item);
};
onUnmounted(() => {
store.commit("setActionOverview", null);
});
watch(
props.taskData,
(newVal, oldVal) => {
// console.log('count changed from', oldVal, 'to', newVal);
if (newVal) {
queryOverview();
taskActionSearch(newVal.id);
}
},
{ immediate: true }
);
</script>
<style scoped lang="less">
.action-planning {
z-index: 500;
color: #fff;
padding: 10px;
box-sizing: border-box;
// 任务详情样式
.task-details {
padding: 10px;
.task-title {
font-size: 20px;
font-weight: 500;
font-family: "Noto Sans S Chinese";
}
.task-content {
margin: 5px 0;
font-family: "Noto Sans S Chinese";
}
.task-footer {
font-family: "Noto Sans S Chinese";
}
}
// 行动列表样式
.action-content {
.action-list {
.list-item {
background: rgba(0, 98, 217, 0.1);
border: 1px solid;
border-color: rgba(85, 166, 254, 0.1);
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
background: rgba(0, 98, 217, 0.4);
border-color: #0062d9;
}
.item-title {
line-height: 24px;
font-family: Noto Sans S Chinese;
font-weight: 500;
color: #ffffff;
font-size: var(--titleSize);
margin-bottom: 5px;
}
.item-content {
line-height: 24px;
font-family: Noto Sans S Chinese;
font-weight: 300;
color: #ffffff;
font-size: var(--fontSize);
margin-bottom: 5px;
}
.item-footer {
font-family: Noto Sans S Chinese;
font-weight: 300;
color: rgba(255, 255, 255, 0.8);
font-size: 15px;
}
.action-status-logo {
width: 132px;
height: 30px;
transform: rotate(45deg);
position: absolute;
top: 12px;
text-align: center;
line-height: 30px;
right: -36px;
}
.action-completed {
background: linear-gradient(
134.57deg,
#05b89f 14.74%,
#007f73 84.95%
);
}
.action-uncompleted {
background: linear-gradient(
134.57deg,
#d09a14 14.74%,
#a76e12 84.95%
);
}
.action-pendinganalysis {
background: linear-gradient(
134.57deg,
#117dff 14.74%,
#0057b8 84.95%
);
}
.action-failed {
background: linear-gradient(
134.57deg,
#838383 14.74%,
#5e5e5e 84.95%
);
}
}
.item-active {
background: rgba(0, 98, 217, 0.4);
border-color: #0062d9;
}
}
}
.item-label {
font-family: "Noto Sans S Chinese";
font-size: 15px;
margin-right: 5px;
}
.item-value {
font-family: "DDIN";
}
.item-tab {
font-size: var(--fontSizes);
font-family: "Noto Sans S Chinese";
padding: 2px 10px;
border: 1px solid;
border-radius: 4px;
}
}
// 详情弹窗页面
.action-details {
.item-head {
font-size: var(--fontSize);
font-family: "Noto Sans S Chinese";
padding: 10px;
}
.item-card {
background: rgba(0, 98, 217, 0.1);
border: 1px solid;
border-color: rgba(85, 166, 254, 0.1);
border-radius: 4px;
padding: 10px;
margin-top: 10px;
margin-bottom: 15px;
.item-index {
font-family: "Noto Sans S Chinese";
font-weight: 500;
}
.item-title {
// font-size: var(--fontSize);
font-family: "Noto Sans S Chinese";
// font-weight: 500;
margin-bottom: 5px;
}
}
}
.allShow {
margin-top: 10px;
max-width: 100px;
padding: 5px;
border-radius: 3px;
cursor: pointer;
line-height: 20px;
border: 1px solid #2b8ffe99 !important;
text-align: center;
}
</style>
@@ -0,0 +1,242 @@
<template>
<div class="performance-evaluation">
<!-- 六度量样式 -->
<div class="six-metrics">
<t-row :gutter="16">
<t-col :span="4">
<div class="item-left">
<div class="metrices-item item-completed" style="margin-left: 50px;">
<div class="item-value">
108<i>个</i>
</div>
<div class="item-label">
大V转发
</div>
</div>
<div class="metrices-item item-completed" style="margin-left: 10px;">
<div class="item-value">
387<i>家</i>
</div>
<div class="item-label">
媒体报道
</div>
</div>
<div class="metrices-item item-completed" style="margin-left: 50px;">
<div class="item-value">
16<i>个</i>
</div>
<div class="item-label">
传播平台
</div>
</div>
</div>
</t-col>
<t-col :span="3">
<div class="item-center">
<div class="level-content">
<div class="metrices-item item-center-bg">
<div class="level-text">
五级
</div>
<div class="level-list">
<span class="list-item" v-for="(item, index) in levelList" :class="{ 'item-active': item.checked }"
:key="index">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style="">
<path fill="currentColor"
d="M313.6 924.48a70.4 70.4 0 0 1-74.152-5.365 70.4 70.4 0 0 1-27.992-68.875l37.888-220.928L88.96 472.96a70.4 70.4 0 0 1 3.788-104.225A70.4 70.4 0 0 1 128 352.896l221.76-32.256 99.2-200.96a70.4 70.4 0 0 1 100.246-28.595 70.4 70.4 0 0 1 25.962 28.595l99.2 200.96 221.824 32.256a70.4 70.4 0 0 1 39.04 120.064L774.72 629.376l37.888 220.928a70.4 70.4 0 0 1-102.144 74.24L512 820.096l-198.4 104.32z">
</path>
</svg>
</span>
</div>
<div class="item-label">
评估等级
</div>
</div>
</div>
</div>
</t-col>
<t-col :span="5">
<div class="item-right">
<div class="metrices-item item-incomplete">
<div class="item-value">
0<i>个</i>
</div>
<div class="item-label">
线下活动
</div>
</div>
<div class="metrices-item item-completed" style="margin-left: 50px;">
<div class="item-value">
6<i>个</i>
</div>
<div class="item-label">
官方回应
</div>
</div>
<div class="metrices-item item-completed">
<div class="item-value">
5<i>个</i>
</div>
<div class="item-label" style="padding-left: 5px;">
关键节点
</div>
</div>
</div>
</t-col>
</t-row>
</div>
<!-- tab标签页 -->
<TabsContent :tabIndex="tabIndex" :tabsList="tabsList" @taskDetailsC="taskDetailsChange"/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import TabsContent from '../views/TabsContent.vue';
defineProps({
msg: String,
})
const levelList = ref([
{ title: 1, checked: true },
{ title: 1, checked: true },
{ title: 1, checked: true },
{ title: 1, checked: true },
{ title: 1, checked: true },
{ title: 1, checked: false },
])
const tabIndex = ref('cognition'); // cognition 认知影响 spread 传播效果 action 行动脉络
const tabsList = ref([
{ label: '认知影响', value: 'cognition' },
{ label: '传播效果', value: 'spread' },
{ label: '行动脉络', value: 'action' },
])
// 标签页切换
const taskDetailsChange = (value) => {
tabIndex.value = value;
}
</script>
<style scoped lang="less">
.performance-evaluation {
z-index: 500;
color: #fff;
padding: 10px;
.six-metrics {
width: 100%;
padding-top: 15px;
margin-bottom: 25px;
.item-left {
width: 100%;
height: 100%;
}
.item-center {
width: 100%;
height: 100%;
position: relative;
.level-content {
padding-left: 15px;
position: absolute;
top: 50px;
left: 50%;
transform: translateX(-50%);
}
}
.item-right {
width: 100%;
height: 100%;
}
.metrices-item {
width: 100px;
height: 62px;
padding-top: 7px;
text-align: center;
}
.item-value {
color: #01e1e9;
font-size: var(--titleSize);
font-family: 'DDINBold';
font-weight: 700;
line-height: var(--fontSize);
i {
font-size: 12px;
font-style: normal;
color: #fff;
font-weight: 300;
margin-left: 5px;
}
}
.item-label {
font-family: Noto Sans S Chinese;
}
.level-text {
font-size: 24px;
font-family: Noto Sans S Chinese;
font-weight: 700;
padding-top: 5px;
animation: levelBreath 2s ease-in-out infinite;
}
.level-list {
width: 120px;
margin-top: 25px;
.list-item {
svg {
width: 20px;
color: #05727a;
}
}
.item-active {
svg {
color: #00ffff !important;
}
}
}
.item-completed {
background: url('../../assets/icon/tj-blue.png') no-repeat;
background-size: cover;
}
.item-incomplete {
background: url('../../assets/icon/tj-grey.png') no-repeat;
background-size: cover;
}
.item-center-bg {
background: url('../../assets/icon/cen-top.png') no-repeat;
background-size: cover;
}
}
}
// 级别动画
@keyframes levelBreath {
0% {
text-shadow: 0 0 0 #f90;
}
50% {
text-shadow: 0 0 25px #f90;
}
100% {
text-shadow: 0 0 0 #f90;
}
}
</style>
@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more