feat(agent): 支持运行恢复与中断续写
完善 AgentRun 的父子运行关系、恢复请求和前端流式状态处理,补充相关单元测试以覆盖中断后恢复场景。
This commit is contained in:
parent
54bf9b61a8
commit
acf150d47d
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB |
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"},
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 函数"""
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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 envelope,SSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 {})
|
||||
```
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
|
||||
@ -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 = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user