Merge branch 'dhk-1' into 'master'

报错兜底

See merge request knowledge-base-bj/deerflow-server!26
This commit is contained in:
董宏凯 committed 2026-05-19 14:56:01 +00:00
commit ef11d3e0ed
10 files changed
+3010 -2726

No files matched your search

@@ -59,15 +59,29 @@ export async function deleteAIWritingSession(id: string): Promise<void> {
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`); if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
} }
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */ /**
* 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。
*
* 该功能属于「锦上添花」:保存失败不应影响写作主流程,因此本函数永不抛错,
* 内部捕获所有异常(网络错误 / 接口 500 等),仅打印告警并返回是否成功。
*/
export async function saveAIWritingTranscript( export async function saveAIWritingTranscript(
id: string, id: string,
transcript: AIWritingTranscript, transcript: AIWritingTranscript,
): Promise<void> { ): Promise<boolean> {
const resp = await apiFetch(`${BASE()}/${id}/transcript`, { try {
method: 'PUT', const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify({ transcript }), headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({ transcript }),
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`); });
if (!resp.ok) {
console.warn(`[ai-writing] 保存对话时间线失败: HTTP ${resp.status}(已忽略,不影响写作流程)`);
return false;
}
return true;
} catch (err) {
console.warn('[ai-writing] 保存对话时间线异常(已忽略,不影响写作流程):', err);
return false;
}
} }
@@ -295,15 +295,19 @@ export function useAIWriting() {
async (payload: UserInterventionPayload) => { async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return; if (!state.sessionId || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接" // 提交干预前始终重连 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) { if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false })); setState((prev) => ({ ...prev, isPaused: false }));
} }
@@ -265,6 +265,7 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
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.** 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.**
- **`PortableJSON`** — native `JSON` on SQLite/Postgres; `TEXT` (with manual `json.dumps`/`loads`) on MySQL, where older servers lack JSON support. - **`PortableJSON`** — native `JSON` on SQLite/Postgres; `TEXT` (with manual `json.dumps`/`loads`) on MySQL, where older servers lack JSON support.
- **`PortableLongText`** — a large text column: maps to MySQL `LONGTEXT` (4 GB) and plain `TEXT` on SQLite/Postgres (already unbounded). Use for big JSON/markdown blobs that would overflow MySQL's 64 KB `TEXT` cap — e.g. `ai_writing_sessions.transcript`.
- **`BeijingDateTime`** — stores timestamps as Beijing (UTC+8) wall-clock time. The app records time with `datetime.now(UTC)`, but MySQL `DATETIME` / SQLite have no timezone and persist whatever wall clock the driver serializes — so a raw `DateTime` column would store UTC and read 8 hours behind Beijing. `BeijingDateTime` converts UTC→Beijing on bind and re-attaches `+08:00` on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQL `timestamptz` stores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so no `tzdata` dependency. - **`BeijingDateTime`** — stores timestamps as Beijing (UTC+8) wall-clock time. The app records time with `datetime.now(UTC)`, but MySQL `DATETIME` / SQLite have no timezone and persist whatever wall clock the driver serializes — so a raw `DateTime` column would store UTC and read 8 hours behind Beijing. `BeijingDateTime` converts UTC→Beijing on bind and re-attaches `+08:00` on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQL `timestamptz` stores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so no `tzdata` dependency.
### Sandbox System (`packages/harness/deerflow/sandbox/`) ### Sandbox System (`packages/harness/deerflow/sandbox/`)
@@ -149,6 +149,10 @@ _session_article_types: dict[str, list[dict]] = {}
# so that a new SSE connection (e.g., after user clicks "继续") can re-inject it. # so that a new SSE connection (e.g., after user clicks "继续") can re-inject it.
_session_pending_pause: dict[str, tuple] = {} _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()
# ── 工具函数 ────────────────────────────────────────────────────────────────── # ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -317,16 +321,26 @@ async def delete_session(session_id: str, request: Request):
@router.put("/sessions/{session_id}/transcript") @router.put("/sessions/{session_id}/transcript")
async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request): async def save_transcript(session_id: str, body: SaveTranscriptRequest, request: Request):
"""保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。""" """保存会话的完整对话时间线,供历史会话回显出与实时对话一致的富时间线。
时间线持久化属于「锦上添花」功能:即便保存失败(如内网部署缺少 transcript
列、或 MySQL TEXT 列超长),也绝不能阻断写作流程。任何异常都吞掉并返回
success=False,由前端忽略即可。
"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request) repo = get_ai_writing_session_store(request)
if repo is None: if repo is None:
return {"success": False} return {"success": False, "reason": "store_unavailable"}
user_id = await get_current_user(request) try:
updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript) user_id = await get_current_user(request)
updated = await repo.update(session_id, user_id=user_id, transcript=body.transcript)
except Exception:
logger.exception("ai_writing: failed to save transcript: session=%s", session_id)
return {"success": False, "reason": "save_failed"}
if updated is None: if updated is None:
raise HTTPException(status_code=404, detail="Session not found") # 会话不存在或不属于当前用户:同样不抛 500,返回 success=False 即可
return {"success": False, "reason": "session_not_found"}
return {"success": True} return {"success": True}
@@ -420,7 +434,17 @@ async def resume_writing(session_id: str, body: ResumeRequest, request: Request)
from app.gateway.deps import get_current_user from app.gateway.deps import get_current_user
if session_id not in _event_queues: if session_id not in _event_queues:
raise HTTPException(status_code=404, detail="Session not found") # 内存会话注册表丢失(进程内清理后重连等)。先尝试从 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) config = _make_config(session_id)
intervention = { intervention = {
@@ -623,6 +647,104 @@ def _event_type(evt: dict) -> str:
return t return t
async def _load_graph_snapshot(session_id: str):
"""读取 AI 写作 Graph 在该 session 上的状态快照(失败/无状态返回 None)。
注意:Graph 用的是内存 checkpointer(MemorySaver),所以这里只能在
**进程存活**期间恢复;进程/容器重启后快照同样会丢失。
"""
try:
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
snapshot = await AI_WRITING_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]: async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
"""从队列消费事件,推送为 SSE 帧。""" """从队列消费事件,推送为 SSE 帧。"""
# 等待队列创建(最多 5 秒) # 等待队列创建(最多 5 秒)
@@ -631,10 +753,24 @@ async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]:
break break
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
else: else:
yield _sse("error", {"message": "Session not found"}) # 内存里没有这个会话队列。不要直接报「Session not found」——
return # 先尝试从 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[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}) yield _sse("connected", {"session_id": session_id})
# Re-inject the saved await_user event if the queue is empty and a pending pause exists. # Re-inject the saved await_user event if the queue is empty and a pending pause exists.
@@ -41,6 +41,26 @@ models:
temperature: 1.0 temperature: 1.0
supports_vision: true supports_vision: true
supports_thinking: true supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: minimax-m2.5
display_name: MiniMax M2.5
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.5
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: false
title: title:
enabled: true enabled: true
@@ -8,6 +8,7 @@ from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
from deerflow.persistence.types import PortableLongText
class AIWritingSessionRow(Base): class AIWritingSessionRow(Base):
@@ -22,7 +23,8 @@ class AIWritingSessionRow(Base):
draft_markdown: Mapped[str | None] = mapped_column(Text, nullable=True) draft_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_interventions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array completed_interventions: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
review_result: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object review_result: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object
# 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显 # 完整对话时间线(前端组装的 progressEvents + completedInterventions),用于历史回显。
transcript: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object # 用 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)) 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)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
@@ -0,0 +1,64 @@
"""Widen ai_writing_sessions.transcript to LONGTEXT on MySQL.
The ``transcript`` column stores the full AI-writing conversation timeline as a
JSON blob. It was originally created as ``TEXT``, which on MySQL caps at 64 KB —
a transcript with many progress events overflows it and the save endpoint
returns HTTP 500. This revision widens the column to ``LONGTEXT`` (4 GB) on
MySQL. SQLite/PostgreSQL ``TEXT`` is already unbounded, so they are left as-is.
Revision ID: 20260519_02
Revises: 20260519_01
Create Date: 2026-05-19
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260519_02"
down_revision = "20260519_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:
bind = op.get_bind()
# Only MySQL has the 64 KB TEXT limit; other backends need no change.
if bind.dialect.name != "mysql":
return
if not _has_column("ai_writing_sessions", "transcript"):
return
op.alter_column(
"ai_writing_sessions",
"transcript",
type_=LONGTEXT(),
existing_nullable=True,
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "mysql":
return
if not _has_column("ai_writing_sessions", "transcript"):
return
# Revert to TEXT. Rows longer than 64 KB would be truncated, but downgrade
# is a developer-only path.
import sqlalchemy as sa
op.alter_column(
"ai_writing_sessions",
"transcript",
type_=sa.Text(),
existing_nullable=True,
)
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import JSON, DateTime, Text from sqlalchemy import JSON, DateTime, Text
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
# China Standard Time. China has observed UTC+8 with no DST since 1991, so a # China Standard Time. China has observed UTC+8 with no DST since 1991, so a
@@ -39,6 +40,25 @@ class PortableJSON(TypeDecorator):
return json.loads(value) return json.loads(value)
class PortableLongText(TypeDecorator):
"""A large text column.
MySQL's ``TEXT`` caps at 64 KB, which silently truncates — or, in STRICT
mode, raises ``Data too long`` (HTTP 500) — for large JSON blobs such as the
AI-writing conversation transcript. This decorator maps to MySQL ``LONGTEXT``
(4 GB) while staying plain ``TEXT`` on SQLite/PostgreSQL, where ``TEXT`` is
already unbounded.
"""
impl = Text
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "mysql":
return dialect.type_descriptor(LONGTEXT())
return dialect.type_descriptor(Text())
class BeijingDateTime(TypeDecorator): class BeijingDateTime(TypeDecorator):
"""Store timestamps as Beijing (UTC+8) wall-clock time. """Store timestamps as Beijing (UTC+8) wall-clock time.
+2702 -2699
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -37,6 +37,26 @@ models:
temperature: 1.0 temperature: 1.0
supports_vision: true supports_vision: true
supports_thinking: true supports_thinking: true
- name: minimax-m2.7
display_name: MiniMax M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: minimax-m2.5
display_name: MiniMax M2.5
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.5
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
tool_groups: tool_groups:
- name: web - name: web
- name: file:read - name: file:read