Compare commits

..
1 Commits
Author SHA1 Message Date
xuhdandClaude Sonnet 4.6 adeace0f7b feat: 新增 multi-llm 路由智能体及 API 端点
添加 multi-llm 路由智能体(LangGraph),支持多 LLM 分发能力;在 Gateway 中注册 /api/multi-llm 路由,并豁免其 CSRF 和认证校验;同时新增 asyncmy 依赖和聊天流格式文档。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:48:16 +08:00
138 changed files with 6488 additions and 12267 deletions

No files matched your search

+2 -5
View File
@@ -24,9 +24,8 @@ frontend-web/src.zip
frontend-web/src9.zip
frontend-web/src-v1.zip
frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts
frontend-web/dist/
frontend-web/dist-scheduled-check/
# Offline backend bundles and local deployment artifacts
@@ -76,6 +75,4 @@ offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
deploy
.history
offline-backend-20260512/backend/tests/*
+474
View File
@@ -0,0 +1,474 @@
# New Chat 接口流式返回格式说明
## 接口概览
- **路径**:`POST /new_chat`
- **传输协议**:Server-Sent Events (SSE)
- **Content-Type**:`text/event-stream`
- **协议基础**:Vercel AI SDK Data Stream Protocol
---
## 请求格式
```json
{
"chat_id": 123,
"search_space_id": 456,
"user_query": "用户问题",
"pic_query": [{"image_url": "/tmp/xxx.png"}],
"model_name": "gpt-4o",
"enable_citations": true,
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `chat_id` | int | ✅ | 会话 ID,同时作为 LangGraph 的 thread_id |
| `search_space_id` | int | ✅ | 搜索空间 ID |
| `user_query` | string | ✅ | 用户问题 |
| `pic_query` | list\|null | ❌ | 图片路径列表,格式 `[{"image_url": "..."}]` |
| `model_name` | string\|null | ❌ | 指定模型名称(插件场景使用) |
| `enable_citations` | bool | ❌ | 是否启用引用(默认 true) |
| `messages` | list\|null | ❌ | 历史消息列表 |
| `attachments` | list\|null | ❌ | 上传附件(已解析为 Markdown) |
| `mentioned_document_ids` | list\|null | ❌ | 前端 @ 引用的文档 ID 列表 |
---
## 响应 Headers
```
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
```
---
## SSE 数据帧格式
每条消息均为标准 SSE 格式:
```
data: {JSON 对象}\n\n
```
流结束时发送:
```
data: [DONE]\n\n
```
---
## 事件类型详解
### 1. 消息生命周期
#### `start` — 消息开始
```json
{"type": "start", "messageId": "msg_abc123"}
```
| 字段 | 说明 |
|------|------|
| `messageId` | 本次消息的唯一 ID |
---
#### `finish` — 消息结束
```json
{"type": "finish"}
```
---
#### `[DONE]` — 流终止标记
```
data: [DONE]
```
> 这是流中最后一帧,为纯字符串,不是 JSON。
---
### 2. 步骤控制(多轮工具调用)
#### `start-step` — 步骤开始
```json
{"type": "start-step"}
```
#### `finish-step` — 步骤结束
```json
{"type": "finish-step"}
```
> 每次 LLM 调用算一个 step。工具调用期间会有多个 step 交替出现。
---
### 3. 文本块(start / delta / end 模式)
#### `text-start` — 文本块开始
```json
{"type": "text-start", "id": "text_abc123"}
```
#### `text-delta` — 文本增量内容
```json
{"type": "text-delta", "id": "text_abc123", "delta": "Hello, "}
```
#### `text-end` — 文本块结束
```json
{"type": "text-end", "id": "text_abc123"}
```
---
### 4. 推理块(Reasoning,适用于 DeepSeek R1 等思维链模型)
#### `reasoning-start` — 推理块开始
```json
{"type": "reasoning-start", "id": "reasoning_abc123"}
```
#### `reasoning-delta` — 推理增量内容
```json
{"type": "reasoning-delta", "id": "reasoning_abc123", "delta": "让我先思考..."}
```
#### `reasoning-end` — 推理块结束
```json
{"type": "reasoning-end", "id": "reasoning_abc123"}
```
> 触发时机:
> - 模型返回 `additional_kwargs.reasoning_content`(DeepSeek R1 原生)
> - 模型输出中包含 `<think>...</think>` 标签(自动剥离)
---
### 5. 工具调用
#### `tool-input-start` — 工具调用开始
```json
{"type": "tool-input-start", "toolCallId": "call_abc123", "toolName": "search_knowledge_base"}
```
#### `tool-input-delta` — 工具输入流式内容(增量)
```json
{"type": "tool-input-delta", "toolCallId": "call_abc123", "inputTextDelta": "San Fra"}
```
#### `tool-input-available` — 工具输入完整参数
```json
{
"type": "tool-input-available",
"toolCallId": "call_abc123",
"toolName": "search_knowledge_base",
"input": {"query": "机器学习入门"}
}
```
#### `tool-output-available` — 工具执行结果
```json
{
"type": "tool-output-available",
"toolCallId": "call_abc123",
"output": {"status": "completed", "result_length": 1234}
}
```
> 注意:`search_knowledge_base` 只返回摘要(不含全文),`write_todos`、`link_preview`、`display_image`、`scrape_webpage`、`generate_podcast` 会返回完整 output 供前端渲染组件。
---
### 6. 来源引用
#### `source-url` — URL 来源
```json
{
"type": "source-url",
"sourceId": "https://example.com",
"url": "https://example.com",
"title": "示例页面"
}
```
#### `source-document` — 文档来源
```json
{
"type": "source-document",
"sourceId": "doc_123",
"mediaType": "file",
"title": "报告.pdf",
"description": "第三季度报告"
}
```
---
### 7. 文件
```json
{"type": "file", "url": "https://example.com/image.png", "mediaType": "image/png"}
```
---
### 8. 自定义数据(`data-*` 类型)
#### `data-terminal-info` — 终端信息提示
```json
{
"type": "data-terminal-info",
"data": {
"text": "知识库搜索已完成",
"type": "success"
}
}
```
`type` 枚举值:`info` | `error` | `success` | `warning`
---
#### `data-further-questions` — 推荐追问问题
```json
{
"type": "data-further-questions",
"data": {
"questions": ["问题1", "问题2", "问题3"]
}
}
```
---
#### `data-thinking-step` — 思考步骤(思维链 UI 展示)
```json
{
"type": "data-thinking-step",
"data": {
"id": "thinking-1",
"title": "正在搜索知识库",
"status": "in_progress",
"items": ["Query: 机器学习入门"]
}
}
```
| 字段 | 说明 |
|------|------|
| `id` | 步骤唯一 ID(格式 `thinking-N`) |
| `title` | 步骤标题 |
| `status` | `pending` \| `in_progress` \| `completed` |
| `items` | 步骤详情列表(可选) |
> 同一个 `id` 的步骤事件会被重复发送以更新状态(先 `in_progress` 后 `completed`)。
**常见步骤标题对照:**
| 工具 / 场景 | 标题 |
|------------|------|
| 初始分析 | 正在理解您的请求 / 正在分析您的内容 |
| `search_knowledge_base` | 查询个人知识库中 |
| `search_web` | 网查搜索中 |
| `link_preview` | 正在获取链接预览 |
| `display_image` | 正在分析图片 |
| `scrape_webpage` | 正在抓取网页 |
| `write_todos`(首次) | 正在创建计划 |
| `write_todos`(更新) | 正在更新进度 |
| `generate_podcast` | 正在生成播客 |
---
### 9. 错误
```json
{
"type": "error",
"errorText": "API rate limit exceeded",
"errorType": "llm",
"errorCode": "rate_limit"
}
```
| 字段 | 说明 |
|------|------|
| `errorText` | 错误描述文本 |
| `errorType` | 错误类型(见下表) |
| `errorCode` | 具体错误代码(可选) |
**errorType 枚举:**
| 值 | 含义 |
|----|------|
| `llm` | LLM / API 错误(模型不存在、限流、密钥错误等) |
| `network` | 网络连通性错误 |
| `timeout` | 请求超时 |
| `authentication` | 鉴权 / 权限错误 |
| `rate_limit` | 速率限制 |
| `validation` | 输入验证错误 |
| `internal` | 内部服务器错误 |
| `unknown` | 未知错误 |
---
### 10. 中断(Human-in-the-loop)
```json
{
"type": "interrupt",
"messageId": "msg_abc123",
"interrupt": {
"action_requests": [...],
"review_configs": [...]
},
"payload": {"expertAgentId": 42}
}
```
---
## 完整流示例(普通问答)
```
data: {"type":"start","messageId":"msg_abc123"}
data: {"type":"start-step"}
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"in_progress","items":["Processing: 什么是向量数据库?"]}}
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"completed","items":["Processing: 什么是向量数据库?"]}}
data: {"type":"text-start","id":"text_xyz789"}
data: {"type":"text-delta","id":"text_xyz789","delta":"向量数据库是"}
data: {"type":"text-delta","id":"text_xyz789","delta":"一种专为存储和检索"}
data: {"type":"text-delta","id":"text_xyz789","delta":"高维向量而设计的数据库..."}
data: {"type":"text-end","id":"text_xyz789"}
data: {"type":"finish-step"}
data: {"type":"finish"}
data: [DONE]
```
---
## 完整流示例(含工具调用)
```
data: {"type":"start","messageId":"msg_abc123"}
data: {"type":"start-step"}
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"in_progress","items":["Processing: 搜索最新 AI 论文"]}}
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"completed","items":["Processing: 搜索最新 AI 论文"]}}
data: {"type":"data-thinking-step","data":{"id":"thinking-2","title":"查询个人知识库中","status":"in_progress","items":["Query: 最新 AI 论文"]}}
data: {"type":"tool-input-start","toolCallId":"call_xxx","toolName":"search_knowledge_base"}
data: {"type":"tool-input-available","toolCallId":"call_xxx","toolName":"search_knowledge_base","input":{"query":"最新 AI 论文"}}
data: {"type":"data-thinking-step","data":{"id":"thinking-2","title":"Searching knowledge base","status":"completed","items":["Query: 最新 AI 论文","Found relevant information (2048 chars)"]}}
data: {"type":"tool-output-available","toolCallId":"call_xxx","output":{"status":"completed","result_length":2048}}
data: {"type":"data-terminal-info","data":{"text":"知识库搜索已完成","type":"success"}}
data: {"type":"text-start","id":"text_xyz789"}
data: {"type":"text-delta","id":"text_xyz789","delta":"根据知识库中的内容..."}
data: {"type":"text-end","id":"text_xyz789"}
data: {"type":"finish-step"}
data: {"type":"finish"}
data: [DONE]
```
---
## 完整流示例(含推理过程)
```
data: {"type":"start","messageId":"msg_abc123"}
data: {"type":"start-step"}
data: {"type":"reasoning-start","id":"reasoning_r01"}
data: {"type":"reasoning-delta","id":"reasoning_r01","delta":"让我先分析这个问题..."}
data: {"type":"reasoning-delta","id":"reasoning_r01","delta":"需要考虑以下几个方面..."}
data: {"type":"reasoning-end","id":"reasoning_r01"}
data: {"type":"text-start","id":"text_xyz789"}
data: {"type":"text-delta","id":"text_xyz789","delta":"综合分析后,结论是..."}
data: {"type":"text-end","id":"text_xyz789"}
data: {"type":"finish-step"}
data: {"type":"finish"}
data: [DONE]
```
---
## 标准消息流顺序
```
start
└─ start-step
├─ [data-thinking-step × N] 思考步骤(可多次)
├─ [reasoning-start/delta/end] 推理过程(可选)
├─ [tool-input-start] 工具调用开始(可多次)
├─ [tool-input-available] 工具参数确认
├─ [tool-output-available] 工具结果
├─ [data-terminal-info] 终端提示(可多次)
├─ [source-url / source-document] 来源引用(可选)
├─ text-start
│ └─ text-delta × N 流式文本
└─ text-end
finish-step
finish
[DONE]
```
+2 -5
View File
@@ -2,8 +2,5 @@
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离)
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
+1 -3
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
@@ -60,7 +60,6 @@
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"katex": "^0.16.28",
@@ -93,7 +92,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
+145 -282
View File
@@ -1,60 +1,45 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
BarChart3,
Bot,
Brain,
CalendarClock,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleHelp,
Compass,
Database,
FileBox,
FileOutput,
FileText,
Folder,
FolderOpen,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquareCode,
MessageSquarePlus,
MessageSquare,
MoreHorizontal,
Network,
NotebookPen,
PlayCircle,
Plus,
Scale,
Settings,
Sparkles,
Target,
User,
TrendingUp,
Users,
Wrench,
Workflow,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
@@ -63,18 +48,19 @@ type PageSidebarProps = {
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ 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" },
{ id: "new-qa", label: "新建问答", icon: Plus },
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
{ id: "agent-qa", label: "智能体问答", icon: Bot },
{ id: "multi-agent-qa", label: "多智能体", icon: Layers },
{ id: "sixstep-chat", label: "六步对话", icon: Workflow },
{ id: "qa-records", label: "问答记录", icon: History },
{ id: "template-editor", label: "模板配置", icon: Settings },
],
},
{
@@ -82,25 +68,6 @@ const menuItems: MenuItem[] = [
label: "轻应用",
icon: Grid3X3,
children: [
{
id: "light-app-note-management",
label: "笔记管理",
icon: NotebookPen,
children: [
{
id: "light-app-new-note",
label: "新建笔记",
icon: Plus,
path: "/page/strategy/light-app/new-note",
},
{
id: "light-app-note-list",
label: "笔记列表",
icon: List,
path: "/page/strategy/light-app/notes",
},
],
},
{ id: "light-app-square", label: "应用广场", icon: Layout },
{ id: "light-app-records", label: "运行记录", icon: History },
],
@@ -110,9 +77,10 @@ const menuItems: MenuItem[] = [
label: "任务管理",
icon: Target,
children: [
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
{
id: "xk-center-task",
label: "XK中任务",
id: "xk-task",
label: "XK任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
@@ -120,92 +88,124 @@ const menuItems: MenuItem[] = [
],
},
{
id: "coh-center-task",
label: "COH中任务",
id: "plan-task",
label: "规划任务",
icon: Folder,
children: [
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
],
},
{
id: "pg-center-task",
label: "PG中任务",
id: "mnty-task",
label: "MNTY任务",
icon: Folder,
children: [
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
{ id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
{ id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
{ id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
],
},
{
id: "ty-center-task",
label: "TY中任务",
id: "effect-task",
label: "效果PG任务",
icon: Folder,
children: [
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
{ id: "effect-account-pg", label: "账号PG", icon: ListTodo },
],
},
{ id: "task-create", label: "创建任务", icon: Plus },
{ id: "task-single", label: "单智能体任务", icon: Bot },
{ id: "task-scene", label: "场景任务", icon: Workflow },
{ id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
{ id: "final-plan", label: "最终输出", icon: FileOutput },
],
},
{ id: "scene-list", label: "场景管理", icon: Layers },
{
id: "agent-list",
label: "智能体管理",
icon: Bot,
children: [
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
id: "result-management",
label: "结果管理",
icon: BarChart3,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "任务管理",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
id: "result-planning",
label: "规划结果",
icon: Target,
children: [
{ id: "result-planning-all", label: "全部", icon: BarChart3 },
{ id: "result-planning-goal", label: "目标分析", icon: Target },
{ id: "result-planning-brand", label: "品牌策略", icon: Target },
{ id: "result-planning-content", label: "内容规划", icon: Target },
{ id: "result-planning-channel", label: "渠道规划", icon: Target },
{ id: "result-planning-narrative", label: "叙事策略", icon: Target },
],
},
{
id: "mgmt-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
id: "result-mnty",
label: "MNTY结果",
icon: Brain,
children: [
{ id: "result-mnty-all", label: "全部", icon: BarChart3 },
{ id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
{ id: "result-mnty-plan", label: "规划设计", icon: Brain },
{ id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
],
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
id: "result-effect-pg",
label: "效果PG结果",
icon: TrendingUp,
children: [
{ id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
{ id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
{ id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
{ id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
{ id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
],
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
id: "result-xk",
label: "XK结果",
icon: Compass,
children: [
{ id: "result-xk-all", label: "全部", icon: BarChart3 },
{ id: "result-xk-jc", label: "任务JC", icon: Compass },
{ id: "result-xk-dispatch", label: "任务调度", icon: Compass },
],
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
id: "result-dq",
label: "DQ结果",
icon: Database,
children: [
{ id: "result-dq-all", label: "全部", icon: BarChart3 },
{ id: "result-dq-audience", label: "受众分析", icon: Database },
{ id: "result-dq-kb", label: "知识研究", icon: Database },
{ id: "result-dq-trend", label: "趋势研究", icon: Database },
],
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
id: "result-other",
label: "其他结果",
icon: MoreHorizontal,
children: [
{ id: "result-other-all", label: "全部", icon: BarChart3 },
{ id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
{ id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
{ id: "result-other-other", label: "其他", icon: MoreHorizontal },
],
},
],
},
@@ -214,82 +214,37 @@ const menuItems: MenuItem[] = [
label: "资源管理",
icon: Database,
children: [
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
{ id: "resource-zz", label: "ZZ资源", icon: FileBox },
{ id: "resource-network-actor", label: "网络达人", icon: Network },
{ id: "resource-knowledge-base", label: "知识库", icon: Library },
{ id: "resource-evaluation-index", label: "评估指标", icon: Scale },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"xk-task",
"result-management",
"result-planning",
]);
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
}
export function PageSidebar({
collapsed,
onToggleCollapsed,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const iconButtonClass = isDark
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = isDark
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
: "ml-4 space-y-1 border-l border-red-100 pl-4";
const iconButtonClass =
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
const panelClass = "ml-4 space-y-1 border-l border-red-100 pl-4";
const labelClass = (compact: boolean, small = false) =>
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
const menuButtonClass = (isActive: boolean, depth = 0) =>
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
} ${
isActive
? isDark
? "bg-blue-500/15 text-cyan-300 font-semibold"
: "bg-red-50 text-red-600 font-semibold"
: isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`;
const footerButtonClass = menuButtonClass(false, 0);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
@@ -313,7 +268,13 @@ export function PageSidebar({
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
className={`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 ${
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
} ${
isActive
? "bg-red-50 text-red-600 font-semibold"
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
}`}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{(!collapsed || depth > 0) && (
@@ -342,7 +303,9 @@ export function PageSidebar({
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={menuButtonClass(false, depth)}
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
collapsed && isTopLevel ? "justify-center px-2" : "gap-2 px-3"
}`}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
@@ -377,47 +340,24 @@ export function PageSidebar({
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<aside
className={`fixed left-0 top-0 z-40 h-screen overflow-y-auto border-r border-red-100 bg-white shadow-sm transition-all duration-300 ${
collapsed ? "w-16" : "w-64"
}`}
>
<div
className={`flex h-16 items-center theme-transition ${
isDark ? "border-b border-blue-800/30" : "border-b border-red-100"
} ${collapsed ? "justify-center px-2" : "justify-between px-6"}`}
className={`flex h-16 items-center border-b border-red-100 ${
collapsed ? "justify-center px-2" : "justify-between px-6"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<div
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg transition-transform duration-300 ease-out ${
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-gradient-to-br from-red-500 to-red-400"
}`}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
<h1
className={`text-sm font-semibold theme-transition ${
isDark ? "text-white" : "text-gray-900"
}`}
>
CM助手
</h1>
<h1 className="text-sm font-semibold text-gray-900">DeerFlow</h1>
<p className="text-xs text-red-400/80">Page Navigation</p>
</div>
)}
</div>
@@ -433,7 +373,7 @@ export function PageSidebar({
)}
</div>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{collapsed && (
<button
type="button"
@@ -445,90 +385,13 @@ export function PageSidebar({
</button>
)}
{visibleMenuItems.map((item) =>
{menuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div
className={`mt-auto shrink-0 theme-transition ${
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
} ${collapsed ? "p-2" : "p-4"}`}
>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={collapsed ? t.workspace.settingsAndMore : undefined}
className={footerButtonClass}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={collapsed ? "start" : "end"}
side={collapsed ? "right" : "top"}
sideOffset={4}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? "bg-cyan-500" : "bg-red-500"
}`}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className={`${footerButtonClass} pointer-events-none`}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
)}
</button>
)}
</div>
<div
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
isDark ? "bg-blue-500/10" : "bg-red-500/5"
}`}
/>
<div
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
}`}
/>
<div className="pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full bg-red-500/5 blur-3xl" />
<div className="pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full bg-red-400/5 blur-3xl" />
</aside>
</>
);
}
@@ -19,12 +19,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useAdminAddMemoryEntry,
useAdminAddV1Fact,
useAdminDeleteMemoryEntry,
useAdminDeleteV1Fact,
useAdminReplaceMemoryEntry,
useAdminUpdateV1Fact,
useAdminUpdateV1Section,
useUserMemory,
} from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/hooks";
@@ -35,288 +31,6 @@ function getEntries(data: Record<string, unknown>): string[] {
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
}
// ── V1 只读视图 ────────────────────────────────────────────────────────────
const CTX_LABELS: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人信息",
topOfMind: "当前重点",
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
};
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
function extractSummary(val: unknown): string {
if (typeof val === "string") return val;
if (val && typeof val === "object" && "summary" in val)
return String((val as Record<string, unknown>).summary ?? "");
return "";
}
// 单个摘要字段的行内编辑
function V1SectionField({
label,
value,
onSave,
}: {
label: string;
value: string;
onSave: (v: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
async function handleSave() {
if (draft.trim() === value.trim()) { setEditing(false); return; }
setSaving(true);
try { await onSave(draft.trim()); setEditing(false); }
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
finally { setSaving(false); }
}
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{!editing && (
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
<Pencil className="size-3" />
</Button>
)}
</div>
{editing ? (
<div className="space-y-1.5">
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
<div className="flex gap-1.5">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
</div>
</div>
) : (
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
)}
</div>
);
}
// 单条事实的行内编辑
function V1FactItem({
fact,
agent,
userId,
}: {
fact: Record<string, unknown>;
agent: string | null;
userId: string;
}) {
const [editing, setEditing] = useState(false);
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
const [draftConfidence, setDraftConfidence] = useState(
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
);
const updateFact = useAdminUpdateV1Fact(userId);
const deleteFact = useAdminDeleteV1Fact(userId);
const factId = String(fact.id ?? "");
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
const category = String(fact.category ?? "context");
async function handleSave() {
try {
await updateFact.mutateAsync({
factId,
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
agent,
});
setEditing(false);
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
}
async function handleDelete() {
try { await deleteFact.mutateAsync({ factId, agent }); }
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
}
return (
<div className="rounded border p-2 space-y-1 group">
{editing ? (
<div className="space-y-2">
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
<div className="flex items-center gap-2 flex-wrap">
<Select value={draftCategory} onValueChange={setDraftCategory}>
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">置信度</span>
<input
type="number" min={0} max={100} value={draftConfidence}
onChange={(e) => setDraftConfidence(Number(e.target.value))}
className="w-16 h-7 rounded border px-2 text-xs"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
<Pencil className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
</div>
</div>
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
</>
)}
</div>
);
}
// 新增事实表单
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [category, setCategory] = useState("context");
const [confidence, setConfidence] = useState(80);
const addFact = useAdminAddV1Fact(userId);
async function handleAdd() {
if (!content.trim()) return;
try {
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
}
if (!open) {
return (
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
<Plus className="size-3" />新增事实
</Button>
);
}
return (
<div className="rounded-md border p-3 space-y-2">
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
<div className="flex items-center gap-2 flex-wrap">
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">置信度</span>
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
</Button>
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
</div>
</div>
);
}
function V1BucketView({
data,
agent,
userId,
}: {
data: Record<string, unknown>;
agent: string | null;
userId: string;
}) {
const updateSection = useAdminUpdateV1Section(userId);
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
}
return (
<div className="space-y-4">
{/* 用户画像字段 */}
<div className="rounded-md border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
{allCtxFields.map((field) => (
<V1SectionField
key={field}
label={CTX_LABELS[field] ?? field}
value={extractSummary(ctxRaw[field])}
onSave={(summary) =>
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
}
/>
))}
</div>
{/* 历史摘要字段 */}
<div className="rounded-md border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
{allHistoryFields.map((field) => (
<V1SectionField
key={field}
label={HISTORY_LABELS[field] ?? field}
value={extractSummary(historyRaw[field])}
onSave={(summary) =>
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
}
/>
))}
</div>
{/* 事实记录 */}
<div className="rounded-md border p-3 space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
{factsRaw.map((fact, i) => (
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
))}
<V1AddFactForm agent={agent} userId={userId} />
</div>
</div>
);
}
// ── Memory entry list with inline editing ──────────────────────────────────
function MemoryEntryList({
@@ -503,76 +217,11 @@ function UserMemoryPanel({ userId }: { userId: string }) {
);
}
const isV1 = memory?.version === "v1";
const globalBucket = memory?.buckets.find((b) => b.name === "global");
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
// ── V1 只读视图 ──────────────────────────────────────────────────────────
if (isV1) {
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
const currentBucket = currentAgent
? agentBuckets.find((b) => b.name === currentAgent)
: globalBucket;
return (
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-4 pt-4">
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
V1 模式:记忆由系统自动提取,不支持手动管理
</div>
<TabsList variant="line">
<TabsTrigger value="global">用户记忆</TabsTrigger>
{agentBuckets.length > 0 && (
<TabsTrigger value="agent">
Agent 记忆
<Badge variant="secondary" className="ml-1.5 text-xs">
{agentBuckets.length}
</Badge>
</TabsTrigger>
)}
</TabsList>
</div>
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
)}
</div>
</ScrollArea>
</TabsContent>
{agentBuckets.length > 0 && (
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-muted-foreground text-xs">选择 Agent:</span>
<Select value={currentAgent ?? ""} onValueChange={(v) => setSelectedAgent(v)}>
<SelectTrigger className="h-7 w-48 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{agentBuckets.map((b) => (
<SelectItem key={b.name} value={b.name} className="text-xs">
{b.display_name || b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
</div>
</ScrollArea>
</TabsContent>
)}
</Tabs>
);
}
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
@@ -635,10 +284,16 @@ function UserMemoryPanel({ userId }: { userId: string }) {
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
<span className="text-muted-foreground text-xs">
— MEMORY.md
</span>
</div>
{currentAgent && (
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
<MemoryEntryList
entries={agentEntries}
userId={userId}
agent={currentAgent}
/>
)}
</>
)}
@@ -6,7 +6,6 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
SparklesIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
@@ -39,16 +38,11 @@ import { Textarea } from "@/components/ui/textarea";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useDeleteAgent,
useSetAgentFeatured,
useUpdateAgent,
} from "@/core/agents";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { cn } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
@@ -59,7 +53,6 @@ export function AgentCard({ agent }: AgentCardProps) {
const router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -154,26 +147,6 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
// safe seed; the manage dialog can rewrite to 0..N-1 later).
const turningOn = agent.featured_order == null;
if (turningOn && !agent.published) {
toast.error("只有已发布的智能体可以加入首页选择器");
return;
}
try {
await setFeatured.mutateAsync({
id: agent.id,
order: turningOn ? Date.now() : null,
});
toast.success(turningOn ? "已加入首页选择器" : "已从首页选择器移除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
function toggleSkill(skillName: string) {
setSelectedSkills((prev) => {
if (prev.includes(skillName)) {
@@ -238,16 +211,6 @@ export function AgentCard({ agent }: AgentCardProps) {
已发布
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
title="已加入首页选择器"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</div>
</div>
{agent.description && (
@@ -300,26 +263,6 @@ export function AgentCard({ agent }: AgentCardProps) {
</Button>
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.featured_order != null &&
"text-amber-600 hover:text-amber-700",
)}
onClick={() => void handleToggleFeatured()}
disabled={setFeatured.isPending}
title={
agent.featured_order != null
? "从首页选择器移除"
: "加入首页选择器"
}
>
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
{isAdmin && (
<Button
size="icon"
@@ -526,7 +469,6 @@ export function AgentCard({ agent }: AgentCardProps) {
/>
)}
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
@@ -1,240 +0,0 @@
"use client";
import {
ArrowDownIcon,
ArrowUpIcon,
BotIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import {
useAgentSelector,
useAgents,
useReorderFeaturedAgents,
useSetAgentFeatured,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
import { cn } from "@/lib/utils";
interface AgentManageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Admin-only homepage agent picker management.
*
* Single list (no tabs, no per-user buckets): admin curates exactly what every
* user sees in the new-chat selector. Up/down to reorder, ✕ to remove, + to
* add a published agent. All actions hit the server immediately and react to
* TanStack Query invalidation, so the selector dropdown stays in lockstep.
*/
export function AgentManageDialog({
open,
onOpenChange,
}: AgentManageDialogProps) {
const { agents, isLoading } = useAgentSelector();
const { agents: allAgents } = useAgents();
const setFeaturedMutation = useSetAgentFeatured();
const reorderMutation = useReorderFeaturedAgents();
const [pickerOpen, setPickerOpen] = useState(false);
const candidates = useMemo(() => {
const existing = new Set(agents.map((a) => a.id));
return allAgents.filter((a) => a.published && !existing.has(a.id));
}, [agents, allAgents]);
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= agents.length) return;
const next = agents.map((a) => a.id);
[next[index], next[target]] = [next[target]!, next[index]!];
try {
await reorderMutation.mutateAsync(next);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const remove = async (agentId: string) => {
try {
await setFeaturedMutation.mutateAsync({ id: agentId, order: null });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const add = async (agent: Agent) => {
if (!agent.published) {
toast.error("只有已发布的智能体可以加入选择器");
return;
}
const maxOrder = agents.reduce(
(acc, a) => Math.max(acc, a.featured_order ?? 0),
-1,
);
try {
await setFeaturedMutation.mutateAsync({
id: agent.id,
order: maxOrder + 1,
});
setPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理 Agent 列表</DialogTitle>
<DialogDescription>
配置首页对话框里展示的 agent。所有人会看到同一份列表,按下方顺序展示。
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{isLoading ? (
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
加载中…
</div>
) : agents.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
还没有配置任何 agent。点击下方“添加 Agent”开始。
</div>
) : (
<div className="max-h-[50vh] divide-y overflow-y-auto rounded-md border">
{agents.map((agent, index) => (
<div
key={agent.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
{index + 1}
</span>
<BotIcon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[11px] text-muted-foreground">
{agent.description}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<Tooltip content="上移">
<Button
size="icon"
variant="ghost"
disabled={busy || index === 0}
onClick={() => void move(index, -1)}
className="size-7"
>
<ArrowUpIcon className="size-3.5" />
</Button>
</Tooltip>
<Tooltip content="下移">
<Button
size="icon"
variant="ghost"
disabled={busy || index === agents.length - 1}
onClick={() => void move(index, 1)}
className="size-7"
>
<ArrowDownIcon className="size-3.5" />
</Button>
</Tooltip>
<Tooltip content="从选择器移除">
<Button
size="icon"
variant="ghost"
disabled={busy}
onClick={() => void remove(agent.id)}
className="text-destructive hover:text-destructive size-7"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
</div>
))}
</div>
)}
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setPickerOpen(true)}
className="w-full"
>
<PlusIcon className="mr-1 size-3.5" />
添加 Agent
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">添加 Agent</DialogTitle>
<Command className={cn("**:data-[slot=command-input-wrapper]:h-auto")}>
<CommandInput placeholder="搜索可添加的 agent…" className="h-auto py-3.5" />
<CommandList>
<CommandEmpty>
{candidates.length === 0
? "没有更多已发布的 agent 可添加。"
: "没有找到匹配的 agent。"}
</CommandEmpty>
{candidates.length > 0 && (
<CommandGroup>
{candidates.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => void add(agent)}
>
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[10px] text-muted-foreground">
{agent.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,173 +0,0 @@
"use client";
import { BotIcon, SettingsIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { useAgentSelector } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
interface AgentSelectorProps {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
disabled?: boolean;
}
/**
* Homepage agent picker — shows the admin-curated selector list.
*
* - Non-admins see a read-only dropdown.
* - Admins also see a "管理 Agent 列表" entry that opens the management dialog.
*
* Selecting an agent flips this chip to a coloured pill with a ✕ to clear.
* The actual handoff to ``AgentChatPage`` happens in ``ChatPage`` on submit.
*/
export function AgentSelector({
selectedAgentId,
onAgentChange,
disabled,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector();
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null;
}, [agents, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onAgentChange(null);
};
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon className="size-3" />
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
</span>
)}
</PromptInputButton>
</DialogTrigger>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
<CommandInput placeholder="搜索 agent…" className="h-auto py-3.5" />
<CommandList>
<CommandEmpty>
{isLoading ? "加载中…" : "没有找到匹配的 agent"}
</CommandEmpty>
{agents.length > 0 && (
<CommandGroup>
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => handleSelect(agent)}
className={cn(
"flex items-center gap-2",
agent.id === selectedAgentId && "bg-accent",
)}
>
<BotIcon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[10px] text-muted-foreground">
{agent.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{!isLoading && agents.length === 0 && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
{isAdmin
? "还没有配置任何 agent。点击下方 “管理” 添加几个。"
: "管理员还没有配置可选的 agent。"}
</div>
)}
</CommandList>
{isAdmin && (
<div className="border-t px-2 py-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="w-full justify-start text-xs"
onClick={() => {
setOpen(false);
setManageOpen(true);
}}
>
<SettingsIcon className="mr-2 size-3.5" />
管理 Agent 列表 …
</Button>
</div>
)}
</Command>
</DialogContent>
</Dialog>
{isAdmin && (
<AgentManageDialog open={manageOpen} onOpenChange={setManageOpen} />
)}
</>
);
}
@@ -2,7 +2,6 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
@@ -65,8 +64,6 @@ import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
import {
ModelSelector,
ModelSelectorContent,
@@ -122,7 +119,6 @@ export function InputBox({
onSubmit,
onStop,
promptPrefix,
agentSelector,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -135,7 +131,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -149,7 +144,6 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -165,15 +159,6 @@ export function InputBox({
on: boolean;
onToggle: () => void;
};
/**
* Optional agent picker shown alongside the model selector. Only the new-chat
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
* thread is already bound to one agent.
*/
agentSelector?: {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
@@ -288,14 +273,6 @@ export function InputBox({
});
}, [context, onContextChange]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
@@ -601,26 +578,6 @@ export function InputBox({
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -910,13 +867,6 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
disabled={disabled}
/>
)}
{promptPrefix && (
<Tooltip
content={
@@ -37,11 +37,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
type SearchResultItem,
} from "@/core/messages/search-results";
import {
extractReasoningContentFromMessage,
findToolCallResult,
@@ -221,21 +216,15 @@ function ToolCall({
const { t } = useI18n();
const artifacts = useArtifactsOptional();
const searchItems = normalizeSearchResults(result);
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? t.toolCalls.searchOnWebFor(query)
: t.toolCalls.searchFor(query);
if (typeof args.query === "string") {
label = t.toolCalls.searchOnWebFor(args.query);
}
const items = normalizeSearchResults(result);
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
{items.length > 0 && <WebSearchResultsList results={items} />}
</ChainOfThoughtStep>
);
} else if (name === "image_search") {
@@ -512,7 +501,32 @@ function convertToSteps(messages: Message[]): CoTStep[] {
return steps;
}
function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
type WebSearchItem = {
title?: string;
url?: string;
snippet?: string;
content?: string;
published_date?: string;
publishedDate?: string;
date?: string;
};
function normalizeSearchResults(
result: string | Record<string, unknown> | undefined,
): WebSearchItem[] {
if (Array.isArray(result)) {
return result as WebSearchItem[];
}
if (result && typeof result === "object") {
const nested = (result as { results?: unknown }).results;
if (Array.isArray(nested)) {
return nested as WebSearchItem[];
}
}
return [];
}
function WebSearchResultsList({ results }: { results: WebSearchItem[] }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
@@ -534,16 +548,16 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
function SearchResultCard({ item }: { item: WebSearchItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
const date = item.published_date ?? item.publishedDate ?? item.date;
let sourceLabel: string | undefined = item.source;
let source: string | undefined;
if (item.url) {
try {
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
source = new URL(item.url).hostname.replace(/^www\./, "");
} catch {
sourceLabel = sourceLabel ?? item.url;
source = undefined;
}
}
return (
@@ -558,10 +572,10 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
{item.title}
</div>
)}
{(sourceLabel || date) && (
{(source || date) && (
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
{sourceLabel && <span className="truncate">{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{source && <span className="truncate">{source}</span>}
{source && date && <span>·</span>}
{date && <span>{date}</span>}
</div>
)}
@@ -577,10 +591,10 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
<DialogTitle className="pr-6 text-base leading-relaxed">
{item.title ?? "检索结果"}
</DialogTitle>
{(sourceLabel || date) && (
{(source || date) && (
<DialogDescription className="flex items-center gap-2 text-xs">
{sourceLabel && <span>{sourceLabel}</span>}
{sourceLabel && date && <span>·</span>}
{source && <span>{source}</span>}
{source && date && <span>·</span>}
{date && <span>{date}</span>}
</DialogDescription>
)}
@@ -150,9 +150,7 @@ export function RecentChatList() {
);
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap",
(thread) => thread.metadata?.thread_type !== "scheduler",
);
if (visibleThreads.length === 0) {
@@ -36,7 +36,6 @@ import {
useReplaceMemoryEntry,
} from "@/core/memory/hooks";
import type {
MemoryFact,
MemorySearchMode,
MemoryTarget,
MemoryV2Config,
@@ -55,10 +54,6 @@ export function MemorySettingsPage() {
const { t } = useI18n();
const m = t.settings.memory;
// 先读配置拿到 version,再决定渲染哪套 UI
const { configResponse } = useMemoryConfig();
const memoryVersion = configResponse?.version ?? "v2";
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
@@ -216,33 +211,7 @@ export function MemorySettingsPage() {
</div>
) : !memory ? (
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
) : memoryVersion === "v1" ? (
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{m.workNotesAgentLabel}:
</span>
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="h-8 w-44" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{m.workNotesDefaultAgent}
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MemoryV1View memory={memory} />
</div>
) : (
// V2 模式:USER.md / MEMORY.md 条目编辑
<div className="space-y-4">
{renderEntrySection(
"user",
@@ -282,17 +251,13 @@ export function MemorySettingsPage() {
)}
</SettingsSection>
{memoryVersion !== "v1" && (
<>
<div className="mt-8">
<MemoryConfigPanel />
</div>
<div className="mt-8">
<MemoryConfigPanel />
</div>
<div className="mt-8">
<MemorySearchPanel />
</div>
</>
)}
<div className="mt-8">
<MemorySearchPanel />
</div>
{/* 新增 / 编辑条目对话框 */}
<Dialog
@@ -450,133 +415,6 @@ function MemoryConfigPanel() {
);
}
const CTX_LABELS: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人信息",
topOfMind: "当前重点",
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
};
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
function MemoryV1View({
memory,
}: {
memory: {
facts: MemoryFact[];
user_context: Record<string, string>;
history: Record<string, string>;
memory_usage: string;
};
}) {
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
return (
<div className="space-y-4">
{/* 说明横幅 */}
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div>
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-base font-medium">用户画像</h3>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</div>
))}
</dl>
)}
</div>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2">
<h3 className="text-base font-medium">历史摘要</h3>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</div>
))}
</dl>
)}
</div>
{/* 事实记录 (facts) */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="space-y-2">
{memory.facts.map((fact) => (
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{fact.category}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
{fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
避免:{fact.sourceError}
</p>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
/** 一行开关控件。 */
function ToggleRow({
label,
@@ -23,7 +23,7 @@ function BrandMark({ className }: { className?: string }) {
);
}
export function SidebarCollapseButton({ className }: { className?: string }) {
function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
@@ -57,6 +57,9 @@ export function WorkspaceHeader({ className }: { className?: string }) {
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
{t.pages.appName}
</div>
<div className="text-muted-foreground truncate text-[11px] leading-tight">
{t.pages.brandSubtitle}
</div>
</div>
);
@@ -68,7 +71,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2">
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
</div>
@@ -77,7 +80,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
</div>
</div>
) : (
<div className="flex items-center gap-2 px-2.5 py-2">
<div className="flex items-center gap-2.5 px-2.5 py-3">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
@@ -13,10 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { cn } from "@/lib/utils";
import { BotIcon, BrainIcon, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -30,109 +27,84 @@ const NEW_CHAT_CLASS =
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
export function WorkspaceNavChatList({
showCollapseTrigger = false,
}: {
showCollapseTrigger?: boolean;
}) {
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarGroup className="px-2 pt-2">
<SidebarMenu className="gap-1">
<SidebarMenuItem>
<div className="flex items-center gap-1">
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
{showCollapseTrigger && (
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
)}
</div>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats/new"}
asChild
className={NEW_CHAT_CLASS}
>
<Link href="/page/workspace/chats/new">
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/memory")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/memory">
<BrainIcon />
<span>记忆</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{!showCollapseTrigger && (
<>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/chats"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/chats">
<MessagesSquare />
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/agents")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/agents">
<BotIcon />
<span>{t.sidebar.agents}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/skills")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/skills">
<SparklesIcon />
<span>技能</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/memory")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/memory">
<BrainIcon />
<span>记忆</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/ai-writing")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/ai-writing">
<PenLine />
<span>AI写作</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -8,8 +8,6 @@ import {
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
@@ -17,40 +15,22 @@ import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
className,
...props
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
}: React.ComponentProps<typeof Sidebar>) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const useWhiteSidebar = compactChrome && theme === "light";
return (
<>
<Sidebar
variant="sidebar"
collapsible="icon"
className={cn(
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
)}
{...props}
>
{!compactChrome && (
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
)}
<Sidebar variant="sidebar" collapsible="icon" {...props}>
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
<WorkspaceNavChatList />
{isSidebarOpen && <RecentChatList />}
</SidebarContent>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
<SidebarRail />
</Sidebar>
</>
-70
View File
@@ -31,7 +31,6 @@ export interface AdminMemoryBucket {
export interface AdminMemoryResponse {
user_id: string;
version: "v1" | "v2";
buckets: AdminMemoryBucket[];
}
@@ -119,72 +118,3 @@ export async function deleteAdminMemoryEntry(
"Failed to delete memory entry",
);
}
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
export async function updateAdminV1Section(
userId: string,
sectionType: "user" | "history",
field: string,
summary: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/sections`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
}),
"Failed to update V1 section",
);
}
export async function addAdminV1Fact(
userId: string,
content: string,
category: string,
confidence: number,
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, category, confidence, agent }),
}),
"Failed to add V1 fact",
);
}
export async function updateAdminV1Fact(
userId: string,
factId: string,
updates: { content?: string; category?: string; confidence?: number },
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...updates, agent }),
}),
"Failed to update V1 fact",
);
}
export async function deleteAdminV1Fact(
userId: string,
factId: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
const params = new URLSearchParams();
if (agent) params.set("agent", agent);
const qs = params.toString() ? `?${params}` : "";
return parse(
await apiFetch(
`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`,
{ method: "DELETE" },
),
"Failed to delete V1 fact",
);
}
-67
View File
@@ -2,15 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addAdminMemoryEntry,
addAdminV1Fact,
deleteAdminMemoryEntry,
deleteAdminV1Fact,
getUserMemory,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
updateAdminV1Fact,
updateAdminV1Section,
} from "./api";
/** Threads owned by a given user. Disabled until a user is picked. */
@@ -74,66 +70,3 @@ export function useAdminDeleteMemoryEntry(userId: string) {
onSuccess: invalidate,
});
}
// ── V1 CRUD hooks ──────────────────────────────────────────────────────────
export function useAdminUpdateV1Section(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({
sectionType,
field,
summary,
agent,
}: {
sectionType: "user" | "history";
field: string;
summary: string;
agent: string | null;
}) => updateAdminV1Section(userId, sectionType, field, summary, agent),
onSuccess: invalidate,
});
}
export function useAdminAddV1Fact(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({
content,
category,
confidence,
agent,
}: {
content: string;
category: string;
confidence: number;
agent: string | null;
}) => addAdminV1Fact(userId, content, category, confidence, agent),
onSuccess: invalidate,
});
}
export function useAdminUpdateV1Fact(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({
factId,
updates,
agent,
}: {
factId: string;
updates: { content?: string; category?: string; confidence?: number };
agent: string | null;
}) => updateAdminV1Fact(userId, factId, updates, agent),
onSuccess: invalidate,
});
}
export function useAdminDeleteV1Fact(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({ factId, agent }: { factId: string; agent: string | null }) =>
deleteAdminV1Fact(userId, factId, agent),
onSuccess: invalidate,
});
}
+1 -50
View File
@@ -1,12 +1,7 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type {
Agent,
AgentSelectorResponse,
CreateAgentRequest,
UpdateAgentRequest,
} from "./types";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -80,50 +75,6 @@ export async function deleteAgent(id: string): Promise<void> {
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
}
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
return res.json() as Promise<AgentSelectorResponse>;
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_ids: agentIds }),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to reorder featured agents: ${res.statusText}`,
);
}
}
export async function setAgentFeatured(
id: string,
order: number | null,
): Promise<Agent> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/${id}/featured`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order }),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to set featured: ${res.statusText}`,
);
}
return res.json() as Promise<Agent>;
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
-40
View File
@@ -9,10 +9,7 @@ import {
createAgent,
deleteAgent,
getAgent,
getAgentSelector,
listAgents,
reorderFeaturedAgents,
setAgentFeatured,
updateAgent,
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
@@ -73,40 +70,3 @@ export function useDeleteAgent() {
},
});
}
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
staleTime: 60_000,
});
return {
agents: data?.agents ?? [],
isLoading,
error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
export function useSetAgentFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order),
onSuccess: (_data, { id }) => {
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
});
}
export function useReorderFeaturedAgents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
onSuccess: () => invalidateAgentQueries(queryClient),
});
}
-5
View File
@@ -14,11 +14,6 @@ export interface Agent {
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
}
export interface AgentSelectorResponse {
agents: Agent[];
}
export interface CreateAgentRequest {
-10
View File
@@ -68,13 +68,3 @@ export function getLangGraphBaseURL(isMock?: boolean) {
return "http://localhost:2026/api/langgraph";
}
}
/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
export function getNoteApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
return new URL(env.NEXT_PUBLIC_NOTE_API_BASE_URL, getBaseOrigin())
.toString()
.replace(/\/+$/, "");
}
return "";
}
+3 -29
View File
@@ -1,23 +1,7 @@
// Memory 数据模型。对应后端 /api/memory/v2 系列端点。支持 V1 / V2 两个版本。
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
/** V1 记忆中的单条事实。 */
export interface MemoryFact {
id: string;
content: string;
category: string;
confidence: number;
createdAt: string;
/** 纠错类事实:被纠正的原错误描述(category=correction 时可能有值)。 */
sourceError?: string;
}
/** 记忆数据视图 —— V1/V2 共用结构。
*
* - V2 模式: user_entries / memory_entries / user_usage / memory_usage 有值
* - V1 模式: facts / user_context / memory_usage 有值
*/
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
export interface MemoryV2Data {
// V2 字段
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
user_entries: string[];
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
@@ -26,27 +10,17 @@ export interface MemoryV2Data {
user_usage: string;
/** MEMORY.md 字符用量。 */
memory_usage: string;
// V1 字段
/** V1 facts 列表,按置信度降序。 */
facts: MemoryFact[];
/** V1 userContext 摘要。 */
user_context: Record<string, string>;
/** V1 history 历史摘要。 */
history: Record<string, string>;
}
/** 记忆条目所属类别。 */
export type MemoryTarget = "user" | "memory";
/** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
/** 生效记忆配置 + 用户可改字段白名单。 */
export interface MemoryV2ConfigResponse {
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
config: MemoryV2Config;
/** 用户可在运行时覆盖的字段 (点号路径)。 */
writable_fields: string[];
/** 当前记忆版本:v1 或 v2。 */
version: "v1" | "v2";
}
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
@@ -1,429 +0,0 @@
/**
* Normalizes retrieval payloads from web_search, intranet skills, MCP tools, etc.
* into a single card-friendly shape for the workspace chat UI.
*
* Parsing is intentionally tolerant: known field aliases first, then key-name
* heuristics, then longest-string fallback for body text.
*/
export type SearchResultItem = {
title?: string;
url?: string;
snippet?: string;
content?: string;
source?: string;
published_date?: string;
publishedDate?: string;
date?: string;
};
const RESULT_LIST_KEYS = [
"results",
"data",
"items",
"hits",
"documents",
"records",
"entries",
"matches",
"chunks",
"sources",
] as const;
const WRAPPER_KEYS = ["result", "payload", "response", "output", "data"] as const;
const NESTED_META_KEYS = ["metadata", "meta", "attrs", "attributes", "properties", "extra"] as const;
/** Keys ignored when guessing content from arbitrary string fields. */
const SKIP_HEURISTIC_KEYS = new Set([
"id",
"uuid",
"recuuid",
"score",
"relevance_score",
"rank",
"index",
"type",
"status",
"ok",
"success",
"error",
"query",
]);
const TITLE_ALIASES = [
"title",
"m_title",
"name",
"heading",
"headline",
"subject",
"document_title",
"doc_title",
"file_name",
"filename",
] as const;
const CONTENT_ALIASES = [
"content",
"content_preview",
"snippet",
"page_content",
"body",
"text",
"summary",
"abstract",
"description",
"desc",
"excerpt",
"preview",
"passage",
"paragraph",
"chunk",
"chunk_text",
"document",
"doc_content",
"answer",
"highlight",
"context",
"message",
] as const;
const URL_ALIASES = ["url", "href", "link", "uri", "source_url", "web_url", "page_url"] as const;
const SOURCE_ALIASES = [
"source",
"source1",
"source_name",
"origin",
"site",
"site_name",
"publisher",
"domain",
"from",
"channel",
"database",
"knowledge_base",
"kb",
] as const;
const DATE_ALIASES = [
"published_date",
"publishedDate",
"publish_date",
"date",
"m_publish",
"published_at",
"publish_time",
"created_at",
"updated_at",
"time",
] as const;
const TITLE_KEY_PATTERN =
/(?:^|_)(?:title|name|heading|headline|subject)(?:$|_)/i;
const CONTENT_KEY_PATTERN =
/(?:^|_)(?:content|snippet|summary|abstract|body|text|preview|passage|excerpt|desc|description|chunk|paragraph|document|answer|highlight|context|message)(?:$|_)/i;
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
const SOURCE_KEY_PATTERN =
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
const DATE_KEY_PATTERN =
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
function pickString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
}
return undefined;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function looksLikeUrl(value: string): boolean {
return /^https?:\/\//i.test(value) || /^www\./i.test(value);
}
function collectNestedRecords(
record: Record<string, unknown>,
): Record<string, unknown>[] {
const layers: Record<string, unknown>[] = [record];
for (const key of NESTED_META_KEYS) {
const nested = asRecord(record[key]);
if (nested) {
layers.push(nested);
}
}
return layers;
}
function pickFromLayers(
layers: Record<string, unknown>[],
aliases: readonly string[],
): string | undefined {
for (const layer of layers) {
for (const alias of aliases) {
const value = pickString(layer[alias]);
if (value) {
return value;
}
}
}
return undefined;
}
function pickByKeyPattern(
layers: Record<string, unknown>[],
pattern: RegExp,
options?: { excludeUrl?: boolean; maxLength?: number },
): string | undefined {
for (const layer of layers) {
for (const [key, value] of Object.entries(layer)) {
if (SKIP_HEURISTIC_KEYS.has(key.toLowerCase())) {
continue;
}
if (!pattern.test(key)) {
continue;
}
const text = pickString(value);
if (!text) {
continue;
}
if (options?.excludeUrl && looksLikeUrl(text)) {
continue;
}
if (options?.maxLength !== undefined && text.length > options.maxLength) {
continue;
}
return text;
}
}
return undefined;
}
function pickLongestContentFallback(
layers: Record<string, unknown>[],
reserved: Set<string>,
): string | undefined {
let best: string | undefined;
for (const layer of layers) {
for (const [key, value] of Object.entries(layer)) {
const lower = key.toLowerCase();
if (SKIP_HEURISTIC_KEYS.has(lower)) {
continue;
}
if (TITLE_KEY_PATTERN.test(key) || URL_KEY_PATTERN.test(key)) {
continue;
}
const text = pickString(value);
if (!text || text.length < 8) {
continue;
}
if (reserved.has(text)) {
continue;
}
if (looksLikeUrl(text) && text.length < 120) {
continue;
}
if (!best || text.length > best.length) {
best = text;
}
}
}
return best;
}
function pickShortTitleFallback(
layers: Record<string, unknown>[],
reserved: Set<string>,
): string | undefined {
let best: string | undefined;
for (const layer of layers) {
for (const [key, value] of Object.entries(layer)) {
if (URL_KEY_PATTERN.test(key) || CONTENT_KEY_PATTERN.test(key)) {
continue;
}
const text = pickString(value);
if (!text || text.length > 200 || text.length < 1) {
continue;
}
if (reserved.has(text)) {
continue;
}
if (looksLikeUrl(text)) {
continue;
}
if (!best || text.length < best.length) {
best = text;
}
}
}
return best;
}
function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const record = asRecord(raw);
if (!record) {
return null;
}
const layers = collectNestedRecords(record);
let title =
pickFromLayers(layers, TITLE_ALIASES) ??
pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 });
let content =
pickFromLayers(layers, CONTENT_ALIASES) ??
pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true });
let url =
pickFromLayers(layers, URL_ALIASES) ??
pickByKeyPattern(layers, URL_KEY_PATTERN);
let source =
pickFromLayers(layers, SOURCE_ALIASES) ??
pickByKeyPattern(layers, SOURCE_KEY_PATTERN, {
excludeUrl: true,
maxLength: 120,
});
const date =
pickFromLayers(layers, DATE_ALIASES) ??
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
const reserved = new Set<string>();
for (const value of [title, content, url, source, date]) {
if (value) {
reserved.add(value);
}
}
if (!content) {
content = pickLongestContentFallback(layers, reserved);
if (content) {
reserved.add(content);
}
}
if (!title) {
title = pickShortTitleFallback(layers, reserved);
}
if (!title && !content && !url) {
return null;
}
return {
title,
url,
snippet: content,
content,
source,
published_date: date,
publishedDate: date,
date,
};
}
function extractRawResults(payload: unknown, depth = 0): unknown[] | null {
if (depth > 4) {
return null;
}
if (Array.isArray(payload)) {
return payload.length > 0 ? payload : null;
}
const record = asRecord(payload);
if (!record) {
return null;
}
for (const key of RESULT_LIST_KEYS) {
const nested = record[key];
if (Array.isArray(nested) && nested.length > 0) {
return nested;
}
}
for (const key of WRAPPER_KEYS) {
const nested = record[key];
const found = extractRawResults(nested, depth + 1);
if (found) {
return found;
}
}
return null;
}
function parseResultPayload(
result: string | Record<string, unknown> | undefined,
): unknown {
if (result === undefined) {
return undefined;
}
if (typeof result === "string") {
const trimmed = result.trim();
if (!trimmed) {
return undefined;
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
return undefined;
}
}
return result;
}
/** True when the tool return value looks like a retrieval hit list. */
export function hasSearchResultPayload(
result: string | Record<string, unknown> | undefined,
): boolean {
return normalizeSearchResults(result).length > 0;
}
export function normalizeSearchResults(
result: string | Record<string, unknown> | undefined,
): SearchResultItem[] {
const payload = parseResultPayload(result);
const rawList = extractRawResults(payload);
if (!rawList) {
return [];
}
const items: SearchResultItem[] = [];
for (const raw of rawList) {
const item = normalizeSearchResultItem(raw);
if (item) {
items.push(item);
}
}
return items;
}
export function resolveSearchQuery(
args: Record<string, unknown>,
result: string | Record<string, unknown> | undefined,
): string | undefined {
if (typeof args.query === "string" && args.query.trim()) {
return args.query.trim();
}
const payload = asRecord(parseResultPayload(result));
if (!payload) {
return undefined;
}
const queryKeys = ["query", "q", "keyword", "keywords", "search_query"] as const;
for (const key of queryKeys) {
const value = payload[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
}
-103
View File
@@ -1,103 +0,0 @@
import { getNoteApiBaseURL } from "@/core/config";
import {
ensureNoteAuth,
getNoteAuthorizationHeader,
getStoredNoteAuth,
} from "./auth";
export type { NoteAuth } from "./auth";
export interface SearchSpace {
created_at: string;
id: number;
name: string;
description: string;
user_id: string;
citations_enabled: boolean;
qna_custom_instructions: string;
summary: string | null;
avatar_url: string | null;
sources_limit: number;
sources_used: number;
is_shared: boolean;
is_owner: boolean;
owner_email: string | null;
member_count?: number;
source_count?: number;
membership_role?: string;
}
export interface ListSearchSpacesQuery {
owned_only?: boolean;
limit?: number;
order_by?: string;
}
export interface CreateSearchSpaceInput {
name: string;
description?: string;
citations_enabled?: boolean;
}
export async function noteApiFetch(
path: string,
init: RequestInit = {},
): Promise<Response> {
const auth = getStoredNoteAuth() ?? (await ensureNoteAuth());
const headers = new Headers(init.headers);
headers.set("Authorization", getNoteAuthorizationHeader(auth));
return fetch(`${getNoteApiBaseURL()}${path}`, {
...init,
headers,
});
}
export async function createSearchSpace(
input: CreateSearchSpaceInput,
): Promise<SearchSpace> {
await ensureNoteAuth();
const response = await noteApiFetch("/api/v1/searchspaces", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: input.name,
description: input.description ?? "",
citations_enabled: input.citations_enabled ?? true,
}),
});
if (!response.ok) {
throw new Error(`创建笔记失败: ${response.status} ${response.statusText}`);
}
return (await response.json()) as SearchSpace;
}
export async function listSearchSpaces(
query: ListSearchSpacesQuery = {},
): Promise<SearchSpace[]> {
await ensureNoteAuth();
const params = new URLSearchParams({
owned_only: String(query.owned_only ?? true),
limit: String(query.limit ?? 1000),
order_by: query.order_by ?? "created_at",
});
const response = await noteApiFetch(`/api/v1/searchspaces?${params.toString()}`, {
method: "GET",
});
if (!response.ok) {
throw new Error(`获取笔记列表失败: ${response.status} ${response.statusText}`);
}
const data: unknown = await response.json();
return Array.isArray(data) ? (data as SearchSpace[]) : [];
}
-94
View File
@@ -1,94 +0,0 @@
import { getNoteApiBaseURL } from "@/core/config";
const NOTE_AUTH_STORAGE_KEY = "deerflow.note.auth";
const NOTE_LOGIN_USERNAME = "dhk@qq.com";
const NOTE_LOGIN_PASSWORD = "1qaz@WSX";
export interface NoteAuth {
access_token: string;
token_type: string;
}
function isBrowser(): boolean {
return typeof window !== "undefined";
}
function assertNoteApiBaseURL(): string {
const baseURL = getNoteApiBaseURL();
if (!baseURL) {
throw new Error("未配置 VITE_NOTE_API_BASE_URL");
}
return baseURL;
}
export function getStoredNoteAuth(): NoteAuth | null {
if (!isBrowser()) {
return null;
}
const raw = window.localStorage.getItem(NOTE_AUTH_STORAGE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as NoteAuth;
} catch {
return null;
}
}
export function setStoredNoteAuth(auth: NoteAuth): void {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(NOTE_AUTH_STORAGE_KEY, JSON.stringify(auth));
}
export function clearStoredNoteAuth(): void {
if (!isBrowser()) {
return;
}
window.localStorage.removeItem(NOTE_AUTH_STORAGE_KEY);
}
export function getNoteAuthorizationHeader(auth: NoteAuth): string {
const token = auth.access_token.trim();
const tokenType = auth.token_type?.trim() || "Bearer";
return `${tokenType} ${token}`;
}
export async function loginNoteApi(): Promise<NoteAuth> {
const formData = new FormData();
formData.append("username", NOTE_LOGIN_USERNAME);
formData.append("password", NOTE_LOGIN_PASSWORD);
formData.append("grant_type", "password");
const response = await fetch(`${assertNoteApiBaseURL()}/auth/jwt/login`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`笔记登录失败: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as Partial<NoteAuth>;
if (!data.access_token) {
throw new Error("笔记登录失败: 未返回 access_token");
}
const auth: NoteAuth = {
access_token: data.access_token,
token_type: data.token_type?.trim() || "bearer",
};
setStoredNoteAuth(auth);
return auth;
}
export async function ensureNoteAuth(): Promise<NoteAuth> {
const existing = getStoredNoteAuth();
if (existing?.access_token) {
return existing;
}
return loginNoteApi();
}
@@ -1,22 +0,0 @@
export const PAGE_SIDEBAR_QUERY_PARAM = "sidebar";
const STORAGE_KEY = "deerflow-page-sidebar-enabled";
export function isSidebarEnabledFromSearch(search: string): boolean {
const params = new URLSearchParams(
search.startsWith("?") ? search : `?${search}`,
);
return params.get(PAGE_SIDEBAR_QUERY_PARAM) === "1";
}
export function persistSidebarEnabled(enabled: boolean): void {
if (enabled) {
sessionStorage.setItem(STORAGE_KEY, "1");
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
export function readSidebarEnabled(): boolean {
return sessionStorage.getItem(STORAGE_KEY) === "1";
}
@@ -46,7 +46,6 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
export async function createScheduledTask(data: {
name: string;
description?: string | null;
prompt: string;
schedule: string;
timezone?: string | null;
@@ -204,7 +203,6 @@ export async function updateScheduledTask(
taskId: string,
data: Partial<{
name: string;
description: string | null;
prompt: string;
schedule: string;
timezone: string;
@@ -331,7 +329,6 @@ export async function adminUpdateScheduledTask(
taskId: string,
data: Partial<{
name: string;
description: string | null;
prompt: string;
schedule: string;
timezone: string;
@@ -14,7 +14,6 @@ export type ScheduledTaskSubscription = {
export type ScheduledTask = {
task_id: string;
name: string;
description?: string | null;
prompt: string;
schedule_text: string;
cron_expr: string;
@@ -28,8 +27,6 @@ export type ScheduledTask = {
scheduler_thread_id: string;
published?: boolean;
published_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
-1
View File
@@ -506,7 +506,6 @@ export function useThreadStream({
: context.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
thread_id: threadId,
},
},
+5 -6
View File
@@ -14,12 +14,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
model_name: string | undefined;
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
agent_id?: string;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
-2
View File
@@ -5,7 +5,6 @@ function readEnv(
| "VITE_BACKEND_BASE_URL"
| "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue {
@@ -19,7 +18,6 @@ export const env = {
// Canvas page uses its own LangGraph base URL so it can be independently
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
-39
View File
@@ -1,39 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { loginNoteApi, type NoteAuth } from "@/core/note/auth";
export function useNoteAuth() {
const [auth, setAuth] = useState<NoteAuth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refreshAuth = useCallback(async () => {
setLoading(true);
setError(null);
try {
const nextAuth = await loginNoteApi();
setAuth(nextAuth);
return nextAuth;
} catch (err) {
const message =
err instanceof Error ? err.message : "笔记登录失败,请稍后重试";
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshAuth().catch(() => {
// 错误已在 refreshAuth 中写入 state
});
}, [refreshAuth]);
return {
auth,
loading,
error,
refreshAuth,
};
}
@@ -32,34 +32,6 @@ export interface AIWritingTranscript {
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
// ── admin 调试用 —— 阶段 7 删冗余时一并移除 ───────────────────────────────────
export interface AIWritingCleanupResult {
deleted_db: number;
deleted_checkpoints: number;
skipped_active: number;
elapsed_seconds: number;
params: { retention_days: number; only_finished: boolean; batch_size: number };
}
/** 仅 admin 可用:触发一次性会话清理(保留 ai-writing-优化.md 迁移期临时调试入口)。 */
export async function adminCleanupAIWritingSessions(body: {
retention_days?: number;
only_finished?: boolean;
batch_size?: number;
} = {}): Promise<AIWritingCleanupResult> {
const resp = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/admin/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`清理失败 ${resp.status}: ${text || resp.statusText}`);
}
return resp.json();
}
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
@@ -0,0 +1,225 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type {
AIWritingRequest,
ProgressEvent,
ProgressEventType,
UserInterventionPayload,
} from '../types/ai-writing';
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
/** 启动写作流程,返回 session_id */
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
const resp = await apiFetch(`${BASE()}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_intent: req.userIntent,
article_type: req.articleType,
target_audience: req.targetAudience,
word_count_target: req.wordCountTarget,
model_name: req.modelName ?? '',
keyword_count: req.keywordCount,
writing_mode: req.writingMode,
}),
});
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
const data = await resp.json();
return data.session_id;
}
const SSE_EVENT_TYPES: ProgressEventType[] = [
'connected', 'step_start', 'step_done', 'research_progress',
'materials_ready', 'outline_chunk', 'outline_ready',
'draft_chunk', 'draft_ready',
'review_chunk', 'review_item_done', 'review_ready',
'await_user', 'resumed', 'notice', 'done', 'error',
];
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
return {
...m,
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
publishedAt: m.published_at ?? m.publishedAt ?? '',
sourceType: m.source_type ?? m.sourceType ?? 'internet',
recUuid: m.recUuid ?? m.rec_uuid ?? '',
};
}
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
return {
...s,
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
keyPoints: s.key_points ?? s.keyPoints ?? [],
materialIds: s.material_ids ?? s.materialIds ?? [],
};
}
function normalizeOutline(o: unknown): unknown {
if (!o || typeof o !== 'object') return o;
const obj = o as Record<string, unknown>;
const sections = Array.isArray(obj.sections)
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
: [];
return { ...obj, sections };
}
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
return { ...i };
}
function normalizeReviewResult(r: unknown): unknown {
if (!r || typeof r !== 'object') return r;
const obj = r as Record<string, unknown>;
return {
...obj,
factScore: obj.fact_score ?? obj.factScore ?? 0,
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
languageScore: obj.language_score ?? obj.languageScore ?? 0,
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
revisionNotes: Array.isArray(obj.revision_notes)
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
: Array.isArray(obj.revisionNotes)
? obj.revisionNotes
: [],
};
}
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
const out: Record<string, unknown> = { ...parsed };
// 通用字段
if ('agent_name' in out) { out.agentName = out.agent_name; }
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
// materials 数组(materials_ready 和 await_user 里都可能有)
if (Array.isArray(out.materials)) {
out.materials = out.materials.map((m: unknown) =>
normalizeMaterial(m as Record<string, unknown>)
);
}
// outline
if (out.outline) out.outline = normalizeOutline(out.outline);
// blocked_sections(section_help await_user)
if (Array.isArray(out.blocked_sections)) {
out.blockedSections = out.blocked_sections.map((b: unknown) => {
const o = b as Record<string, unknown>;
return {
sectionTitle: o.section_title ?? o.sectionTitle ?? '',
reason: o.reason ?? '',
};
});
}
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
if ('revision_count' in out) out.revisionCount = out.revision_count;
// review_ready
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
// review_item_done (streaming per-category result from sse_queue)
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
out.reviewItemIssues = out.issues.map((x: unknown) =>
normalizeReviewIssue(x as Record<string, unknown>)
);
}
return out as Partial<ProgressEvent>;
}
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
export function subscribeToProgress(
sessionId: string,
onEvent: (event: ProgressEvent) => void,
signal?: AbortSignal,
): () => void {
let stopped = false;
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
.then(async (response) => {
if (!response.ok || !response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentType = '';
let currentData = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
currentData = line.slice(6).trim();
} else if (line === '' && currentType && currentData) {
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
try {
const parsed = JSON.parse(currentData);
const normalized = normalizeEvent(parsed);
onEvent({
type: currentType as ProgressEventType,
sessionId,
message: '',
...normalized,
} as ProgressEvent);
} catch {
// malformed JSON — skip
}
}
currentType = '';
currentData = '';
}
}
}
reader.releaseLock();
})
.catch(() => {});
return () => { stopped = true; };
}
/** 提交用户干预决策,恢复暂停的流程 */
export async function resumeWriting(
sessionId: string,
payload: UserInterventionPayload,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: payload.action,
approved_material_ids: payload.approvedMaterialIds ?? [],
extra_keywords: payload.extraKeywords ?? [],
edited_outline: payload.editedOutline ?? null,
outline_feedback: payload.outlineFeedback ?? '',
user_revision_notes: payload.userRevisionNotes ?? '',
override_verdict: payload.overrideVerdict ?? false,
selected_issue_indices: payload.selectedIssueIndices ?? null,
section_decisions: (payload.sectionDecisions ?? []).map((d) => ({
section_title: d.sectionTitle,
decision: d.decision,
})),
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -14,34 +13,13 @@ import {
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWritingStream';
import { useAIWriting } from '../../hooks/useAIWriting';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
/**
* 暂停 / 继续功能开关(默认 false = 关)。
*
* 历史背景:legacy 路径下,为了让用户点"停止"后还能"继续",引入了 per-worker
* 内存 dict(``_event_queues`` / ``_session_pending_pause``)。多 worker 部署下
* 这些 dict 互相看不见 → SSE 重连失败 / "会话丢失" → 触发了整个 ai-writing-优化.md
* 的迁移计划。
*
* 当前 LangGraph Server 路径下架构已无 per-worker 状态,但 ``stream.stop()`` 仅
* 取消客户端 SSE 订阅,**不会真正暂停后端 run**(LLM 继续烧 token);``resume``
* 也无法重新订阅同一个 run。这是个产品语义没想清楚的功能,先关掉。
*
* 后期想清楚怎么做"真·暂停"(``client.runs.cancel(run_id, action='interrupt')``
* 保留 checkpoint)+ "真·继续"(``client.runs.joinStream`` 续上原 run)时,
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
*/
const ENABLE_PAUSE_RESUME_UI = false;
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
@@ -193,23 +171,6 @@ export function AIWritingPanel() {
reset();
}, [reset]);
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
const handleAdminCleanup = useCallback(async () => {
if (!window.confirm('确认清理:retention_days=0 会清掉所有 done/error 的历史会话,正在跑的会跳过。继续?')) {
return;
}
try {
const result = await adminCleanupAIWritingSessions({ retention_days: 0, only_finished: true });
toast.success(`已清理:DB ${result.deleted_db} 行,checkpoint ${result.deleted_checkpoints} 条,跳过活跃 ${result.skipped_active}`);
// 刷新历史列表
refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
}, [refresh]);
const showNewButton = historySession !== null || state.status !== 'idle';
// History view mode
@@ -264,16 +225,6 @@ export function AIWritingPanel() {
AI 智能写作
</span>
<div className="flex items-center gap-1.5">
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={handleAdminCleanup}
title="[admin 调试] 清理所有过期会话"
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -289,9 +240,7 @@ export function AIWritingPanel() {
新建
</Button>
)}
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
{isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
@@ -302,7 +251,7 @@ export function AIWritingPanel() {
停止
</Button>
)}
{ENABLE_PAUSE_RESUME_UI && state.isPaused && (
{state.isPaused && (
<>
<Button
variant="ghost"
@@ -345,17 +294,6 @@ export function AIWritingPanel() {
<AIWritingForm onSubmit={startWriting} />
)}
{/* 意图解析:流式 → 结果卡片承接。
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
{(state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
@@ -407,9 +345,7 @@ export function AIWritingPanel() {
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</div>
@@ -450,7 +386,7 @@ export function AIWritingPanel() {
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{ENABLE_PAUSE_RESUME_UI && state.isPaused ? (
{state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
@@ -1,51 +0,0 @@
import { Sparkles } from 'lucide-react';
import type { ParsedIntent } from '../../types/ai-writing';
import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
}
/**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
return (
<div className="px-4 pb-2">
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/40 dark:bg-blue-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
意图解析完成
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} />
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline />
)}
</div>
</div>
</div>
);
}
function Row({ label, value, multiline }: { label: string; value: string; multiline?: boolean }) {
return (
<div className={multiline ? 'flex flex-col gap-0.5' : 'flex items-baseline gap-2'}>
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className={multiline ? 'text-foreground leading-relaxed' : 'text-foreground truncate'}>
{value}
</span>
</div>
);
}
@@ -1,101 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Sparkles, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface IntentStreamingViewProps {
text: string;
thinkingText?: string;
}
/**
* 意图解析流式视图:与 OutlineStreamingView / DraftStreamingView 同样的打字机机制,
* 颜色用蓝色主题区分意图理解阶段。展示 LLM 边吐 JSON 边滚动的过程,结果由
* IntentResultCard 接力展示结构化字段。
*/
export function IntentStreamingView({ text, thinkingText }: IntentStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
useEffect(() => {
if (preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) {
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="px-4 pb-2 space-y-2">
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{text && (
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/50 dark:bg-blue-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
意图解析中…
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-blue-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-blue-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
// ── Edit mode ─────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
<WritingToolbar editor={editor} />
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
@@ -12,11 +12,7 @@ import {
Quote,
Code,
Pilcrow,
Download,
Loader2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import {
@@ -25,7 +21,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
import { exportMarkdownToDocx } from "../../utils/export-docx";
interface ToolbarButtonProps {
onClick: () => void;
@@ -65,32 +60,11 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
interface WritingToolbarProps {
editor: Editor | null;
/** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */
markdown?: string;
/** 文档标题,用作 .docx 文件名与封面 */
title?: string;
}
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
const [exporting, setExporting] = useState(false);
export function WritingToolbar({ editor }: WritingToolbarProps) {
if (!editor) return null;
const canExport = !exporting && !!markdown?.trim();
const handleExport = async () => {
if (!canExport) return;
setExporting(true);
try {
await exportMarkdownToDocx(markdown ?? "", title);
} catch (err) {
console.error("[export-docx] failed", err);
toast.error("导出 Word 失败,请稍后重试");
} finally {
setExporting(false);
}
};
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */}
@@ -199,37 +173,8 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
{/* Right side: export + model selector */}
<div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
handleExport();
}}
disabled={!canExport}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
导出 Word
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-4" />
{/* Model selector — pushed to the right */}
<div className="ml-auto">
<CanvasModelSelector />
</div>
</div>
@@ -0,0 +1,432 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
CompletedIntervention,
ProgressEvent,
ReviewResult,
StreamingDraftSection,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
request: null,
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const handleEvent = useCallback(
(event: ProgressEvent) => {
setState((prev) => {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'research_progress':
// 素材收集专家实时进度:仅更新最新提示文案
return { ...base, researchProgress: event.message ?? '' };
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
};
case 'outline_chunk': {
const chunkText = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
status: 'writing_outline',
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
};
}
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
outlineStreamingText: '',
outlineThinkingText: '',
};
case 'draft_chunk': {
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
const existing = sections.find((s) => s.sectionIndex === idx);
if (existing) {
if (isThinking) existing.thinkingText += text;
else existing.text += text;
existing.isDone = done;
} else {
sections.push({
sectionIndex: idx,
sectionTitle: title,
text: isThinking ? '' : text,
thinkingText: isThinking ? text : '',
isDone: done,
});
}
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
};
}
case 'review_chunk': {
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
const isNewCycle = prev.status !== 'reviewing';
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
let item = items.find((it) => it.category === category);
if (!item) {
item = {
category, isDone: false,
streamingText: '', thinkingText: '',
score: 0, issues: [],
};
items.push(item);
}
if (isThinking) item.thinkingText += text;
else item.streamingText += text;
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'review_item_done': {
const category = event.reviewItemCategory ?? 'fact';
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
const item = items.find((it) => it.category === category);
if (item) {
item.isDone = true;
item.score = event.reviewItemScore ?? 80;
item.issues = event.reviewItemIssues ?? [];
} else {
// 兜底:没收到任何 chunk 直接来 done
items.push({
category, isDone: true,
streamingText: '', thinkingText: '',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
});
}
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
// when queue is empty. If we already have this pause set, nothing needs to change.
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) {
extra.materials = event.materials as AIMaterial[];
}
if (pp === 'outline_confirm' && event.outline) {
extra.outline = event.outline as AIOutline;
}
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error': {
// error 事件携带 title(如「模型错误」)+ detail(真实原因),拼成底部状态栏文案
const msg = event.detail
? `${event.title ?? '出错了'}:${event.detail}`
: (event.message ?? '未知错误');
return { ...base, status: 'error', error: msg };
}
// notice:非致命告警(ES 波动 / 模型兜底),仅追加到时间线,不改变写作状态
case 'notice':
return base;
default:
return base;
}
});
// 草稿实时同步到右侧 Artifact 面板
if (event.type === 'draft_ready' && event.draftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [
{
index: 0,
type: 'text',
title: event.draftTitle ?? '草稿',
fullMarkdown: event.draftMarkdown,
},
],
});
}
},
[setArtifact],
);
const startWriting = useCallback(
async (request: AIWritingRequest) => {
if (
state.status !== 'idle' &&
state.status !== 'done' &&
state.status !== 'error'
)
return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setState({ ...INITIAL_STATE, status: 'researching', request });
try {
const sessionId = await startWritingSession(request);
setState((prev) => ({ ...prev, sessionId }));
unsubRef.current = subscribeToProgress(
sessionId,
handleEvent,
abortRef.current.signal,
);
} catch (err) {
setState((prev) => ({
...prev,
status: 'error',
error: String(err),
}));
}
},
[state.status, handleEvent],
);
const submitIntervention = useCallback(
async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
// 提交干预前始终重连 SSE:
// - 正常流程下旧连接仍在,abort 后重建无副作用(后端通过 _session_pending_pause
// 重发 await_user,前端 handleEvent 对同一暂停点有去重);
// - 若旧连接已因服务重启 / 反向代理空闲超时而断开,这一步能确保后端恢复出的
// 会话有消费者,否则 resume 后的进度事件会堆在队列里无人接收。
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
if (state.isPaused) {
setState((prev) => ({ ...prev, isPaused: false }));
}
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') {
record.materials = state.materials;
} else if (pausePoint === 'outline_confirm') {
record.outline = state.outline ?? undefined;
} else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
payload.selectedIssueIndices !== undefined &&
state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
pendingRevisionContext = {
submittedIssues,
skippedIssues,
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// Optimistic next-status so the correct skeleton shows immediately after
// the user clicks confirm, without waiting for the first SSE chunk.
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising'; // user_revise
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
} else if (pausePoint === 'section_help') {
// 含「补充检索」→ 回检索;否则(删章节 / 通用知识续写)→ 直接回写作
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
? 'researching'
: 'writing_draft';
}
try {
await resumeWritingApi(state.sessionId, payload);
setState((prev) => ({
...prev,
currentPause: null,
status: nextStatus,
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
completedInterventions: [...prev.completedInterventions, record],
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
}));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
);
const pauseWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, isPaused: true }));
}, []);
const resumeWriting = useCallback(() => {
if (!state.sessionId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}, [state.sessionId, handleEvent]);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
}
@@ -1,814 +0,0 @@
/**
* AI 写作前端 hook —— LangGraph Server 路径(阶段 3)。
*
* 对外 API 与老 hook ``useAIWriting`` 完全一致:返回
* ``{ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }``,
* 所以 ``AIWritingPanel`` 只需要按 env 开关切 import,不用改任何组件。
*
* 底层差别(参见 ai-writing-优化.md §阶段 3):
* - 老 hook 走 ``POST /start`` + ``GET /{id}/stream`` + ``POST /{id}/resume`` 自写 SSE
* - 本 hook 用 ``@langchain/langgraph-sdk/react`` 的 ``useStream``,assistant=``ai_writing``,
* thread 和 SSE 协议都交给 LangGraph Server 管。``resume`` 改成 ``Command(resume=...)``。
*
* 事件翻译层:
* - LangGraph ``custom`` 通道(节点 ``get_stream_writer`` 推) →
* ``ProgressEvent`` { type=draft_chunk/outline_chunk/research_progress/review_chunk/review_item_done }
* - LangGraph ``updates`` 通道(节点 return 的 state delta) → 合成
* ``materials_ready`` / ``outline_ready`` / ``draft_ready`` / ``review_ready`` /
* ``notice`` / ``step_*`` (后端节点把这些塞进 ``progress_events`` 数组里 return)
* - ``stream.interrupt`` → ``await_user`` 事件
*
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
*/
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
BlockedSection,
CompletedIntervention,
ParsedIntent,
ProgressEvent,
ProgressEventType,
ReviewIssue,
ReviewResult,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
// ── INITIAL_STATE 与 reducer 与老 hook 共享 ────────────────────────────────────
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
request: null,
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
intentStreamingText: '',
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
type Action =
| { type: 'handle'; event: ProgressEvent }
| { type: 'start'; request: AIWritingRequest }
| { type: 'set_session'; sessionId: string }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'pause' }
| { type: 'resume_ui' }
| { type: 'reset' }
| { type: 'set_error'; message: string };
function reducer(prev: AIWritingState, action: Action): AIWritingState {
switch (action.type) {
case 'handle':
return handleEvent(prev, action.event);
case 'start':
return { ...INITIAL_STATE, status: 'researching', request: action.request };
case 'set_session':
return { ...prev, sessionId: action.sessionId };
case 'intervention_submitted': {
const ns = action.nextStatus;
return {
...prev,
currentPause: null,
status: ns,
...(ns === 'researching' ? { researchProgress: '' } : {}),
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
};
}
case 'pause':
return { ...prev, isPaused: true };
case 'resume_ui':
return { ...prev, isPaused: false };
case 'reset':
return INITIAL_STATE;
case 'set_error':
return { ...prev, status: 'error', error: action.message };
default:
return prev;
}
}
// 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用
// (副作用单独提到 hook 顶层 useEffect 里处理)。
function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'intent_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
intentStreamingText: isThinking ? prev.intentStreamingText : prev.intentStreamingText + text,
intentThinkingText: isThinking ? prev.intentThinkingText + text : prev.intentThinkingText,
};
}
case 'intent_ready': {
const ev = event as unknown as {
articleType?: string;
article_type?: string;
targetAudience?: string;
target_audience?: string;
wordCountTarget?: number;
word_count_target?: number;
refinedIntent?: string;
refined_intent?: string;
};
const parsed: ParsedIntent = {
articleType: ev.articleType ?? ev.article_type ?? '',
targetAudience: ev.targetAudience ?? ev.target_audience ?? '',
wordCountTarget: Number(ev.wordCountTarget ?? ev.word_count_target ?? 800),
refinedIntent: ev.refinedIntent ?? ev.refined_intent ?? '',
};
return {
...base,
parsedIntent: parsed,
intentStreamingText: '',
intentThinkingText: '',
};
}
case 'research_progress':
return { ...base, researchProgress: event.message ?? '' };
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
};
case 'outline_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
status: 'writing_outline',
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + text,
outlineThinkingText: isThinking ? prev.outlineThinkingText + text : prev.outlineThinkingText,
};
}
case 'outline_ready':
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
outlineStreamingText: '',
outlineThinkingText: '',
};
case 'draft_chunk': {
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
const existing = sections.find((s) => s.sectionIndex === idx);
if (existing) {
if (isThinking) existing.thinkingText += text;
else existing.text += text;
existing.isDone = done;
} else {
sections.push({
sectionIndex: idx,
sectionTitle: title,
text: isThinking ? '' : text,
thinkingText: isThinking ? text : '',
isDone: done,
});
}
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
};
}
case 'review_chunk': {
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const isNewCycle = prev.status !== 'reviewing';
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
let item = items.find((it) => it.category === category);
if (!item) {
item = { category, isDone: false, streamingText: '', thinkingText: '', score: 0, issues: [] };
items.push(item);
}
if (isThinking) item.thinkingText += text;
else item.streamingText += text;
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'review_item_done': {
const category = event.reviewItemCategory ?? 'fact';
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
const item = items.find((it) => it.category === category);
if (item) {
item.isDone = true;
item.score = event.reviewItemScore ?? 80;
item.issues = event.reviewItemIssues ?? [];
} else {
items.push({
category, isDone: true, streamingText: '', thinkingText: '',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
});
}
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return { ...base, reviewResult: (event.reviewResult ?? null) as ReviewResult | null };
case 'await_user': {
const pp = event.pausePoint;
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp ? (`awaiting_${pp}` as AIWritingState['status']) : prev.status;
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials;
if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline;
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error': {
const msg = event.detail
? `${event.title ?? '出错了'}:${event.detail}`
: (event.message ?? '未知错误');
return { ...base, status: 'error', error: msg };
}
case 'notice':
return base;
default:
return base;
}
}
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
return {
id: String(m.id ?? ''),
title: String(m.title ?? ''),
content: String(m.content ?? ''),
source: String(m.source ?? ''),
publishedAt: String(m.published_at ?? m.publishedAt ?? ''),
relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0),
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as 'internet' | 'intranet',
url: m.url as string | undefined,
recUuid: (m.recUuid ?? m.rec_uuid) as string | undefined,
};
}
function normalizeOutlineSection(s: Record<string, unknown>) {
return {
sectionTitle: String(s.section_title ?? s.sectionTitle ?? ''),
keyPoints: (s.key_points ?? s.keyPoints ?? []) as string[],
materialIds: (s.material_ids ?? s.materialIds ?? []) as string[],
};
}
function normalizeOutline(o: unknown): AIOutline | null {
if (!o || typeof o !== 'object') return null;
const obj = o as Record<string, unknown>;
return {
title: String(obj.title ?? ''),
sections: Array.isArray(obj.sections) ? obj.sections.map((s) => normalizeOutlineSection(s as Record<string, unknown>)) : [],
};
}
function normalizeReviewIssue(i: Record<string, unknown>): ReviewIssue {
return {
category: (i.category as ReviewIssue['category']) ?? 'fact',
severity: (i.severity as ReviewIssue['severity']) ?? 'minor',
location: String(i.location ?? ''),
issue: String(i.issue ?? ''),
suggestion: String(i.suggestion ?? ''),
};
}
function normalizeReviewResult(r: unknown): ReviewResult | null {
if (!r || typeof r !== 'object') return null;
const o = r as Record<string, unknown>;
return {
verdict: (o.verdict as 'pass' | 'reject') ?? 'pass',
factScore: Number(o.fact_score ?? o.factScore ?? 0),
logicScore: Number(o.logic_score ?? o.logicScore ?? 0),
languageScore: Number(o.language_score ?? o.languageScore ?? 0),
overallScore: Number(o.overall_score ?? o.overallScore ?? 0),
passThreshold: Number(o.pass_threshold ?? o.passThreshold ?? 75),
revisionNotes: Array.isArray(o.revision_notes ?? o.revisionNotes)
? ((o.revision_notes ?? o.revisionNotes) as Array<Record<string, unknown>>).map(normalizeReviewIssue)
: [],
};
}
function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection {
return {
sectionTitle: String(b.section_title ?? b.sectionTitle ?? ''),
reason: String(b.reason ?? ''),
};
}
/** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。 */
function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: string): ProgressEvent {
const out: Record<string, unknown> = { ...parsed, sessionId };
if ('agent_name' in out) out.agentName = out.agent_name;
if ('pause_point' in out) out.pausePoint = out.pause_point;
if (Array.isArray(out.materials)) out.materials = (out.materials as Record<string, unknown>[]).map(normalizeMaterial);
if (out.outline) out.outline = normalizeOutline(out.outline);
if (Array.isArray(out.blocked_sections)) {
out.blockedSections = (out.blocked_sections as Record<string, unknown>[]).map(normalizeBlockedSection);
}
// draft_ready: 节点 emit 的字段是 draft_markdown / draft_title
if ('draft_markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.draft_markdown;
if ('draft_title' in out && !out.draftTitle) out.draftTitle = out.draft_title;
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
if ('revision_count' in out) out.revisionCount = out.revision_count;
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
if ('category' in out) out.reviewItemCategory = out.category;
if ('score' in out) out.reviewItemScore = out.score;
if ('issues' in out && Array.isArray(out.issues)) {
out.reviewItemIssues = (out.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
}
if (!out.message) out.message = '';
return out as unknown as ProgressEvent;
}
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
const type = String(o.type ?? '');
// 后端 _save_draft_to_repo 等 helper 不走 custom 通道,type 必为节点 emit 的 chunk 类型
if (!type) return null;
const allowed: ProgressEventType[] = [
'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
];
if (!allowed.includes(type as ProgressEventType)) return null;
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
// draft_chunk / outline_chunk 用 camelCase 字段名(reducer 期望)
if ('section_index' in merged) merged.sectionIndex = merged.section_index;
if ('section_title' in merged) merged.sectionTitle = merged.section_title;
if ('is_section_done' in merged) merged.isSectionDone = merged.is_section_done;
if ('category' in merged) merged.reviewItemCategory = merged.category;
if ('score' in merged) merged.reviewItemScore = merged.score;
if (Array.isArray(merged.issues)) {
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
}
return merged as unknown as ProgressEvent;
}
/** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */
function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
if (!nodeOutput || typeof nodeOutput !== 'object') return [];
const out = nodeOutput as Record<string, unknown>;
const events: ProgressEvent[] = [];
// 节点 return 的 progress_events 数组(step_start / step_done / notice / draft_ready / outline_ready / review_ready / resumed / error)
if (Array.isArray(out.progress_events)) {
for (const e of out.progress_events as Record<string, unknown>[]) {
events.push(normalizeProgressDict(e, sessionId));
}
}
// ``material_package`` 出现 → materials_ready(节点 progress_events 里不带)
if (out.material_package && typeof out.material_package === 'object') {
const pkg = out.material_package as Record<string, unknown>;
events.push({
type: 'materials_ready',
sessionId,
message: '',
materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
keywords: (pkg.keywords ?? []) as string[],
materialSummary: (pkg.summary as string) ?? '',
});
}
// ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条
if (out.current_outline && !events.some((e) => e.type === 'outline_ready')) {
const outline = normalizeOutline(out.current_outline);
if (outline) events.push({ type: 'outline_ready', sessionId, message: '', outline });
}
// ``review_result`` 出现且 progress_events 里没 review_ready → 合成一条(带 reviewResult 字段)
if (out.review_result) {
const reviewResult = normalizeReviewResult(out.review_result);
if (reviewResult) {
// 如果 progress_events 里已有 review_ready(节点 emit 的)但字段不全,补上 reviewResult
const existing = events.find((e) => e.type === 'review_ready');
if (existing) {
existing.reviewResult = reviewResult;
} else {
events.push({ type: 'review_ready', sessionId, message: '', reviewResult });
}
}
}
// ``current_draft`` 出现 → 补全 draft_ready 的 draftMarkdown / draftTitle / revisionCount
if (out.current_draft && typeof out.current_draft === 'object') {
const d = out.current_draft as Record<string, unknown>;
const draftReady = events.find((e) => e.type === 'draft_ready');
const ev: ProgressEvent = draftReady ?? { type: 'draft_ready', sessionId, message: '' };
if (!ev.draftMarkdown) ev.draftMarkdown = (d.full_markdown ?? d.fullMarkdown ?? '') as string;
if (!ev.draftTitle) ev.draftTitle = (d.title ?? '') as string;
if (ev.revisionCount == null) ev.revisionCount = (d.revision_count ?? d.revisionCount ?? 0) as number;
if (!draftReady) events.push(ev);
}
return events;
}
/** ``stream.interrupt.value`` → await_user ProgressEvent。 */
function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
if (!value || typeof value !== 'object') return null;
const v = value as Record<string, unknown>;
return {
type: 'await_user',
sessionId,
message: String(v.message ?? ''),
pausePoint: v.pause_point as ProgressEvent['pausePoint'],
materials: Array.isArray(v.materials) ? (v.materials as Record<string, unknown>[]).map(normalizeMaterial) : undefined,
keywords: v.keywords as string[] | undefined,
materialSummary: v.summary as string | undefined,
outline: v.outline ? (normalizeOutline(v.outline) as AIOutline) : undefined,
reviewResult: v.review_result ? (normalizeReviewResult(v.review_result) as ReviewResult) : undefined,
draftTitle: v.draft_title as string | undefined,
revisionCount: v.revision_count as number | undefined,
blockedSections: Array.isArray(v.blocked_sections)
? (v.blocked_sections as Record<string, unknown>[]).map(normalizeBlockedSection)
: undefined,
};
}
// ── 主 hook ───────────────────────────────────────────────────────────────────
type AIWritingValues = {
user_intent?: string;
article_type?: string;
status?: string;
material_package?: unknown;
current_outline?: unknown;
current_draft?: unknown;
review_result?: unknown;
progress_events?: unknown[];
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
// 真实 thread_id,我们存到 state.sessionId。之后 reducer 直接读它。
const threadId = state.sessionId ?? undefined;
// 把已处理的 interrupt id 记下来,避免 useStream re-render 时重复 dispatch
const seenInterruptRef = useRef<string | null>(null);
const stream = useStream<AIWritingValues>({
client: getAPIClient(),
assistantId: 'ai_writing',
threadId,
reconnectOnMount: true,
onCreated(meta) {
// 第一次提交时拿到的 thread_id == 业务表 ai_writing_sessions.id
dispatch({ type: 'set_session', sessionId: meta.thread_id });
},
onCustomEvent(event: unknown) {
const pe = customToProgressEvent(event, state.sessionId ?? '');
if (pe) dispatch({ type: 'handle', event: pe });
},
onUpdateEvent(data: Record<string, unknown> | undefined) {
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
if (!data) return;
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
// 这里直接合成 await_user 不至于卡死 UI。
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
for (const intr of interrupts) {
const value = (intr && typeof intr === 'object' && 'value' in intr)
? (intr as { value: unknown }).value : intr;
const ev = interruptToEvent(value, state.sessionId ?? '');
if (ev) dispatch({ type: 'handle', event: ev });
}
return;
}
for (const nodeOutput of Object.values(data)) {
const events = nodeUpdatesToEvents(nodeOutput, state.sessionId ?? '');
for (const ev of events) dispatch({ type: 'handle', event: ev });
}
},
onError(err: unknown) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
},
onFinish(state) {
// graph 自然终止(END)—— 合成一条 done 事件,UI 切到 done 状态
const finalStatus = (state.values as AIWritingValues | undefined)?.status;
if (finalStatus === 'done' || finalStatus === 'error') {
dispatch({
type: 'handle',
event: {
type: finalStatus === 'error' ? 'error' : 'done',
sessionId: state.values?.sessionId as string ?? '',
message: finalStatus === 'error' ? '写作出错' : '写作完成',
},
});
}
},
});
// interrupt → await_user:useStream 每次 re-render 都会暴露当前 interrupt,
// 我们用 id 去重;老 hook 的 reducer 本身对 pausePoint 也有去重,双保险。
useEffect(() => {
const intr = stream.interrupt;
if (!intr) {
seenInterruptRef.current = null;
return;
}
const key = intr.id ?? JSON.stringify(intr.value);
if (seenInterruptRef.current === key) return;
seenInterruptRef.current = key;
const ev = interruptToEvent(intr.value, state.sessionId ?? '');
if (ev) dispatch({ type: 'handle', event: ev });
}, [stream.interrupt, state.sessionId]);
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
useEffect(() => {
if (state.currentDraftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: state.currentDraftTitle || '草稿',
fullMarkdown: state.currentDraftMarkdown,
}],
});
}
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
// ── 操作 API ────────────────────────────────────────────────────────────────
const startWriting = useCallback(async (request: AIWritingRequest) => {
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
dispatch({ type: 'start', request });
try {
await stream.submit(
{
user_intent: request.userIntent,
article_type: request.articleType,
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
revision_count: 0,
max_revisions: 3,
status: 'researching',
drafts: [],
progress_events: [],
material_package: null,
current_outline: null,
current_draft: null,
review_result: null,
last_user_intervention: null,
} as Partial<AIWritingValues>,
{
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
},
);
} catch (err) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.status, stream]);
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') record.materials = state.materials;
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// pendingRevisionContext:编辑打回卡提交时记录本轮被选中 / 跳过的意见,draft_ready 后用于生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// 乐观更新到下一阶段,跟老 hook 行为对齐
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising';
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
} else if (pausePoint === 'section_help') {
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
? 'researching' : 'writing_draft';
}
// 后端 pause 节点期望的 resume payload 字段名:保持 camelCase(节点里直接 user_input.get('action')
// / approvedMaterialIds 等),所以这里**不要**转 snake_case。
const resumePayload: Record<string, unknown> = {
action: payload.action,
};
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
if (payload.sectionDecisions !== undefined) {
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
sectionTitle: d.sectionTitle, decision: d.decision,
}));
}
seenInterruptRef.current = null; // 允许下一个 interrupt 被消费
// ⚠️ 乐观更新必须放在 await 之前 ——
// ``stream.submit(undefined, {command})`` 返回的 Promise **要等整条流跑完**
// (resume → 跑 writer_outline → 推 outline_chunk → 到达 pause_outline_confirm
// → 推新 interrupt → end) 才 resolve。这中间 useEffect(stream.interrupt) 已经
// 把 currentPause 更新到了 outline_confirm;若我们在 await 后才 dispatch
// ``intervention_submitted``(含 currentPause: null),就会把新弹的卡片擦掉,
// UI 永远卡在 writing_outline skeleton。
//
// 老 hook 没这个问题:``resumeWritingApi`` 是 POST /resume,几十毫秒就返回。
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
try {
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
});
} catch (err) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status, stream]);
const pauseWriting = useCallback(() => {
void stream.stop();
dispatch({ type: 'pause' });
}, [stream]);
const resumeWriting = useCallback(() => {
// useStream 在挂载 / threadId 变化时会自动 reconnect,不需要显式 attach。
dispatch({ type: 'resume_ui' });
}, []);
const reset = useCallback(() => {
void stream.stop();
setArtifact(null);
seenInterruptRef.current = null;
dispatch({ type: 'reset' });
}, [stream, setArtifact]);
// 与老 hook 同名导出对象
return useMemo(
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
);
}
@@ -88,6 +88,7 @@ import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import {
@@ -112,6 +113,8 @@ export default function AIWritingPage() {
<CanvasUIProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<AIWritingPageHeader />
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
@@ -84,8 +84,6 @@ export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'intent_chunk'
| 'intent_ready'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
@@ -215,14 +213,6 @@ export interface CompletedIntervention {
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
/** 意图解析最终结构化字段,来自 intent_ready 事件。 */
export interface ParsedIntent {
articleType: string;
targetAudience: string;
wordCountTarget: number;
refinedIntent: string;
}
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
@@ -244,10 +234,6 @@ export interface AIWritingState {
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 意图解析(流式 + 结果)
intentStreamingText: string;
intentThinkingText: string;
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)
@@ -1,345 +0,0 @@
/**
* Markdown → Word(.docx) 导出工具。
*
* 设计要点:
* - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。
* - 文档结构:封面标题(无 H1 时补) → 正文。
*/
import {
AlignmentType,
Document,
ExternalHyperlink,
HeadingLevel,
LevelFormat,
Packer,
Paragraph,
TextRun,
} from "docx";
import { saveAs } from "file-saver";
/** 标题层级(H1~H3) */
type HeadingLevelNum = 1 | 2 | 3;
interface ParsedHeading {
level: HeadingLevelNum;
text: string;
}
type Block =
| { kind: "heading"; level: HeadingLevelNum; text: string }
| { kind: "paragraph"; text: string }
| { kind: "blockquote"; text: string }
| { kind: "code"; code: string }
| { kind: "list"; ordered: boolean; items: string[] };
// ────────────────────────────────────────────────────────────────────────────
// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法
// ────────────────────────────────────────────────────────────────────────────
const RE_HEADING = /^(#{1,3})\s+(.*)$/;
const RE_FENCE = /^```/;
const RE_QUOTE = /^>\s?/;
const RE_UL = /^[-*]\s+/;
const RE_OL = /^\d+\.\s+/;
const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/;
function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: Block[] = [];
const headings: ParsedHeading[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
if (RE_FENCE.test(line)) {
const buf: string[] = [];
i++;
while (i < lines.length && !RE_FENCE.test(lines[i])) {
buf.push(lines[i]);
i++;
}
if (i < lines.length) i++; // 跳过闭合 ```
blocks.push({ kind: "code", code: buf.join("\n") });
continue;
}
const hm = RE_HEADING.exec(line);
if (hm) {
const level = hm[1].length as HeadingLevelNum;
const text = hm[2].trim();
headings.push({ level, text });
blocks.push({ kind: "heading", level, text });
i++;
continue;
}
if (RE_QUOTE.test(line)) {
const buf: string[] = [];
while (i < lines.length && RE_QUOTE.test(lines[i])) {
buf.push(lines[i].replace(RE_QUOTE, ""));
i++;
}
blocks.push({ kind: "blockquote", text: buf.join("\n") });
continue;
}
if (RE_UL.test(line)) {
const items: string[] = [];
while (i < lines.length && RE_UL.test(lines[i])) {
items.push(lines[i].replace(RE_UL, ""));
i++;
}
blocks.push({ kind: "list", ordered: false, items });
continue;
}
if (RE_OL.test(line)) {
const items: string[] = [];
while (i < lines.length && RE_OL.test(lines[i])) {
items.push(lines[i].replace(RE_OL, ""));
i++;
}
blocks.push({ kind: "list", ordered: true, items });
continue;
}
// 段落:合并相邻非空、非块起始行
const buf: string[] = [line];
i++;
while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) {
buf.push(lines[i]);
i++;
}
blocks.push({ kind: "paragraph", text: buf.join("\n") });
}
return { blocks, headings };
}
// ────────────────────────────────────────────────────────────────────────────
// 行内格式 → TextRun / Hyperlink
// ────────────────────────────────────────────────────────────────────────────
/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */
const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g;
type InlineChild = TextRun | ExternalHyperlink;
function parseInline(text: string): InlineChild[] {
if (!text) return [new TextRun({ text: "" })];
const out: InlineChild[] = [];
let last = 0;
let m: RegExpExecArray | null;
RE_INLINE.lastIndex = 0;
while ((m = RE_INLINE.exec(text)) !== null) {
if (m.index > last) {
out.push(new TextRun({ text: text.slice(last, m.index) }));
}
const tok = m[0];
if (tok.startsWith("**")) {
out.push(new TextRun({ text: tok.slice(2, -2), bold: true }));
} else if (tok.startsWith("`")) {
out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" }));
} else if (tok.startsWith("[")) {
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok);
if (linkMatch) {
out.push(
new ExternalHyperlink({
link: linkMatch[2],
children: [
new TextRun({
text: linkMatch[1],
color: "0563C1",
underline: {},
}),
],
}),
);
} else {
out.push(new TextRun({ text: tok }));
}
} else if (tok.startsWith("*") || tok.startsWith("_")) {
out.push(new TextRun({ text: tok.slice(1, -1), italics: true }));
}
last = RE_INLINE.lastIndex;
}
if (last < text.length) {
out.push(new TextRun({ text: text.slice(last) }));
}
return out;
}
// ────────────────────────────────────────────────────────────────────────────
// Block → Paragraph[]
// ────────────────────────────────────────────────────────────────────────────
const DOCX_HEADING: Record<HeadingLevelNum, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
};
function blockToParagraphs(b: Block): Paragraph[] {
switch (b.kind) {
case "heading": {
return [
new Paragraph({
heading: DOCX_HEADING[b.level],
children: parseInline(b.text),
}),
];
}
case "paragraph": {
return [
new Paragraph({
children: parseInline(b.text),
spacing: { line: 360, after: 120 },
}),
];
}
case "blockquote": {
return [
new Paragraph({
children: parseInline(b.text),
indent: { left: 480 },
spacing: { line: 360, after: 120 },
}),
];
}
case "code": {
return b.code.split("\n").map(
(line) =>
new Paragraph({
children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })],
}),
);
}
case "list": {
return b.items.map(
(item) =>
new Paragraph({
numbering: {
reference: b.ordered ? "ol" : "ul",
level: 0,
},
children: parseInline(item),
spacing: { line: 320 },
}),
);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// 文件名工具
// ────────────────────────────────────────────────────────────────────────────
function sanitizeFilename(name: string): string {
// Windows 非法字符 + 控制字符 → 下划线
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim();
}
function fallbackFilename(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
}
function resolveFilename(title: string | undefined): string {
const base = title ? sanitizeFilename(title) : "";
return `${base || fallbackFilename()}.docx`;
}
// ────────────────────────────────────────────────────────────────────────────
// 主入口
// ────────────────────────────────────────────────────────────────────────────
/**
* 把 markdown 文档导出为 .docx 并触发浏览器下载。
*
* @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown)
* @param title 文档标题,用作文件名与封面(空则用时间戳兜底)
*/
export async function exportMarkdownToDocx(
markdown: string,
title?: string,
): Promise<void> {
const { blocks, headings } = parseMarkdown(markdown ?? "");
// 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1
const coverText =
(title && title.trim()) ||
headings.find((h) => h.level === 1)?.text ||
"未命名文档";
const children: Paragraph[] = [];
// 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复)
const hasFirstH1 = headings[0]?.level === 1;
if (!hasFirstH1) {
children.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 240, after: 240 },
children: [new TextRun({ text: coverText, bold: true, size: 36 })],
}),
);
}
// 正文
for (const b of blocks) {
children.push(...blockToParagraphs(b));
}
const doc = new Document({
creator: "DeerFlow AI 写作",
title: coverText,
styles: {
default: {
document: {
run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑
},
},
},
numbering: {
config: [
{
reference: "ul",
levels: [
{
level: 0,
format: LevelFormat.BULLET,
text: "•",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
},
],
},
{
reference: "ol",
levels: [
{
level: 0,
format: LevelFormat.DECIMAL,
text: "%1.",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
},
],
},
],
},
sections: [{ children }],
});
const blob = await Packer.toBlob(doc);
saveAs(blob, resolveFilename(title));
}
+24 -26
View File
@@ -196,32 +196,30 @@ export default function AgentChatPage() {
</div>
</div>
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
-40
View File
@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -35,7 +34,6 @@ import { cn } from "@/lib/utils";
export default function ChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -46,7 +44,6 @@ export default function ChatPage() {
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
@@ -58,7 +55,6 @@ export default function ChatPage() {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedAgentId(null);
}
}, [isNewThread, threadId]);
@@ -109,32 +105,6 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// If the user picked an agent before sending the opening turn, hand off
// to AgentChatPage so the conversation runs against that agent. We stash
// the pending text in sessionStorage because the next/navigation shim
// doesn't carry router state across hash-route pushes. File attachments
// are bound to the current thread upload directory and would not match
// the new thread, so we skip the handoff when files are present and let
// the message run on Lead Agent instead.
if (
selectedAgentId &&
isNewThread &&
!hasStartedConversation &&
message.text &&
(!message.files || message.files.length === 0)
) {
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
);
} catch {
// sessionStorage may be unavailable in private mode; fall through.
}
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
return;
}
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
@@ -170,8 +140,6 @@ export default function ChatPage() {
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
selectedAgentId,
router,
settings.context.writing_mode,
writingArtifactPath,
],
@@ -290,14 +258,6 @@ export default function ChatPage() {
setSettings("context", context)
}
promptPrefix={promptPrefixProp}
agentSelector={
isLandingState
? {
selectedAgentId,
onAgentChange: setSelectedAgentId,
}
: undefined
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
@@ -1,133 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { ArrowRight, Loader2, NotebookPen, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { createSearchSpace } from "@/core/note/api";
import { useNoteAuth } from "@/hooks/useNoteAuth";
import { cn } from "@/lib/utils";
export default function LightAppNewNotePage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const { loading: authLoading, error: authError } = useNoteAuth();
const [titleInput, setTitleInput] = useState("");
const [creating, setCreating] = useState(false);
const hasTitle = useMemo(() => titleInput.trim().length > 0, [titleInput]);
const isBusy = authLoading || creating;
const handleCreate = async () => {
const name = titleInput.trim();
if (!name || isBusy || authError) {
return;
}
setCreating(true);
try {
const searchSpace = await createSearchSpace({ name });
toast.success(`笔记「${searchSpace.name}」创建成功`);
setTitleInput("");
} catch (error) {
const message =
error instanceof Error ? error.message : "创建笔记失败,请稍后重试";
toast.error(message);
} finally {
setCreating(false);
}
};
return (
<div
className={cn(
"flex h-full w-full items-center justify-center px-8",
isDark ? "bg-slate-900/20" : "bg-[#fdfcfc]",
)}
>
<div className="w-full max-w-[720px] -translate-y-12 space-y-8">
<div className="flex items-center gap-3">
<NotebookPen
className={cn("h-5 w-5 shrink-0", isDark ? "text-slate-400" : "text-black/70")}
/>
<h1
className={cn(
"text-base font-medium tracking-tight",
isDark ? "text-slate-200" : "text-black/85",
)}
>
新建笔记
</h1>
</div>
<div className="flex items-center gap-3">
<input
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreate();
}
}}
placeholder="新建笔记"
disabled={isBusy}
className={cn(
"min-w-0 flex-1 bg-transparent text-[42px] leading-tight font-normal tracking-tight outline-none",
hasTitle
? isDark
? "text-white"
: "text-black/85"
: isDark
? "text-slate-500 placeholder:text-slate-600"
: "text-black/25 placeholder:text-black/25",
)}
/>
{hasTitle && (
<button
type="button"
onClick={() => void handleCreate()}
disabled={isBusy || !!authError}
className={cn(
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-70",
isDark
? "bg-blue-500 text-white hover:bg-blue-400"
: "bg-sky-200 text-slate-700 hover:bg-sky-300",
)}
aria-label="创建笔记"
>
{creating || authLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
)}
</div>
<div
className={cn(
"flex h-11 items-center gap-2.5 rounded-xl px-4",
isDark
? "border border-slate-700 bg-slate-800/70"
: "border border-black/5 bg-[#ececef]",
)}
>
<Sparkles
className={cn("h-3.5 w-3.5 shrink-0", isDark ? "text-slate-400" : "text-black/55")}
/>
<p
className={cn(
"text-sm",
isDark ? "text-slate-400" : "text-black/55",
)}
>
新建笔记开始使用
{authError ? `(${authError})` : ""}
</p>
</div>
</div>
</div>
);
}
@@ -1,371 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
BookOpen,
Grid3X3,
LayoutList,
Loader2,
MoreHorizontal,
Plus,
RefreshCw,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { listSearchSpaces, type SearchSpace } from "@/core/note/api";
import { useNoteAuth } from "@/hooks/useNoteAuth";
import { cn } from "@/lib/utils";
type ViewMode = "grid" | "list";
function formatCreatedAt(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
function getSourceCount(note: SearchSpace): number {
return note.source_count ?? note.sources_used ?? 0;
}
const cardThemes = [
"bg-[#e4f2f6]",
"bg-[#f0eaf3]",
"bg-[#edf4e6]",
"bg-[#f5eadf]",
"bg-[#e9edf8]",
];
function NoteIcon({ isDark }: { isDark: boolean }) {
return (
<div
className={cn(
"flex h-10 w-8 shrink-0 items-center justify-center rounded-sm shadow-sm",
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
)}
>
<BookOpen className="h-5 w-5" />
</div>
);
}
export default function LightAppNoteListPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
const { loading: authLoading, error: authError } = useNoteAuth();
const [notes, setNotes] = useState<SearchSpace[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const loadNotes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listSearchSpaces({
owned_only: true,
limit: 1000,
order_by: "created_at",
});
setNotes(data);
} catch (err) {
const message =
err instanceof Error ? err.message : "获取笔记列表失败,请稍后重试";
setError(message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (authLoading || authError) {
return;
}
void loadNotes();
}, [authError, authLoading, loadNotes]);
const isBusy = authLoading || loading;
const createButtonClass = cn(
"group text-center transition-all hover:-translate-y-0.5",
isDark
? "border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:bg-slate-900"
: "border-slate-200 bg-white text-slate-600 shadow-sm hover:border-sky-200 hover:shadow-md",
);
const renderCreateButton = (layout: ViewMode) => {
if (layout === "grid") {
return (
<button
type="button"
onClick={() => navigate("/page/strategy/light-app/new-note")}
className={cn(
createButtonClass,
"flex h-[132px] flex-col items-center justify-center rounded-2xl border border-dashed",
)}
>
<span
className={cn(
"mb-3 flex h-9 w-9 items-center justify-center rounded-full transition-colors",
isDark
? "bg-slate-800 text-sky-300 group-hover:bg-slate-700"
: "bg-sky-50 text-sky-500 group-hover:bg-sky-100",
)}
>
<Plus className="h-5 w-5" />
</span>
<span className="text-base font-medium">新建笔记本</span>
</button>
);
}
return (
<button
type="button"
onClick={() => navigate("/page/strategy/light-app/new-note")}
className={cn(
createButtonClass,
"flex w-full items-center gap-4 rounded-2xl border border-dashed px-5 py-4",
)}
>
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors",
isDark
? "bg-slate-800 text-sky-300 group-hover:bg-slate-700"
: "bg-sky-50 text-sky-500 group-hover:bg-sky-100",
)}
>
<Plus className="h-5 w-5" />
</span>
<span className="text-base font-medium">新建笔记本</span>
</button>
);
};
const renderNoteCard = (note: SearchSpace, index: number) => (
<article
key={note.id}
className={cn(
"group relative overflow-hidden rounded-2xl p-4 shadow-sm ring-1 ring-black/5 transition-all hover:-translate-y-0.5 hover:shadow-lg",
isDark
? "h-[132px] bg-slate-900 text-slate-100 ring-white/10"
: cn("h-[132px]", cardThemes[index % cardThemes.length]),
)}
>
<button
type="button"
className={cn(
"absolute right-3 top-3 rounded-full p-1.5 opacity-80 transition-colors",
isDark ? "bg-slate-800/80 text-slate-300" : "bg-white/70 text-slate-500",
)}
aria-label="更多"
>
<MoreHorizontal className="h-4 w-4" />
</button>
<NoteIcon isDark={isDark} />
<h2 className="max-w-[130px] truncate text-xl font-semibold tracking-tight">
{note.name}
</h2>
<p className={cn("mt-1 text-xs", isDark ? "text-slate-400" : "text-slate-600")}>
{formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源
</p>
</article>
);
const renderNoteRow = (note: SearchSpace) => (
<article
key={note.id}
className={cn(
"group flex items-center gap-4 rounded-2xl px-5 py-4 shadow-sm ring-1 transition-all hover:-translate-y-0.5 hover:shadow-md",
isDark
? "bg-slate-900 text-slate-100 ring-white/10"
: "bg-white ring-black/5",
)}
>
<NoteIcon isDark={isDark} />
<div className="min-w-0 flex-1">
<h2 className="truncate text-lg font-semibold tracking-tight">{note.name}</h2>
<p className={cn("mt-1 text-sm", isDark ? "text-slate-400" : "text-slate-500")}>
{formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源
</p>
</div>
<button
type="button"
className={cn(
"shrink-0 rounded-full p-2 opacity-80 transition-colors",
isDark ? "text-slate-400 hover:bg-slate-800" : "text-slate-500 hover:bg-slate-100",
)}
aria-label="更多"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</article>
);
return (
<div
className={cn(
"h-full w-full overflow-y-auto px-10 py-10",
isDark ? "bg-slate-950" : "bg-[#fdfcfc]",
)}
>
<div className="mx-auto w-full max-w-[1120px] space-y-7">
<div className="flex items-center justify-between gap-4">
<h1
className={cn(
"text-[32px] font-semibold tracking-tight",
isDark ? "text-slate-100" : "text-slate-950",
)}
>
我的笔记本
</h1>
<div className="flex items-center gap-2">
<div
className={cn(
"flex rounded-xl p-1",
isDark ? "bg-slate-900" : "bg-white shadow-sm ring-1 ring-black/5",
)}
>
<button
type="button"
onClick={() => setViewMode("grid")}
className={cn(
"rounded-lg p-2 transition-colors",
viewMode === "grid"
? isDark
? "bg-slate-800 text-slate-100"
: "bg-slate-100 text-slate-900"
: isDark
? "text-slate-500 hover:text-slate-300"
: "text-slate-400 hover:text-slate-600",
)}
aria-label="网格视图"
aria-pressed={viewMode === "grid"}
>
<Grid3X3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={cn(
"rounded-lg p-2 transition-colors",
viewMode === "list"
? isDark
? "bg-slate-800 text-slate-100"
: "bg-slate-100 text-slate-900"
: isDark
? "text-slate-500 hover:text-slate-300"
: "text-slate-400 hover:text-slate-600",
)}
aria-label="列表视图"
aria-pressed={viewMode === "list"}
>
<LayoutList className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => void loadNotes()}
disabled={isBusy || !!authError}
className={cn(
"rounded-xl p-2 shadow-sm ring-1 ring-black/5 transition-colors disabled:cursor-not-allowed disabled:opacity-60",
isDark
? "bg-slate-900 text-slate-300 hover:bg-slate-800"
: "bg-white text-slate-600 hover:bg-slate-50",
)}
aria-label="刷新"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
</div>
{authError && (
<div
className={cn(
"rounded-xl border px-4 py-3 text-sm",
isDark
? "border-red-500/30 bg-red-500/10 text-red-300"
: "border-red-200 bg-red-50 text-red-600",
)}
>
{authError}
</div>
)}
{error && !authError && (
<div
className={cn(
"rounded-xl border px-4 py-3 text-sm",
isDark
? "border-red-500/30 bg-red-500/10 text-red-300"
: "border-red-200 bg-red-50 text-red-600",
)}
>
{error}
</div>
)}
{isBusy && !authError && (
<div className="flex items-center justify-center py-24">
<Loader2
className={cn("h-6 w-6 animate-spin", isDark ? "text-slate-400" : "text-black/40")}
/>
</div>
)}
{!isBusy && !authError && !error && viewMode === "grid" && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
{renderCreateButton("grid")}
{notes.map((note, index) => renderNoteCard(note, index))}
{notes.length === 0 && (
<div
className={cn(
"flex h-[132px] items-center justify-center rounded-2xl border text-sm",
isDark
? "border-slate-800 bg-slate-900/50 text-slate-500"
: "border-slate-100 bg-white/70 text-slate-400",
)}
>
暂无笔记
</div>
)}
</div>
)}
{!isBusy && !authError && !error && viewMode === "list" && (
<div className="space-y-3">
{renderCreateButton("list")}
{notes.map((note) => renderNoteRow(note))}
{notes.length === 0 && (
<div
className={cn(
"rounded-2xl border px-6 py-10 text-center text-sm",
isDark
? "border-slate-800 bg-slate-900/50 text-slate-500"
: "border-slate-100 bg-white/70 text-slate-400",
)}
>
暂无笔记
</div>
)}
</div>
)}
</div>
</div>
);
}
+2 -12
View File
@@ -1,14 +1,10 @@
"use client";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
isSidebarEnabledFromSearch,
persistSidebarEnabled,
} from "@/core/page-layout/sidebar-enabled";
const DEFAULT_USERNAME = "guest";
@@ -17,11 +13,6 @@ type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const searchParams = useSearchParams();
const sidebarEnabled = useMemo(
() => isSidebarEnabledFromSearch(searchParams.toString()),
[searchParams],
);
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
@@ -41,7 +32,6 @@ export default function LoginPage() {
return;
}
setStoredAuth(auth);
persistSidebarEnabled(sidebarEnabled);
setStatus("success");
router.replace("/page/workspace/chats/new");
} catch (error) {
@@ -59,7 +49,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [resolvedUsername, router, sidebarEnabled]);
}, [resolvedUsername, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
+1 -1
View File
@@ -139,7 +139,7 @@ export default function NewAgentPage() {
if (threadPrepared) {
return;
}
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
await getAPIClient().threads.create({ threadId });
setThreadPrepared(true);
}, [threadId, threadPrepared]);
+9 -9
View File
@@ -1,21 +1,22 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
const SIDEBAR_ENABLED = false;
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
return (
<div className="h-screen flex bg-muted/20">
{sidebarEnabled && (
{SIDEBAR_ENABLED && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
@@ -23,10 +24,10 @@ function PageLayout() {
)}
<div
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
!SIDEBAR_ENABLED ? "" : collapsed ? "pl-16" : "pl-64"
}`}
>
{sidebarEnabled && <Header />}
{SIDEBAR_ENABLED && <Header />}
<main className="flex-1 overflow-hidden">
<Routes>
<Route index element={<Navigate to="workspace" replace />} />
@@ -35,9 +36,8 @@ function PageLayout() {
element={
<WorkspaceRoutes
pageSidebarCollapsed={collapsed}
pageSidebarHidden={!sidebarEnabled}
headerHidden={!sidebarEnabled}
compactWorkspaceSidebar={sidebarEnabled}
pageSidebarHidden={!SIDEBAR_ENABLED}
headerHidden={!SIDEBAR_ENABLED}
/>
}
/>
+302 -592
View File
@@ -1,39 +1,15 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Check,
Download,
FileText,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Download, FileText, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
@@ -43,7 +19,6 @@ import { cn } from "@/lib/utils";
type PublicTask = {
task_id: string;
name: string;
description?: string | null;
schedule_text: string;
cron_expr?: string;
timezone: string;
@@ -60,9 +35,6 @@ type PublicRunFile = {
mime_type?: string | null;
kind?: string | null;
size?: number | null;
/** Server-reported "is the archive file still on disk?" flag. Older responses
* may omit this — treat missing as available. */
available?: boolean;
};
type PublicRunResult = {
@@ -107,8 +79,8 @@ function publicBaseUrl() {
return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
const body = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
@@ -126,41 +98,8 @@ async function fetchPublicRunFile(runId: string, index: number): Promise<PublicF
);
}
async function updatePublicRunFile(
runId: string,
index: number,
content: string,
): Promise<void> {
await fetchJson(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
}
async function updatePublicRunBody(runId: string, content: string): Promise<void> {
await fetchJson(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/body`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
}
async function deletePublicRun(runId: string): Promise<void> {
const response = await fetch(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`,
{ method: "DELETE" },
);
if (!response.ok && response.status !== 204) {
const body = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
}
function publicRunDownloadUrl(runId: string): string {
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/download`;
}
function publicRunFileDownloadUrl(runId: string, index: number): string {
@@ -203,52 +142,18 @@ function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?:
);
}
function isFileAvailable(file: PublicRunFile): boolean {
// Older responses omit `available`; treat that as still available so we
// don't accidentally hide content from a backend that hasn't shipped the
// flag yet.
return file.available !== false;
}
function stripExtension(name: string): string {
if (!name) return name;
const dot = name.lastIndexOf(".");
if (dot <= 0) return name;
return name.slice(0, dot);
}
/**
* Strip a trailing ` - YYYY-MM-DD HH:MM[:SS]` (or `_YYYY-MM-DD ...`) suffix
* that the agent appends to run titles. The scheduled-for column is already
* displayed separately below the title, so the duplicated date looks like noise.
*/
function stripTimestampSuffix(title: string): string {
if (!title) return title;
const cleaned = title.replace(
/[\s_]*[-_]?\s*\d{4}-\d{1,2}-\d{1,2}[\sT]+\d{1,2}:\d{2}(?::\d{2})?\s*$/,
"",
);
return cleaned.trim() || title;
}
const BODY_TAB_VALUE = "__body__";
/**
* Public, unauthenticated scheduled-task viewer.
*
* URL: `#/public/scheduled-tasks/<task_id>`
*
* Left pane lists every run of the task (latest first). Right pane uses tabs
* to switch between the inline body (`正文`) and each Markdown attachment.
* Markdown attachments take priority — when they exist, the first one is the
* default tab. Each tab supports inline editing (saved via the public PUT
* endpoints) and a per-tab Word download.
* Left pane lists every run of the task (latest first). Selecting a run shows
* its Markdown body on the right by default; the user can switch tabs to view
* generated files or raw metadata.
*/
export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>();
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null);
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", taskId],
@@ -259,51 +164,57 @@ export default function PublicScheduledTaskPage() {
refetchInterval: 8000,
});
const deleteRunMutation = useMutation({
mutationFn: (runId: string) => deletePublicRun(runId),
onSuccess: async (_data, runId) => {
toast.success("已删除");
if (selectedRunId === runId) {
setSelectedRunId(null);
}
setPendingDeleteRun(null);
await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "删除失败");
},
});
// Public viewer only surfaces runs that have actual content to show:
// pending/running/failed are hidden, and succeeded runs with neither a body
// nor a Markdown attachment fall out too (nothing to render).
const runs = useMemo(() => {
const all = data?.runs ?? [];
return all.filter((run) => {
if (run.status !== "succeeded") return false;
const result = run.result;
if (!result) return false;
const hasBody = !!result.content && result.content.trim().length > 0;
const hasMarkdownFile = (result.files ?? []).some(
(file) => isMarkdownFile(file) && isFileAvailable(file),
);
return hasBody || hasMarkdownFile;
});
}, [data]);
const runs = useMemo(() => data?.runs ?? [], [data]);
const selectedRun = useMemo(
() => runs.find((r) => r.id === selectedRunId) ?? null,
[runs, selectedRunId],
);
// Files attached to the result that are Markdown. When at least one is
// present we render ONLY those — body / meta panels are deliberately hidden
// so the public viewer focuses on the deliverable doc.
const markdownFiles = useMemo(
() => (selectedRun?.result?.files ?? []).filter(isMarkdownFile),
[selectedRun],
);
const hasMarkdownFiles = markdownFiles.length > 0;
useEffect(() => {
if (!selectedRunId && runs.length > 0) {
setSelectedRunId(runs[0]!.id);
setSelectedRunId(runs[0].id);
}
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
setSelectedRunId(runs[0]!.id);
setSelectedRunId(runs[0].id);
}
}, [runs, selectedRunId]);
const handleDownloadResult = async () => {
if (!selectedRun) return;
const a = document.createElement("a");
a.href = publicRunDownloadUrl(selectedRun.id);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
};
const handleDownloadBodyAsWord = async () => {
if (!selectedRun?.result?.content) {
toast.error("当前记录正文为空");
return;
}
try {
await downloadMarkdownAsDocx(
selectedRun.result.content,
selectedRun.result.title || "scheduled-task-result",
selectedRun.result.title || undefined,
);
toast.success("Word 文件已生成");
} catch (err) {
toast.error(err instanceof Error ? err.message : "生成 Word 失败");
}
};
if (!taskId) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
@@ -332,486 +243,285 @@ export default function PublicScheduledTaskPage() {
const task = data?.task;
return (
<TooltipProvider delayDuration={150}>
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-1">
<div className="flex items-start justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="line-clamp-2 text-base leading-snug break-all">
{task?.name || "任务"}
</CardTitle>
</TooltipTrigger>
{task?.name && (
<TooltipContent side="bottom" className="max-w-[320px] break-all">
{task.name}
</TooltipContent>
)}
</Tooltip>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
{task?.enabled ? "启用" : "暂停"}
</Badge>
</div>
{task?.description && task.description.trim() && (
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
{task.description.trim()}
</p>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-2 p-3 pt-0">
{runs.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
暂无可查看的执行记录。
</div>
)}
{runs.map((run) => {
const active = run.id === selectedRunId;
const runRunning = run.status === "running" || run.status === "pending";
const runTitle = stripTimestampSuffix(
run.result?.title || formatDate(run.scheduled_for),
);
const isDeletingThis =
deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
return (
<div
key={run.id}
role="button"
tabIndex={0}
className={cn(
"bg-card hover:bg-accent/60 group rounded-md border p-3 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active && "border-primary bg-accent",
)}
onClick={() => setSelectedRunId(run.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedRunId(run.id);
}
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-sm leading-snug font-medium break-all"
title={runTitle}
>
{runTitle}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[360px] break-all">
{runTitle}
</TooltipContent>
</Tooltip>
<div className="text-muted-foreground mt-1 text-xs">
执行时间:{formatDate(run.scheduled_for)}
</div>
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-1">
<div className="flex items-center justify-between gap-2">
<CardTitle className="truncate text-base">
{task?.name || "任务"}
</CardTitle>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
{task?.enabled ? "启用" : "暂停"}
</Badge>
</div>
<p className="text-muted-foreground truncate text-xs">
{task?.schedule_text} · {task?.timezone}
</p>
<p className="text-muted-foreground text-xs">
最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)}
</p>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-2 p-3 pt-0">
{runs.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
这个任务还没有执行记录。
</div>
)}
{runs.map((run) => {
const active = run.id === selectedRunId;
const runRunning = run.status === "running" || run.status === "pending";
return (
<button
key={run.id}
className={cn(
"bg-card hover:bg-accent/60 rounded-md border p-3 text-left transition-colors",
active && "border-primary bg-accent",
)}
onClick={() => setSelectedRunId(run.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Badge
variant={statusVariant(run.status)}
className={cn(
"shrink-0",
runRunning && "border-primary/30 bg-primary/10 text-primary",
)}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7"
disabled={isDeletingThis}
onClick={(event) => {
event.stopPropagation();
setPendingDeleteRun({ id: run.id, title: runTitle });
}}
title="删除记录"
>
{isDeletingThis ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</Button>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)}
</div>
</div>
<Badge
variant={statusVariant(run.status)}
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
</button>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
{selectedRun ? (
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
) : (
<CardContent className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
请在左侧选择一条执行记录。
</CardContent>
)}
</Card>
</div>
<Dialog
open={pendingDeleteRun !== null}
onOpenChange={(open) => {
if (!open && !deleteRunMutation.isPending) {
setPendingDeleteRun(null);
}
}}
>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>删除执行记录</DialogTitle>
<DialogDescription>
确认删除这条执行记录吗?此操作不可恢复。
</DialogDescription>
</DialogHeader>
{pendingDeleteRun && (
<div className="bg-muted/40 text-foreground rounded-md border px-3 py-2 text-sm break-all">
{pendingDeleteRun.title}
<Card className="flex min-h-0 flex-col">
<CardHeader className="flex flex-wrap items-center justify-between gap-2 space-y-0">
<div className="min-w-0">
<CardTitle className="truncate text-base">
{selectedRun?.result?.title || "请选择一条执行记录"}
</CardTitle>
{selectedRun && (
<p className="text-muted-foreground mt-1 text-xs">
开始:{formatDate(selectedRun.started_at)} · 完成:
{formatDate(selectedRun.finished_at)}
</p>
)}
</div>
{selectedRun?.result && (
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={handleDownloadResult}>
<Download className="size-4" />
下载结果
</Button>
{!hasMarkdownFiles && selectedRun.result.content && (
<Button
size="sm"
variant="outline"
onClick={handleDownloadBodyAsWord}
>
<FileText className="size-4" />
下载 Word
</Button>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setPendingDeleteRun(null)}
disabled={deleteRunMutation.isPending}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => {
if (pendingDeleteRun) {
deleteRunMutation.mutate(pendingDeleteRun.id);
}
}}
disabled={deleteRunMutation.isPending}
>
{deleteRunMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{selectedRun?.result || selectedRun?.error ? (
hasMarkdownFiles ? (
// Result has MD files — show ONLY those. Body, meta, and other
// attachments are intentionally hidden so the public viewer is
// focused on the deliverable document.
<ScrollArea className="size-full">
<div className="p-3">
<MarkdownFilesPane runId={selectedRun.id} files={markdownFiles} />
</div>
</ScrollArea>
) : (
// No MD files — fall back to the body Markdown.
<ScrollArea className="size-full">
<div className="p-4">
{selectedRun.error && (
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
{selectedRun.error}
</div>
)}
{selectedRun.result?.content ? (
selectedRun.result.format === "markdown" ||
selectedRun.result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={selectedRun.result.content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{selectedRun.result.content}
</pre>
)
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
无正文内容。
</div>
)}
</div>
</ScrollArea>
)
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
请在左侧选择一条执行记录。
</div>
)}
</CardContent>
</Card>
</div>
);
}
type ContentTab = {
/** Stable tab identifier — `__body__` for inline content, `file-<index>` for files. */
value: string;
/** Short label rendered in the TabsTrigger. */
label: string;
/** Distinguishes body vs MD attachment so the editor knows which PUT to call. */
kind: "body" | "file";
/** File index (when `kind === "file"`). */
fileIndex?: number;
/** Filename for downloads (defaults to the run title for body). */
fileName?: string;
};
/**
* Right-pane controller for a single run: builds the tab list (body + MDs),
* fetches each tab's source text, and hosts the view/edit toggle.
* Markdown-only result viewer used by the public page.
*
* Whenever a run has at least one .md file attached, this is the **only**
* thing the right pane shows — body text, metadata, and non-Markdown
* attachments are all hidden by the parent. We render each .md file inline
* as rich Markdown so anonymous viewers can read it without downloading.
* If multiple .md files exist a small switcher at the top lets the user
* pick which one is rendered; the others are not exposed.
*/
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
const queryClient = useQueryClient();
function MarkdownFilesPane({
runId,
files,
}: {
runId: string;
files: PublicRunFile[];
}) {
const [contents, setContents] = useState<Record<number, PublicFileContent>>({});
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
// Skip files whose archive is missing on disk — otherwise the public viewer
// would render a tab that 404s with "Result file is no longer available".
const markdownFiles = useMemo(
() =>
(run.result?.files ?? []).filter(
(file) => isMarkdownFile(file) && isFileAvailable(file),
),
[run.result?.files],
);
// Tabs: every MD file first (priority per spec), then the inline body.
const tabs: ContentTab[] = useMemo(() => {
const list: ContentTab[] = markdownFiles.map((file, idx) => ({
value: `file-${file.index}`,
// Show the actual filename without the `.md` suffix — the user explicitly
// asked for "no '文件' label, just the name without extension".
label: stripExtension(file.name) || `文件 ${idx + 1}`,
kind: "file",
fileIndex: file.index,
fileName: file.name,
}));
list.push({
value: BODY_TAB_VALUE,
label: "正文",
kind: "body",
fileName: run.result?.title || "scheduled-task-result",
});
return list;
}, [markdownFiles, run.result?.title]);
const [activeTab, setActiveTab] = useState<string>(tabs[0]?.value ?? BODY_TAB_VALUE);
// When the run's MD-file set shifts (rare) — fall back to the first tab.
useEffect(() => {
if (!tabs.some((t) => t.value === activeTab)) {
setActiveTab(tabs[0]?.value ?? BODY_TAB_VALUE);
}
}, [tabs, activeTab]);
// Fetch each MD file's text once. Keyed by file index inside the run.
const [fileTexts, setFileTexts] = useState<Record<number, string>>({});
const [fileLoading, setFileLoading] = useState<Record<number, boolean>>({});
const loadFile = useCallback(
async (fileIndex: number) => {
setFileLoading((s) => ({ ...s, [fileIndex]: true }));
let cancelled = false;
async function loadAll() {
if (files.length === 0) {
setContents({});
return;
}
setLoading(true);
try {
const file = await fetchPublicRunFile(run.id, fileIndex);
const text = file.previewable && file.content != null ? file.content : "";
setFileTexts((s) => ({ ...s, [fileIndex]: text }));
} catch {
// Race: the archive may have disappeared between list and fetch. Fall
// back to empty content; the parent filter will drop this tab on the
// next refetch tick.
setFileTexts((s) => ({ ...s, [fileIndex]: "" }));
const all = await Promise.all(
files.map((f) => fetchPublicRunFile(runId, f.index)),
);
if (!cancelled) {
const map: Record<number, PublicFileContent> = {};
for (const item of all) map[item.index] = item;
setContents(map);
}
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : "加载文件内容失败");
}
} finally {
setFileLoading((s) => ({ ...s, [fileIndex]: false }));
if (!cancelled) setLoading(false);
}
},
[run.id],
);
}
void loadAll();
return () => {
cancelled = true;
};
}, [runId, files]);
// Pull every MD file on first render — keeps tab-switching instant.
useEffect(() => {
for (const file of markdownFiles) {
if (fileTexts[file.index] == null && !fileLoading[file.index]) {
void loadFile(file.index);
}
if (activeIndex == null && files.length > 0) {
setActiveIndex(files[0]!.index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [markdownFiles, run.id]);
// Editor state — separate from displayed source so users can cancel.
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<string>("");
const activeTabMeta = useMemo(
() => tabs.find((t) => t.value === activeTab) ?? tabs[0],
[tabs, activeTab],
);
const activeSource = useMemo(() => {
if (!activeTabMeta) return "";
if (activeTabMeta.kind === "body") return run.result?.content ?? "";
return fileTexts[activeTabMeta.fileIndex!] ?? "";
}, [activeTabMeta, run.result?.content, fileTexts]);
// Reset the editor state any time the tab changes underneath us.
useEffect(() => {
setIsEditing(false);
setDraft(activeSource);
}, [activeTabMeta?.value, activeSource]);
const saveMutation = useMutation({
mutationFn: async () => {
if (!activeTabMeta) return;
if (activeTabMeta.kind === "body") {
await updatePublicRunBody(run.id, draft);
} else {
await updatePublicRunFile(run.id, activeTabMeta.fileIndex!, draft);
}
},
onSuccess: async () => {
if (activeTabMeta?.kind === "file") {
setFileTexts((s) => ({ ...s, [activeTabMeta.fileIndex!]: draft }));
}
toast.success("已保存");
setIsEditing(false);
// Force a refetch so the left-list run row picks up the change too.
await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "保存失败");
},
});
const handleDownloadWord = useCallback(async () => {
if (!activeTabMeta) return;
const markdown = isEditing ? draft : activeSource;
if (!markdown.trim()) {
toast.error("当前内容为空");
return;
if (activeIndex != null && !files.some((f) => f.index === activeIndex)) {
setActiveIndex(files[0]?.index ?? null);
}
const fileName = activeTabMeta.fileName || run.result?.title || "scheduled-task-result";
const title = activeTabMeta.kind === "body" ? run.result?.title : undefined;
try {
await downloadMarkdownAsDocx(markdown, fileName, title);
toast.success("Word 文件已生成");
} catch (err) {
toast.error(err instanceof Error ? err.message : "生成 Word 失败");
}
}, [activeTabMeta, draft, isEditing, activeSource, run.result?.title]);
}, [activeIndex, files]);
const isFileTab = activeTabMeta?.kind === "file";
const fileIndex = activeTabMeta?.fileIndex;
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
const showEmptyHint = !activeSource && !fileIsLoading;
if (files.length === 0) {
return null;
}
const activeFile =
activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null;
const activeContent = activeFile ? contents[activeFile.index] : null;
return (
<>
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="line-clamp-2 text-base leading-snug break-all">
{run.result?.title || "执行记录"}
</CardTitle>
</TooltipTrigger>
{run.result?.title && (
<TooltipContent side="bottom" className="max-w-[420px] break-all">
{run.result.title}
</TooltipContent>
)}
</Tooltip>
<p className="text-muted-foreground mt-1 text-xs">
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{isFileTab && fileIndex != null && (
<a
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
href={publicRunFileDownloadUrl(run.id, fileIndex)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-4" />
下载原文件
</a>
)}
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" />
下载 Word
</Button>
{isEditing ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setDraft(activeSource);
}}
disabled={saveMutation.isPending}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
<div className="flex flex-col gap-3">
{files.length > 1 && (
<div className="flex flex-wrap gap-2 border-b pb-2">
{files.map((file) => {
const active = file.index === activeIndex;
return (
<button
key={file.index}
onClick={() => setActiveIndex(file.index)}
className={cn(
"rounded-md border px-3 py-1 text-xs transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "bg-card hover:bg-accent/60",
)}
保存
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setDraft(activeSource);
setIsEditing(true);
}}
disabled={fileIsLoading}
>
<Pencil className="size-4" />
编辑
</Button>
)}
>
<FileText className="mr-1 inline size-3" />
{file.name}
</button>
);
})}
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{tabs.length === 0 ? (
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full min-h-0 flex-col gap-0 px-4 pb-3"
)}
{activeFile && (
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">
{activeFile.name}
{typeof activeFile.size === "number" ? ` · ${activeFile.size} bytes` : ""}
</div>
<a
className="bg-background hover:bg-accent inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs"
href={publicRunFileDownloadUrl(runId, activeFile.index)}
target="_blank"
rel="noopener noreferrer"
>
<TabsList
variant="line"
className="bg-card sticky top-0 z-10 h-auto flex-wrap justify-start gap-1 border-b py-1"
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="data-[state=active]:bg-accent/60 max-w-[220px]"
title={tab.label}
>
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex min-h-0 flex-1 flex-col pt-3">
{run.error && (
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
{run.error}
</div>
)}
{fileIsLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件内容...
</div>
) : isEditing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="min-h-0 flex-1 resize-none font-mono text-sm"
spellCheck={false}
/>
) : (
<ScrollArea className="min-h-0 flex-1">
{showEmptyHint ? (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
无内容。点击「编辑」开始添加。
</div>
) : (
<MarkdownContent
className="max-w-none pr-2"
content={activeSource}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
)}
</ScrollArea>
)}
</div>
</Tabs>
)}
</CardContent>
</>
<Download className="size-3" />
下载
</a>
</div>
)}
{loading && !activeContent && (
<div className="text-muted-foreground text-sm">正在加载内容...</div>
)}
{activeContent ? (
activeContent.previewable && activeContent.content ? (
<MarkdownContent
className="max-w-none"
content={activeContent.content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
无法预览此 Markdown 文件,请下载查看。
</div>
)
) : !loading ? (
<div className="text-muted-foreground text-sm">未加载到内容。</div>
) : null}
</div>
);
}
+8 -26
View File
@@ -78,7 +78,6 @@ function displayUsername(value?: string | null): string {
const emptyForm = {
name: "",
description: "",
schedule: "",
timezone: "Asia/Shanghai",
prompt: "",
@@ -117,13 +116,9 @@ export default function ScheduledTasksPage() {
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) => {
// Newest task first. Fall back to next_run_at so tasks that lack a
// created_at (older rows / subscribed tasks) still order consistently.
const aKey = a.created_at ?? a.next_run_at ?? "";
const bKey = b.created_at ?? b.next_run_at ?? "";
return String(bKey).localeCompare(String(aKey));
}),
[...tasks].sort((a, b) =>
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
),
[tasks],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
@@ -167,7 +162,6 @@ export default function ScheduledTasksPage() {
setEditing(task);
setForm({
name: task.name,
description: task.description ?? "",
schedule: task.schedule_text,
timezone: task.timezone,
prompt: task.prompt,
@@ -196,7 +190,6 @@ export default function ScheduledTasksPage() {
if (creating) {
const task = await actions.create.mutateAsync({
name: form.name || "定时任务",
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt,
@@ -209,7 +202,6 @@ export default function ScheduledTasksPage() {
taskId: editing.task_id,
data: {
name: form.name,
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone,
prompt: form.prompt,
@@ -332,11 +324,9 @@ export default function ScheduledTasksPage() {
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug break-all">
{task.name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate font-medium">{task.name}</div>
{isRunningNow && (
<Badge
variant="secondary"
@@ -361,7 +351,7 @@ export default function ScheduledTasksPage() {
{task.schedule_text} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
<Badge variant={task.enabled ? "default" : "secondary"}>
{task.enabled ? "启用" : "暂停"}
</Badge>
</div>
@@ -770,14 +760,6 @@ export default function ScheduledTasksPage() {
}
placeholder="任务名称"
/>
<Textarea
className="min-h-16"
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: event.target.value }))
}
placeholder="任务描述(可选,将显示在公开页标题下方)"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -804,7 +786,7 @@ export default function ScheduledTasksPage() {
<SelectContent>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<SelectItem key={agent.name} value={agent.name}>
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
</SelectItem>
+2 -82
View File
@@ -12,20 +12,9 @@ import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import NewAgentPage from "./NewAgentPage";
import AgentChatPage from "./AgentChatPage";
import SkillsPage from "./SkillsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import MemoryPage from "./MemoryPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
export default function StrategyRoutes() {
return (
@@ -38,10 +27,6 @@ export default function StrategyRoutes() {
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
@@ -69,11 +54,6 @@ export default function StrategyRoutes() {
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
@@ -85,74 +65,14 @@ export default function StrategyRoutes() {
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
{/* 设置 */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/strategy/chats" replace />
)
}
/>
+2 -20
View File
@@ -31,20 +31,17 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
export function WorkspaceLayout({
children,
pageSidebarCollapsed,
pageSidebarHidden = false,
headerHidden = false,
compactWorkspaceSidebar = false,
}: {
children: ReactNode;
pageSidebarCollapsed: boolean;
pageSidebarHidden?: boolean;
headerHidden?: boolean;
compactWorkspaceSidebar?: boolean;
}) {
const pageSidebarWidth = pageSidebarHidden
? "0rem"
@@ -66,7 +63,7 @@ export function WorkspaceLayout({
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen
>
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
@@ -88,19 +85,12 @@ export default function WorkspaceRoutes({
pageSidebarCollapsed = false,
pageSidebarHidden = false,
headerHidden = false,
compactWorkspaceSidebar = false,
}: {
pageSidebarCollapsed?: boolean;
pageSidebarHidden?: boolean;
headerHidden?: boolean;
compactWorkspaceSidebar?: boolean;
}) {
const layoutProps = {
pageSidebarCollapsed,
pageSidebarHidden,
headerHidden,
compactWorkspaceSidebar,
};
const layoutProps = { pageSidebarCollapsed, pageSidebarHidden, headerHidden };
return (
<RouteBaseProvider value="/page/workspace">
@@ -178,14 +168,6 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
+18 -30
View File
@@ -1,29 +1,31 @@
import React, { useState } from 'react';
import React 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';
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
return (
<>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
<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">
<div className="relative">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
<input
type="text"
placeholder="搜索场景、智能体、任务..."
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
isDark
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
}`}
/>
</div>
</div>
{/* 右侧 - 用户信息和通知 */}
@@ -42,24 +44,11 @@ const Header: React.FC = () => {
</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="通知"
>
<button className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<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'
}`}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
</button>
{/* 分隔线 */}
@@ -82,7 +71,6 @@ const Header: React.FC = () => {
</button>
</div>
</header>
</>
);
};
+1 -1
View File
@@ -58,4 +58,4 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
# PYTHON_PACKAGE_INDEX_URL=http://your-python-mirror/simple
# PYTHON_EXTRA_INDEX_URL=
# PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4
MYSQL_DATABASE_URL=mysql+asyncmy://kland:kland%4088124932@192.168.0.123:3306/deerflow?charset=utf8mb4
+1 -3
View File
@@ -233,7 +233,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags` and `featured_order`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed); `GET /selector` - homepage agent picker (single admin-curated list, visibility-filtered, sorted by `featured_order` asc); `PUT /{id}/featured` - **admin** sets/clears the homepage `featured_order` (must be `published=true` to feature; passing `order: null` clears unconditionally); `PUT /featured/order` - **admin** bulk-reorders the homepage list (body `{agent_ids: [...]}` → indices become 0..N-1) |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
@@ -260,8 +260,6 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
### Portable Column Types (`deerflow/persistence/types.py`)
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
@@ -108,31 +108,25 @@ async def _delete_checkpoint_thread(checkpointer, thread_id: str) -> None:
)
async def _get_active_session_ids_langgraph(thread_store) -> set[str]:
"""新路径下「正在跑 / 等用户干预」的 thread_id 集合。
def _get_active_session_ids() -> set[str]:
"""返回内存里**当前正在跑**的 session_id 集合。
``threads_meta.status`` 由 ``start_run`` / ``run_agent`` 维护:
- ``running``:graph 正在跑(节点中)
- ``interrupted``:graph 暂停在 interrupt 点等用户回头填卡
这两种都视作活跃,**绝不能删**。``idle / success / error`` 才是真正终止状态。
LangGraph Server 路径 + 老路径的 lead_agent 都用同一份 ``thread_store``,所以
这里拿的 active 集合是全部 graph 的;筛 ai_writing 是按 assistant_id 过滤。
这些会话的写作流程还活着 —— 任何清理都不能动它们,否则用户「正在写」
的稿子会被秒删,体验灾难。延迟到下一轮清理(24 小时后)再处理也比误
删强。
"""
if thread_store is None:
return set()
try:
running = await thread_store.search(status="running", user_id=None, limit=1000)
interrupted = await thread_store.search(status="interrupted", user_id=None, limit=1000)
# 延迟 import 避免循环依赖:cleanup 模块在 lifespan 启动时被 import,
# routers/ai_writing 也被 include_router 主动 import,两者拓扑无环但
# 还是放函数里更稳。
from app.gateway.routers.ai_writing import _event_queues
return set(_event_queues.keys())
except Exception:
logger.warning("ai_writing_cleanup: 读取 LangGraph 活动 thread 列表失败", exc_info=True)
# 兜底:拿不到就当作没有活动会话(最差就是清掉一个刚跑完的会话,
# 比误删正在写的要好得多 —— 用户至少能从 DB 历史里看到结果)
logger.warning("ai_writing_cleanup: 读取活动会话列表失败", exc_info=True)
return set()
# 仅关心 assistant_id='ai_writing' 的 thread —— 别的 graph 不归这套清理管
active: set[str] = set()
for row in running + interrupted:
if row.get("assistant_id") == "ai_writing":
active.add(row["thread_id"])
return active
async def run_cleanup_once(
@@ -140,27 +134,18 @@ async def run_cleanup_once(
retention_days: int,
only_finished: bool = False,
batch_size: int = 500,
checkpointer=None,
thread_store=None,
) -> dict:
"""执行一轮清理。返回统计信息。
Args:
checkpointer: 主 LangGraph checkpointer(``app.state.checkpointer``)。
AI 写作的 thread 状态由 LangGraph Server 注入,存在这里 —— admin 路由
+ scheduler loop 都会从 ``app.state`` 取到引用传进来;测试场景下传 None
会让 checkpoint 删除变成 no-op,但 DB 行仍会被清。
thread_store: ``app.state.thread_store``。用来按 ``status in (running,
interrupted)`` 查活跃 thread 列表做活跃保护。
**安全保证**:清理永远跳过仍在跑 / 等用户干预的会话(``threads_meta.status
in (running, interrupted)`` + ``assistant_id='ai_writing'``)。即使 admin
手动触发 ``retention_days=0`` 也强制生效。
**安全保证**:清理永远跳过内存里仍在跑的会话(``_event_queues`` 里有
对应 key 的)—— 这条规则即使在 admin 手动触发清理时也强制生效,防止
把用户「正在写」的稿子误删。
幂等 + 可重入 —— 多次调用对结果只会越扫越干净。即便清理途中被中断,下次
再跑会重新把 DB 里仍然过期的行扫到(checkpointer 删一个已不存在的 thread
是 no-op)。
"""
from deerflow.agents.ai_writing.graph import get_ai_writing_checkpointer
from deerflow.persistence.ai_writing_sessions import AIWritingSessionRepository
from deerflow.persistence.engine import get_session_factory
@@ -170,7 +155,8 @@ async def run_cleanup_once(
return {"deleted_db": 0, "deleted_checkpoints": 0, "skipped_active": 0}
repo = AIWritingSessionRepository(sf)
active_ids = await _get_active_session_ids_langgraph(thread_store)
checkpointer = get_ai_writing_checkpointer()
active_ids = _get_active_session_ids()
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
logger.info(
@@ -252,16 +238,11 @@ def _next_cron_delay_seconds(cron_expr: str, base: datetime | None = None) -> fl
return max(1.0, (nxt - base).total_seconds())
async def cleanup_scheduler_loop(app=None) -> None:
async def cleanup_scheduler_loop() -> None:
"""常驻后台任务:按 cron 调度跑清理。
多 worker 部署下文件锁做 leader election,没拿到锁的 worker 直接退出。
被 cancel 时 CancelledError 不会被 except Exception 吞掉,能干净退出。
Args:
app: FastAPI 实例,用来从 ``app.state`` 拿主 checkpointer / thread_store
引用,喂给 ``run_cleanup_once`` —— 新路径下 ai_writing 的 thread 状态
在主 checkpointer 里。``app=None`` 时回退到老 checkpointer(仅老路径)。
"""
from deerflow.config.app_config import get_app_config
from deerflow.config.runtime_paths import runtime_home
@@ -308,17 +289,10 @@ async def cleanup_scheduler_loop(app=None) -> None:
logger.info("ai_writing_cleanup: 下一次清理 %.0f 秒后", delay)
await asyncio.sleep(delay)
try:
# 每次循环重新读 app.state —— 不缓存,避免 lifespan 中重启 / 重置
# checkpointer 时拿到死引用。app=None 时 main_checkpointer / store
# 也为 None,run_cleanup_once 内部走老 fallback。
main_checkpointer = getattr(getattr(app, "state", None), "checkpointer", None)
main_thread_store = getattr(getattr(app, "state", None), "thread_store", None)
await run_cleanup_once(
retention_days=retention_days,
only_finished=only_finished,
batch_size=batch_size,
checkpointer=main_checkpointer,
thread_store=main_thread_store,
)
except Exception:
logger.exception("ai_writing_cleanup: 本轮清理失败(不影响下一轮)")
@@ -1,10 +1,8 @@
import asyncio
import logging
import os
import queue
import sys
import threading
from collections.abc import AsyncGenerator
import asyncio
import logging
import os
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dotenv import load_dotenv
@@ -36,6 +34,7 @@ from app.gateway.routers import (
mcp,
memory,
models,
multi_llm,
notifications,
public_scheduled_tasks,
recommended_questions,
@@ -73,6 +72,12 @@ logger = logging.getLogger(__name__)
# firing signals into a worker that is stuck waiting for shutdown cleanup.
_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
# AI 写作 in-flight task 收尾的最大等待时间。一个 graph 片段通常在几秒内
# 跑到下一个 interrupt 或结束,但单段草稿生成走完整个 LLM 可能耗时较久,
# 给 30 秒兜底;超时则 cancel 强制结束,已落盘的 checkpoint 仍可用于重启
# 后恢复,丢的只是最后一段 in-flight 但未 checkpoint 的进度。
_AI_WRITING_DRAIN_TIMEOUT_SECONDS = 30.0
async def _ensure_admin_user(app: FastAPI) -> None:
"""Startup hook: handle first boot and migrate orphan threads otherwise.
@@ -247,7 +252,8 @@ 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
import asyncio
from pathlib import Path
from alembic import command
from alembic.config import Config
@@ -260,51 +266,18 @@ 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)")
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
def _run_sync() -> None:
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")
try:
await asyncio.get_running_loop().run_in_executor(None, _run_sync)
logger.info("Database migrations applied (head)")
except Exception:
logger.exception("Database migration failed — schema may be out of date")
@@ -399,10 +372,11 @@ async def _curator_scheduler_loop() -> None:
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
维护 run_id → SSE 订阅者的内存 map。多 worker 下,用户在 worker A 发起
``/runs/stream``、稍后在 worker B 调 join/resume,会因 in-process map 互相
看不见而 404 / 卡死。
AI 写作 + 主 agent 流式响应都依赖**进程内**的内存状态字典
(``_event_queues``、``_session_pending_pause`` 等)。多 worker 下,
用户的 /start /stream /resume 会被 OS 随机分配到不同 worker,
per-worker 的内存字典互相看不见 → SSE 事件丢失、会话「凭空消失」、
resume 走不通。
所以这里在 startup 打 ERROR 强提醒。**不抛异常 fail-fast** —— 万一
运维确实知道自己在干嘛(比如配了 sticky session),不应该阻止启动。
@@ -476,18 +450,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# 预热 recall_budget cache(从 DB 批量加载,使 get_effective_memory_config 同步可读)
try:
from deerflow.config.memory_config import load_recall_budget_cache
_mc_store = getattr(app.state, "memory_config_store", None)
if _mc_store is not None:
_recall_budgets = await _mc_store.load_all()
load_recall_budget_cache(_recall_budgets)
logger.info("Memory config cache warmed: %d user override(s)", len(_recall_budgets))
except Exception:
logger.exception("Memory config cache warm-up failed (non-fatal)")
# Ensure admin user exists (auto-create on first boot)
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
await _ensure_admin_user(app)
@@ -513,7 +475,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop())
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
@@ -565,9 +527,32 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("AI writing cleanup scheduler shutdown error")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
# ``langgraph_runtime()`` 的 contextmanager 统一关闭,无需在这里特殊处理。
# 等待所有 in-flight 写作 graph task 跑到下一个 interrupt / 结束。
# 这是「写作流程能完整继续」的关键 —— 如果直接关 checkpointer
# connection,正在跑 astream 的 task 会在写 checkpoint 时抛错,
# 已经生成但还没落盘的进度全丢。
try:
from app.gateway.routers.ai_writing import wait_for_inflight_graph_tasks
await wait_for_inflight_graph_tasks(timeout=_AI_WRITING_DRAIN_TIMEOUT_SECONDS)
except Exception:
logger.exception("Wait for in-flight AI writing tasks failed (non-fatal)")
# 关闭 AI 写作 graph 持有的 checkpointer 连接(aiosqlite Connection / pg pool)
try:
from deerflow.agents.ai_writing.graph import shutdown_ai_writing_graph
await asyncio.wait_for(
shutdown_ai_writing_graph(),
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"AI writing checkpointer shutdown exceeded %.1fs; proceeding with worker exit.",
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
)
except Exception:
logger.exception("Failed to shutdown AI writing checkpointer")
try:
scheduler_service = getattr(app.state, "scheduled_task_service", None)
@@ -812,6 +797,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# AI Writing multi-agent API is mounted at /api/ai-writing
app.include_router(ai_writing.router)
# Multi-LLM routing agent API is mounted at /api/multi-llm
app.include_router(multi_llm.router)
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
@@ -33,6 +33,7 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/redoc",
"/openapi.json",
"/api/public/",
"/api/multi-llm/",
)
# Exact auth paths that are public (login/register/status check).
@@ -37,17 +37,12 @@ def should_check_csrf(request: Request) -> bool:
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return False
path = request.url.path
stripped = path.rstrip("/")
path = request.url.path.rstrip("/")
# Exempt /api/v1/auth/me endpoint
if stripped == "/api/v1/auth/me":
if path == "/api/v1/auth/me":
return False
# Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES).
# These intentionally have no cookie session, so the Double-Submit Cookie
# pattern is both impossible (no token to compare) and unnecessary (no
# session for an attacker to ride). Without this exemption every public
# PUT/POST returns 403 even though auth was bypassed earlier in the chain.
if path.startswith("/api/public/"):
# Exempt multi-llm streaming endpoint (API-to-API calls, no browser session)
if path.startswith("/api/multi-llm"):
return False
return True
@@ -130,24 +130,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.thread_share_store = make_thread_share_store(sf)
from deerflow.persistence.ai_writing_sessions import (
make_ai_writing_session_store,
register_default_store as _register_ai_writing_default_store,
)
from deerflow.persistence.ai_writing_sessions import make_ai_writing_session_store
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
# 让 LangGraph Server 路径下的 AI 写作节点能跨 app/harness 边界拿到 repo。
# 老 /start /stream /resume 路由仍走 request.app.state,互不影响。
_register_ai_writing_default_store(app.state.ai_writing_session_store)
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
from deerflow.persistence.memory_config import make_user_memory_config_store
app.state.memory_config_store = make_user_memory_config_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -271,14 +261,6 @@ def get_article_type_store(request: Request):
return getattr(request.app.state, "article_type_store", None)
def get_memory_config_store(request: Request):
"""Return the per-user memory config store."""
val = getattr(request.app.state, "memory_config_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Memory config store not available")
return val
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -1,3 +1,3 @@
from . import ai_writing, artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
from . import ai_writing, artifacts, assistants_compat, mcp, models, multi_llm, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
__all__ = ["ai_writing", "artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
__all__ = ["ai_writing", "artifacts", "assistants_compat", "mcp", "models", "multi_llm", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
@@ -27,7 +27,6 @@ from app.gateway.deps import (
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.paths import get_paths
from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
@@ -146,29 +145,36 @@ class AdminMemoryEntryReplaceRequest(BaseModel):
class AdminMemoryResponse(BaseModel):
user_id: str
version: str = Field(default="v2", description="记忆版本:v1 或 v2")
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
def _read_memory_bucket(user_id: str, agent_id: str | None) -> dict:
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict."""
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict.
``agent_id is None`` -> the user's ``USER.md`` (profile, cross-agent);
otherwise that agent's ``MEMORY.md`` (per-agent working notes).
"""
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=agent_id)
target = "user" if agent_id is None else "memory"
return {"entries": provider.read(target)}
def _read_v1_memory_bucket(user_id: str, agent_id: str | None) -> dict:
"""Read one V1 memory.json into its raw dict (facts / user / history)."""
provider = _build_v1_provider(user_id, agent_id)
return provider.read_v1_data()
@router.get("/{user_id}/memory", response_model=AdminMemoryResponse)
async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse:
"""Return every memory bucket for one user (V1 or V2 depending on config)."""
"""Return every memory bucket for one user.
Memory V2 layout: the ``global`` bucket is ``USER.md`` (user profile);
each per-agent bucket is that agent's ``MEMORY.md`` working notes.
Agent list is sourced from the database (all agents owned by this user)
so agents without any existing MEMORY.md still appear.
"""
await _require_admin(request)
buckets: list[AdminMemoryBucket] = [
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
]
# Source agents from the database: only agents owned by this specific user.
agent_store = get_agent_store(request)
all_agents = await agent_store.list_visible(user_id)
@@ -177,39 +183,16 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
key=lambda a: (a.get("name") or a["id"]).lower(),
)
if _is_v1(user_id):
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
buckets: list[AdminMemoryBucket] = []
try:
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data=_read_v1_memory_bucket(user_id, None)))
except Exception:
buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data={}))
for agent in owned_agents:
agent_id: str = agent["id"]
display_name: str = agent.get("name") or agent_id
try:
data = _read_v1_memory_bucket(user_id, agent_id)
except Exception:
data = {}
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
return AdminMemoryResponse(user_id=user_id, version="v1", buckets=buckets)
# V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
buckets = [
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
]
for agent in owned_agents:
agent_id = agent["id"]
display_name = agent.get("name") or agent_id
agent_id: str = agent["id"]
display_name: str = agent.get("name") or agent_id
try:
data = _read_memory_bucket(user_id, agent_id)
except Exception:
data = {"entries": []}
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
return AdminMemoryResponse(user_id=user_id, version="v2", buckets=buckets)
return AdminMemoryResponse(user_id=user_id, buckets=buckets)
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
@@ -283,133 +266,3 @@ async def admin_delete_memory_entry(
raise HTTPException(status_code=500, detail="Failed to delete memory entry") from exc
bucket_name = "global" if agent is None else agent
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, agent))
# ── V1 Memory CRUD (admin) ─────────────────────────────────────────────────
_V1_USER_FIELDS = {"workContext", "personalContext", "topOfMind"}
_V1_HISTORY_FIELDS = {"recentMonths", "earlierContext", "longTermBackground"}
class AdminV1SectionUpdateRequest(BaseModel):
section_type: str = Field(..., description="'user' 或 'history'")
field: str = Field(..., description="字段名,如 workContext / recentMonths")
summary: str = Field(..., description="新的摘要文本")
agent: str | None = Field(None, description="Agent id;None 表示用户级全局记忆")
class AdminV1FactAddRequest(BaseModel):
content: str = Field(..., min_length=1)
category: str = Field(default="context")
confidence: float = Field(default=0.8, ge=0.0, le=1.0)
agent: str | None = Field(None)
class AdminV1FactUpdateRequest(BaseModel):
content: str | None = Field(None)
category: str | None = Field(None)
confidence: float | None = Field(None, ge=0.0, le=1.0)
agent: str | None = Field(None)
@router.put("/{user_id}/memory/v1/sections", response_model=AdminMemoryBucket)
async def admin_update_v1_section(
user_id: str,
body: AdminV1SectionUpdateRequest,
request: Request,
) -> AdminMemoryBucket:
"""更新 V1 记忆的某个摘要字段(用户画像 / 历史)。"""
await _require_admin(request)
if body.section_type == "user" and body.field not in _V1_USER_FIELDS:
raise HTTPException(status_code=400, detail=f"无效 user 字段:{body.field}")
if body.section_type == "history" and body.field not in _V1_HISTORY_FIELDS:
raise HTTPException(status_code=400, detail=f"无效 history 字段:{body.field}")
if body.section_type not in ("user", "history"):
raise HTTPException(status_code=400, detail="section_type 须为 'user' 或 'history'")
from deerflow.agents.memory.v1.storage import get_memory_storage, utc_now_iso_z
storage = get_memory_storage()
data = storage.load(body.agent, user_id=user_id)
data.setdefault(body.section_type, {})[body.field] = {
"summary": body.summary,
"updatedAt": utc_now_iso_z(),
}
if not storage.save(data, body.agent, user_id=user_id):
raise HTTPException(status_code=500, detail="写入失败")
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
@router.post("/{user_id}/memory/v1/facts", response_model=AdminMemoryBucket)
async def admin_add_v1_fact(
user_id: str,
body: AdminV1FactAddRequest,
request: Request,
) -> AdminMemoryBucket:
"""新增一条 V1 事实记录。"""
await _require_admin(request)
try:
from deerflow.agents.memory.v1.updater import create_memory_fact
create_memory_fact(
body.content,
category=body.category,
confidence=body.confidence,
agent_name=body.agent,
user_id=user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail="新增失败") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
@router.put("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
async def admin_update_v1_fact(
user_id: str,
fact_id: str,
body: AdminV1FactUpdateRequest,
request: Request,
) -> AdminMemoryBucket:
"""更新一条 V1 事实记录(内容 / 类别 / 置信度)。"""
await _require_admin(request)
try:
from deerflow.agents.memory.v1.updater import update_memory_fact
update_memory_fact(
fact_id,
content=body.content,
category=body.category,
confidence=body.confidence,
agent_name=body.agent,
user_id=user_id,
)
except KeyError:
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail="更新失败") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, body.agent))
@router.delete("/{user_id}/memory/v1/facts/{fact_id}", response_model=AdminMemoryBucket)
async def admin_delete_v1_fact(
user_id: str,
fact_id: str,
request: Request,
agent: str | None = Query(None, description="Agent id;None 表示用户级全局记忆"),
) -> AdminMemoryBucket:
"""删除一条 V1 事实记录。"""
await _require_admin(request)
try:
from deerflow.agents.memory.v1.updater import delete_memory_fact
delete_memory_fact(fact_id, agent_name=agent, user_id=user_id)
except KeyError:
raise HTTPException(status_code=404, detail=f"事实 '{fact_id}' 不存在")
except Exception as exc:
raise HTTPException(status_code=500, detail="删除失败") from exc
bucket_name = "global" if agent is None else agent
return AdminMemoryBucket(name=bucket_name, data=_read_v1_memory_bucket(user_id, agent))
@@ -45,11 +45,6 @@ class AgentResponse(BaseModel):
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
default=None,
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
)
class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents."""
@@ -81,28 +76,6 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
class AgentSelectorResponse(BaseModel):
"""Response for the homepage agent selector dropdown.
The list is fully curated by admins (``featured_order`` on each row).
Non-admin users get a read-only view of the same list.
"""
agents: list[AgentResponse] = Field(
default_factory=list,
description="Admin-curated agents, sorted by featured_order ascending.",
)
class AgentFeaturedRequest(BaseModel):
"""Request body for setting/clearing an agent's homepage featured rank."""
order: int | None = Field(
default=None,
description="Featured rank. Lower sorts first. Pass null to remove the agent from the homepage selector.",
)
def _require_agents_api_enabled() -> None:
"""Reject access unless the custom-agent management API is explicitly enabled."""
if not get_agents_api_config().enabled:
@@ -206,45 +179,7 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
logger.exception("Failed to sync legacy folder-backed agents")
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
"""Lazily restore an agent's on-disk artifacts from its DB record.
The DB row is authoritative for the agent's existence. If the matching
``{base_dir}/agents/{id}/`` directory is missing (data dir moved between
machines, filesystem wiped, etc.) we recreate it with a minimal
``config.yaml`` + empty ``SOUL.md`` synthesized from the row. This restores
a usable agent without requiring the admin to manually delete + recreate.
"""
agent_id = record.get("id")
if not agent_id:
return
try:
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return
logger.warning(
"Agent dir missing for '%s' — rebuilding minimal config.yaml + SOUL.md from DB",
agent_id,
)
_write_agent_config(
agent_id,
name=record.get("name") or agent_id,
description=record.get("description") or "",
)
_write_agent_soul(agent_id, "")
except Exception:
# Self-heal is best-effort. ``load_agent_config`` already tolerates a
# missing dir, so the worst case is the agent runs with default
# settings until someone re-edits it.
logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
def _record_to_response(
record: dict[str, Any],
*,
include_soul: bool = False,
) -> AgentResponse:
_ensure_agent_dir_from_record(record)
def _record_to_response(record: dict[str, Any], *, include_soul: bool = False) -> AgentResponse:
soul: str | None = None
if include_soul:
soul = load_agent_soul(record["id"]) or ""
@@ -264,7 +199,6 @@ def _record_to_response(
model=extras["model"],
tool_groups=extras["tool_groups"],
skills=extras["skills"],
featured_order=record.get("featured_order"),
)
@@ -350,32 +284,6 @@ async def check_agent_name(name: str) -> dict[str, Any]:
return {"available": True, "name": normalized}
@router.get(
"/agents/selector",
response_model=AgentSelectorResponse,
summary="Agent Homepage Selector",
description=(
"Return the admin-curated agent list shown on the new-chat homepage. "
"Sorted by ``featured_order`` ascending. Visibility-filtered, so an "
"agent the caller can't see (author un-published) is silently omitted."
),
)
async def get_agent_selector(request: Request) -> AgentSelectorResponse:
_require_agents_api_enabled()
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
user_id = _current_user_id(request)
records = await store.list_featured(user_id)
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records])
except HTTPException:
raise
except Exception as e:
logger.error("Failed to load agent selector: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to load agent selector: {str(e)}") from e
@router.get(
"/agents/{agent_id}",
response_model=AgentResponse,
@@ -389,8 +297,7 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
record = await store.get_visible(agent_id, _current_user_id(request))
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
return _record_to_response(record, include_soul=True)
@@ -607,91 +514,3 @@ async def delete_agent(agent_id: str, request: Request) -> None:
except Exception as e:
logger.error("Failed to delete agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
response_model=AgentResponse,
summary="Set Agent Featured Rank (admin)",
description=(
"Admin-only. Set or clear the homepage-selector featured rank for any agent. "
"Requires the agent to be ``published=true`` when a non-null rank is supplied; "
"passing ``order: null`` removes the agent from the selector and is always allowed."
),
)
async def set_agent_featured(
agent_id: str,
body: AgentFeaturedRequest,
request: Request,
) -> AgentResponse:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can set the homepage featured rank")
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
record = await store.get_any(agent_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
if body.order is not None and not record.get("published"):
raise HTTPException(status_code=409, detail="Only published agents can be featured on the homepage")
updated = await store.set_featured(agent_id, body.order)
if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
return _record_to_response(updated, include_soul=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to set featured for agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class AgentFeaturedReorderRequest(BaseModel):
"""Request body for admin to bulk-reorder the homepage featured list."""
agent_ids: list[str] = Field(
default_factory=list,
description=(
"Featured agent ids in the desired top→bottom display order. Ids not "
"currently featured are silently skipped, so the client can send the "
"current visible order without first reconciling with the server."
),
)
@router.put(
"/agents/featured/order",
status_code=204,
summary="Reorder Featured Agents (admin)",
description=(
"Admin-only. Bulk-assign ``featured_order`` to each id in the order received "
"(``0`` is topmost). After this, the homepage selector renders agents in "
"exactly the supplied order. Ids not already featured are ignored."
),
)
async def reorder_featured_agents(
body: AgentFeaturedReorderRequest,
request: Request,
) -> None:
_require_agents_api_enabled()
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can reorder the homepage selector")
try:
store = get_agent_store(request)
for index, agent_id in enumerate(body.agent_ids):
# ``set_featured`` is a no-op for unknown ids — it returns None and
# we move on without raising, matching the docstring.
await store.set_featured(agent_id, index)
logger.info("Admin reordered featured agents (%d ids)", len(body.agent_ids))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
@@ -1,26 +1,31 @@
"""AI 写作路由 —— 业务表 CRUD + transcript 持久化 + admin cleanup。
写作主流程(``/start /stream /resume /state``)已迁到 LangGraph Server 的
``ai_writing`` assistant(前端走 ``useAIWritingStream`` hook + SDK),路由层
不再承载 graph 推进,只保留以下「不依赖 graph 状态」的辅助接口:
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
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 查询当前流程状态
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 保存完整对话时间线
GET /api/ai-writing/article-types 列出所有文章类型
POST /api/ai-writing/article-types 新增文章类型
PATCH /api/ai-writing/article-types/{id} 更新文章类型
DELETE /api/ai-writing/article-types/{id} 删除文章类型
POST /api/ai-writing/admin/cleanup (admin)手动清理会话
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from langgraph.types import Command
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@@ -29,6 +34,38 @@ router = APIRouter(prefix="/api/ai-writing", tags=["ai-writing"])
# ── 请求/响应模型 ─────────────────────────────────────────────────────────────
class StartWritingRequest(BaseModel):
user_intent: str
article_type: str = "analysis"
target_audience: str = "普通读者"
word_count_target: int = 800
model_name: str = ""
# 素材检索关键词数量:用户指定用几个关键词检索(1~10,默认 6)
keyword_count: int = 6
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
writing_mode: str = "loose"
class StartWritingResponse(BaseModel):
session_id: str
message: str = "写作流程已启动"
class ResumeRequest(BaseModel):
action: str
pause_point: str = ""
approved_material_ids: list[str] = Field(default_factory=list)
extra_keywords: list[str] = Field(default_factory=list)
edited_outline: Optional[dict] = None
outline_feedback: str = ""
user_revision_notes: str = ""
override_verdict: bool = False
selected_issue_indices: Optional[list[int]] = None
# 素材不足求助点:每个被阻塞章节的处理决策
# [{"sectionTitle": "...", "decision": "supplement|delete|loose"}]
section_decisions: list[dict] = Field(default_factory=list)
class RenameSessionRequest(BaseModel):
title: str
@@ -48,8 +85,8 @@ class AdminCleanupRequest(BaseModel):
class AdminCleanupResponse(BaseModel):
deleted_db: int
deleted_checkpoints: int
# 因「仍在跑 / 等用户干预」被保护掉的会话数(threads_meta.status in
# running/interrupted)。即使 retention_days=0 也不会秒杀用户正在写的稿。
# 因「仍在跑」被保护掉的会话数。永远跳过 _event_queues 里活跃的会话,
# 即使 retention_days=0 也不会秒杀用户正在写的稿。
skipped_active: int = 0
elapsed_seconds: float
params: dict
@@ -105,7 +142,244 @@ class SessionResponse(BaseModel):
updated_at: str
# ── 内部状态 ──────────────────────────────────────────────────────────────────
# session_id → asyncio.Queue
_event_queues: dict[str, asyncio.Queue] = {}
# session_id → model_name(resume 时复用)
_session_model_names: dict[str, str] = {}
# session_id → AIWritingSessionRepository(for DB saves)
_session_repos: dict[str, object] = {}
# session_id → user_id (str | None, already resolved)
_session_user_ids: dict[str, str | None] = {}
# session_id → accumulated intervention records
_session_interventions: dict[str, list] = {}
# session_id → True if session ended with error
_session_error_flags: dict[str, bool] = {}
# session_id → valid_article_types(list[dict] with key/label)
_session_article_types: dict[str, list[dict]] = {}
# session_id → pending await_user event tuple for SSE reconnect re-delivery
# asyncio.Queue is consume-once; if the old SSE generator dequeued the await_user event
# before the client disconnected, the event is lost from the queue. We save a copy here
# so that a new SSE connection (e.g., after user clicks "继续") can re-inject it.
_session_pending_pause: dict[str, tuple] = {}
# Serializes session recovery so that a concurrent /stream + /resume on the same
# lost session don't each rebuild a different queue object.
_recovery_lock = asyncio.Lock()
# 在飞的 graph 推进任务(每次 /start 和 /resume 都会塞一个进来)。
# Lifespan shutdown 时会等这些 task 跑到下一个 interrupt / 结束,再关闭
# checkpointer 连接 —— 否则中途关 connection,task 内部的 sqlite 写入会抛
# 错,导致 LLM 已经生成但还没落 checkpoint 的进度丢失。
_inflight_graph_tasks: set[asyncio.Task] = set()
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str:
"""组装一帧 SSE。
JSON 序列化对 progress_events 来说理论上不会失败(节点里塞的都是 str/dict
/list/int 这类基础类型),但一旦塞进了 datetime / bytes / 自定义对象,
``json.dumps`` 会抛 TypeError → SSE 生成器爆掉 → 客户端再也收不到任何
事件,包括关键的 await_user 和 done。
这里用 ``default=str`` 兜底任何「带 __str__」的对象;如果连 ``str()``
都炸了,最后还兜一次手写一条 error 帧推出去 —— 写作流程「能继续」比
「漂亮地序列化」更重要。
"""
try:
payload = json.dumps(data, ensure_ascii=False, default=str)
except Exception as exc:
logger.exception("ai_writing: SSE payload 序列化失败 event=%s", event)
payload = json.dumps(
{
"session_id": data.get("session_id") if isinstance(data, dict) else None,
"message": f"事件序列化失败(已降级): {type(exc).__name__}",
},
ensure_ascii=False,
)
event = "notice" # 不要硬塞回原 event,万一是 error 会污染语义
return f"event: {event}\ndata: {payload}\n\n"
def _ai_writing_config() -> dict:
"""读取 config.yaml 的 ai_writing 段(含素材检索 provider 配置)。"""
try:
from deerflow.config import get_app_config
aw = getattr(get_app_config(), "ai_writing", None)
if isinstance(aw, dict):
return aw
return dict(aw) if aw else {}
except Exception:
return {}
def _make_config(session_id: str, model_name: str = "", valid_article_types: list[dict] | None = None) -> dict:
return {
"configurable": {
"thread_id": session_id,
"model_name": model_name or _session_model_names.get(session_id, ""),
"ai_writing_config": _ai_writing_config(),
# 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk
"sse_queue": _event_queues.get(session_id),
"sse_session_id": session_id,
# 动态文章类型列表,intent_parser 从此处读取有效枚举
"valid_article_types": valid_article_types or _session_article_types.get(session_id),
}
}
def _truncate_title(text: str, max_len: int = 50) -> str:
text = text.strip()
return text[:max_len] + ("…" if len(text) > max_len else "")
def _spawn_graph_task(coro) -> asyncio.Task:
"""启一个 graph 推进 task 并登记到 _inflight_graph_tasks。
用 ``add_done_callback`` 在完成时自动反登记 —— 不管 task 正常结束、抛
异常、还是被 cancel,都会清理;这样 _inflight_graph_tasks 不会泄漏。
"""
task = asyncio.create_task(coro)
_inflight_graph_tasks.add(task)
task.add_done_callback(_inflight_graph_tasks.discard)
return task
async def wait_for_inflight_graph_tasks(timeout: float) -> tuple[int, int]:
"""Lifespan shutdown 入口:阻塞等待所有 in-flight graph task 收尾。
每个 ``_stream_graph_segment`` 跑到下一个 ``interrupt()`` 或 ``END`` 就
会自然返回;通常只需要等几秒到几十秒。超时后剩下的会被 ``cancel()``,
但 cancel 完仍要等它们 ack 退出(带极短的 grace),否则下一步关
checkpointer 时还会撞上活的协程。
Returns:
(drained, cancelled): 自然结束的数量、被 cancel 强制结束的数量
"""
if not _inflight_graph_tasks:
return 0, 0
pending = set(_inflight_graph_tasks)
initial = len(pending)
logger.info("ai_writing: 等待 %d 个 in-flight graph task 收尾(timeout=%.1fs)", initial, timeout)
done, still_running = await asyncio.wait(pending, timeout=timeout)
drained = len(done)
cancelled = 0
if still_running:
logger.warning(
"ai_writing: 仍有 %d 个 task 超时未结束,强制 cancel", len(still_running)
)
for t in still_running:
t.cancel()
cancelled += 1
# 给 cancel 一点 ack 时间,避免下游关连接时撞活协程
await asyncio.wait(still_running, timeout=2.0)
logger.info("ai_writing: in-flight 收尾完成 drained=%d cancelled=%d", drained, cancelled)
return drained, cancelled
# ── 路由 ──────────────────────────────────────────────────────────────────────
@router.post("/start", response_model=StartWritingResponse)
async def start_writing(body: StartWritingRequest, request: Request):
"""启动写作流程,返回 session_id。
**失败兜底契约**:本接口除非 Pydantic 校验 body 失败(422),其他任何
后端组件(DB / checkpointer / 文章类型仓库 / 用户解析)失败都不会导致
/start 返回 5xx。哪怕兜底走的是「极简内存模式」也要先把 session_id
交给前端,让它能起 SSE 接到 error 事件,避免 UI 卡死在 loading 状态。
"""
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
from app.gateway.deps import get_ai_writing_session_store, get_article_type_store, get_current_user
# get_ai_writing_graph() 内部已对 sqlite/postgres 失败做了 InMemorySaver
# 降级,理论上永不抛。但 build_ai_writing_graph 拼图结构也可能因下游
# 节点 import 失败抛错 —— 这种情况 graph 完全不可用,必须早 fail,前端
# 弹错处理。
try:
graph = await get_ai_writing_graph()
except Exception as e:
logger.exception("ai_writing: graph 构建彻底失败,无法启动")
raise HTTPException(
status_code=503,
detail=f"AI 写作服务暂不可用: {type(e).__name__}",
) from e
session_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue
_session_model_names[session_id] = body.model_name
_session_interventions[session_id] = []
# 加载动态文章类型列表,注入给 intent_parser 节点
article_type_store = get_article_type_store(request)
valid_article_types: list[dict] | None = None
if article_type_store is not None:
try:
records = await article_type_store.list()
if records:
valid_article_types = [{"key": r["key"], "label": r["label"]} for r in records]
except Exception:
logger.exception("ai_writing: failed to load article types for session=%s", session_id)
_session_article_types[session_id] = valid_article_types or []
# 持久化会话记录(DB 可能未配置,则跳过)
repo = get_ai_writing_session_store(request)
user_id = await get_current_user(request)
if repo is not None:
try:
title = _truncate_title(body.user_intent)
await repo.create(
id=session_id,
title=title,
user_intent=body.user_intent,
user_id=user_id,
)
_session_repos[session_id] = repo
_session_user_ids[session_id] = user_id
except Exception:
logger.exception("ai_writing: failed to create session record: session=%s", session_id)
initial_state = {
"user_intent": body.user_intent,
"article_type": body.article_type,
"target_audience": body.target_audience,
"word_count_target": body.word_count_target,
# 规整到 [1, 10],非法值回退默认 6
"keyword_count": max(1, min(10, body.keyword_count)),
# 写作模式,非法值回退 loose
"writing_mode": body.writing_mode if body.writing_mode in ("strict", "loose") else "loose",
"revision_count": 0,
"max_revisions": 3,
"status": "researching",
"drafts": [],
"progress_events": [],
"material_package": None,
"current_outline": None,
"current_draft": None,
"review_result": None,
"last_user_intervention": None,
}
config = _make_config(session_id, body.model_name, valid_article_types=valid_article_types)
_spawn_graph_task(_stream_graph_segment(session_id, initial_state, config, graph))
return StartWritingResponse(session_id=session_id)
@router.get("/sessions", response_model=list[SessionResponse])
async def list_sessions(request: Request):
"""列出当前用户的 AI 写作历史会话。"""
@@ -186,6 +460,8 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
# 会话不存在或不属于当前用户:同样不抛 500,返回 success=False 即可
return {"success": False, "reason": "session_not_found"}
return {"success": True}
@router.get("/article-types", response_model=list[ArticleTypeResponse])
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
@@ -259,6 +535,91 @@ async def delete_article_type(article_type_id: str, request: Request):
return {"success": True}
@router.get("/{session_id}/stream")
async def stream_progress(session_id: str):
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
return StreamingResponse(
_sse_generator(session_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/{session_id}/resume")
async def resume_writing(session_id: str, body: ResumeRequest, request: Request):
"""用户提交干预决策,恢复暂停的流程。"""
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
from app.gateway.deps import get_current_user
graph = await get_ai_writing_graph()
if session_id not in _event_queues:
# 内存会话注册表丢失(进程内清理后重连等)。先尝试从 checkpointer 恢复,
# 让用户能在干预点继续,而不是直接 404 中断整个写作流程。
recovery = await _recover_session(session_id, request)
if recovery != "interrupted":
logger.warning(
"ai_writing: resume on unrecoverable session=%s (recovery=%s)", session_id, recovery
)
raise HTTPException(
status_code=404,
detail="会话状态已丢失或流程已结束,无法继续,请重新发起写作。",
)
config = _make_config(session_id)
intervention = {
"action": body.action,
"approvedMaterialIds": body.approved_material_ids,
"extraKeywords": body.extra_keywords,
"editedOutline": body.edited_outline,
"outlineFeedback": body.outline_feedback,
"userRevisionNotes": body.user_revision_notes,
"overrideVerdict": body.override_verdict,
"selectedIssueIndices": body.selected_issue_indices,
"sectionDecisions": body.section_decisions,
}
# Record this intervention for session history
record = {
"pausePoint": body.pause_point,
"action": body.action,
"timestamp": datetime.now(UTC).isoformat(),
}
if session_id in _session_interventions:
_session_interventions[session_id].append(record)
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
completed_interventions=_session_interventions[session_id],
)
except Exception:
logger.exception("ai_writing: failed to save intervention: session=%s", session_id)
# Clear pending pause so SSE reconnect won't re-inject the stale await_user event
_session_pending_pause.pop(session_id, None)
_spawn_graph_task(
_stream_graph_segment(session_id, Command(resume=intervention), config, graph)
)
return {"message": "已提交,流程恢复中"}
@router.get("/{session_id}/state")
async def get_state(session_id: str):
"""查询当前流程状态快照。"""
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
config = _make_config(session_id)
snapshot = await graph.aget_state(config)
if not snapshot or not snapshot.values:
raise HTTPException(status_code=404, detail="Session not found")
return snapshot.values
# ── 管理接口 ──────────────────────────────────────────────────────────────────
@@ -327,20 +688,9 @@ async def admin_cleanup(
await _require_admin(request)
params = _resolve_cleanup_params(body)
# 透传主 LangGraph checkpointer / thread_store —— 新路径下 ai_writing 的
# thread 状态在主 checkpointer 里,thread_store 用来按 status 过滤活跃 thread。
# getattr 嵌套防御:测试用 stub Request 时 ``request.app`` 可能不存在。
_app_state = getattr(getattr(request, "app", None), "state", None)
main_checkpointer = getattr(_app_state, "checkpointer", None)
main_thread_store = getattr(_app_state, "thread_store", None)
started = time.monotonic()
try:
result = await run_cleanup_once(
**params,
checkpointer=main_checkpointer,
thread_store=main_thread_store,
)
result = await run_cleanup_once(**params)
except Exception as e:
logger.exception("ai_writing admin cleanup failed: params=%s", params)
# 抛 500 是合理的 —— 管理员触发清理失败需要看到失败原因,跟「业务
@@ -365,3 +715,292 @@ async def admin_cleanup(
elapsed_seconds=round(elapsed, 3),
params=params,
)
# ── 内部辅助 ──────────────────────────────────────────────────────────────────
async def _stream_graph_segment(
session_id: str,
input_or_command,
config: dict,
graph,
):
"""运行一个 Graph 片段(初始运行或 resume),将输出推入队列。
遇到 interrupt 时停止(不关闭队列),等待下次 resume。
Graph 结束时关闭队列(放入 None 哨兵)。
"""
queue = _event_queues.get(session_id)
if queue is None:
return
reached_end = False
is_error = False
try:
async for chunk in graph.astream(input_or_command, config=config, stream_mode="updates"):
node_name = next(iter(chunk))
node_output = chunk[node_name]
# interrupt() 触发
if node_name == "__interrupt__":
# node_output 是 (Interrupt(...),) 元组
interrupt_list = node_output if isinstance(node_output, (list, tuple)) else [node_output]
for intr in interrupt_list:
interrupt_data = intr.value if hasattr(intr, "value") else intr
event_tuple = ("await_user", interrupt_data)
await queue.put(event_tuple)
# Save for SSE reconnect re-delivery: if the old generator consumed this
# event before the client disconnected, a new connection would block forever.
_session_pending_pause[session_id] = event_tuple
return # 等待 resume,不关闭队列
if not isinstance(node_output, dict):
continue
# 推送 progress_events
for evt in node_output.get("progress_events") or []:
await queue.put((_event_type(evt), {**evt, "session_id": session_id}))
# 素材就绪
if pkg := node_output.get("material_package"):
await queue.put(("materials_ready", {
"session_id": session_id,
"keywords": pkg.get("keywords", []),
"materials": pkg.get("materials", []),
"summary": pkg.get("summary", ""),
}))
# 大纲就绪
if outline := node_output.get("current_outline"):
await queue.put(("outline_ready", {"session_id": session_id, "outline": outline}))
# 草稿就绪 — 保存到 DB
if draft := node_output.get("current_draft"):
draft_title = draft.get("title", "")
draft_markdown = draft.get("full_markdown", "")
await queue.put(("draft_ready", {
"session_id": session_id,
"title": draft_title,
"markdown": draft_markdown,
"revision_count": draft.get("revision_count", 0),
}))
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="draft_ready",
title=draft_title or _truncate_title(
next(iter(_session_interventions.get(session_id, [{}])), {}).get("action", "草稿"),
),
draft_title=draft_title,
draft_markdown=draft_markdown,
)
except Exception:
logger.exception("ai_writing: failed to save draft: session=%s", session_id)
# 编辑审核就绪 — 保存到 DB
if review := node_output.get("review_result"):
await queue.put(("review_ready", {"session_id": session_id, "review_result": review}))
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="review_ready",
review_result=review,
)
except Exception:
logger.exception("ai_writing: failed to save review: session=%s", session_id)
# 状态 done:Graph 结束
if node_output.get("status") == "done":
reached_end = True
# astream 正常结束 = Graph 执行到 END
reached_end = True
await queue.put(("done", {"session_id": session_id, "message": "写作完成"}))
except Exception as e:
logger.exception("ai_writing graph segment failed: session=%s", session_id)
await queue.put(("error", {"session_id": session_id, "message": str(e)}))
is_error = True
reached_end = True
finally:
if reached_end:
# 更新最终状态到 DB
repo = _session_repos.get(session_id)
user_id = _session_user_ids.get(session_id)
if repo is not None:
try:
await repo.update(
session_id,
user_id=user_id,
status="error" if is_error else "done",
)
except Exception:
logger.exception("ai_writing: failed to update final status: session=%s", session_id)
await queue.put(None) # 哨兵,通知 SSE 生成器关闭
_event_queues.pop(session_id, None)
_session_model_names.pop(session_id, None)
_session_repos.pop(session_id, None)
_session_user_ids.pop(session_id, None)
_session_interventions.pop(session_id, None)
_session_error_flags.pop(session_id, None)
_session_article_types.pop(session_id, None)
_session_pending_pause.pop(session_id, None)
def _event_type(evt: dict) -> str:
t = evt.get("type", "progress")
# 过滤掉已在上方单独推送的事件类型,避免重复
if t in ("materials_ready", "outline_ready", "draft_ready", "review_ready", "await_user",
"draft_chunk", "outline_chunk"):
return "step_done"
return t
async def _load_graph_snapshot(session_id: str):
"""读取 AI 写作 Graph 在该 session 上的状态快照(失败/无状态返回 None)。
Graph 的 checkpointer 现在按 config.yaml 持久化到磁盘(默认 SQLite),
所以容器重启后只要数据卷还在,仍然能恢复出来。
"""
try:
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
graph = await get_ai_writing_graph()
snapshot = await graph.aget_state(_make_config(session_id))
except Exception:
logger.exception("ai_writing: aget_state failed during recovery: session=%s", session_id)
return None
if not snapshot or not getattr(snapshot, "values", None):
return None
return snapshot
def _extract_interrupt(snapshot) -> dict | None:
"""从快照里取出当前挂起的 interrupt 负载(即 await_user 的数据)。"""
for task in getattr(snapshot, "tasks", None) or ():
for intr in getattr(task, "interrupts", None) or ():
value = getattr(intr, "value", None)
if isinstance(value, dict):
return value
return None
async def _recover_session(session_id: str, request: Request | None = None) -> str:
"""内存会话注册表丢失时,尝试从 checkpointer 重建会话。
返回:
- "interrupted" —— 图正停在某个干预点,已重建队列并预置 await_user 事件,可继续 resume
- "finished" —— 图已完成,已重建队列并预置 done 事件(+草稿)
- "unrecoverable" —— 无可恢复状态(进程/容器重启过,或 session 从未存在)
幂等:内存里已有队列时直接当作 "interrupted" 返回。整段在 `_recovery_lock`
下执行,避免并发的 /stream 与 /resume 各自重建出不同的队列对象。
"""
async with _recovery_lock:
if session_id in _event_queues:
return "interrupted"
snapshot = await _load_graph_snapshot(session_id)
if snapshot is None:
return "unrecoverable"
queue: asyncio.Queue = asyncio.Queue()
_event_queues[session_id] = queue
_session_interventions.setdefault(session_id, [])
_session_model_names.setdefault(session_id, "")
_session_article_types.setdefault(session_id, [])
# 尽量恢复 DB 仓库引用,使后续 resume 仍能持久化草稿/审核结果
if request is not None and session_id not in _session_repos:
try:
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is not None:
_session_repos[session_id] = repo
_session_user_ids[session_id] = await get_current_user(request)
except Exception:
logger.exception("ai_writing: failed to restore repo during recovery: session=%s", session_id)
interrupt_val = _extract_interrupt(snapshot)
if interrupt_val is not None:
event = ("await_user", {**interrupt_val, "session_id": session_id})
await queue.put(event)
_session_pending_pause[session_id] = event
logger.info("ai_writing: recovered interrupted session from checkpointer: session=%s", session_id)
return "interrupted"
values = snapshot.values or {}
if not getattr(snapshot, "next", None) or values.get("status") == "done":
draft = values.get("current_draft") or {}
if draft.get("full_markdown"):
await queue.put(("draft_ready", {
"session_id": session_id,
"title": draft.get("title", ""),
"markdown": draft.get("full_markdown", ""),
"revision_count": draft.get("revision_count", 0),
}))
await queue.put(("done", {"session_id": session_id, "message": "写作已完成"}))
await queue.put(None) # 哨兵:让 SSE 生成器读完即关闭
logger.info("ai_writing: recovered finished session from checkpointer: session=%s", session_id)
return "finished"
# 既不在干预点、也未结束:上次运行多半被中断在某个节点,无法续推
await queue.put(("error", {
"session_id": session_id,
"message": "写作流程已中断,请重新发起写作。",
}))
await queue.put(None)
logger.warning("ai_writing: session checkpoint stuck mid-flow, not resumable: session=%s", session_id)
return "finished"
async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
"""从队列消费事件,推送为 SSE 帧。"""
# 等待队列创建(最多 5 秒)
for _ in range(50):
if session_id in _event_queues:
break
await asyncio.sleep(0.1)
else:
# 内存里没有这个会话队列。不要直接报「Session not found」——
# 先尝试从 checkpointer 恢复(图可能仍停在某个干预点 / 已完成)。
recovery = await _recover_session(session_id)
if recovery == "unrecoverable":
yield _sse("connected", {"session_id": session_id})
yield _sse("error", {
"session_id": session_id,
"message": "会话状态已丢失(服务可能已重启),请重新发起写作。",
})
return
# 恢复成功:_event_queues[session_id] 已重建并预置事件,继续走正常消费逻辑。
queue = _event_queues.get(session_id)
if queue is None:
# 极端兜底:恢复后又被并发清理
yield _sse("connected", {"session_id": session_id})
yield _sse("error", {"session_id": session_id, "message": "会话状态已丢失,请重新发起写作。"})
return
yield _sse("connected", {"session_id": session_id})
# Re-inject the saved await_user event if the queue is empty and a pending pause exists.
# This covers the case where the old SSE generator dequeued the await_user event and
# yielded it to a disconnecting client — the event is then lost from the consume-once queue.
if queue.empty() and session_id in _session_pending_pause:
await queue.put(_session_pending_pause[session_id])
while True:
item = await queue.get()
if item is None:
break
event_type, data = item
yield _sse(event_type, data)
await asyncio.sleep(0)
@@ -21,16 +21,15 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.gateway.deps import get_memory_config_store
from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.memory_config import (
USER_WRITABLE_FIELDS,
delete_user_memory_override_db,
delete_user_memory_override,
get_effective_memory_config,
save_user_memory_override_db,
save_user_memory_override,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
@@ -73,38 +72,20 @@ _AgentQuery = Query(
# ===========================================================================
class MemoryFact(BaseModel):
"""V1 记忆中的单条事实。"""
id: str = Field(default="", description="事实 UUID")
content: str = Field(default="", description="事实文本")
category: str = Field(default="knowledge", description="事实类别")
confidence: float = Field(default=0.0, description="置信度 0-1")
createdAt: str = Field(default="", description="创建时间 ISO 格式")
sourceError: str = Field(default="", description="纠错类事实:被纠正的原错误描述")
class MemoryV2DataResponse(BaseModel):
"""Memory 数据视图 —— V1/V2 共用,多余字段各版本置空。"""
"""Memory V2 builtin provider 的数据视图。"""
# V2 字段
user_entries: list[str] = Field(default_factory=list, description="V2: USER.md 条目 (用户画像)")
memory_entries: list[str] = Field(default_factory=list, description="V2: MEMORY.md 条目 (agent 工作笔记)")
user_usage: str = Field(default="", description="字符/计数用量描述")
memory_usage: str = Field(default="", description="字符/计数用量描述")
# V1 字段
facts: list[MemoryFact] = Field(default_factory=list, description="V1: facts 列表 (含置信度)")
user_context: dict[str, str] = Field(default_factory=dict, description="V1: userContext 摘要")
history: dict[str, str] = Field(default_factory=dict, description="V1: history 历史摘要")
user_entries: list[str] = Field(default_factory=list, description="USER.md 条目 (用户画像)")
memory_entries: list[str] = Field(default_factory=list, description="MEMORY.md 条目 (agent 工作笔记)")
user_usage: str = Field(default="", description="USER.md 字符用量,如 '12% — 165/1375 字符'")
memory_usage: str = Field(default="", description="MEMORY.md 字符用量")
class MemoryV2ConfigResponse(BaseModel):
"""Memory 生效配置 (含三层覆盖合并结果) + 用户可改字段白名单 + 版本号。"""
"""Memory V2 生效配置 (含三层覆盖合并结果) + 用户可改字段白名单。"""
config: dict[str, Any] = Field(..., description="按 per-user 覆盖合并后的生效配置")
writable_fields: list[str] = Field(..., description="用户可在运行时覆盖的字段 (点号路径)")
version: str = Field(default="v2", description="当前记忆版本:v1 或 v2")
class MemoryConfigPatchRequest(BaseModel):
@@ -152,33 +133,6 @@ class MemorySearchResponse(BaseModel):
# ===========================================================================
def _is_v1(user_id: str) -> bool:
"""判断当前用户是否处于 V1 记忆模式。"""
config = get_effective_memory_config(user_id)
return getattr(config, "version", "v2") == "v1"
def _build_v1_provider(user_id: str, agent_name: str | None = None):
"""构建并初始化 V1BuiltinProvider。"""
from deerflow.agents.memory.providers.v1_builtin import V1BuiltinProvider
config = get_effective_memory_config(user_id)
v1_cfg = config.v1
provider = V1BuiltinProvider(
max_facts=v1_cfg.max_facts,
fact_confidence_threshold=v1_cfg.fact_confidence_threshold,
debounce_seconds=v1_cfg.debounce_seconds,
model_name=v1_cfg.model_name,
token_budget=v1_cfg.token_budget,
)
provider.initialize(
user_id=user_id,
agent_id=agent_name,
base_dir=str(get_paths().base_dir),
)
return provider
def _build_builtin_provider(user_id: str, agent_name: str | None):
"""按生效配置构建并初始化一个 V2 builtin provider。"""
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
@@ -206,7 +160,7 @@ def _validate_v2_target(target: str) -> str:
def _v2_data_response(provider) -> MemoryV2DataResponse:
"""从 V2 builtin provider 读出当前数据。"""
"""从 builtin provider 读出当前数据,组装成 MemoryV2DataResponse。"""
return MemoryV2DataResponse(
user_entries=provider.read("user"),
memory_entries=provider.read("memory"),
@@ -215,49 +169,6 @@ def _v2_data_response(provider) -> MemoryV2DataResponse:
)
def _v1_data_response(provider) -> MemoryV2DataResponse:
"""从 V1 provider 读出当前数据,转换为统一响应格式。
支持新格式 {user: {workContext: {summary, updatedAt}}} 和老格式 {userContext: {workContext: str}}。
"""
data = provider.read_v1_data()
raw_facts = data.get("facts", [])
facts = [
MemoryFact(
id=str(f.get("id", "")),
content=str(f.get("content", "")),
category=str(f.get("category", "knowledge")),
confidence=float(f.get("confidence", 0)),
createdAt=str(f.get("createdAt", "")),
sourceError=str(f.get("sourceError", "")),
)
for f in raw_facts
]
def _extract_section(section: dict) -> dict[str, str]:
"""从 {key: {summary: str, updatedAt: str}} 或 {key: str} 中提取摘要字符串。"""
result = {}
for k, v in section.items():
if isinstance(v, dict):
summary = v.get("summary", "")
else:
summary = str(v)
if summary:
result[k] = summary
return result
# 新格式用 "user" key,老格式用 "userContext" key(已由 storage 迁移,此处兜底)
ctx_raw = data.get("user") or data.get("userContext") or {}
history_raw = data.get("history") or {}
return MemoryV2DataResponse(
facts=sorted(facts, key=lambda f: f.confidence, reverse=True),
user_context=_extract_section(ctx_raw),
history=_extract_section(history_raw),
memory_usage=provider.usage_text(),
)
def _raise_if_write_failed(result: dict) -> None:
"""builtin provider 写操作失败时抛 400。"""
if not result.get("success"):
@@ -272,43 +183,33 @@ def _raise_if_write_failed(result: dict) -> None:
@router.get(
"/memory/v2",
response_model=MemoryV2DataResponse,
summary="Get Memory Data",
description="读取记忆数据。V1 模式返回 facts 列表;V2 模式返回 USER.md / MEMORY.md 条目。",
summary="Get Memory V2 Data",
description="读取 Memory V2 builtin provider 的 USER.md / MEMORY.md 条目。",
)
async def get_memory_v2(agent: str | None = _AgentQuery) -> MemoryV2DataResponse:
"""读取当前用户的本地记忆。"""
"""读取当前用户的 Memory V2 本地记忆。"""
agent_name = _resolve_agent_name(agent)
user_id = get_effective_user_id()
try:
if _is_v1(user_id):
provider = _build_v1_provider(user_id, agent_name)
return _v1_data_response(provider)
provider = _build_builtin_provider(user_id, agent_name)
return _v2_data_response(provider)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail="Failed to read memory data.") from exc
def _assert_not_v1(user_id: str) -> None:
"""V1 模式下写入操作返回 400。"""
if _is_v1(user_id):
raise HTTPException(status_code=400, detail="V1 模式下记忆由系统自动提取,不支持手动修改。")
raise HTTPException(status_code=500, detail="Failed to read memory V2 data.") from exc
@router.post(
"/memory/v2/entries",
response_model=MemoryV2DataResponse,
summary="Add Memory V2 Entry",
description="手动往 USER.md / MEMORY.md 新增一条记忆条目 (仅 V2 模式)。",
description="手动往 USER.md / MEMORY.md 新增一条记忆条目。",
)
async def add_memory_v2_entry(
request: MemoryEntryCreateRequest,
agent: str | None = _AgentQuery,
) -> MemoryV2DataResponse:
"""手动新增一条 V2 记忆条目。"""
user_id = get_effective_user_id()
_assert_not_v1(user_id)
_validate_v2_target(request.target)
user_id = get_effective_user_id()
try:
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
result = provider.add(request.target, request.content)
@@ -324,16 +225,15 @@ async def add_memory_v2_entry(
"/memory/v2/entries",
response_model=MemoryV2DataResponse,
summary="Replace Memory V2 Entry",
description="用 old_text 定位一条记忆条目并替换为新内容 (仅 V2 模式)。",
description="用 old_text 定位一条记忆条目并替换为新内容。",
)
async def replace_memory_v2_entry(
request: MemoryEntryReplaceRequest,
agent: str | None = _AgentQuery,
) -> MemoryV2DataResponse:
"""手动替换一条 V2 记忆条目。"""
user_id = get_effective_user_id()
_assert_not_v1(user_id)
_validate_v2_target(request.target)
user_id = get_effective_user_id()
try:
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
result = provider.replace(request.target, request.old_text, request.content)
@@ -349,7 +249,7 @@ async def replace_memory_v2_entry(
"/memory/v2/entries",
response_model=MemoryV2DataResponse,
summary="Delete Memory V2 Entry",
description="用 old_text 定位并删除一条记忆条目 (仅 V2 模式)。",
description="用 old_text 定位并删除一条记忆条目。",
)
async def delete_memory_v2_entry(
target: str = Query(..., description="memory 或 user"),
@@ -357,9 +257,8 @@ async def delete_memory_v2_entry(
agent: str | None = _AgentQuery,
) -> MemoryV2DataResponse:
"""手动删除一条 V2 记忆条目。"""
user_id = get_effective_user_id()
_assert_not_v1(user_id)
_validate_v2_target(target)
user_id = get_effective_user_id()
try:
provider = _build_builtin_provider(user_id, _resolve_agent_name(agent))
result = provider.remove(target, old_text)
@@ -389,7 +288,6 @@ async def get_memory_v2_config() -> MemoryV2ConfigResponse:
return MemoryV2ConfigResponse(
config=config.model_dump(),
writable_fields=sorted(USER_WRITABLE_FIELDS),
version=getattr(config, "version", "v2"),
)
@@ -397,24 +295,20 @@ async def get_memory_v2_config() -> MemoryV2ConfigResponse:
"/memory/v2/config",
response_model=MemoryV2ConfigResponse,
summary="Patch Memory V2 Config",
description="把 hindsight.recall_budget 写入当前用户的 per-user 配置覆盖 (DB 存储)。",
description="把白名单字段写入当前用户的 per-user 配置覆盖。非白名单字段被忽略。",
)
async def patch_memory_v2_config(request: MemoryConfigPatchRequest, req: Request) -> MemoryV2ConfigResponse:
"""更新当前用户的 per-user 记忆配置覆盖 (仅 hindsight.recall_budget)。"""
async def patch_memory_v2_config(request: MemoryConfigPatchRequest) -> MemoryV2ConfigResponse:
"""更新当前用户的 per-user 记忆配置覆盖。"""
user_id = get_effective_user_id()
store = get_memory_config_store(req)
try:
await save_user_memory_override_db(user_id, request.overrides, store)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
save_user_memory_override(user_id, request.overrides)
except OSError as exc:
raise HTTPException(status_code=500, detail="Failed to save memory config override.") from exc
config = get_effective_memory_config(user_id)
return MemoryV2ConfigResponse(
config=config.model_dump(),
writable_fields=sorted(USER_WRITABLE_FIELDS),
version=getattr(config, "version", "v2"),
)
@@ -423,27 +317,24 @@ async def patch_memory_v2_config(request: MemoryConfigPatchRequest, req: Request
response_model=MemoryV2ConfigResponse,
summary="Delete Memory V2 Config Override",
description=(
"撤销当前用户的 per-user 配置覆盖。传 ?field=hindsight.recall_budget 只撤销该字段;"
"不传 field 则清空全部覆盖。"
"撤销当前用户的 per-user 配置覆盖。传 ?field=injection.enabled 只撤销该字段;"
"不传 field 则清空整个 per-user 覆盖。"
),
)
async def delete_memory_v2_config(
req: Request,
field: str | None = Query(None, description="要撤销的点号路径字段;省略则清空全部覆盖"),
) -> MemoryV2ConfigResponse:
"""撤销当前用户的 per-user 记忆配置覆盖。"""
user_id = get_effective_user_id()
store = get_memory_config_store(req)
try:
await delete_user_memory_override_db(user_id, field, store)
except Exception as exc: # noqa: BLE001
delete_user_memory_override(user_id, field)
except OSError as exc:
raise HTTPException(status_code=500, detail="Failed to delete memory config override.") from exc
config = get_effective_memory_config(user_id)
return MemoryV2ConfigResponse(
config=config.model_dump(),
writable_fields=sorted(USER_WRITABLE_FIELDS),
version=getattr(config, "version", "v2"),
)
@@ -0,0 +1,89 @@
"""Multi-LLM routing agent — SSE streaming endpoint.
POST /api/multi-llm/{thread_id}/stream
Body: {"message": "..."}
Returns text/event-stream with frames (all as "data: <json>\\n\\n"):
{"type": "routing", "system": "B"} ← router decision
{"type": "thinking", "content": "...", "system": "B"} ← reasoning (if available)
{"type": "text", "content": "...", "system": "B"} ← final text chunks
{"type": "done"} ← stream end
{"type": "error", "message": "..."} ← on failure
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
from langgraph.types import Checkpointer
from pydantic import BaseModel
from app.gateway.deps import get_checkpointer
from deerflow.agents.multi_llm.graph import build_multi_llm_graph
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multi-llm", tags=["multi-llm"])
# Lazy singleton: built once on first request, reusing the app-scoped checkpointer.
_graph = None
_graph_lock = asyncio.Lock()
async def _get_graph(checkpointer: Checkpointer):
global _graph
if _graph is None:
async with _graph_lock:
if _graph is None:
_graph = build_multi_llm_graph(checkpointer=checkpointer)
return _graph
class ChatRequest(BaseModel):
message: str
@router.post("/{thread_id}/stream", summary="Multi-LLM routing agent (SSE stream)")
async def multi_llm_stream(
thread_id: str,
body: ChatRequest,
request: Request,
checkpointer: Checkpointer = Depends(get_checkpointer),
) -> StreamingResponse:
graph = await _get_graph(checkpointer)
lg_config = {"configurable": {"thread_id": thread_id}}
input_state = {"messages": [HumanMessage(content=body.message)]}
async def generate():
try:
async for event in graph.astream_events(input_state, config=lg_config, version="v2"):
if await request.is_disconnected():
break
ev = event["event"]
if ev == "on_custom_event" and event.get("name") == "llm_chunk":
d = event["data"]
yield f"data: {json.dumps({'type': d['type'], 'content': d['content'], 'system': d['system']}, ensure_ascii=False)}\n\n"
elif ev == "on_chain_end" and event.get("name") == "router":
output = event.get("data", {}).get("output", {})
system = output.get("selected_system", "") if isinstance(output, dict) else ""
if system:
yield f"data: {json.dumps({'type': 'routing', 'system': system})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except Exception as e:
logger.exception("multi_llm stream failed: thread=%s", thread_id)
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
@@ -1,4 +1,4 @@
"""Public (unauthenticated) API for scheduled tasks.
"""Public (unauthenticated) read-only API for scheduled tasks.
Mounted under the ``/api/public/`` prefix so the auth middleware lets these
routes through without a session cookie. Intended for embedding scheduled
@@ -12,15 +12,9 @@ Surface area:
- ``GET /api/public/scheduled-tasks/{task_id}/runs`` — runs for a task (latest first)
- ``GET /api/public/scheduled-tasks/runs/{run_id}`` — single run (result included)
- ``GET /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— inline file content / preview
- ``PUT /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— overwrite an editable result file
- ``PUT /api/public/scheduled-tasks/runs/{run_id}/body`` — overwrite the inline Markdown body
- ``GET /api/public/scheduled-tasks/runs/{run_id}/download`` — zip / md download of result
- ``DELETE /api/public/scheduled-tasks/runs/{run_id}`` — remove a single run (no auth)
The PUT endpoints intentionally skip ownership checks so the anonymous public
viewer at ``#/public/scheduled-tasks/<task_id>`` can fix typos in result MD
files / body content. Tighten this if the deployment requires read-only
sharing.
These are read-only. No write/mutation routes belong here.
"""
from __future__ import annotations
@@ -36,7 +30,6 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from deerflow.runtime.scheduler import get_scheduled_task_service
@@ -46,17 +39,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/public/scheduled-tasks", tags=["public-scheduled-tasks"])
_MAX_PUBLIC_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB — mirrors the authenticated edit cap
class PublicRunFileUpdateRequest(BaseModel):
content: str = Field(..., description="New text content to write into the run file")
class PublicRunBodyUpdateRequest(BaseModel):
content: str = Field(..., description="New Markdown body for the run's result")
# ---------------------------------------------------------------------------
# Sanitisation helpers
# ---------------------------------------------------------------------------
@@ -111,15 +93,6 @@ def _redact_run(run: dict[str, Any]) -> dict[str, Any]:
# to fetch content via the public file endpoint.
public_file = {k: v for k, v in item.items() if k != "path"}
public_file["index"] = idx
# Mark whether the underlying archive is still on disk. The
# public viewer filters out tabs whose source is gone so users
# never see the raw "Result file is no longer available" 404.
path_value = item.get("path")
public_file["available"] = (
isinstance(path_value, str)
and bool(path_value)
and Path(path_value).is_file()
)
cleaned_files.append(public_file)
result = {**result, "files": cleaned_files}
cleaned["result"] = result
@@ -329,57 +302,3 @@ async def public_download_run(run_id: str):
media_type="application/zip",
headers={"Content-Disposition": _content_disposition(f"{base_name}.zip")},
)
@router.put("/runs/{run_id}/files/{file_index}")
async def public_update_run_file(
run_id: str,
file_index: int,
body: PublicRunFileUpdateRequest,
) -> dict[str, Any]:
"""Overwrite an MD/text result file on disk and refresh its size in the run row.
Anonymous — no ownership check. See module docstring for the rationale.
"""
if len(body.content.encode("utf-8")) > _MAX_PUBLIC_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
try:
return await service.admin_update_run_file(run_id, file_index, body.content)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except PermissionError as exc:
# Surfaces archive-path escape attempts. Treat as 400 since the caller is
# already trusted to be reaching a real run row.
raise HTTPException(status_code=400, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/runs/{run_id}", status_code=204)
async def public_delete_run(run_id: str) -> Response:
"""Remove a single run regardless of owner.
Anonymous — no ownership check, mirroring the existing public PUT endpoints.
Tighten this if the deployment requires read-only public sharing.
"""
service = get_scheduled_task_service()
deleted = await service.admin_delete_run(run_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return Response(status_code=204)
@router.put("/runs/{run_id}/body")
async def public_update_run_body(run_id: str, body: PublicRunBodyUpdateRequest) -> dict[str, Any]:
"""Overwrite the inline Markdown body (``result.content``) of a run.
Anonymous — no ownership check. Used by the public viewer's "正文" tab.
"""
if len(body.content.encode("utf-8")) > _MAX_PUBLIC_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
try:
return await service.admin_update_run_body(run_id, body.content)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@@ -31,7 +31,6 @@ router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
class ScheduledTaskCreateRequest(BaseModel):
name: str = Field(default="Scheduled task")
description: str | None = None
prompt: str
schedule: str
timezone: str | None = None
@@ -41,7 +40,6 @@ class ScheduledTaskCreateRequest(BaseModel):
class ScheduledTaskUpdateRequest(BaseModel):
name: str | None = None
description: str | None = None
prompt: str | None = None
schedule: str | None = None
timezone: str | None = None
@@ -549,7 +547,6 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
task = await service.create_task(
user_id=user_id,
name=body.name,
description=body.description,
prompt=body.prompt,
schedule=body.schedule,
timezone=body.timezone,
@@ -100,7 +100,7 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json.
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
@@ -186,20 +186,15 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
def resolve_agent_factory(assistant_id: str | None):
"""Resolve the agent factory callable from config.
Built-in graphs (``lead_agent``, ``canvas_agent``, ``ai_writing``) are dispatched
directly to their own factory. Custom agents share the ``lead_agent`` factory
with ``agent_id`` injected into configurable/context by :func:`build_run_config`.
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
to their own factory. Custom agents share the ``lead_agent`` factory with
``agent_id`` injected into configurable/context by :func:`build_run_config`.
"""
if assistant_id == "canvas_agent":
from deerflow.agents.canvas_agent.agent import make_canvas_agent
return make_canvas_agent
if assistant_id == "ai_writing":
from deerflow.agents.ai_writing.graph import make_ai_writing_graph
return make_ai_writing_graph
from deerflow.agents.lead_agent.agent import make_lead_agent
return make_lead_agent
@@ -405,11 +400,6 @@ async def start_run(
)
else:
await run_ctx.thread_store.update_status(thread_id, "running")
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
# assistant 筛选时拿不到关联。
if not existing.get("assistant_id") and body.assistant_id:
await run_ctx.thread_store.update_assistant_id(thread_id, body.assistant_id)
except Exception:
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
@@ -451,9 +441,6 @@ async def start_run(
stream_subgraphs=body.stream_subgraphs,
interrupt_before=body.interrupt_before,
interrupt_after=body.interrupt_after,
# 透传 SDK ``Command``:resume / update / goto。Pregel 会据此从最近
# checkpoint 处恢复,而不是把 graph_input 当新一轮输入跑。
command=getattr(body, "command", None),
)
)
record.task = task
+10 -21
View File
@@ -144,19 +144,8 @@ checkpointer:
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
version: v2 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
# (version=v1 时此字段忽略)
# V1 专属配置 —— version=v1 时生效
v1:
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
model_name: "" # 用于事实提取的 LLM 模型名;空则继承全局默认模型
token_budget: 2000 # 注入系统提示词的 token 预算
builtin:
enabled: true
memory_char_limit: 2200
@@ -165,15 +154,15 @@ memory:
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://47.88.25.99:18888
api_url: http://localhost:8765
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: "deerflow-user-{user_id}"
work_bank_id_template: "deerflow-work-{user_id}-{agent_id}"
memory_mode: context # 'context' | 'tools' | 'hybrid'
bank_mission: "你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。"
bank_retain_mission: "请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。"
recall_budget: mid # 'low' | 'mid' | 'high'
memory_mode: context # 'context' | 'tools' | 'hybrid'
bank_mission: ""
bank_retain_mission: ""
recall_budget: mid # 'low' | 'mid' | 'high'
recall_max_tokens: 4096
recall_max_input_chars: 800
auto_recall: true
@@ -199,10 +188,10 @@ memory:
max_tokens: 2000
include_builtin: true
include_hindsight: true
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
security:
scan_content: true # 注入/外渗模式扫描
scan_content: true # 注入/外渗模式扫描
block_invisible_unicode: true
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
@@ -211,6 +200,6 @@ memory:
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
@@ -7,8 +7,7 @@
"env": ".env",
"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"
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent"
},
"auth": {
"path": "./app/gateway/langgraph_auth.py:auth"
@@ -1,3 +1,15 @@
from .graph import make_ai_writing_graph
from .graph import (
AI_WRITING_GRAPH,
build_ai_writing_graph,
get_ai_writing_checkpointer,
get_ai_writing_graph,
shutdown_ai_writing_graph,
)
__all__ = ["make_ai_writing_graph"]
__all__ = [
"AI_WRITING_GRAPH",
"build_ai_writing_graph",
"get_ai_writing_graph",
"get_ai_writing_checkpointer",
"shutdown_ai_writing_graph",
]
@@ -3,7 +3,6 @@ from __future__ import annotations
import logging
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, StateGraph
from langgraph.types import interrupt
@@ -16,30 +15,6 @@ from .nodes.editor import editor_node
logger = logging.getLogger(__name__)
async def _mark_session_done(config: RunnableConfig | None) -> None:
"""把业务表行 status 写成 ``done``。
pause_draft_confirm action=finalize / pause_review_confirm force_finalize 时调,
让 LangGraph Server 路径下新会话有正确终态(老路径靠 ``_stream_graph_segment``
finally 块写终态,新路径完全不走它)。失败不影响 graph 流程。
"""
if not config:
return
thread_id = (config.get("configurable") or {}).get("thread_id")
if not thread_id:
return
from deerflow.persistence.ai_writing_sessions import get_default_store
from deerflow.runtime.user_context import AUTO
repo = get_default_store()
if repo is None:
return
try:
await repo.update(thread_id, user_id=AUTO, status="done")
except Exception:
logger.exception("ai_writing: mark_session_done failed (non-fatal) thread_id=%s", thread_id)
# ── 干预节点 ──────────────────────────────────────────────────────────────────
async def pause_material_confirm(state: AIWritingState) -> dict:
@@ -152,7 +127,7 @@ async def pause_outline_confirm(state: AIWritingState) -> dict:
return updates
async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) -> dict:
async def pause_draft_confirm(state: AIWritingState) -> dict:
"""PAUSE-3:草稿确认"""
draft = state["current_draft"]
user_input: dict = interrupt({
@@ -166,7 +141,6 @@ async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) ->
action = user_input.get("action", "to_editor")
if action == "finalize":
await _mark_session_done(config)
return {
"last_user_intervention": user_input,
"status": "done",
@@ -249,7 +223,7 @@ async def pause_section_help(state: AIWritingState) -> dict:
}
async def pause_review_confirm(state: AIWritingState, config: RunnableConfig) -> dict:
async def pause_review_confirm(state: AIWritingState) -> dict:
"""PAUSE-4:编辑打回确认"""
user_input: dict = interrupt({
"pause_point": "review_confirm",
@@ -260,7 +234,6 @@ async def pause_review_confirm(state: AIWritingState, config: RunnableConfig) ->
})
if user_input.get("overrideVerdict") or user_input.get("action") == "force_finalize":
await _mark_session_done(config)
return {
"last_user_intervention": user_input,
"status": "done",
@@ -326,26 +299,25 @@ def after_review_pause(state: AIWritingState) -> str:
return "end" if state["status"] == "done" else "writer_draft"
# ── 组装 Graph ────────────────────────────────────────────────────────────────
# ── LangGraph Server 入口 ────────────────────────────────────────────────────
#
# compile 出裸 graph(不带 checkpointer),由 LangGraph Server 注入主 checkpointer
# (langgraph.json 里 ``make_checkpointer`` 配的那个)。AI 写作的 thread 状态与
# 主聊天共享同一份 saver —— LangGraph schema 用 ``(thread_id, checkpoint_ns)`` 做
# 主键,UUID 不会撞。
def make_ai_writing_graph(config=None):
"""LangGraph Server graph factory:不接收 checkpointer,由 Server 注入。
def build_ai_writing_graph(checkpointer=None):
"""构建并 compile AI 写作 graph。
Args:
config: LangGraph Server 调用工厂时注入的 RunnableConfig(暂未消费)。
checkpointer: 可选的 LangGraph checkpointer。
- ``None`` → 退回 ``MemorySaver`` —— 仅供测试 / 开发使用。
进程或容器重启后会话状态全部丢失,前端拿着旧 session_id 调
``/resume`` 会得到 404。
- 其他 → 传入的 (Async)BaseCheckpointSaver 实例,用于持久化。
"""
g = StateGraph(AIWritingState)
g.add_node("intent_parser", intent_parser_node)
g.add_node("researcher", researcher_node)
g.add_node("pause_material", pause_material_confirm)
g.add_node("writer_outline", writer_outline_node)
g.add_node("pause_outline", pause_outline_confirm)
g.add_node("intent_parser", intent_parser_node)
g.add_node("researcher", researcher_node)
g.add_node("pause_material", pause_material_confirm)
g.add_node("writer_outline", writer_outline_node)
g.add_node("pause_outline", pause_outline_confirm)
g.add_node("writer_draft", writer_draft_node)
g.add_node("pause_section_help", pause_section_help)
g.add_node("pause_draft", pause_draft_confirm)
@@ -353,23 +325,214 @@ def make_ai_writing_graph(config=None):
g.add_node("pause_review", pause_review_confirm)
g.set_entry_point("intent_parser")
g.add_edge("intent_parser", "researcher")
g.add_edge("intent_parser", "researcher")
g.add_conditional_edges("researcher", after_researcher,
{"pause_material": "pause_material", "writer_draft": "writer_draft"})
{"pause_material": "pause_material", "writer_draft": "writer_draft"})
g.add_conditional_edges("pause_material", after_material_pause,
{"researcher": "researcher", "writer_outline": "writer_outline"})
{"researcher": "researcher", "writer_outline": "writer_outline"})
g.add_edge("writer_outline", "pause_outline")
g.add_conditional_edges("pause_outline", after_outline_pause,
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
g.add_conditional_edges("writer_draft", after_writer_draft,
{"pause_section_help": "pause_section_help", "pause_draft": "pause_draft"})
g.add_conditional_edges("pause_section_help", after_section_help_pause,
{"researcher": "researcher", "writer_draft": "writer_draft"})
g.add_conditional_edges("pause_draft", after_draft_pause,
{"end": END, "writer_draft": "writer_draft", "editor": "editor"})
g.add_conditional_edges("editor", after_review,
{"pause_draft": "pause_draft", "pause_review": "pause_review"})
g.add_conditional_edges("pause_review", after_review_pause,
{"end": END, "writer_draft": "writer_draft"})
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
return g.compile()
g.add_conditional_edges("writer_draft", after_writer_draft,
{"pause_section_help": "pause_section_help", "pause_draft": "pause_draft"})
g.add_conditional_edges("pause_section_help", after_section_help_pause,
{"researcher": "researcher", "writer_draft": "writer_draft"})
g.add_conditional_edges("pause_draft", after_draft_pause,
{"end": END, "writer_draft": "writer_draft", "editor": "editor"})
g.add_conditional_edges("editor", after_review,
{"pause_draft": "pause_draft", "pause_review": "pause_review"})
g.add_conditional_edges("pause_review", after_review_pause,
{"end": END, "writer_draft": "writer_draft"})
if checkpointer is None:
# MemorySaver 仅作为 fallback —— 提示一下,正式部署应通过
# get_ai_writing_graph() 拿到带持久化 checkpointer 的 graph。
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
return g.compile(checkpointer=checkpointer)
# ── 持久化 checkpointer 单例(懒加载)─────────────────────────────────────────
# 模块级 graph 槽位。import 时为 None;首次 ``get_ai_writing_graph()`` 调用时
# 按 config.yaml 的 checkpointer 段懒构建出真正的 graph。
# 测试可通过 ``monkeypatch.setattr(graph_mod, "AI_WRITING_GRAPH", stub)``
# 直接注入桩;本模块所有访问点都走 ``get_ai_writing_graph()`` 这一入口,会优先
# 返回 module attribute 的值,保留测试桩注入路径。
AI_WRITING_GRAPH = None
# 持有 async checkpointer(底层 aiosqlite Connection 或 psycopg pool 的所有者)。
# 进程生命周期内常驻;shutdown 时由 ``shutdown_ai_writing_graph`` 显式关闭。
_async_checkpointer_singleton = None
def _fallback_to_memory_saver(reason: str):
"""退回 InMemorySaver 的统一兜底入口。
把 reason 打到 warning 日志里方便运维定位。InMemorySaver 进程重启会丢
状态,但**当次写作能跑完** —— 我们的首要目标是「写作流程能继续」,
持久化是锦上添花。
"""
from langgraph.checkpoint.memory import InMemorySaver
logger.warning(
"ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver。原因: %s",
reason,
)
return InMemorySaver()
async def _get_async_checkpointer():
"""按 config.yaml 的 checkpointer 段返回一个 async checkpointer 实例。
**绝不抛异常** —— 任何失败一律退回 InMemorySaver。这是为了保证「写作
流程能继续」这个首要目标:持久化坏了用户至少还能写完当次稿,重启会丢
会话但不影响当下创作。
优先级:
1. ``checkpointer:`` 段(``type=sqlite|postgres|memory``)
2. fallback 到 ``InMemorySaver`` 并打 warning
返回的 saver 与主 agent 公用同一 sqlite 文件 —— LangGraph 的 schema
用 ``(thread_id, checkpoint_ns)`` 做主键,AI 写作的 thread_id 是
会话 uuid,跟主 agent 的 LangGraph thread_id 不会撞。
"""
global _async_checkpointer_singleton
if _async_checkpointer_singleton is not None:
return _async_checkpointer_singleton
try:
from deerflow.config.checkpointer_config import get_checkpointer_config
cp_cfg = get_checkpointer_config()
except Exception:
logger.exception("ai_writing: 加载 checkpointer 配置失败")
_async_checkpointer_singleton = _fallback_to_memory_saver("加载 config 抛异常")
return _async_checkpointer_singleton
if cp_cfg is None:
_async_checkpointer_singleton = _fallback_to_memory_saver(
"config.yaml 未配置 checkpointer 段"
)
return _async_checkpointer_singleton
if cp_cfg.type == "memory":
from langgraph.checkpoint.memory import InMemorySaver
_async_checkpointer_singleton = InMemorySaver()
logger.info("ai_writing: 配置 type=memory,使用 InMemorySaver")
return _async_checkpointer_singleton
if cp_cfg.type == "sqlite":
try:
import aiosqlite
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from deerflow.runtime.store._sqlite_utils import (
ensure_sqlite_parent_dir,
resolve_sqlite_conn_str,
)
conn_str = resolve_sqlite_conn_str(
cp_cfg.connection_string or "ai_writing_checkpoints.db"
)
ensure_sqlite_parent_dir(conn_str)
# 直接持有 aiosqlite.Connection —— 进程生命周期,shutdown 时显式关。
conn = await aiosqlite.connect(conn_str)
saver = AsyncSqliteSaver(conn)
await saver.setup()
_async_checkpointer_singleton = saver
logger.info("ai_writing: 使用 AsyncSqliteSaver (%s)", conn_str)
return saver
except Exception as exc:
# sqlite 失败原因可能是:依赖未装 / 磁盘满 / 文件权限 / 路径不存在 /
# 文件被外部进程独占锁。任何一个都不能让 AI 写作整个不可用。
logger.exception(
"ai_writing: AsyncSqliteSaver 初始化失败,conn_str=%r",
getattr(cp_cfg, "connection_string", None),
)
_async_checkpointer_singleton = _fallback_to_memory_saver(
f"sqlite 初始化失败: {type(exc).__name__}: {exc}"
)
return _async_checkpointer_singleton
if cp_cfg.type == "postgres":
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg_pool import AsyncConnectionPool
if not cp_cfg.connection_string:
raise ValueError("checkpointer.connection_string 是 postgres 必填项")
pool = AsyncConnectionPool(cp_cfg.connection_string, open=False)
await pool.open()
saver = AsyncPostgresSaver(pool)
await saver.setup()
_async_checkpointer_singleton = saver
logger.info("ai_writing: 使用 AsyncPostgresSaver")
return saver
except Exception as exc:
logger.exception("ai_writing: AsyncPostgresSaver 初始化失败")
_async_checkpointer_singleton = _fallback_to_memory_saver(
f"postgres 初始化失败: {type(exc).__name__}: {exc}"
)
return _async_checkpointer_singleton
# 未知 type —— config 配错也不能让服务挂
_async_checkpointer_singleton = _fallback_to_memory_saver(
f"未知 checkpointer.type={cp_cfg.type!r}"
)
return _async_checkpointer_singleton
async def get_ai_writing_graph():
"""懒加载并返回 AI 写作 graph 单例。
第一次调用按 config 选定的 checkpointer 构建 graph 并缓存;后续调用直接
返回缓存。测试可在调用前通过 ``monkeypatch.setattr`` 直接替换模块级
``AI_WRITING_GRAPH`` —— 本函数会优先返回已设的值。
"""
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
def get_ai_writing_checkpointer():
"""返回当前已实例化的 checkpointer(清理任务用它来删 thread)。
若尚未懒构建过则返回 None。
"""
return _async_checkpointer_singleton
async def shutdown_ai_writing_graph() -> None:
"""关闭 saver 持有的底层连接,并清空 graph 单例。Lifespan shutdown 调用。"""
global AI_WRITING_GRAPH, _async_checkpointer_singleton
saver = _async_checkpointer_singleton
_async_checkpointer_singleton = None
AI_WRITING_GRAPH = None
if saver is None:
return
# aiosqlite saver: saver.conn 是 aiosqlite.Connection
conn = getattr(saver, "conn", None)
if conn is not None and hasattr(conn, "close"):
try:
await conn.close()
except Exception:
logger.warning("ai_writing: 关闭 checkpointer connection 失败", exc_info=True)
# postgres saver: saver.pool 是 AsyncConnectionPool
pool = getattr(saver, "pool", None)
if pool is not None and hasattr(pool, "close"):
try:
await pool.close()
except Exception:
logger.warning("ai_writing: 关闭 checkpointer pool 失败", exc_info=True)
@@ -15,54 +15,6 @@ from langchain_core.runnables import RunnableConfig
logger = logging.getLogger(__name__)
def get_ai_writing_config() -> dict:
"""读 ``config.yaml`` 的 ``ai_writing`` 段。
阶段 7 之前由路由层 ``_make_config`` 把这份 dict 注入到 ``configurable.ai_writing_config``
给节点使用。阶段 7 删冗余后 ``_make_config`` 和注入路径全部消失(前端 ``stream.submit``
的 config 只传 ``model_name``)—— 节点改成自己直接从 ``get_app_config()`` 读,跨边界
更干净,``config.yaml`` 改完也不用重启前端 / 重发起会话。
返回字段(按使用方):
- researcher:``keyword_model`` / ``rank_model`` / ``max_materials`` /
``search_provider`` 等
- editor:``pass_threshold``
"""
try:
from deerflow.config import get_app_config
aw = getattr(get_app_config(), "ai_writing", None)
if isinstance(aw, dict):
return aw
return dict(aw) if aw else {}
except Exception:
logger.exception("ai_writing: 读取 app config 失败,使用空配置")
return {}
def safe_get_stream_writer():
"""返回一个 stream writer callable;在没有 LangGraph runtime 上下文时退化成 no-op。
AI 写作节点用它把流式 chunk 推到 LangGraph 原生 ``custom`` 通道。
场景:
- LangGraph Server 路径(``client.runs.stream`` 订阅了 ``stream_mode=["custom", ...]``)
→ 真实 writer,事件透传给 SDK 消费方。
- 老 ``/start /stream /resume`` 路径(``graph.astream(stream_mode="updates")``)
→ 真实 writer 仍可调用,只是没人订阅 custom 通道,事件被静默丢弃。
这是阶段 2「硬切到 get_stream_writer」的预期行为:老前端 EventSource 不再收到
token-level chunk,但整体进度卡(draft_ready / review_ready 这些 state 级事件)
仍正常 —— 阶段 3 前端切到 useStream 后立刻补齐。
- 单测里直接调节点函数(无 LangGraph runtime) → 返回 no-op,调用方零分支。
"""
try:
from langgraph.config import get_stream_writer # 延迟 import,便于单测打补丁
return get_stream_writer()
except Exception:
return lambda _payload: None
def get_model(config: RunnableConfig, model_name: str | None = None):
"""创建对话模型。
@@ -1,8 +1,8 @@
"""编辑节点:三项独立 LLM 审查(事实/逻辑/语言),逐 token 流式推送以支持打字机效果。"""
from __future__ import annotations
import asyncio
import logging
from typing import Callable
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
@@ -11,7 +11,7 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
)
from ._utils import get_ai_writing_config, get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer
from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser
logger = logging.getLogger(__name__)
@@ -40,9 +40,9 @@ def _parse_issues(raw_issues: list, category: str) -> list[ReviewIssue]:
async def _stream_one_review(
model, user_prompt: str, category: str,
writer: Callable[[dict], None],
sse_queue: asyncio.Queue | None, session_id: str,
) -> str:
"""流式执行单项审查,逐 chunk 经 LangGraph custom 通道推送 review_chunk 事件,返回累积的正文文本(用于 JSON 解析)。"""
"""流式执行单项审查,逐 chunk 推送 review_chunk 事件,返回累积的正文文本(用于 JSON 解析)。"""
full_text = "" # 仅累积正文(非思考),供后续 JSON 解析
parser = ThinkTagParser()
async for chunk in model.astream([
@@ -55,42 +55,16 @@ async def _stream_one_review(
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "review_chunk",
"category": category,
"text": text,
"is_thinking": is_thinking,
})
if sse_queue is not None:
await sse_queue.put(("review_chunk", {
"session_id": session_id,
"category": category,
"text": text,
"is_thinking": is_thinking,
}))
return full_text
async def _save_review_to_repo(config: RunnableConfig, review: ReviewResult) -> None:
"""把 review_result 异步落到业务表(best-effort,写失败不影响流程)。
``user_id`` 走 ``resolve_user_id(AUTO)`` 读 contextvar(与 intent_parser 建行、
writer_draft 写草稿同套规则)。
"""
cfg = config.get("configurable", {}) or {}
thread_id = cfg.get("thread_id")
if not thread_id:
return
from deerflow.persistence.ai_writing_sessions import get_default_store
from deerflow.runtime.user_context import AUTO
repo = get_default_store()
if repo is None:
return
try:
await repo.update(
thread_id,
user_id=AUTO,
status="review_ready",
review_result=dict(review),
)
except Exception:
logger.exception("editor: save_review failed (non-fatal) thread_id=%s", thread_id)
async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
draft = state.get("current_draft")
if not draft:
@@ -104,9 +78,9 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
mats = _materials_summary(pkg)
cfg = config.get("configurable", {})
writer = safe_get_stream_writer()
# 阶段 7 后从 app config 直接读,不走 configurable 注入
ai_writing_config = get_ai_writing_config()
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
ai_writing_config = cfg.get("ai_writing_config", {}) or {}
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
REVIEW_TASKS = [
@@ -136,7 +110,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
for category, user_prompt in REVIEW_TASKS:
fallback = {"score": 80, "issues": []}
try:
full_text = await _stream_one_review(model, user_prompt, category, writer)
full_text = await _stream_one_review(model, user_prompt, category, sse_queue, session_id)
res = parse_json(full_text, fallback)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
@@ -146,13 +120,16 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
results[category] = res
# 这一项审查完成 —— 推送结构化结果(前端据此把流式文本替换为评分/意见)
issues = _parse_issues(res.get("issues", []), category)
writer({
"type": "review_item_done",
"category": category,
"score": int(res.get("score", 80)),
"issues": [dict(iss) for iss in issues],
})
if sse_queue is not None:
issues = _parse_issues(res.get("issues", []), category)
await sse_queue.put(("review_item_done", {
"session_id": session_id,
"category": category,
"score": int(res.get("score", 80)),
"issues": [dict(iss) for iss in issues],
}))
else:
logger.debug("editor [%s] done (no sse_queue)", category)
# 汇总
fact_score = int(results["fact"].get("score", 80))
@@ -179,9 +156,6 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
revision_notes=all_issues,
)
# 阶段 2:节点内并存 DB 写入(路由层同样也写一遍,阶段 7 删冗余时移除路由那份)
await _save_review_to_repo(config, review)
# 分项审查出现模型异常时,以非致命 notice 提示用户,审核结论仍照常产出
notices: list[dict] = []
if review_errors:
@@ -1,64 +1,12 @@
"""意图解析节点:将用户自然语言意图结构化为写作配置。"""
from __future__ import annotations
import logging
import re
from langchain_core.runnables import RunnableConfig
from langchain_core.messages import HumanMessage, SystemMessage
from ..state import AIWritingState
from ._utils import (
ThinkTagParser,
_content_to_text,
get_model,
parse_json,
safe_get_stream_writer,
)
logger = logging.getLogger(__name__)
def _truncate_title(text: str, max_len: int = 50) -> str:
"""与老 /start 路由里 _truncate_title 行为一致:UTF 字符截断 + 省略号。"""
text = (text or "").strip()
if len(text) <= max_len:
return text
return text[:max_len].rstrip() + "…"
async def _upsert_business_row(configurable: dict, state: AIWritingState) -> None:
"""在 intent_parser 入口幂等创建 ai_writing_sessions 业务表行。
LangGraph Server 路径下没有 /start 路由建行,必须节点自建,否则列表页查不到、
transcript / draft / review 后续写入都会因 update 找不到行而静默丢数据。
resume 路径下 thread_id 已经有行,upsert_session 只刷新 updated_at。
user_id 走 ``resolve_user_id(AUTO)``:FastAPI 请求线程把它写到 contextvar,
asyncio.create_task 继承父任务的 context,所以这里能拿到当前用户;
contextvar 没设时(测试 / 离线场景)抛 RuntimeError,try/except 兜底跳过。
"""
from deerflow.persistence.ai_writing_sessions import get_default_store
from deerflow.runtime.user_context import AUTO
thread_id = configurable.get("thread_id")
if not thread_id:
return
repo = get_default_store()
if repo is None:
return
user_intent = (state.get("user_intent") or "").strip()
try:
await repo.upsert_session(
id=thread_id,
title=_truncate_title(user_intent) or "AI 写作",
user_intent=user_intent,
user_id=AUTO,
status="in_progress",
)
except Exception:
logger.exception("intent_parser: upsert_session failed (non-fatal) thread_id=%s", thread_id)
from ._utils import llm_json
# 兜底类型列表,当 config 中未注入 valid_article_types 时使用
_DEFAULT_ARTICLE_TYPES: list[dict] = [
@@ -131,10 +79,6 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
valid_keys: list[str] = [t["key"] for t in valid_article_types]
default_key: str = valid_keys[0] if valid_keys else "analysis"
# 业务表 upsert(LangGraph Server 路径下没有 /start 路由建行,节点入口自建;
# resume 时幂等不重复建。失败不阻断写作流程)。
await _upsert_business_row(configurable, state)
type_options = _build_type_options(valid_article_types)
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
user = _USER_TEMPLATE.format(
@@ -146,79 +90,42 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if fallback_type not in valid_keys:
fallback_type = default_key
fallback_result = {
"article_type": fallback_type,
"target_audience": state.get("target_audience", "普通读者"),
"word_count_target": state.get("word_count_target", 800),
"refined_intent": state["user_intent"],
}
result = await llm_json(
system,
user,
fallback={
"article_type": fallback_type,
"target_audience": state.get("target_audience", "普通读者"),
"word_count_target": state.get("word_count_target", 800),
"refined_intent": state["user_intent"],
},
config=config,
)
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
writer = safe_get_stream_writer()
llm_error: str | None = None
full_text = ""
parser = ThinkTagParser()
try:
model = get_model(config)
async for chunk in model.astream(
[SystemMessage(content=system), HumanMessage(content=user)]
):
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "intent_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as exc:
# 模型 / 网络挂掉 —— 不阻断流程,用 fallback 继续。
logger.error("intent_parser streaming failed: %s", exc, exc_info=True)
llm_error = f"{type(exc).__name__}: {exc}"
result = parse_json(full_text, fallback_result) if full_text else fallback_result
# 意图解析模型调用失败时以非致命 notice 提示用户(fallback 已兜底,流程仍可继续)
# 意图解析模型调用失败时以非致命 notice 提示用户(llm_json 已用 fallback 兜底,
# 流程仍可继续,但用户需知道结构化配置可能不准)。
progress_events: list[dict] = []
if llm_error:
if result.get("_llm_error"):
progress_events.append({
"type": "notice",
"agent_name": "系统",
"title": "模型错误",
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
"detail": f"意图解析失败,已按默认配置兜底。原因:{result['_llm_error']}",
})
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
raw_type = result.get("article_type", "")
final_article_type = raw_type if raw_type in valid_keys else fallback_type
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
word_count = _coerce_word_count(result.get("word_count_target"))
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
final_refined_intent = result.get("refined_intent") or state["user_intent"]
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
writer({
"type": "intent_ready",
"article_type": final_article_type,
"target_audience": final_target_audience,
"word_count_target": final_word_count,
"refined_intent": final_refined_intent,
})
progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"})
updates: dict = {"status": "researching", "progress_events": progress_events}
updates["article_type"] = final_article_type
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
raw_type = result.get("article_type", "")
updates["article_type"] = raw_type if raw_type in valid_keys else fallback_type
if result.get("target_audience"):
updates["target_audience"] = final_target_audience
updates["target_audience"] = result["target_audience"]
word_count = _coerce_word_count(result.get("word_count_target"))
if word_count is not None:
updates["word_count_target"] = final_word_count
updates["word_count_target"] = word_count
if result.get("refined_intent"):
updates["user_intent"] = final_refined_intent
updates["user_intent"] = result["refined_intent"]
return updates
@@ -15,12 +15,12 @@ from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER,
)
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
from ._utils import llm_json
logger = logging.getLogger(__name__)
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
_DEFAULT_MAX_MATERIALS = 50
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 20
_DEFAULT_MAX_MATERIALS = 20
_MIN_MAX_MATERIALS = 1
_MAX_MAX_MATERIALS = 100
@@ -93,80 +93,68 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
article_type = state.get("article_type", "analysis")
target_audience = state.get("target_audience", "普通读者")
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
# 检查是否是补充搜索(PAUSE-1 re_search action)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
action = intervention.get("action")
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
is_supplement = action == "supplement_search"
# PAUSE-1 素材确认卡用户点「追加检索」并填了新关键词:
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
# - 只检索 extra_keywords 这几个新词,不重检索之前 LLM 出过的词
# - 保留 existing material_package,按 URL 去重合并
is_append_only = action == "re_search" and bool(extra_keywords)
is_supplement = intervention.get("action") == "supplement_search"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
# 实时进度推送:阶段 2 起统一走 LangGraph 原生 custom 通道。
# SSE 实时进度推送(与 outline/draft 节点共用 sse_queue 机制)
cfg = config.get("configurable", {})
writer = safe_get_stream_writer()
sse_queue = cfg.get("sse_queue")
sse_session_id: str = cfg.get("sse_session_id", "")
# 素材收集专家专用模型:检索词生成 / 素材排序 可在 config.yaml 的
# ai_writing 段单独配置(keyword_model / rank_model),留空则回退写作主模型。
# 注:阶段 7 后这份配置由节点直接读 app config,不再走 configurable 注入。
ai_writing_config = get_ai_writing_config()
ai_writing_config = cfg.get("ai_writing_config", {}) or {}
keyword_model: str | None = ai_writing_config.get("keyword_model") or None
rank_model: str | None = ai_writing_config.get("rank_model") or None
# 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message})
async def _emit(message: str) -> None:
if sse_queue is None:
return
await sse_queue.put(("research_progress", {
"session_id": sse_session_id,
"message": message,
}))
# Step 1: 提取关键词
kw_result: dict = {} # 走追加分支时跳过 LLM 调用,但下方 notice 拼接需要兜底值
if is_append_only:
# 追加模式:直接用用户填的关键词,不再调 LLM
keywords: list[str] = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
await _emit("正在生成检索词…")
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords: list[str] = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 2: 并发搜索
provider = get_search_provider(ai_writing_config)
# 按用户指定的关键词数量截断。
# 追加模式:keywords 本身就是用户填的,全部检索不截断(用户填 N 个就搜 N 个)。
# 其他模式:受 keyword_count 限制,但 extra_keywords 在内时上限放宽到 keywords 总数。
if is_append_only:
search_keywords = keywords
else:
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
# 按用户指定的关键词数量截断;补充搜索时不截断用户额外添加的关键词
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
total = len(search_keywords)
# 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整)
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
await _emit(f"检索词已生成,开始检索 {total} 个方向…")
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
completed = 0
@@ -178,22 +166,18 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
await _emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
await _emit(f"已完成全部 {total} 次检索")
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested: list[list[SearchResult]] = await asyncio.gather(*search_tasks, return_exceptions=True)
# 合并去重
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
seen_urls: set[str] = set()
raw_materials: list[Material] = []
existing_pkg = state.get("material_package")
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
if existing_pkg and intervention.get("action") != "re_search":
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
@@ -224,7 +208,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
search_errors.extend(getattr(provider, "errors", []))
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
_emit("检索完成,正在筛选排序素材…")
await _emit("检索完成,正在筛选排序素材…")
rank_error = ""
if len(raw_materials) > max_materials:
materials_list = "\n".join(
@@ -257,13 +241,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if not materials:
summary = "未检索到相关素材,可重新搜索或在无素材的情况下继续写作"
# 追加模式下,pkg.keywords 体现到目前为止累积的检索词(旧 + 新追加),
# 让前端列表 / 历史回显能看到完整的搜索路径。
pkg_keywords = keywords
if is_append_only and existing_pkg:
old_kws = list(existing_pkg.get("keywords") or [])
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
pkg = MaterialPackage(keywords=keywords, materials=materials, summary=summary)
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
@@ -281,18 +259,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
raw="\n\n----------\n\n".join(raw_dumps[:3]),
))
# 进度事件文案:追加模式下让用户看到只搜了哪几个新词
step_start_msg = (
f"追加检索关键词:{', '.join(keywords[:3])}"
if is_append_only
else f"搜索关键词:{', '.join(keywords[:3])}"
)
return {
"material_package": pkg,
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
"status": "writing_draft" if is_supplement else "awaiting_material_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "素材收集专家", "message": step_start_msg},
{"type": "step_start", "agent_name": "素材收集专家", "message": f"搜索关键词:{', '.join(keywords[:3])}"},
*notices,
{"type": "materials_ready", "agent_name": "素材收集专家", "message": summary},
],
@@ -1,6 +1,7 @@
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
from __future__ import annotations
import asyncio
import logging
import re
@@ -12,7 +13,7 @@ from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, safe_get_stream_writer
from ._utils import get_model, _content_to_text, count_words, ThinkTagParser
logger = logging.getLogger(__name__)
@@ -63,35 +64,6 @@ def _get_section_materials(material_ids: list[str], pkg: dict) -> str:
return "\n".join(lines) if lines else "(无相关素材)"
async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> None:
"""把 current_draft 异步落到业务表(best-effort,写失败不影响流程)。
``user_id`` 走 ``resolve_user_id(AUTO)`` 读 contextvar(FastAPI 请求线程设置,
asyncio.create_task 继承)。intent_parser 在阶段 4 已用同样规则建行;这里 update
时也用 AUTO 才能命中 owner filter。
"""
cfg = config.get("configurable", {}) or {}
thread_id = cfg.get("thread_id")
if not thread_id:
return
from deerflow.persistence.ai_writing_sessions import get_default_store
from deerflow.runtime.user_context import AUTO
repo = get_default_store()
if repo is None:
return
try:
await repo.update(
thread_id,
user_id=AUTO,
status="draft_ready",
draft_title=draft.get("title") or "",
draft_markdown=draft.get("full_markdown") or "",
)
except Exception:
logger.exception("writer_draft: save_draft failed (non-fatal) thread_id=%s", thread_id)
async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict:
outline = state.get("current_outline")
if not outline:
@@ -108,10 +80,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
writing_mode = state.get("writing_mode", "loose")
loose_titles: set[str] = set(intervention.get("_loose_sections") or [])
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
# cfg 仍保留以便阶段 4 取 user_id / thread_id 用。
# SSE 流式推送所需
cfg = config.get("configurable", {})
writer = safe_get_stream_writer()
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
# 修改意见(来自用户或编辑)
revision_notes = ""
@@ -202,23 +174,25 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
if sse_queue is not None:
await sse_queue.put(("draft_chunk", {
"session_id": session_id,
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
}))
# 章节完成
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": "",
"is_section_done": True,
})
if sse_queue is not None:
await sse_queue.put(("draft_chunk", {
"session_id": session_id,
"section_index": i,
"section_title": sec_title,
"text": "",
"is_section_done": True,
}))
cleaned_buf = section_buf.strip()
blocked_reason = ""
@@ -315,11 +289,6 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
revision_count=revision_count,
)
# 阶段 2:节点内并存 DB 写入(路由层 _stream_graph_segment 同样也会写一遍,
# 阶段 7 删冗余时移除路由层那份)。new path (LangGraph Server) 下路由层
# 不再介入,节点写就是唯一来源。
await _save_draft_to_repo(config, draft)
# 分节写作出现模型异常时,以非致命 notice 提示用户,草稿仍照常产出
notices: list[dict] = []
if section_errors:
@@ -1,6 +1,7 @@
"""作家节点 Phase 1:生成文章大纲,支持流式 chunk 输出。"""
from __future__ import annotations
import asyncio
import json
import logging
@@ -11,7 +12,7 @@ from ..state import AIWritingState, Outline, OutlineSection
from ..prompts.writer_prompts import (
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
)
from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer
from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser
logger = logging.getLogger(__name__)
@@ -78,8 +79,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
feedback_section=feedback_section,
)
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
writer = safe_get_stream_writer()
# SSE 流式推送
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
full_text = "" # 只累积正文(非思考)用于 JSON 解析
parser = ThinkTagParser()
@@ -98,11 +101,12 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
if sse_queue is not None:
await sse_queue.put(("outline_chunk", {
"session_id": session_id,
"text": text,
"is_thinking": is_thinking,
}))
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
@@ -268,7 +268,6 @@ def _build_middlewares(
# Add TodoList middleware if plan mode is enabled
cfg = _get_runtime_config(config)
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None:
middlewares.append(todo_list_middleware)
@@ -280,10 +279,8 @@ def _build_middlewares(
# Add TitleMiddleware
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add MemoryMiddleware (after TitleMiddleware)
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
@@ -339,18 +336,13 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
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"))
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
except FileNotFoundError:
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
agent_config = None
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
agent_display_name = agent_config.name if agent_config else agent_id
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
agent_model_name = agent_config.model if agent_config and agent_config.model else None
@@ -427,7 +419,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=None,
is_bootstrap=True,
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
),
state_schema=ThreadState,
)
@@ -452,7 +443,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_artifact_path=writing_artifact_path,
available_skills=(set(agent_config.skills or []) if agent_config else None),
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
),
state_schema=ThreadState,
)
@@ -604,7 +604,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
"""
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str:
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None) -> str:
"""组装注入系统提示词的记忆上下文 (Memory V2)。
通过 :class:`MemoryManager` 收集各 provider 的记忆块。P1 期只有 builtin
@@ -614,13 +614,10 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
Args:
agent_name: agent 标识;``None`` 时按 "default" 处理。
app_config: 显式应用配置 (本函数实际按 per-user 覆盖解析生效配置)。
injection_enabled: 运行时开关;``False`` 时跳过注入直接返回空串。
Returns:
包在 ``<memory>`` 标签里的记忆上下文;未启用或为空时返回空串。
"""
if not injection_enabled:
return ""
try:
from deerflow.agents.memory.manager import build_memory_manager
from deerflow.config.memory_config import get_effective_memory_config
@@ -856,12 +853,11 @@ def apply_prompt_template(
available_skills: set[str] | None = None,
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name
# Get memory context
memory_context = _get_memory_context(storage_agent_id, app_config=app_config, injection_enabled=memory_injection_enabled)
memory_context = _get_memory_context(storage_agent_id, app_config=app_config)
# Include subagent section only if enabled (from runtime parameter)
n = max_concurrent_subagents
@@ -223,11 +223,8 @@ def build_memory_manager(
) -> MemoryManager:
"""按生效配置组装并初始化一个 MemoryManager。
这是构建 manager 的统一入口 —— 根据 ``config.version`` 选择 V1 或 V2 provider:
- ``version="v1"``: 仅注册 V1BuiltinProvider (memory.json + LLM 自动提取)
- ``version="v2"``: 注册 BuiltinFileProvider + 可选 HindsightProvider (USER.md/MEMORY.md)
这是构建 manager 的统一入口 —— 注册 builtin provider,以及在
``config.provider == "hindsight"`` 且 Hindsight 可用时注册 HindsightProvider。
所有 provider 已 ``initialize`` 完毕,可直接使用。
Parameters
@@ -237,6 +234,7 @@ def build_memory_manager(
config:
生效的 :class:`MemoryConfig`;为 ``None`` 时按 ``user_id`` 解析 per-user 配置。
"""
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
from deerflow.config.memory_config import get_effective_memory_config
from deerflow.config.paths import get_paths
@@ -244,53 +242,34 @@ def build_memory_manager(
config = get_effective_memory_config(user_id)
manager = MemoryManager()
base_dir = str(get_paths().base_dir)
if getattr(config, "version", "v2") == "v1":
# V1 模式:原版 DeerFlow memory.json + LLM 自动提取
from deerflow.agents.memory.providers.v1_builtin import V1BuiltinProvider
v1_cfg = config.v1
if config.builtin.enabled:
manager.add_provider(
V1BuiltinProvider(
max_facts=v1_cfg.max_facts,
fact_confidence_threshold=v1_cfg.fact_confidence_threshold,
debounce_seconds=v1_cfg.debounce_seconds,
model_name=v1_cfg.model_name,
token_budget=v1_cfg.token_budget,
BuiltinFileProvider(
memory_char_limit=config.builtin.memory_char_limit,
user_char_limit=config.builtin.user_char_limit,
deduplicate_on_load=config.builtin.deduplicate_on_load,
security=config.security,
)
)
else:
# V2 模式:USER.md / MEMORY.md + 可选 Hindsight
from deerflow.agents.memory.providers.builtin import BuiltinFileProvider
if config.builtin.enabled:
manager.add_provider(
BuiltinFileProvider(
memory_char_limit=config.builtin.memory_char_limit,
user_char_limit=config.builtin.user_char_limit,
deduplicate_on_load=config.builtin.deduplicate_on_load,
security=config.security,
)
)
if config.provider == "hindsight":
try:
from deerflow.agents.memory.providers.hindsight import HindsightProvider
if config.provider == "hindsight":
try:
from deerflow.agents.memory.providers.hindsight import HindsightProvider
hindsight = HindsightProvider(config.hindsight)
if hindsight.is_available():
manager.add_provider(hindsight)
else:
logger.info("Hindsight provider 已配置但不可用 (包未装或缺 api_url),退回纯 builtin")
except Exception as exc: # noqa: BLE001 - Hindsight 接入失败不能阻断记忆系统
logger.warning("加载 Hindsight provider 失败,退回纯 builtin:%s", exc)
hindsight = HindsightProvider(config.hindsight)
if hindsight.is_available():
manager.add_provider(hindsight)
else:
logger.info("Hindsight provider 已配置但不可用 (包未装或缺 api_url),退回纯 builtin")
except Exception as exc: # noqa: BLE001 - Hindsight 接入失败不能阻断记忆系统
logger.warning("加载 Hindsight provider 失败,退回纯 builtin:%s", exc)
manager.initialize_all(
user_id=user_id,
agent_id=agent_id or "default",
thread_id=thread_id,
base_dir=base_dir,
base_dir=str(get_paths().base_dir),
)
return manager
@@ -69,42 +69,3 @@ def filter_messages_for_memory(messages: list[Any]) -> list[Any]:
filtered.append(msg)
return filtered
_CORRECTION_PATTERNS = re.compile(
r"\b(?:no[,.]?\s+(?:that'?s?\s+)?(?:wrong|incorrect|not\s+right|not\s+what)|"
r"actually[,.]?\s+(?:it\s+(?:is|should)|you\s+(?:should|need|must)|the\s+correct)|"
r"that'?s?\s+(?:wrong|incorrect|not\s+right|not\s+what\s+i)|"
r"you\s+(?:got\s+it\s+wrong|misunderstood|made\s+an?\s+error|made\s+a\s+mistake)|"
r"incorrect[,.]|wrong[,.]|mistake[,.]|correction:|that\s+is\s+not\s+right)\b",
re.IGNORECASE,
)
_REINFORCEMENT_PATTERNS = re.compile(
r"\b(?:perfect[,.]?|exactly[,.]?|correct[,.]?|that'?s?\s+(?:right|correct|perfect|exactly\s+right)|"
r"you\s+(?:got\s+it|nailed\s+it)|great\s+(?:job|work)|well\s+done|"
r"yes[,.]?\s+(?:that'?s?\s+(?:right|correct|exactly)|exactly)|"
r"keep\s+(?:it\s+up|doing\s+that|going)|"
r"(?:that'?s?\s+|this\s+is\s+)?(?:exactly|precisely)\s+(?:what\s+i|right))\b",
re.IGNORECASE,
)
def detect_correction(messages: list[Any]) -> bool:
"""Detect if the conversation contains explicit user correction signals."""
for msg in messages:
if getattr(msg, "type", None) == "human":
text = extract_message_text(msg)
if _CORRECTION_PATTERNS.search(text):
return True
return False
def detect_reinforcement(messages: list[Any]) -> bool:
"""Detect if the conversation contains positive reinforcement signals."""
for msg in messages:
if getattr(msg, "type", None) == "human":
text = extract_message_text(msg)
if _REINFORCEMENT_PATTERNS.search(text):
return True
return False
@@ -1,161 +0,0 @@
"""Memory V1 — 原版 DeerFlow JSON-based MemoryProvider 实现。
存储格式:``{base_dir}/users/{user_id}/memory.json``(经 FileMemoryStorage 管理)
JSON 结构使用嵌套 ``{summary, updatedAt}`` 对象,与原版 DeerFlow 完全一致。
LLM 在 V1 模式下不调用 memory 工具 —— 事实完全由异步 LLM 提取。
"""
from __future__ import annotations
import logging
from typing import Any
from deerflow.agents.memory.provider import MemoryProvider
logger = logging.getLogger(__name__)
class _FakeMsg:
"""轻量 message 对象,用于将文本传给原版 format_conversation_for_update。"""
__slots__ = ("type", "content")
def __init__(self, type_: str, content: str) -> None:
self.type = type_
self.content = content
class V1BuiltinProvider(MemoryProvider):
"""原版 DeerFlow V1 记忆 provider。"""
def __init__(
self,
*,
max_facts: int = 100,
fact_confidence_threshold: float = 0.7,
debounce_seconds: int = 30,
model_name: str = "",
token_budget: int = 2000,
) -> None:
self._max_facts = max_facts
self._confidence_threshold = fact_confidence_threshold
self._debounce_seconds = debounce_seconds
self._model_name = model_name
self._token_budget = token_budget
self._user_id: str = ""
self._thread_id: str = ""
self._agent_id: str | None = None
self._frozen_block: str = ""
@property
def name(self) -> str:
return "v1_builtin"
def is_available(self) -> bool:
return True
def initialize(
self,
*,
user_id: str,
agent_id: str | None = None,
thread_id: str | None = None,
base_dir: str,
**kwargs: Any,
) -> None:
_ = base_dir # FileMemoryStorage uses get_paths() internally
self._user_id = user_id
self._thread_id = thread_id or ""
self._agent_id = agent_id
self._frozen_block = self._build_frozen_block()
# ---- 系统提示词 -------------------------------------------------------
def _build_frozen_block(self) -> str:
"""从 memory.json 读取数据,用 format_memory_for_injection 渲染注入块。
当 agent_id 已知时加载 per-agent 记忆;否则加载用户级记忆。
"""
try:
from deerflow.agents.memory.v1.prompt import format_memory_for_injection
from deerflow.agents.memory.v1.storage import get_memory_storage
data = get_memory_storage().load(
agent_name=self._agent_id or None,
user_id=self._user_id,
)
text = format_memory_for_injection(data, max_tokens=self._token_budget)
if not text:
return ""
return f"<memory>\n{text}\n</memory>"
except Exception as exc: # noqa: BLE001
logger.warning("V1 provider 加载 memory.json 失败: %s", exc)
return ""
def system_prompt_block(self) -> str:
return self._frozen_block
# ---- 召回 / 写入 ------------------------------------------------------
def prefetch(self, query: str, *, thread_id: str = "") -> str:
# V1 无动态召回,静态 snapshot 已在 system_prompt_block 里
return ""
def sync_turn(
self,
user_content: str,
assistant_content: str,
*,
thread_id: str = "",
turn_index: int | None = None,
) -> None:
"""把本轮对话加入防抖队列,等待 LLM 异步提取事实。"""
if not user_content.strip() and not assistant_content.strip():
return
try:
from deerflow.agents.memory.v1.queue import get_memory_queue
msgs = [
_FakeMsg("human", user_content),
_FakeMsg("ai", assistant_content),
]
queue = get_memory_queue()
queue.add(
thread_id=thread_id or self._thread_id,
messages=msgs,
user_id=self._user_id,
agent_name=self._agent_id or None,
)
except Exception as exc: # noqa: BLE001
logger.warning("V1 provider sync_turn 入队失败: %s", exc)
# ---- 工具 (V1 不暴露工具给 LLM) -------------------------------------
def get_tool_schemas(self) -> list[dict[str, Any]]:
return []
# ---- API 读取接口 (供 Gateway router 用) -----------------------------
def read_v1_data(self) -> dict[str, Any]:
"""读取 memory.json 的当前内容(实时读磁盘,非 frozen snapshot)。
当 agent_id 已知时读取 per-agent 记忆;否则读用户级记忆。
"""
from deerflow.agents.memory.v1.storage import get_memory_storage
return get_memory_storage().load(
agent_name=self._agent_id or None,
user_id=self._user_id,
)
def fact_count(self) -> int:
try:
return len(self.read_v1_data().get("facts", []))
except Exception: # noqa: BLE001
return 0
def usage_text(self) -> str:
return f"{self.fact_count()}/{self._max_facts} 个事实"
__all__ = ["V1BuiltinProvider"]
@@ -1 +0,0 @@
"""Memory V1 — 原版 DeerFlow JSON-based 记忆子模块。"""
@@ -1,371 +0,0 @@
"""Prompt templates for memory update and injection."""
import math
import re
from typing import Any
try:
import tiktoken
TIKTOKEN_AVAILABLE = True
except ImportError:
TIKTOKEN_AVAILABLE = False
# Prompt template for updating memory based on conversation
MEMORY_UPDATE_PROMPT = """You are a memory management system. Your task is to analyze a conversation and update the user's memory profile.
Current Memory State:
<current_memory>
{current_memory}
</current_memory>
New Conversation to Process:
<conversation>
{conversation}
</conversation>
Instructions:
1. Analyze the conversation for important information about the user
2. Extract relevant facts, preferences, and context with specific details (numbers, names, technologies)
3. Update the memory sections as needed following the detailed length guidelines below
Before extracting facts, perform a structured reflection on the conversation:
1. Error/Retry Detection: Did the agent encounter errors, require retries, or produce incorrect results?
If yes, record the root cause and correct approach as a high-confidence fact with category "correction".
2. User Correction Detection: Did the user correct the agent's direction, understanding, or output?
If yes, record the correct interpretation or approach as a high-confidence fact with category "correction".
Include what went wrong in "sourceError" only when category is "correction" and the mistake is explicit in the conversation.
3. Project Constraint Discovery: Were any project-specific constraints discovered during the conversation?
If yes, record them as facts with the most appropriate category and confidence.
{correction_hint}
Memory Section Guidelines:
**User Context** (Current state - concise summaries):
- workContext: Professional role, company, key projects, main technologies (2-3 sentences)
Example: Core contributor, project names with metrics (16k+ stars), technical stack
- personalContext: Languages, communication preferences, key interests (1-2 sentences)
Example: Bilingual capabilities, specific interest areas, expertise domains
- topOfMind: Multiple ongoing focus areas and priorities (3-5 sentences, detailed paragraph)
Example: Primary project work, parallel technical investigations, ongoing learning/tracking
Include: Active implementation work, troubleshooting issues, market/research interests
Note: This captures SEVERAL concurrent focus areas, not just one task
**History** (Temporal context - rich paragraphs; updated incrementally each conversation):
- recentMonths: Running log of recent work and activities (4-6 sentences or 1-2 paragraphs)
Scope: Accumulate from each conversation — any task the user worked on, problems solved, technologies touched
Update policy: Set shouldUpdate=true whenever the conversation contains any task, discussion, or work activity; integrate new content into existing text rather than overwriting
Include: Technologies explored, projects worked on, problems solved, interests demonstrated
- earlierContext: Important patterns promoted from recentMonths over time (3-5 sentences or 1 paragraph)
Scope: Recurring themes and established patterns across multiple conversations
Update policy: Set shouldUpdate=true when a pattern repeats or an older item in recentMonths should be archived here
Include: Past projects, learning journeys, established patterns
- longTermBackground: Persistent foundational context that rarely changes (2-4 sentences)
Scope: Core expertise, fundamental working style, long-standing interests
Update policy: Set shouldUpdate=true only when a genuinely new foundational fact emerges
Include: Core expertise, longstanding interests, fundamental working style
**Facts Extraction**:
- Extract specific, quantifiable details (e.g., "16k+ GitHub stars", "200+ datasets")
- Include proper nouns (company names, project names, technology names)
- Preserve technical terminology and version numbers
- Categories:
* preference: Tools, styles, approaches user prefers/dislikes
* knowledge: Specific expertise, technologies mastered, domain knowledge
* context: Background facts (job title, projects, locations, languages)
* behavior: Working patterns, communication habits, problem-solving approaches
* goal: Stated objectives, learning targets, project ambitions
* correction: Explicit agent mistakes or user corrections, including the correct approach
- Confidence levels:
* 0.9-1.0: Explicitly stated facts ("I work on X", "My role is Y")
* 0.7-0.8: Strongly implied from actions/discussions
* 0.5-0.6: Inferred patterns (use sparingly, only for clear patterns)
**What Goes Where**:
- workContext: Current job, active projects, primary tech stack
- personalContext: Languages, personality, interests outside direct work tasks
- topOfMind: Multiple ongoing priorities and focus areas user cares about recently (gets updated most frequently)
Should capture 3-5 concurrent themes: main work, side explorations, learning/tracking interests
- recentMonths: Cumulative log of recent work — update from EVERY conversation with meaningful content
- earlierContext: Recurring patterns promoted from recentMonths; update when patterns solidify
- longTermBackground: Unchanging foundational facts about the user; update only for new foundational info
**Language Requirement**:
- ALL summaries and fact content MUST be written in Chinese (Simplified)
- Keep technical terms, proper nouns, product names, and company names in their original form (e.g., DeepSeek, LangGraph, GitHub)
- Surrounding descriptive text must be in Chinese regardless of the conversation language
Output Format (JSON):
{{
"user": {{
"workContext": {{ "summary": "...", "shouldUpdate": true/false }},
"personalContext": {{ "summary": "...", "shouldUpdate": true/false }},
"topOfMind": {{ "summary": "...", "shouldUpdate": true/false }}
}},
"history": {{
"recentMonths": {{ "summary": "...", "shouldUpdate": true/false }},
"earlierContext": {{ "summary": "...", "shouldUpdate": true/false }},
"longTermBackground": {{ "summary": "...", "shouldUpdate": true/false }}
}},
"newFacts": [
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
],
"factsToRemove": ["fact_id_1", "fact_id_2"]
}}
Important Rules:
- For recentMonths: set shouldUpdate=true whenever the conversation contains any task, work, or substantive discussion — this field accumulates like a log
- For earlierContext / longTermBackground: set shouldUpdate=true only when a new pattern solidifies or a foundational fact changes
- For user context fields: set shouldUpdate=true if there's meaningful new information
- Follow length guidelines: workContext/personalContext are concise (1-3 sentences), topOfMind and history sections are detailed (paragraphs)
- Include specific metrics, version numbers, and proper nouns in facts
- Only add facts that are clearly stated (0.9+) or strongly implied (0.7+)
- Use category "correction" for explicit agent mistakes or user corrections; assign confidence >= 0.95 when the correction is explicit
- Include "sourceError" only for explicit correction facts when the prior mistake or wrong approach is clearly stated; omit it otherwise
- Remove facts that are contradicted by new information
- When updating topOfMind, integrate new focus areas while removing completed/abandoned ones
Keep 3-5 concurrent focus themes that are still active and relevant
- For history sections, integrate new information chronologically into appropriate time period
- Preserve technical accuracy - keep exact names of technologies, companies, projects
- Focus on information useful for future interactions and personalization
- IMPORTANT: Do NOT record file upload events in memory. Uploaded files are
session-specific and ephemeral — they will not be accessible in future sessions.
Recording upload events causes confusion in subsequent conversations.
- CRITICAL: Write ALL summary text and fact content in Chinese (Simplified). Technical terms and proper nouns (product names, company names, technology names) may remain in their original language, but all other text must be in Chinese.
Return ONLY valid JSON, no explanation or markdown."""
# Prompt template for extracting facts from a single message
FACT_EXTRACTION_PROMPT = """Extract factual information about the user from this message.
Message:
{message}
Extract facts in this JSON format:
{{
"facts": [
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
]
}}
Categories:
- preference: User preferences (likes/dislikes, styles, tools)
- knowledge: User's expertise or knowledge areas
- context: Background context (location, job, projects)
- behavior: Behavioral patterns
- goal: User's goals or objectives
- correction: Explicit corrections or mistakes to avoid repeating
Rules:
- Only extract clear, specific facts
- Confidence should reflect certainty (explicit statement = 0.9+, implied = 0.6-0.8)
- Skip vague or temporary information
- Write all fact content in Chinese (Simplified); technical terms and proper nouns may remain in their original language
Return ONLY valid JSON."""
def _count_tokens(text: str, encoding_name: str = "cl100k_base") -> int:
"""Count tokens in text using tiktoken.
Args:
text: The text to count tokens for.
encoding_name: The encoding to use (default: cl100k_base for GPT-4/3.5).
Returns:
The number of tokens in the text.
"""
if not TIKTOKEN_AVAILABLE:
return len(text) // 4
try:
encoding = tiktoken.get_encoding(encoding_name)
return len(encoding.encode(text))
except Exception:
return len(text) // 4
def _coerce_confidence(value: Any, default: float = 0.0) -> float:
"""Coerce a confidence-like value to a bounded float in [0, 1].
Non-finite values (NaN, inf, -inf) are treated as invalid and fall back
to the default before clamping, preventing them from dominating ranking.
The ``default`` parameter is assumed to be a finite value.
"""
try:
confidence = float(value)
except (TypeError, ValueError):
return max(0.0, min(1.0, default))
if not math.isfinite(confidence):
return max(0.0, min(1.0, default))
return max(0.0, min(1.0, confidence))
def format_memory_for_injection(memory_data: dict[str, Any], max_tokens: int = 2000) -> str:
"""Format memory data for injection into system prompt.
Args:
memory_data: The memory data dictionary.
max_tokens: Maximum tokens to use (counted via tiktoken for accuracy).
Returns:
Formatted memory string for system prompt injection.
"""
if not memory_data:
return ""
sections = []
# Format user context
user_data = memory_data.get("user", {})
if user_data:
user_sections = []
work_ctx = user_data.get("workContext", {})
if work_ctx.get("summary"):
user_sections.append(f"Work: {work_ctx['summary']}")
personal_ctx = user_data.get("personalContext", {})
if personal_ctx.get("summary"):
user_sections.append(f"Personal: {personal_ctx['summary']}")
top_of_mind = user_data.get("topOfMind", {})
if top_of_mind.get("summary"):
user_sections.append(f"Current Focus: {top_of_mind['summary']}")
if user_sections:
sections.append("User Context:\n" + "\n".join(f"- {s}" for s in user_sections))
# Format history
history_data = memory_data.get("history", {})
if history_data:
history_sections = []
recent = history_data.get("recentMonths", {})
if recent.get("summary"):
history_sections.append(f"Recent: {recent['summary']}")
earlier = history_data.get("earlierContext", {})
if earlier.get("summary"):
history_sections.append(f"Earlier: {earlier['summary']}")
background = history_data.get("longTermBackground", {})
if background.get("summary"):
history_sections.append(f"Background: {background['summary']}")
if history_sections:
sections.append("History:\n" + "\n".join(f"- {s}" for s in history_sections))
# Format facts (sorted by confidence; include as many as token budget allows)
facts_data = memory_data.get("facts", [])
if isinstance(facts_data, list) and facts_data:
ranked_facts = sorted(
(f for f in facts_data if isinstance(f, dict) and isinstance(f.get("content"), str) and f.get("content").strip()),
key=lambda fact: _coerce_confidence(fact.get("confidence"), default=0.0),
reverse=True,
)
base_text = "\n\n".join(sections)
base_tokens = _count_tokens(base_text) if base_text else 0
facts_header = "Facts:\n"
separator_tokens = _count_tokens("\n\n" + facts_header) if base_text else _count_tokens(facts_header)
running_tokens = base_tokens + separator_tokens
fact_lines: list[str] = []
for fact in ranked_facts:
content_value = fact.get("content")
if not isinstance(content_value, str):
continue
content = content_value.strip()
if not content:
continue
category = str(fact.get("category", "context")).strip() or "context"
confidence = _coerce_confidence(fact.get("confidence"), default=0.0)
source_error = fact.get("sourceError")
if category == "correction" and isinstance(source_error, str) and source_error.strip():
line = f"- [{category} | {confidence:.2f}] {content} (avoid: {source_error.strip()})"
else:
line = f"- [{category} | {confidence:.2f}] {content}"
line_text = ("\n" + line) if fact_lines else line
line_tokens = _count_tokens(line_text)
if running_tokens + line_tokens <= max_tokens:
fact_lines.append(line)
running_tokens += line_tokens
else:
break
if fact_lines:
sections.append("Facts:\n" + "\n".join(fact_lines))
if not sections:
return ""
result = "\n\n".join(sections)
token_count = _count_tokens(result)
if token_count > max_tokens:
char_per_token = len(result) / token_count
target_chars = int(max_tokens * char_per_token * 0.95)
result = result[:target_chars] + "\n..."
return result
def format_conversation_for_update(messages: list[Any]) -> str:
"""Format conversation messages for memory update prompt.
Args:
messages: List of conversation messages.
Returns:
Formatted conversation string.
"""
lines = []
for msg in messages:
role = getattr(msg, "type", "unknown")
content = getattr(msg, "content", str(msg))
# Handle content that might be a list (multimodal)
if isinstance(content, list):
text_parts = []
for p in content:
if isinstance(p, str):
text_parts.append(p)
elif isinstance(p, dict):
text_val = p.get("text")
if isinstance(text_val, str):
text_parts.append(text_val)
content = " ".join(text_parts) if text_parts else str(content)
# Strip uploaded_files tags from human messages to avoid persisting
# ephemeral file path info into long-term memory. Skip the turn entirely
# when nothing remains after stripping (upload-only message).
if role == "human":
content = re.sub(r"<uploaded_files>[\s\S]*?</uploaded_files>\n*", "", str(content)).strip()
if not content:
continue
# Truncate very long messages
if len(str(content)) > 1000:
content = str(content)[:1000] + "..."
if role == "human":
lines.append(f"User: {content}")
elif role == "ai":
lines.append(f"Assistant: {content}")
return "\n\n".join(lines)
__all__ = [
"MEMORY_UPDATE_PROMPT",
"FACT_EXTRACTION_PROMPT",
"format_memory_for_injection",
"format_conversation_for_update",
"_count_tokens",
"_coerce_confidence",
]
@@ -1,253 +0,0 @@
"""Memory V1 — 防抖异步更新队列(原版 DeerFlow MemoryUpdateQueue)。"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
logger = logging.getLogger(__name__)
def _get_v1_cfg():
from deerflow.config.memory_config import get_memory_config
return get_memory_config().v1
def _memory_enabled() -> bool:
from deerflow.config.memory_config import get_memory_config
return get_memory_config().enabled
@dataclass
class ConversationContext:
"""Context for a conversation to be processed for memory update."""
thread_id: str
messages: list[Any]
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
agent_name: str | None = None
user_id: str | None = None
correction_detected: bool = False
reinforcement_detected: bool = False
class MemoryUpdateQueue:
"""Queue for memory updates with debounce mechanism.
Collects conversation contexts and processes them after a configurable
debounce period. Multiple conversations received within the debounce
window are deduplicated by (thread_id, user_id, agent_name).
"""
def __init__(self) -> None:
self._queue: list[ConversationContext] = []
self._lock = threading.Lock()
self._timer: threading.Timer | None = None
self._processing = False
@staticmethod
def _queue_key(thread_id: str, user_id: str | None, agent_name: str | None) -> tuple[str, str | None, str | None]:
return (thread_id, user_id, agent_name)
def add(
self,
thread_id: str,
messages: list[Any],
agent_name: str | None = None,
user_id: str | None = None,
correction_detected: bool = False,
reinforcement_detected: bool = False,
) -> None:
"""Add a conversation to the update queue (with debounce)."""
if not _memory_enabled():
return
with self._lock:
self._enqueue_locked(
thread_id=thread_id,
messages=messages,
agent_name=agent_name,
user_id=user_id,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
)
self._reset_timer()
logger.info("V1 memory update queued for thread %s, queue size: %d", thread_id, len(self._queue))
def add_nowait(
self,
thread_id: str,
messages: list[Any],
agent_name: str | None = None,
user_id: str | None = None,
correction_detected: bool = False,
reinforcement_detected: bool = False,
) -> None:
"""Add a conversation and start processing immediately in the background."""
if not _memory_enabled():
return
with self._lock:
self._enqueue_locked(
thread_id=thread_id,
messages=messages,
agent_name=agent_name,
user_id=user_id,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
)
self._schedule_timer(0)
logger.info("V1 memory update queued for immediate processing on thread %s, queue size: %d", thread_id, len(self._queue))
def _enqueue_locked(
self,
*,
thread_id: str,
messages: list[Any],
agent_name: str | None,
user_id: str | None,
correction_detected: bool,
reinforcement_detected: bool,
) -> None:
queue_key = self._queue_key(thread_id, user_id, agent_name)
existing = next(
(ctx for ctx in self._queue if self._queue_key(ctx.thread_id, ctx.user_id, ctx.agent_name) == queue_key),
None,
)
merged_correction = correction_detected or (existing.correction_detected if existing is not None else False)
merged_reinforcement = reinforcement_detected or (existing.reinforcement_detected if existing is not None else False)
context = ConversationContext(
thread_id=thread_id,
messages=messages,
agent_name=agent_name,
user_id=user_id,
correction_detected=merged_correction,
reinforcement_detected=merged_reinforcement,
)
self._queue = [ctx for ctx in self._queue if self._queue_key(ctx.thread_id, ctx.user_id, ctx.agent_name) != queue_key]
self._queue.append(context)
def _reset_timer(self) -> None:
cfg = _get_v1_cfg()
self._schedule_timer(cfg.debounce_seconds)
logger.debug("V1 memory update timer set for %ss", cfg.debounce_seconds)
def _schedule_timer(self, delay_seconds: float) -> None:
if self._timer is not None:
self._timer.cancel()
self._timer = threading.Timer(delay_seconds, self._process_queue)
self._timer.daemon = True
self._timer.start()
def _process_queue(self) -> None:
from deerflow.agents.memory.v1.updater import MemoryUpdater
with self._lock:
if self._processing:
self._schedule_timer(0)
return
if not self._queue:
return
self._processing = True
contexts_to_process = self._queue.copy()
self._queue.clear()
self._timer = None
logger.info("V1 memory: processing %d queued updates", len(contexts_to_process))
try:
updater = MemoryUpdater()
for context in contexts_to_process:
try:
logger.info("V1 memory: updating for thread %s", context.thread_id)
success = updater.update_memory(
messages=context.messages,
thread_id=context.thread_id,
agent_name=context.agent_name,
correction_detected=context.correction_detected,
reinforcement_detected=context.reinforcement_detected,
user_id=context.user_id,
)
if success:
logger.info("V1 memory: updated successfully for thread %s", context.thread_id)
else:
logger.warning("V1 memory: update skipped/failed for thread %s", context.thread_id)
except Exception as e: # noqa: BLE001
logger.error("V1 memory: error updating for thread %s: %s", context.thread_id, e)
if len(contexts_to_process) > 1:
time.sleep(0.5)
finally:
with self._lock:
self._processing = False
def flush(self) -> None:
"""Force immediate processing of the queue."""
with self._lock:
if self._timer is not None:
self._timer.cancel()
self._timer = None
self._process_queue()
def flush_nowait(self) -> None:
"""Start queue processing immediately in a background thread."""
with self._lock:
self._schedule_timer(0)
def clear(self) -> None:
"""Clear the queue without processing (for testing)."""
with self._lock:
if self._timer is not None:
self._timer.cancel()
self._timer = None
self._queue.clear()
self._processing = False
@property
def pending_count(self) -> int:
with self._lock:
return len(self._queue)
@property
def is_processing(self) -> bool:
with self._lock:
return self._processing
_memory_queue: MemoryUpdateQueue | None = None
_queue_lock = threading.Lock()
def get_memory_queue() -> MemoryUpdateQueue:
"""Get the global memory update queue singleton."""
global _memory_queue
with _queue_lock:
if _memory_queue is None:
_memory_queue = MemoryUpdateQueue()
return _memory_queue
def reset_memory_queue() -> None:
"""Reset the global memory queue (for testing)."""
global _memory_queue
with _queue_lock:
if _memory_queue is not None:
_memory_queue.clear()
_memory_queue = None
__all__ = [
"ConversationContext",
"MemoryUpdateQueue",
"get_memory_queue",
"reset_memory_queue",
]
@@ -1,226 +0,0 @@
"""Memory V1 — 存储层(原版 DeerFlow 格式)。
JSON 结构:
{
"version": "1.0",
"lastUpdated": "...",
"user": {
"workContext": {"summary": "", "updatedAt": ""},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""}
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground":{"summary": "", "updatedAt": ""}
},
"facts": []
}
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
def utc_now_iso_z() -> str:
"""Current UTC time as ISO-8601 with ``Z`` suffix."""
return datetime.now(UTC).isoformat().removesuffix("+00:00") + "Z"
def create_empty_memory() -> dict[str, Any]:
"""Create an empty memory structure (new format)."""
return {
"version": "1.0",
"lastUpdated": utc_now_iso_z(),
"user": {
"workContext": {"summary": "", "updatedAt": ""},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""},
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground": {"summary": "", "updatedAt": ""},
},
"facts": [],
}
def _migrate_legacy(data: dict[str, Any]) -> dict[str, Any]:
"""把老格式 {userContext: {workContext: str}} 迁移到新格式 {user: {workContext: {summary, updatedAt}}}。"""
if "userContext" not in data:
return data
now = utc_now_iso_z()
old_ctx = data.pop("userContext", {})
data["user"] = {
k: {"summary": v or "", "updatedAt": now} if isinstance(v, str) else v
for k, v in old_ctx.items()
}
# history 字段可能是纯字符串,需要包裹
if "history" in data:
hist = data["history"]
data["history"] = {
k: {"summary": v or "", "updatedAt": now} if isinstance(v, str) else v
for k, v in hist.items()
}
# 确保 user 包含所有必要字段
base = create_empty_memory()
for key in ("user", "history"):
section = base[key]
for field in section:
if field not in data.get(key, {}):
data.setdefault(key, {})[field] = section[field]
data["version"] = "1.0"
data.pop("updatedAt", None)
return data
class FileMemoryStorage:
"""File-based memory storage provider (原版 DeerFlow 实现)。"""
def __init__(self) -> None:
# Per-(user_id, agent_name) cache: value = (memory_data, file_mtime)
self._memory_cache: dict[tuple[str | None, str | None], tuple[dict[str, Any], float | None]] = {}
self._cache_lock = threading.Lock()
def _validate_agent_name(self, agent_name: str) -> None:
if not agent_name:
raise ValueError("Agent name must be a non-empty string.")
if not AGENT_NAME_PATTERN.match(agent_name):
raise ValueError(f"Invalid agent name {agent_name!r}: names must match {AGENT_NAME_PATTERN.pattern}")
def _get_memory_file_path(self, agent_name: str | None = None, *, user_id: str | None = None) -> Path:
paths = get_paths()
if user_id is not None:
if agent_name is not None:
self._validate_agent_name(agent_name)
return paths.user_agent_memory_file(user_id, agent_name)
return paths.user_memory_file(user_id)
# Legacy: no user_id
if agent_name is not None:
self._validate_agent_name(agent_name)
return paths.agent_memory_file(agent_name)
return paths.memory_file
def _load_from_file(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
if not file_path.exists():
return create_empty_memory()
try:
with open(file_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return create_empty_memory()
data = _migrate_legacy(data)
return data
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to load memory file %s: %s", file_path, e)
return create_empty_memory()
@staticmethod
def _cache_key(agent_name: str | None = None, *, user_id: str | None = None) -> tuple[str | None, str | None]:
return (user_id, agent_name)
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
"""Load memory data (cached with file modification time check)."""
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
cache_key = self._cache_key(agent_name, user_id=user_id)
try:
current_mtime = file_path.stat().st_mtime if file_path.exists() else None
except OSError:
current_mtime = None
with self._cache_lock:
cached = self._memory_cache.get(cache_key)
if cached is not None and cached[1] == current_mtime:
return cached[0]
memory_data = self._load_from_file(agent_name, user_id=user_id)
with self._cache_lock:
self._memory_cache[cache_key] = (memory_data, current_mtime)
return memory_data
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
"""Reload memory data from file, forcing cache invalidation."""
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
memory_data = self._load_from_file(agent_name, user_id=user_id)
cache_key = self._cache_key(agent_name, user_id=user_id)
try:
mtime = file_path.stat().st_mtime if file_path.exists() else None
except OSError:
mtime = None
with self._cache_lock:
self._memory_cache[cache_key] = (memory_data, mtime)
return memory_data
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
"""Save memory data to file and update cache (atomic write)."""
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
cache_key = self._cache_key(agent_name, user_id=user_id)
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
memory_data = {**memory_data, "lastUpdated": utc_now_iso_z()}
temp_path = file_path.with_suffix(f".{uuid.uuid4().hex}.tmp")
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(memory_data, f, indent=2, ensure_ascii=False)
temp_path.replace(file_path)
try:
mtime = file_path.stat().st_mtime
except OSError:
mtime = None
with self._cache_lock:
self._memory_cache[cache_key] = (memory_data, mtime)
logger.info("Memory saved to %s", file_path)
return True
except OSError as e:
logger.error("Failed to save memory file: %s", e)
return False
_storage_instance: FileMemoryStorage | None = None
_storage_lock = threading.Lock()
def get_memory_storage() -> FileMemoryStorage:
"""Get the global FileMemoryStorage singleton."""
global _storage_instance
if _storage_instance is not None:
return _storage_instance
with _storage_lock:
if _storage_instance is None:
_storage_instance = FileMemoryStorage()
return _storage_instance
__all__ = [
"create_empty_memory",
"utc_now_iso_z",
"FileMemoryStorage",
"get_memory_storage",
]
@@ -1,55 +0,0 @@
"""Hooks fired before summarization removes messages from state(原版 DeerFlow)。"""
from __future__ import annotations
from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory
from deerflow.agents.memory.v1.queue import get_memory_queue
from deerflow.config.memory_config import get_memory_config
def memory_flush_hook(event: object) -> None:
"""Flush messages about to be summarized into the V1 memory queue.
``event`` is expected to be a ``SummarizationEvent``-like object with:
- ``thread_id: str``
- ``messages_to_summarize: list``
- ``agent_name: str | None``
- ``runtime: RunnableConfig``
"""
if not get_memory_config().enabled:
return
thread_id = getattr(event, "thread_id", None)
if not thread_id:
return
messages_to_summarize = getattr(event, "messages_to_summarize", [])
filtered_messages = filter_messages_for_memory(list(messages_to_summarize))
user_messages = [m for m in filtered_messages if getattr(m, "type", None) == "human"]
assistant_messages = [m for m in filtered_messages if getattr(m, "type", None) == "ai"]
if not user_messages or not assistant_messages:
return
correction_detected = detect_correction(filtered_messages)
reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages)
try:
from deerflow.runtime.user_context import resolve_runtime_user_id
runtime = getattr(event, "runtime", None)
user_id = resolve_runtime_user_id(runtime) if runtime is not None else None
except Exception: # noqa: BLE001
user_id = None
agent_name = getattr(event, "agent_name", None)
queue = get_memory_queue()
queue.add_nowait(
thread_id=thread_id,
messages=filtered_messages,
agent_name=agent_name,
user_id=user_id,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
)
__all__ = ["memory_flush_hook"]
@@ -1,495 +0,0 @@
"""Memory V1 — updater(原版 DeerFlow MemoryUpdater)。"""
from __future__ import annotations
import asyncio
import atexit
import concurrent.futures
import copy
import json
import logging
import math
import re
import uuid
from typing import Any
from deerflow.agents.memory.v1.prompt import (
MEMORY_UPDATE_PROMPT,
format_conversation_for_update,
)
from deerflow.agents.memory.v1.storage import (
create_empty_memory,
get_memory_storage,
utc_now_iso_z,
)
logger = logging.getLogger(__name__)
_SYNC_MEMORY_UPDATER_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
max_workers=4,
thread_name_prefix="v1-memory-updater-sync",
)
atexit.register(lambda: _SYNC_MEMORY_UPDATER_EXECUTOR.shutdown(wait=False))
def _get_v1_cfg():
"""返回 V1MemoryConfig(含 max_facts, fact_confidence_threshold, model_name)。"""
from deerflow.config.memory_config import get_memory_config
return get_memory_config().v1
# ---------------------------------------------------------------------------
# 向后兼容的便捷函数(供 gateway router 等调用)
# ---------------------------------------------------------------------------
def get_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
return get_memory_storage().load(agent_name, user_id=user_id)
def reload_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
return get_memory_storage().reload(agent_name, user_id=user_id)
def import_memory_data(memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
storage = get_memory_storage()
if not storage.save(memory_data, agent_name, user_id=user_id):
raise OSError("Failed to save imported memory data")
return storage.load(agent_name, user_id=user_id)
def clear_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
cleared = create_empty_memory()
if not get_memory_storage().save(cleared, agent_name, user_id=user_id):
raise OSError("Failed to save cleared memory data")
return cleared
def _validate_confidence(confidence: float) -> float:
if not math.isfinite(confidence) or confidence < 0 or confidence > 1:
raise ValueError("confidence")
return confidence
def create_memory_fact(
content: str,
category: str = "context",
confidence: float = 0.5,
agent_name: str | None = None,
*,
user_id: str | None = None,
) -> dict[str, Any]:
normalized_content = content.strip()
if not normalized_content:
raise ValueError("content")
normalized_category = category.strip() or "context"
validated_confidence = _validate_confidence(confidence)
now = utc_now_iso_z()
memory_data = get_memory_data(agent_name, user_id=user_id)
updated_memory = dict(memory_data)
facts = list(memory_data.get("facts", []))
facts.append({
"id": f"fact_{uuid.uuid4().hex[:8]}",
"content": normalized_content,
"category": normalized_category,
"confidence": validated_confidence,
"createdAt": now,
"source": "manual",
})
updated_memory["facts"] = facts
if not get_memory_storage().save(updated_memory, agent_name, user_id=user_id):
raise OSError("Failed to save memory data after creating fact")
return updated_memory
def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
memory_data = get_memory_data(agent_name, user_id=user_id)
facts = memory_data.get("facts", [])
updated_facts = [f for f in facts if f.get("id") != fact_id]
if len(updated_facts) == len(facts):
raise KeyError(fact_id)
updated_memory = dict(memory_data)
updated_memory["facts"] = updated_facts
if not get_memory_storage().save(updated_memory, agent_name, user_id=user_id):
raise OSError(f"Failed to save memory data after deleting fact '{fact_id}'")
return updated_memory
def update_memory_fact(
fact_id: str,
content: str | None = None,
category: str | None = None,
confidence: float | None = None,
agent_name: str | None = None,
*,
user_id: str | None = None,
) -> dict[str, Any]:
memory_data = get_memory_data(agent_name, user_id=user_id)
updated_memory = dict(memory_data)
updated_facts: list[dict[str, Any]] = []
found = False
for fact in memory_data.get("facts", []):
if fact.get("id") == fact_id:
found = True
updated_fact = dict(fact)
if content is not None:
normalized = content.strip()
if not normalized:
raise ValueError("content")
updated_fact["content"] = normalized
if category is not None:
updated_fact["category"] = category.strip() or "context"
if confidence is not None:
updated_fact["confidence"] = _validate_confidence(confidence)
updated_facts.append(updated_fact)
else:
updated_facts.append(fact)
if not found:
raise KeyError(fact_id)
updated_memory["facts"] = updated_facts
if not get_memory_storage().save(updated_memory, agent_name, user_id=user_id):
raise OSError(f"Failed to save memory data after updating fact '{fact_id}'")
return updated_memory
# ---------------------------------------------------------------------------
# 内容提取辅助
# ---------------------------------------------------------------------------
def _extract_text(content: Any) -> str:
"""从 LLM response content(str 或 list of blocks)提取纯文本。"""
if isinstance(content, str):
return content
if isinstance(content, list):
pieces: list[str] = []
pending: list[str] = []
def flush():
if pending:
pieces.append("".join(pending))
pending.clear()
for block in content:
if isinstance(block, str):
pending.append(block)
elif isinstance(block, dict):
flush()
text_val = block.get("text")
if isinstance(text_val, str):
pieces.append(text_val)
flush()
return "\n".join(pieces)
return str(content)
_UPLOAD_SENTENCE_RE = re.compile(
r"[^.!?]*\b(?:"
r"upload(?:ed|ing)?(?:\s+\w+){0,3}\s+(?:file|files?|document|documents?|attachment|attachments?)"
r"|file\s+upload"
r"|/mnt/user-data/uploads/"
r"|<uploaded_files>"
r")[^.!?]*[.!?]?\s*",
re.IGNORECASE,
)
def _strip_upload_mentions_from_memory(memory_data: dict[str, Any]) -> dict[str, Any]:
"""移除记忆中关于文件上传事件的句子(上传文件是会话级临时的)。"""
for section in ("user", "history"):
section_data = memory_data.get(section, {})
for _key, val in section_data.items():
if isinstance(val, dict) and "summary" in val:
cleaned = _UPLOAD_SENTENCE_RE.sub("", val["summary"]).strip()
cleaned = re.sub(r" +", " ", cleaned)
val["summary"] = cleaned
facts = memory_data.get("facts", [])
if facts:
memory_data["facts"] = [f for f in facts if not _UPLOAD_SENTENCE_RE.search(f.get("content", ""))]
return memory_data
def _fact_content_key(content: Any) -> str | None:
if not isinstance(content, str):
return None
stripped = content.strip()
if not stripped:
return None
return stripped.casefold()
# ---------------------------------------------------------------------------
# MemoryUpdater(原版核心类)
# ---------------------------------------------------------------------------
class MemoryUpdater:
"""Updates memory using LLM based on conversation context."""
def __init__(self, model_name: str | None = None) -> None:
self._model_name = model_name
def _get_model(self):
from deerflow.models import create_chat_model
cfg = _get_v1_cfg()
model_name = self._model_name or cfg.model_name
return create_chat_model(name=model_name or None, thinking_enabled=False)
def _build_correction_hint(self, correction_detected: bool, reinforcement_detected: bool) -> str:
correction_hint = ""
if correction_detected:
correction_hint = (
"IMPORTANT: Explicit correction signals were detected in this conversation. "
"Pay special attention to what the agent got wrong, what the user corrected, "
"and record the correct approach as a fact with category "
'"correction" and confidence >= 0.95 when appropriate.'
)
if reinforcement_detected:
reinforcement_hint = (
"IMPORTANT: Positive reinforcement signals were detected in this conversation. "
"The user explicitly confirmed the agent's approach was correct or helpful. "
"Record the confirmed approach, style, or preference as a fact with category "
'"preference" or "behavior" and confidence >= 0.9 when appropriate.'
)
correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint
return correction_hint
def _prepare_update_prompt(
self,
messages: list[Any],
agent_name: str | None,
correction_detected: bool,
reinforcement_detected: bool,
user_id: str | None = None,
) -> tuple[dict[str, Any], str] | None:
from deerflow.config.memory_config import get_memory_config
config = get_memory_config()
if not config.enabled or not messages:
return None
current_memory = get_memory_data(agent_name, user_id=user_id)
conversation_text = format_conversation_for_update(messages)
if not conversation_text.strip():
return None
correction_hint = self._build_correction_hint(correction_detected, reinforcement_detected)
prompt = MEMORY_UPDATE_PROMPT.format(
current_memory=json.dumps(current_memory, indent=2, ensure_ascii=False),
conversation=conversation_text,
correction_hint=correction_hint,
)
return current_memory, prompt
def _finalize_update(
self,
current_memory: dict[str, Any],
response_content: Any,
thread_id: str | None,
agent_name: str | None,
user_id: str | None = None,
) -> bool:
response_text = _extract_text(response_content).strip()
if response_text.startswith("```"):
lines = response_text.split("\n")
response_text = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
update_data = json.loads(response_text)
updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id)
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
return get_memory_storage().save(updated_memory, agent_name, user_id=user_id)
async def aupdate_memory(
self,
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
correction_detected: bool = False,
reinforcement_detected: bool = False,
user_id: str | None = None,
) -> bool:
return await asyncio.to_thread(
self._do_update_memory_sync,
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
user_id=user_id,
)
def _do_update_memory_sync(
self,
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
correction_detected: bool = False,
reinforcement_detected: bool = False,
user_id: str | None = None,
) -> bool:
try:
prepared = self._prepare_update_prompt(
messages=messages,
agent_name=agent_name,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
user_id=user_id,
)
if prepared is None:
return False
current_memory, prompt = prepared
model = self._get_model()
response = model.invoke(prompt, config={"run_name": "v1_memory_agent"})
return self._finalize_update(
current_memory=current_memory,
response_content=response.content,
thread_id=thread_id,
agent_name=agent_name,
user_id=user_id,
)
except json.JSONDecodeError as e:
logger.warning("V1 memory: failed to parse LLM response: %s", e)
return False
except Exception as e: # noqa: BLE001
logger.exception("V1 memory update failed: %s", e)
return False
def update_memory(
self,
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
correction_detected: bool = False,
reinforcement_detected: bool = False,
user_id: str | None = None,
) -> bool:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
try:
future = _SYNC_MEMORY_UPDATER_EXECUTOR.submit(
self._do_update_memory_sync,
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
user_id=user_id,
)
return future.result()
except Exception: # noqa: BLE001
logger.exception("V1 memory: failed to offload update to executor")
return False
return self._do_update_memory_sync(
messages=messages,
thread_id=thread_id,
agent_name=agent_name,
correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected,
user_id=user_id,
)
def _apply_updates(
self,
current_memory: dict[str, Any],
update_data: dict[str, Any],
thread_id: str | None = None,
) -> dict[str, Any]:
cfg = _get_v1_cfg()
now = utc_now_iso_z()
# 更新 user context 段
user_updates = update_data.get("user", {})
for section in ("workContext", "personalContext", "topOfMind"):
section_data = user_updates.get(section, {})
if section_data.get("shouldUpdate") and section_data.get("summary"):
current_memory.setdefault("user", {})[section] = {
"summary": section_data["summary"],
"updatedAt": now,
}
# 更新 history 段
history_updates = update_data.get("history", {})
for section in ("recentMonths", "earlierContext", "longTermBackground"):
section_data = history_updates.get(section, {})
if section_data.get("shouldUpdate") and section_data.get("summary"):
current_memory.setdefault("history", {})[section] = {
"summary": section_data["summary"],
"updatedAt": now,
}
# 删除事实
facts_to_remove = set(update_data.get("factsToRemove", []))
if facts_to_remove:
current_memory["facts"] = [f for f in current_memory.get("facts", []) if f.get("id") not in facts_to_remove]
# 新增事实(去重 + 置信度过滤)
existing_keys = {
key for key in (_fact_content_key(f.get("content")) for f in current_memory.get("facts", []))
if key is not None
}
for fact in update_data.get("newFacts", []):
confidence = fact.get("confidence", 0.5)
if confidence < cfg.fact_confidence_threshold:
continue
raw_content = fact.get("content", "")
if not isinstance(raw_content, str):
continue
normalized_content = raw_content.strip()
fact_key = _fact_content_key(normalized_content)
if fact_key is not None and fact_key in existing_keys:
continue
fact_entry: dict[str, Any] = {
"id": f"fact_{uuid.uuid4().hex[:8]}",
"content": normalized_content,
"category": fact.get("category", "context"),
"confidence": confidence,
"createdAt": now,
"source": thread_id or "unknown",
}
source_error = fact.get("sourceError")
if isinstance(source_error, str) and source_error.strip():
fact_entry["sourceError"] = source_error.strip()
current_memory["facts"].append(fact_entry)
if fact_key is not None:
existing_keys.add(fact_key)
# 超出上限时驱逐置信度最低的事实
if len(current_memory["facts"]) > cfg.max_facts:
current_memory["facts"] = sorted(
current_memory["facts"],
key=lambda f: f.get("confidence", 0),
reverse=True,
)[:cfg.max_facts]
return current_memory
def update_memory_from_conversation(
messages: list[Any],
thread_id: str | None = None,
agent_name: str | None = None,
correction_detected: bool = False,
reinforcement_detected: bool = False,
user_id: str | None = None,
) -> bool:
updater = MemoryUpdater()
return updater.update_memory(messages, thread_id, agent_name, correction_detected, reinforcement_detected, user_id=user_id)
__all__ = [
"MemoryUpdater",
"update_memory_from_conversation",
"get_memory_data",
"reload_memory_data",
"import_memory_data",
"clear_memory_data",
"create_memory_fact",
"delete_memory_fact",
"update_memory_fact",
]
@@ -90,18 +90,16 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
state_schema = MemoryMiddlewareState
def __init__(self, agent_name: str | None = None, *, memory_config: "MemoryConfig | None" = None, injection_enabled: bool = True):
def __init__(self, agent_name: str | None = None, *, memory_config: "MemoryConfig | None" = None):
"""初始化 MemoryMiddleware。
Args:
agent_name: 提供时记忆按 agent 隔离;None 用默认 agent。
memory_config: 显式记忆配置;省略时用全局配置。
injection_enabled: 运行时开关;``False`` 时跳过 Hindsight 动态召回注入。
"""
super().__init__()
self._agent_name = agent_name
self._memory_config = memory_config
self._injection_enabled = injection_enabled
# ---- 运行时上下文解析 -------------------------------------------------
@@ -135,8 +133,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
背景信息而非用户的新输入。
"""
if not self._injection_enabled:
return None
config = self._memory_config or get_memory_config()
if not config.enabled:
return None
@@ -0,0 +1,51 @@
"""Multi-LLM routing graph.
Systems: fast, bigmodel, hxchat, moreModes, rag, taskChat
Graph layout:
START → router → (conditional) → fast / bigmodel / hxchat / more_modes / rag / task_chat → END
"""
from __future__ import annotations
from langgraph.graph import END, START, StateGraph
from deerflow.agents.multi_llm.nodes.router import route_by_system, router_node
from deerflow.agents.multi_llm.nodes.systems import (
bigmodel_node,
fast_node,
hxchat_node,
more_modes_node,
rag_node,
task_chat_node,
)
from deerflow.agents.multi_llm.state import MultiLLMState
# LangGraph node name → node function
_SYSTEM_NODES = {
"fast": fast_node,
"bigmodel": bigmodel_node,
"hxchat": hxchat_node,
"more_modes": more_modes_node,
"rag": rag_node,
"task_chat": task_chat_node,
}
def build_multi_llm_graph(checkpointer=None):
g = StateGraph(MultiLLMState)
g.add_node("router", router_node)
for name, fn in _SYSTEM_NODES.items():
g.add_node(name, fn)
g.add_edge(START, "router")
g.add_conditional_edges(
"router",
route_by_system,
{name: name for name in _SYSTEM_NODES},
)
for name in _SYSTEM_NODES:
g.add_edge(name, END)
return g.compile(checkpointer=checkpointer)
@@ -0,0 +1,77 @@
"""Router node: uses an LLM to decide which system to invoke."""
from __future__ import annotations
import logging
from typing import Literal
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from deerflow.agents.multi_llm.state import MultiLLMState
from deerflow.config.app_config import get_app_config
from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
SystemName = Literal["fast", "bigmodel", "hxchat", "moreModes", "rag", "taskChat"]
_VALID_SYSTEMS: tuple[str, ...] = ("fast", "bigmodel", "hxchat", "moreModes", "rag", "taskChat")
_ROUTER_SYSTEM_PROMPT = f"""你是一个路由助手,负责将用户请求分配给最合适的AI系统。
可用系统(共 {len(_VALID_SYSTEMS)} 个):
- fast : 快速问答,适合通用问题、知识库检索
- bigmodel : 大模型,适合复杂推理、长文本分析
- hxchat : HX 对话,适合...(TODO: 补充说明)
- moreModes : 多模态,适合...(TODO: 补充说明)
- rag : RAG 检索增强,适合需要查找文档的问题
- taskChat : 任务对话,适合需要执行多步骤任务的场景
请根据用户请求,仅输出一个系统名称(区分大小写),不要输出任何其他内容。
有效值:fast / bigmodel / hxchat / moreModes / rag / taskChat"""
async def router_node(state: MultiLLMState, config: RunnableConfig) -> dict:
messages = state.get("messages", [])
if not messages:
return {"selected_system": "fast"}
last_human = next(
(
m
for m in reversed(messages)
if getattr(m, "type", None) == "human" or (isinstance(m, dict) and m.get("role") == "user")
),
None,
)
user_content = ""
if last_human is not None:
user_content = last_human.content if hasattr(last_human, "content") else last_human.get("content", "")
try:
app_config = get_app_config()
llm = create_chat_model(app_config=app_config)
prompt = [
SystemMessage(content=_ROUTER_SYSTEM_PROMPT),
HumanMessage(content=user_content or "请帮我处理这个请求"),
]
response = await llm.ainvoke(prompt, config={"run_name": "multi_llm_router"})
raw = (response.content or "").strip()
selected = raw if raw in _VALID_SYSTEMS else "fast"
except Exception:
logger.exception("multi_llm router failed, defaulting to fast")
selected = "fast"
return {"selected_system": selected}
def route_by_system(state: MultiLLMState) -> str:
system = state.get("selected_system", "fast")
if system not in _VALID_SYSTEMS:
system = "fast"
# LangGraph node names use underscores
return system.lower() if system in ("fast", "bigmodel", "hxchat", "rag") else {
"moreModes": "more_modes",
"taskChat": "task_chat",
}[system]
@@ -0,0 +1,223 @@
"""System nodes: call each external LLM system and stream chunks via custom events.
Systems: fast, bigmodel, hxchat, moreModes, rag, taskChat
System URLs are read from config.yaml under multi_llm_systems.<name>_url,
falling back to environment variables FAST_URL / BIGMODEL_URL / etc.
fast uses a custom request format (Vercel AI SDK / POST /new_chat):
{"chat_id": <thread_id>, "search_space_id": <id>, "user_query": "..."}
search_space_id from config multi_llm_systems.fast_search_space_id
or env FAST_SEARCH_SPACE_ID (default 0).
All other systems use generic {"messages": [...]} format (placeholder until
their real formats are confirmed).
"""
from __future__ import annotations
import logging
import os
import httpx
from langchain_core.callbacks.manager import adispatch_custom_event
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from deerflow.agents.multi_llm.parsers import PARSERS
from deerflow.agents.multi_llm.state import MultiLLMState
from deerflow.config.app_config import get_app_config
from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
SYSTEM_NAMES = ["fast", "bigmodel", "hxchat", "moreModes", "rag", "taskChat"]
# Maps system name → env var suffix for URL lookup
_ENV_VAR_MAP = {
"fast": "FAST_URL",
"bigmodel": "BIGMODEL_URL",
"hxchat": "HXCHAT_URL",
"moreModes": "MOREMODES_URL",
"rag": "RAG_URL",
"taskChat": "TASKCHAT_URL",
}
def _get_multi_llm_config() -> dict:
try:
cfg = get_app_config()
return getattr(cfg, "multi_llm_systems", None) or {}
except Exception:
return {}
def _get_url(system: str) -> str:
c = _get_multi_llm_config()
config_key = f"{system}_url"
env_key = _ENV_VAR_MAP.get(system, f"{system.upper()}_URL")
return c.get(config_key) or os.environ.get(env_key, "")
def _extract_last_user_content(messages: list) -> str:
for m in reversed(messages):
if hasattr(m, "type") and m.type == "human":
return m.content if isinstance(m.content, str) else str(m.content)
if isinstance(m, dict) and m.get("role") == "user":
return m.get("content", "")
return ""
def _build_generic_payload(messages: list) -> dict:
result = []
for m in messages:
if hasattr(m, "type"):
role = "user" if m.type == "human" else "assistant"
content = m.content if isinstance(m.content, str) else str(m.content)
elif isinstance(m, dict):
role = m.get("role", "user")
content = m.get("content", "")
else:
continue
result.append({"role": role, "content": content})
return {"messages": result}
_FAST_TEST_URL = "http://47.88.25.99:8000/api/v1/new_chat"
_FAST_TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MmFhNmRkMC03NDA5LTQ0NDEtOGQzNC1iNTY4YmZjMzg5ODIiLCJhdWQiOlsiZmFzdGFwaS11c2VyczphdXRoIl0sImV4cCI6MTc3OTQzNjY0NH0.Da_KDlQaEc-cH95AyUzcx82pwZmU3t-9fGmJC9tRF7E"
_FAST_TEST_MODEL = "Qwen3.5-plus"
_FAST_TEST_SEARCH_SPACE_ID = 197
_FAST_TEST_CHAT_ID = 600
def _build_fast_payload(messages: list, thread_id: str = "") -> dict: # noqa: ARG001
"""Build the /new_chat request payload for the fast system."""
return {
"model_name": _FAST_TEST_MODEL,
"chat_id": _FAST_TEST_CHAT_ID,
"search_space_id": _FAST_TEST_SEARCH_SPACE_ID,
"user_query": _extract_last_user_content(messages),
}
async def _call_system(system: str, messages: list, thread_id: str = "") -> str:
if system == "fast":
url = _FAST_TEST_URL
headers = {"Authorization": f"Bearer {_FAST_TEST_TOKEN}"}
else:
url = _get_url(system)
headers = {}
if not url:
raise ValueError(
f"No URL configured for system '{system}'. "
f"Set multi_llm_systems.{system}_url in config.yaml or {_ENV_VAR_MAP.get(system, system.upper() + '_URL')} env var."
)
parser = PARSERS[system]
payload = _build_fast_payload(messages, thread_id) if system == "fast" else _build_generic_payload(messages)
text_buf = ""
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream("POST", url, json=payload, headers=headers) as resp:
resp.raise_for_status()
async for chunk in parser.parse(resp):
if chunk.content:
if chunk.type == "thinking":
await adispatch_custom_event("llm_chunk", {"type": "thinking", "content": chunk.content, "system": system})
else:
text_buf += chunk.content
await adispatch_custom_event("llm_chunk", {"type": "text", "content": chunk.content, "system": system})
return text_buf
def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
"""Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
thinking = ""
text = ""
content = getattr(chunk, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, str):
text += block
elif isinstance(block, dict):
btype = block.get("type", "")
if btype in ("thinking", "thinking_delta"):
thinking += block.get("thinking", "")
elif btype in ("text", "text_delta"):
text += block.get("text", "")
elif btype == "reasoning":
thinking += block.get("content", "")
if not thinking:
kwargs = getattr(chunk, "additional_kwargs", {}) or {}
r = kwargs.get("reasoning_content") or ""
if isinstance(r, str):
thinking = r
return thinking, text
async def _fallback_to_local_llm(messages: list, config: RunnableConfig) -> str:
"""Fall back to the local configured LLM when an external system fails."""
app_config = get_app_config()
llm = create_chat_model(app_config=app_config)
lc_messages = []
for m in messages:
if hasattr(m, "type"):
content = m.content if isinstance(m.content, str) else str(m.content)
lc_messages.append(HumanMessage(content=content) if m.type == "human" else AIMessage(content=content))
elif isinstance(m, dict):
content = m.get("content", "")
lc_messages.append(HumanMessage(content=content) if m.get("role") == "user" else AIMessage(content=content))
text_buf = ""
async for chunk in llm.astream(lc_messages):
thinking, text = _extract_chunk_parts(chunk)
if thinking:
await adispatch_custom_event("llm_chunk", {"type": "thinking", "content": thinking, "system": "local"})
if text:
text_buf += text
await adispatch_custom_event("llm_chunk", {"type": "text", "content": text, "system": "local"})
return text_buf
async def _system_node(system: str, state: MultiLLMState, config: RunnableConfig) -> dict:
messages = state.get("messages", [])
thread_id = (config or {}).get("configurable", {}).get("thread_id", "")
try:
result = await _call_system(system, messages, thread_id=thread_id)
except Exception as e:
logger.warning("multi_llm system '%s' failed (%s), falling back to local LLM", system, e)
await adispatch_custom_event("llm_chunk", {"type": "fallback", "system": "local", "reason": str(e)})
try:
result = await _fallback_to_local_llm(messages, config)
except Exception as e2:
logger.exception("local LLM fallback also failed")
result = f"[系统异常,请稍后重试: {e2}]"
return {"messages": [AIMessage(content=result)]}
async def fast_node(state: MultiLLMState, config: RunnableConfig) -> dict:
return await _system_node("fast", state, config)
async def bigmodel_node(state: MultiLLMState, config: RunnableConfig) -> dict:
return await _system_node("bigmodel", state, config)
async def hxchat_node(state: MultiLLMState, config: RunnableConfig) -> dict:
return await _system_node("hxchat", state, config)
async def more_modes_node(state: MultiLLMState, config: RunnableConfig) -> dict:
return await _system_node("moreModes", state, config)
async def rag_node(state: MultiLLMState, config: RunnableConfig) -> dict:
return await _system_node("rag", state, config)
async def task_chat_node(state: MultiLLMState, config: RunnableConfig) -> dict:
return await _system_node("taskChat", state, config)
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more