feat(agent): 支持运行恢复与中断续写

完善 AgentRun 的父子运行关系、恢复请求和前端流式状态处理,补充相关单元测试以覆盖中断后恢复场景。
This commit is contained in:
Wenjie Zhang 2026-05-28 12:53:29 +08:00
parent 54bf9b61a8
commit acf150d47d
27 changed files with 693 additions and 973 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

View File

@ -23,12 +23,19 @@ class AgentRunRepository:
result = await self.db.execute(select(AgentRun).where(AgentRun.request_id == request_id))
return result.scalar_one_or_none()
async def get_run_for_user(self, run_id: str, uid: str) -> AgentRun | None:
async def get_resume_run(self, parent_run_id: str, resume_request_id: str) -> AgentRun | None:
result = await self.db.execute(
select(AgentRun).where(and_(AgentRun.id == run_id, AgentRun.uid == str(uid)))
select(AgentRun).where(
AgentRun.parent_run_id == parent_run_id,
AgentRun.resume_request_id == resume_request_id,
)
)
return result.scalar_one_or_none()
async def get_run_for_user(self, run_id: str, uid: str) -> AgentRun | None:
result = await self.db.execute(select(AgentRun).where(and_(AgentRun.id == run_id, AgentRun.uid == str(uid))))
return result.scalar_one_or_none()
async def create_run(
self,
*,
@ -38,6 +45,11 @@ class AgentRunRepository:
uid: str,
request_id: str,
input_payload: dict,
conversation_id: int | None = None,
parent_run_id: str | None = None,
run_type: str = "chat",
resume_request_id: str | None = None,
checkpoint_thread_id: str | None = None,
) -> AgentRun:
run = AgentRun(
id=run_id,
@ -45,6 +57,11 @@ class AgentRunRepository:
agent_id=agent_id,
uid=str(uid),
request_id=request_id,
conversation_id=conversation_id,
parent_run_id=parent_run_id,
run_type=run_type,
resume_request_id=resume_request_id,
checkpoint_thread_id=checkpoint_thread_id or thread_id,
input_payload=input_payload or {},
status="pending",
)
@ -52,6 +69,15 @@ class AgentRunRepository:
await self.db.flush()
return run
async def set_input_message(self, run_id: str, message_id: int) -> AgentRun | None:
run = await self.get_run(run_id)
if not run:
return None
run.input_message_id = message_id
run.updated_at = utc_now_naive()
await self.db.flush()
return run
async def mark_running(self, run_id: str) -> AgentRun | None:
run = await self._lock_run(run_id)
if not run:

View File

