This commit is contained in:
许海洋 committed 2026-05-22 11:49:53 +08:00
commit 667b365a84
94 files changed
+6430 -2390

No files matched your search

+3 -1
View File
@@ -26,6 +26,7 @@ frontend-web/src-v1.zip
frontend-web/src-*.rar
# Frontend build artifacts
frontend-web/dist/
frontend-web/dist-scheduled-check/
# Offline backend bundles and local deployment artifacts
@@ -75,4 +76,5 @@ offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
offline-backend-20260512/backend/tests/*
deploy
+5 -2
View File
@@ -2,5 +2,8 @@
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
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
+501
View File
@@ -0,0 +1,501 @@
# AI 写作 — 迁移到 LangGraph Server 的分步实现方案
> **文档目的**:彻底解决 AI 写作在多 worker 部署下"SSE 时断时续 / resume 偶发 404"的根因,参照主聊天(`/workspace/chats/new`)的实现,把 AI 写作的 graph 注册成 LangGraph Server 上的一个 assistant,让 Server 接管线程、checkpoint、SSE、interrupt/resume 的全部协议。
>
> **配套文档**:[ai-writing-说明.md](./ai-writing-说明.md) 里描述了为什么会出问题、当前已落地的兜底方案,以及方案 B/C/F 三条"绕过"路径。本文档是其中"长期方案"的具体实现路线 —— 不是绕过多 worker,而是从架构层面让它**天然不受 worker 数影响**。
>
> **更新日期**:2026-05-20
---
## 一、为什么走这条路
主聊天和 AI 写作功能形态几乎一致(多步流式、可中断/恢复、有持久化),但**主聊天完全不受多 worker 影响**。差别在于:
| 维度 | 主聊天(现状) | AI 写作(现状) | AI 写作(迁移后) |
|---|---|---|---|
| 前端流接入 | `useStream` (SDK) | `fetch` + 自写 `EventSource` | `useStream` (SDK) |
| 后端运行时 | **LangGraph Server**(独立进程,端口 2024 / 经 nginx 走 `/api/langgraph`) | 嵌在 FastAPI Gateway 路由里直接跑 | **LangGraph Server**(同主聊天) |
| 线程状态 | Server 自带持久化 checkpointer | 自管 sqlite + 一堆模块级 dict | Server 自带持久化 checkpointer |
| SSE 通道 | Server ↔ SDK 协议层 | 自写 `asyncio.Queue` per session_id | Server ↔ SDK 协议层 |
| 多 worker | 任何 worker 都能查 Server | per-worker 内存 dict 隔离 | 任何 worker 都能查 Server |
| Interrupt/Resume | Server 协议(`Command(resume=...)`)| 自写 `_session_pending_pause` + `/resume` 路由 | Server 协议 |
**核心收益**:迁完后 `_event_queues`、`_session_pending_pause`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_article_types`、`_session_error_flags`、`_session_pending_pause`、`_inflight_graph_tasks`、`_recover_session()`、自管 sqlite checkpointer 这一整套"per-process 内存状态"全部可以删掉 —— 不是"绕过"问题,是**让问题消失**。
**代价**:节点要改造(流式 chunk 改走 LangGraph 原生 `StreamWriter`/state update)、前端干预卡的交互要重写、业务表写入路径要从"路由里写"挪到"节点里写"。约 **6 人日**核心改造 + 50% buffer,建议按 **9 人日**排期。
---
## 二、现状盘点:要删 / 要留 / 要改
### 2.1 要删的(迁完即可移除)
| 位置 | 内容 | 备注 |
|---|---|---|
| `app/gateway/routers/ai_writing.py` | `POST /start`、`GET /{id}/stream`、`POST /{id}/resume`、`GET /{id}/state` | 由 LangGraph Server 的 `runs.stream / threads.create / runs.wait` 替代 |
| 同上 | `_event_queues`、`_session_pending_pause`、`_session_model_names`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_error_flags`、`_session_article_types` 这 8 个模块级 dict | 状态全部交给 LangGraph Server |
| 同上 | `_recover_session()`、`_inflight_graph_tasks`、`_spawn_graph_task()`、`wait_for_inflight_graph_tasks()` | 跨 worker 恢复 / drain 都由 Server 内建机制处理 |
| 同上 | `_make_config()` 里注入 `sse_queue` / `sse_session_id` 的逻辑 | 流式输出改走 LangGraph 原生通道 |
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()`、`AI_WRITING_GRAPH` 单例、`_async_checkpointer_singleton` | checkpoint 由 langgraph.json 的全局 checkpointer 接管 |
| `app/gateway/ai_writing_cleanup.py` | "联动清理 sqlite checkpointer thread" 那一段 | 改成调 `langgraph_sdk` 的 `threads.delete()` |
| `offline-backend-20260512/config.yaml` | `checkpointer:` 段里 AI 写作专用的 sqlite 路径(如果跟主 checkpointer 不同) | 共用主配置 |
### 2.2 要保留的
| 位置 | 内容 | 理由 |
|---|---|---|
| `packages/harness/deerflow/persistence/ai_writing_sessions/` | MySQL 业务表(标题、最终 draft、completed_interventions、transcript、article_type、status…) | 列表页 / 历史回显 / 全文搜索都依赖业务表,**不能让 LangGraph checkpoint 取代它** —— checkpoint 是流程状态,业务表是产品形态 |
| `GET/PATCH/DELETE /api/ai-writing/sessions[/{id}]` | 列表 / 重命名 / 删除 | 只查业务表,不碰 graph |
| `PUT /api/ai-writing/sessions/{id}/transcript` | 时间线持久化 | 维持锦上添花的回显方案 |
| `GET/POST/PATCH/DELETE /api/ai-writing/article-types[/{id}]` | 文章类型 CRUD | 纯业务路由,跟 graph 无关 |
| `POST /api/ai-writing/admin/cleanup` | admin 清理入口 | 路由保留,内部实现改 |
| 4 个 interrupt 暂停点 + 5 个节点 + 全部 state 字段 | 业务逻辑零改动 | `pause_material_confirm` / `pause_outline_confirm` / `pause_section_help` / `pause_draft_confirm` / `pause_review_confirm` 用的就是 LangGraph 原生 `interrupt()`,直接吃迁移红利 |
### 2.3 要改的
| 位置 | 改造点 |
|---|---|
| `graph.py` | 删自管 checkpointer,导出 `make_ai_writing_graph(config) → CompiledStateGraph`,由 Server 注入 checkpointer |
| `langgraph.json` | `graphs` 里增加 `"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"` |
| 各 node(`researcher.py` / `writer_outline.py` / `writer_draft.py` / `editor.py`) | 流式输出从 `sse_queue.put_nowait(...)` 改成 LangGraph 原生 `get_stream_writer()` 推 custom 事件 |
| 各 node | DB 写入(草稿、审核结果、status)从「路由里调 repo」改成「节点里通过 `RunnableConfig` 拿 `thread_id` 调 repo」 |
| 各 node | "创建 session 行"从 `/start` 路由里挪到 `intent_parser_node`(或前端 `onCreated` 回调里调一个轻量级 upsert API) |
| `langgraph_auth.py` | 给 `ai_writing` assistant 加授权规则(保证只能访问自己的 thread) |
| 前端 `frontend-web/src/open-canvas/` | 完全弃用现有 `api/ai-writing.ts` 的 `fetch + EventSource`,新写 `useAIWritingStream` hook(基于 `@langchain/langgraph-sdk/react` 的 `useStream`) |
| 前端 4 个干预卡 | 提交回调从 `POST /resume` 改为 `thread.submit(new Command({ resume: payload }))` |
| 前端"启动写作"按钮 | 从 `POST /start` + 拿 session_id + 建 EventSource 改成 `thread.submit({...args})` 触发 `onCreated` 回调 |
| `tests/test_ai_writing_session_recovery.py` | 整体删除 —— 跨 worker 恢复由 LangGraph Server 接管,不再是 AI 写作自己的事 |
| 新增 `tests/test_ai_writing_assistant.py` | 通过 LangGraph SDK in-process 跑一遍 graph,覆盖 interrupt / resume / custom stream / state 持久化 |
---
## 三、分步实施
### 阶段 0:可行性预演(0.5 d)
**目标**:在不动现有代码的前提下,验证"把 ai_writing graph 注册到 LangGraph Server 并通过 SDK 跑通"这条路完全可行。
1. 临时在 `langgraph.json` 的 `graphs` 里加一条 `"ai_writing_probe": "deerflow.agents.ai_writing.graph:build_ai_writing_graph"`(直接复用现有 `build_ai_writing_graph`,不传 checkpointer 让 Server 注入)。
2. 起 `make dev`,确认 LangGraph Server 把 `ai_writing_probe` 列入 assistant 列表。
3. 用 Python SDK 客户端(`langgraph_sdk.get_client()`):
```python
from langgraph_sdk import get_client
client = get_client(url="http://localhost:8001/api/langgraph")
thread = await client.threads.create()
async for chunk in client.runs.stream(
thread["thread_id"], "ai_writing_probe",
input={"user_intent": "测试", "article_type": "analysis", ...},
stream_mode=["values", "messages-tuple", "custom"],
): print(chunk)
# 跑到 pause_material_confirm 应该收到 interrupt 事件
await client.runs.stream(thread["thread_id"], "ai_writing_probe",
command={"resume": {"action": "confirm", "approvedMaterialIds": [...]}})
```
4. 验证:interrupt 能正确暂停、`Command(resume=...)` 能续跑、state 能持久化到 Server checkpoint。
5. 跑通后**回滚 langgraph.json** —— 这一步只是"摸路"。
**通过条件**:4 个 pause 点全部能用 SDK 跑通 interrupt + resume。如果某个 pause 点的 payload 结构跟 SDK 协议不兼容(例如用了非 JSON 可序列化的对象),阶段 0 就要解决,否则会在阶段 2 翻车。
---
### 阶段 1:graph 注册为 assistant(0.5 d)
**改动文件**:`packages/harness/deerflow/agents/ai_writing/graph.py`、`offline-backend-20260512/backend/langgraph.json`
1. `graph.py` 顶部新增:
```python
from langchain_core.runnables import RunnableConfig
def make_ai_writing_graph(config: RunnableConfig | None = None):
"""LangGraph Server 入口:不接收 checkpointer,由 Server 注入。"""
g = StateGraph(AIWritingState)
# ... 跟现在 build_ai_writing_graph 完全一样的节点 / 边定义 ...
return g.compile() # 注意:不传 checkpointer
```
2. 旧的 `build_ai_writing_graph(checkpointer=None)`、`get_ai_writing_graph()`、`_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()` 全部**先标 `@deprecated` 暂留** —— 阶段 7 再删,避免阶段 2/3 出错时无法快速回退。
3. `langgraph.json`:
```json
{
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
}
}
```
4. `checkpointer.path` 已经指向 `make_checkpointer`(看 `langgraph.json:15-17`),AI 写作直接共享主 checkpointer 即可,**不要**重新搞一套。
**通过条件**:启动 LangGraph Server 后 `GET /api/langgraph/assistants` 能列出 `ai_writing`,state schema 字段齐全。
---
### 阶段 2:节点改造 —— 流式输出走原生通道(1.5 d)
**核心问题**:现在的 `writer_draft_node` / `writer_outline_node` / `researcher_node` 通过 `config["configurable"]["sse_queue"]` 把 LLM token chunk 推给前端。LangGraph Server 不会再注入 `sse_queue`,必须改用原生流通道。
**选型**:用 `langgraph.config.get_stream_writer()` 推 `custom` 事件(保留现有"非聊天消息、自定义协议"的形态)。
> 不选 `messages-tuple` 模式的原因:草稿生成不是聊天消息流,而是大段 markdown;硬塞成 `AIMessage` 会让前端渲染逻辑变得很别扭。custom 通道改动最小、契合度最高。
**改造模板**(每个节点都按这个模板改):
```python
# 旧:
from .nodes._utils import push_sse_event
async def writer_draft_node(state, config):
sse_queue = config["configurable"].get("sse_queue")
sse_queue.put_nowait({"event": "progress", "data": {...}})
async for chunk in llm.astream(...):
sse_queue.put_nowait({"event": "draft_chunk", "data": {"text": chunk}})
# 新:
from langgraph.config import get_stream_writer
async def writer_draft_node(state, config):
writer = get_stream_writer() # 注意:必须在 async 函数体内调,不能模块级缓存
writer({"type": "progress", "agent": "writer", "message": "开始写作"})
async for chunk in llm.astream(...):
writer({"type": "draft_chunk", "text": chunk})
```
**业务表写入改造**:现在 `writer_draft_node` 收尾时通过 `config["configurable"]["sse_session_id"]` + `_session_repos[sid]` 调 `repo.save_draft()`。新方案:
```python
async def writer_draft_node(state, config):
thread_id = config["configurable"]["thread_id"] # LangGraph Server 注入
# 从 thread metadata / state 里拿到 user_id(在阶段 4 写入)
user_id = state.get("_user_id") or config["configurable"].get("user_id")
repo = get_ai_writing_session_store() # 进程内全局,无 per-session 字典
try:
await repo.save_draft(thread_id, user_id, state["current_draft"])
except Exception:
logger.exception("save_draft failed (non-fatal)")
```
**注意事项**:
- `get_stream_writer()` 在子图 / 工具函数里会拿到当前 graph 的 writer,**不要**在模块 import 时缓存。
- 节点要兼容"测试场景下没有 LangGraph runtime":包一层 `try: writer = get_stream_writer() except ...`,单测里 writer 是 no-op。
- progress_events 字段保留写入 state(现在节点都已经 return `{"progress_events": [...]}`),这样 history / 回放仍然能在 state 里看到完整轨迹;custom stream 只是为了**实时推送**。
**通过条件**:手动跑一次 `make dev`,用 SDK 客户端从前一阶段验证脚本流式拿到 `draft_chunk` 事件。
---
### 阶段 3:前端接入 LangGraph SDK(1 d)
**核心改动**:`frontend-web/src/open-canvas/api/ai-writing.ts` 整文件作废,改写 `frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`。
**新 hook 骨架**(参考 `frontend-web/src/core/threads/hooks.ts:207` 现有主聊天写法):
```ts
import { useStream } from "@langchain/langgraph-sdk/react";
import { Command } from "@langchain/langgraph-sdk";
import { getAPIClient } from "@/core/api/api-client";
interface AIWritingValues {
user_intent: string;
article_type: string;
status: string;
material_package?: MaterialPackage;
current_outline?: Outline;
current_draft?: DraftArticle;
review_result?: ReviewResult;
progress_events: any[];
}
export function useAIWritingStream(sessionId: string | "new") {
const stream = useStream<AIWritingValues, { CustomEventType: AIWritingCustomEvent }>({
client: getAPIClient(),
assistantId: "ai_writing",
threadId: sessionId === "new" ? undefined : sessionId,
reconnectOnMount: true,
onCreated(meta) {
// 后端会用同一个 thread_id 做 ai_writing_sessions.id;通知 UI 写入 URL
navigateTo(`/workspace/ai-writing/${meta.thread_id}`);
},
onCustomEvent(event) {
// 节点 push 来的 draft_chunk / progress / notice
pushUIEvent(event);
},
});
return stream;
}
```
**4 个干预卡的提交逻辑**(旧 → 新):
```ts
// 旧(自写 fetch):
await fetch(`/api/ai-writing/${sessionId}/resume`, {
method: "POST",
body: JSON.stringify({ action: "confirm", approvedMaterialIds: [...] }),
});
// 新(SDK 原生 Command):
stream.submit(undefined, {
command: { resume: { action: "confirm", approvedMaterialIds: [...] } },
});
```
**启动写作**:
```ts
// 旧:POST /start → 返回 session_id → 建 EventSource
// 新:
stream.submit({
user_intent, article_type, target_audience, word_count_target, keyword_count, writing_mode,
}, {
config: { configurable: { model_name } },
metadata: { feature: "ai_writing", article_type }, // 走业务表筛选
});
```
**列表页 / 历史回显**:仍然走 `GET /api/ai-writing/sessions` 业务 API,**无改动**。点进某条历史记录时:
- 拿到 `sessionId` (== LangGraph `thread_id`)
- `useAIWritingStream(sessionId)` 自动调 `client.threads.getState()` 拉最新 state,渲染最后一次草稿 / 干预点 / interrupt 等待
- 已结束的会话 (`status == "done"`) 直接读业务表的 `draft_markdown` 字段,**不挂 SDK stream**(省 Server 资源)
**通过条件**:手动跑完一个完整流程(启动 → 4 次干预 → 拿到草稿),过程中刷新页面 / 切 worker,进度都不丢。
---
### 阶段 4:业务表写入解耦(0.5 d)
**问题**:现在 `/start` 路由先 INSERT 一行 `ai_writing_sessions(id=session_id, ...)`,再启动 graph。迁移后前端用 `stream.submit(...)` 直接触发 LangGraph 建 thread + 跑 graph,业务表行没人建 → 列表页查不到。
**两个方案二选一**:
**方案 4A(推荐)**:节点里建业务表行
- `intent_parser_node` 开头:`await repo.upsert_session(thread_id, user_id, user_intent, article_type, status="researching")`
- 优点:单一数据源(节点),不依赖前端做"双写"。
- 缺点:用户从前端点"启动" → 看到列表里出现这条 → 之间有 200~500ms 延迟(intent_parser 跑完才入库),需要前端 onCreated 后乐观插入一条临时行。
**方案 4B**:前端 onCreated 时调一个轻量 upsert API
- 新增 `POST /api/ai-writing/sessions/{thread_id}/init`:只插业务表行,不碰 graph。
- 前端 `onCreated(meta)` 回调里立刻 fire-and-forget 调它。
- 优点:列表瞬间出现新行。
- 缺点:前端要多调一次 API,且失败要兜底;多了一条"双方都能建行"的路径。
**推荐 4A** —— 单一数据源比 UI 响应更重要,200ms 延迟用 loading skeleton 即可。
**用户身份传递**:节点要拿到 `user_id` 写入业务表。两条路:
- LangGraph Server 已有 `langgraph_auth.py` 注入 auth context,在节点里 `config["configurable"].get("langgraph_auth_user")` 取(具体字段名以现有实现为准)。
- 或者前端 `submit()` 时 metadata 里带上 `user_id`,节点从 `config["configurable"]["metadata"]` 拿。
**通过条件**:从启动到列表显示新行,延迟 < 1s;多用户场景下不会出现"A 用户的 thread 落到 B 的业务表行"。
---
### 阶段 5:transcript 持久化(0.2 d)
**当前**:前端 `PUT /api/ai-writing/sessions/{id}/transcript` 全量覆盖 `ai_writing_sessions.transcript` 列。
**改造选择**:
- **维持现状**(推荐):transcript 是前端组装的时间线 blob,让前端继续 fire-and-forget PUT 即可,跟迁移正交。
- 或者完全弃用 transcript 列,直接从 `client.threads.getHistory()` + `getState()` 还原 —— 工作量大且需要重写回显逻辑,**不推荐第一版做**。
**通过条件**:原 PUT 接口照常工作;新建会话也能落库。
---
### 阶段 6:admin 清理改造(0.3 d)
**改动文件**:`app/gateway/ai_writing_cleanup.py`
```python
# 旧:
saver = get_ai_writing_checkpointer()
await saver.adelete_thread(sid) # 自管 sqlite
# 新:
from langgraph_sdk import get_client
lg_client = get_client(url=internal_langgraph_url())
await lg_client.threads.delete(sid)
```
**活跃保护**:旧逻辑是看 `sid in _event_queues`。新逻辑:
```python
state = await lg_client.threads.get_state(sid)
is_active = (
state.get("next") # graph 还有未执行的节点
or state.get("tasks") # 当前有 pending task
or state.get("metadata", {}).get("interrupted") # 卡在 interrupt 上
)
if is_active:
skipped_active += 1
continue
```
**通过条件**:admin cleanup 接口照常返回 `deleted_db / deleted_checkpoints / skipped_active`,且能正确跳过仍在 interrupt 等待用户的会话。
---
### 阶段 7:删冗余代码(0.3 d)
按 §2.1 列表逐项删除。建议**单独开一个 PR**,跟阶段 1~6 解耦:
- 前 6 个 PR 全部上线、灰度验证至少 1 周
- 确认旧代码路径再无流量后,再走清理 PR
- 这样万一灰度阶段需要回滚,旧路径还在
**通过条件**:`make test` 全绿,`grep -r "_event_queues\|_session_pending_pause\|_recover_session" app/ packages/` 零命中。
---
### 阶段 8:测试 + 灰度(1 d)
**新增测试**:
- `tests/test_ai_writing_assistant.py` —— 用 `langgraph_sdk` 客户端打 LangGraph Server,覆盖:
- 完整流程:start → 4 个 interrupt → resume → done
- state 持久化:跑到 pause_outline 后重建客户端,能 `getState` 看到 outline
- custom stream:能收到 `draft_chunk` 等事件
- 错误兜底:节点抛错时 status 落 error、业务表 status 同步
- 保留 `tests/test_portable_long_text.py` / `tests/test_ai_writing_transcript_endpoint.py` / `tests/test_ai_writing_session_repo.py` —— 这几个跟业务表强相关,不受迁移影响。
**删除测试**:
- `tests/test_ai_writing_session_recovery.py` —— 跨 worker 恢复改由 LangGraph Server 提供,不再是 AI 写作自己的能力。
- `tests/test_ai_writing_cleanup.py` 里关于"drain + checkpointer 降级 + SSE 序列化"的几个用例 —— 这些机制已删,对应用例失效。
**灰度策略**:
- **前端开关**:`VITE_AI_WRITING_BACKEND` = `legacy | langgraph`,默认 `legacy`
- `legacy` → 走老的 `fetch + EventSource + /resume`
- `langgraph` → 走新 `useAIWritingStream`
- **后端**:旧路由(`/start /stream /resume /state`)保留两个发版周期,逐步下线
- **数据兼容**:业务表 schema 完全不变,两条路径都用同一张表
- **回滚成本**:改回 `VITE_AI_WRITING_BACKEND=legacy` 重新部署即可,无 DB migration
**通过条件**:
- 灰度环境跑 100+ 写作流程,对比新旧两条路径的成功率、SSE 中断率、resume 成功率
- 新路径在 worker=4 部署下 SSE 中断率 = 0(旧路径基线约 5~15%)
---
## 四、关键设计决策与权衡
### 4.1 为什么不让 LangGraph checkpoint 替代业务表?
LangGraph checkpoint 存的是"图执行状态",业务表存的是"产品形态"。两者职责不同:
- **会话列表筛选/排序**:业务表 `WHERE user_id = ? AND status = ? ORDER BY updated_at` —— LangGraph checkpoint 没法这样查(它是 `(thread_id, checkpoint_ns)` 主键)
- **全文搜索 draft**:业务表 `LIKE '%关键词%'` —— checkpoint 是序列化 blob
- **transcript 自定义 UI 协议**:前端组装的时间线 JSON,跟 graph state 不一一对应
**结论**:两者**并存**,`thread_id` 作为外键。这是主聊天的做法(`threads_meta` + LangGraph checkpoint),AI 写作迁完后也是同样的形态。
### 4.2 为什么 graph 节点里直接调 repo,不发到外面消费?
考虑过"节点只 emit custom event,路由层订阅事件后写业务表" —— 但这又把状态分散回路由层了。
直接在节点里调 `await repo.save_draft(...)`:
- 节点拿得到 `thread_id`(== session_id == business id),主键一致
- `repo` 是单例(DB 连接池),不依赖 per-session 字典
- 写入失败包 try/except 即可(草稿落 state 比落 DB 更关键,DB 失败不阻断流程)
### 4.3 为什么用 custom stream 而不是 messages-tuple?
writer_draft 输出是大段 markdown,强行包成 `AIMessage` 后:
- 前端要把它从消息列表里拎出来当草稿渲染(混杂)
- 大纲 / 审核结果是结构化 JSON,更不像聊天消息
- progress_events / notice / draft_chunk / outline_chunk 几种事件类型,全塞 message 通道得发明 metadata convention
custom stream 是为这种"非聊天但需要流式 push"场景设计的,匹配度更高。
### 4.4 在 Server 进程里跑 graph,资源够用吗?
LangGraph Server 是独立 Python 进程,跟 Gateway 共享同一台机器但不共享内存。新增一个 assistant 的常驻开销 ~10 MB(graph 编译后的 schema 缓存),每个并发会话峰值 ~50 MB(LLM client + state)。
实测建议:在阶段 0 之后跑一次 10 个并发的压测,观察 Server RSS 增量。若一台机器 4 GB 内存,理论支撑 60~80 并发 AI 写作,足够 50 人规模团队使用。
---
## 五、风险清单
| # | 风险 | 缓解 |
|---|---|---|
| R1 | LangGraph Server 升级版本时 `interrupt()` / `Command(resume=...)` 协议有 breaking change | 在 `pyproject.toml` 里 pin `langgraph` minor 版本;升级前先在阶段 0 那个 probe 跑一遍冒烟 |
| R2 | 前端 `useStream` 的 `reconnectOnMount` 行为跟自写 EventSource 不完全一致,刷新页面后某些情况下不会自动续接 SSE | 阶段 3 手动测刷新场景;如不达预期,配合 `client.threads.getState()` 拉一次 state 兜底 |
| R3 | 节点里 `get_stream_writer()` 在单测里没有 LangGraph runtime → 抛错 | 节点 wrap `try: writer = get_stream_writer() except: writer = lambda x: None` |
| R4 | 多用户场景下 `langgraph_auth.py` 没对 ai_writing assistant 做隔离,可能跨用户拿到别人的 thread state | 阶段 4 一并配置 auth;新增专项测试 |
| R5 | `client.threads.delete()` 是异步删,admin 清理脚本调完后立即查 state 还能短暂查到 → `skipped_active` 误判 | 删之前先 lock thread 或调用 `client.runs.cancel()` 强制收敛 |
| R6 | LangGraph Server 自带 checkpoint 仍然是 sqlite(`langgraph.json` 里配的),在容器重启时跟 AI 写作原有的 `.deer-flow/data/checkpoints.db` 是同一个文件 → schema 共用 | 阶段 1 之前先确认主 checkpointer 配置路径,避免双开 sqlite 文件锁竞争 |
| R7 | 现有用户在迁移窗口期发起的会话,灰度切到新路径后无法续跑(state 在旧 sqlite,新路径查不到) | 迁移前 1 小时禁止新会话;或写一个一次性脚本把旧 checkpointer 的 thread 复制到主 checkpointer |
---
## 六、工时与里程碑
| 阶段 | 工时 | 可独立交付 |
|---|---|---|
| 阶段 0:可行性预演 | 0.5 d | ✅ |
| 阶段 1:graph 注册 | 0.5 d | ❌(后端只生效,前端没接) |
| 阶段 2:节点改造 | 1.5 d | ❌ |
| 阶段 3:前端接入 | 1 d | ✅(带开关上灰度) |
| 阶段 4:业务表写入解耦 | 0.5 d | ❌(跟阶段 3 合并发) |
| 阶段 5:transcript | 0.2 d | ✅ |
| 阶段 6:admin 清理改造 | 0.3 d | ✅ |
| 阶段 7:删冗余 | 0.3 d | ✅(独立 PR,灰度后再发) |
| 阶段 8:测试 + 灰度 | 1 d | ✅ |
| **核心工时** | **5.8 d** | |
| **+ 50% buffer** | **8.7 d ≈ 9 d** | |
**建议排期**:
- 第 1 周:阶段 0 + 1 + 2(后端完整、本地能跑通)
- 第 2 周:阶段 3 + 4 + 5 + 6(前端 + 业务集成)
- 第 3 周:阶段 8(测试 + 灰度)
- 灰度 1~2 周后:阶段 7(删冗余)
---
## 七、回滚预案
每个阶段都设计为可独立回滚:
| 场景 | 回滚动作 |
|---|---|
| 阶段 3 上线后发现 SSE 不稳 | 前端开关 `VITE_AI_WRITING_BACKEND=legacy`,重新部署,0 dataloss |
| 阶段 7 删冗余代码后发现新路径有问题 | 从 git revert 删除 commit,恢复旧路由代码;业务表/checkpoint 不受影响 |
| 阶段 1 注册 assistant 后 LangGraph Server 起不来 | 从 `langgraph.json` 移除 `ai_writing` 那一行,重启 Server,回到迁移前状态 |
**数据兼容承诺**:
- 业务表 schema 完全不变
- `thread_id` (== `ai_writing_sessions.id`) 含义保持一致
- transcript 列含义不变
**最坏回滚**:把 `VITE_AI_WRITING_BACKEND` 改回 `legacy`、`langgraph.json` 删 `ai_writing` 那行,回到迁移前的"方案 C/F/B 路径"。已经走完的会话留在新 checkpoint 里成"孤儿",但业务表里的草稿和审核结果**完整无损**。
---
## 八、迁移成功后能享受的"免费红利"
1. **完整 history API**:`client.threads.getHistory(thread_id)` 拿到每一步 checkpoint,做"回到某一步重试"非常便宜
2. **time travel**:用户能从任意 checkpoint 分叉重写
3. **跟主聊天共享基础设施**:升级 LangGraph 版本时一并升级,不用维护两套 SSE / checkpoint 协议
4. **天然支持水平扩容**:哪天上 K8s 多副本部署,AI 写作跟主聊天一样开箱即用
5. **天然支持 IM 渠道接入**:`app/channels/` 那套 Feishu / Slack 实现是 `langgraph-sdk` 的,迁完后 AI 写作也能在 IM 里用,零额外代码
---
## 九、相关文件索引(迁移前后参照)
### 现状(迁前)
- `app/gateway/routers/ai_writing.py:148-176` —— 8 个模块级 dict + drain task
- `packages/harness/deerflow/agents/ai_writing/graph.py:370-538` —— 自管 checkpointer
- `frontend-web/src/open-canvas/api/ai-writing.ts` —— 自写 fetch + EventSource
### 参照(主聊天迁后形态)
- `frontend-web/src/core/threads/hooks.ts:207` —— `useStream` 用法
- `frontend-web/src/core/api/api-client.ts` —— LangGraph SDK Client 初始化
- `offline-backend-20260512/backend/langgraph.json` —— assistant 注册位置
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/` —— graph 工厂参考写法
- `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py` —— 共享 checkpointer 入口
### 目标(迁后)
- 新增:`packages/harness/deerflow/agents/ai_writing/graph.py:make_ai_writing_graph`
- 新增:`frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`
- 删除:`app/gateway/routers/ai_writing.py` 中的 `/start /stream /resume /state` 路由 + 8 个 dict
- 删除:`frontend-web/src/open-canvas/api/ai-writing.ts` 中的 `startWriting / streamSession / resume` 全部 fetch 调用
- 简化:`app/gateway/ai_writing_cleanup.py`(改用 SDK client 删 thread)
---
## 十、决策摘要(给 PM / 维护者)
> **TL;DR**
> 1. 主聊天不受多 worker 影响,是因为它把状态托管给了独立的 LangGraph Server 进程,前端直接通过 SDK 跟 Server 通信。AI 写作要做同样的事。
> 2. 改造范围:后端 graph 注册 + 节点流通道改造 + 前端 hook 换 SDK + 业务表写入解耦。**核心约 6 人日,含 buffer 9 人日**。
> 3. 业务表、节点逻辑、4 个干预点、UI 形态全部保留,**用户感知层面零变化**。
> 4. 改造完一次性获得"刷新不丢进度、多 worker 不裂、跟主聊天共享基础设施、IM 渠道免费支持"等长期红利。
> 5. 建议跟"方案 C"并行:先按 [ai-writing-说明.md](./ai-writing-说明.md) 上方案 C 应急止血,本方案作为长期改造按周排期。两者不冲突,方案 C 上线即受益,本方案完工后可下线方案 C 的双进程配置。
+326
View File
@@ -327,6 +327,332 @@ upstream gateway_pool {
---
## 六-A、方案 C 在 Docker 容器内的详细实施指南
> **目标**:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
>
> **基础设施**:本项目镜像基于 `python:3.12-slim-bookworm`,用 `uv` 管理依赖,Dockerfile 位于 `offline-backend-20260512/backend/Dockerfile`,默认入口是
>
> ```
> CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
> ```
### 总览:单容器双 uvicorn 进程
```
┌─────────────── nginx (容器外或前置) ───────────────┐
│ │
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
└────────────────────────────────────────────────────┘
▲
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
```
两进程都用 **同一个 `app.gateway.app:app`**,区别只在:
- `8002` 单 worker,专门接 `/api/ai-writing/*`
- `8001` 多 worker,接其他所有路由
- 容器内用 `supervisord` 同时托管这两个 uvicorn
### 步骤 1:在镜像里加 supervisord
编辑 `offline-backend-20260512/backend/Dockerfile`,给 **runtime stage**(第 67 行 `FROM python:3.12-slim-bookworm` 之后)补一段:
```dockerfile
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
RUN apt-get update \
&& apt-get install -y --no-install-recommends supervisor \
&& rm -rf /var/lib/apt/lists/*
```
如果内网无法访问 debian 源,可用现有的 `APT_MIRROR` build-arg:
```bash
docker build \
--build-arg APT_MIRROR=mirrors.aliyun.com \
-f offline-backend-20260512/backend/Dockerfile \
-t deerflow-gateway:dual .
```
### 步骤 2:写 supervisord 配置
新建 `offline-backend-20260512/backend/deploy/supervisord.conf`:
```ini
[supervisord]
nodaemon=true ; 容器主进程,必须前台
user=root
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
[program:gateway-main]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8001
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
[program:gateway-aiwriting]
command=uv run --no-sync uvicorn app.gateway.app:app
--host 0.0.0.0 --port 8002
--workers 1
--proxy-headers --forwarded-allow-ips=*
directory=/app/backend
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=35
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
**几个关键点**:
- `nodaemon=true` 让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程
- `stopasgroup=true` + `killasgroup=true` 确保 uvicorn 多 worker 模式下所有子进程一起退
- `stopwaitsecs=35` 必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL
- `%(ENV_GATEWAY_MAIN_WORKERS)s` 让主进程 worker 数走环境变量,下面会传
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage `COPY --from=builder /app/backend ./backend` 之后加):
```dockerfile
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
```
### 步骤 3:把 Dockerfile 入口换成 supervisord
把 Dockerfile **runtime stage 最后一行** 改成:
```dockerfile
# 之前
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
# 之后
EXPOSE 8001 8002 2024
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
```
> **`dev` stage 不用改**:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
### 步骤 4:暴露端口 / 挂卷 / 环境变量
启动容器时:
```bash
docker run -d --name deerflow-gateway \
-p 8001:8001 \
-p 8002:8002 \
-e GATEWAY_MAIN_WORKERS=4 \
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
-v /your/host/path/.deer-flow:/app/.deer-flow \
-v /your/host/path/config.yaml:/app/config.yaml:ro \
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
deerflow-gateway:dual
```
**两个进程必须共享 `.deer-flow` 卷** —— 因为 `checkpoints.db` 在里面(`offline-backend-20260512/config.yaml` 里配的是相对路径 `.deer-flow/data/checkpoints.db`)。两个进程跑在同一个容器内,天然共享文件系统,**这一步不用做任何额外配置**,只要确认数据卷挂出去就行。
> ⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
### 步骤 5:nginx 路由
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
```nginx
upstream gateway_main { server gateway-host:8001; }
upstream gateway_aiwriting { server gateway-host:8002; }
server {
listen 80;
server_name deerflow.example.com;
# SSE 长连接必须关 buffering
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
# AI 写作 → 单 worker 后端
location /api/ai-writing/ {
proxy_pass http://gateway_aiwriting;
proxy_read_timeout 3600s; # graph 跑得久,给足时间
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 其它一切 API → 多 worker 后端
location /api/ {
proxy_pass http://gateway_main;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端静态资源不变
location / {
proxy_pass http://frontend:5174;
}
}
```
**`proxy_buffering off` 是必须的** —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 `frontend-web` 里 AI 写作相关的 `fetch`/`EventSource` 改成 `:8002`,但更推荐 **始终走 nginx**,对前端透明。
### 步骤 6:如果你用 docker-compose
完整 compose 示例:
```yaml
services:
gateway:
image: deerflow-gateway:dual
container_name: deerflow-gateway
restart: unless-stopped
environment:
GATEWAY_MAIN_WORKERS: "4"
DEER_FLOW_CONFIG_PATH: /app/config.yaml
ports:
- "8001:8001"
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
volumes:
- ./data/deer-flow:/app/.deer-flow
- ./config.yaml:/app/config.yaml:ro
- ./extensions_config.json:/app/extensions_config.json:ro
healthcheck:
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
nginx:
image: nginx:1.27-alpine
depends_on: [gateway]
ports: ["80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
```
`stop_grace_period: 45s` 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
### 步骤 7:验证(按这个清单逐项过)
```bash
# 1) 两个进程都起来了
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
# 期望看到:
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
# 3) 8001 不再警告多 worker
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
# 4) 8002 进程能看到 ai_writing 持久化日志
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
# 5) /api/ai-writing/* 走 8002,其他走 8001
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
```
### 步骤 8:监控两个进程的健康
supervisord 自带 `unix_http_server` + `supervisorctl`,但对容器化场景更直接的是看 docker logs:
```bash
# 实时看两个进程的日志
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
# 进程级状态
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
# 期望:
# gateway-main RUNNING pid 7, uptime 0:05:12
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
```
### 资源开销(实测预估)
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|---|---|---|---|
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
**结论**:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
### 常见坑位
1. **`stop_grace_period` 没改大 → 重启时 in-flight 写作被秒杀**
表现:每次 `docker compose restart` 都有用户报告「写到一半没了」。修复见步骤 6。
2. **8002 没暴露 / nginx 没改 → 所有请求还是落 8001**
表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
3. **两路用了不同的 `config.yaml`**
表现:8001 跟 8002 行为不一致。务必让两个进程**只读同一个文件**(在 compose 里只挂一次卷)。
4. **`.deer-flow` 卷没挂 → 8002 重启后会话全丢**
表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 `-v` 必须有。
5. **AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸**
表现:`gateway-aiwriting` 状态在 RUNNING/EXITED/BACKOFF 间反复。
修复:先 `supervisorctl tail gateway-aiwriting stderr` 看真正原因(多半是 sqlite 权限或配置文件路径),再修,**别盲目调 `autorestart=false`**。
6. **想做 K8s 部署但还想用方案 C**
不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
### 回滚方案
如果上线后发现问题,回滚只需要 1 步:
```bash
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
```
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),**不会丢任何用户数据**,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
---
## 七、运维操作清单
### 部署前验证
+282 -145
View File
@@ -1,45 +1,60 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
BarChart3,
Bell,
Bot,
Brain,
CalendarClock,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleHelp,
Compass,
Database,
FileBox,
FileOutput,
FileText,
Folder,
FolderOpen,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquare,
MoreHorizontal,
MessageSquareCode,
MessageSquarePlus,
Network,
PlayCircle,
NotebookPen,
Plus,
Scale,
Settings,
Sparkles,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
} 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[];
};
@@ -48,19 +63,18 @@ type PageSidebarProps = {
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ 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 },
{ 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" },
],
},
{
@@ -68,6 +82,25 @@ 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 },
],
@@ -77,10 +110,9 @@ const menuItems: MenuItem[] = [
label: "任务管理",
icon: Target,
children: [
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
{
id: "xk-task",
label: "XK任务",
id: "xk-center-task",
label: "XK中任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
@@ -88,124 +120,92 @@ const menuItems: MenuItem[] = [
],
},
{
id: "plan-task",
label: "规划任务",
id: "coh-center-task",
label: "COH中任务",
icon: Folder,
children: [
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
{ 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: "mnty-task",
label: "MNTY任务",
id: "pg-center-task",
label: "PG中任务",
icon: Folder,
children: [
{ id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
{ id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
{ id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
{ 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: "effect-task",
label: "效果PG任务",
id: "ty-center-task",
label: "TY中任务",
icon: Folder,
children: [
{ 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: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "ty-task-xxty", label: "XXTY任务", 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: "result-management",
label: "结果管理",
icon: BarChart3,
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
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: "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-tasks",
label: "任务管理",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
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-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
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-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
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-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
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-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
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 },
],
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,
},
],
},
@@ -214,37 +214,82 @@ const menuItems: MenuItem[] = [
label: "资源管理",
icon: Database,
children: [
{ 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 },
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-task",
"result-management",
"result-planning",
"xk-center-task",
]);
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();
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";
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 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) => {
@@ -268,13 +313,7 @@ export function PageSidebar({
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
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"
}`}
className={menuButtonClass(isActive, depth)}
>
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
{(!collapsed || depth > 0) && (
@@ -303,9 +342,7 @@ export function PageSidebar({
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
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"
}`}
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
@@ -340,24 +377,47 @@ export function PageSidebar({
};
return (
<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"
}`}
>
<>
<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"}`}
>
<div
className={`flex h-16 items-center border-b border-red-100 ${
collapsed ? "justify-center px-2" : "justify-between px-6"
}`}
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"}`}
>
<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 bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
<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"
}`}
>
<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 text-gray-900">DeerFlow</h1>
<p className="text-xs text-red-400/80">Page Navigation</p>
<h1
className={`text-sm font-semibold theme-transition ${
isDark ? "text-white" : "text-gray-900"
}`}
>
CM助手
</h1>
</div>
)}
</div>
@@ -373,7 +433,7 @@ export function PageSidebar({
)}
</div>
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{collapsed && (
<button
type="button"
@@ -385,13 +445,90 @@ export function PageSidebar({
</button>
)}
{menuItems.map((item) =>
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<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" />
<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"
}`}
/>
</aside>
</>
);
}
@@ -0,0 +1,371 @@
"use client";
import { useState } from "react";
import { Check, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useAdminAddMemoryEntry,
useAdminDeleteMemoryEntry,
useAdminReplaceMemoryEntry,
useUserMemory,
} from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/hooks";
function getEntries(data: Record<string, unknown>): string[] {
const raw = (data as { entries?: unknown }).entries;
if (!Array.isArray(raw)) return [];
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
}
// ── Memory entry list with inline editing ──────────────────────────────────
function MemoryEntryList({
entries,
userId,
agent,
}: {
entries: string[];
userId: string;
agent: string | null;
}) {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState("");
const [addDraft, setAddDraft] = useState("");
const [isAdding, setIsAdding] = useState(false);
const addMutation = useAdminAddMemoryEntry(userId);
const replaceMutation = useAdminReplaceMemoryEntry(userId);
const deleteMutation = useAdminDeleteMemoryEntry(userId);
const isPending =
addMutation.isPending || replaceMutation.isPending || deleteMutation.isPending;
function startEdit(index: number) {
setEditIndex(index);
setEditDraft(entries[index] ?? "");
}
function cancelEdit() {
setEditIndex(null);
setEditDraft("");
}
async function confirmEdit(index: number) {
const oldText = entries[index];
if (!oldText || editDraft.trim() === oldText.trim()) {
cancelEdit();
return;
}
try {
await replaceMutation.mutateAsync({ oldText, content: editDraft.trim(), agent });
cancelEdit();
} catch (err) {
toast.error(err instanceof Error ? err.message : "修改失败");
}
}
async function handleDelete(entry: string) {
try {
await deleteMutation.mutateAsync({ oldText: entry, agent });
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
}
async function handleAdd() {
const content = addDraft.trim();
if (!content) return;
try {
await addMutation.mutateAsync({ content, agent });
setAddDraft("");
setIsAdding(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "新增失败");
}
}
return (
<div className="flex flex-col gap-2">
{entries.length === 0 && !isAdding && (
<p className="text-muted-foreground py-6 text-center text-sm">暂无记忆条目</p>
)}
{entries.map((entry, i) => (
<div key={i} className="group rounded-md border p-3">
{editIndex === i ? (
<div className="flex flex-col gap-2">
<Textarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
className="min-h-[80px] text-sm"
autoFocus
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => confirmEdit(i)}
disabled={replaceMutation.isPending}
>
{replaceMutation.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Check className="size-3" />
)}
保存
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>
<X className="size-3" />
取消
</Button>
</div>
</div>
) : (
<div className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0 text-sm">·</span>
<span className="flex-1 whitespace-pre-wrap text-sm">{entry}</span>
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => startEdit(i)}
disabled={isPending}
>
<Pencil className="size-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7 hover:text-destructive"
onClick={() => handleDelete(entry)}
disabled={isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</Button>
</div>
</div>
)}
</div>
))}
{isAdding ? (
<div className="flex flex-col gap-2 rounded-md border p-3">
<Textarea
placeholder="输入新记忆条目内容…"
value={addDraft}
onChange={(e) => setAddDraft(e.target.value)}
className="min-h-[80px] text-sm"
autoFocus
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending || !addDraft.trim()}>
{addMutation.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
确认
</Button>
<Button size="sm" variant="ghost" onClick={() => { setIsAdding(false); setAddDraft(""); }}>
<X className="size-3" />
取消
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="mt-1 w-full"
onClick={() => setIsAdding(true)}
disabled={isPending}
>
<Plus className="size-3" />
新增条目
</Button>
)}
</div>
);
}
// ── Right panel: memory tabs for a selected user ───────────────────────────
function UserMemoryPanel({ userId }: { userId: string }) {
const { data: memory, isLoading } = useUserMemory(userId);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
if (isLoading) {
return (
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
);
}
const globalBucket = memory?.buckets.find((b) => b.name === "global");
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
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) : [];
return (
<Tabs defaultValue="user" className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-4 pt-4">
<TabsList variant="line">
<TabsTrigger value="user">
用户画像
{userEntries.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">
{userEntries.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="agent">
Agent 记忆
{agentBuckets.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">
{agentBuckets.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="user" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
<p className="text-muted-foreground mb-3 text-xs">
USER.md — 跨 Agent 共享的用户画像
</p>
<MemoryEntryList entries={userEntries} userId={userId} agent={null} />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
{agentBuckets.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">该用户暂无 Agent 记忆</p>
) : (
<>
<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>
<span className="text-muted-foreground text-xs">
— MEMORY.md
</span>
</div>
{currentAgent && (
<MemoryEntryList
entries={agentEntries}
userId={userId}
agent={currentAgent}
/>
)}
</>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
);
}
// ── Main page ──────────────────────────────────────────────────────────────
export default function AdminMemoryPage() {
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const filteredUsers = (users ?? []).filter((u) =>
u.email.toLowerCase().includes(userQuery.toLowerCase()),
);
return (
<div className="flex h-full">
{/* Left: user list */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="border-b p-3">
<Input
placeholder="搜索用户…"
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
className="h-8 text-sm"
/>
</div>
<ScrollArea className="flex-1">
{usersLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
)}
{!usersLoading && filteredUsers.length === 0 && (
<p className="text-muted-foreground py-8 text-center text-sm">暂无用户</p>
)}
{filteredUsers.map((user) => (
<button
key={user.id}
className={`flex w-full flex-col items-start gap-0.5 px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent ${
selectedUserId === user.id ? "bg-accent font-medium" : ""
}`}
onClick={() => setSelectedUserId(user.id)}
>
<span className="max-w-full truncate">{user.email}</span>
{user.system_role === "admin" && (
<Badge variant="default" className="h-4 px-1 text-[10px]">
Admin
</Badge>
)}
</button>
))}
</ScrollArea>
</div>
{/* Right: memory tabs */}
<div className="flex flex-1 flex-col overflow-hidden">
{selectedUserId ? (
<UserMemoryPanel key={selectedUserId} userId={selectedUserId} />
) : (
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
请在左侧选择用户
</div>
)}
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
SparklesIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
@@ -38,11 +39,16 @@ 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, useUpdateAgent } from "@/core/agents";
import {
useDeleteAgent,
useSetAgentFeatured,
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;
@@ -53,6 +59,7 @@ 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);
@@ -147,6 +154,26 @@ 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)) {
@@ -211,6 +238,16 @@ 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 && (
@@ -263,6 +300,26 @@ 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"
@@ -469,6 +526,7 @@ export function AgentCard({ agent }: AgentCardProps) {
/>
)}
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
@@ -0,0 +1,240 @@
"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>
</>
);
}
@@ -0,0 +1,173 @@
"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} />
)}
</>
);
}
@@ -64,6 +64,8 @@ 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,
@@ -119,6 +121,7 @@ export function InputBox({
onSubmit,
onStop,
promptPrefix,
agentSelector,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -159,6 +162,15 @@ 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();
@@ -867,6 +879,13 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
disabled={disabled}
/>
)}
{promptPrefix && (
<Tooltip
content={
@@ -1,7 +1,7 @@
"use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -345,16 +345,13 @@ export function MemorySettingsPage() {
);
}
/** 记忆设置面板:用户可改的白名单字段。 */
/** 记忆设置面板:仅暴露 Hindsight 这一个可选开关,其余默认开启不展示。 */
function MemoryConfigPanel() {
const { t } = useI18n();
const m = t.settings.memory;
const { configResponse, isLoading } = useMemoryConfig();
const patchConfig = usePatchMemoryConfig();
const maxTokensId = useId();
const bankMissionId = useId();
async function patch(overrides: Record<string, unknown>) {
try {
await patchConfig.mutateAsync(overrides);
@@ -367,9 +364,7 @@ function MemoryConfigPanel() {
const config: MemoryV2Config | null = configResponse?.config ?? null;
const writable = new Set(configResponse?.writable_fields ?? []);
function isWritable(field: string) {
return writable.has(field);
}
const hindsightOn = config?.injection.include_hindsight ?? false;
return (
<SettingsSection title={m.settingsTitle} description={m.settingsDesc}>
@@ -377,123 +372,43 @@ function MemoryConfigPanel() {
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : (
<div className="space-y-4 rounded-lg border p-4">
<ToggleRow
label={m.injectionEnabled}
description={m.injectionEnabledDesc}
checked={config.injection.enabled}
disabled={!isWritable("injection.enabled") || patchConfig.isPending}
onChange={(v) => void patch({ injection: { enabled: v } })}
/>
<ToggleRow
label={m.includeBuiltin}
description={m.includeBuiltinDesc}
checked={config.injection.include_builtin}
disabled={
!isWritable("injection.include_builtin") || patchConfig.isPending
}
onChange={(v) => void patch({ injection: { include_builtin: v } })}
/>
{/* 只暴露 Hindsight 开关,记忆总开关和本地记忆默认开启不展示 */}
<ToggleRow
label={m.includeHindsight}
description={m.includeHindsightDesc}
checked={config.injection.include_hindsight}
checked={hindsightOn}
disabled={
!isWritable("injection.include_hindsight") || patchConfig.isPending
!writable.has("injection.include_hindsight") || patchConfig.isPending
}
onChange={(v) => void patch({ injection: { include_hindsight: v } })}
/>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<label htmlFor={maxTokensId} className="text-sm font-medium">
{m.maxTokens}
</label>
<p className="text-muted-foreground text-sm">{m.maxTokensDesc}</p>
</div>
<Input
id={maxTokensId}
type="number"
min="100"
max="8000"
step="100"
className="w-32"
defaultValue={config.injection.max_tokens}
disabled={!isWritable("injection.max_tokens") || patchConfig.isPending}
onBlur={(event) => {
const value = Number(event.target.value);
if (
Number.isFinite(value) &&
value !== config.injection.max_tokens
) {
void patch({ injection: { max_tokens: value } });
}
}}
/>
</div>
{/* Hindsight 细节设置:仅在 Hindsight 开启时展示 */}
{hindsightOn && (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<label className="text-sm font-medium">{m.recallBudget}</label>
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
</div>
<Select
value={config.hindsight.recall_budget}
disabled={!writable.has("hindsight.recall_budget") || patchConfig.isPending}
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<label className="text-sm font-medium">{m.memoryMode}</label>
<p className="text-muted-foreground text-sm">{m.memoryModeDesc}</p>
</div>
<Select
value={config.hindsight.memory_mode}
disabled={!isWritable("hindsight.memory_mode") || patchConfig.isPending}
onValueChange={(v) => void patch({ hindsight: { memory_mode: v } })}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="context">{m.memoryModeContext}</SelectItem>
<SelectItem value="tools">{m.memoryModeTools}</SelectItem>
<SelectItem value="hybrid">{m.memoryModeHybrid}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<label className="text-sm font-medium">{m.recallBudget}</label>
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
</div>
<Select
value={config.hindsight.recall_budget}
disabled={
!isWritable("hindsight.recall_budget") || patchConfig.isPending
}
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 border-t pt-4">
<label htmlFor={bankMissionId} className="text-sm font-medium">
{m.bankMission}
</label>
<p className="text-muted-foreground text-sm">{m.bankMissionDesc}</p>
<Textarea
id={bankMissionId}
rows={2}
defaultValue={config.hindsight.bank_mission}
placeholder={m.bankMissionPlaceholder}
disabled={!isWritable("hindsight.bank_mission") || patchConfig.isPending}
onBlur={(event) => {
const value = event.target.value;
if (value !== config.hindsight.bank_mission) {
void patch({ hindsight: { bank_mission: value } });
}
}}
/>
</div>
</>
)}
</div>
)}
</SettingsSection>
@@ -23,7 +23,7 @@ function BrandMark({ className }: { className?: string }) {
);
}
function SidebarCollapseButton({ className }: { className?: string }) {
export function SidebarCollapseButton({ className }: { className?: string }) {
const { open, toggleSidebar } = useSidebar();
return (
<Button
@@ -57,9 +57,6 @@ 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>
);
@@ -71,7 +68,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
)}
>
{state === "collapsed" ? (
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
<div className="group/workspace-header relative flex min-h-[3.75rem] items-center justify-center py-2">
<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>
@@ -80,7 +77,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
</div>
</div>
) : (
<div className="flex items-center gap-2.5 px-2.5 py-3">
<div className="flex items-center gap-2 px-2.5 py-2">
<BrandMark />
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
@@ -13,7 +13,10 @@ 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, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header";
import { cn } from "@/lib/utils";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -27,84 +30,97 @@ 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() {
export function WorkspaceNavChatList({
showCollapseTrigger = false,
}: {
showCollapseTrigger?: boolean;
}) {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="px-2 pt-2">
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1">
<SidebarMenuItem>
<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>
<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>
</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 === "/page/workspace/scheduled/tasks"}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -2,6 +2,7 @@
import {
BellIcon,
BrainIcon,
CalendarClockIcon,
ChevronsUpDown,
GaugeIcon,
@@ -160,6 +161,14 @@ export function WorkspaceNavMenu() {
用户管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/memory")}
>
<BrainIcon />
记忆管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -8,6 +8,8 @@ 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";
@@ -15,22 +17,40 @@ import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
compactChrome = false,
className,
...props
}: React.ComponentProps<typeof Sidebar>) {
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const useWhiteSidebar = compactChrome && theme === "light";
return (
<>
<Sidebar variant="sidebar" collapsible="icon" {...props}>
<SidebarHeader className="py-0">
<WorkspaceHeader />
</SidebarHeader>
<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>
)}
<SidebarContent>
<WorkspaceNavChatList />
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
</SidebarContent>
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
{!compactChrome && (
<SidebarFooter>
<WorkspaceNavMenu />
</SidebarFooter>
)}
<SidebarRail />
</Sidebar>
</>
+48
View File
@@ -25,6 +25,7 @@ export interface AdminMessage {
/** One memory bucket: the global file or a per-agent file. */
export interface AdminMemoryBucket {
name: string;
display_name?: string;
data: Record<string, unknown>;
}
@@ -70,3 +71,50 @@ export async function getUserMemory(
"Failed to load memory",
);
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, agent }),
}),
"Failed to add memory entry",
);
}
export async function replaceAdminMemoryEntry(
userId: string,
oldText: string,
content: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/entries`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_text: oldText, content, agent }),
}),
"Failed to replace memory entry",
);
}
export async function deleteAdminMemoryEntry(
userId: string,
oldText: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
const params = new URLSearchParams({ old_text: oldText });
if (agent) params.set("agent", agent);
return parse(
await apiFetch(
`${base()}/${encodeURIComponent(userId)}/memory/entries?${params}`,
{ method: "DELETE" },
),
"Failed to delete memory entry",
);
}
+41 -2
View File
@@ -1,6 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getUserMemory, listUserThreadMessages, listUserThreads } from "./api";
import {
addAdminMemoryEntry,
deleteAdminMemoryEntry,
getUserMemory,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
} from "./api";
/** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) {
@@ -31,3 +38,35 @@ export function useUserMemory(userId: string | null) {
enabled: !!userId,
});
}
function useMemoryInvalidate(userId: string) {
const qc = useQueryClient();
return () => qc.invalidateQueries({ queryKey: ["admin-activity", "memory", userId] });
}
export function useAdminAddMemoryEntry(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({ content, agent }: { content: string; agent: string | null }) =>
addAdminMemoryEntry(userId, content, agent),
onSuccess: invalidate,
});
}
export function useAdminReplaceMemoryEntry(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({ oldText, content, agent }: { oldText: string; content: string; agent: string | null }) =>
replaceAdminMemoryEntry(userId, oldText, content, agent),
onSuccess: invalidate,
});
}
export function useAdminDeleteMemoryEntry(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({ oldText, agent }: { oldText: string; agent: string | null }) =>
deleteAdminMemoryEntry(userId, oldText, agent),
onSuccess: invalidate,
});
}
+50 -1
View File
@@ -1,7 +1,12 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
import type {
Agent,
AgentSelectorResponse,
CreateAgentRequest,
UpdateAgentRequest,
} from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -75,6 +80,50 @@ 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,7 +9,10 @@ import {
createAgent,
deleteAgent,
getAgent,
getAgentSelector,
listAgents,
reorderFeaturedAgents,
setAgentFeatured,
updateAgent,
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
@@ -70,3 +73,40 @@ 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,6 +14,11 @@ 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,3 +68,13 @@ 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 "";
}
+103
View File
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,94 @@
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();
}
@@ -0,0 +1,22 @@
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";
}
+2
View File
@@ -5,6 +5,7 @@ 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 {
@@ -18,6 +19,7 @@ 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
@@ -0,0 +1,39 @@
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,6 +32,34 @@ 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}`);
@@ -1,225 +0,0 @@
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,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -13,13 +14,34 @@ import {
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWriting';
import { useAIWriting } from '../../hooks/useAIWritingStream';
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';
@@ -171,6 +193,23 @@ 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
@@ -225,6 +264,16 @@ 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"
@@ -240,7 +289,9 @@ export function AIWritingPanel() {
新建
</Button>
)}
{isRunning && !state.isPaused && (
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
<Button
variant="ghost"
size="sm"
@@ -251,7 +302,7 @@ export function AIWritingPanel() {
停止
</Button>
)}
{state.isPaused && (
{ENABLE_PAUSE_RESUME_UI && state.isPaused && (
<>
<Button
variant="ghost"
@@ -294,6 +345,17 @@ 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
@@ -345,7 +407,9 @@ export function AIWritingPanel() {
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</div>
@@ -386,7 +450,7 @@ export function AIWritingPanel() {
isError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{state.isPaused ? (
{ENABLE_PAUSE_RESUME_UI && state.isPaused ? (
<>
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
已暂停
@@ -0,0 +1,51 @@
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>
);
}
@@ -0,0 +1,101 @@
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>
);
}
@@ -1,432 +0,0 @@
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 };
}
@@ -0,0 +1,814 @@
/**
* 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,7 +88,6 @@ 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 {
@@ -113,8 +112,6 @@ 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,6 +84,8 @@ export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'intent_chunk'
| 'intent_ready'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
@@ -213,6 +215,14 @@ 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;
@@ -234,6 +244,10 @@ export interface AIWritingState {
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 意图解析(流式 + 结果)
intentStreamingText: string;
intentThinkingText: string;
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)
@@ -0,0 +1,5 @@
import AdminMemoryComponent from "@/components/workspace/admin/admin-memory-page";
export default function AdminMemoryPage() {
return <AdminMemoryComponent />;
}
+37 -10
View File
@@ -3,11 +3,10 @@
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
@@ -56,6 +55,11 @@ export default function AgentChatPage() {
}
}, [isNewThread, threadId]);
// Auto-submit a message that the user typed on ChatPage right before
// selecting this agent. ChatPage stashed it in sessionStorage so we don't
// depend on router state (the next/navigation shim doesn't carry it).
const autoSubmitDoneRef = useRef(false);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
@@ -100,6 +104,29 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id],
);
useEffect(() => {
if (autoSubmitDoneRef.current) return;
if (!isNewThread || hasStartedConversation) return;
let raw: string | null = null;
try {
raw = sessionStorage.getItem("pendingAgentMessage");
} catch {
return;
}
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { agentId?: string; text?: string };
if (parsed.agentId !== agent_id || !parsed.text) {
return;
}
autoSubmitDoneRef.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: parsed.text, files: [] });
} catch {
sessionStorage.removeItem("pendingAgentMessage");
}
}, [agent_id, hasStartedConversation, handleSubmit, isNewThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
@@ -116,7 +143,12 @@ export default function AgentChatPage() {
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
// AgentChatPage skips the centered welcome/landing state entirely — when the
// user reaches this page they've already committed to an agent (either by
// selecting one on the homepage or by visiting the agent's URL), so we drop
// them straight into the conversation layout: messages area on top, input at
// the bottom. The agent badge in the header still identifies the agent.
const isLandingState = false;
return (
<ThreadContext.Provider value={{ thread }}>
@@ -198,9 +230,9 @@ export default function AgentChatPage() {
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isLandingState}
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isLandingState}
autoFocus={isNewThread}
status={
thread.error
? "error"
@@ -209,11 +241,6 @@ export default function AgentChatPage() {
: "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}
+40
View File
@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -34,6 +35,7 @@ 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);
@@ -44,6 +46,7 @@ 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();
@@ -55,6 +58,7 @@ export default function ChatPage() {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedAgentId(null);
}
}, [isNewThread, threadId]);
@@ -105,6 +109,32 @@ 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 &&
@@ -140,6 +170,8 @@ export default function ChatPage() {
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
selectedAgentId,
router,
settings.context.writing_mode,
writingArtifactPath,
],
@@ -258,6 +290,14 @@ export default function ChatPage() {
setSettings("context", context)
}
promptPrefix={promptPrefixProp}
agentSelector={
isLandingState
? {
selectedAgentId,
onAgentChange: setSelectedAgentId,
}
: undefined
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
@@ -0,0 +1,133 @@
"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>
);
}
@@ -0,0 +1,371 @@
"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>
);
}
+12 -2
View File
@@ -1,10 +1,14 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } 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";
@@ -13,6 +17,11 @@ 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;
@@ -32,6 +41,7 @@ export default function LoginPage() {
return;
}
setStoredAuth(auth);
persistSidebarEnabled(sidebarEnabled);
setStatus("success");
router.replace("/page/workspace/chats/new");
} catch (error) {
@@ -49,7 +59,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
}, [resolvedUsername, router, sidebarEnabled]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
+30 -2
View File
@@ -5,6 +5,7 @@ import {
CheckCircleIcon,
InfoIcon,
SaveIcon,
SearchIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -76,6 +77,7 @@ export default function NewAgentPage() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillSearch, setSkillSearch] = useState("");
const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
@@ -85,6 +87,16 @@ export default function NewAgentPage() {
[skills],
);
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
@@ -230,7 +242,8 @@ export default function NewAgentPage() {
return;
}
toast.success(`智能体「${saved.name}」已保存`);
}, [ensureAgent]);
router.push("/page/workspace/agents");
}, [ensureAgent, router]);
const handleChatSubmit = useCallback(
async (text: string) => {
@@ -439,6 +452,17 @@ export default function NewAgentPage() {
已选 {selectedSkills.length} 个
</span>
</div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能名称或描述…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
@@ -446,9 +470,13 @@ export default function NewAgentPage() {
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
没有匹配的技能。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
+9 -9
View File
@@ -1,22 +1,21 @@
import { useState } from "react";
import { useMemo, 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">
{SIDEBAR_ENABLED && (
{sidebarEnabled && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
@@ -24,10 +23,10 @@ function PageLayout() {
)}
<div
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${
!SIDEBAR_ENABLED ? "" : collapsed ? "pl-16" : "pl-64"
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
}`}
>
{SIDEBAR_ENABLED && <Header />}
{sidebarEnabled && <Header />}
<main className="flex-1 overflow-hidden">
<Routes>
<Route index element={<Navigate to="workspace" replace />} />
@@ -36,8 +35,9 @@ function PageLayout() {
element={
<WorkspaceRoutes
pageSidebarCollapsed={collapsed}
pageSidebarHidden={!SIDEBAR_ENABLED}
headerHidden={!SIDEBAR_ENABLED}
pageSidebarHidden={!sidebarEnabled}
headerHidden={!sidebarEnabled}
compactWorkspaceSidebar={sidebarEnabled}
/>
}
/>
+82 -2
View File
@@ -12,9 +12,20 @@ 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 (
@@ -27,6 +38,10 @@ 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
@@ -54,6 +69,11 @@ export default function StrategyRoutes() {
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
@@ -65,14 +85,74 @@ export default function StrategyRoutes() {
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 */}
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/strategy/chats" replace />
<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 />
)
}
/>
+24 -2
View File
@@ -28,6 +28,7 @@ import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
@@ -36,11 +37,13 @@ export function WorkspaceLayout({
pageSidebarCollapsed,
pageSidebarHidden = false,
headerHidden = false,
compactWorkspaceSidebar = false,
}: {
children: ReactNode;
pageSidebarCollapsed: boolean;
pageSidebarHidden?: boolean;
headerHidden?: boolean;
compactWorkspaceSidebar?: boolean;
}) {
const pageSidebarWidth = pageSidebarHidden
? "0rem"
@@ -62,7 +65,7 @@ export function WorkspaceLayout({
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen
>
<WorkspaceSidebar />
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
@@ -84,12 +87,19 @@ 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 };
const layoutProps = {
pageSidebarCollapsed,
pageSidebarHidden,
headerHidden,
compactWorkspaceSidebar,
};
return (
<RouteBaseProvider value="/page/workspace">
@@ -223,6 +233,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/memory"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminMemoryPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
+30 -18
View File
@@ -1,31 +1,29 @@
import React from 'react';
import React, { useState } from 'react';
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
import { NotificationDialog } from '@/components/workspace/notification-dialog';
import { useTheme } from '../contexts/ThemeContext';
import { useUnreadCount } from '@/core/notifications';
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
return (
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
<>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
isDark
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
: 'bg-red-600 border-b border-red-700'
}`}>
{/* 左侧 - 搜索框 */}
<div className="flex items-center gap-4">
<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>
{/* 右侧 - 用户信息和通知 */}
@@ -44,11 +42,24 @@ const Header: React.FC = () => {
</button>
{/* 通知 */}
<button className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<button
type="button"
onClick={() => setNotificationOpen(true)}
className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}
title="通知"
>
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-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>
)}
</button>
{/* 分隔线 */}
@@ -71,6 +82,7 @@ const Header: React.FC = () => {
</button>
</div>
</header>
</>
);
};
+3 -1
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`); `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) |
| **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) |
| **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,6 +260,8 @@ 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,25 +108,31 @@ async def _delete_checkpoint_thread(checkpointer, thread_id: str) -> None:
)
def _get_active_session_ids() -> set[str]:
"""返回内存里**当前正在跑**的 session_id 集合。
async def _get_active_session_ids_langgraph(thread_store) -> set[str]:
"""新路径下「正在跑 / 等用户干预」的 thread_id 集合。
这些会话的写作流程还活着 —— 任何清理都不能动它们,否则用户「正在写」
的稿子会被秒删,体验灾难。延迟到下一轮清理(24 小时后)再处理也比误
删强。
``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 过滤。
"""
try:
# 延迟 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:
# 兜底:拿不到就当作没有活动会话(最差就是清掉一个刚跑完的会话,
# 比误删正在写的要好得多 —— 用户至少能从 DB 历史里看到结果)
logger.warning("ai_writing_cleanup: 读取活动会话列表失败", exc_info=True)
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)
except Exception:
logger.warning("ai_writing_cleanup: 读取 LangGraph 活动 thread 列表失败", 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(
@@ -134,18 +140,27 @@ async def run_cleanup_once(
retention_days: int,
only_finished: bool = False,
batch_size: int = 500,
checkpointer=None,
thread_store=None,
) -> dict:
"""执行一轮清理。返回统计信息。
**安全保证**:清理永远跳过内存里仍在跑的会话(``_event_queues`` 里有
对应 key 的)—— 这条规则即使在 admin 手动触发清理时也强制生效,防止
把用户「正在写」的稿子误删。
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`` 也强制生效。
幂等 + 可重入 —— 多次调用对结果只会越扫越干净。即便清理途中被中断,下次
再跑会重新把 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
@@ -155,8 +170,7 @@ async def run_cleanup_once(
return {"deleted_db": 0, "deleted_checkpoints": 0, "skipped_active": 0}
repo = AIWritingSessionRepository(sf)
checkpointer = get_ai_writing_checkpointer()
active_ids = _get_active_session_ids()
active_ids = await _get_active_session_ids_langgraph(thread_store)
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
logger.info(
@@ -238,11 +252,16 @@ 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() -> None:
async def cleanup_scheduler_loop(app=None) -> 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
@@ -289,10 +308,17 @@ async def cleanup_scheduler_loop() -> 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: 本轮清理失败(不影响下一轮)")
@@ -71,12 +71,6 @@ 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.
@@ -371,11 +365,10 @@ async def _curator_scheduler_loop() -> None:
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
AI 写作 + 主 agent 流式响应都依赖**进程内**的内存状态字典
(``_event_queues``、``_session_pending_pause`` 等)。多 worker 下,
用户的 /start /stream /resume 会被 OS 随机分配到不同 worker,
per-worker 的内存字典互相看不见 → SSE 事件丢失、会话「凭空消失」、
resume 走不通。
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
维护 run_id → SSE 订阅者的内存 map。多 worker 下,用户在 worker A 发起
``/runs/stream``、稍后在 worker B 调 join/resume,会因 in-process map 互相
看不见而 404 / 卡死。
所以这里在 startup 打 ERROR 强提醒。**不抛异常 fail-fast** —— 万一
运维确实知道自己在干嘛(比如配了 sticky session),不应该阻止启动。
@@ -449,6 +442,18 @@ 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)
@@ -474,7 +479,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.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
@@ -526,32 +531,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("AI writing cleanup scheduler shutdown error")
# 等待所有 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")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
# ``langgraph_runtime()`` 的 contextmanager 统一关闭,无需在这里特殊处理。
try:
scheduler_service = getattr(app.state, "scheduled_task_service", None)
@@ -130,14 +130,24 @@ 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
from deerflow.persistence.ai_writing_sessions import (
make_ai_writing_session_store,
register_default_store as _register_ai_writing_default_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)
@@ -261,6 +271,14 @@ 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.
@@ -19,12 +19,12 @@ from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import (
get_agent_store,
get_checkpointer,
get_current_user_from_request,
get_thread_store,
)
from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.paths import get_paths
from deerflow.runtime import serialize_channel_values
@@ -127,10 +127,22 @@ async def list_user_thread_messages(
class AdminMemoryBucket(BaseModel):
name: str = Field(..., description="Bucket name: 'global' or a custom-agent name")
name: str = Field(..., description="Bucket name: 'global' or the agent id (used for CRUD)")
display_name: str = Field("", description="Human-readable name shown in the UI")
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
class AdminMemoryEntryAddRequest(BaseModel):
content: str = Field(..., min_length=1, description="Entry content to add")
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
class AdminMemoryEntryReplaceRequest(BaseModel):
old_text: str = Field(..., min_length=1, description="Short unique substring to locate the entry")
content: str = Field(..., min_length=1, description="New content to replace with")
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
class AdminMemoryResponse(BaseModel):
user_id: str
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
@@ -154,25 +166,103 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
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", data=_read_memory_bucket(user_id, None))
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
]
agents_root = get_paths().user_dir(user_id) / "agents"
if agents_root.is_dir():
for entry in sorted(agents_root.iterdir(), key=lambda p: p.name.lower()):
if not entry.is_dir() or not (entry / "MEMORY.md").exists():
continue
if not AGENT_NAME_PATTERN.match(entry.name):
continue
try:
data = _read_memory_bucket(user_id, entry.name)
except Exception:
logger.warning("Failed to read memory bucket %s for user", entry.name)
continue
buckets.append(AdminMemoryBucket(name=entry.name, data=data))
# 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)
owned_agents = sorted(
[a for a in all_agents if a.get("user_id") == user_id],
key=lambda a: (a.get("name") or a["id"]).lower(),
)
for agent in owned_agents:
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, buckets=buckets)
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
async def admin_add_memory_entry(
user_id: str,
body: AdminMemoryEntryAddRequest,
request: Request,
) -> AdminMemoryBucket:
"""Add a memory entry for a user (admin only)."""
await _require_admin(request)
target = "user" if body.agent is None else "memory"
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=body.agent)
try:
result = provider.add(target, body.content)
if result is False:
raise HTTPException(status_code=400, detail="Failed to add entry (quota exceeded or write error)")
except HTTPException:
raise
except Exception as exc:
logger.error("admin_add_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to write memory entry") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
@router.put("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
async def admin_replace_memory_entry(
user_id: str,
body: AdminMemoryEntryReplaceRequest,
request: Request,
) -> AdminMemoryBucket:
"""Replace a memory entry for a user (admin only)."""
await _require_admin(request)
target = "user" if body.agent is None else "memory"
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=body.agent)
try:
result = provider.replace(target, body.old_text, body.content)
if result is False:
raise HTTPException(status_code=404, detail="Entry not found or write failed")
except HTTPException:
raise
except Exception as exc:
logger.error("admin_replace_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to replace memory entry") from exc
bucket_name = "global" if body.agent is None else body.agent
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
@router.delete("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
async def admin_delete_memory_entry(
user_id: str,
request: Request,
old_text: str = Query(..., min_length=1, description="Short unique substring to locate the entry"),
agent: str | None = Query(None, description="Agent name; None = global USER.md bucket"),
) -> AdminMemoryBucket:
"""Delete a memory entry for a user (admin only)."""
await _require_admin(request)
target = "user" if agent is None else "memory"
provider = BuiltinFileProvider()
provider.initialize(user_id=user_id, agent_id=agent)
try:
result = provider.remove(target, old_text)
if result is False:
raise HTTPException(status_code=404, detail="Entry not found")
except HTTPException:
raise
except Exception as exc:
logger.error("admin_delete_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
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))
@@ -45,6 +45,11 @@ 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."""
@@ -76,6 +81,28 @@ 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:
@@ -179,7 +206,45 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
logger.exception("Failed to sync legacy folder-backed agents")
def _record_to_response(record: dict[str, Any], *, include_soul: bool = False) -> AgentResponse:
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)
soul: str | None = None
if include_soul:
soul = load_agent_soul(record["id"]) or ""
@@ -199,6 +264,7 @@ def _record_to_response(record: dict[str, Any], *, include_soul: bool = False) -
model=extras["model"],
tool_groups=extras["tool_groups"],
skills=extras["skills"],
featured_order=record.get("featured_order"),
)
@@ -284,6 +350,32 @@ 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,
@@ -297,7 +389,8 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
record = await store.get_visible(agent_id, _current_user_id(request))
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
return _record_to_response(record, include_soul=True)
@@ -514,3 +607,91 @@ 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,31 +1,26 @@
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
"""AI 写作路由 —— 业务表 CRUD + transcript 持久化 + admin cleanup。
写作主流程(``/start /stream /resume /state``)已迁到 LangGraph Server 的
``ai_writing`` assistant(前端走 ``useAIWritingStream`` hook + SDK),路由层
不再承载 graph 推进,只保留以下「不依赖 graph 状态」的辅助接口:
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__)
@@ -34,38 +29,6 @@ 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
@@ -85,8 +48,8 @@ class AdminCleanupRequest(BaseModel):
class AdminCleanupResponse(BaseModel):
deleted_db: int
deleted_checkpoints: int
# 因「仍在跑」被保护掉的会话数。永远跳过 _event_queues 里活跃的会话,
# 即使 retention_days=0 也不会秒杀用户正在写的稿。
# 因「仍在跑 / 等用户干预」被保护掉的会话数(threads_meta.status in
# running/interrupted)。即使 retention_days=0 也不会秒杀用户正在写的稿。
skipped_active: int = 0
elapsed_seconds: float
params: dict
@@ -142,244 +105,7 @@ 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 写作历史会话。"""
@@ -460,8 +186,6 @@ 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 升序排列。"""
@@ -535,91 +259,6 @@ 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
# ── 管理接口 ──────────────────────────────────────────────────────────────────
@@ -688,9 +327,20 @@ 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)
result = await run_cleanup_once(
**params,
checkpointer=main_checkpointer,
thread_store=main_thread_store,
)
except Exception as e:
logger.exception("ai_writing admin cleanup failed: params=%s", params)
# 抛 500 是合理的 —— 管理员触发清理失败需要看到失败原因,跟「业务
@@ -715,292 +365,3 @@ 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,15 +21,16 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
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,
delete_user_memory_override_db,
get_effective_memory_config,
save_user_memory_override,
save_user_memory_override_db,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
@@ -295,14 +296,17 @@ async def get_memory_v2_config() -> MemoryV2ConfigResponse:
"/memory/v2/config",
response_model=MemoryV2ConfigResponse,
summary="Patch Memory V2 Config",
description="把白名单字段写入当前用户的 per-user 配置覆盖。非白名单字段被忽略。",
description="把 hindsight.recall_budget 写入当前用户的 per-user 配置覆盖 (DB 存储)。",
)
async def patch_memory_v2_config(request: MemoryConfigPatchRequest) -> MemoryV2ConfigResponse:
"""更新当前用户的 per-user 记忆配置覆盖。"""
async def patch_memory_v2_config(request: MemoryConfigPatchRequest, req: Request) -> MemoryV2ConfigResponse:
"""更新当前用户的 per-user 记忆配置覆盖 (仅 hindsight.recall_budget)。"""
user_id = get_effective_user_id()
store = get_memory_config_store(req)
try:
save_user_memory_override(user_id, request.overrides)
except OSError as exc:
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
raise HTTPException(status_code=500, detail="Failed to save memory config override.") from exc
config = get_effective_memory_config(user_id)
@@ -317,18 +321,20 @@ async def patch_memory_v2_config(request: MemoryConfigPatchRequest) -> MemoryV2C
response_model=MemoryV2ConfigResponse,
summary="Delete Memory V2 Config Override",
description=(
"撤销当前用户的 per-user 配置覆盖。传 ?field=injection.enabled 只撤销该字段;"
"不传 field 则清空整个 per-user 覆盖。"
"撤销当前用户的 per-user 配置覆盖。传 ?field=hindsight.recall_budget 只撤销该字段;"
"不传 field 则清空全部覆盖。"
),
)
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:
delete_user_memory_override(user_id, field)
except OSError as exc:
await delete_user_memory_override_db(user_id, field, store)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail="Failed to delete memory config override.") from exc
config = get_effective_memory_config(user_id)
@@ -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"})
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
@@ -191,15 +191,20 @@ 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``) 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``, ``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`.
"""
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
@@ -424,6 +429,11 @@ 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))
@@ -465,6 +475,9 @@ 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
+18 -16
View File
@@ -306,25 +306,27 @@ subagents:
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
provider:
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
builtin:
enabled: true
memory_char_limit: 2200
user_char_limit: 1375
deduplicate_on_load: true
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://localhost:8765
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://47.88.25.99:18888
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
@@ -350,10 +352,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>
@@ -362,6 +364,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: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
@@ -7,7 +7,8 @@
"env": ".env",
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent"
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
},
"auth": {
"path": "./app/gateway/langgraph_auth.py:auth"
@@ -1,15 +1,3 @@
from .graph import (
AI_WRITING_GRAPH,
build_ai_writing_graph,
get_ai_writing_checkpointer,
get_ai_writing_graph,
shutdown_ai_writing_graph,
)
from .graph import make_ai_writing_graph
__all__ = [
"AI_WRITING_GRAPH",
"build_ai_writing_graph",
"get_ai_writing_graph",
"get_ai_writing_checkpointer",
"shutdown_ai_writing_graph",
]
__all__ = ["make_ai_writing_graph"]
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, StateGraph
from langgraph.types import interrupt
@@ -15,6 +16,30 @@ 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:
@@ -127,7 +152,7 @@ async def pause_outline_confirm(state: AIWritingState) -> dict:
return updates
async def pause_draft_confirm(state: AIWritingState) -> dict:
async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) -> dict:
"""PAUSE-3:草稿确认"""
draft = state["current_draft"]
user_input: dict = interrupt({
@@ -141,6 +166,7 @@ async def pause_draft_confirm(state: AIWritingState) -> dict:
action = user_input.get("action", "to_editor")
if action == "finalize":
await _mark_session_done(config)
return {
"last_user_intervention": user_input,
"status": "done",
@@ -223,7 +249,7 @@ async def pause_section_help(state: AIWritingState) -> dict:
}
async def pause_review_confirm(state: AIWritingState) -> dict:
async def pause_review_confirm(state: AIWritingState, config: RunnableConfig) -> dict:
"""PAUSE-4:编辑打回确认"""
user_input: dict = interrupt({
"pause_point": "review_confirm",
@@ -234,6 +260,7 @@ async def pause_review_confirm(state: AIWritingState) -> dict:
})
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",
@@ -299,25 +326,26 @@ def after_review_pause(state: AIWritingState) -> str:
return "end" if state["status"] == "done" else "writer_draft"
# ── 组装 Graph ────────────────────────────────────────────────────────────────
def build_ai_writing_graph(checkpointer=None):
"""构建并 compile AI 写作 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 注入。
Args:
checkpointer: 可选的 LangGraph checkpointer。
- ``None`` → 退回 ``MemorySaver`` —— 仅供测试 / 开发使用。
进程或容器重启后会话状态全部丢失,前端拿着旧 session_id 调
``/resume`` 会得到 404。
- 其他 → 传入的 (Async)BaseCheckpointSaver 实例,用于持久化。
config: LangGraph Server 调用工厂时注入的 RunnableConfig(暂未消费)。
"""
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)
@@ -325,214 +353,23 @@ def build_ai_writing_graph(checkpointer=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"})
{"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"})
{"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"})
{"researcher": "researcher", "writer_draft": "writer_draft"})
g.add_conditional_edges("pause_draft", after_draft_pause,
{"end": END, "writer_draft": "writer_draft", "editor": "editor"})
{"end": END, "writer_draft": "writer_draft", "editor": "editor"})
g.add_conditional_edges("editor", after_review,
{"pause_draft": "pause_draft", "pause_review": "pause_review"})
{"pause_draft": "pause_draft", "pause_review": "pause_review"})
g.add_conditional_edges("pause_review", after_review_pause,
{"end": END, "writer_draft": "writer_draft"})
{"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)
return g.compile()
@@ -15,6 +15,54 @@ 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_model, parse_json, _content_to_text, ThinkTagParser
from ._utils import get_ai_writing_config, get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer
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,
sse_queue: asyncio.Queue | None, session_id: str,
writer: Callable[[dict], None],
) -> str:
"""流式执行单项审查,逐 chunk 推送 review_chunk 事件,返回累积的正文文本(用于 JSON 解析)。"""
"""流式执行单项审查,逐 chunk 经 LangGraph custom 通道推送 review_chunk 事件,返回累积的正文文本(用于 JSON 解析)。"""
full_text = "" # 仅累积正文(非思考),供后续 JSON 解析
parser = ThinkTagParser()
async for chunk in model.astream([
@@ -55,16 +55,42 @@ async def _stream_one_review(
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if sse_queue is not None:
await sse_queue.put(("review_chunk", {
"session_id": session_id,
"category": category,
"text": text,
"is_thinking": is_thinking,
}))
writer({
"type": "review_chunk",
"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:
@@ -78,9 +104,9 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
mats = _materials_summary(pkg)
cfg = config.get("configurable", {})
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 {}
writer = safe_get_stream_writer()
# 阶段 7 后从 app config 直接读,不走 configurable 注入
ai_writing_config = get_ai_writing_config()
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
REVIEW_TASKS = [
@@ -110,7 +136,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, sse_queue, session_id)
full_text = await _stream_one_review(model, user_prompt, category, writer)
res = parse_json(full_text, fallback)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
@@ -120,16 +146,13 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
results[category] = res
# 这一项审查完成 —— 推送结构化结果(前端据此把流式文本替换为评分/意见)
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)
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],
})
# 汇总
fact_score = int(results["fact"].get("score", 80))
@@ -156,6 +179,9 @@ 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,12 +1,64 @@
"""意图解析节点:将用户自然语言意图结构化为写作配置。"""
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 llm_json
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)
# 兜底类型列表,当 config 中未注入 valid_article_types 时使用
_DEFAULT_ARTICLE_TYPES: list[dict] = [
@@ -79,6 +131,10 @@ 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(
@@ -90,42 +146,79 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if fallback_type not in valid_keys:
fallback_type = default_key
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,
)
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"],
}
# 意图解析模型调用失败时以非致命 notice 提示用户(llm_json 已用 fallback 兜底,
# 流程仍可继续,但用户需知道结构化配置可能不准)。
# 流式调用:与 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 已兜底,流程仍可继续)
progress_events: list[dict] = []
if result.get("_llm_error"):
if llm_error:
progress_events.append({
"type": "notice",
"agent_name": "系统",
"title": "模型错误",
"detail": f"意图解析失败,已按默认配置兜底。原因:{result['_llm_error']}",
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
})
progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"})
updates: dict = {"status": "researching", "progress_events": progress_events}
# 校验 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"] = result["target_audience"]
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
if result.get("target_audience"):
updates["target_audience"] = final_target_audience
if word_count is not None:
updates["word_count_target"] = word_count
updates["word_count_target"] = final_word_count
if result.get("refined_intent"):
updates["user_intent"] = result["refined_intent"]
updates["user_intent"] = final_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 llm_json
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 20
_DEFAULT_MAX_MATERIALS = 20
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
_DEFAULT_MAX_MATERIALS = 50
_MIN_MAX_MATERIALS = 1
_MAX_MAX_MATERIALS = 100
@@ -93,68 +93,80 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
article_type = state.get("article_type", "analysis")
target_audience = state.get("target_audience", "普通读者")
# 检查是否是补充搜索(PAUSE-1 re_search action)
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
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 = intervention.get("action") == "supplement_search"
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)
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
# SSE 实时进度推送(与 outline/draft 节点共用 sse_queue 机制)
# 实时进度推送:阶段 2 起统一走 LangGraph 原生 custom 通道。
cfg = config.get("configurable", {})
sse_queue = cfg.get("sse_queue")
sse_session_id: str = cfg.get("sse_session_id", "")
writer = safe_get_stream_writer()
# 素材收集专家专用模型:检索词生成 / 素材排序 可在 config.yaml 的
# ai_writing 段单独配置(keyword_model / rank_model),留空则回退写作主模型。
ai_writing_config = cfg.get("ai_writing_config", {}) or {}
# 注:阶段 7 后这份配置由节点直接读 app config,不再走 configurable 注入。
ai_writing_config = get_ai_writing_config()
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"))
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,
}))
def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message})
# Step 1: 提取关键词
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))
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))
# Step 2: 并发搜索
provider = get_search_provider(ai_writing_config)
# 按用户指定的关键词数量截断;补充搜索时不截断用户额外添加的关键词
search_limit = keyword_count
if extra_keywords:
search_limit = max(search_limit, len(keywords))
search_keywords = keywords[:search_limit]
# 按用户指定的关键词数量截断。
# 追加模式: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]
total = len(search_keywords)
# 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整)
per_keyword = _per_keyword_limit(max_materials, total)
await _emit(f"检索词已生成,开始检索 {total} 个方向…")
_emit(f"检索词已生成,开始检索 {total} 个方向…")
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
completed = 0
@@ -166,18 +178,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
finally:
completed += 1
if completed < total:
await _emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
await _emit(f"已完成全部 {total} 次检索")
_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")
if existing_pkg and intervention.get("action") != "re_search":
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
raw_materials.append(m)
@@ -208,7 +224,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
search_errors.extend(getattr(provider, "errors", []))
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
await _emit("检索完成,正在筛选排序素材…")
_emit("检索完成,正在筛选排序素材…")
rank_error = ""
if len(raw_materials) > max_materials:
materials_list = "\n".join(
@@ -241,7 +257,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if not materials:
summary = "未检索到相关素材,可重新搜索或在无素材的情况下继续写作"
pkg = MaterialPackage(keywords=keywords, materials=materials, summary=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)
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
@@ -259,12 +281,18 @@ 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": f"搜索关键词:{', '.join(keywords[:3])}"},
{"type": "step_start", "agent_name": "素材收集专家", "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": "素材收集专家", "message": summary},
],
@@ -1,7 +1,6 @@
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
from __future__ import annotations
import asyncio
import logging
import re
@@ -13,7 +12,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
from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -64,6 +63,35 @@ 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:
@@ -80,10 +108,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 [])
# SSE 流式推送所需
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
# cfg 仍保留以便阶段 4 取 user_id / thread_id 用。
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
writer = safe_get_stream_writer()
# 修改意见(来自用户或编辑)
revision_notes = ""
@@ -174,25 +202,23 @@ 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
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": 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": "",
"is_section_done": True,
}))
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": "",
"is_section_done": True,
})
cleaned_buf = section_buf.strip()
blocked_reason = ""
@@ -289,6 +315,11 @@ 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,7 +1,6 @@
"""作家节点 Phase 1:生成文章大纲,支持流式 chunk 输出。"""
from __future__ import annotations
import asyncio
import json
import logging
@@ -12,7 +11,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
from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -79,10 +78,8 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
feedback_section=feedback_section,
)
# SSE 流式推送
cfg = config.get("configurable", {})
sse_queue: asyncio.Queue | None = cfg.get("sse_queue")
session_id: str = cfg.get("sse_session_id", "")
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
writer = safe_get_stream_writer()
full_text = "" # 只累积正文(非思考)用于 JSON 解析
parser = ThinkTagParser()
@@ -101,12 +98,11 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if sse_queue is not None:
await sse_queue.put(("outline_chunk", {
"session_id": session_id,
"text": text,
"is_thinking": is_thinking,
}))
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
@@ -411,7 +411,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
config["metadata"] = {}
if is_bootstrap:
runtime_available_skills: list[str] | None = ["bootstrap"]
runtime_available_skills: list[str] | None = None # all enabled skills visible in bootstrap
elif agent_config:
# Custom agents must use an explicit allowlist. If omitted, default to none.
runtime_available_skills = list(agent_config.skills or [])
@@ -452,7 +452,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
max_concurrent_subagents=max_concurrent_subagents,
writing_mode=writing_mode,
writing_artifact_path=writing_artifact_path,
available_skills=set(["bootstrap"]),
available_skills=None,
is_bootstrap=True,
app_config=resolved_app_config,
),
state_schema=ThreadState,
@@ -496,6 +496,8 @@ You: "Deploying to staging..." [proceed]
{skills_section}
{bootstrap_section}
{deferred_tools_section}
{writing_mode_section}
@@ -819,6 +821,27 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
def _build_bootstrap_section() -> str:
return """<bootstrap_agent_creation>
你正在帮助用户创建一个自定义智能体。
**技能匹配规则(必须遵守):**
1. 先调用 `skill_list` 查看用户当前所有可用技能及其描述。
2. 根据用户对新智能体的描述/意图,从已有技能中选出最相关的 1-5 个技能名称(skill name,使用 hyphen-case)。
3. 将选出的技能名称填入 `setup_agent` 的 `skills` 参数。
4. **严禁在本次创建流程中调用 `skill_manage`(创建、编辑或删除技能)**。如用户提出需要新技能,请告知他们创建完智能体后再单独创建。
5. 如果没有任何技能与用户意图相关,可将 `skills` 设为空列表,并向用户说明原因。
**工作流程:**
1. 与用户充分沟通,明确新智能体的用途、专长和边界。
2. 调用 `skill_list` 列出现有技能。
3. 在思考中匹配技能,选出最合适的。
4. 草拟 SOUL.md(定义智能体的人设、工作方式、输出要求)。
5. 向用户确认配置后,调用 `setup_agent` 提交。
</bootstrap_agent_creation>
"""
def apply_prompt_template(
subagent_enabled: bool = False,
max_concurrent_subagents: int = 3,
@@ -828,6 +851,7 @@ def apply_prompt_template(
writing_mode: bool = False,
writing_artifact_path: str | None = None,
available_skills: set[str] | None = None,
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
soul_override: str | None = None,
) -> str:
@@ -858,6 +882,9 @@ def apply_prompt_template(
else ""
)
# Bootstrap section: skill-matching guidance (only in agent creation flow)
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
@@ -903,6 +930,7 @@ Writing mode is enabled for this run.
agent_name=agent_name or storage_agent_id or "CM 助手",
soul=f"<soul>\n{soul_override}\n</soul>\n" if soul_override else get_agent_soul(storage_agent_id),
skills_section=skills_section,
bootstrap_section=bootstrap_section,
deferred_tools_section=deferred_tools_section,
writing_mode_section=writing_mode_section,
memory_context=memory_context,
@@ -59,11 +59,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
agent_id: The agent storage/runtime id.
Returns:
AgentConfig instance.
AgentConfig instance, or ``None`` when the on-disk artifacts are missing.
Raises:
FileNotFoundError: If the agent directory or config.yaml does not exist.
ValueError: If config.yaml cannot be parsed.
ValueError: If config.yaml exists but cannot be parsed.
Tolerance:
A missing agent directory or missing config.yaml is **not** an error —
the runtime simply falls back to default Lead Agent behavior for that
id. This keeps the chat session usable when the DB still has an agent
row but its files have been deleted (e.g. data dir moved between
machines, or filesystem wiped). The router layer is expected to
lazily restore the directory next time the agent is fetched via API.
"""
if agent_id is None:
@@ -74,10 +81,20 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
config_file = agent_dir / "config.yaml"
if not agent_dir.exists():
raise FileNotFoundError(f"Agent directory not found: {agent_dir}")
logger.warning(
"Agent directory missing for id '%s' (%s); falling back to default config",
agent_id,
agent_dir,
)
return None
if not config_file.exists() and not (agent_dir / SOUL_FILENAME).exists():
raise FileNotFoundError(f"Agent config not found: {config_file}")
logger.warning(
"Agent has no config.yaml or %s at %s; falling back to default config",
SOUL_FILENAME,
agent_dir,
)
return None
data: dict[str, Any] = {}
if config_file.exists():
@@ -12,21 +12,21 @@
injection: {...} # 系统提示词注入
security: {...} # 内容安全扫描
三层配置覆盖
两层配置覆盖
============
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
1. per-user 覆盖 ``{base_dir}/users/{user_id}/memory_config.json``
2. 全局覆盖 ``{base_dir}/memory_user_overrides.json``
3. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
4. Pydantic 默认值
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
(有 per-user 记录时自动强制 enabled=True)
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
3. Pydantic 默认值
第 1、2 层只允许覆盖 :data:`USER_WRITABLE_FIELDS` 白名单内的字段。
第 1 层只允许覆盖 :data:`USER_WRITABLE_FIELDS` 白名单内的字段。
cache 由 Gateway lifespan 启动时预热,写入时同步更新,保持同步读取路径。
"""
from __future__ import annotations
import copy
import json
import logging
from typing import Any
@@ -73,7 +73,7 @@ class HindsightMemoryConfig(BaseModel):
description="agent 私有工作 bank 命名模板",
)
memory_mode: str = Field(
default="context",
default="hybrid",
description="工具暴露策略:context(仅自动) / tools(仅工具) / hybrid(两者)",
)
bank_mission: str = Field(default="", description="reflect 推理的身份/框架描述")
@@ -129,29 +129,26 @@ class MemoryConfig(BaseModel):
# ===========================================================================
# per-user 运行时覆盖
# per-user 运行时覆盖 — 白名单
# ===========================================================================
# 用户可在运行时覆盖的字段白名单 (点号路径)。
# 不在此表中的字段必须改 config.yaml 并重启 (例如 provider / api_key / 字符配额)。
# 用户唯一可覆盖的字段:召回深度。
# injection.* / builtin.* / enabled 始终由 config.yaml 控制,用户不可关闭。
USER_WRITABLE_FIELDS: frozenset[str] = frozenset(
{
"injection.enabled",
"injection.max_tokens",
"injection.include_builtin",
"injection.include_hindsight",
"hindsight.memory_mode",
"hindsight.recall_budget",
"hindsight.bank_mission",
}
)
_VALID_RECALL_BUDGETS: frozenset[str] = frozenset({"low", "mid", "high"})
def _filter_to_whitelist(overrides: dict[str, Any]) -> dict[str, Any]:
"""把覆盖 dict 过滤为只含白名单字段的嵌套 dict。
输入既支持嵌套形式 ``{"injection": {"enabled": false}}``,也支持
点号扁平形式 ``{"injection.enabled": false}``。非白名单字段被丢弃并 log。
输入支持嵌套形式 ``{"hindsight": {"recall_budget": "high"}}`` 或
点号扁平形式 ``{"hindsight.recall_budget": "high"}``。
非白名单字段被丢弃并 log。
"""
result: dict[str, Any] = {}
@@ -182,21 +179,6 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
return merged
def _load_json_file(path: Any) -> dict[str, Any]:
"""读取一个 JSON 覆盖文件;不存在或解析失败时返回空 dict。"""
try:
from pathlib import Path
p = Path(path)
if not p.exists():
return {}
data = json.loads(p.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("读取记忆配置覆盖文件失败 %s: %s", path, exc)
return {}
# ===========================================================================
# 全局单例 + 加载入口
# ===========================================================================
@@ -221,82 +203,88 @@ def load_memory_config_from_dict(config_dict: dict) -> None:
_memory_config = MemoryConfig(**config_dict)
# ===========================================================================
# per-user recall_budget 进程内 cache
#
# Gateway lifespan 启动时调用 load_recall_budget_cache() 从 DB 批量加载。
# API 写入时同步更新 cache,使 get_effective_memory_config() 保持同步读取。
# ===========================================================================
_recall_budget_cache: dict[str, str] = {}
def load_recall_budget_cache(data: dict[str, str]) -> None:
"""用 DB 批量加载结果替换整个 cache (启动时调用一次)。"""
global _recall_budget_cache
_recall_budget_cache = dict(data)
logger.info("recall_budget cache loaded: %d user(s)", len(_recall_budget_cache))
def set_recall_budget_cache(user_id: str, value: str) -> None:
"""写入单个用户的 recall_budget cache。"""
_recall_budget_cache[user_id] = value
def delete_recall_budget_cache(user_id: str) -> None:
"""从 cache 中移除某用户的 recall_budget。"""
_recall_budget_cache.pop(user_id, None)
def get_recall_budget_from_cache(user_id: str) -> str | None:
"""返回 cache 中某用户的 recall_budget,未设置时返回 None。"""
return _recall_budget_cache.get(user_id)
def get_effective_memory_config(user_id: str) -> MemoryConfig:
"""按三层覆盖优先级,返回某用户的生效记忆配置。
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
优先级 (高 -> 低):per-user 覆盖 > 全局覆盖 > config.yaml > 默认值。
第 1、2 层只应用 :data:`USER_WRITABLE_FIELDS` 白名单内的字段。
覆盖文件读取失败不会抛异常 —— 退化为只用 config.yaml 级别配置。
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
有 per-user 记录时自动强制 enabled=True。
"""
base = _memory_config.model_dump()
recall_budget = _recall_budget_cache.get(user_id)
if recall_budget is not None:
base.setdefault("hindsight", {})["recall_budget"] = recall_budget
base["enabled"] = True
try:
from deerflow.config.paths import get_paths
paths = get_paths()
global_overrides = _filter_to_whitelist(
_load_json_file(paths.memory_global_overrides_file)
)
user_overrides = _filter_to_whitelist(
_load_json_file(paths.user_memory_config_file(user_id))
)
except Exception as exc: # noqa: BLE001 - 配置覆盖失败必须降级,不能阻断对话
logger.warning("加载 per-user 记忆配置覆盖失败,退回 config.yaml: %s", exc)
return _memory_config
merged = _deep_merge(_deep_merge(base, global_overrides), user_overrides)
try:
return MemoryConfig(**merged)
return MemoryConfig(**base)
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
logger.warning("合并后的记忆配置非法,退回 config.yaml: %s", exc)
return _memory_config
def save_user_memory_override(user_id: str, patch: dict[str, Any]) -> dict[str, Any]:
"""把白名单字段写入某用户的 per-user 覆盖文件,返回写入后的完整覆盖 dict。
# ===========================================================================
# Gateway 调用的异步 DB 对接函数
# ===========================================================================
async def save_user_memory_override_db(user_id: str, patch: dict[str, Any], store: Any) -> dict[str, Any]:
"""把白名单字段写入 DB store 并更新 cache,返回生效配置 dict。
``patch`` 支持嵌套或点号扁平形式;非白名单字段被忽略。
``store`` 为 ``UserMemoryConfigStore`` 实例 (避免循环 import,用 Any)。
"""
from deerflow.config.paths import get_paths
path = get_paths().user_memory_config_file(user_id)
current = _load_json_file(path)
filtered = _filter_to_whitelist(patch)
merged = _deep_merge(current, filtered)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
return merged
recall_budget = (filtered.get("hindsight") or {}).get("recall_budget")
if recall_budget is not None:
if recall_budget not in _VALID_RECALL_BUDGETS:
raise ValueError(f"recall_budget 必须为 {sorted(_VALID_RECALL_BUDGETS)} 之一,收到:{recall_budget!r}")
await store.set_recall_budget(user_id, recall_budget)
set_recall_budget_cache(user_id, recall_budget)
return get_effective_memory_config(user_id).model_dump()
def delete_user_memory_override(user_id: str, field: str | None = None) -> dict[str, Any]:
"""撤销某用户的 per-user 覆盖。
async def delete_user_memory_override_db(user_id: str, field: str | None, store: Any) -> dict[str, Any]:
"""撤销某用户的 per-user 覆盖并更新 cache,返回生效配置 dict。
``field`` 为点号路径 (如 ``injection.enabled``) 时只删该字段;
为 ``None`` 时清空整个 per-user 覆盖文件。返回剩余的覆盖 dict。
``field=None`` 清空全部;``field="hindsight.recall_budget"`` 只清该字段。
"""
from deerflow.config.paths import get_paths
path = get_paths().user_memory_config_file(user_id)
if field is None:
if path.exists():
path.unlink()
return {}
current = _load_json_file(path)
if "." in field:
section, sub_field = field.split(".", 1)
if isinstance(current.get(section), dict):
current[section].pop(sub_field, None)
if not current[section]:
current.pop(section, None)
if field is None or field == "hindsight.recall_budget":
await store.delete(user_id)
delete_recall_budget_cache(user_id)
else:
current.pop(field, None)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
return current
logger.warning("忽略删除非白名单的记忆配置覆盖字段: %s", field)
return get_effective_memory_config(user_id).model_dump()
__all__ = [
@@ -310,6 +298,10 @@ __all__ = [
"set_memory_config",
"load_memory_config_from_dict",
"get_effective_memory_config",
"save_user_memory_override",
"delete_user_memory_override",
"load_recall_budget_cache",
"set_recall_budget_cache",
"delete_recall_budget_cache",
"get_recall_budget_from_cache",
"save_user_memory_override_db",
"delete_user_memory_override_db",
]
@@ -61,3 +61,23 @@ class AgentStore(ABC):
async def count_owned(self, user_id: str) -> int:
"""Return the number of agents owned by ``user_id`` (user_id IS NOT NULL)."""
raise NotImplementedError
@abstractmethod
async def set_featured(self, agent_id: str, order: int | None) -> dict[str, Any] | None:
"""Set or clear the homepage-selector featured rank for an agent.
Returns the updated row, or ``None`` if the agent does not exist.
Caller is responsible for authorization (admin) and for verifying
that ``published`` is true when ``order`` is not ``None``.
"""
raise NotImplementedError
@abstractmethod
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
"""Return featured + still-visible-to-``user_id`` agents, ordered by ``featured_order`` asc."""
raise NotImplementedError
@abstractmethod
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
raise NotImplementedError
@@ -55,6 +55,7 @@ class MemoryAgentStore(AgentStore):
"description": data.get("description") or "",
"user_id": data.get("user_id"),
"published": bool(data.get("published", False)),
"featured_order": None,
"created_at": now,
"updated_at": now,
}
@@ -70,6 +71,7 @@ class MemoryAgentStore(AgentStore):
"description": data.get("description") or "",
"user_id": None,
"published": bool(data.get("published", True)),
"featured_order": None,
"created_at": _now(),
"updated_at": _now(),
}
@@ -117,3 +119,22 @@ class MemoryAgentStore(AgentStore):
async def count_owned(self, user_id: str) -> int:
return sum(1 for a in self._agents.values() if a.get("user_id") == user_id)
async def set_featured(self, agent_id: str, order: int | None) -> dict[str, Any] | None:
row = self._agents.get(agent_id)
if row is None:
return None
row["featured_order"] = order
row["updated_at"] = _now()
return _copy(row)
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
candidates = [a for a in self._agents.values() if a.get("featured_order") is not None and self._is_visible(a, user_id)]
candidates.sort(key=lambda a: (a.get("featured_order"), a.get("created_at") or ""))
return [_copy(a) for a in candidates]
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)]
rows.sort(key=lambda a: order_map.get(a["id"], 0))
return [_copy(a) for a in rows]
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, String
from sqlalchemy import BigInteger, Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -21,5 +21,11 @@ class AgentRow(Base):
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
# featured rank (lower sorts first). Only ``published=True`` agents are
# eligible; the router enforces that invariant on writes. BIGINT so the
# frontend can seed initial ranks from ``Date.now()`` (ms-resolution
# timestamps overflow a 4-byte INT on MySQL).
featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -142,3 +142,35 @@ class AgentRepository(AgentStore):
stmt = select(func.count()).select_from(AgentRow).where(AgentRow.user_id == user_id)
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def set_featured(self, agent_id: str, order: int | None) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(AgentRow, agent_id)
if row is None:
return None
row.featured_order = order
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(AgentRow)
.where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id))
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
if not agent_ids:
return []
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id))
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
return [_row_to_dict(r) for r in rows]
@@ -8,4 +8,39 @@ def make_ai_writing_session_store(session_factory) -> AIWritingSessionRepository
return AIWritingSessionRepository(session_factory)
__all__ = ["AIWritingSessionRow", "AIWritingSessionRepository", "make_ai_writing_session_store"]
# ── 进程级默认 repo 单例 ──────────────────────────────────────────────────────
#
# AI 写作 graph 注册成 LangGraph Server assistant 后,节点跑在 harness 进程里,
# 拿不到 FastAPI 的 ``request.app.state``;harness → app 又是禁止的 import 方向。
# 这里提供一个 app 启动时注册、harness 节点运行时读取的「跨边界单例」通道。
#
# 写入:app/gateway/deps.py 在创建完 session_factory 之后调
# ``register_default_store(app.state.ai_writing_session_store)``
# 读取:节点里 ``get_default_store()``,None 表示未配置 DB(跳过写入即可)。
_default_store: AIWritingSessionRepository | None = None
def register_default_store(store: AIWritingSessionRepository | None) -> None:
"""登记进程级默认 AI 写作 session store。
幂等:可多次调用,覆盖前值。传 ``None`` 用于显式清理(主要给测试)。
"""
global _default_store
_default_store = store
def get_default_store() -> AIWritingSessionRepository | None:
"""返回进程级默认 AI 写作 session store;未配置时为 ``None``。
节点调用方应在拿到 ``None`` 时跳过 DB 写入(写入是「锦上添花」非致命)。
"""
return _default_store
__all__ = [
"AIWritingSessionRow",
"AIWritingSessionRepository",
"make_ai_writing_session_store",
"register_default_store",
"get_default_store",
]
@@ -4,11 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import PortableLongText
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class AIWritingSessionRow(Base):
@@ -26,5 +26,13 @@ class AIWritingSessionRow(Base):
# 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显。
# 用 PortableLongText:MySQL 上映射为 LONGTEXT,避免 TEXT 64KB 上限导致保存 500。
transcript: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
# 时间列必须用 BeijingDateTime(项目规范,与 threads_meta 等其它表对齐)—— MySQL
# DATETIME 不存时区,raw ``DateTime(timezone=True)`` 会把 UTC wall clock 写入
# 列,读出来比真实北京时间晚 8 小时。``BeijingDateTime`` 在 bind 时 UTC→Beijing、
# 在 result 时 attach +08:00,前后端拿到的就是北京时间且 tz-aware。
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -63,6 +63,52 @@ class AIWritingSessionRepository:
await session.refresh(row)
return self._row_to_dict(row)
async def upsert_session(
self,
*,
id: str,
title: str,
user_intent: str | None = None,
user_id: str | None | _AutoSentinel = AUTO,
status: str = "in_progress",
) -> dict:
"""幂等:行不存在则创建,行存在则保留原数据(不覆盖 draft / review 等已有写入)。
AI 写作 graph 的 ``intent_parser_node`` 在每次进入时调一次(首启 + resume 时
都会跑),所以必须幂等。resume 时若直接 ``create`` 会因主键冲突抛错;用
``update`` 又找不到行而 no-op。这个方法把两条路合一:行不存在新建,行存在
只刷新 ``updated_at`` 以便列表页排序前置。
"""
resolved_user_id = resolve_user_id(user_id, method_name="AIWritingSessionRepository.upsert_session")
now = datetime.now(UTC)
async with self._sf() as session:
row = await session.get(AIWritingSessionRow, id)
if row is None:
row = AIWritingSessionRow(
id=id,
user_id=resolved_user_id,
title=title,
user_intent=user_intent,
status=status,
draft_title=None,
draft_markdown=None,
completed_interventions=None,
review_result=None,
transcript=None,
created_at=now,
updated_at=now,
)
session.add(row)
else:
# 行已存在(resume 路径):仅刷新 updated_at,保留 user_id / title / draft / review 等。
# 若 user_id 之前为空(migration 兼容场景),用解析出来的填上。
if row.user_id is None and resolved_user_id is not None:
row.user_id = resolved_user_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def update(
self,
session_id: str,
@@ -10,13 +10,14 @@ None and fall back to in-memory implementations.
from __future__ import annotations
import json
import logging
import sys
from importlib.util import find_spec
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
import asyncio
import json
import logging
import sys
from importlib.util import find_spec
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
def _json_serializer(obj: object) -> str:
@@ -26,37 +27,89 @@ def _json_serializer(obj: object) -> str:
logger = logging.getLogger(__name__)
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def _add_runtime_vendor_path() -> None:
"""Allow offline deployments to place optional DB drivers in DEER_FLOW_HOME.
The offline Docker image keeps the Python environment inside the image, while
``/app/backend/.deer-flow`` is a persistent bind mount. The MySQL switch
script installs ``asyncmy`` into ``{DEER_FLOW_HOME}/python-vendor`` so the
unchanged startup script can still import it after container recreation.
"""
try:
from deerflow.config.runtime_paths import runtime_home
vendor_dir = runtime_home() / "python-vendor"
except Exception:
vendor_dir = Path(".deer-flow/python-vendor").resolve()
vendor = str(vendor_dir)
if vendor_dir.exists() and vendor not in sys.path:
sys.path.insert(0, vendor)
def _mysql_driver_name(url: str) -> str:
if url.startswith("mysql+aiomysql://"):
return "aiomysql"
return "asyncmy"
async def _auto_create_postgres_db(url: str) -> None:
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def _add_runtime_vendor_path() -> None:
"""Allow offline deployments to place optional DB drivers in DEER_FLOW_HOME.
The offline Docker image keeps the Python environment inside the image, while
``/app/backend/.deer-flow`` is a persistent bind mount. The MySQL switch
script installs ``asyncmy`` into ``{DEER_FLOW_HOME}/python-vendor`` so the
unchanged startup script can still import it after container recreation.
"""
try:
from deerflow.config.runtime_paths import runtime_home
vendor_dir = runtime_home() / "python-vendor"
except Exception:
vendor_dir = Path(".deer-flow/python-vendor").resolve()
vendor = str(vendor_dir)
if vendor_dir.exists() and vendor not in sys.path:
sys.path.insert(0, vendor)
def _mysql_driver_name(url: str) -> str:
if url.startswith("mysql+aiomysql://"):
return "aiomysql"
return "asyncmy"
async def _run_alembic_upgrade(url: str) -> None:
"""Apply pending Alembic migrations against ``url`` (``alembic upgrade head``).
Runs after ``Base.metadata.create_all`` so brand-new deployments still get
a complete schema even without Alembic, while existing deployments pick up
in-place schema fixes (column widenings, new indexes, etc.) at the next
restart — no manual ``alembic upgrade`` step required.
Notes:
* Each historical migration in ``migrations/versions/`` already guards
itself with ``_has_table`` / ``_has_column``, so re-running them against
a database that was originally built via ``create_all`` is a sequence of
no-ops up to the first migration that actually has work to do.
* The Alembic ``env.py`` calls ``asyncio.run`` internally, which cannot run
inside an already-running loop — so we hop into a worker thread.
* Failures here are logged but do not abort startup. The app remains
operable on the ``create_all`` schema; the operator just won't get
in-place fixes until they investigate.
"""
ini_path = Path(__file__).parent / "migrations" / "alembic.ini"
if not ini_path.exists():
logger.warning("Alembic config not found at %s; skipping auto-migrate", ini_path)
return
# Alembic's ``Config`` is backed by ConfigParser, which performs ``%``
# interpolation on every option value. URL-encoded credentials such as
# ``kland%4088124932`` (the ``@`` in ``kland@88124932``) trip that up with
# ``ValueError: invalid interpolation syntax ... at position N``. Doubling
# the percent signs neutralises the interpolation; ConfigParser un-doubles
# them back to the original value when read.
escaped_url = url.replace("%", "%%")
def _do_upgrade() -> None:
# Import inside the worker so a missing Alembic install can't break
# the engine import chain.
from alembic import command
from alembic.config import Config
cfg = Config(str(ini_path))
cfg.set_main_option("sqlalchemy.url", escaped_url)
# The ``script_location`` in alembic.ini is ``%(here)s`` which points
# at the directory of alembic.ini itself — already correct for us.
command.upgrade(cfg, "head")
try:
await asyncio.to_thread(_do_upgrade)
logger.info("Alembic migrations applied (or already up-to-date)")
except Exception:
logger.exception("Auto-applying Alembic migrations failed; continuing with create_all schema")
async def _auto_create_postgres_db(url: str) -> None:
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
The target database name is extracted from *url*. The connection is
@@ -94,8 +147,8 @@ async def init_engine(
"""Create the async engine and session factory, then auto-create tables.
Args:
backend: "memory", "sqlite", "postgres", or "mysql".
url: SQLAlchemy async URL (for sqlite/postgres/mysql).
backend: "memory", "sqlite", "postgres", or "mysql".
url: SQLAlchemy async URL (for sqlite/postgres/mysql).
echo: Echo SQL to log.
pool_size: Postgres connection pool size.
sqlite_dir: Directory to create for SQLite (ensured to exist).
@@ -106,20 +159,20 @@ async def init_engine(
logger.info("Persistence backend=memory -- ORM engine not initialized")
return
if backend == "postgres":
try:
import asyncpg # noqa: F401
except ImportError:
raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None
if backend == "mysql":
_add_runtime_vendor_path()
driver = _mysql_driver_name(url)
if find_spec(driver) is None:
raise ImportError(
"database.backend is set to 'mysql' but the MySQL async driver is not installed.\n"
"Install asyncmy, or run switch_database_to_mysql.sh so it can install asyncmy into "
f"{Path('.deer-flow/python-vendor')} from the offline wheelhouse."
)
if backend == "postgres":
try:
import asyncpg # noqa: F401
except ImportError:
raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None
if backend == "mysql":
_add_runtime_vendor_path()
driver = _mysql_driver_name(url)
if find_spec(driver) is None:
raise ImportError(
"database.backend is set to 'mysql' but the MySQL async driver is not installed.\n"
"Install asyncmy, or run switch_database_to_mysql.sh so it can install asyncmy into "
f"{Path('.deer-flow/python-vendor')} from the offline wheelhouse."
)
if backend == "sqlite":
import os
@@ -150,29 +203,31 @@ async def init_engine(
cursor.execute("PRAGMA foreign_keys=ON;")
finally:
cursor.close()
elif backend == "postgres":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
json_serializer=_json_serializer,
)
elif backend == "mysql":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
else:
raise ValueError(f"Unknown persistence backend: {backend!r}")
elif backend == "postgres":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
json_serializer=_json_serializer,
)
elif backend == "mysql":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
else:
raise ValueError(f"Unknown persistence backend: {backend!r}")
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
# Auto-create tables (dev convenience). Production should use Alembic.
# Auto-create tables for fresh deployments, then let Alembic apply any
# in-place schema fixes on top (see ``_run_alembic_upgrade`` below). Both
# are idempotent so repeating them across restarts is harmless.
from deerflow.persistence.base import Base
# Import all models so Base.metadata discovers them.
@@ -200,6 +255,10 @@ async def init_engine(
else:
raise
# Apply in-place schema fixes (column widenings, etc.) that ``create_all``
# cannot do on its own. Failures don't abort startup — see the helper.
await _run_alembic_upgrade(url)
logger.info("Persistence engine initialized: backend=%s", backend)
@@ -209,10 +268,10 @@ async def init_engine_from_config(config) -> None:
await init_engine("memory")
return
await init_engine(
backend=config.backend,
url=config.app_sqlalchemy_url,
echo=config.echo_sql,
pool_size=config.pool_size,
backend=config.backend,
url=config.app_sqlalchemy_url,
echo=config.echo_sql,
pool_size=config.pool_size,
sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "",
)
@@ -0,0 +1,23 @@
"""Per-user memory configuration override persistence."""
from __future__ import annotations
from deerflow.persistence.memory_config.base import UserMemoryConfigStore
from deerflow.persistence.memory_config.memory import MemoryUserMemoryConfigStore
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.memory_config.sql import SqlUserMemoryConfigRepository
def make_user_memory_config_store(session_factory) -> UserMemoryConfigStore:
if session_factory is None:
return MemoryUserMemoryConfigStore()
return SqlUserMemoryConfigRepository(session_factory)
__all__ = [
"UserMemoryConfigStore",
"UserMemoryConfigRow",
"MemoryUserMemoryConfigStore",
"SqlUserMemoryConfigRepository",
"make_user_memory_config_store",
]
@@ -0,0 +1,32 @@
"""Abstract persistence API for per-user memory config overrides."""
from __future__ import annotations
from abc import ABC, abstractmethod
class UserMemoryConfigStore(ABC):
@abstractmethod
async def get_recall_budget(self, user_id: str) -> str | None:
"""Return the user's recall_budget override, or None if not set."""
raise NotImplementedError
@abstractmethod
async def set_recall_budget(self, user_id: str, value: str) -> None:
"""Upsert the user's recall_budget override."""
raise NotImplementedError
@abstractmethod
async def delete(self, user_id: str) -> None:
"""Remove all per-user overrides for the given user."""
raise NotImplementedError
@abstractmethod
async def load_all(self) -> dict[str, str]:
"""Bulk-load all user recall_budget overrides.
Returns {user_id: recall_budget} for all rows where recall_budget IS NOT NULL.
Called once at startup to warm the in-memory cache.
"""
raise NotImplementedError
@@ -0,0 +1,23 @@
"""In-memory user memory config store (used when database.backend=memory)."""
from __future__ import annotations
from deerflow.persistence.memory_config.base import UserMemoryConfigStore
class MemoryUserMemoryConfigStore(UserMemoryConfigStore):
def __init__(self) -> None:
self._data: dict[str, str] = {}
async def get_recall_budget(self, user_id: str) -> str | None:
return self._data.get(user_id)
async def set_recall_budget(self, user_id: str, value: str) -> None:
self._data[user_id] = value
async def delete(self, user_id: str) -> None:
self._data.pop(user_id, None)
async def load_all(self) -> dict[str, str]:
return dict(self._data)
@@ -0,0 +1,29 @@
"""ORM model for per-user memory configuration overrides."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class UserMemoryConfigRow(Base):
__tablename__ = "user_memory_configs"
# One row per user; upserted on write.
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
# The only user-writable field. NULL means "use global default from config.yaml".
# Allowed values: "low" | "mid" | "high".
recall_budget: Mapped[str | None] = mapped_column(String(16), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,50 @@
"""SQLAlchemy-backed user memory config repository."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.memory_config.base import UserMemoryConfigStore
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
class SqlUserMemoryConfigRepository(UserMemoryConfigStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def get_recall_budget(self, user_id: str) -> str | None:
async with self._sf() as session:
row = await session.get(UserMemoryConfigRow, user_id)
return row.recall_budget if row is not None else None
async def set_recall_budget(self, user_id: str, value: str) -> None:
async with self._sf() as session:
row = await session.get(UserMemoryConfigRow, user_id)
if row is None:
row = UserMemoryConfigRow(
user_id=user_id,
recall_budget=value,
updated_at=datetime.now(UTC),
)
session.add(row)
else:
row.recall_budget = value
row.updated_at = datetime.now(UTC)
await session.commit()
async def delete(self, user_id: str) -> None:
async with self._sf() as session:
row = await session.get(UserMemoryConfigRow, user_id)
if row is not None:
await session.delete(row)
await session.commit()
async def load_all(self) -> dict[str, str]:
stmt = select(UserMemoryConfigRow).where(UserMemoryConfigRow.recall_budget.is_not(None))
async with self._sf() as session:
result = await session.execute(stmt)
return {row.user_id: row.recall_budget for row in result.scalars() if row.recall_budget is not None}
@@ -0,0 +1,83 @@
"""Homepage agent selector: ``user_agent_pin`` table + ``agents.featured_order``.
Adds two pieces backing the new chat-homepage agent picker:
- ``agents.featured_order`` (nullable INT) — admin-curated featured rank.
NULL = not shown on the selector. Non-NULL rows sort ascending.
- ``user_agent_pin`` — per-user pinned agents (user's "favorites"). Composite
PK ``(user_id, agent_id)`` so a user pins each agent at most once. Foreign
key to ``agents.id`` with ``ON DELETE CASCADE`` so deleting an agent also
removes every user's pin of it.
Revision ID: 20260520_01
Revises: 20260519_02
Create Date: 2026-05-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260520_01"
down_revision = "20260519_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
# 1. agents.featured_order — ALTER existing table. Skipped on brand-new DBs
# where create_all already materialised the column.
if _has_table("agents") and not _has_column("agents", "featured_order"):
op.add_column("agents", sa.Column("featured_order", sa.Integer(), nullable=True))
if _has_table("agents") and not _has_index("agents", "ix_agents_featured_order"):
op.create_index("ix_agents_featured_order", "agents", ["featured_order"])
# 2. user_agent_pin — per-user pinned agents.
if not _has_table("user_agent_pin"):
op.create_table(
"user_agent_pin",
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column(
"agent_id",
sa.String(64),
sa.ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("user_id", "agent_id", name="pk_user_agent_pin"),
)
op.create_index("ix_user_agent_pin_sort_order", "user_agent_pin", ["sort_order"])
def downgrade() -> None:
if _has_index("user_agent_pin", "ix_user_agent_pin_sort_order"):
op.drop_index("ix_user_agent_pin_sort_order", table_name="user_agent_pin")
if _has_table("user_agent_pin"):
op.drop_table("user_agent_pin")
if _has_index("agents", "ix_agents_featured_order"):
op.drop_index("ix_agents_featured_order", table_name="agents")
if _has_column("agents", "featured_order"):
op.drop_column("agents", "featured_order")
@@ -0,0 +1,66 @@
"""Widen ``agents.featured_order`` from INT to BIGINT.
The previous revision (``20260520_01``) created ``featured_order`` as plain
``INTEGER``. The frontend seeds the initial rank with ``Date.now()`` so admins
get "newest featured sinks to the bottom" with zero UI — but ms-resolution
timestamps (~1.78e12) overflow a 4-byte signed INT on MySQL, surfacing as
sqlalchemy.exc.DataError: (1264, "Out of range value for column 'featured_order'")
SQLite and PostgreSQL ``INTEGER`` is already 64-bit / arbitrary-precision and
needs no change, but the migration runs everywhere for schema parity.
Revision ID: 20260520_02
Revises: 20260520_01
Create Date: 2026-05-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260520_02"
down_revision = "20260520_01"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return False
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_column("agents", "featured_order"):
return
bind = op.get_bind()
# SQLite has only INTEGER (dynamic precision) — alter_column would rebuild
# the table for nothing. PostgreSQL INTEGER is 32-bit too, so alter it.
if bind.dialect.name == "sqlite":
return
op.alter_column(
"agents",
"featured_order",
type_=sa.BigInteger(),
existing_nullable=True,
)
def downgrade() -> None:
if not _has_column("agents", "featured_order"):
return
bind = op.get_bind()
if bind.dialect.name == "sqlite":
return
# Any rows with values > 2^31-1 would be truncated. Downgrade is dev-only.
op.alter_column(
"agents",
"featured_order",
type_=sa.Integer(),
existing_nullable=True,
)
@@ -0,0 +1,61 @@
"""Drop ``user_agent_pin`` (homepage selector simplified to admin-only).
The original design split the homepage agent picker into two buckets:
admin-curated "featured" + per-user "pinned". Product feedback showed the
per-user side added complexity without clear value — admins now own the
single list shown to everyone. ``agents.featured_order`` is the only piece
that survives; this revision removes the now-unused pin table.
Revision ID: 20260520_03
Revises: 20260520_02
Create Date: 2026-05-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260520_03"
down_revision = "20260520_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
if _has_index("user_agent_pin", "ix_user_agent_pin_sort_order"):
op.drop_index("ix_user_agent_pin_sort_order", table_name="user_agent_pin")
if _has_table("user_agent_pin"):
op.drop_table("user_agent_pin")
def downgrade() -> None:
if not _has_table("user_agent_pin"):
op.create_table(
"user_agent_pin",
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column(
"agent_id",
sa.String(64),
sa.ForeignKey("agents.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("user_id", "agent_id", name="pk_user_agent_pin"),
)
op.create_index("ix_user_agent_pin_sort_order", "user_agent_pin", ["sort_order"])
@@ -0,0 +1,44 @@
"""Create user_memory_configs table.
Replaces the per-user file-based memory config override
({base_dir}/users/{user_id}/memory_config.json) with a DB row.
Stores only hindsight.recall_budget — the single user-writable field
after the memory config simplification.
Revision ID: 20260521_01
Revises: 20260519_02
Create Date: 2026-05-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260521_01"
down_revision = "20260519_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("user_memory_configs"):
return
op.create_table(
"user_memory_configs",
sa.Column("user_id", sa.String(64), primary_key=True),
sa.Column("recall_budget", sa.String(16), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
if _has_table("user_memory_configs"):
op.drop_table("user_memory_configs")
@@ -16,6 +16,7 @@ there is no matching entity directory.
"""
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
@@ -55,5 +56,6 @@ __all__ = [
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
"UserMemoryConfigRow",
"UserRow",
]
@@ -56,6 +56,17 @@ class ThreadMetaStore(abc.ABC):
async def update_status(self, thread_id: str, status: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
pass
@abc.abstractmethod
async def update_assistant_id(self, thread_id: str, assistant_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
"""Backfill ``assistant_id`` for a thread row.
Used by ``start_run`` when the row was pre-created via ``POST /threads``
without an assistant_id (the SDK's ``client.threads.create()`` doesn't
pass it), and the first run on the thread reveals the assistant. No-op
if the thread does not exist or the owner check fails.
"""
pass
@abc.abstractmethod
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
"""Merge ``metadata`` into the thread's metadata field.
@@ -117,6 +117,14 @@ class MemoryThreadMetaStore(ThreadMetaStore):
record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record)
async def update_assistant_id(self, thread_id: str, assistant_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_assistant_id")
if record is None:
return
record["assistant_id"] = assistant_id
record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record)
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
if record is None:
@@ -174,6 +174,20 @@ class ThreadMetaRepository(ThreadMetaStore):
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(status=status, updated_at=datetime.now(UTC)))
await session.commit()
async def update_assistant_id(
self,
thread_id: str,
assistant_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> None:
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_assistant_id")
async with self._sf() as session:
if not await self._check_ownership(session, thread_id, resolved_user_id):
return
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(assistant_id=assistant_id, updated_at=datetime.now(UTC)))
await session.commit()
async def update_metadata(
self,
thread_id: str,
@@ -134,8 +134,16 @@ async def run_agent(
stream_subgraphs: bool = False,
interrupt_before: list[str] | Literal["*"] | None = None,
interrupt_after: list[str] | Literal["*"] | None = None,
command: dict[str, Any] | None = None,
) -> None:
"""Execute an agent in the background, publishing events to *bridge*."""
"""Execute an agent in the background, publishing events to *bridge*.
``command`` 来自 LangGraph SDK 的 ``Command`` 协议(``{resume, update, goto}``)。
存在时把它翻译成 ``langgraph.types.Command`` 并作为 ``astream`` 的入参 ——
Pregel 检查到 Command 输入会从最近 checkpoint 处恢复(典型场景:interrupt 之后
的 ``Command(resume=user_input)``);不存在时仍按 ``graph_input`` dict 起新 run。
AI 写作的 4 个干预卡就靠这个把 user_input 传回 ``pause_*_confirm`` 节点。
"""
# Unpack infrastructure dependencies from RunContext.
checkpointer = ctx.checkpointer
@@ -273,10 +281,23 @@ async def run_agent(
logger.info("Run %s: streaming with modes %s (requested: %s)", run_id, lg_modes, requested_modes)
# 7. Stream using graph.astream
# 如果客户端传了 Command(典型:interrupt 后续提的 ``Command(resume=...)``),
# 把它翻译成 langgraph.types.Command 作为 astream 输入;否则按 graph_input dict
# 起新一轮。Pregel 检查到 Command 会从最近 checkpoint 处恢复,而不是重头跑。
stream_input: Any = graph_input
if command is not None:
from langgraph.types import Command as _LGCommand
stream_input = _LGCommand(
resume=command.get("resume"),
update=command.get("update"),
goto=command.get("goto"),
)
if len(lg_modes) == 1 and not stream_subgraphs:
# Single mode, no subgraphs: astream yields raw chunks
single_mode = lg_modes[0]
async for chunk in agent.astream(graph_input, config=runnable_config, stream_mode=single_mode):
async for chunk in agent.astream(stream_input, config=runnable_config, stream_mode=single_mode):
if record.abort_event.is_set():
logger.info("Run %s abort requested — stopping", run_id)
break
@@ -285,7 +306,7 @@ async def run_agent(
else:
# Multiple modes or subgraphs: astream yields tuples
async for item in agent.astream(
graph_input,
stream_input,
config=runnable_config,
stream_mode=lg_modes,
subgraphs=stream_subgraphs,
@@ -10,6 +10,7 @@ Consumers: ``deerflow.runtime.runs.worker`` (SSE publishing) and
from __future__ import annotations
import dataclasses
from typing import Any
@@ -35,6 +36,16 @@ def serialize_lc_object(obj: Any) -> Any:
return obj.dict()
except Exception:
pass
# Dataclass instances —— LangGraph 的 ``Interrupt`` 就是 dataclass 没有
# model_dump 也没有 dict 方法;之前 fallback 到 ``str(obj)`` 会把
# ``Interrupt(value=..., id='...')`` 序列化成字符串,下游 SDK
# (``@langchain/langgraph-sdk``) 期待 dict 结构 → 直接丢弃 → AI 写作
# 的 4 个干预卡永远不弹(参 ai-writing-优化.md §阶段 3 排查记录)。
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
try:
return {k: serialize_lc_object(v) for k, v in dataclasses.asdict(obj).items()}
except Exception:
pass
# Last resort
try:
return str(obj)
@@ -45,12 +56,21 @@ def serialize_lc_object(obj: Any) -> Any:
def serialize_channel_values(channel_values: dict[str, Any]) -> dict[str, Any]:
"""Serialize channel values, stripping internal LangGraph keys.
Internal keys like ``__pregel_*`` and ``__interrupt__`` are removed
to match what the LangGraph Platform API returns.
``__pregel_*`` 键始终剥离。``__interrupt__`` 仅在**为空时**剥离 —— 因为
``@langchain/langgraph-sdk`` 的 ``useStream`` 只在 ``event === "values"``
通道里读 ``data.__interrupt__`` 并通过 ``stream.interrupt`` 暴露给上层。
AI 写作的 4 个干预卡(material/outline/draft/review)就是靠它触发的;
继续过滤会让前端永远拿不到 interrupt。
主聊天不消费 ``stream.interrupt``,但若我们让一个空数组溜过去,SDK 会
fallback 成 ``{when: "breakpoint"}`` 的伪 interrupt —— 所以仅在数组非空
时保留,两侧都不破坏。
"""
result: dict[str, Any] = {}
for key, value in channel_values.items():
if key.startswith("__pregel_") or key == "__interrupt__":
if key.startswith("__pregel_"):
continue
if key == "__interrupt__" and not value:
continue
result[key] = serialize_lc_object(value)
return result
@@ -22,6 +22,8 @@ dependencies = [
"pyjwt>=2.9.0",
"email-validator>=2.0.0",
"croniter>=6.2.2",
"hindsight-client>=0.4.22",
"asyncmy>=0.2.11",
]
[project.optional-dependencies]
@@ -0,0 +1,179 @@
"""AI 写作 assistant 冒烟脚本:验证 ai_writing graph 注册到 LangGraph Server 后,
interrupt/Command(resume=...)/state 持久化/custom stream 全链路是否跑通。
阶段 0 用名为 ai_writing_probe;阶段 1 起改为正式 assistant 名 ai_writing,本脚本随之切换。
前置条件:
1. langgraph.json 已注册 ai_writing(见 ai-writing-优化.md 阶段 1)。
2. 后端已通过 `make dev` 启动,Gateway + LangGraph Server 监听 8001。
3. config.yaml 配好至少一个可用 model,researcher 用到的 search_provider 也已配置
—— 否则 researcher 节点会失败(这是业务依赖,不是迁移问题,仍然算「跑通」)。
运行:
cd offline-backend-20260512/backend
PYTHONPATH=. uv run python scripts/probe_ai_writing_assistant.py
通过条件(与 ai-writing-优化.md §阶段 0 §通过条件 对齐):
- assistants 列表里能看到 ai_writing_probe
- 跑到 pause_material_confirm 能拿到 interrupt 事件
- Command(resume={"action": "confirm", ...}) 能成功续跑
- 重建客户端后 client.threads.get_state(thread_id) 仍能拿到上一阶段 state
"""
from __future__ import annotations
import asyncio
import os
import sys
from typing import Any
try:
from langgraph_sdk import get_client
except ImportError:
print("ERROR: langgraph-sdk 未安装。run: uv pip install langgraph-sdk", file=sys.stderr)
sys.exit(1)
ASSISTANT_ID = "ai_writing"
DEFAULT_URL = os.environ.get("DEERFLOW_LANGGRAPH_URL", "http://localhost:8001/api/langgraph")
def _banner(text: str) -> None:
print(f"\n{'=' * 8} {text} {'=' * 8}")
def _summarize_event(event: Any) -> str:
"""裁剪日志,避免一屏全是大段 markdown / 素材列表。"""
s = repr(event)
return s if len(s) <= 240 else s[:240] + "…"
async def _stream_until_interrupt(
client, thread_id: str, *, input_payload: dict | None = None, command: dict | None = None
) -> dict | None:
"""跑一次 runs.stream,捕获第一个 interrupt(含 pause payload)后返回,否则返回 None。
收到 interrupt 时把 payload 一并打出来,便于人工核对 4 个 pause 点的 schema。
"""
interrupt_payload: dict | None = None
kwargs: dict[str, Any] = {
"stream_mode": ["values", "messages-tuple", "custom", "updates"],
}
if input_payload is not None:
kwargs["input"] = input_payload
if command is not None:
kwargs["command"] = command
async for chunk in client.runs.stream(thread_id, ASSISTANT_ID, **kwargs):
# langgraph-sdk 把不同 mode 的事件混在同一 stream 里,按 chunk.event 分类。
event_name = getattr(chunk, "event", None) or (
chunk.get("event") if isinstance(chunk, dict) else None
)
data = getattr(chunk, "data", None) or (
chunk.get("data") if isinstance(chunk, dict) else None
)
print(f"[stream] event={event_name} data={_summarize_event(data)}")
# SDK 在遇到 interrupt() 时会发一个特殊事件 —— 不同版本字段名不同,做下兼容。
if event_name == "updates" and isinstance(data, dict):
for _node, node_update in data.items():
if isinstance(node_update, dict) and "__interrupt__" in node_update:
interrupt_payload = node_update["__interrupt__"]
if event_name in {"interrupt", "__interrupt__"}:
interrupt_payload = data
break
return interrupt_payload
async def main() -> int:
print(f"LangGraph URL = {DEFAULT_URL}")
client = get_client(url=DEFAULT_URL)
# ── Step 1: 确认 assistant 已注册 ──────────────────────────────────────────
_banner("Step 1: 列 assistant,确认 probe 已注册")
assistants = await client.assistants.search()
names = [a.get("name") or a.get("graph_id") for a in assistants]
print(f"已注册 assistants: {names}")
if not any(ASSISTANT_ID in (n or "") for n in names):
print(f"FAIL: 未找到 {ASSISTANT_ID},请检查 langgraph.json 注册是否生效。")
return 1
# ── Step 2: 创建 thread 并触发第一次 run ──────────────────────────────────
_banner("Step 2: 创建 thread + 启动写作(应停在 pause_material)")
thread = await client.threads.create()
thread_id = thread["thread_id"]
print(f"thread_id = {thread_id}")
start_input = {
"user_intent": "写一篇关于 LangGraph 多 worker 部署最佳实践的分析报道,目标读者是后端工程师,约 800 字",
"article_type": "analysis",
"target_audience": "后端工程师",
"word_count_target": 800,
"keyword_count": 4,
"writing_mode": "loose",
"max_revisions": 2,
"status": "researching",
"drafts": [],
"progress_events": [],
}
payload = await _stream_until_interrupt(client, thread_id, input_payload=start_input)
if not payload:
print("FAIL: 未收到 interrupt 事件 —— pause_material 可能没停住,或 researcher 抛错。")
return 2
print(f"\nPAUSE-1 payload = {_summarize_event(payload)}")
# ── Step 3: 确认素材,应停在 pause_outline ────────────────────────────────
_banner("Step 3: Command(resume) 确认素材(应停在 pause_outline)")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "confirm"}}, # 不指定 approvedMaterialIds → 默认全部通过
)
if not payload:
print("FAIL: 未停在 pause_outline。")
return 3
print(f"\nPAUSE-2 payload = {_summarize_event(payload)}")
# ── Step 4: 确认大纲,应停在 pause_draft 或 pause_section_help ───────────
_banner("Step 4: Command(resume) 确认大纲(应停在 pause_draft 或 pause_section_help)")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "confirm"}},
)
if not payload:
print("FAIL: 未停在 pause_draft / pause_section_help。")
return 4
print(f"\nPAUSE-3 payload = {_summarize_event(payload)}")
# ── Step 5: 定稿 ─────────────────────────────────────────────────────────
_banner("Step 5: Command(resume) finalize 草稿(应跑到 END)")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "finalize"}},
)
if payload:
print(f"NOTE: 收到额外 interrupt(可能编辑审核打回) payload = {_summarize_event(payload)}")
print("尝试强制定稿…")
payload = await _stream_until_interrupt(
client, thread_id,
command={"resume": {"action": "force_finalize", "overrideVerdict": True}},
)
if payload:
print("WARN: 强制定稿后仍有 interrupt,graph 路由可能有意外分支。")
# ── Step 6: 验证 state 持久化 ────────────────────────────────────────────
_banner("Step 6: 重新建客户端 client.threads.get_state(thread_id) 应能拿到最终 state")
client2 = get_client(url=DEFAULT_URL)
state = await client2.threads.get_state(thread_id)
values = state.get("values") or {}
final_status = values.get("status")
has_draft = bool(values.get("current_draft"))
print(f"final status = {final_status}, has_draft = {has_draft}, "
f"next nodes = {state.get('next')}")
if final_status not in {"done", "writing_draft", "revising"}:
print("WARN: 最终 status 不是 done,请人工确认上面流程是否正常。")
print("\nPROBE OK —— 阶段 0 验证通过的核心条件已成立(interrupt + resume + state 持久化)。")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))