Merge branch 'pr/ELK-milu/314'

This commit is contained in:
Wenjie Zhang 2025-11-01 21:44:23 +08:00
commit 0b0dbe6a69
23 changed files with 1267 additions and 225 deletions

View File

@ -1 +1 @@
Read AGENTS.md See AGENTS.md

View File

@ -314,7 +314,10 @@ def upload(
directory: pathlib.Path = typer.Option( directory: pathlib.Path = typer.Option(
..., help="The directory containing files to upload.", exists=True, file_okay=False ..., help="The directory containing files to upload.", exists=True, file_okay=False
), ),
pattern: list[str] = typer.Option(["*.md"], help="The glob patterns for files to upload (e.g., '*.pdf', '**/*.txt'). Can be specified multiple times."), pattern: list[str] = typer.Option(
["*.md"],
help="The glob patterns for files to upload (e.g., '*.pdf', '**/*.txt'). Can be specified multiple times.",
),
base_url: str = typer.Option("http://127.0.0.1:5050/api", help="The base URL of the API server."), base_url: str = typer.Option("http://127.0.0.1:5050/api", help="The base URL of the API server."),
username: str = typer.Option(..., help="Admin username for login."), username: str = typer.Option(..., help="Admin username for login."),
password: str = typer.Option(..., help="Admin password for login."), password: str = typer.Option(..., help="Admin password for login."),
@ -354,7 +357,9 @@ def upload(
if not all_files: if not all_files:
patterns_str = "', '".join(pattern) patterns_str = "', '".join(pattern)
console.print(f"[bold yellow]No files found in '{directory}' matching patterns: '{patterns_str}'. Aborting.[/bold yellow]") console.print(
f"[bold yellow]No files found in '{directory}' matching patterns: '{patterns_str}'. Aborting.[/bold yellow]"
)
raise typer.Exit() raise typer.Exit()
# 过滤掉macos的隐藏文件 # 过滤掉macos的隐藏文件
@ -398,11 +403,13 @@ def upload(
# Split all files into batches # Split all files into batches
for batch_num in range(0, len(files_to_upload), batch_size): for batch_num in range(0, len(files_to_upload), batch_size):
batch_files = files_to_upload[batch_num:batch_num + batch_size] batch_files = files_to_upload[batch_num : batch_num + batch_size]
batch_start = batch_num + 1 batch_start = batch_num + 1
batch_end = min(batch_num + batch_size, len(files_to_upload)) batch_end = min(batch_num + batch_size, len(files_to_upload))
console.print(f"\n[bold yellow]=== Batch {batch_start}-{batch_end} of {len(files_to_upload)} ===[/bold yellow]") console.print(
f"\n[bold yellow]=== Batch {batch_start}-{batch_end} of {len(files_to_upload)} ===[/bold yellow]"
)
# Step 1: Upload this batch of files sequentially # Step 1: Upload this batch of files sequentially
console.print(f"[blue]Step 1: Uploading {len(batch_files)} files...[/blue]") console.print(f"[blue]Step 1: Uploading {len(batch_files)} files...[/blue]")
@ -420,7 +427,9 @@ def upload(
console=console, console=console,
transient=True, transient=True,
) as progress: ) as progress:
upload_task_id = progress.add_task(f"Uploading batch {batch_start}-{batch_end}...", total=len(batch_files), postfix="") upload_task_id = progress.add_task(
f"Uploading batch {batch_start}-{batch_end}...", total=len(batch_files), postfix=""
)
for file_path, file_hash in batch_files: for file_path, file_hash in batch_files:
server_file_path = await upload_single_file( server_file_path = await upload_single_file(
@ -458,7 +467,9 @@ def upload(
# Step 3: Wait for this batch to complete # Step 3: Wait for this batch to complete
if wait_for_completion and task_id: if wait_for_completion and task_id:
console.print(f"[cyan]Step 3: Waiting for batch {batch_start}-{batch_end} to complete...[/cyan]") console.print(
f"[cyan]Step 3: Waiting for batch {batch_start}-{batch_end} to complete...[/cyan]"
)
await wait_for_tasks_completion(client, base_url, [task_id], poll_interval) await wait_for_tasks_completion(client, base_url, [task_id], poll_interval)
console.print(f"[green]Batch {batch_start}-{batch_end} completed![/green]") console.print(f"[green]Batch {batch_start}-{batch_end} completed![/green]")
else: else:

View File

@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from langchain.messages import AIMessageChunk, HumanMessage from langchain.messages import AIMessageChunk, HumanMessage
from langgraph.types import Command
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -81,6 +82,200 @@ async def set_default_agent(request_data: dict = Body(...), current_user=Depends
# ============================================================================= # =============================================================================
async def _get_langgraph_messages(agent_instance, config_dict):
"""获取LangGraph中的消息"""
graph = await agent_instance.get_graph()
state = await graph.aget_state(config_dict)
if not state or not state.values:
logger.warning("No state found in LangGraph")
return None
return state.values.get("messages", [])
def _get_existing_message_ids(conv_mgr, thread_id):
"""获取已保存的消息ID集合"""
existing_messages = conv_mgr.get_messages_by_thread_id(thread_id)
return {msg.extra_metadata["id"] for msg in existing_messages if msg.extra_metadata and "id" in msg.extra_metadata}
async def _save_ai_message(conv_mgr, thread_id, msg_dict):
"""保存AI消息和相关的工具调用"""
content = msg_dict.get("content", "")
tool_calls_data = msg_dict.get("tool_calls", [])
# 保存AI消息
ai_msg = conv_mgr.add_message_by_thread_id(
thread_id=thread_id,
role="assistant",
content=content,
message_type="text",
extra_metadata=msg_dict,
)
# 保存工具调用
if tool_calls_data:
logger.debug(f"Saving {len(tool_calls_data)} tool calls from AI message")
for tc in tool_calls_data:
conv_mgr.add_tool_call(
message_id=ai_msg.id,
tool_name=tc.get("name", "unknown"),
tool_input=tc.get("args", {}),
status="pending",
langgraph_tool_call_id=tc.get("id"),
)
logger.debug(f"Saved AI message {ai_msg.id} with {len(tool_calls_data)} tool calls")
def _save_tool_message(conv_mgr, msg_dict):
"""保存工具执行结果"""
tool_call_id = msg_dict.get("tool_call_id")
content = msg_dict.get("content", "")
name = msg_dict.get("name", "")
if not tool_call_id:
return
# 确保tool_output是字符串类型
if isinstance(content, list):
tool_output = json.dumps(content) if content else ""
else:
tool_output = str(content)
# 更新工具调用结果
updated_tc = conv_mgr.update_tool_call_output(
langgraph_tool_call_id=tool_call_id,
tool_output=tool_output,
status="success",
)
if updated_tc:
logger.debug(f"Updated tool_call {tool_call_id} ({name}) with output")
else:
logger.warning(f"Tool call {tool_call_id} not found for update")
async def save_messages_from_langgraph_state(
agent_instance,
thread_id,
conv_mgr,
config_dict,
):
"""
LangGraph state 中读取完整消息并保存到数据库
这样可以获得完整的 tool_calls 参数
"""
try:
messages = await _get_langgraph_messages(agent_instance, config_dict)
if messages is None:
return
logger.debug(f"Retrieved {len(messages)} messages from LangGraph state")
existing_ids = _get_existing_message_ids(conv_mgr, thread_id)
for msg in messages:
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
msg_type = msg_dict.get("type", "unknown")
if msg_type == "human" or msg.id in existing_ids:
continue
if msg_type == "ai":
await _save_ai_message(conv_mgr, thread_id, msg_dict)
elif msg_type == "tool":
_save_tool_message(conv_mgr, msg_dict)
else:
logger.warning(f"Unknown message type: {msg_type}, skipping")
continue
logger.debug(f"Processed message type={msg_type}")
logger.info("Saved messages from LangGraph state")
except Exception as e:
logger.error(f"Error saving messages from LangGraph state: {e}")
logger.error(traceback.format_exc())
async def check_and_handle_interrupts(agent, langgraph_config, make_chunk, meta, thread_id):
"""检查并处理 LangGraph 中断状态,发送人工审批请求到前端"""
try:
# 获取 agent 的 graph 对象
graph = await agent.get_graph()
# 获取当前状态,检查是否有中断
state = await graph.aget_state(langgraph_config)
if not state or not state.values:
logger.debug("No state found when checking for interrupts")
return
# 检查是否有中断信息
# LangGraph 中断信息通常在 state.tasks 或 __interrupt__ 字段中
interrupt_info = None
# 方法1: 检查 state.tasks 中的中断
if hasattr(state, "tasks") and state.tasks:
for task in state.tasks:
if hasattr(task, "interrupts") and task.interrupts:
interrupt_info = task.interrupts[0] # 取第一个中断
break
# 方法2: 检查 state.values 中的 __interrupt__ 字段
if not interrupt_info and state.values:
interrupt_data = state.values.get("__interrupt__")
if interrupt_data and isinstance(interrupt_data, list) and len(interrupt_data) > 0:
interrupt_info = interrupt_data[0]
# 方法3: 检查 state.next 字段,如果指向中断节点
if not interrupt_info and hasattr(state, "next") and state.next:
# 如果 next 指向某个需要审批的节点,可能需要额外处理
logger.debug(f"State next nodes: {state.next}")
if interrupt_info:
logger.info(f"Human approval interrupt detected: {interrupt_info}")
# 提取中断信息
question = "是否批准以下操作?"
operation = "需要人工审批的操作"
if isinstance(interrupt_info, dict):
question = interrupt_info.get("question", question)
operation = interrupt_info.get("operation", operation)
elif isinstance(interrupt_info, (list, tuple)) and len(interrupt_info) > 0:
# 有些情况下中断信息可能是元组形式
first_interrupt = interrupt_info[0]
if isinstance(first_interrupt, dict):
question = first_interrupt.get("question", question)
operation = first_interrupt.get("operation", operation)
else:
operation = str(first_interrupt)
else:
operation = str(interrupt_info)
# 发送人工审批请求到前端
logger.info(f"Sending human approval request - question: {question}, operation: {operation}")
yield make_chunk(
status="human_approval_required",
thread_id=thread_id,
interrupt_info={"question": question, "operation": operation},
)
else:
logger.debug("No human approval interrupt detected")
except Exception as e:
logger.error(f"Error checking for interrupts: {e}")
logger.error(traceback.format_exc())
# 不抛出异常,避免影响主流程
# =============================================================================
@chat.post("/call") @chat.post("/call")
async def call(query: str = Body(...), meta: dict = Body(None), current_user: User = Depends(get_required_user)): async def call(query: str = Body(...), meta: dict = Body(None), current_user: User = Depends(get_required_user)):
"""调用模型进行简单问答(需要登录)""" """调用模型进行简单问答(需要登录)"""
@ -147,114 +342,6 @@ async def chat_agent(
+ b"\n" + b"\n"
) )
async def save_messages_from_langgraph_state(
agent_instance,
thread_id,
conv_mgr,
config_dict,
):
"""
LangGraph state 中读取完整消息并保存到数据库
这样可以获得完整的 tool_calls 参数
"""
try:
graph = await agent_instance.get_graph()
state = await graph.aget_state(config_dict)
if not state or not state.values:
logger.warning("No state found in LangGraph")
return
messages = state.values.get("messages", [])
logger.debug(f"Retrieved {len(messages)} messages from LangGraph state")
# 获取已保存的消息数量,避免重复保存
existing_messages = conv_mgr.get_messages_by_thread_id(thread_id)
existing_ids = {
msg.extra_metadata["id"]
for msg in existing_messages
if msg.extra_metadata and "id" in msg.extra_metadata
}
for msg in messages:
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
msg_type = msg_dict.get("type", "unknown")
if msg_type == "human" or msg.id in existing_ids:
continue
elif msg_type == "ai":
# AI 消息
content = msg_dict.get("content", "")
tool_calls_data = msg_dict.get("tool_calls", [])
# 格式清洗
if finish_reason := msg_dict.get("response_metadata", {}).get("finish_reason"):
if "tool_call" in finish_reason and len(finish_reason) > len("tool_call"):
model_name = msg_dict.get("response_metadata", {}).get("model_name", "")
repeat_count = len(finish_reason) // len("tool_call")
msg_dict["response_metadata"]["finish_reason"] = "tool_call"
msg_dict["response_metadata"]["model_name"] = model_name[: len(model_name) // repeat_count]
# 保存 AI 消息
ai_msg = conv_mgr.add_message_by_thread_id(
thread_id=thread_id,
role="assistant",
content=content,
message_type="text",
extra_metadata=msg_dict, # 保存原始 model_dump
)
# 保存 tool_calls如果有- 使用 LangGraph 的 tool_call_id
if tool_calls_data:
logger.debug(f"Saving {len(tool_calls_data)} tool calls from AI message")
for tc in tool_calls_data:
conv_mgr.add_tool_call(
message_id=ai_msg.id,
tool_name=tc.get("name", "unknown"),
tool_input=tc.get("args", {}), # 完整的参数
status="pending", # 工具还未执行
langgraph_tool_call_id=tc.get("id"), # 保存 LangGraph tool_call_id
)
logger.debug(f"Saved AI message {ai_msg.id} with {len(tool_calls_data)} tool calls")
elif msg_type == "tool":
# 工具执行结果消息 - 使用 tool_call_id 精确匹配
tool_call_id = msg_dict.get("tool_call_id")
content = msg_dict.get("content", "")
name = msg_dict.get("name", "")
if tool_call_id:
# 确保tool_output是字符串类型避免SQLite不支持列表类型
if isinstance(content, list):
tool_output = json.dumps(content) if content else ""
else:
tool_output = str(content)
# 通过 LangGraph tool_call_id 精确匹配并更新
updated_tc = conv_mgr.update_tool_call_output(
langgraph_tool_call_id=tool_call_id,
tool_output=tool_output,
status="success",
)
if updated_tc:
logger.debug(f"Updated tool_call {tool_call_id} ({name}) with output")
else:
logger.warning(f"Tool call {tool_call_id} not found for update")
else:
logger.warning(f"Unknown message type: {msg_type}, skipping")
continue
logger.debug(f"Processed message type={msg_type}")
logger.info("Saved messages from LangGraph state")
except Exception as e:
logger.error(f"Error saving messages from LangGraph state: {e}")
logger.error(traceback.format_exc())
# TODO:[功能建议]针对需要人工审批后再执行的工具, # TODO:[功能建议]针对需要人工审批后再执行的工具,
# 可以使用langgraph的interrupt方法中断对话等待用户输入后再使用command跳转回去 # 可以使用langgraph的interrupt方法中断对话等待用户输入后再使用command跳转回去
async def stream_messages(): async def stream_messages():
@ -323,11 +410,17 @@ async def chat_agent(
yield make_chunk(message="检测到敏感内容,已中断输出", status="error") yield make_chunk(message="检测到敏感内容,已中断输出", status="error")
return return
# After streaming finished, check for interrupts and save messages
langgraph_config = {"configurable": input_context}
# Check for human approval interrupts
async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_chunk, meta, thread_id):
yield chunk
meta["time_cost"] = asyncio.get_event_loop().time() - start_time meta["time_cost"] = asyncio.get_event_loop().time() - start_time
yield make_chunk(status="finished", meta=meta) yield make_chunk(status="finished", meta=meta)
# After streaming finished, save all messages from LangGraph state # Save all messages from LangGraph state
langgraph_config = {"configurable": input_context}
await save_messages_from_langgraph_state( await save_messages_from_langgraph_state(
agent_instance=agent, agent_instance=agent,
thread_id=thread_id, thread_id=thread_id,
@ -336,8 +429,17 @@ async def chat_agent(
) )
except (asyncio.CancelledError, ConnectionError) as e: except (asyncio.CancelledError, ConnectionError) as e:
# 客户端主动中断连接,尝试保存已生成的部分内容 # 客户端主动中断连接,检查中断并保存已生成的部分内容
logger.warning(f"Client disconnected, cancelling stream: {e}") logger.warning(f"Client disconnected, cancelling stream: {e}")
# 即使在断开连接时也检查中断,确保状态一致性
langgraph_config = {"configurable": input_context}
try:
async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_chunk, meta, thread_id):
yield chunk
except Exception as interrupt_error:
logger.error(f"Error checking interrupts during disconnect: {interrupt_error}")
if full_msg: if full_msg:
# 创建新的 db session因为原 session 可能已关闭 # 创建新的 db session因为原 session 可能已关闭
new_db = db_manager.get_session() new_db = db_manager.get_session()
@ -360,6 +462,15 @@ async def chat_agent(
except Exception as e: except Exception as e:
logger.error(f"Error streaming messages: {e}, {traceback.format_exc()}") logger.error(f"Error streaming messages: {e}, {traceback.format_exc()}")
# 即使在异常情况下也检查中断,确保状态一致性
langgraph_config = {"configurable": input_context}
try:
async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_chunk, meta, thread_id):
yield chunk
except Exception as interrupt_error:
logger.error(f"Error checking interrupts during exception: {interrupt_error}")
if full_msg: if full_msg:
# 创建新的 db session因为原 session 可能已关闭 # 创建新的 db session因为原 session 可能已关闭
new_db = db_manager.get_session() new_db = db_manager.get_session()
@ -420,6 +531,91 @@ async def get_tools(agent_id: str, current_user: User = Depends(get_required_use
return {"tools": {tool["id"]: tool for tool in tools_info}} return {"tools": {tool["id"]: tool for tool in tools_info}}
@chat.post("/agent/{agent_id}/resume")
async def resume_agent_chat(
agent_id: str,
thread_id: str = Body(...),
approved: bool = Body(...),
current_user: User = Depends(get_required_user),
db: Session = Depends(get_db),
):
"""恢复被人工审批中断的对话(需要登录)"""
start_time = asyncio.get_event_loop().time()
logger.info(f"Resuming agent_id: {agent_id}, thread_id: {thread_id}, approved: {approved}")
meta = {
"agent_id": agent_id,
"thread_id": thread_id,
"user_id": current_user.id,
"approved": approved,
}
if "request_id" not in meta or not meta.get("request_id"):
meta["request_id"] = str(uuid.uuid4())
async def stream_resume():
# 定义resume专用的make_chunk函数与主聊天端点保持一致
def make_resume_chunk(content=None, **kwargs):
return (
json.dumps(
{"request_id": meta.get("request_id"), "response": content, **kwargs}, ensure_ascii=False
).encode("utf-8")
+ b"\n"
)
try:
agent = agent_manager.get_agent(agent_id)
except Exception as e:
logger.error(f"Error getting agent {agent_id}: {e}, {traceback.format_exc()}")
yield (
f'{{"request_id": "{meta.get("request_id")}", "message": '
f'"Error getting agent {agent_id}: {e}", "status": "error"}}\n'
)
return
# 发送init状态块与主聊天端点保持一致
init_msg = {"type": "system", "content": f"Resume with approved: {approved}"}
yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
# 使用 Command(resume=approved) 恢复执行
resume_command = Command(resume=approved)
graph = await agent.get_graph()
# 加载 context包含 tools, model 等配置)
input_context = {"user_id": str(current_user.id), "thread_id": thread_id}
context = agent.context_schema.from_file(module_name=agent.module_name, input_context=input_context)
logger.debug(f"Resume with context: {context}")
# 创建流式数据源
stream_source = graph.astream(
resume_command, context=context, config={"configurable": input_context}, stream_mode="messages"
)
async for msg, metadata in stream_source:
# 确保msg有正确的ID结构
msg_dict = msg.model_dump()
if "id" not in msg_dict:
msg_dict["id"] = str(uuid.uuid4())
yield make_resume_chunk(
content=getattr(msg, "content", ""), msg=msg_dict, metadata=metadata, status="loading"
)
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
yield make_resume_chunk(status="finished", meta=meta)
# 保存消息到数据库
langgraph_config = {"configurable": input_context}
conv_manager = ConversationManager(db)
await save_messages_from_langgraph_state(
agent_instance=agent,
thread_id=thread_id,
conv_mgr=conv_manager,
config_dict=langgraph_config,
)
return StreamingResponse(stream_resume(), media_type="application/json")
@chat.post("/agent/{agent_id}/config") @chat.post("/agent/{agent_id}/config")
async def save_agent_config(agent_id: str, config: dict = Body(...), current_user: User = Depends(get_required_user)): async def save_agent_config(agent_id: str, config: dict = Body(...), current_user: User = Depends(get_required_user)):
"""保存智能体配置到YAML文件需要登录""" """保存智能体配置到YAML文件需要登录"""

View File

@ -88,6 +88,7 @@ async def get_mcp_tools(server_name: str, additional_servers: dict[str, dict] =
logger.error(f"Failed to load tools from MCP server '{server_name}': {e}") logger.error(f"Failed to load tools from MCP server '{server_name}': {e}")
return [] return []
async def get_all_mcp_tools() -> list[Callable[..., Any]]: async def get_all_mcp_tools() -> list[Callable[..., Any]]:
"""Get all tools from all configured MCP servers.""" """Get all tools from all configured MCP servers."""
all_tools = [] all_tools = []

View File

@ -10,8 +10,9 @@ from src.agents.common.mcp import get_mcp_tools
from src.agents.common.models import load_chat_model from src.agents.common.models import load_chat_model
from src.utils import logger from src.utils import logger
from .state import BaseState
from .context import BaseContext from .context import BaseContext
from .state import BaseState
class ToolAgent(BaseAgent): class ToolAgent(BaseAgent):
name = "ToolAgent" name = "ToolAgent"
@ -24,7 +25,6 @@ class ToolAgent(BaseAgent):
self.context_schema = BaseContext self.context_schema = BaseContext
self.agent_tools = None self.agent_tools = None
# TODO:[修改建议] _get_invoke_tools,llm_call,dynamic_tools_node这类针对工具调用的功能大多数Agent都能用得到 # TODO:[修改建议] _get_invoke_tools,llm_call,dynamic_tools_node这类针对工具调用的功能大多数Agent都能用得到
# 可以通过一个ToolAgent类继承BaseAgent,通过重写抽象方法获取tools,通过继承BaseState和BaseContext获取配置 # 可以通过一个ToolAgent类继承BaseAgent,通过重写抽象方法获取tools,通过继承BaseState和BaseContext获取配置
# 必要时可通过重写以下方法实现其他逻辑 # 必要时可通过重写以下方法实现其他逻辑
@ -33,7 +33,6 @@ class ToolAgent(BaseAgent):
logger.error(f"get_tools() is not implemented in {self.__class__.__name__}") logger.error(f"get_tools() is not implemented in {self.__class__.__name__}")
return [] return []
async def _get_invoke_tools(self, selected_tools: list[str], selected_mcps: list[str]): async def _get_invoke_tools(self, selected_tools: list[str], selected_mcps: list[str]):
"""根据配置获取工具。 """根据配置获取工具。
默认不使用任何工具 默认不使用任何工具
@ -84,4 +83,4 @@ class ToolAgent(BaseAgent):
# Execute the tool node # Execute the tool node
result = await tool_node.ainvoke(state) result = await tool_node.ainvoke(state)
return cast(dict[str, list[ToolMessage]], result) return cast(dict[str, list[ToolMessage]], result)

View File

@ -54,6 +54,50 @@ def get_approved_user_goal(
return result return result
# TODO[修改建议]:前端需要通过interrupt进行交互点击是或否来批准执行
# 返回中断点:
# is_approved : bool = True 或者 False
# resume_command = Command(resume=is_approved)
# stream = graph.stream(resume_command, config=config, stream_mode="messages")
# graph.invoke(resume_command, config=config)
@tool(name_or_callable="人工审批工具", description="请求人工审批工具,用于在执行重要操作前获得人类确认。")
def get_approved_user_goal(
operation_description: str,
) -> dict:
"""
请求人工审批在执行重要操作前获得人类确认
Args:
operation_description: 需要审批的操作描述例如 "调用知识库工具"
Returns:
dict: 包含审批结果的字典格式为 {"approved": bool, "message": str}
"""
# 构建详细的中断信息
interrupt_info = {
"question": "是否批准以下操作?",
"operation": operation_description,
}
# 触发人工审批
is_approved = interrupt(interrupt_info)
# 返回审批结果
if is_approved:
result = {
"approved": True,
"message": f"✅ 操作已批准:{operation_description}",
}
print(f"✅ 人工审批通过: {operation_description}")
else:
result = {
"approved": False,
"message": f"❌ 操作被拒绝:{operation_description}",
}
print(f"❌ 人工审批被拒绝: {operation_description}")
return result
@tool(name_or_callable="查询知识图谱", description="使用这个工具可以查询知识图谱中包含的三元组信息。") @tool(name_or_callable="查询知识图谱", description="使用这个工具可以查询知识图谱中包含的三元组信息。")
def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]) -> Any: def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]) -> Any:
"""Use this to query knowledge graph, which include some food domain knowledge.""" """Use this to query knowledge graph, which include some food domain knowledge."""
@ -72,10 +116,7 @@ def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge
def get_static_tools() -> list: def get_static_tools() -> list:
"""注册静态工具""" """注册静态工具"""
static_tools = [ static_tools = [query_knowledge_graph, get_approved_user_goal]
query_knowledge_graph,
get_approved_user_goal
]
# 检查是否启用网页搜索 # 检查是否启用网页搜索
if config.enable_web_search: if config.enable_web_search:

View File

@ -0,0 +1,3 @@
from .graph import MiniAgent
__all__ = ["MiniAgent"]

View File

@ -0,0 +1,48 @@
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, dynamic_prompt, wrap_model_call
from src.agents.common.base import BaseAgent
from src.agents.common.models import load_chat_model
from src.agents.common.tools import get_buildin_tools
@dynamic_prompt
def context_aware_prompt(request: ModelRequest) -> str:
runtime = request.runtime
return runtime.context.system_prompt
@wrap_model_call
async def context_based_model(request: ModelRequest, handler) -> ModelResponse:
# 从 runtime context 读取配置
model_spec = request.runtime.context.model
model = load_chat_model(model_spec)
request = request.override(model=model)
return await handler(request)
class MiniAgent(BaseAgent):
name = "智能体 Demo"
description = "一个基于内置工具的智能体示例"
def __init__(self, **kwargs):
super().__init__(**kwargs)
def get_tools(self):
return get_buildin_tools()
async def get_graph(self, **kwargs):
if self.graph:
return self.graph
# 创建 MiniAgent
graph = create_agent(
model=load_chat_model("siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507"), # 实际会被覆盖
tools=self.get_tools(),
middleware=[context_aware_prompt, context_based_model],
checkpointer=await self._get_checkpointer(),
)
self.graph = graph
return graph

View File

@ -0,0 +1,3 @@
from .graph import SampleMultiAgent
__all__ = ["SampleMultiAgent"]

View File

@ -0,0 +1,25 @@
from dataclasses import dataclass, field
from typing import Annotated
from src.agents.common.context import BaseContext
from src.agents.common.mcp import MCP_SERVERS
from src.agents.common.tools import gen_tool_info
from .tools import get_tools
@dataclass(kw_only=True)
class Context(BaseContext):
tools: Annotated[list[dict], {"__template_metadata__": {"kind": "tools"}}] = field(
default_factory=list,
metadata={
"name": "工具",
"options": gen_tool_info(get_tools()), # 这里的选择是所有的工具
"description": "工具列表",
},
)
mcps: list[str] = field(
default_factory=list,
metadata={"name": "MCP服务器", "options": list(MCP_SERVERS.keys()), "description": "MCP服务器列表"},
)

View File

@ -0,0 +1,61 @@
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import tools_condition
from src.agents.common.toolagent import ToolAgent
from .context import Context
from .state import State
from .tools import get_tools
class SampleMultiAgent(ToolAgent):
name = "MultiAgent智能体"
description = "Supervisor智能体具有调用其他子智能体的能力(在工具中添加)"
# TODO[已完成]: 通过将其他agent封装为工具的方式添加了多智能体调度
"""
你是一个多智能体核心通过多智能体调用的方式帮助用户完成一系列任务
1.当你需要知识库问答功能时请调用对话聊天智能体实现
2.当你需要加密计算的时候请调用加密计算智能体实现
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.graph = None
self.checkpointer = None
self.context_schema = Context
self.agent_tools = None
def get_tools(self):
return get_tools()
async def get_graph(self, **kwargs):
"""构建图"""
if self.graph:
return self.graph
builder = StateGraph(State, context_schema=self.context_schema)
builder.add_node("chatbot", self.llm_call)
builder.add_node("tools", self.dynamic_tools_node)
builder.add_edge(START, "chatbot")
builder.add_conditional_edges(
"chatbot",
tools_condition,
)
builder.add_edge("tools", "chatbot")
builder.add_edge("chatbot", END)
self.checkpointer = await self._get_checkpointer()
graph = builder.compile(checkpointer=self.checkpointer, name=self.name)
self.graph = graph
return graph
def main():
pass
if __name__ == "__main__":
main()
# asyncio.run(main())

View File

@ -0,0 +1,22 @@
"""Define the state structures for the agent."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Annotated
from langchain.messages import AnyMessage
from langgraph.graph import add_messages
from src.agents.common.state import BaseState
@dataclass
class State(BaseState):
"""Defines the input state for the agent, representing a narrower interface to the outside world.
This class is used to define the initial state and structure of incoming data.
"""
messages: Annotated[Sequence[AnyMessage], add_messages] = field(default_factory=list)

View File

@ -0,0 +1,76 @@
from typing import Any
from langchain.tools import tool
from langchain_core.runnables import RunnableConfig
from src.agents import agent_manager
from src.agents.common.tools import get_buildin_tools
from src.utils import logger
# TODO[修改建议]:能不能通过前端直接指定子智能体?
# 调用子智能体后的日志是输出到tool_calls的
@tool(name_or_callable="对话聊天智能体", description="调用指定智能体进行对话聊天的功能")
async def call_chatbot(query: str, config: RunnableConfig) -> str:
"""
调用指定chatbot智能体进行对话聊天的功能
Args:
query: 根据需要构造的提问
config: LangGraph运行时配置(自动注入)
Returns:
str: 最终的回答结果
"""
try:
input = [{"role": "user", "content": query}]
chatbot = agent_manager.get_agent("ChatbotAgent")
configurable = config.get("configurable", {})
input_context = {
"thread_id": configurable.get("thread_id"),
"user_id": configurable.get("user_id"),
}
message = await chatbot.invoke_messages(input, input_context=input_context)
# 直接获取最后一个消息的内容
final_answer = message.get("messages", [])[-1].content
logger.info(f"ChatbotAgent: {final_answer}")
return final_answer
except Exception as e:
logger.error(f"CallAgent error: {e}")
raise
@tool(name_or_callable="加密计算智能体", description="调用指定智能体进行加密计算的功能")
async def call_react_agent(query: str, config: RunnableConfig) -> str:
"""
调用指定智能体进行加密计算的功能
Args:
query: 根据需要构造的提问
config: LangGraph运行时配置(自动注入)
Returns:
str: 最终的回答结果
"""
try:
input = [{"role": "user", "content": query}]
chatbot = agent_manager.get_agent("ReActAgent")
configurable = config.get("configurable", {})
input_context = {
"thread_id": configurable.get("thread_id"),
"user_id": configurable.get("user_id"),
}
message = await chatbot.invoke_messages(input, input_context=input_context)
# 直接获取最后一个消息的内容
final_answer = message.get("messages", [])[-1].content
logger.info(f"ReActAgent: {final_answer}")
return final_answer
except Exception as e:
logger.error(f"CallAgent error: {e}")
raise
def get_tools() -> list[Any]:
"""获取所有可运行的工具(给大模型使用)"""
tools = get_buildin_tools()
tools.append(call_chatbot)
tools.append(call_react_agent)
return tools

View File

@ -1,12 +1,13 @@
from langgraph.constants import START, END from langgraph.constants import END
from langgraph.graph import StateGraph from langgraph.graph import StateGraph
from src.agents.common.toolagent import ToolAgent from src.agents.common.toolagent import ToolAgent
from .state import State
from .context import Context from .context import Context
from .state import State
from .tools import get_tools from .tools import get_tools
def tools_branch_continue(state: State): def tools_branch_continue(state: State):
messages = state.messages messages = state.messages
last_message = messages[-1] last_message = messages[-1]
@ -18,8 +19,17 @@ def tools_branch_continue(state: State):
class ReActAgent(ToolAgent): class ReActAgent(ToolAgent):
name = "智能体 Demo" name = "ReActAgent"
description = "A react agent that can answer questions and help with tasks." description = "符合 ReAct 范式的智能体,可以通过调用工具来完成复杂任务。"
"""
提示词示例
你是一个智能体助手
你的所有需要计算的工作不能自己计算只能通过调用加密计算机工具进行,你只能通过调用该工具的方式进行计算才能得到正确答案计算顺序是正常的
例如 2+3+2你必须调用两次加密计算机工具进行运算最终结果为9
"""
# TODO:[已完成] React智能体 # TODO:[已完成] React智能体
''' '''

View File

@ -1,14 +1,12 @@
import os
from typing import Any from typing import Any
import requests
from langchain.tools import tool from langchain.tools import tool
from src.agents.common.toolkits.mysql import get_mysql_tools from src.agents.common.toolkits.mysql import get_mysql_tools
from src.agents.common.tools import get_buildin_tools from src.agents.common.tools import get_buildin_tools
from src.storage.minio import upload_image_to_minio
from src.utils import logger from src.utils import logger
@tool(name_or_callable="加密计算器", description="可以对给定的2个数字选择进行加减乘除四种加密计算") @tool(name_or_callable="加密计算器", description="可以对给定的2个数字选择进行加减乘除四种加密计算")
def calculator(a: float, b: float, operation: str) -> float: def calculator(a: float, b: float, operation: str) -> float:
""" """

View File

@ -1,12 +1,11 @@
import textwrap import textwrap
from pathlib import Path
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, dynamic_prompt, wrap_model_call from langchain.agents.middleware import ModelRequest, ModelResponse, dynamic_prompt, wrap_model_call
from src.agents.common.base import BaseAgent from src.agents.common.base import BaseAgent
from src.agents.common.models import load_chat_model
from src.agents.common.mcp import get_mcp_tools from src.agents.common.mcp import get_mcp_tools
from src.agents.common.models import load_chat_model
from src.agents.common.toolkits.mysql import get_mysql_tools from src.agents.common.toolkits.mysql import get_mysql_tools
from src.utils import logger from src.utils import logger
@ -17,6 +16,7 @@ _mcp_servers = {
}, },
} }
@dynamic_prompt @dynamic_prompt
def context_aware_prompt(request: ModelRequest) -> str: def context_aware_prompt(request: ModelRequest) -> str:
user_prompt = request.runtime.context.system_prompt user_prompt = request.runtime.context.system_prompt
@ -64,4 +64,4 @@ class SqlReporterAgent(BaseAgent):
self.graph = graph self.graph = graph
logger.info("SqlReporterAgent 构建成功") logger.info("SqlReporterAgent 构建成功")
return graph return graph

View File

@ -136,10 +136,8 @@ class KnowledgeBase(ABC):
""" """
from src.utils import hashstr from src.utils import hashstr
from src.utils import hashstr
# 从 kwargs 中获取 is_private 配置 # 从 kwargs 中获取 is_private 配置
is_private = kwargs.get('is_private', False) is_private = kwargs.get("is_private", False)
prefix = "kb_private_" if is_private else "kb_" prefix = "kb_private_" if is_private else "kb_"
db_id = f"{prefix}{hashstr(database_name, with_salt=True)}" db_id = f"{prefix}{hashstr(database_name, with_salt=True)}"

View File

@ -137,7 +137,33 @@ export const agentApi = {
* 获取所有可用工具的信息 * 获取所有可用工具的信息
* @returns {Promise} - 工具信息列表 * @returns {Promise} - 工具信息列表
*/ */
getTools: (agentId) => apiGet(`/api/chat/tools?agent_id=${agentId}`) getTools: (agentId) => apiGet(`/api/chat/tools?agent_id=${agentId}`),
/**
* 恢复被人工审批中断的对话流式响应
* @param {string} agentId - 智能体ID
* @param {Object} data - 恢复数据 { thread_id, approved }
* @param {Object} options - 可选参数signal, headers等
* @returns {Promise} - 恢复响应流
*/
resumeAgentChat: (agentId, data, options = {}) => {
const { signal, headers: extraHeaders, ...restOptions } = options || {};
const baseHeaders = {
'Content-Type': 'application/json',
...useUserStore().getAuthHeaders()
};
return fetch(`/api/chat/agent/${agentId}/resume`, {
method: 'POST',
body: JSON.stringify(data),
signal,
headers: {
...baseHeaders,
...(extraHeaders || {})
},
...restOptions
})
}
} }

View File

@ -29,7 +29,7 @@
<div type="button" class="agent-nav-btn" v-if="!uiState.isSidebarOpen" @click="toggleSidebar"> <div type="button" class="agent-nav-btn" v-if="!uiState.isSidebarOpen" @click="toggleSidebar">
<PanelLeftOpen class="nav-btn-icon" size="18"/> <PanelLeftOpen class="nav-btn-icon" size="18"/>
</div> </div>
<div type="button" class="agent-nav-btn" v-if="!uiState.isSidebarOpen" @click="createNewChat" :disabled="isProcessing"> <div type="button" class="agent-nav-btn" v-if="!uiState.isSidebarOpen" @click="createNewChat" :disabled="chatState.creatingNewChat">
<MessageCirclePlus class="nav-btn-icon" size="18"/> <MessageCirclePlus class="nav-btn-icon" size="18"/>
<span class="text" :class="{'hide-text': isMediumContainer}">新对话</span> <span class="text" :class="{'hide-text': isMediumContainer}">新对话</span>
</div> </div>
@ -97,14 +97,14 @@
</AgentMessageComponent> </AgentMessageComponent>
<!-- 显示对话最后一个消息使用的模型 --> <!-- 显示对话最后一个消息使用的模型 -->
<RefsComponent <RefsComponent
v-if="getLastMessage(conv) && conv.status !== 'streaming'" v-if="shouldShowRefs(conv)"
:message="getLastMessage(conv)" :message="getLastMessage(conv)"
:show-refs="['model', 'copy']" :show-refs="['model', 'copy']"
:is-latest-message="false" :is-latest-message="false"
/> />
</div> </div>
<!-- 生成中的加载状态 --> <!-- 生成中的加载状态 - 增强条件支持主聊天和resume流程 -->
<div class="generating-status" v-if="isProcessing && conversations.length > 0"> <div class="generating-status" v-if="isProcessing && conversations.length > 0">
<div class="generating-indicator"> <div class="generating-indicator">
<div class="loading-dots"> <div class="loading-dots">
@ -117,6 +117,15 @@
</div> </div>
</div> </div>
<div class="bottom"> <div class="bottom">
<!-- 人工审批弹窗 - 放在输入框上方 -->
<HumanApprovalModal
:visible="approvalState.showModal"
:question="approvalState.question"
:operation="approvalState.operation"
@approve="handleApprove"
@reject="handleReject"
/>
<div class="message-input-wrapper" v-if="conversations.length > 0"> <div class="message-input-wrapper" v-if="conversations.length > 0">
<MessageInputComponent <MessageInputComponent
v-model="userInput" v-model="userInput"
@ -152,6 +161,8 @@ import { useAgentStore } from '@/stores/agent';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { MessageProcessor } from '@/utils/messageProcessor'; import { MessageProcessor } from '@/utils/messageProcessor';
import { agentApi, threadApi } from '@/apis'; import { agentApi, threadApi } from '@/apis';
import HumanApprovalModal from '@/components/HumanApprovalModal.vue';
import { useApproval } from '@/composables/useApproval';
// ==================== PROPS & EMITS ==================== // ==================== PROPS & EMITS ====================
const props = defineProps({ const props = defineProps({
@ -181,6 +192,14 @@ const exampleQuestions = computed(() => {
})); }));
}); });
// Keep per-thread streaming scratch data in a consistent shape.
const createOnGoingConvState = () => ({
msgChunks: {},
currentRequestKey: null,
currentAssistantKey: null,
toolCallBuffers: {}
});
const chatState = reactive({ const chatState = reactive({
currentThreadId: null, currentThreadId: null,
isLoadingThreads: false, isLoadingThreads: false,
@ -227,6 +246,18 @@ const currentThread = computed(() => {
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || []); const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || []);
// Refs
const shouldShowRefs = computed(() => {
return (conv) => {
return getLastMessage(conv) &&
conv.status !== 'streaming' &&
!approvalState.showModal &&
!(approvalState.threadId &&
chatState.currentThreadId === approvalState.threadId &&
isProcessing.value);
};
});
// 线computed // 线computed
const currentThreadState = computed(() => { const currentThreadState = computed(() => {
return getThreadState(currentChatId.value); return getThreadState(currentChatId.value);
@ -316,7 +347,7 @@ const getThreadState = (threadId) => {
chatState.threadStates[threadId] = { chatState.threadStates[threadId] = {
isStreaming: false, isStreaming: false,
streamAbortController: null, streamAbortController: null,
onGoingConv: { msgChunks: {} } onGoingConv: createOnGoingConvState()
}; };
} }
return chatState.threadStates[threadId]; return chatState.threadStates[threadId];
@ -349,11 +380,11 @@ const resetOnGoingConv = (threadId = null, preserveMessages = false) => {
// //
setTimeout(() => { setTimeout(() => {
if (threadState.onGoingConv) { if (threadState.onGoingConv) {
threadState.onGoingConv = { msgChunks: {} }; threadState.onGoingConv = createOnGoingConvState();
} }
}, 100); }, 100);
} else { } else {
threadState.onGoingConv = { msgChunks: {} }; threadState.onGoingConv = createOnGoingConvState();
} }
} }
} else { } else {
@ -369,11 +400,11 @@ const resetOnGoingConv = (threadId = null, preserveMessages = false) => {
if (preserveMessages) { if (preserveMessages) {
setTimeout(() => { setTimeout(() => {
if (threadState.onGoingConv) { if (threadState.onGoingConv) {
threadState.onGoingConv = { msgChunks: {} }; threadState.onGoingConv = createOnGoingConvState();
} }
}, 100); }, 100);
} else { } else {
threadState.onGoingConv = { msgChunks: {} }; threadState.onGoingConv = createOnGoingConvState();
} }
} }
} else { } else {
@ -388,7 +419,7 @@ const resetOnGoingConv = (threadId = null, preserveMessages = false) => {
const _processStreamChunk = (chunk, threadId) => { const _processStreamChunk = (chunk, threadId) => {
const { status, msg, request_id, message } = chunk; const { status, msg, request_id, message } = chunk;
const threadState = getThreadState(threadId); const threadState = getThreadState(threadId);
// console.log('msg:', msg); // console.log('Processing stream chunk:', chunk, 'for thread:', threadId);
if (!threadState) return false; if (!threadState) return false;
@ -400,10 +431,10 @@ const _processStreamChunk = (chunk, threadId) => {
if (msg.id) { if (msg.id) {
if (!threadState.onGoingConv.msgChunks[msg.id]) { if (!threadState.onGoingConv.msgChunks[msg.id]) {
threadState.onGoingConv.msgChunks[msg.id] = []; threadState.onGoingConv.msgChunks[msg.id] = [];
} }
threadState.onGoingConv.msgChunks[msg.id].push(msg); threadState.onGoingConv.msgChunks[msg.id].push(msg);
} }
return false; return false;
case 'error': case 'error':
handleChatError({ message }, 'stream'); handleChatError({ message }, 'stream');
// Stop the loading indicator // Stop the loading indicator
@ -421,6 +452,9 @@ const _processStreamChunk = (chunk, threadId) => {
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }); fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId });
resetOnGoingConv(threadId); resetOnGoingConv(threadId);
return true; return true;
case 'human_approval_required':
// 使 composable
return processApprovalInStream(chunk, threadId, currentAgentId.value);
case 'finished': case 'finished':
// //
if (threadState) { if (threadState) {
@ -543,6 +577,13 @@ const fetchThreadMessages = async ({ agentId, threadId }) => {
} }
}; };
// ==================== ====================
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
getThreadState,
resetOnGoingConv,
fetchThreadMessages
});
// //
const sendMessage = async ({ agentId, threadId, text, signal = undefined }) => { const sendMessage = async ({ agentId, threadId, text, signal = undefined }) => {
if (!agentId || !threadId || !text) { if (!agentId || !threadId || !text) {
@ -591,7 +632,7 @@ const switchToFirstChatIfEmpty = async () => {
}; };
const createNewChat = async () => { const createNewChat = async () => {
if (!AgentValidator.validateAgentId(currentAgentId.value, '创建对话') || isProcessing.value) return; if (!AgentValidator.validateAgentId(currentAgentId.value, '创建对话') || chatState.creatingNewChat) return;
// //
if (await switchToFirstChatIfEmpty()) return; if (await switchToFirstChatIfEmpty()) return;
@ -604,6 +645,17 @@ const createNewChat = async () => {
try { try {
const newThread = await createThread(currentAgentId.value, '新的对话'); const newThread = await createThread(currentAgentId.value, '新的对话');
if (newThread) { if (newThread) {
// 线
const previousThreadId = chatState.currentThreadId;
if (previousThreadId) {
const previousThreadState = getThreadState(previousThreadId);
if (previousThreadState?.isStreaming && previousThreadState.streamAbortController) {
previousThreadState.streamAbortController.abort();
previousThreadState.isStreaming = false;
previousThreadState.streamAbortController = null;
}
}
chatState.currentThreadId = newThread.id; chatState.currentThreadId = newThread.id;
} }
} catch (error) { } catch (error) {
@ -616,8 +668,17 @@ const createNewChat = async () => {
const selectChat = async (chatId) => { const selectChat = async (chatId) => {
if (!AgentValidator.validateAgentIdWithError(currentAgentId.value, '选择对话', handleValidationError)) return; if (!AgentValidator.validateAgentIdWithError(currentAgentId.value, '选择对话', handleValidationError)) return;
// 线线 // 线
// resetOnGoingConv(chatState.currentThreadId); const previousThreadId = chatState.currentThreadId;
if (previousThreadId && previousThreadId !== chatId) {
const previousThreadState = getThreadState(previousThreadId);
if (previousThreadState?.isStreaming && previousThreadState.streamAbortController) {
previousThreadState.streamAbortController.abort();
previousThreadState.isStreaming = false;
previousThreadState.streamAbortController = null;
}
}
chatState.currentThreadId = chatId; chatState.currentThreadId = chatId;
chatState.isLoadingMessages = true; chatState.isLoadingMessages = true;
try { try {
@ -764,6 +825,106 @@ const handleSendOrStop = async () => {
await handleSendMessage(); await handleSendMessage();
}; };
// ==================== ====================
const handleApprovalWithStream = async (approved) => {
console.log('🔄 [STREAM] Starting resume stream processing');
const threadId = approvalState.threadId;
if (!threadId) {
message.error('无效的审批请求');
approvalState.showModal = false;
return;
}
const threadState = getThreadState(threadId);
if (!threadState) {
message.error('无法找到对应的对话线程');
approvalState.showModal = false;
return;
}
try {
// 使 composable
const response = await handleApproval(approved, currentAgentId.value);
if (!response) return; // handleApproval
console.log('🔄 [STREAM] Processing resume streaming response');
//
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let stopReading = false;
while (!stopReading) {
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);
console.log('🔄 [STREAM] Processing chunk:', chunk);
// chunk - _processStreamChunk
if (_processStreamChunk(chunk, threadId)) {
stopReading = true;
break;
}
} catch (e) {
console.warn('Failed to parse stream chunk JSON:', e, 'Line:', trimmedLine);
}
}
}
}
if (!stopReading && buffer.trim()) {
try {
const chunk = JSON.parse(buffer.trim());
console.log('🔄 [STREAM] Processing final chunk:', chunk);
// chunk - _processStreamChunk
if (_processStreamChunk(chunk, threadId)) {
stopReading = true;
}
} catch (e) {
console.warn('Failed to parse final stream chunk JSON:', e);
}
}
console.log('🔄 [STREAM] Resume stream processing completed');
} catch (error) {
console.error('❌ [STREAM] Resume stream failed:', error);
if (error.name !== 'AbortError') {
console.error('Resume approval error:', error);
// handleChatError useApproval
}
} finally {
console.log('🔄 [STREAM] Cleaning up streaming state');
if (threadState) {
threadState.isStreaming = false;
threadState.streamAbortController = null;
}
}
};
const handleApprove = () => {
handleApprovalWithStream(true);
};
const handleReject = () => {
handleApprovalWithStream(false);
};
// ==================== UI HANDLERS ==================== // ==================== UI HANDLERS ====================
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -796,7 +957,6 @@ defineExpose({
getExportPayload: buildExportPayload getExportPayload: buildExportPayload
}); });
const retryMessage = (msg) => { /* TODO */ };
const toggleSidebar = () => { const toggleSidebar = () => {
uiState.isSidebarOpen = !uiState.isSidebarOpen; uiState.isSidebarOpen = !uiState.isSidebarOpen;
localStorage.setItem('chat_sidebar_open', uiState.isSidebarOpen); localStorage.setItem('chat_sidebar_open', uiState.isSidebarOpen);
@ -813,7 +973,24 @@ const getLastMessage = (conv) => {
}; };
const showMsgRefs = (msg) => { const showMsgRefs = (msg) => {
if (msg.isLast) return ['copy']; // refs
if (approvalState.showModal) {
return false;
}
// 线ID线ID
// refs
if (approvalState.threadId &&
chatState.currentThreadId === approvalState.threadId &&
!approvalState.showModal &&
isProcessing) {
return false;
}
// refs
if (msg.isLast && msg.status === 'finished') {
return ['copy'];
}
return false; return false;
}; };
@ -876,6 +1053,7 @@ watch(currentAgentId, async (newAgentId, oldAgentId) => {
} }
}, { immediate: true }); }, { immediate: true });
watch(conversations, () => { watch(conversations, () => {
if (isProcessing.value) { if (isProcessing.value) {
scrollController.scrollToBottom(); scrollController.scrollToBottom();

View File

@ -3,6 +3,8 @@
<!-- 用户消息 --> <!-- 用户消息 -->
<p v-if="message.type === 'human'" class="message-text">{{ message.content }}</p> <p v-if="message.type === 'human'" class="message-text">{{ message.content }}</p>
<p v-else-if="message.type === 'system'" class="message-text-system">{{ message.content }}</p>
<!-- 助手消息 --> <!-- 助手消息 -->
<div v-else-if="message.type === 'ai'" class="assistant-message"> <div v-else-if="message.type === 'ai'" class="assistant-message">
<div v-if="parsedData.reasoning_content" class="reasoning-box"> <div v-if="parsedData.reasoning_content" class="reasoning-box">
@ -272,6 +274,19 @@ const toggleToolCall = (toolCallId) => {
white-space: pre-line; white-space: pre-line;
} }
.message-text-system {
max-width: 100%;
margin-bottom: 0;
white-space: pre-line;
color: var(--gray-600);
font-style: italic;
font-size: 14px;
padding: 8px 12px;
background-color: var(--gray-50);
border-left: 3px solid var(--gray-300);
border-radius: 4px;
}
.err-msg { .err-msg {
color: #d15252; color: #d15252;
border: 1px solid #f19999; border: 1px solid #f19999;

View File

@ -0,0 +1,238 @@
<template>
<transition name="slide-up">
<div v-if="visible" class="approval-modal">
<div class="approval-content">
<div class="approval-header">
<h4>{{ question }}</h4>
</div>
<div class="approval-operation">
<span class="label">操作</span>
<span class="operation-text">{{ operation }}</span>
</div>
</div>
<div class="approval-actions">
<button class="btn btn-reject" @click="handleReject" :disabled="isProcessing">
拒绝
</button>
<button class="btn btn-approve" @click="handleApprove" :disabled="isProcessing">
批准
</button>
</div>
<div v-if="isProcessing" class="approval-processing">
<span class="processing-spinner"></span>
处理中...
</div>
</div>
</transition>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
question: {
type: String,
default: '是否批准此操作?'
},
operation: {
type: String,
default: ''
}
});
const emit = defineEmits(['approve', 'reject']);
const isProcessing = ref(false);
//
watch(() => props.visible, (newVal) => {
if (!newVal) {
isProcessing.value = false;
}
});
const handleApprove = () => {
if (isProcessing.value) return;
isProcessing.value = true;
emit('approve');
};
const handleReject = () => {
if (isProcessing.value) return;
isProcessing.value = true;
emit('reject');
};
</script>
<style scoped>
.approval-modal {
background: white;
border-radius: 12px 12px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12);
margin: 0 auto 8px;
max-width: 800px;
width: 100%;
border: 1px solid var(--gray-200);
}
.approval-content {
padding: 16px 20px;
}
.approval-header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 12px;
}
.approval-header h4 {
margin: 0;
font-size: 15px;
font-weight: 500;
color: var(--gray-800);
text-align: center;
}
.approval-operation {
background: var(--gray-50);
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
display: flex;
gap: 6px;
}
.approval-operation .label {
color: var(--gray-600);
font-weight: 500;
flex-shrink: 0;
}
.approval-operation .operation-text {
color: var(--gray-800);
word-break: break-word;
}
.approval-actions {
display: flex;
gap: 10px;
padding: 12px 20px 16px;
}
.btn {
flex: 1;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-reject {
background: var(--gray-100);
color: var(--gray-700);
}
.btn-reject:hover:not(:disabled) {
background: var(--gray-200);
}
.btn-approve {
background: var(--main-color);
color: white;
}
.btn-approve:hover:not(:disabled) {
background: var(--main-700);
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
}
.approval-processing {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
color: var(--gray-600);
font-size: 13px;
background: var(--gray-25);
border-top: 1px solid var(--gray-100);
}
.processing-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--gray-300);
border-top-color: var(--main-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 滑入滑出动画 */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.25s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(20px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(20px);
}
@media (max-width: 520px) {
.approval-content {
padding: 12px 16px;
}
.approval-header h4 {
font-size: 14px;
}
.approval-operation {
font-size: 12px;
padding: 8px 10px;
}
.approval-actions {
padding: 10px 16px 12px;
gap: 8px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,128 @@
import { reactive } from 'vue';
import { message } from 'ant-design-vue';
import { handleChatError } from '@/utils/errorHandler';
import { agentApi } from '@/apis';
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
// 审批状态
const approvalState = reactive({
showModal: false,
question: '',
operation: '',
threadId: null,
interruptInfo: null
});
// 处理审批逻辑
const handleApproval = async (approved, currentAgentId) => {
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();
console.log('🔄 [APPROVAL] Starting resume process:', { approved, threadId, currentAgentId });
try {
// 调用恢复接口
const response = await agentApi.resumeAgentChat(
currentAgentId,
{
thread_id: threadId,
approved: approved
},
{
signal: threadState.streamAbortController?.signal
}
);
console.log('🔄 [APPROVAL] Resume API response received');
if (!response.ok) {
const errorText = await response.text();
console.error('Resume API error:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`);
}
console.log('🔄 [APPROVAL] Resume API successful, returning response for stream processing');
return response; // 返回响应供调用方处理流式数据
} catch (error) {
console.error('❌ [APPROVAL] Resume failed:', error);
if (error.name !== 'AbortError') {
handleChatError(error, 'resume');
message.error(`恢复对话失败: ${error.message || '未知错误'}`);
}
// 重置状态 - 只在错误时重置
threadState.isStreaming = false;
threadState.streamAbortController = null;
throw error; // 重新抛出错误让调用方处理
}
// 移除 finally 块 - 让组件管理流式状态的生命周期
};
// 在流式处理中处理审批请求
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
if (chunk.status !== 'human_approval_required') {
return false;
}
const { interrupt_info } = chunk;
const threadState = getThreadState(threadId);
if (!threadState) return false;
// 停止显示"处理中"状态,让用户可以看到并操作审批弹窗
threadState.isStreaming = false;
// 显示审批弹窗
approvalState.showModal = true;
approvalState.question = interrupt_info?.question || '是否批准以下操作?';
approvalState.operation = interrupt_info?.operation || '未知操作';
approvalState.threadId = chunk.thread_id || threadId;
approvalState.interruptInfo = interrupt_info;
// 刷新消息历史显示已执行的部分
fetchThreadMessages({ agentId: currentAgentId, threadId: threadId });
return true; // 表示已处理审批请求,应停止流式处理
};
// 重置审批状态
const resetApprovalState = () => {
approvalState.showModal = false;
approvalState.question = '';
approvalState.operation = '';
approvalState.threadId = null;
approvalState.interruptInfo = null;
};
return {
approvalState,
handleApproval,
processApprovalInStream,
resetApprovalState
};
}

View File

@ -138,10 +138,6 @@ export class MessageProcessor {
// 处理AIMessageChunk类型 // 处理AIMessageChunk类型
if (result.type === 'AIMessageChunk') { if (result.type === 'AIMessageChunk') {
result.type = 'ai'; result.type = 'ai';
// 将tool_calls从additional_kwargs移到顶层并确保格式正确
if (result.additional_kwargs?.tool_calls) {
result.tool_calls = result.additional_kwargs.tool_calls;
}
} }
return result; return result;
@ -154,78 +150,47 @@ export class MessageProcessor {
* @param {Object} chunk - 当前块 * @param {Object} chunk - 当前块
*/ */
static _mergeToolCalls(result, chunk) { static _mergeToolCalls(result, chunk) {
// 1. 处理 additional_kwargs.tool_calls (旧格式,保持兼容性) if (chunk.tool_call_chunks && chunk.tool_call_chunks.length > 0) {
if (chunk.additional_kwargs?.tool_calls) { // 确保 result 有 tool_calls 数组
if (!result.additional_kwargs) result.additional_kwargs = {};
if (!result.additional_kwargs.tool_calls) result.additional_kwargs.tool_calls = [];
for (const toolCall of chunk.additional_kwargs.tool_calls) {
const existingToolCall = result.additional_kwargs.tool_calls.find(
t => (t.id === toolCall.id || t.index === toolCall.index)
);
if (existingToolCall) {
// 合并相同ID的tool call
if (existingToolCall.function && toolCall.function) {
existingToolCall.function.arguments += toolCall.function.arguments;
}
} else {
// 添加新的tool call
result.additional_kwargs.tool_calls.push(JSON.parse(JSON.stringify(toolCall)));
}
}
}
// 2. 处理顶层的 tool_calls (新格式)
if (chunk.tool_calls) {
if (!result.tool_calls) result.tool_calls = []; if (!result.tool_calls) result.tool_calls = [];
for (const toolCall of chunk.tool_calls) { for (const toolCallChunk of chunk.tool_call_chunks) {
// 过滤掉无效的工具调用没有id或name的空对象 // 使用 index 来标识工具调用(因为可能有多个工具调用)
if (!toolCall.id && !toolCall.name) { const existingToolCallIndex = result.tool_calls.findIndex(
continue; t => t.index === toolCallChunk.index
} );
const existingToolCall = result.tool_calls.find(t => t.id === toolCall.id); if (existingToolCallIndex !== -1) {
// 合并相同index的tool call
const existingToolCall = result.tool_calls[existingToolCallIndex];
if (existingToolCall) { // 更新名称和ID如果存在
// 合并相同ID的tool call的args if (toolCallChunk.name && !existingToolCall.function?.name) {
if (toolCall.args && existingToolCall.args !== undefined) { if (!existingToolCall.function) existingToolCall.function = {};
existingToolCall.args += toolCall.args; existingToolCall.function.name = toolCallChunk.name;
}
if (toolCallChunk.id && !existingToolCall.id) {
existingToolCall.id = toolCallChunk.id;
}
// 合并参数
if (toolCallChunk.args) {
if (!existingToolCall.function) existingToolCall.function = {};
if (!existingToolCall.function.arguments) existingToolCall.function.arguments = '';
existingToolCall.function.arguments += toolCallChunk.args;
} }
} else { } else {
// 添加新的tool call // 添加新的tool call
result.tool_calls.push(JSON.parse(JSON.stringify(toolCall))); const newToolCall = {
} index: toolCallChunk.index,
} id: toolCallChunk.id,
} function: {
name: toolCallChunk.name || null,
// 3. 处理 tool_call_chunks (分片的工具调用参数) arguments: toolCallChunk.args || ''
if (chunk.tool_call_chunks) { }
if (!result.tool_call_chunks) result.tool_call_chunks = []; };
result.tool_calls.push(newToolCall);
for (const toolCallChunk of chunk.tool_call_chunks) {
// 过滤掉无效的chunk没有id、name和args的空对象
if (!toolCallChunk.id && !toolCallChunk.name && !toolCallChunk.args) {
continue;
}
const existingChunk = result.tool_call_chunks.find(
t => (t.id === toolCallChunk.id || (t.index === toolCallChunk.index && t.index !== null))
);
if (existingChunk) {
// 合并参数字符串
if (toolCallChunk.args && existingChunk.args !== undefined) {
existingChunk.args += toolCallChunk.args;
}
// 更新工具名称
if (toolCallChunk.name && !existingChunk.name) {
existingChunk.name = toolCallChunk.name;
}
} else {
// 添加新的chunk
result.tool_call_chunks.push(JSON.parse(JSON.stringify(toolCallChunk)));
} }
} }
} }