@ -98,6 +98,9 @@ class ConversationRepository:
message_type: str = "text",
extra_metadata: dict | None = None,
image_content: str | None = None,
run_id: str | None = None,
request_id: str | None = None,
delivery_status: str = "complete",
) -> Message:
message = Message(
conversation_id=conversation_id,
@ -106,6 +109,9 @@ class ConversationRepository:
message_type=message_type,
extra_metadata=extra_metadata or {},
image_content=image_content,
run_id=run_id,
request_id=request_id,
delivery_status=delivery_status,
)
self.db.add(message)
@ -129,6 +135,9 @@ class ConversationRepository:
message_type: str = "text",
extra_metadata: dict | None = None,
image_content: str | None = None,
run_id: str | None = None,
request_id: str | None = None,
delivery_status: str = "complete",
) -> Message | None:
conversation = await self.get_conversation_by_thread_id(thread_id)
if not conversation:
@ -142,6 +151,9 @@ class ConversationRepository:
message_type=message_type,
extra_metadata=extra_metadata,
image_content=image_content,
run_id=run_id,
request_id=request_id,
delivery_status=delivery_status,
)
async def add_tool_call(

View File

@ -24,7 +24,7 @@ from yuxi.services.run_queue_service import (
publish_cancel_signal,
)
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User
from yuxi.storage.postgres.models_business import Message, User
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
@ -39,31 +39,37 @@ def _build_run_response(run) -> dict:
"thread_id": run.thread_id,
"status": run.status,
"request_id": run.request_id,
"stream_url": f"/api/agent/runs/{run.id}/events?after_seq=0-0",
"stream_url": f"/api/agent/runs/{run.id}/events",
}
def _format_sse(data: dict, event: str | None = None) -> str:
lines = []
if event:
lines.append(f"event: {event}")
lines.append(f"data: {json.dumps(data, ensure_ascii=False)}")
def _format_sse(data: dict, event: str, event_id: str | None = None) -> str:
lines = [f"event: {event}", f"data: {json.dumps(data, ensure_ascii=False)}"]
if event_id:
lines.append(f"id: {event_id}")
lines.append("")
return "\n".join(lines) + "\n"
def _format_heartbeat() -> str:
return ": heartbeat\n\n"
async def create_agent_run_view(
*,
query: str,
query: str | None,
agent_id: str,
thread_id: str,
meta: dict,
image_content: str | None,
current_uid: str,
db: AsyncSession,
resume: object | None = None,
parent_run_id: str | None = None,
resume_request_id: str | None = None,
) -> dict:
if not query:
raise HTTPException(status_code=422, detail="query 不能为空")
if not query and resume is None:
raise HTTPException(status_code=422, detail="query 或 resume 不能为空")
if not thread_id:
raise HTTPException(status_code=422, detail="thread_id 不能为空")
@ -87,9 +93,22 @@ async def create_agent_run_view(
if not agent_manager.get_agent(agent_item.backend_id):
raise HTTPException(status_code=404, detail=f"智能体后端 {agent_item.backend_id} 不存在")
request_id = str((meta or {}).get("request_id") or uuid.uuid4())
run_type = "resume" if resume is not None else "chat"
request_id = str(resume_request_id or (meta or {}).get("request_id") or uuid.uuid4())
config = {"thread_id": thread_id, "agent_id": agent_id}
run_repo = AgentRunRepository(db)
if run_type == "resume":
if not parent_run_id:
raise HTTPException(status_code=422, detail="parent_run_id 不能为空")
parent_run = await run_repo.get_run_for_user(parent_run_id, str(current_uid))
if not parent_run or parent_run.thread_id != thread_id:
raise HTTPException(status_code=404, detail="被恢复的运行任务不存在")
if parent_run.status != "interrupted":
raise HTTPException(status_code=409, detail="只有 interrupted run 可以恢复")
if resume_request_id:
existing_resume = await run_repo.get_resume_run(parent_run_id, resume_request_id)
if existing_resume and existing_resume.uid == str(current_uid):
return _build_run_response(existing_resume)
existing = await run_repo.get_run_by_request_id(request_id)
if existing and existing.uid == str(current_uid):
return _build_run_response(existing)
@ -98,7 +117,11 @@ async def create_agent_run_view(
run_id = str(uuid.uuid4())
input_payload = {
"query": query,
"query": query or "",
"resume": resume,
"parent_run_id": parent_run_id,
"resume_request_id": resume_request_id,
"run_type": run_type,
"config": config or {},
"image_content": image_content,
"agent_id": agent_id,
@ -117,7 +140,38 @@ async def create_agent_run_view(
uid=str(current_uid),
request_id=request_id,
input_payload=input_payload,
conversation_id=conversation.id,
parent_run_id=parent_run_id,
run_type=run_type,
resume_request_id=resume_request_id,
checkpoint_thread_id=thread_id,
)
input_content = query or json.dumps(resume, ensure_ascii=False)
input_metadata = {
"request_id": request_id,
"run_id": run_id,
"run_type": run_type,
"parent_run_id": parent_run_id,
"resume": resume,
"attachments": [],
}
if run_type == "resume":
input_metadata["source"] = "ask_user_question_resume"
input_message = Message(
conversation_id=conversation.id,
role="user",
content=input_content,
message_type="resume" if run_type == "resume" else "text",
image_content=image_content,
run_id=run_id,
request_id=request_id,
delivery_status="complete",
extra_metadata=input_metadata,
)
db.add(input_message)
await db.flush()
await run_repo.set_input_message(run_id, input_message.id)
await db.commit()
except IntegrityError:
await db.rollback()
@ -170,7 +224,6 @@ async def stream_agent_run_events(
run = await repo.get_run_for_user(run_id, str(current_uid))
if not run:
yield _format_sse({"run_id": run_id, "message": "运行任务不存在"}, event="error")
yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close")
return
except asyncio.CancelledError:
raise
@ -184,7 +237,6 @@ async def stream_agent_run_events(
},
event="error",
)
yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close")
return
try:
@ -199,32 +251,38 @@ async def stream_agent_run_events(
},
event="error",
)
yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close")
return
emitted_terminal = False
for event in events:
seq = str(event.get("seq") or "0-0")
last_seq = seq
event_type = event.get("event_type") or "message"
envelope = event.get("payload") or {}
yield _format_sse(envelope, event=event_type, event_id=seq)
if event_type == "end":
emitted_terminal = True
yield _format_sse(
{
"run_id": run_id,
"seq": seq,
"event_type": event.get("event_type") or "message",
"payload": event.get("payload") or {},
"ts": event.get("ts"),
},
event=event.get("event_type") or "message",
)
if emitted_terminal:
return
if run.status in TERMINAL_RUN_STATUSES and not events:
terminal_seq = last_seq
if terminal_seq in {"", "0-0"}:
terminal_seq = await get_last_run_stream_seq(run_id)
if terminal_seq in {"", "0-0"}:
terminal_seq = None
yield _format_sse(
{"run_id": run_id, "status": run.status, "last_seq": terminal_seq},
event="close",
{
"schema_version": 1,
"run_id": run_id,
"thread_id": run.thread_id,
"event": "end",
"payload": {"status": run.status},
"created_at": utc_now_naive().isoformat(),
},
event="end",
event_id=terminal_seq,
)
return
@ -232,11 +290,10 @@ async def stream_agent_run_events(
elapsed_seconds = (now - started_at).total_seconds()
heartbeat_elapsed = (now - last_heartbeat_ts).total_seconds()
if heartbeat_elapsed >= SSE_HEARTBEAT_SECONDS:
yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="heartbeat")
yield _format_heartbeat()
last_heartbeat_ts = now
if elapsed_seconds >= SSE_MAX_CONNECTION_MINUTES * 60:
yield _format_sse({"run_id": run_id, "last_seq": last_seq}, event="close")
return
await asyncio.sleep(SSE_POLL_INTERVAL_SECONDS)
@ -248,15 +305,27 @@ async def get_active_run_by_thread(*, thread_id: str, current_uid: str, db: Asyn
from sqlalchemy import select
from yuxi.storage.postgres.models_business import AgentRun
result = await db.execute(
active_result = await db.execute(
select(AgentRun)
.where(
AgentRun.thread_id == thread_id,
AgentRun.uid == str(current_uid),
AgentRun.status.notin_(list(TERMINAL_RUN_STATUSES)),
AgentRun.status.in_(["pending", "running", "cancel_requested"]),
)
.order_by(AgentRun.created_at.desc())
.limit(1)
)
run = result.scalar_one_or_none()
run = active_result.scalar_one_or_none()
if not run:
interrupted_result = await db.execute(
select(AgentRun)
.where(
AgentRun.thread_id == thread_id,
AgentRun.uid == str(current_uid),
AgentRun.status == "interrupted",
)
.order_by(AgentRun.created_at.desc())
.limit(1)
)
run = interrupted_result.scalar_one_or_none()
return {"run": run.to_dict() if run else None}

View File

@ -711,6 +711,7 @@ async def stream_agent_chat(
image_content: str | None,
current_user,
db,
save_user_message: bool = True,
) -> AsyncIterator[bytes]:
start_time = asyncio.get_event_loop().time()
@ -819,21 +820,22 @@ async def stream_agent_chat(
init_msg["image_content"] = image_content
yield make_chunk(status="init", meta=meta, msg=init_msg)
try:
await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
role="user",
content=query,
message_type=message_type,
image_content=image_content,
extra_metadata={
"raw_message": human_message.model_dump(),
"request_id": meta.get("request_id"),
"attachments": request_attachments,
},
)
except Exception as e:
logger.error(f"Error saving user message: {e}")
if save_user_message:
try:
await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
role="user",
content=query,
message_type=message_type,
image_content=image_content,
extra_metadata={
"raw_message": human_message.model_dump(),
"request_id": meta.get("request_id"),
"attachments": request_attachments,
},
)
except Exception as e:
logger.error(f"Error saving user message: {e}")
# 先构建 langgraph_config
langgraph_config = {"configurable": {"thread_id": thread_id, "uid": uid}}
@ -1001,8 +1003,7 @@ async def stream_agent_resume(
+ b"\n"
)
init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"}
yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
yield make_resume_chunk(status="init", meta=meta)
resume_command = Command(resume=resume_input)

View File

@ -855,7 +855,7 @@ async def oidc_callback_handler(code: str, state: str, db, request: Request | No
response_data = {
"access_token": jwt_token,
"token_type": "bearer",
"uid": user.id,
"user_id": user.id,
"username": user.username,
"uid": user.uid,
"phone_number": user.phone_number,

View File

@ -159,13 +159,22 @@ async def clear_cancel_signal(run_id: str) -> None:
logger.warning(f"Failed to clear cancel signal for run {run_id}: {e}")
async def append_run_stream_event(run_id: str, event_type: str, payload: dict) -> str:
async def append_run_stream_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None) -> str:
redis = await get_redis_client()
key = _event_stream_key(run_id)
now_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
now = datetime.now(tz=UTC)
now_ms = int(now.timestamp() * 1000)
envelope = {
"schema_version": 1,
"run_id": run_id,
"thread_id": thread_id,
"event": event_type,
"payload": payload or {},
"created_at": now.isoformat(),
}
fields = {
"event_type": event_type,
"payload": json.dumps(payload or {}, ensure_ascii=False),
"payload": json.dumps(envelope, ensure_ascii=False),
"ts": str(now_ms),
}
@ -198,11 +207,22 @@ async def list_run_stream_events(
except Exception:
payload = {}
event_type = fields.get("event_type") or "message"
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
payload = {
"schema_version": 1,
"run_id": run_id,
"thread_id": None,
"event": event_type,
"payload": payload if isinstance(payload, dict) else {},
"created_at": None,
}
ts_value = fields.get("ts")
events.append(
{
"seq": str(event_id),
"event_type": fields.get("event_type") or "message",
"event_type": event_type,
"payload": payload,
"ts": int(ts_value) if ts_value else None,
}

View File

@ -11,15 +11,15 @@ from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository
from yuxi.services.chat_service import stream_agent_chat
from yuxi.services.chat_service import stream_agent_chat, stream_agent_resume
from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db
from yuxi.services.skill_service import init_builtin_skills
from yuxi.services.run_queue_service import (
append_run_stream_event,
clear_cancel_signal,
has_cancel_signal,
wait_for_cancel_signal,
)
from yuxi.services.skill_service import init_builtin_skills
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger
@ -77,8 +77,9 @@ class RunContext:
class ChunkedEventWriter:
def __init__(self, run_id: str, interval_ms: int = 100, max_chars: int = 512):
def __init__(self, run_id: str, thread_id: str | None, interval_ms: int = 100, max_chars: int = 512):
self.run_id = run_id
self.thread_id = thread_id
self.interval_seconds = interval_ms / 1000
self.max_chars = max_chars
self.buffer: list[dict] = []
@ -97,7 +98,7 @@ class ChunkedEventWriter:
async def flush(self):
if not self.buffer:
return
await append_run_event(self.run_id, "loading", {"items": self.buffer})
await append_run_event(self.run_id, "messages", {"items": self.buffer}, thread_id=self.thread_id)
self.buffer = []
self.buffer_chars = 0
self.last_flush = time.monotonic()
@ -109,8 +110,8 @@ async def _get_run(run_id: str):
return await repo.get_run(run_id)
async def append_run_event(run_id: str, event_type: str, payload: dict):
await append_run_stream_event(run_id, event_type, payload)
async def append_run_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None):
await append_run_stream_event(run_id, event_type, payload, thread_id=thread_id)
async def mark_run_running(run_id: str):
@ -169,6 +170,31 @@ def _iter_json_chunks(chunk_bytes: bytes) -> list[dict]:
return chunks
def _map_chunk_to_run_event(chunk: dict) -> tuple[str, dict]:
status = chunk.get("status") or "event"
if status == "loading":
return "messages", {"chunk": chunk}
if status == "agent_state":
return "custom", {"name": "yuxi.agent_state", "chunk": chunk, "agent_state": chunk.get("agent_state") or {}}
if status in {"ask_user_question_required", "human_approval_required", "interrupted"}:
reason = "human_approval" if status == "human_approval_required" else status
return "interrupt", {"reason": reason, "chunk": chunk}
if status == "warning":
return "custom", {"name": "yuxi.warning", "chunk": chunk}
if status == "error":
return "error", {"chunk": chunk, "retryable": bool(chunk.get("retryable"))}
if status == "finished":
return "end", {"status": "completed", "chunk": chunk}
return "custom", {"name": f"yuxi.{status}", "chunk": chunk}
async def _append_end_event(run_id: str, status: str, *, thread_id: str | None, payload: dict | None = None):
end_payload = {"status": status}
if payload:
end_payload.update(payload)
await append_run_event(run_id, "end", end_payload, thread_id=thread_id)
async def _consume_stream_with_cancel(agen, run_ctx: RunContext):
while True:
next_task = asyncio.create_task(agen.__anext__())
@ -200,11 +226,14 @@ async def process_agent_run(ctx, run_id: str):
payload = run.input_payload or {}
query = payload.get("query")
resume_input = payload.get("resume")
run_type = payload.get("run_type") or "chat"
config = payload.get("config") or {}
agent_id = payload.get("agent_id")
image_content = payload.get("image_content")
uid = payload.get("uid")
request_id = payload.get("request_id")
thread_id = config.get("thread_id") or payload.get("thread_id")
user = await _load_user(uid)
if not user:
@ -229,23 +258,45 @@ async def process_agent_run(ctx, run_id: str):
run_ctx = RunContext(run_id=run_id)
writer = ChunkedEventWriter(
run_id=run_id,
thread_id=thread_id,
interval_ms=LOADING_FLUSH_INTERVAL_MS,
max_chars=LOADING_FLUSH_MAX_CHARS,
)
await run_ctx.start()
await append_run_event(
run_id,
"metadata",
{
"request_id": request_id,
"agent_id": agent_id,
"backend_id": payload.get("backend_id"),
"uid": uid,
},
thread_id=thread_id,
)
terminal_set = False
try:
async with pg_manager.get_async_session_context() as db:
stream = stream_agent_chat(
query=query,
agent_id=config.get("agent_id") or agent_id,
thread_id=config.get("thread_id"),
meta=meta,
image_content=image_content,
current_user=user,
db=db,
)
if run_type == "resume":
stream = stream_agent_resume(
thread_id=thread_id,
resume_input=resume_input,
meta=meta,
current_user=user,
db=db,
)
else:
stream = stream_agent_chat(
query=query,
agent_id=config.get("agent_id") or agent_id,
thread_id=thread_id,
meta=meta,
image_content=image_content,
current_user=user,
db=db,
save_user_message=False,
)
async for chunk_bytes in _consume_stream_with_cancel(stream, run_ctx):
for chunk in _iter_json_chunks(chunk_bytes):
@ -255,10 +306,13 @@ async def process_agent_run(ctx, run_id: str):
await writer.flush()
status = chunk.get("status") or "event"
await append_run_event(run_id, status, {"chunk": chunk})
event_type, event_payload = _map_chunk_to_run_event(chunk)
if event_type != "end":
await append_run_event(run_id, event_type, event_payload, thread_id=thread_id)
if status == "finished":
await mark_run_terminal(run_id, "completed")
await _append_end_event(run_id, "completed", thread_id=thread_id, payload={"chunk": chunk})
terminal_set = True
elif status == "error":
await mark_run_terminal(
@ -267,6 +321,7 @@ async def process_agent_run(ctx, run_id: str):
error_type=chunk.get("error_type") or "stream_error",
error_message=chunk.get("error_message") or chunk.get("message"),
)
await _append_end_event(run_id, "failed", thread_id=thread_id, payload={"chunk": chunk})
terminal_set = True
elif status == "interrupted":
status_value = "cancelled" if await _is_cancel_requested(run_id) else "interrupted"
@ -276,8 +331,9 @@ async def process_agent_run(ctx, run_id: str):
error_type=status_value,
error_message=chunk.get("message"),
)
await _append_end_event(run_id, status_value, thread_id=thread_id, payload={"chunk": chunk})
terminal_set = True
elif status == "ask_user_question_required":
elif status in {"ask_user_question_required", "human_approval_required"}:
questions = chunk.get("questions") if isinstance(chunk, dict) else None
first_question = ""
if isinstance(questions, list) and questions:
@ -288,9 +344,10 @@ async def process_agent_run(ctx, run_id: str):
await mark_run_terminal(
run_id,
"interrupted",
error_type="ask_user_question_required",
error_type=status,
error_message=first_question or "需要用户回答问题",
)
await _append_end_event(run_id, "interrupted", thread_id=thread_id, payload={"chunk": chunk})
terminal_set = True
if await run_ctx.is_cancelled():
@ -298,36 +355,40 @@ async def process_agent_run(ctx, run_id: str):
await writer.flush()
if not terminal_set:
finished_chunk = {"status": "finished", "request_id": request_id}
await mark_run_terminal(run_id, "completed")
await append_run_event(run_id, "finished", {"chunk": {"status": "finished", "request_id": request_id}})
await _append_end_event(run_id, "completed", thread_id=thread_id, payload={"chunk": finished_chunk})
except asyncio.CancelledError:
await writer.flush()
cancel_chunk = {"status": "interrupted", "message": "对话已取消", "request_id": request_id}
await append_run_event(
run_id,
"interrupted",
{"chunk": {"status": "interrupted", "message": "对话已取消", "request_id": request_id}},
"interrupt",
{"reason": "cancelled", "chunk": cancel_chunk},
thread_id=thread_id,
)
await mark_run_terminal(run_id, "cancelled", error_type="cancelled", error_message="对话已取消")
await _append_end_event(run_id, "cancelled", thread_id=thread_id, payload={"chunk": cancel_chunk})
logger.info(f"Run cancelled: {run_id}")
except Exception as e:
await writer.flush()
if _is_retryable_exception(e):
job_try = _job_try(ctx)
logger.warning(f"Run retryable failure {run_id} (try={job_try}): {e}")
retryable_error_chunk = {
"status": "error",
"error_type": "retryable_worker_error",
"error_message": str(e),
"request_id": request_id,
"retryable": True,
"job_try": job_try,
}
await append_run_event(
run_id,
"error",
{
"chunk": {
"status": "error",
"error_type": "retryable_worker_error",
"error_message": str(e),
"request_id": request_id,
"retryable": True,
"job_try": job_try,
}
},
{"chunk": retryable_error_chunk, "retryable": True},
thread_id=thread_id,
)
if _is_last_try(ctx):
await mark_run_terminal(
@ -336,6 +397,12 @@ async def process_agent_run(ctx, run_id: str):
error_type="retryable_worker_error",
error_message=str(e),
)
await _append_end_event(
run_id,
"failed",
thread_id=thread_id,
payload={"chunk": retryable_error_chunk},
)
logger.error(f"Run failed after retries exhausted {run_id}: {e}")
return
@ -344,20 +411,21 @@ async def process_agent_run(ctx, run_id: str):
raise RetryableRunError(str(e)) from e
logger.error(f"Run failed {run_id}: {e}")
error_chunk = {
"status": "error",
"error_type": "worker_error",
"error_message": str(e),
"request_id": request_id,
"retryable": False,
}
await append_run_event(
run_id,
"error",
{
"chunk": {
"status": "error",
"error_type": "worker_error",
"error_message": str(e),
"request_id": request_id,
"retryable": False,
}
},
{"chunk": error_chunk, "retryable": False},
thread_id=thread_id,
)
await mark_run_terminal(run_id, "failed", error_type="worker_error", error_message=str(e))
await _append_end_event(run_id, "failed", thread_id=thread_id, payload={"chunk": error_chunk})
return
finally:
await run_ctx.close()

View File

@ -447,23 +447,6 @@ class PostgresManager(metaclass=SingletonMeta):
updated_at TIMESTAMPTZ DEFAULT NOW()
)
""",
"""
CREATE TABLE IF NOT EXISTS agent_runs (
id VARCHAR(64) PRIMARY KEY,
thread_id VARCHAR(64) NOT NULL,
agent_id VARCHAR(64) NOT NULL,
uid VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
request_id VARCHAR(64) NOT NULL UNIQUE,
input_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
error_type VARCHAR(64),
error_message TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_uid_created ON agent_runs(uid, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)",

View File

@ -300,6 +300,9 @@ class Message(Base):
token_count = Column(Integer, nullable=True, comment="Token count (optional)")
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata (complete message dump)")
image_content = Column(Text, nullable=True, comment="Base64 encoded image content for multimodal messages")
run_id = Column(String(64), ForeignKey("agent_runs.id"), nullable=True, index=True, comment="Agent run ID")
request_id = Column(String(64), nullable=True, index=True, comment="Request ID for idempotency")
delivery_status = Column(String(32), nullable=False, default="complete", comment="Message status")
# Relationships
conversation = relationship("Conversation", back_populates="messages")
@ -317,6 +320,9 @@ class Message(Base):
"token_count": self.token_count,
"metadata": self.extra_metadata or {},
"image_content": self.image_content,
"run_id": self.run_id,
"request_id": self.request_id,
"status": self.delivery_status,
"tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else [],
}
@ -769,6 +775,16 @@ class AgentRun(Base):
comment="Run status: pending/running/completed/failed/cancel_requested/cancelled/interrupted",
)
request_id = Column(String(64), unique=True, index=True, nullable=False, comment="Idempotency request ID")
conversation_id = Column(
Integer, ForeignKey("conversations.id"), nullable=True, index=True, comment="Conversation ID"
)
parent_run_id = Column(String(64), nullable=True, index=True, comment="Parent interrupted run ID")
run_type = Column(String(32), nullable=False, default="chat", comment="Run type: chat/resume")
resume_request_id = Column(String(64), nullable=True, index=True, comment="Resume idempotency request ID")
input_message_id = Column(Integer, nullable=True, comment="Input message ID")
output_message_id = Column(Integer, nullable=True, comment="Output message ID")
checkpoint_thread_id = Column(String(64), nullable=True, comment="LangGraph checkpoint thread ID")
last_event_id = Column(String(64), nullable=True, comment="Last Redis stream event ID")
input_payload = Column(JSON, nullable=False, default=dict, comment="Original input payload")
error_type = Column(String(64), nullable=True, comment="Error type")
error_message = Column(Text, nullable=True, comment="Error message")
@ -785,6 +801,14 @@ class AgentRun(Base):
"uid": self.uid,
"status": self.status,
"request_id": self.request_id,
"conversation_id": self.conversation_id,
"parent_run_id": self.parent_run_id,
"run_type": self.run_type,
"resume_request_id": self.resume_request_id,
"input_message_id": self.input_message_id,
"output_message_id": self.output_message_id,
"checkpoint_thread_id": self.checkpoint_thread_id,
"last_event_id": self.last_event_id,
"input_payload": self.input_payload or {},
"error_type": self.error_type,
"error_message": self.error_message,

View File

@ -1,6 +1,8 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@ -46,11 +48,14 @@ class AgentUpdate(BaseModel):
class AgentRunCreate(BaseModel):
query: str = Field(..., description="用户输入的问题")
query: str | None = Field(None, description="用户输入的问题")
agent_id: str = Field(..., description="智能体 ID")
thread_id: str = Field(..., description="会话线程 ID")
meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id")
image_content: str | None = Field(None, description="可选base64 图片内容")
resume: Any | None = Field(None, description="可选,恢复 interrupted run 的输入")
parent_run_id: str | None = Field(None, description="可选,被恢复的 run ID")
resume_request_id: str | None = Field(None, description="可选resume 幂等键")
class AgentChatRequest(BaseModel):
@ -293,6 +298,9 @@ async def create_agent_run(
image_content=payload.image_content,
current_uid=str(current_user.uid),
db=db,
resume=payload.resume,
parent_run_id=payload.parent_run_id,
resume_request_id=payload.resume_request_id,
)
@ -311,9 +319,15 @@ async def cancel_agent_run(
@agent_router.get("/runs/{run_id}/events")
async def stream_run_events(run_id: str, after_seq: str = "0-0", current_user: User = Depends(get_required_user)):
async def stream_run_events(
run_id: str,
after_seq: str = "0-0",
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
current_user: User = Depends(get_required_user),
):
cursor = last_event_id or after_seq
return StreamingResponse(
stream_agent_run_events(run_id=run_id, after_seq=after_seq, current_uid=str(current_user.uid)),
stream_agent_run_events(run_id=run_id, after_seq=cursor, current_uid=str(current_user.uid)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)

View File

@ -4,21 +4,12 @@ from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from sqlalchemy.exc import IntegrityError
import yuxi.services.agent_run_service as agent_run_service
class FakeConfigRepo:
def __init__(self, db_session):
self.db = db_session
async def get_by_id(self, config_id: int):
return SimpleNamespace(id=config_id, agent_id="ChatbotAgent", uid="user-1")
@pytest.mark.asyncio
async def test_stream_agent_run_events_emits_error_and_close_on_db_error(monkeypatch: pytest.MonkeyPatch):
async def test_stream_agent_run_events_emits_error_on_db_error(monkeypatch: pytest.MonkeyPatch):
@asynccontextmanager
async def fake_session_ctx():
yield object()
@ -42,14 +33,13 @@ async def test_stream_agent_run_events_emits_error_and_close_on_db_error(monkeyp
):
chunks.append(chunk)
assert len(chunks) == 2
assert len(chunks) == 1
assert chunks[0].startswith("event: error")
assert '"reason": "db_error"' in chunks[0]
assert chunks[1].startswith("event: close")
@pytest.mark.asyncio
async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatch: pytest.MonkeyPatch):
async def test_stream_agent_run_events_reads_redis_and_ends_on_end_event(monkeypatch: pytest.MonkeyPatch):
@asynccontextmanager
async def fake_session_ctx():
yield object()
@ -60,7 +50,7 @@ async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatc
async def get_run_for_user(self, run_id: str, uid: str):
del run_id, uid
return SimpleNamespace(status="completed")
return SimpleNamespace(status="completed", thread_id="thread-1")
calls = {"count": 0}
@ -71,21 +61,36 @@ async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatc
return [
{
"seq": "1700000000000-0",
"event_type": "loading",
"payload": {"items": [{"status": "loading", "response": ""}]},
"event_type": "messages",
"payload": {
"schema_version": 1,
"run_id": "run-1",
"thread_id": "thread-1",
"event": "messages",
"payload": {"items": [{"status": "loading", "response": ""}]},
"created_at": "2026-05-27T00:00:00+00:00",
},
"ts": 1700000000000,
}
},
{
"seq": "1700000000001-0",
"event_type": "end",
"payload": {
"schema_version": 1,
"run_id": "run-1",
"thread_id": "thread-1",
"event": "end",
"payload": {"status": "completed"},
"created_at": "2026-05-27T00:00:01+00:00",
},
"ts": 1700000000001,
},
]
return []
async def fake_last_seq(run_id: str):
del run_id
return "1700000000000-0"
monkeypatch.setattr(agent_run_service.pg_manager, "get_async_session_context", fake_session_ctx)
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
monkeypatch.setattr(agent_run_service, "list_run_stream_events", fake_list_events)
monkeypatch.setattr(agent_run_service, "get_last_run_stream_seq", fake_last_seq)
monkeypatch.setattr(agent_run_service, "SSE_POLL_INTERVAL_SECONDS", 0)
chunks = []
@ -96,17 +101,36 @@ async def test_stream_agent_run_events_reads_redis_and_close_terminal(monkeypatc
):
chunks.append(chunk)
assert any(item.startswith("event: loading") for item in chunks)
assert chunks[-1].startswith("event: close")
assert '"last_seq": "1700000000000-0"' in chunks[-1]
assert chunks[0].startswith("event: messages")
assert "id: 1700000000000-0" in chunks[0]
assert chunks[-1].startswith("event: end")
assert "id: 1700000000001-0" in chunks[-1]
@pytest.mark.asyncio
async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.MonkeyPatch):
async def test_create_agent_run_persists_input_before_enqueue(monkeypatch: pytest.MonkeyPatch):
class FakeResult:
def scalar_one_or_none(self):
return SimpleNamespace(uid="user-1", role="user")
class FakeDB:
def __init__(self):
self.order: list[str] = []
self.committed = False
self.added = []
async def execute(self, stmt):
del stmt
return FakeResult()
def add(self, item):
self.added.append(item)
async def flush(self):
self.order.append("flush")
for item in self.added:
if getattr(item, "id", None) is None:
item.id = 10
async def commit(self):
self.order.append("commit")
@ -117,14 +141,14 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke
db = FakeDB()
created_run = SimpleNamespace(
id="run-1",
id="",
thread_id="thread-1",
status="pending",
request_id="req-1",
uid="user-1",
)
class Repo:
class RunRepo:
def __init__(self, db_session):
self.db = db_session
@ -134,6 +158,13 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke
async def create_run(self, **kwargs):
assert kwargs["request_id"] == "req-1"
assert kwargs["conversation_id"] == 1
created_run.id = kwargs["run_id"]
return created_run
async def set_input_message(self, run_id: str, message_id: int):
assert run_id == created_run.id
assert message_id == 10
return created_run
class ConvRepo:
@ -142,33 +173,36 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke
async def get_conversation_by_thread_id(self, thread_id: str):
del thread_id
return SimpleNamespace(
uid="user-1",
status="active",
department_id=1,
extra_metadata={"agent_config_id": 1},
)
return SimpleNamespace(id=1, uid="user-1", status="active", agent_id="default")
class AgentRepo:
def __init__(self, db_session):
self.db = db_session
async def get_visible_by_slug(self, slug: str, user):
del user
return SimpleNamespace(slug=slug, backend_id="ChatbotAgent")
class Queue:
async def enqueue_job(self, job_name: str, run_id: str, _job_id: str):
assert job_name == "process_agent_run"
assert run_id == "run-1"
assert _job_id == "run:run-1"
assert run_id == created_run.id
assert _job_id == f"run:{created_run.id}"
db.order.append("enqueue")
assert db.committed is True
async def fake_get_arq_pool():
return Queue()
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object())
monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo)
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda backend_id: object())
monkeypatch.setattr(agent_run_service, "AgentRepository", AgentRepo)
monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo)
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
monkeypatch.setattr(agent_run_service, "AgentRunRepository", RunRepo)
monkeypatch.setattr(agent_run_service, "get_arq_pool", fake_get_arq_pool)
result = await agent_run_service.create_agent_run_view(
query="hello",
agent_config_id=1,
agent_id="default",
thread_id="thread-1",
meta={"request_id": "req-1"},
image_content=None,
@ -176,55 +210,83 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke
db=db,
)
assert db.order == ["commit", "enqueue"]
assert result["run_id"] == "run-1"
assert db.order[-2:] == ["commit", "enqueue"]
assert result["run_id"] == created_run.id
assert result["request_id"] == "req-1"
assert db.added[0].run_id == created_run.id
assert db.added[0].request_id == "req-1"
@pytest.mark.asyncio
async def test_create_agent_run_handles_integrity_error_with_same_user_existing(monkeypatch: pytest.MonkeyPatch):
async def test_create_resume_run_marks_input_message_source(monkeypatch: pytest.MonkeyPatch):
class FakeResult:
def scalar_one_or_none(self):
return SimpleNamespace(uid="user-1", role="user")
class FakeDB:
def __init__(self):
self.commit_called = 0
self.rollback_called = 0
self.order: list[str] = []
self.committed = False
self.added = []
async def execute(self, stmt):
del stmt
return FakeResult()
def add(self, item):
self.added.append(item)
async def flush(self):
self.order.append("flush")
for item in self.added:
if getattr(item, "id", None) is None:
item.id = 11
async def commit(self):
self.commit_called += 1
raise IntegrityError("insert", {"request_id": "req-1"}, Exception("duplicate"))
self.order.append("commit")
self.committed = True
async def rollback(self):
self.rollback_called += 1
raise AssertionError("rollback should not be called")
db = FakeDB()
existing_run = SimpleNamespace(
id="run-existing",
created_run = SimpleNamespace(
id="",
thread_id="thread-1",
status="running",
request_id="req-1",
status="pending",
request_id="resume-req",
uid="user-1",
)
state = {"lookup_count": 0}
class Repo:
class RunRepo:
def __init__(self, db_session):
self.db = db_session
async def get_run_for_user(self, run_id: str, uid: str):
assert run_id == "parent-run"
assert uid == "user-1"
return SimpleNamespace(id=run_id, thread_id="thread-1", status="interrupted")
async def get_resume_run(self, parent_run_id: str, resume_request_id: str):
assert parent_run_id == "parent-run"
assert resume_request_id == "resume-req"
return None
async def get_run_by_request_id(self, request_id: str):
del request_id
state["lookup_count"] += 1
if state["lookup_count"] == 1:
return None
return existing_run
assert request_id == "resume-req"
return None
async def create_run(self, **kwargs):
del kwargs
return SimpleNamespace(
id="run-new",
thread_id="thread-1",
status="pending",
request_id="req-1",
uid="user-1",
)
assert kwargs["run_type"] == "resume"
assert kwargs["parent_run_id"] == "parent-run"
assert kwargs["resume_request_id"] == "resume-req"
created_run.id = kwargs["run_id"]
return created_run
async def set_input_message(self, run_id: str, message_id: int):
assert run_id == created_run.id
assert message_id == 11
return created_run
class ConvRepo:
def __init__(self, db_session):
@ -232,106 +294,45 @@ async def test_create_agent_run_handles_integrity_error_with_same_user_existing(
async def get_conversation_by_thread_id(self, thread_id: str):
del thread_id
return SimpleNamespace(
uid="user-1",
status="active",
department_id=1,
extra_metadata={"agent_config_id": 1},
)
return SimpleNamespace(id=1, uid="user-1", status="active", agent_id="default")
class AgentRepo:
def __init__(self, db_session):
self.db = db_session
async def get_visible_by_slug(self, slug: str, user):
del user
return SimpleNamespace(slug=slug, backend_id="ChatbotAgent")
class Queue:
async def enqueue_job(self, job_name: str, run_id: str, _job_id: str):
assert job_name == "process_agent_run"
assert run_id == created_run.id
assert _job_id == f"run:{created_run.id}"
assert db.committed is True
async def fake_get_arq_pool():
raise AssertionError("should not enqueue on integrity fallback")
return Queue()
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object())
monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo)
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda backend_id: object())
monkeypatch.setattr(agent_run_service, "AgentRepository", AgentRepo)
monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo)
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
monkeypatch.setattr(agent_run_service, "AgentRunRepository", RunRepo)
monkeypatch.setattr(agent_run_service, "get_arq_pool", fake_get_arq_pool)
result = await agent_run_service.create_agent_run_view(
query="hello",
agent_config_id=1,
query=None,
agent_id="default",
thread_id="thread-1",
meta={"request_id": "req-1"},
meta={"request_id": "resume-req"},
image_content=None,
current_uid="user-1",
db=db,
resume={"language": "python"},
parent_run_id="parent-run",
resume_request_id="resume-req",
)
assert db.commit_called == 1
assert db.rollback_called == 1
assert result["run_id"] == "run-existing"
assert result["status"] == "running"
@pytest.mark.asyncio
async def test_create_agent_run_integrity_error_returns_409_for_other_user(monkeypatch: pytest.MonkeyPatch):
class FakeDB:
async def commit(self):
raise IntegrityError("insert", {"request_id": "req-1"}, Exception("duplicate"))
async def rollback(self):
return None
db = FakeDB()
existing_run = SimpleNamespace(
id="run-existing",
thread_id="thread-1",
status="pending",
request_id="req-1",
uid="user-2",
)
state = {"lookup_count": 0}
class Repo:
def __init__(self, db_session):
self.db = db_session
async def get_run_by_request_id(self, request_id: str):
del request_id
state["lookup_count"] += 1
if state["lookup_count"] == 1:
return None
return existing_run
async def create_run(self, **kwargs):
del kwargs
return SimpleNamespace(
id="run-new",
thread_id="thread-1",
status="pending",
request_id="req-1",
uid="user-1",
)
class ConvRepo:
def __init__(self, db_session):
self.db = db_session
async def get_conversation_by_thread_id(self, thread_id: str):
del thread_id
return SimpleNamespace(
uid="user-1",
status="active",
department_id=1,
extra_metadata={"agent_config_id": 1},
)
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object())
monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo)
monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo)
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
with pytest.raises(agent_run_service.HTTPException) as exc:
await agent_run_service.create_agent_run_view(
query="hello",
agent_config_id=1,
thread_id="thread-1",
meta={"request_id": "req-1"},
image_content=None,
current_uid="user-1",
db=db,
)
assert exc.value.status_code == 409
assert exc.value.detail == "request_id 冲突"
assert result["run_id"] == created_run.id
assert db.added[0].message_type == "resume"
assert db.added[0].extra_metadata["source"] == "ask_user_question_resume"

