Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xhy
This commit is contained in:
commit
667b365a84
94 files changed
+6430
-2390
No files matched your search
+3
-1
@@ -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
|
||||
@@ -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
|
||||
@@ -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 的双进程配置。
|
||||
@@ -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 断断续续」的旧体验。
|
||||
|
||||
---
|
||||
|
||||
## 七、运维操作清单
|
||||
|
||||
### 部署前验证
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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[]) : [];
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-14
@@ -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"]
|
||||
+53
-216
@@ -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()
|
||||
+48
@@ -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):
|
||||
"""创建对话模型。
|
||||
|
||||
|
||||
+51
-25
@@ -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:
|
||||
|
||||
+118
-25
@@ -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
|
||||
+73
-45
@@ -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},
|
||||
],
|
||||
|
||||
+53
-22
@@ -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:
|
||||
|
||||
+8
-12
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+28
@@ -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():
|
||||
|
||||
+85
-93
@@ -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
|
||||
+21
@@ -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]
|
||||
+7
-1
@@ -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]
|
||||
+36
-1
@@ -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",
|
||||
]
|
||||
+12
-4
@@ -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),
|
||||
)
|
||||
+46
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
+23
@@ -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",
|
||||
]
|
||||
+32
@@ -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
|
||||
+23
@@ -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)
|
||||
+29
@@ -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),
|
||||
)
|
||||
+50
@@ -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}
|
||||
+83
@@ -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")
|
||||
+66
@@ -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,
|
||||
)
|
||||
+61
@@ -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"])
|
||||
+44
@@ -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")
|
||||
+2
@@ -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",
|
||||
]
|
||||
+11
@@ -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.
|
||||
|
||||
+8
@@ -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:
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+23
-3
@@ -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()))
|
||||
Reference in new issue
Block a user