View File

@ -1,7 +1,11 @@
"""测试 chat_service 中的 interrupt 相关函数"""
import json
import sys
import os
from types import SimpleNamespace
import pytest
sys.path.insert(0, os.getcwd())
@ -9,6 +13,7 @@ from yuxi.services.chat_service import (
_normalize_interrupt_questions,
_build_ask_user_question_payload,
_coerce_interrupt_payload,
stream_agent_resume,
)
from yuxi.utils.question_utils import normalize_options
@ -173,6 +178,24 @@ class TestNormalizeInterruptQuestions:
assert result[0]["question"] == "有效问题"
@pytest.mark.asyncio
async def test_stream_agent_resume_init_does_not_render_resume_input():
stream = stream_agent_resume(
thread_id="thread-1",
resume_input={"language": "python"},
meta={"request_id": "req-1"},
current_user=SimpleNamespace(uid="user-1"),
db=object(),
)
first_chunk = json.loads((await stream.__anext__()).decode("utf-8"))
await stream.aclose()
assert first_chunk["status"] == "init"
assert "msg" not in first_chunk
assert "Resume with input" not in json.dumps(first_chunk, ensure_ascii=False)
class TestCoerceInterruptPayload:
"""测试 _coerce_interrupt_payload 函数"""

View File

@ -103,7 +103,9 @@ async def test_run_stream_event_roundtrip(monkeypatch: pytest.MonkeyPatch):
events = await run_queue_service.list_run_stream_events(run_id, after_seq="0-0", limit=100)
assert [item["event_type"] for item in events] == ["loading", "finished"]
assert events[0]["payload"] == {"items": [1]}
assert events[0]["payload"]["schema_version"] == 1
assert events[0]["payload"]["run_id"] == run_id
assert events[0]["payload"]["payload"] == {"items": [1]}
next_events = await run_queue_service.list_run_stream_events(run_id, after_seq=seq1, limit=100)
assert len(next_events) == 1

View File

@ -90,8 +90,8 @@ async def test_process_agent_run_non_retryable_error_marks_failed(monkeypatch: p
terminal_statuses: list[str] = []
events: list[str] = []
async def fake_append_event(run_id: str, event_type: str, payload: dict):
del run_id, payload
async def fake_append_event(run_id: str, event_type: str, payload: dict, **kwargs):
del run_id, payload, kwargs
events.append(event_type)
async def fake_mark_terminal(run_id: str, status: str, error_type=None, error_message=None):
@ -121,8 +121,8 @@ async def test_process_agent_run_retryable_error_retries_then_completes(monkeypa
events: list[dict] = []
attempts = {"count": 0}
async def fake_append_event(run_id: str, event_type: str, payload: dict):
del run_id
async def fake_append_event(run_id: str, event_type: str, payload: dict, **kwargs):
del run_id, kwargs
events.append({"event_type": event_type, "payload": payload})
async def fake_mark_terminal(run_id: str, status: str, error_type=None, error_message=None):
@ -169,10 +169,20 @@ async def test_worker_startup_ensures_builtin_mcp_servers(monkeypatch: pytest.Mo
async def fake_ensure_builtin_mcp_servers_in_db():
calls.append("ensure_builtin_mcp_servers_in_db")
@asynccontextmanager
async def fake_session_ctx():
yield object()
async def fake_init_builtin_skills(session):
del session
calls.append("init_builtin_skills")
monkeypatch.setattr(run_worker.pg_manager, "initialize", fake_initialize)
monkeypatch.setattr(run_worker.pg_manager, "create_business_tables", fake_create_business_tables)
monkeypatch.setattr(run_worker.pg_manager, "ensure_business_schema", fake_ensure_business_schema)
monkeypatch.setattr(run_worker.pg_manager, "get_async_session_context", fake_session_ctx)
monkeypatch.setattr(run_worker, "ensure_builtin_mcp_servers_in_db", fake_ensure_builtin_mcp_servers_in_db)
monkeypatch.setattr(run_worker, "init_builtin_skills", fake_init_builtin_skills)
await run_worker._worker_startup({})
@ -181,4 +191,5 @@ async def test_worker_startup_ensures_builtin_mcp_servers(monkeypatch: pytest.Mo
"create_business_tables",
"ensure_business_schema",
"ensure_builtin_mcp_servers_in_db",
"init_builtin_skills",
]

View File

@ -66,6 +66,7 @@
- 收敛用户身份命名:原业务登录标识统一改为 `uid`Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid``user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。
- 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。
- 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。
---

View File

@ -1,159 +0,0 @@
# Notion Zotero Knowledge Base Reader
This folder contains a small Python CLI for reading the shared Notion `Zotero`
knowledge base through the public Notion API.
## Verified configuration
- Connection token: provide through `NOTION_TOKEN`; do not hard-code it.
- Required capability: read content.
- Required sharing: the parent database must be shared with the connection.
- Notion API version used by the script: `2026-03-11`.
- Data source found: `Zotero`
- Data source id: `1a958b0f-ad1f-4f78-a082-cbdc66a3cd23`
## Usage
```bash
export NOTION_TOKEN="ntn_..."
```
Search shared pages/data sources by title:
```bash
./notion_kb_reader.py search zotero --type data_source --limit 5
./notion_kb_reader.py search "chain of thought" --type page --limit 10
```
Query the Zotero data source and locally filter page properties:
```bash
./notion_kb_reader.py query \
--data-source-id 1a958b0f-ad1f-4f78-a082-cbdc66a3cd23 \
"reasoning" \
--limit 10
```
Read a page into Markdown-like text:
```bash
./notion_kb_reader.py read 12fee1d7-d69e-813b-81c8-f150b3d324af --output sample_page.md
```
Find text inside one page after fetching properties and block content:
```bash
./notion_kb_reader.py find 12fee1d7-d69e-813b-81c8-f150b3d324af reasoning
```
Search or query, select the first matched page, then read it:
```bash
./notion_kb_reader.py demo \
--data-source-id 1a958b0f-ad1f-4f78-a082-cbdc66a3cd23 \
"reasoning" \
--output first_match.md
```
```python
#!/usr/bin/env python3
"""Search and read a Notion knowledge base via the public API.
Set NOTION_TOKEN before running:
export NOTION_TOKEN="ntn_..."
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import textwrap
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
API_BASE = "https://api.notion.com/v1"
NOTION_VERSION = os.environ.get("NOTION_VERSION", "2026-03-11")
class NotionAPIError(RuntimeError):
pass
class NotionClient:
def __init__(self, token: str) -> None:
self.token = token
def request(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
retries: int = 4,
) -> dict[str, Any]:
url = f"{API_BASE}{path}"
if query:
url += "?" + urllib.parse.urlencode(
{k: v for k, v in query.items() if v is not None}
)
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"Authorization": f"Bearer {self.token}",
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
"User-Agent": "notion-kb-reader/1.0",
}
for attempt in range(retries + 1):
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=45) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
retry_after = exc.headers.get("Retry-After")
if exc.code in {429, 500, 502, 503, 504} and attempt < retries:
sleep_s = float(retry_after or min(2**attempt, 8))
time.sleep(sleep_s)
continue
raise NotionAPIError(f"HTTP {exc.code} {exc.reason}: {detail}") from exc
except urllib.error.URLError as exc:
if attempt < retries:
time.sleep(min(2**attempt, 8))
continue
raise NotionAPIError(str(exc)) from exc
raise NotionAPIError("request failed after retries")
def require_token() -> str:
token = os.environ.get("NOTION_TOKEN") or os.environ.get("NOTION_API_KEY")
if not token:
raise SystemExit(
"Missing token. Run: export NOTION_TOKEN='ntn_...'\n"
"Tip: do not hard-code the token in this script."
)
return token
def paginate(
client: NotionClient,
method: str,
path: str,
body: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
limit: int | None = None,
) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
cursor: str | None = None
while True:
request_body = dict(body or {})
request_query = dict(query or {})
```

View File

@ -1,10 +1,4 @@
import {
apiGet,
apiPost,
apiDelete,
apiPut,
apiRequest
} from './base'
import { apiGet, apiPost, apiDelete, apiPut, apiRequest } from './base'
import { useUserStore } from '@/stores/user'
/**
@ -18,30 +12,6 @@ import { useUserStore } from '@/stores/user'
// =============================================================================
export const agentApi = {
/**
* 发送聊天消息到指定智能体流式响应
* @param {Object} data - 聊天数据
* @returns {Promise} - 聊天响应流
*/
sendAgentMessage: (data, options = {}) => {
const { signal, headers: extraHeaders, ...restOptions } = options || {}
const baseHeaders = {
'Content-Type': 'application/json',
...useUserStore().getAuthHeaders()
}
return fetch('/api/agent/chat', {
method: 'POST',
body: JSON.stringify(data),
signal,
headers: {
...baseHeaders,
...(extraHeaders || {})
},
...restOptions
})
},
/**
* 简单聊天调用非流式
* @param {string} query - 查询内容
@ -111,39 +81,12 @@ export const agentApi = {
*/
getMessageFeedback: (messageId) => apiGet(`/api/chat/message/${messageId}/feedback`),
createAgent: (payload) => apiPost('/api/agent', payload),
updateAgent: (agentId, payload) => apiPut(`/api/agent/${agentId}`, payload),
deleteAgent: (agentId) => apiDelete(`/api/agent/${agentId}`),
/**
* 恢复被人工审批中断的对话流式响应
* @param {string} threadId - 会话 ID
* @param {Object} data - 恢复数据 { answer: { question_id: answer }, approved }
* @param {Object} options - 可选参数signal, headers等
* @returns {Promise} - 恢复响应流
*/
resumeAgentChat: (threadId, data, options = {}) => {
const { signal, headers: extraHeaders, ...restOptions } = options || {}
const baseHeaders = {
'Content-Type': 'application/json',
...useUserStore().getAuthHeaders()
}
return fetch(`/api/chat/thread/${threadId}/resume`, {
method: 'POST',
body: JSON.stringify(data),
signal,
headers: {
...baseHeaders,
...(extraHeaders || {})
},
...restOptions
})
},
/**
* 创建异步运行任务Run
* @param {Object} data - run 请求体
@ -155,7 +98,10 @@ export const agentApi = {
agent_id: data.agent_id,
thread_id: data.thread_id,
meta: data.meta || {},
image_content: data.image_content || null
image_content: data.image_content || null,
resume: data.resume ?? null,
parent_run_id: data.parent_run_id || null,
resume_request_id: data.resume_request_id || null
}),
/**
@ -188,16 +134,18 @@ export const agentApi = {
*/
streamAgentRunEvents: (runId, afterSeq = '0-0', options = {}) => {
const { signal } = options
return fetch(
`/api/agent/runs/${runId}/events?after_seq=${encodeURIComponent(String(afterSeq))}`,
{
method: 'GET',
headers: {
...useUserStore().getAuthHeaders()
},
signal
}
)
const headers = {
...useUserStore().getAuthHeaders()
}
const cursor = String(afterSeq || '0-0')
if (cursor && cursor !== '0-0') {
headers['Last-Event-ID'] = cursor
}
return fetch(`/api/agent/runs/${runId}/events`, {
method: 'GET',
headers,
signal
})
}
}

View File

@ -234,8 +234,6 @@ const { threads, currentThreadId, currentThread } = storeToRefs(chatThreadsStore
const userInput = ref('')
const sendCooldownActive = ref(false)
let sendCooldownTimer = null
const useRunsApi = import.meta.env.VITE_USE_RUNS_API === 'true'
//
const greetingMessages = [
'👋 您好,有什么可以帮您?',
@ -1085,13 +1083,12 @@ const handleAttachmentRemove = async (attachment) => {
}
// ==================== ====================
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
const { approvalState, processApprovalInStream } = useApproval({
getThreadState,
resetOnGoingConv,
fetchThreadMessages
})
const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
const { handleStreamChunk } = useAgentStreamHandler({
getThreadState,
processApprovalInStream,
currentAgentId,
@ -1100,10 +1097,8 @@ const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
})
const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = useAgentRunStream({
getThreadState,
useRunsApi,
currentAgentId,
handleStreamChunk,
processApprovalInStream,
fetchThreadMessages,
fetchAgentState,
resetOnGoingConv,
@ -1111,45 +1106,6 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } =
streamSmoother
})
//
const sendMessage = async ({
agentId,
threadId,
text,
signal = undefined,
imageData = undefined,
requestId = '',
attachmentFileIds = []
}) => {
if (!agentId || !threadId || !text) {
const error = new Error('Missing agent, thread, or message text')
handleChatError(error, 'send')
return Promise.reject(error)
}
const requestData = {
query: text,
agent_id: agentId,
thread_id: threadId,
meta: {
request_id: requestId,
attachment_file_ids: attachmentFileIds
}
}
//
if (imageData && imageData.imageContent) {
requestData.image_content = imageData.imageContent
}
try {
return await agentApi.sendAgentMessage(requestData, signal ? { signal } : undefined)
} catch (error) {
handleChatError(error, 'send')
throw error
}
}
// ==================== CHAT ACTIONS ====================
//
const getFirstNonPinnedChat = (chatList) => {
@ -1289,72 +1245,6 @@ const handleSendMessage = async ({ image } = {}) => {
.map((attachment) => attachment.file_id)
.filter(Boolean)
if (useRunsApi) {
if ((threadMessages.value[threadId] || []).length === 0) {
const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000)
if (autoTitle) {
void (async () => {
try {
const generatedTitle = await agentApi.generateTitle(
autoTitle,
configStore.config?.fast_model
)
if (generatedTitle) {
const finalTitle = generatedTitle.slice(0, 30).replace(/\s+/g, ' ').trim()
if (finalTitle) {
void chatThreadsStore.updateThread(threadId, finalTitle).catch(() => {})
}
}
} catch (e) {
console.error('Title generation failed:', e)
// 使
void chatThreadsStore.updateThread(threadId, autoTitle.slice(0, 30)).catch(() => {})
}
})()
}
}
resetOnGoingConv(threadId)
const requestId = createClientRequestId()
const previousAttachments = markAttachmentsRequestId(threadId, pendingAttachments, requestId)
insertOptimisticHumanMessage(threadState, {
requestId,
text,
imageContent,
attachments: pendingAttachments.map((attachment) => ({
...attachment,
request_id: requestId
}))
})
threadState.isStreaming = true
try {
const runResp = await agentApi.createAgentRun({
query: text,
agent_id: currentAgentId.value,
thread_id: threadId,
meta: {
request_id: requestId,
attachment_file_ids: pendingAttachmentFileIds
},
image_content: imageContent
})
const runId = runResp?.run_id
if (!runId) {
throw new Error('创建 run 失败:缺少 run_id')
}
await startRunStream(threadId, runId, 0)
} catch (error) {
threadState.isStreaming = false
threadState.replyLoadingVisible = false
threadState.pendingRequestId = null
rollbackAttachments(threadId, previousAttachments)
resetOnGoingConv(threadId)
handleChatError(error, 'send')
}
return
}
// fast-model
if ((threadMessages.value[threadId] || []).length === 0) {
const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000)
if (autoTitle) {
@ -1372,14 +1262,12 @@ const handleSendMessage = async ({ image } = {}) => {
}
} catch (e) {
console.error('Title generation failed:', e)
// 使
void chatThreadsStore.updateThread(threadId, autoTitle.slice(0, 30)).catch(() => {})
}
})()
}
}
threadState.isStreaming = true
resetOnGoingConv(threadId)
const requestId = createClientRequestId()
const previousAttachments = markAttachmentsRequestId(threadId, pendingAttachments, requestId)
@ -1387,40 +1275,36 @@ const handleSendMessage = async ({ image } = {}) => {
requestId,
text,
imageContent,
attachments: pendingAttachments.map((attachment) => ({ ...attachment, request_id: requestId }))
attachments: pendingAttachments.map((attachment) => ({
...attachment,
request_id: requestId
}))
})
threadState.streamAbortController = new AbortController()
threadState.isStreaming = true
try {
const response = await sendMessage({
agentId: currentAgentId.value,
threadId: threadId,
text: text,
signal: threadState.streamAbortController?.signal,
imageData: image,
requestId,
attachmentFileIds: pendingAttachmentFileIds
const runResp = await agentApi.createAgentRun({
query: text,
agent_id: currentAgentId.value,
thread_id: threadId,
meta: {
request_id: requestId,
attachment_file_ids: pendingAttachmentFileIds
},
image_content: imageContent
})
await handleAgentResponse(response, threadId)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Stream error:', error)
rollbackAttachments(threadId, previousAttachments)
handleChatError(error, 'send')
} else {
console.warn('[Interrupted] Catch')
const runId = runResp?.run_id
if (!runId) {
throw new Error('创建 run 失败:缺少 run_id')
}
await startRunStream(threadId, runId, 0)
} catch (error) {
threadState.isStreaming = false
} finally {
threadState.streamAbortController = null
//
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(() => {
//
resetOnGoingConv(threadId)
handleAgentStateRefresh(threadId)
scrollController.scrollToBottom()
})
threadState.replyLoadingVisible = false
threadState.pendingRequestId = null
rollbackAttachments(threadId, previousAttachments)
resetOnGoingConv(threadId)
handleChatError(error, 'send')
}
}
@ -1432,32 +1316,14 @@ const handleSendOrStop = async (payload) => {
const threadId = currentChatId.value
const threadState = getThreadState(threadId)
if (isProcessing.value && threadState) {
if (useRunsApi && threadState.activeRunId) {
try {
await agentApi.cancelAgentRun(threadState.activeRunId)
message.info('已发送取消请求')
} catch (error) {
handleChatError(error, 'stop')
}
return
}
if (threadState.streamAbortController) {
//
threadState.streamAbortController.abort()
//
try {
await fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId, delay: 500 })
fetchAgentState(currentAgentId.value, threadId)
message.info('已中断对话生成')
} catch (error) {
console.error('刷新消息历史失败:', error)
message.info('已中断对话生成')
}
return
if (isProcessing.value && threadState?.activeRunId) {
try {
await agentApi.cancelAgentRun(threadState.activeRunId)
message.info('已发送取消请求')
} catch (error) {
handleChatError(error, 'stop')
}
return
}
if (props.sendDisabled) return
await handleSendMessage(payload)
@ -1479,30 +1345,35 @@ const handleApprovalWithStream = async (answer) => {
return
}
if (!approvalState.parentRunId) {
message.error('无法找到需要恢复的运行任务')
approvalState.showModal = false
return
}
try {
// 使 composable
const response = await handleApproval(answer)
if (!response) return // handleApproval
//
await handleAgentResponse(response, threadId)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Resume approval error:', error)
}
} finally {
if (threadState) {
threadState.isStreaming = false
threadState.streamAbortController = null
}
//
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(() => {
resetOnGoingConv(threadId)
fetchAgentState(currentAgentId.value, threadId)
scrollController.scrollToBottom()
approvalState.showModal = false
threadState.isStreaming = true
resetOnGoingConv(threadId)
const resumeRequestId = createClientRequestId()
const runResp = await agentApi.createAgentRun({
query: null,
agent_id: currentAgentId.value,
thread_id: threadId,
meta: { request_id: resumeRequestId },
resume: answer,
parent_run_id: approvalState.parentRunId,
resume_request_id: resumeRequestId
})
const runId = runResp?.run_id
if (!runId) {
throw new Error('创建 resume run 失败:缺少 run_id')
}
await startRunStream(threadId, runId, '0-0')
} catch (error) {
threadState.isStreaming = false
threadState.replyLoadingVisible = false
handleChatError(error, 'resume')
}
}

View File

@ -792,14 +792,14 @@ watch(
align-items: center;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
background: var(--gray-25);
color: var(--gray-700);
overflow: hidden;
flex-shrink: 0;
&.active {
border-color: var(--main-200);
background: var(--main-20);
border-color: var(--main-600);
background: var(--gray-0);
color: var(--main-800);
}
}

View File

@ -150,7 +150,7 @@ const toggleToolCallsExpanded = () => {
<style lang="less" scoped>
.tool-calls-container {
width: 100%;
margin: 0;
margin: 10px 0;
padding: 0;
.tool-calls-summary {
@ -160,7 +160,7 @@ const toggleToolCallsExpanded = () => {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--gray-500);
color: var(--gray-700);
text-align: left;
cursor: pointer;
outline: none;
@ -171,18 +171,18 @@ const toggleToolCallsExpanded = () => {
background: transparent;
&:hover {
color: var(--gray-700);
color: var(--gray-800);
}
&.is-expanded {
color: var(--gray-700);
color: var(--gray-800);
margin-bottom: 4px;
}
.summary-leading {
display: inline-flex;
align-items: center;
color: var(--gray-600);
color: var(--gray-700);
flex-shrink: 0;
}
@ -196,17 +196,17 @@ const toggleToolCallsExpanded = () => {
}
.summary-title {
font-weight: 500;
font-weight: 400;
white-space: nowrap;
}
.summary-separator {
color: var(--gray-300);
color: var(--gray-500);
flex-shrink: 0;
}
.summary-meta {
color: var(--gray-500);
color: var(--gray-600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -217,7 +217,7 @@ const toggleToolCallsExpanded = () => {
font-size: 11px;
padding: 0px 4px;
background: var(--gray-25);
color: var(--gray-500);
color: var(--gray-600);
border-radius: 4px;
white-space: nowrap;
font-weight: normal;
@ -226,7 +226,7 @@ const toggleToolCallsExpanded = () => {
.summary-trailing {
display: inline-flex;
align-items: center;
color: var(--gray-300);
color: var(--gray-500);
flex-shrink: 0;
}
}

View File

@ -48,8 +48,20 @@ const processRunSseResponse = async (response, onEvent) => {
const decoder = new TextDecoder()
let buffer = ''
let eventType = 'message'
let eventId = null
let dataLines = []
const dispatch = () => {
if (dataLines.length === 0) return
const dataText = dataLines.join('\n')
try {
const parsed = JSON.parse(dataText)
onEvent(eventType, parsed, eventId)
} catch (e) {
console.warn('Failed to parse run SSE data:', e, dataText)
}
}
try {
while (true) {
const { done, value } = await reader.read()
@ -61,37 +73,27 @@ const processRunSseResponse = async (response, onEvent) => {
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '')
if (!line) {
if (dataLines.length > 0) {
const dataText = dataLines.join('\n')
try {
const parsed = JSON.parse(dataText)
onEvent(eventType, parsed)
} catch (e) {
console.warn('Failed to parse run SSE data:', e, dataText)
}
}
dispatch()
eventType = 'message'
eventId = null
dataLines = []
continue
}
if (line.startsWith(':')) {
continue
}
if (line.startsWith('event:')) {
eventType = line.slice(6).trim() || 'message'
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim())
dataLines.push(line.slice(5).trimStart())
} else if (line.startsWith('id:')) {
eventId = line.slice(3).trim()
}
}
}
if (dataLines.length > 0) {
const dataText = dataLines.join('\n')
try {
const parsed = JSON.parse(dataText)
onEvent(eventType, parsed)
} catch (e) {
console.warn('Failed to parse trailing run SSE data:', e, dataText)
}
}
dispatch()
} finally {
try {
reader.releaseLock()
@ -103,10 +105,8 @@ const processRunSseResponse = async (response, onEvent) => {
export function useAgentRunStream({
getThreadState,
useRunsApi,
currentAgentId,
handleStreamChunk,
processApprovalInStream,
fetchThreadMessages,
fetchAgentState,
resetOnGoingConv,
@ -152,7 +152,7 @@ export function useAgentRunStream({
}
const startRunStream = async (threadId, runId, afterSeq = '0-0') => {
if (!threadId || !runId || !useRunsApi) return
if (!threadId || !runId) return
const ts = getThreadState(threadId)
if (!ts) return
@ -173,20 +173,20 @@ export function useAgentRunStream({
throw new Error(`SSE response not ok: ${response.status}`)
}
await processRunSseResponse(response, (event, data) => {
await processRunSseResponse(response, (event, data, eventId) => {
if (!data || ts.activeRunId !== runId) return
if (data.seq !== undefined && data.seq !== null) {
const incomingSeq = normalizeRunSeq(data.seq)
if (eventId) {
const incomingSeq = normalizeRunSeq(eventId)
if (compareRunSeq(incomingSeq, ts.runLastSeq) <= 0) return
ts.runLastSeq = incomingSeq
saveActiveRunSnapshot(threadId, runId, incomingSeq)
}
if (event === 'heartbeat') return
const payload = data.payload || {}
const isRetryableError = event === 'error' && payload?.chunk?.retryable === true
const terminalStatus = event === 'end' ? payload.status : data.status
const isRetryableError =
event === 'error' && (payload?.retryable === true || payload?.chunk?.retryable === true)
if (isRetryableError) {
const parsedJobTry = Number.parseInt(payload?.chunk?.job_try, 10)
const retryJobTry = Number.isNaN(parsedJobTry) ? null : parsedJobTry
@ -205,25 +205,19 @@ export function useAgentRunStream({
if (Array.isArray(payload.items)) {
payload.items.forEach((chunk) => {
handleStreamChunk(chunk, threadId)
handleStreamChunk({ ...chunk, run_id: chunk.run_id || data.run_id || runId }, threadId)
})
} else if (payload.chunk) {
handleStreamChunk(payload.chunk, threadId)
handleStreamChunk(
{ ...payload.chunk, run_id: payload.chunk.run_id || data.run_id || runId },
threadId
)
}
const approvalStatuses = ['ask_user_question_required', 'human_approval_required']
const isApprovalEvent =
approvalStatuses.includes(event) || approvalStatuses.includes(payload?.chunk?.status)
if (isApprovalEvent) {
const approvalChunk = payload?.chunk || { status: event, thread_id: threadId }
processApprovalInStream(approvalChunk, threadId, unref(currentAgentId))
}
if (event === 'close') {
if (event === 'end') {
streamSmoother?.flushThread(threadId)
ts.isStreaming = false
if (RUN_TERMINAL_STATUSES.has(data.status)) {
if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
ts.activeRunId = null
ts.lastRetryableJobTry = null
ts.replyLoadingVisible = false
@ -231,26 +225,14 @@ export function useAgentRunStream({
clearActiveRunSnapshot(threadId)
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay: 200 }).finally(
() => {
resetOnGoingConv(threadId)
fetchAgentState(unref(currentAgentId), threadId)
}
)
} else if (ts.activeRunId === runId) {
setTimeout(() => {
if (ts.activeRunId === runId && !ts.runStreamAbortController) {
void startRunStream(threadId, runId, ts.runLastSeq)
}
}, 300)
}
}
const chunkStatus = payload?.chunk?.status
if (
event === 'finished' ||
event === 'error' ||
event === 'interrupted' ||
approvalStatuses.includes(event) ||
approvalStatuses.includes(chunkStatus)
) {
if (event === 'error') {
ts.isStreaming = false
ts.activeRunId = null
ts.lastRetryableJobTry = null
@ -295,7 +277,7 @@ export function useAgentRunStream({
}
const resumeActiveRunForThread = async (threadId) => {
if (!useRunsApi || !threadId) return
if (!threadId) return
const ts = getThreadState(threadId)
if (!ts || ts.runStreamAbortController) return

View File

@ -2,65 +2,6 @@ import { message } from 'ant-design-vue'
import { handleChatError } from '@/utils/errorHandler'
import { unref } from 'vue'
/**
* Process a streaming response from the server
* @param {Response} response - The fetch response object
* @param {Function} onChunk - Callback function for each parsed JSON chunk. Return true to stop processing.
*/
const processStreamResponse = async (response, onChunk) => {
if (!response || !response.body) {
console.warn('Invalid response or missing body for stream processing')
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let stopProcessing = false
try {
while (!stopProcessing) {
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) {
const trimmedLine = line.trim()
if (trimmedLine) {
try {
const chunk = JSON.parse(trimmedLine)
if (onChunk && onChunk(chunk)) {
stopProcessing = true
break
}
} catch (e) {
console.warn('Failed to parse stream chunk JSON:', e, 'Line:', trimmedLine)
}
}
}
}
if (!stopProcessing && buffer.trim()) {
try {
const chunk = JSON.parse(buffer.trim())
if (onChunk) {
onChunk(chunk)
}
} catch (e) {
console.warn('Failed to parse final stream chunk JSON:', e)
}
}
} finally {
try {
reader.releaseLock()
} catch {
// Ignore errors on releasing lock
}
}
}
export function useAgentStreamHandler({
getThreadState,
@ -88,6 +29,8 @@ export function useAgentStreamHandler({
const resolvedRequestId = request_id || threadState.pendingRequestId
if (resolvedRequestId) {
threadState.pendingRequestId = resolvedRequestId
}
if (resolvedRequestId && msg && msg.type !== 'system') {
threadState.onGoingConv.msgChunks[resolvedRequestId] = [
{
...msg,
@ -126,11 +69,6 @@ export function useAgentStreamHandler({
threadState.replyLoadingVisible = false
threadState.pendingRequestId = null
// Abort the stream controller to stop processing further events
if (threadState.streamAbortController) {
threadState.streamAbortController.abort()
threadState.streamAbortController = null
}
}
return true
@ -229,37 +167,7 @@ export function useAgentStreamHandler({
return false
}
/**
* Process the full agent stream response
* @param {Response} response - The fetch response
* @param {String} threadId - The thread ID
* @param {Function} [onChunk] - Optional callback for each chunk (e.g. for logging)
*/
const handleAgentResponse = async (response, threadId, onChunk = null) => {
console.log(`${debugPrefix}[stream_start]`, {
threadId,
currentAgentId: unref(currentAgentId),
supportsFiles: unref(supportsFiles)
})
await processStreamResponse(response, (chunk) => {
if (chunk?.status && chunk.status !== 'loading') {
console.log(`${debugPrefix}[chunk_status]`, {
threadId,
status: chunk.status,
requestId: chunk.request_id
})
}
if (onChunk) onChunk(chunk)
return handleStreamChunk(chunk, threadId)
})
console.log(`${debugPrefix}[stream_end]`, {
threadId,
currentAgentId: unref(currentAgentId)
})
}
return {
handleStreamChunk,
handleAgentResponse
handleStreamChunk
}
}

View File

@ -23,7 +23,6 @@ export function useAgentThreadState({
if (!chatState.threadStates[threadId]) {
chatState.threadStates[threadId] = {
isStreaming: false,
streamAbortController: null,
runStreamAbortController: null,
activeRunId: null,
runLastSeq: '0-0',
@ -39,17 +38,9 @@ export function useAgentThreadState({
const stopThreadStream = (threadId) => {
if (!threadId) return
const threadState = chatState.threadStates[threadId]
if (typeof onStopThread === 'function') {
onStopThread(threadId)
}
if (!threadState?.streamAbortController) return
threadState.streamAbortController.abort()
threadState.streamAbortController = null
threadState.isStreaming = false
resetThreadUiState(threadState)
}
const cleanupThreadState = (threadId) => {
@ -61,9 +52,6 @@ export function useAgentThreadState({
onBeforeCleanupThread(threadId)
}
if (threadState.streamAbortController) {
threadState.streamAbortController.abort()
}
if (threadState.runStreamAbortController) {
threadState.runStreamAbortController.abort()
}
@ -82,10 +70,6 @@ export function useAgentThreadState({
onBeforeResetThread(targetThreadId)
}
if (threadState.streamAbortController) {
threadState.streamAbortController.abort()
threadState.streamAbortController = null
}
if (threadState.runStreamAbortController) {
threadState.runStreamAbortController.abort()
threadState.runStreamAbortController = null

View File

@ -1,7 +1,4 @@
import { reactive } from 'vue'
import { message } from 'ant-design-vue'
import { handleChatError } from '@/utils/errorHandler'
import { agentApi } from '@/apis'
import { normalizeQuestions } from '@/utils/questionUtils'
const extractQuestionPayload = (chunk) => {
@ -16,103 +13,15 @@ const extractQuestionPayload = (chunk) => {
}
}
const parseApprovedDecision = (answer) => {
const parseFromValue = (value) => {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'approve' || normalized === 'approved' || normalized === 'true')
return true
if (normalized === 'reject' || normalized === 'rejected' || normalized === 'false')
return false
}
return null
}
const direct = parseFromValue(answer)
if (direct !== null) return direct
if (answer && typeof answer === 'object' && !Array.isArray(answer)) {
const values = Object.values(answer)
if (values.length === 1) {
return parseFromValue(values[0])
}
}
return null
}
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
export function useApproval({ getThreadState, fetchThreadMessages }) {
const approvalState = reactive({
showModal: false,
questions: [],
status: '',
threadId: null
threadId: null,
parentRunId: null
})
const handleApproval = async (answer) => {
const threadId = approvalState.threadId
if (!threadId) {
message.error('无效的提问请求')
approvalState.showModal = false
return
}
const threadState = getThreadState(threadId)
if (!threadState) {
message.error('无法找到对应的对话线程')
approvalState.showModal = false
return
}
approvalState.showModal = false
if (threadState.streamAbortController) {
threadState.streamAbortController.abort()
threadState.streamAbortController = null
}
threadState.isStreaming = true
resetOnGoingConv(threadId)
threadState.streamAbortController = new AbortController()
const requestBody = {
thread_id: threadId
}
if (approvalState.status === 'human_approval_required') {
const approved = parseApprovedDecision(answer)
if (approved !== null) {
requestBody.approved = approved
} else {
requestBody.answer = answer
}
} else {
requestBody.answer = answer
}
try {
const response = await agentApi.resumeAgentChat(threadId, requestBody, {
signal: threadState.streamAbortController?.signal
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`)
}
return response
} catch (error) {
if (error.name !== 'AbortError') {
handleChatError(error, 'resume')
message.error(`恢复对话失败: ${error.message || '未知错误'}`)
}
threadState.isStreaming = false
threadState.streamAbortController = null
throw error
}
}
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
if (
chunk.status !== 'ask_user_question_required' &&
@ -133,6 +42,7 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
approvalState.questions = payload.questions
approvalState.status = chunk.status || ''
approvalState.threadId = chunk.thread_id || threadId
approvalState.parentRunId = chunk.run_id || null
fetchThreadMessages({ agentId: currentAgentId, threadId })
@ -144,11 +54,11 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
approvalState.questions = []
approvalState.status = ''
approvalState.threadId = null
approvalState.parentRunId = null
}
return {
approvalState,
handleApproval,
processApprovalInStream,
resetApprovalState
}

View File

@ -92,6 +92,23 @@ const run = () => {
const idxB = chunks.findIndex((c) => c.content === 'B')
assert.equal(idxA < idxB, true)
const conversations = MessageProcessor.convertServerHistoryToMessages([
{ type: 'human', content: '请选择语言' },
{ type: 'ai', content: '请选择输出语言' },
{
type: 'human',
content: '{"language":"python"}',
extra_metadata: { source: 'ask_user_question_resume' }
},
{ type: 'ai', content: '这是 Python 版本' }
])
assert.equal(conversations.length, 1)
assert.equal(conversations[0].messages.length, 3)
assert.equal(conversations[0].messages.at(-1).content, '这是 Python 版本')
assert.equal(conversations[0].messages.at(-1).isLast, true)
assert.equal(conversations[0].status, 'finished')
console.log('messageProcessor extractKnowledgeChunksFromConversation: all assertions passed')
}

View File

@ -49,7 +49,11 @@ export class MessageProcessor {
static convertServerHistoryToMessages(serverHistory) {
// Filter out standalone 'tool' messages since tool results are already in AI messages' tool_calls
// Backend new storage: tool results are embedded in AI messages' tool_calls array with tool_call_result field
const filteredHistory = serverHistory.filter((item) => item.type !== 'tool')
const filteredHistory = serverHistory.filter(
(item) =>
item.type !== 'tool' &&
!(item.type === 'human' && item.extra_metadata?.source === 'ask_user_question_resume')
)
// 按照对话分组
const conversations = []