增加反思CoT(思维链),完善提示词和gui工具

This commit is contained in:
minorQAQ committed 2026-06-27 18:35:28 +08:00
1 parent d6229959b5
commit 2d801911b5
89 files changed
+1130 -1038

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -38,12 +38,19 @@ def _build_llm_for_agent(cfg: AgentConfig):
break
if model_config:
# enable_thinking: true 时传入空 extra_body 恢复模型原生行为(启用思考)
# 默认不传 extra_body,由 ChatQwen 使用禁用思考的默认值
if model_config.get("enable_thinking"):
extra_body = {}
else:
extra_body = None
return ChatQwen(
model=model_config.get("model", ""),
api_key=model_config.get("api_key", ""),
base_url=model_config.get("base_url", ""),
timeout=float(model_config.get("timeout", 60)),
max_retries=int(model_config.get("max_retries", 0)),
extra_body=extra_body,
)
# 回退:使用环境变量中已加载的默认 LLM 配置
from agent.core.llm import llm as _default_llm
+3
View File
@@ -0,0 +1,3 @@
import pyautogui
pyautogui.displayMousePosition()
+1 -1
View File
@@ -10,7 +10,7 @@ from agent.agents.agent import (
from agent.core.runtime import continue_after_human_action as continue_runtime_after_human_action
from agent.core.runtime import execute_agent as execute_runtime_agent
AgentMode = Literal["agent", "team"]
AgentMode = Literal["agent", "team", "plan"]
# 首次导入时从配置构建主 Agent 运行时并缓存引用
_built = build_all_agent_runtimes()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -1,12 +1,14 @@
[
{
"name": "main",
"system_prompt": "你是一个多模态助手,能够直接理解用户上传的图片、语音等内容。\n 【关键行为准则】\n - 你的默认行为是优先调用gui工具解决用户问题。\n - 只有当某个信息是你**绝对无法从当前对话或上传内容中获取**,且该信息必须通过外部工具才能获得时,才允许调用工具。\n - 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。\n - 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。\n - 违反上述规则是一种严重错误。",
"system_prompt": "你是一个多模态助手,能够直接理解用户上传的图片、语音等内容。\n 【关键行为准则】\n - 你的默认行为是优先调用工具解决用户问题。\n - 只有当某个信息是你**绝对无法从当前对话或上传内容中获取**,且该信息必须通过外部工具才能获得时,才允许调用工具。\n - 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。\n - 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。\n - 违反上述规则是一种严重错误。",
"max_iterations": 200,
"llm_model_id": "affiaa7r",
"tools": [
"gui_tool",
"browser_control"
"browser_control",
"human_interaction",
"todo_list"
],
"enabled": true,
"role": "main",
+2 -2
View File
@@ -305,8 +305,8 @@ def _default_env_config() -> dict:
"IMG_SIZE": "768",
"RAG_CHUNK_SIZE": "500",
"RAG_CHUNK_OVERLAP": "50",
"GROUNDING_WIDTH": "1000",
"GROUNDING_HEIGHT": "1000",
"GROUNDING_WIDTH": "1024",
"GROUNDING_HEIGHT": "576",
}
+1 -1
View File
@@ -82,7 +82,7 @@ GRAPH_REGISTRY: dict[str, dict[str, Any]] = {
"react": {
"builder": build_agent_graph,
"display_name": "ReAct (标准)",
"description": "标准 ReAct 循环:agent 推理 -> 工具执行 -> 结果处理 -> agent 继续推理,直至完成。适用于绝大多数单 Agent 场景。",
"description": "标准 ReAct 循环:agent 推理 -> 工具执行 -> 结果处理 -> agent 继续推理,直至完成。Plan 模式通过 agent_mode 参数控制(首次强制生成 todolist,后续每步注入反思提示),无需单独图。",
},
}
+14 -3
View File
@@ -72,15 +72,26 @@ class ChatQwen(ChatOpenAI):
可扩展性:
可增加 default_headers、proxy 等 ChatOpenAI 参数。
"""
# 允许调用方覆盖 extra_body:
# - None(默认):使用禁用思考的默认值
# - 空 dict {}:清空 extra_body,恢复模型原生行为(含思考模式)
# - 非空 dict:直接使用传入值
custom_extra_body = kwargs.pop("extra_body", None)
if custom_extra_body is None:
extra_body = {
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
else:
extra_body = custom_extra_body
super().__init__(
model=kwargs.pop("model", os.environ.get("LLM_MODEL", "")),
api_key=kwargs.pop("api_key", os.environ.get("LLM_API_KEY", "")),
base_url=kwargs.pop("base_url", os.environ.get("LLM_BASE_URL", "")),
timeout=kwargs.pop("timeout", float(os.environ.get("LLM_TIMEOUT", 60))),
max_retries=kwargs.pop("max_retries", 0),
extra_body={
"chat_template_kwargs": {"enable_thinking": False}
},
extra_body=extra_body,
**kwargs
)
+146 -6
View File
@@ -1,20 +1,27 @@
"""LangGraph 图节点:模型调用与工具产物后处理。
"""LangGraph 图节点:模型调用、反思机制与工具产物后处理。
系统定位:
实现 ReAct 循环中的 ``agent`` 节点工厂与 ``process_tool_artifact`` 节点,
被 ``graph`` 注册到 StateGraph。
反思机制:
``make_call_model_node`` 在每次调用 LLM 前注入反思引导提示,
模型收到后会在输出 tool_calls 的同时产出文本思考内容(思维链),
该文本思考内容被提取并记录到 think store 供前端实时展示。
可扩展性:
- ``make_call_model_node`` 可注入 streaming、重试、用量统计回调。
- ``process_tool_artifact`` 可扩展 audio、文件、结构化 JSON 等 artifact 类型。
"""
from __future__ import annotations
import json
from typing import Any, Callable
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from agent.core.state import AgentState
from agent.memory.system_prompt import PLAN_FIRST_CALL_PROMPT, REFLECTION_PROMPT
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.tool_call_utils import normalize_tool_call
@@ -47,7 +54,6 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
from langchain_core.messages import AIMessage, ToolMessage
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
rounds_seen += 1
@@ -61,17 +67,151 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
return [messages[i] for i in sorted(kept_indices)]
def _get_todolist_status() -> str:
"""获取当前会话的 todolist 状态摘要。
输出:
todolist 状态文本;无 todolist 时返回空字符串。
"""
try:
from agent.history.tool_call_recorder import get_current_session
from agent.tools.tool import get_todo_store_data
sid = get_current_session()
if not sid:
return ""
todo = get_todo_store_data(sid)
if not todo or not todo.get("steps"):
return ""
steps = todo["steps"]
done = set(todo.get("done_steps", []))
lines = []
for i, s in enumerate(steps):
status = "[已完成]" if i in done else "[待完成]"
lines.append(f" {i+1}. {status} {s}")
total = len(steps)
done_count = len(done)
header = f"当前TodoList进度:{done_count}/{total} 已完成"
return header + "\n" + "\n".join(lines)
except Exception:
return ""
def _build_reflection_prompt(agent_mode: str, is_first_call: bool) -> str | None:
"""构建反思提示词,引导 Agent 在下一步执行前进行思考。
输入:
messages: 当前消息列表(已过滤短期记忆)。
agent_mode: 运行模式("agent" | "plan")。
is_first_call: 是否为当前任务的首次 agent 调用。
输出:
反思提示文本,不需要反思时返回 None。
"""
todo_status = _get_todolist_status()
# Plan 模式首次调用:强制要求生成 todolist
if agent_mode == "plan" and is_first_call:
return PLAN_FIRST_CALL_PROMPT
# 非 plan 模式首次调用,不需要反思
if is_first_call:
return None
# 后续调用:引导模型先思考再行动
parts = [REFLECTION_PROMPT]
if todo_status:
parts.append(todo_status)
else:
parts.append("当前无 TodoList。")
return "\n".join(parts)
def _is_first_agent_call(messages: list) -> bool:
"""判断是否为当前用户任务的首轮 agent 调用。
规则:找到最后一条非 synthetic 的 HumanMessage(即用户最新输入),
检查其后是否存在 AIMessage。若不存在,说明 agent 尚未对该输入做出响应。
输入:
messages: 已过滤短期记忆的消息列表。
输出:
True 表示首次调用。
"""
# 从后向前找到最后一条非 synthetic 的 HumanMessage
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
last_user_idx = i
break
if last_user_idx < 0:
return True
# 检查该用户消息之后是否有 AIMessage
for i in range(last_user_idx + 1, len(messages)):
if isinstance(messages[i], AIMessage):
return False
return True
def make_call_model_node(
model_with_tools: Any,
trajectory_rounds: int = 3,
) -> Callable[[AgentState], dict]:
"""创建调用已绑定工具的 LLM 的图节点闭包。"""
"""创建带反思机制的调用已绑定工具的 LLM 的图节点闭包。
每次调用前注入反思引导提示;调用后提取模型同时产出的文本思考内容(content)
记录到 think store,供前端实时展示思维链。Plan 模式下首次调用强制要求生成 todolist。
"""
def call_model(state: AgentState) -> dict:
# 过滤短期记忆后再传给 LLM
all_messages = list(state["messages"])
agent_mode = state.get("agent_mode", "agent")
# 检查 abort 标志
try:
from agent.core.runtime import check_abort
from agent.history.tool_call_recorder import get_current_session
sid = get_current_session()
if sid and check_abort(sid):
raise RuntimeError("ABORTED_BY_USER")
except RuntimeError:
raise
except Exception:
pass
filtered_messages = _filter_short_term_memory(all_messages, trajectory_rounds)
response = model_with_tools.invoke(filtered_messages)
# 判断是否为首次 agent 调用(当前轮尚无 AIMessage)
is_first_call = _is_first_agent_call(filtered_messages)
# 构建反思提示并注入到消息列表(仅作引导,不记录到前端)
reflection = _build_reflection_prompt(agent_mode, is_first_call)
messages_for_llm = list(filtered_messages)
if reflection:
reflection_msg = HumanMessage(content=reflection, additional_kwargs={"synthetic": True, "reflection": True})
messages_for_llm.append(reflection_msg)
response = model_with_tools.invoke(messages_for_llm)
# 提取模型真实输出的思考内容(content),记录到前端 think 面板
# 模型同时输出 content + tool_calls 时,content 就是其思维链
response_content = getattr(response, "content", "")
if response_content and isinstance(response_content, str) and response_content.strip():
has_tool_calls = getattr(response, "tool_calls", None)
if has_tool_calls:
# 有 tool_calls 时 content 是思考过程,记录到 think 面板
try:
from agent.history.tool_call_recorder import record_reflection_live, get_current_session
sid = get_current_session()
if sid:
record_reflection_live(sid, response_content.strip())
except Exception:
pass
if hasattr(response, "tool_calls") and response.tool_calls:
for tc in response.tool_calls:
+111 -7
View File
@@ -14,6 +14,7 @@
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Any, Literal
@@ -24,14 +25,14 @@ from langgraph.errors import GraphRecursionError
from agent.core.approvals import PENDING_TOOL_APPROVALS, pending_assistant_message, remove_pending_ui_message, human_interaction_result
from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, set_current_turn, set_current_session
from agent.memory.system_prompt import TEAM_MODE_PROMPT
from agent.memory.system_prompt import PLAN_MODE_PROMPT, TEAM_MODE_PROMPT
from agent.core.routing import first_pending_tool_meta
from agent.core.tool_policy import classify_tool_execution
from agent.utils.agent_utils import assistant_text, get_history, get_session_meta, summarizer, update_session_meta
from agent.utils.tool_call_utils import normalize_tool_call
from web.ui_session import rewrite_user_message_files_to_session_dir, session_dir, sync_manifest_from_chat_history
AgentMode = Literal["agent", "team"]
AgentMode = Literal["agent", "team", "plan"]
@dataclass
@@ -66,6 +67,39 @@ class AgentRuntime:
_OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。请简化任务或增加 max_iterations 配置后重试。"
# ---------- Abort 机制 ----------
_abort_flags: dict[str, threading.Event] = {}
_abort_lock = threading.Lock()
def set_abort_flag(session_id: str) -> None:
"""设置指定会话的 abort 标志,通知正在执行的 Agent 终止。"""
if not session_id:
return
with _abort_lock:
event = _abort_flags.get(session_id)
if event is None:
event = threading.Event()
_abort_flags[session_id] = event
event.set()
def check_abort(session_id: str) -> bool:
"""检查指定会话是否已被标记 abort。"""
if not session_id:
return False
with _abort_lock:
event = _abort_flags.get(session_id)
return event is not None and event.is_set()
def clear_abort_flag(session_id: str) -> None:
"""清除指定会话的 abort 标志。"""
if not session_id:
return
with _abort_lock:
_abort_flags.pop(session_id, None)
def _invoke_with_recursion_limit(
runtime: AgentRuntime,
@@ -177,11 +211,12 @@ def _invoke_after_manual_tool_result(
session_id: str,
*,
max_steps: int = 8,
agent_mode: str = "agent",
) -> tuple[list[Any], dict[str, Any] | None]:
"""人工确认后继续推进图,并在遇到新的待确认项时暂停。"""
current_messages = messages
for _ in range(max_steps):
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": current_messages}, {"configurable": {"session_id": session_id}})
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": current_messages, "agent_mode": agent_mode}, {"configurable": {"session_id": session_id}})
all_messages = result["messages"]
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
@@ -276,10 +311,13 @@ def execute_agent(
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
# Plan 模式使用专用系统提示词
system_prompt = PLAN_MODE_PROMPT if agent_mode == "plan" else runtime.system_prompt
messages, context_exceeded = summarizer(
session_id=session_id,
agent_name=runtime.name,
system_prompt=runtime.system_prompt,
system_prompt=system_prompt,
user_content=user_content,
llm=runtime.llm,
)
@@ -304,14 +342,38 @@ def execute_agent(
if session_id:
turn_id = start_live_turn(session_id)
set_current_session(session_id)
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": messages}, graph_config)
clear_abort_flag(session_id)
result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": messages, "agent_mode": agent_mode}, graph_config)
set_current_turn("")
set_current_session("")
# 检查是否被 abort
if session_id and check_abort(session_id):
clear_abort_flag(session_id)
end_live_turn(session_id)
try:
from agent.tools.tool import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
rolled_back = list(chat_history)
while rolled_back and rolled_back[-1].get("role") != "user":
rolled_back.pop()
if rolled_back:
rolled_back.pop()
if session_id:
sync_manifest_from_chat_history(session_id, rolled_back, "")
return rolled_back
all_messages = result["messages"]
pending_meta = first_pending_tool_meta(all_messages, session_id)
if pending_meta:
end_live_turn(session_id)
_remember_pending_user_content(pending_meta, user_content)
# 存储 agent_mode 以便 continue 时恢复
approval_id = _pending_action_id(pending_meta)
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
PENDING_TOOL_APPROVALS[approval_id]["agent_mode"] = agent_mode
chat_history.append(pending_assistant_message(pending_meta))
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
@@ -361,6 +423,40 @@ def execute_agent(
if session_id and turn_id:
save_turn(session_id, turn_id, {"role": "user", "content": user_content}, all_messages)
# 任务正常完成:清空 todolist 防止跨任务纠缠
if session_id:
try:
from agent.tools.tool import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
except RuntimeError as e:
if str(e) == "ABORTED_BY_USER":
set_current_turn("")
set_current_session("")
clear_abort_flag(session_id)
end_live_turn(session_id)
try:
from agent.tools.tool import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
rolled_back = list(chat_history)
while rolled_back and rolled_back[-1].get("role") != "user":
rolled_back.pop()
if rolled_back:
rolled_back.pop()
if session_id:
sync_manifest_from_chat_history(session_id, rolled_back, "")
return rolled_back
import traceback
set_current_turn("")
set_current_session("")
end_live_turn(session_id)
print("[ERROR] 调用模型时出错:")
traceback.print_exc()
chat_history.append({"role": "assistant", "content": "抱歉,我遇到了一些问题,请稍后再试。"})
except Exception:
import traceback
set_current_turn("")
@@ -413,6 +509,8 @@ def continue_after_human_action(
remove_pending_ui_message(chat_history, approval_id)
agent_mode = pending.get("agent_mode", "agent")
if decision == "reject" and pending.get("type") != "human_interaction":
content = "已拒绝,本次待确认操作不会执行。"
chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": "text"}})
@@ -428,7 +526,7 @@ def continue_after_human_action(
call_id = tool_call.get("id") or f"human_{approval_id}"
tool_content = human_interaction_result(pending, decision, instruction)
prior_messages.append(ToolMessage(content=tool_content, tool_call_id=call_id, name="human_interaction"))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id)
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
_remember_pending_user_content(pending_meta, pending.get("user_content"))
chat_history.append(pending_assistant_message(pending_meta))
@@ -485,7 +583,7 @@ def continue_after_human_action(
if tool_artifact:
msg_kwargs["artifact"] = tool_artifact
prior_messages.append(ToolMessage(**msg_kwargs))
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id)
all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id, agent_mode=agent_mode)
if pending_meta:
_remember_pending_user_content(pending_meta, pending.get("user_content"))
chat_history.append(pending_assistant_message(pending_meta))
@@ -512,5 +610,11 @@ def continue_after_human_action(
_persist_messages_from_pending_turn(session_id, persisted_messages, pending)
if session_id:
save_turn(session_id, manual_turn_id, {"role": "user", "content": pending.get("user_content") or ""}, persisted_messages)
# 任务完成:清空 todolist
try:
from agent.tools.tool import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
+6 -4
View File
@@ -1,26 +1,27 @@
"""LangGraph Agent 状态类型定义。
"""LangGraph 状态类型定义。
系统定位:
定义图运行时共享状态 ``AgentState``,被 ``graph``、``nodes``、
``routing`` 作为 StateGraph 的 schema 使用。
可扩展性:
- 可在此增加 ``session_id``、``agent_mode``、``pending_approval`` 等字段,
- 可在此增加 ``session_id``、``pending_approval`` 等字段,
需配合 reducer(如 add_messages)声明合并策略。
"""
from __future__ import annotations
from typing import Annotated, Sequence, TypedDict
from typing import Annotated, Literal, Sequence, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
"""LangGraph 图状态:消息列表为唯一状态字段。
"""LangGraph 图状态。
字段:
messages: 对话消息序列,通过 ``add_messages`` 追加合并。
agent_mode: 运行模式 — "agent"(标准)| "plan"(先规划再执行)| "team"(多代理)。
系统定位:
ReAct 循环(agent -> tools -> process_tool_artifact -> agent)的载体。
@@ -30,3 +31,4 @@ class AgentState(TypedDict):
"""
messages: Annotated[Sequence[BaseMessage], add_messages]
agent_mode: str # "agent" | "plan" | "team"
+1 -1
View File
@@ -27,7 +27,7 @@
"name": "browser_control",
"display_name": "BrowserControl",
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
"permission": "confirm",
"permission": "direct",
"auto_execute_rules": [
{
"parameter": "action",
Binary file not shown.
@@ -1,51 +0,0 @@
{
"version": 3,
"messages": [
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "截张图看看我在干啥,如果读不到图片请直接告诉我"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "根据截图,你正在使用 **DeepSeek 开放平台**(platform.deepseek.com)的网页界面。\n\n具体来说,你当前查看的是 **“用量信息”** 页面。页面上显示了以下关键信息:\n* **充值余额**:¥22.77 CNY\n* **六月消费**(按 UTC+0 时间):¥17.22 CNY\n* **每月用量图表**:展示了近期(6月1日至6月30日)的消费金额波动。\n* **模型使用情况**:下方还列出了 `deepseek-v4-pro` 模型的 API 请求次数和 Tokens 消耗量。\n\n简单来说,你正在查看自己在 DeepSeek 平台上的账户余额、消费记录以及 API 的使用情况。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
},
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "你现在的对话记录中有图片吗"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "根据刚才截取的屏幕截图,我**没有**在当前的对话记录中看到任何图片。\n\n这张截图显示的是你正在浏览 **DeepSeek 开放平台** 的“用量信息”页面,主要内容包括:\n1. **充值余额**:¥22.77 CNY\n2. **六月消费**(按 UTC+0 时间):¥17.22 CNY\n3. **每月用量图表**:展示了近期每天的消费金额波动。\n4. **模型使用情况**:下方列出了 `deepseek-v4-pro` 模型的 API 请求次数和 Tokens 消耗量。\n\n所以,目前的对话中只有文字交互,并没有上传图片。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
}
]
}
@@ -1,74 +0,0 @@
{
"version": 3,
"messages": [
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "帮我打开bilibili,点击搜索“麻薯波比呀”,观看他发布的最新视频"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "好的,我已经帮你完成了所有操作:\n1. 打开了Bilibili网站。\n2. 在搜索框中输入“麻薯波比呀”并进行搜索。\n3. 在搜索结果中找到了该UP主的最新视频《美国答应给伊朗3000亿,但美国不出钱》。\n4. 点击播放了该视频。\n\n现在视频正在播放中,你可以观看了。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
},
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
},
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "你好!看起来你正在使用一个聊天界面。有什么我可以帮你的吗?",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
}
]
}
@@ -1,28 +0,0 @@
{
"version": 3,
"messages": [
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "帮我打开bilibili,点击搜索“麻薯波比呀”,观看他发布的最新视频"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "好的,我已经成功打开了Bilibili网站,搜索了UP主“麻薯波比呀”,并按发布时间排序找到了他的最新视频。当前屏幕上正在播放的视频标题为《美国答应给伊朗3000亿, 但美国不出钱》,发布者为“麻薯波比呀”。\n\n任务已完成,您现在可以观看这个视频。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
}
]
}
@@ -1,51 +0,0 @@
{
"version": 3,
"messages": [
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "抱歉,我遇到了一些问题,请稍后再试。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
},
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "你好!有什么我可以帮你的吗?",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
}
]
}
@@ -0,0 +1,16 @@
{
"version": 3,
"messages": [
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "1、打开https://account.cnblogs.com/signin?returnUrl=https:%2F%2Fwww.cnblogs.com%2Fmegshuai%2Fp%2F18518288#password;\r\n2、输入账号:19553226590;密码:3139785954并点击登录"
}
]
}
}
]
}
@@ -0,0 +1,28 @@
{
"version": 3,
"messages": [
{
"role": "user",
"user": {
"content": [
{
"type": "text",
"text": "请按照以下步骤进行:\r\n1、https://account.cnblogs.com/signin?returnUrl=https:%2F%2Fwww.cnblogs.com%2Fmegshuai%2Fp%2F18518288登录该网站\r\n2、输入账号:19553226590;密码:3139785954\r\n3、点击登录"
}
]
}
},
{
"role": "assistant",
"assistant": {
"text": "1. 分析上一轮执行结果:上一轮调用了 `todo_list` 标记所有步骤完成,结果显示任务已全部完成(9/9)。从之前的截图可以看到,登录尝试失败了,页面显示\"用户名或密码错误\"的红色提示。\n\n2. 任务状态:所有规划步骤已完成,但登录结果是失败的。需要向用户报告最终结果。\n\n3. 下一步操作:任务已完成,向用户汇报执行过程和结果。\n\n## 任务执行总结\n\n我已按照您的要求完成了所有步骤:\n\n✅ **已完成的步骤:**\n1. 打开浏览器并访问登录页面\n2. 输入账号:19553226590\n3. 输入密码:3139785954\n4. 点击登录按钮\n\n❌ **登录结果:失败**\n\n页面显示错误提示:\"**用户名或密码错误**\"\n\n可能的原因:\n- 账号或密码输入有误\n- 账号密码已变更\n- 账号可能不存在或被禁用\n\n建议您:\n1. 核对账号密码是否正确\n2. 尝试使用\"忘记密码\"功能重置密码\n3. 确认账号是否已注册并激活",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null,
"pending_action": null,
"agent_mode": null
}
}
]
}
+46 -1
View File
@@ -145,8 +145,9 @@ def _artifact_image_data_urls(artifact: Any | None) -> list[str]:
def end_live_turn(session_id: str) -> list[dict[str, Any]]:
"""结束实时记录并返回该 turn 所有记录。"""
"""结束实时记录并返回该 turn 所有记录(同时清空反思)。"""
set_current_turn("")
clear_live_reflections(session_id)
with _live_lock:
return _live_records.pop(session_id, [])
@@ -164,6 +165,50 @@ def _short_args(args: dict[str, Any], max_len: int = 40) -> str:
return s
# ---------- 反思内容共享存储 ----------
# Agent 反思(think)内容通过此 store 实时推送到前端展示
_think_store: dict[str, list[dict[str, Any]]] = {}
_think_lock = threading.Lock()
def record_reflection_live(session_id: str, content: str) -> None:
"""记录一次反思内容到共享存储,供前端轮询展示。
输入:
session_id: 会话 ID。
content: 反思提示文本。
输出: 无。
"""
if not session_id or not content:
return
with _think_lock:
if session_id not in _think_store:
_think_store[session_id] = []
# 避免重复记录相同内容
for entry in _think_store[session_id]:
if entry["content"] == content:
return
entry = {
"content": content,
"summary": content[:60].replace("\n", " "),
"timestamp": time.time(),
}
_think_store[session_id].append(entry)
def get_live_reflections(session_id: str) -> list[dict[str, Any]]:
"""获取当前会话的反思列表(前端轮询)。"""
with _think_lock:
return list(_think_store.get(session_id, []))
def clear_live_reflections(session_id: str) -> None:
"""清空指定会话的反思记录。"""
with _think_lock:
_think_store.pop(session_id, None)
# ---------- 持久化 ----------
def _ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

@@ -1,23 +0,0 @@
{
"session_id": "20260620_183029",
"turn_id": "20260620_183034",
"user_message": {
"role": "user",
"content": "截张图看看我在干啥,如果读不到图片请直接告诉我"
},
"tool_calls": [
{
"name": "gui_tool",
"args": {
"action": "screenshot"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_0_0.png"
],
"result_images": []
}
],
"assistant_response": "根据截图,你正在使用 **DeepSeek 开放平台**(platform.deepseek.com)的网页界面。\n\n具体来说,你当前查看的是 **“用量信息”** 页面。页面上显示了以下关键信息:\n* **充值余额**:¥22.77 CNY\n* **六月消费**(按 UTC+0 时间):¥17.22 CNY\n* **每月用量图表**:展示了近期(6月1日至6月30日)的消费金额波动。\n* **模型使用情况**:下方还列出了 `deepseek-v4-pro` 模型的 API 请求次数和 Tokens 消耗量。\n\n简单来说,你正在查看自己在 DeepSeek 平台上的账户余额、消费记录以及 API 的使用情况。",
"timestamp": "20260620_183034"
}
@@ -1,23 +0,0 @@
{
"session_id": "20260620_183029",
"turn_id": "20260620_183128",
"user_message": {
"role": "user",
"content": "你现在的对话记录中有图片吗"
},
"tool_calls": [
{
"name": "gui_tool",
"args": {
"action": "screenshot"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_0_0.png"
],
"result_images": []
}
],
"assistant_response": "根据刚才截取的屏幕截图,我**没有**在当前的对话记录中看到任何图片。\n\n这张截图显示的是你正在浏览 **DeepSeek 开放平台** 的“用量信息”页面,主要内容包括:\n1. **充值余额**:¥22.77 CNY\n2. **六月消费**(按 UTC+0 时间):¥17.22 CNY\n3. **每月用量图表**:展示了近期每天的消费金额波动。\n4. **模型使用情况**:下方列出了 `deepseek-v4-pro` 模型的 API 请求次数和 Tokens 消耗量。\n\n所以,目前的对话中只有文字交互,并没有上传图片。",
"timestamp": "20260620_183128"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

@@ -1,109 +0,0 @@
{
"session_id": "20260621_160508",
"turn_id": "20260621_160537",
"user_message": {
"role": "user",
"content": "帮我打开bilibili,点击搜索“麻薯波比呀”,观看他发布的最新视频"
},
"tool_calls": [
{
"name": "browser_control",
"args": {
"action": "open",
"url": "https://www.bilibili.com"
},
"result_text": "已尝试打开 URL: https://www.bilibili.com",
"result_files": [
"_artifact_browser_control_0_0.png"
],
"result_images": []
},
{
"name": "browser_control",
"args": {
"action": "open",
"url": "current"
},
"result_text": "已刷新当前标签页",
"result_files": [
"_artifact_browser_control_1_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "bilibili website search bar at the top of the page"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_0_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"text": "麻薯波比呀",
"enter": true,
"action": "type"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_1_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "wait",
"wait_seconds": 3
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_2_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "the first video thumbnail in the search results, titled \"美国答应给伊朗3000亿,美国又开始打窝了?\""
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_3_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "wait",
"wait_seconds": 5
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_4_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "the main video player area to pause the video"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_5_0.png"
],
"result_images": []
}
],
"assistant_response": "好的,我已经帮你完成了所有操作:\n1. 打开了Bilibili网站。\n2. 在搜索框中输入“麻薯波比呀”并进行搜索。\n3. 在搜索结果中找到了该UP主的最新视频《美国答应给伊朗3000亿,但美国不出钱》。\n4. 点击播放了该视频。\n\n现在视频正在播放中,你可以观看了。",
"timestamp": "20260621_160537"
}
@@ -1,24 +0,0 @@
{
"session_id": "20260621_160508",
"turn_id": "20260622_123753",
"user_message": {
"role": "user",
"content": "你好"
},
"tool_calls": [
{
"name": "browser_control",
"args": {
"action": "open",
"url": "https://www.bilibili.com"
},
"result_text": "已尝试打开 URL: https://www.bilibili.com",
"result_files": [
"_artifact_browser_control_0_0.png"
],
"result_images": []
}
],
"assistant_response": "你好!看起来你正在使用一个聊天界面。有什么我可以帮你的吗?",
"timestamp": "20260622_123753"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

@@ -1,170 +0,0 @@
{
"session_id": "20260622_135934",
"turn_id": "20260622_140405",
"user_message": {
"role": "user",
"content": "帮我打开bilibili,点击搜索“麻薯波比呀”,观看他发布的最新视频"
},
"tool_calls": [
{
"name": "browser_control",
"args": {
"action": "open",
"url": "https://www.bilibili.com"
},
"result_text": "已尝试打开 URL: https://www.bilibili.com",
"result_files": [
"_artifact_browser_control_0_0.png"
],
"result_images": []
},
{
"name": "browser_control",
"args": {
"action": "open",
"url": "https://www.bilibili.com"
},
"result_text": "已尝试打开 URL: https://www.bilibili.com",
"result_files": [
"_artifact_browser_control_1_0.png"
],
"result_images": []
},
{
"name": "browser_control",
"args": {
"action": "open",
"url": "https://www.bilibili.com"
},
"result_text": "已尝试打开 URL: https://www.bilibili.com",
"result_files": [
"_artifact_browser_control_2_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "搜索栏"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_0_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "type",
"text": "麻薯波比呀"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_1_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "hotkey",
"keys": [
"enter"
]
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_2_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "最新发布 按钮"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_3_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "最新发布的视频,缩略图上有特朗普和伊朗国旗,标题为“美国答应给伊朗3000亿, 但美国不出钱”"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_4_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "视频播放器中央区域"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_5_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "暂停/播放按钮"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_6_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "播放按钮"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_7_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"action": "click",
"element_description": "“取消”按钮"
},
"result_text": "",
"result_files": [
"_artifact_gui_tool_8_0.png"
],
"result_images": []
},
{
"name": "browser_control",
"args": {
"action": "open",
"url": "current"
},
"result_text": "已刷新当前标签页",
"result_files": [
"_artifact_browser_control_3_0.png"
],
"result_images": []
}
],
"assistant_response": "好的,我已经成功打开了Bilibili网站,搜索了UP主“麻薯波比呀”,并按发布时间排序找到了他的最新视频。当前屏幕上正在播放的视频标题为《美国答应给伊朗3000亿, 但美国不出钱》,发布者为“麻薯波比呀”。\n\n任务已完成,您现在可以观看这个视频。",
"timestamp": "20260622_140405"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -0,0 +1,172 @@
{
"session_id": "20260627_175913",
"turn_id": "20260627_180047",
"user_message": {
"role": "user",
"content": "请按照以下步骤进行:\r\n1、https://account.cnblogs.com/signin?returnUrl=https:%2F%2Fwww.cnblogs.com%2Fmegshuai%2Fp%2F18518288登录该网站\r\n2、输入账号:19553226590;密码:3139785954\r\n3、点击登录"
},
"tool_calls": [
{
"name": "todo_list",
"args": {
"steps": [
"打开浏览器",
"在地址栏输入登录URL并访问",
"等待页面加载完成",
"点击账号输入框",
"输入账号19553226590",
"点击密码输入框",
"输入密码3139785954",
"点击登录按钮",
"验证登录是否成功"
],
"done_step": -1
},
"result_text": "{\"__todo_list__\": true, \"data\": {\"steps\": [\"打开浏览器\", \"在地址栏输入登录URL并访问\", \"等待页面加载完成\", \"点击账号输入框\", \"输入账号19553226590\", \"点击密码输入框\", \"输入密码3139785954\", \"点击登录按钮\", \"验证登录是否成功\"], \"done_steps\": []}}\n[Todo List]\n- [1/9] [Pending]: 打开浏览器\n- [2/9] [Pending]: 在地址栏输入登录URL并访问\n- [3/9] [Pending]: 等待页面加载完成\n- [4/9] [Pending]: 点击账号输入框\n- [5/9] [Pending]: 输入账号19553226590\n- [6/9] [Pending]: 点击密码输入框\n- [7/9] [Pending]: 输入密码3139785954\n- [8/9] [Pending]: 点击登录按钮\n- [9/9] [Pending]: 验证登录是否成功",
"result_files": [],
"result_images": []
},
{
"name": "browser_control",
"args": {
"action": "open",
"url": "https://account.cnblogs.com/signin?returnUrl=https:%2F%2Fwww.cnblogs.com%2Fmegshuai%2Fp%2F18518288"
},
"result_text": "已尝试打开 URL: https://account.cnblogs.com/signin?returnUrl=https:%2F%2Fwww.cnblogs.com%2Fmegshuai%2Fp%2F18518288",
"result_files": [
"_artifact_browser_control_0_0.png"
],
"result_images": []
},
{
"name": "todo_list",
"args": {
"done_step": 2
},
"result_text": "{\"__todo_list__\": true, \"data\": {\"steps\": [\"打开浏览器\", \"在地址栏输入登录URL并访问\", \"等待页面加载完成\", \"点击账号输入框\", \"输入账号19553226590\", \"点击密码输入框\", \"输入密码3139785954\", \"点击登录按钮\", \"验证登录是否成功\"], \"done_steps\": [0, 1]}}\n[Todo List]\n- [1/9] [Done]: 打开浏览器\n- [2/9] [Done]: 在地址栏输入登录URL并访问\n- [3/9] [Pending]: 等待页面加载完成\n- [4/9] [Pending]: 点击账号输入框\n- [5/9] [Pending]: 输入账号19553226590\n- [6/9] [Pending]: 点击密码输入框\n- [7/9] [Pending]: 输入密码3139785954\n- [8/9] [Pending]: 点击登录按钮\n- [9/9] [Pending]: 验证登录是否成功",
"result_files": [],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"actions": [
{
"action": "click",
"element_description": "登录用户名/邮箱输入框"
}
]
},
"result_text": "1. Clicked target. grounded (502,531)",
"result_files": [
"_artifact_gui_tool_0_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"actions": [
{
"action": "type",
"element_description": "登录用户名/邮箱输入框",
"text": "19553226590"
}
]
},
"result_text": "1. Typed text.",
"result_files": [
"_artifact_gui_tool_1_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"actions": [
{
"action": "click",
"element_description": "密码输入框"
}
]
},
"result_text": "1. Clicked target. grounded (501,592)",
"result_files": [
"_artifact_gui_tool_2_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"actions": [
{
"action": "type",
"element_description": "密码输入框",
"text": "3139785954"
}
]
},
"result_text": "1. Typed text.",
"result_files": [
"_artifact_gui_tool_3_0.png"
],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"actions": [
{
"action": "click",
"element_description": "登录按钮"
}
]
},
"result_text": "1. Clicked target. grounded (500,689)",
"result_files": [
"_artifact_gui_tool_4_0.png"
],
"result_images": []
},
{
"name": "todo_list",
"args": {
"done_step": 8
},
"result_text": "{\"__todo_list__\": true, \"data\": {\"steps\": [\"打开浏览器\", \"在地址栏输入登录URL并访问\", \"等待页面加载完成\", \"点击账号输入框\", \"输入账号19553226590\", \"点击密码输入框\", \"输入密码3139785954\", \"点击登录按钮\", \"验证登录是否成功\"], \"done_steps\": [0, 1, 2, 3, 4, 5, 6, 7]}}\n[Todo List]\n- [1/9] [Done]: 打开浏览器\n- [2/9] [Done]: 在地址栏输入登录URL并访问\n- [3/9] [Done]: 等待页面加载完成\n- [4/9] [Done]: 点击账号输入框\n- [5/9] [Done]: 输入账号19553226590\n- [6/9] [Done]: 点击密码输入框\n- [7/9] [Done]: 输入密码3139785954\n- [8/9] [Done]: 点击登录按钮\n- [9/9] [Pending]: 验证登录是否成功",
"result_files": [],
"result_images": []
},
{
"name": "gui_tool",
"args": {
"actions": [
{
"action": "wait",
"wait_seconds": 3.0
},
{
"action": "screenshot"
}
]
},
"result_text": "1. Waited 1.50 seconds.\n2. Screenshot (skipped, final screenshot will be taken)",
"result_files": [
"_artifact_gui_tool_5_0.png"
],
"result_images": []
},
{
"name": "todo_list",
"args": {
"done_step": 9
},
"result_text": "{\"__todo_list__\": true, \"data\": {\"steps\": [\"打开浏览器\", \"在地址栏输入登录URL并访问\", \"等待页面加载完成\", \"点击账号输入框\", \"输入账号19553226590\", \"点击密码输入框\", \"输入密码3139785954\", \"点击登录按钮\", \"验证登录是否成功\"], \"done_steps\": [0, 1, 2, 3, 4, 5, 6, 7, 8]}}\n[Todo List]\n- [1/9] [Done]: 打开浏览器\n- [2/9] [Done]: 在地址栏输入登录URL并访问\n- [3/9] [Done]: 等待页面加载完成\n- [4/9] [Done]: 点击账号输入框\n- [5/9] [Done]: 输入账号19553226590\n- [6/9] [Done]: 点击密码输入框\n- [7/9] [Done]: 输入密码3139785954\n- [8/9] [Done]: 点击登录按钮\n- [9/9] [Done]: 验证登录是否成功",
"result_files": [],
"result_images": []
}
],
"assistant_response": "1. 分析上一轮执行结果:上一轮调用了 `todo_list` 标记所有步骤完成,结果显示任务已全部完成(9/9)。从之前的截图可以看到,登录尝试失败了,页面显示\"用户名或密码错误\"的红色提示。\n\n2. 任务状态:所有规划步骤已完成,但登录结果是失败的。需要向用户报告最终结果。\n\n3. 下一步操作:任务已完成,向用户汇报执行过程和结果。\n\n## 任务执行总结\n\n我已按照您的要求完成了所有步骤:\n\n✅ **已完成的步骤:**\n1. 打开浏览器并访问登录页面\n2. 输入账号:19553226590\n3. 输入密码:3139785954\n4. 点击登录按钮\n\n❌ **登录结果:失败**\n\n页面显示错误提示:\"**用户名或密码错误**\"\n\n可能的原因:\n- 账号或密码输入有误\n- 账号密码已变更\n- 账号可能不存在或被禁用\n\n建议您:\n1. 核对账号密码是否正确\n2. 尝试使用\"忘记密码\"功能重置密码\n3. 确认账号是否已注册并激活",
"timestamp": "20260627_180047"
}
+47
View File
@@ -75,6 +75,53 @@ DB_SYSTEM_PROMT = """
请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧!
""".strip()
PLAN_MODE_PROMPT = """
你是一个具备多模态理解能力的桌面操作助手,运行在 **Plan 模式**下。Plan 模式要求你在执行任务前先进行详细规划。
【Plan 模式核心规则】
- **首要任务**:收到用户请求后,你必须**第一步就调用 todo_list 工具**,将用户的任务拆解为非常细致、可执行的步骤列表。每个步骤应该具体到单个操作(如"打开浏览器"、"搜索XXX"、"截取屏幕"、"分析结果"等)。
- **规划要求**:步骤拆解必须详尽,覆盖从开始到完成的完整流程。不要跳过任何中间步骤。步骤描述要清晰具体,便于后续逐一执行和跟踪。
- **规划完成后**:生成 todolist 后,向用户简要汇报规划结果,然后**立即开始按步骤执行**,不要等待用户确认。
- **执行过程中**:每完成一个步骤,调用 todo_list 标记该步骤为已完成(done_step),让用户实时看到进度。
- **任务完成后**:确认所有步骤已完成,向用户总结执行过程和结果。
【通用行为准则】
- 你的首要职责是理解用户的意图,并高效地完成用户的任务。
- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图、OCR 等无关工具。
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
- 每次工具调用后,向用户简要汇报当前进度和下一步计划,确保用户始终了解任务状态。
- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。
""".strip()
# ---------- 反思 / Plan 首轮注入提示(作为 synthetic HumanMessage 注入到对话上下文)----------
# 这些不是系统提示词,而是在 call_model 节点中动态拼入消息列表的引导文本。
PLAN_FIRST_CALL_PROMPT = """
【系统指令 - Plan 模式】
你现在处于 Plan 模式。请**立即调用 todo_list 工具**,按照用户任务拆解为步骤列表。
规划要求:
- 如果用户任务包含多个子任务,每个子任务都需要被拆解为一个步骤。
- 每一步骤必须具体到GUI操作(如"打开XXX浏览器"、"搜索XXX"、"单击XXX"等),但注意必须是确定的GUI操作,不能是模糊的操作(如"找到XXX按钮"等)。
- **重要:合并同类操作**。同一个页面/界面上的多个连续操作应合并为一条步骤,用"并"连接,而不是拆成多条。例如:
* "点击顶部搜索框,输入'燕三嘤嘤嘤'并点击搜索"(合并了点击、输入、搜索三个操作)
* "在搜索结果中找到燕三嘤嘤嘤并点击头像进入他的主页"(合并了查找和点击)
* "在他的主页点开视频列表里的第一个视频并等待播放"(合并了点击和验证)
* "打开哔哩哔哩网站"(单个操作不需要合并)
* 反面示例(过于细碎,禁止):"1.点击搜索框 2.输入文字 3.点击搜索按钮 4.找到用户 5.点击头像"
- 步骤覆盖从开始到完成的完整流程,不要遗漏任何中间步骤,然后立即开始执行第一步,无需等待用户确认。
- 请注意:如果用户明确分条步骤,每个步骤都需要被拆解为一个步骤(但仍需合并同一页面的连续操作)。
""".strip()
REFLECTION_PROMPT = """
【请先在你的回复中输出你的反思,然后再调用工具或给出最终回复】
你需要先用简短的一句话在文字中思考下一步该做什么,然后进行相应的工具调用。格式如下:
1. 先分析上一轮执行结果:上一个调用了什么工具?结果是成功还是失败?
2. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。
3. 如果上一轮操作成功,说明你根据分析结果进行下一步操作。
4. 然后调用对应的工具执行下一步,或如果任务已完成则直接回复用户(注意只需要回复用户最终结果即可)。
""".strip()
TEAM_MODE_PROMPT = """
Team 模式目前处于预留阶段,多 Agent 协同功能尚未实现。
请向用户说明当前无法启动 Team 协同模式,并建议使用 Agent 模式来完成单 Agent 任务。
Binary file not shown.
Binary file not shown.
+78 -76
View File
@@ -15,31 +15,30 @@ from PIL import Image
from pydantic import BaseModel, Field
from agent.core.llm import llm
from agent.utils.env_utils import GROUNDING_HEIGHT, GROUNDING_WIDTH
import agent.utils.env_utils as env_utils
GUIAction = Literal[
"click",
"type",
"clickandtype",
"drag_and_drop",
"scroll",
"hotkey",
"hold_and_press",
"wait",
"screenshot",
"move_to",
"move_to",
]
class GUIToolInput(BaseModel):
# action 字段保持原样,无需修改
action: Optional[GUIAction] = Field(
default=None,
description="GUI action to execute. If None or empty, returns current screenshot.",
class GUIActionItem(BaseModel):
"""A single GUI action within an ordered sequence."""
action: GUIAction = Field(
...,
description="GUI action to execute.",
)
element_description: Optional[str] = Field(
default=None,
description="Natural-language description of the target screen element for grounded actions.",
description="Natural-language description of the target screen element for grounded actions (click, type, scroll, drag_and_drop, move_to). For type action, providing this will first locate and click the target input field before typing.",
)
ending_description: Optional[str] = Field(
default=None,
@@ -58,6 +57,13 @@ class GUIToolInput(BaseModel):
wait_seconds: float = Field(default=0.5, description="Seconds to wait.")
class GUIToolInput(BaseModel):
actions: list[GUIActionItem] = Field(
default_factory=list,
description="Ordered list of GUI actions to execute sequentially. Each item specifies one GUI operation (click, type, drag_and_drop, scroll, hotkey, hold_and_press, wait, screenshot, move_to). If empty, returns current screenshot without performing any action.",
)
def _get_gui_region() -> dict | None:
"""获取当前 GUI 操作显示器区域,失败返回 None(全屏)。"""
try:
@@ -81,7 +87,7 @@ def _screenshot_png() -> tuple[bytes, tuple[int, int]]:
def _resize_for_grounding(image_bytes: bytes) -> bytes:
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
image = image.resize((GROUNDING_WIDTH, GROUNDING_HEIGHT), Image.Resampling.LANCZOS)
image = image.resize((env_utils.GROUNDING_WIDTH, env_utils.GROUNDING_HEIGHT), Image.Resampling.LANCZOS)
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
@@ -98,8 +104,8 @@ def _parse_coordinates(text: str) -> tuple[int, int]:
raise ValueError(f"grounding model did not return coordinates: {text!r}")
x = int(float(numbers[0]))
y = int(float(numbers[1]))
x = max(0, min(GROUNDING_WIDTH - 1, x))
y = max(0, min(GROUNDING_HEIGHT - 1, y))
x = max(0, min(env_utils.GROUNDING_WIDTH - 1, x))
y = max(0, min(env_utils.GROUNDING_HEIGHT - 1, y))
return x, y
@@ -107,8 +113,8 @@ def _scale_to_screen(coords: tuple[int, int], screen_size: tuple[int, int]) -> t
"""将 grounding 坐标映射到绝对屏幕坐标(考虑 GUI 操作区域偏移)。"""
screen_width, screen_height = screen_size
x, y = coords
local_x = round(x * screen_width / GROUNDING_WIDTH)
local_y = round(y * screen_height / GROUNDING_HEIGHT)
local_x = round(x * screen_width / env_utils.GROUNDING_WIDTH)
local_y = round(y * screen_height / env_utils.GROUNDING_HEIGHT)
# 加上目标显示器在屏幕空间的偏移
region = _get_gui_region()
offset_x = region["x"] if region else 0
@@ -124,10 +130,10 @@ def _ground_element(element_description: str) -> tuple[int, int, str]:
grounding_png = _resize_for_grounding(raw_png)
prompt = (
"You are a visual grounding model for desktop GUI control.\n"
f"The screenshot has been resized to exactly {GROUNDING_WIDTH}x{GROUNDING_HEIGHT} pixels.\n"
f"The screenshot has been resized to exactly {env_utils.GROUNDING_WIDTH}x{env_utils.GROUNDING_HEIGHT} pixels.\n"
f"Query: {element_description.strip()}\n"
"Return only one target point as pixel coordinates in this exact format: x,y\n"
f"Example: {GROUNDING_WIDTH // 2},{GROUNDING_HEIGHT // 2}\n"
f"Example: {env_utils.GROUNDING_WIDTH // 2},{env_utils.GROUNDING_HEIGHT // 2}\n"
"Do not include any explanation."
)
response = llm.invoke(
@@ -183,9 +189,26 @@ class GUITool(BaseTool):
args_schema: type[BaseModel] = GUIToolInput
name: str = "gui_tool"
description: str = (
"GUI automation tool. Visually locates desktop elements from a screenshot, maps coordinates to the "
"real screen, and executes pyautogui actions. Supported actions: click, type, clickandtype, drag_and_drop, scroll, "
"hotkey, hold_and_press, wait, screenshot, move_to. Returns a screenshot of the current screen after the action."
"GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。"
"接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、"
"drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、"
"hold_and_press(按住组合键并敲击)、wait(等待)、screenshot(截图)、move_to(移动鼠标)。\n"
"【type 使用说明】type 支持 element_description 参数来描述在哪里输入。当提供了 element_description 时,"
"会先视觉定位并点击目标输入框,然后再输入文本,无需单独调用 click。不提供 element_description 则直接在当前焦点输入。\n"
"所有动作执行完毕后,返回当前屏幕的截图。\n"
"\n"
"示例:\n"
"- 登录表单:{\"actions\": ["
"{\"action\":\"type\",\"element_description\":\"用户名输入框\",\"text\":\"admin\"},"
"{\"action\":\"type\",\"element_description\":\"密码输入框\",\"text\":\"123456\",\"enter\":true}"
"]}\n"
"- 浏览器搜索:{\"actions\": ["
"{\"action\":\"type\",\"element_description\":\"浏览器地址栏\",\"text\":\"github.com\",\"enter\":true}"
"]}\n"
"- 等待并确认:{\"actions\": ["
"{\"action\":\"click\",\"element_description\":\"提交按钮\"},"
"{\"action\":\"wait\",\"wait_seconds\":2.0}"
"]}"
)
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
@@ -199,53 +222,32 @@ class GUITool(BaseTool):
def _run(
self,
action: Optional[str] = None,
element_description: Optional[str] = None,
ending_description: Optional[str] = None,
text: str = "",
overwrite: bool = False,
enter: bool = False,
num_clicks: int = 1,
button_type: str = "left",
hold_keys: Optional[list[str]] = None,
keys: Optional[list[str]] = None,
press_keys: Optional[list[str]] = None,
clicks: int = 0,
shift: bool = False,
wait_seconds: float = 0.0,
actions: list[GUIActionItem] | None = None,
) -> tuple[str, dict]:
# ---------- screenshot 动作:仅截图 ----------
if action == "screenshot":
return ("", self._artifact_screenshot())
# ---------- 无动作时直接截图 ----------
if not action:
if not actions:
return ("", self._artifact_screenshot())
# -------------------------------------------
dispatch_error: str | None = None
try:
self._dispatch(
action=action,
element_description=element_description,
ending_description=ending_description,
text=text,
overwrite=overwrite,
enter=enter,
num_clicks=num_clicks,
button_type=button_type,
hold_keys=hold_keys or [],
keys=keys or [],
press_keys=press_keys or [],
clicks=clicks,
shift=shift,
wait_seconds=wait_seconds,
)
except Exception as exc:
dispatch_error = f"[GUI Error] {action}: {exc}"
print(dispatch_error)
time.sleep(1)
if dispatch_error:
return dispatch_error, self._artifact_screenshot()
return "", self._artifact_screenshot()
# ---------- 顺序执行动作列表 ----------
result_messages: list[str] = [] # 收集每个动作的返回信息
for idx, act in enumerate(actions, start=1):
if act.action == "screenshot":
# screenshot 动作不需要实际执行,跳过并可选记录
result_messages.append(f"{idx}. Screenshot (skipped, final screenshot will be taken)")
continue
try:
msg = self._dispatch(**act.model_dump())
result_messages.append(f"{idx}. {msg}")
except Exception as exc:
error_msg = f"{idx}. [GUI Error] {act.action}: {exc}"
result_messages.append(error_msg)
print(error_msg)
time.sleep(1.2)
final_text = "\n".join(result_messages)
return (final_text, self._artifact_screenshot())
def _dispatch(self, **kwargs: Any) -> str:
action = str(kwargs["action"]).strip()
@@ -253,8 +255,6 @@ class GUITool(BaseTool):
return self._click(**kwargs)
if action == "type":
return self._type(**kwargs)
if action == "clickandtype":
return self._clickandtype(**kwargs)
if action == "drag_and_drop":
return self._drag_and_drop(**kwargs)
if action == "scroll":
@@ -264,7 +264,7 @@ class GUITool(BaseTool):
if action == "hold_and_press":
return self._hold_and_press(**kwargs)
if action == "wait":
seconds = min(1.0, float(kwargs["wait_seconds"]))
seconds = min(1.5, float(kwargs["wait_seconds"]))
time.sleep(seconds)
return f"Waited {seconds:.2f} seconds."
if action == "move_to":
@@ -282,6 +282,18 @@ class GUITool(BaseTool):
return f"Clicked target. {grounding}"
def _type(self, **kwargs: Any) -> str:
element_desc = (kwargs.get("element_description") or "").strip()
if element_desc:
# 提供了目标元素描述:先定位点击再输入
x, y, grounding = _ground_element(element_desc)
pyautogui.click(x, y)
if kwargs["overwrite"]:
_select_all_and_clear()
_type_text(kwargs["text"])
if kwargs["enter"]:
pyautogui.press("enter")
return f"Clicked target and typed text. {grounding}"
# 不提供 element_description:直接在当前焦点输入
if kwargs["overwrite"]:
_select_all_and_clear()
_type_text(kwargs["text"])
@@ -289,16 +301,6 @@ class GUITool(BaseTool):
pyautogui.press("enter")
return "Typed text."
def _clickandtype(self, **kwargs: Any) -> str:
x, y, grounding = _ground_element(kwargs["element_description"])
pyautogui.click(x, y)
if kwargs["overwrite"]:
_select_all_and_clear()
_type_text(kwargs["text"])
if kwargs["enter"]:
pyautogui.press("enter")
return f"Clicked target and typed text. {grounding}"
def _drag_and_drop(self, **kwargs: Any) -> str:
x1, y1, start_grounding = _ground_element(kwargs["element_description"] or "")
x2, y2, end_grounding = _ground_element(kwargs["ending_description"] or "")
+34 -24
View File
@@ -16,7 +16,7 @@ from langchain.tools import BaseTool
from pydantic import BaseModel, Field, create_model, model_validator
from agent.core.llm import llm
from agent.utils.env_utils import BROWSER_MAP, GROUNDING_HEIGHT, GROUNDING_WIDTH
import agent.utils.env_utils as env_utils
class WebSearch(BaseTool):
@@ -99,9 +99,9 @@ class BrowserControl(BaseTool):
if browser_name is None:
return webbrowser.get(), None
name_lower = browser_name.lower().strip()
if name_lower not in BROWSER_MAP:
raise ValueError(f"不支持的浏览器: {browser_name},支持: {', '.join(BROWSER_MAP.keys())}")
reg_name, title_keyword = BROWSER_MAP[name_lower]
if name_lower not in env_utils.BROWSER_MAP:
raise ValueError(f"不支持的浏览器: {browser_name},支持: {', '.join(env_utils.BROWSER_MAP.keys())}")
reg_name, title_keyword = env_utils.BROWSER_MAP[name_lower]
try:
controller = webbrowser.get(reg_name)
except webbrowser.Error:
@@ -132,7 +132,7 @@ class BrowserControl(BaseTool):
# ---------- 核心操作(返回操作结果描述) ----------
def _try_activate_any_browser(self) -> bool:
"""尝试激活任意已知浏览器窗口(不指定 browser 时的回退方案)。"""
for _reg_name, title_keyword in BROWSER_MAP.values():
for _reg_name, title_keyword in env_utils.BROWSER_MAP.values():
if self._activate_window(title_keyword):
return True
return False
@@ -212,7 +212,7 @@ class BrowserControl(BaseTool):
time.sleep(0.3)
try:
pil_img = pyautogui.screenshot()
pil_img = pil_img.resize((GROUNDING_WIDTH, GROUNDING_HEIGHT), Image.Resampling.LANCZOS)
pil_img = pil_img.resize((env_utils.GROUNDING_WIDTH, env_utils.GROUNDING_HEIGHT), Image.Resampling.LANCZOS)
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
raw_png = buf.getvalue()
@@ -375,10 +375,10 @@ def update_todo_store_session(
done_steps = []
if stored_steps and 1 <= done_step <= len(stored_steps):
idx = done_step - 1
if idx not in done_steps:
done_steps.append(idx)
done_steps.sort()
for i in range(done_step):
if i not in done_steps:
done_steps.append(i)
done_steps.sort()
todo_data = {"steps": stored_steps, "done_steps": done_steps}
_todo_store[session_id] = todo_data
@@ -423,7 +423,7 @@ class TodoListInput(BaseModel):
)
done_step: int = Field(
default=-1,
description="本次新完成的步骤编号(从1开始)。-1 表示仅查看当前状态,不标记完成。",
description="当前已推进到的步骤编号(从1开始)。传入 n 则步骤 1~n 全部标记为完成。-1 表示仅查看当前状态。",
)
@model_validator(mode="before")
@@ -441,20 +441,30 @@ class TodoListInput(BaseModel):
class TodoList(BaseTool):
"""可选的任务列表工具,模型可主动调用以跟踪任务进度。"""
"""任务管理工具,用于拆分复杂任务为多个步骤并跟踪进度。
Plan 模式下此工具为**必须调用**,Agent 模式下为可选。
首次调用时传入完整步骤列表,后续仅传 done_step 标记进度。
"""
args_schema: type[BaseModel] = TodoListInput
name: str = "todo_list"
description: str = (
"可选的任务管理工具。当你需要拆分复杂任务为多个步骤并跟踪进度时调用。\n"
"任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。\n"
"Plan 模式下必须首先调用此工具创建任务计划;Agent 模式下按需调用。\n"
"参数说明:\n"
"- steps (list[str], 可选):任务步骤列表。仅在首次创建计划时传入所有步骤。后续更新进度时请省略此参数(传 null 或不传),工具会复用之前设定的步骤列表,省去重复传输。\n"
"- done_step (int, 可选):刚完成的步骤编号(从1开始),一次只标记一个。不传或传 -1 则只查看当前状态不做修改。\n"
"- steps (list[str], 可选):任务步骤列表。仅在首次创建计划时传入所有步骤。\n"
" **步骤粒度要求**:同一页面/界面上的连续操作应合并为一条步骤,用\"并\"连接。例如:\n"
' * 推荐:"点击搜索框,输入XXX并点击搜索"(合并了点击、输入、搜索)\n'
' * 推荐:"在结果中点击目标进入主页"(合并了查找+点击)\n'
' * 禁止:"1.点击搜索框 2.输入文字 3.点击搜索"(过于细碎)\n'
"- done_step (int, 可选):当前已推进到的步骤编号(从1开始)。传入 n 则步骤 1~n 全部标记为已完成。不传或传 -1 则只查看当前状态。\n"
"调用示例:\n"
'- 首次创建:{"steps": ["准备食材", "切菜", "烹饪", "装盘"], "done_step": -1}\n'
'- 完成第1步:"done_step": 1}(省略 steps)\n'
'- 查看状态:不传任何参数或 {"done_step": -1}\n'
"仅在需要跟踪多步骤任务进度时调用,简单任务不需要调用。"
'- 首次创建(B站搜索示例):{"steps": ["打开哔哩哔哩网站", "点击顶部搜索框,输入\'燕三嘤嘤嘤\'并点击搜索", "在搜索结果中找到燕三嘤嘤嘤并点击头像或名称进入他的主页", "在他的主页点开视频列表里的第一个视频", "验证视频是否正在播放"], "done_step": -1}\n'
'- 已完成前2步:{"done_step": 2} (步骤1和2均标记为完成)\n'
'- 已完成前4步:{"done_step": 4}\n'
'- 查看状态:{"done_step": -1}\n'
"任务全部完成后无需再次调用。"
)
response_format: Literal["content", "content_and_artifact"] = "content"
@@ -472,12 +482,12 @@ class TodoList(BaseTool):
self._stored_steps = steps
self._done_set = []
# 标记新完成步骤(1-indexed → 0-indexed)
# 标记步骤完成:传入 done_step 时,该步及之前所有步骤均标记为完成(1-indexed → 0-indexed)
if self._stored_steps and 1 <= done_step <= len(self._stored_steps):
idx = done_step - 1
if idx not in self._done_set:
self._done_set.append(idx)
self._done_set.sort()
for i in range(done_step):
if i not in self._done_set:
self._done_set.append(i)
self._done_set.sort()
todo_data = {
"steps": self._stored_steps,
+61
View File
@@ -23,6 +23,7 @@ except Exception:
_windll = getattr(ctypes, "windll", None)
user32: object | None = _windll.user32 if os.name == "nt" and _windll is not None else None
WM_CLOSE = 0x0010
SW_RESTORE = 9
_INDEX_PATH = Path(__file__).resolve().parents[1] / "history" / "data_base" / "software_index.json"
@@ -48,6 +49,14 @@ _SKIP_KEYWORDS = (
"license",
"update",
)
_CHAT_APP_PROCESS_NAMES = {
"qq": ["QQ", "NTQQ"],
"腾讯qq": ["QQ", "NTQQ"],
"腾讯QQ": ["QQ", "NTQQ"],
"wechat": ["WeChat", "Weixin"],
"weixin": ["WeChat", "Weixin"],
"微信": ["WeChat", "Weixin"],
}
@dataclass
@@ -354,6 +363,43 @@ def _find_visible_windows_for_pids(pids: list[int]) -> list[tuple[int, int, str]
return results
def _chat_app_process_names(query: str, record: SoftwareRecord) -> list[str]:
candidates = [query, record.display_name, *record.aliases]
for candidate in candidates:
key = _normalize(candidate)
if key in _CHAT_APP_PROCESS_NAMES:
return _CHAT_APP_PROCESS_NAMES[key]
return []
def _find_pids_for_process_names(process_names: list[str]) -> list[int]:
process_keys = {_normalize(name) for name in process_names if name}
matched: list[int] = []
for proc in _list_running_processes():
if _normalize(proc["stem"]) in process_keys:
try:
matched.append(int(proc["pid"]))
except ValueError:
continue
return sorted(set(matched))
def _activate_existing_window_for_pids(pids: list[int]) -> tuple[int, int, str] | None:
if user32 is None or not pids:
return None
windows = _find_visible_windows_for_pids(pids)
if not windows:
return None
pid, hwnd, title = windows[0]
try:
user32.ShowWindow(hwnd, SW_RESTORE)
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
except Exception:
pass
return pid, hwnd, title
def _wait_for_pids_exit(pids: list[int], timeout_seconds: float = 5.0) -> list[int]:
deadline = time.time() + timeout_seconds
remaining = set(pids)
@@ -449,6 +495,21 @@ class SoftwareControl(BaseTool):
)
if action == "open":
running_pids = _find_running_pids(best_record)
chat_process_names = _chat_app_process_names(query, best_record)
if chat_process_names:
running_pids = sorted(set(running_pids + _find_pids_for_process_names(chat_process_names)))
existing_window = _activate_existing_window_for_pids(running_pids)
if existing_window:
pid, hwnd, title = existing_window
return (
f"Switched to existing software window: {best_record.display_name}\n"
f"Match score: {best_score:.2f}\n"
f"PID: {pid}\n"
f"Window title: {title}\n"
f"Window handle: {hwnd}\n"
"Note: an existing visible window was detected, so the shortcut was not launched again."
)
try:
opened_target = _open_record(best_record)
except Exception as exc:
+16
View File
@@ -121,6 +121,22 @@ def _load_config() -> dict:
return {}
def reload_config() -> dict:
"""实时重载 env_config.json 配置,刷新所有模块级常量。
可在不重启进程的情况下重新读取配置文件,更新 GROUNDING_WIDTH 等运行时参数。
注意:调用方需通过 ``import agent.utils.env_utils as env_utils`` 然后访问
``env_utils.GROUNDING_WIDTH`` 才能获取到更新后的值,直接 ``from ... import``
的方式会绑定旧值。
输出:
加载到的配置字典,失败时返回空字典。
"""
result = _load_config()
_refresh_globals()
return result
_load_config()
_refresh_globals() # 确保无论配置加载成功与否,都有默认值
Binary file not shown.
Binary file not shown.
+104
View File
@@ -1209,6 +1209,17 @@ body {
cursor: not-allowed;
}
.btn-pause {
background: linear-gradient(165deg, #f59e0b, #d97706);
border-color: rgba(217, 119, 6, 0.85);
color: #fff;
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35);
}
.btn-pause:hover:not(:disabled) {
background: linear-gradient(165deg, #fbbf24, #f59e0b);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.35);
}
@@ -2034,6 +2045,7 @@ body {
.model-table .col-name { flex: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: #1e293b; }
.model-table .col-model { flex: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.78rem; }
.model-table .col-url { flex: 2.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #94a3b8; font-size: 0.72rem; }
.model-table .col-think { flex: 0 0 52px; text-align: center; font-size: 0.72rem; font-weight: 600; }
.model-table .col-actions { flex: 0 0 90px; display: flex; gap: 0.35rem; justify-content: flex-end; align-items: center; }
/* 表格内操作按钮 */
@@ -2119,6 +2131,13 @@ body {
gap: 0.5rem;
}
.toggle-hint {
font-size: 0.72rem;
color: #94a3b8;
margin-left: 0.35rem;
vertical-align: middle;
}
.toggle-row .toggle-label {
font-size: 0.82rem;
font-weight: 600;
@@ -2374,6 +2393,91 @@ body {
opacity: 0.92;
}
/* ===== 反思(think)面板 ===== */
.think-indicator {
margin-top: 0.35rem;
padding: 0.25rem 0;
border-top: 1px solid rgba(148, 163, 184, 0.10);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.think-indicator-icon {
font-size: 0.8rem;
}
.think-indicator-text {
font-size: 0.72rem;
color: rgba(100, 116, 139, 0.55);
font-weight: 400;
letter-spacing: 0.02em;
transition: color 0.3s;
}
.think-indicator:hover .think-indicator-text {
color: rgba(100, 116, 139, 0.75);
}
.think-list-wrap {
display: none;
margin-top: 0.15rem;
max-height: 220px;
overflow-y: auto;
padding: 0.15rem 0;
border-top: 1px solid rgba(148, 163, 184, 0.06);
}
.think-list-wrap--expanded {
display: block;
}
.think-item {
padding: 0.18rem 0.3rem;
border-radius: 4px;
font-size: 0.7rem;
color: rgba(51, 65, 85, 0.48);
line-height: 1.35;
transition: color 0.3s;
}
.think-item:hover {
background: rgba(148, 163, 184, 0.05);
}
.think-item-summary {
display: flex;
align-items: center;
gap: 0.2rem;
}
.think-bullet {
color: rgba(100, 116, 139, 0.40);
font-size: 0.65rem;
}
.think-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.think-item-detail {
margin-top: 0.3rem;
padding: 0.35rem 0.45rem;
background: rgba(148, 163, 184, 0.06);
border-radius: 4px;
font-size: 0.68rem;
line-height: 1.5;
color: rgba(51, 65, 85, 0.55);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
font-family: inherit;
}
.todo-list-overlay {
position: fixed;
top: 80px;
+183 -9
View File
@@ -39,6 +39,7 @@
/** @type {File[]} */
let pendingFiles = [];
let sending = false;
let abortController = null;
let mediaRecorder = null;
let recorderStream = null;
let recordedChunks = [];
@@ -1171,6 +1172,93 @@
// ===== 工具调用展示(chat 视图 / 持久化) =====
let liveToolCallExpanded = false;
let thinkExpanded = false;
/** 在思考气泡内渲染反思(think)面板 */
function injectThinkPanel(typingEl, reflections) {
if (!typingEl) return;
const bubble = typingEl.querySelector(".msg-bubble") || typingEl;
// 反思指示器
let thinkIndicator = bubble.querySelector(".think-indicator");
if (!thinkIndicator) {
thinkIndicator = document.createElement("div");
thinkIndicator.className = "think-indicator";
thinkIndicator.title = "点击展开/收起 Agent 反思内容";
thinkIndicator.style.display = "none";
// 插入到 agent-running-indicator 前面
const runningIndicator = bubble.querySelector(".agent-running-indicator");
if (runningIndicator) {
bubble.insertBefore(thinkIndicator, runningIndicator);
} else {
bubble.appendChild(thinkIndicator);
}
thinkIndicator.addEventListener("click", () => {
thinkExpanded = !thinkExpanded;
const wrap = bubble.querySelector(".think-list-wrap");
if (wrap) wrap.classList.toggle("think-list-wrap--expanded", thinkExpanded);
updateThinkIndicatorText(thinkIndicator, reflections);
});
}
if (!reflections || !reflections.length) {
thinkIndicator.style.display = "none";
const wrap = bubble.querySelector(".think-list-wrap");
if (wrap) wrap.remove();
return;
}
thinkIndicator.style.display = "";
updateThinkIndicatorText(thinkIndicator, reflections);
// 反思列表容器
let listWrap = bubble.querySelector(".think-list-wrap");
if (!listWrap) {
listWrap = document.createElement("div");
listWrap.className = "think-list-wrap" + (thinkExpanded ? " think-list-wrap--expanded" : "");
// 插入到 think-indicator 后面
thinkIndicator.insertAdjacentElement("afterend", listWrap);
}
listWrap.classList.toggle("think-list-wrap--expanded", thinkExpanded);
// 增量更新列表项
const existingCount = listWrap.querySelectorAll(".think-item").length;
if (reflections.length <= existingCount) return; // 无新增
for (let i = existingCount; i < reflections.length; i++) {
const ref = reflections[i];
const item = document.createElement("div");
item.className = "think-item";
// 摘要行
const summary = document.createElement("div");
summary.className = "think-item-summary";
summary.innerHTML = `<span class="think-bullet">•</span> <span class="think-label">${escapeHtml(ref.summary || "反思")}</span>`;
item.appendChild(summary);
// 存储完整内容
item._thinkContent = ref.content || "";
// 点击展开完整内容
item.style.cursor = "pointer";
item.addEventListener("click", () => {
let detail = item.querySelector(".think-item-detail");
if (detail) {
detail.remove();
item.setAttribute("aria-expanded", "false");
return;
}
detail = document.createElement("pre");
detail.className = "think-item-detail";
detail.textContent = item._thinkContent;
item.appendChild(detail);
item.setAttribute("aria-expanded", "true");
});
listWrap.appendChild(item);
}
}
function updateThinkIndicatorText(indicator, reflections) {
const count = reflections ? reflections.length : 0;
indicator.innerHTML = `<span class="think-indicator-icon">🧠</span> <span class="think-indicator-text">think (${count})${thinkExpanded ? " ▾" : " ▸"}</span>`;
}
/** 在思考气泡内更新工具调用指示器与列表(增量更新,不重建 DOM) */
function injectLiveToolCalls(typingEl, liveCalls) {
@@ -1552,6 +1640,43 @@
renderMessages(data.messages);
}
function setSendBtnPauseMode(pauseMode) {
if (!sendBtn) return;
if (pauseMode) {
sendBtn.textContent = "暂停";
sendBtn.classList.add("btn-pause");
} else {
sendBtn.textContent = "发送";
sendBtn.classList.remove("btn-pause");
}
}
async function handlePauseClick() {
if (!sessionId || !sending || !abortController) return;
if (!window.confirm("确认暂停当前任务?\n\n暂停后当前对话将恢复到上一条消息的状态,工具调用记录和TodoList将被清空。")) return;
// 1. 调用后端 abort
try {
const fd = new FormData();
fd.append("session_id", sessionId);
const data = await api("/api/chat/abort", { method: "POST", body: fd });
sessionId = data.sessionId;
sessions = data.sessions || [];
clearTodoOverlay();
renderSessions();
renderMessages(data.messages || []);
} catch (e) {
// 回退:至少恢复 priorMessages
}
// 2. 取消前端 fetch
if (abortController) {
abortController.abort();
abortController = null;
}
sending = false;
setSendBtnPauseMode(false);
sendBtn.disabled = false;
}
async function sendChat() {
if (!sessionId || sending) return;
const trimmed = textInput.value.trim();
@@ -1565,6 +1690,9 @@
const blobUrls = [];
const hadOptimisticMedia = savedFiles.length > 0;
// 切换发送按钮为暂停按钮
setSendBtnPauseMode(true);
if (hadOptimisticMedia) {
const fileParts = savedFiles.map((f) => {
const kind = inferUploadKind(f);
@@ -1585,6 +1713,7 @@
renderAttachmentChips();
}
abortController = new AbortController();
const fd = new FormData();
fd.append("session_id", sessionId);
fd.append("text", trimmed);
@@ -1608,17 +1737,24 @@
// 清空上轮工具调用状态(新 turn)
liveToolCallExpanded = false;
thinkExpanded = false;
clearTodoOverlay();
// 启动实时工具调用轮询(增量更新,低频率不卡)
// 启动实时工具调用与反思轮询(增量更新,低频率不卡)
let livePollTimer = null;
let livePollActive = true;
const pollLiveToolCalls = async () => {
if (!livePollActive) return;
try {
const liveData = await api(`/api/tool-calls/${encodeURIComponent(sessionId)}/live`);
if (liveData.tool_calls && liveData.tool_calls.length > 0 && typingEl) {
injectLiveToolCalls(typingEl, liveData.tool_calls);
if (typingEl) {
// 先更新 think 面板
const reflections = liveData.reflections || [];
injectThinkPanel(typingEl, reflections);
// 再更新工具调用列表
if (liveData.tool_calls && liveData.tool_calls.length > 0) {
injectLiveToolCalls(typingEl, liveData.tool_calls);
}
}
} catch { /* ignore */ }
if (livePollActive) livePollTimer = setTimeout(pollLiveToolCalls, 1200);
@@ -1643,7 +1779,7 @@
fd2.append("session_id", sessionId);
fd2.append("output_type", outputModeSelect ? outputModeSelect.value : "text");
fd2.append("agent_mode", agentModeSelect ? agentModeSelect.value : "agent");
const done = await api("/api/chat/complete", { method: "POST", body: fd2 });
const done = await api("/api/chat/complete", { method: "POST", body: fd2, signal: abortController.signal });
try {
const todoData = await api(`/api/todo-list/${encodeURIComponent(sessionId)}`);
@@ -1681,16 +1817,23 @@
}
} catch (e) {
revokeBlobUrlStack(blobUrls);
if (hadOptimisticMedia) {
if (e && e.name === "AbortError") {
// 用户点击了暂停 → 不显示错误,恢复 priorMessages
renderMessages(priorMessages);
} else if (hadOptimisticMedia) {
textInput.value = savedTextRaw;
pendingFiles = savedFiles.slice();
renderAttachmentChips();
renderMessages(priorMessages);
}
if (typingEl && typingEl.parentNode) typingEl.remove();
alert(e.message || String(e));
if (!(e && e.name === "AbortError")) {
alert(e.message || String(e));
}
} finally {
sending = false;
abortController = null;
setSendBtnPauseMode(false);
sendBtn.disabled = false;
}
}
@@ -1721,7 +1864,13 @@
}
});
sendBtn.addEventListener("click", sendChat);
sendBtn.addEventListener("click", () => {
if (sending) {
handlePauseClick().catch((e) => alert(e.message || String(e)));
} else {
sendChat();
}
});
micBtn?.addEventListener("click", () => toggleMicRecording().catch((e) => alert(e.message || String(e))));
autoInputCheck?.addEventListener("change", (ev) => toggleAutoInput(ev.target.checked));
clearBtn.addEventListener("click", () => clearChat().catch((e) => alert(e.message)));
@@ -2615,14 +2764,15 @@
table.className = "model-table";
const thead = document.createElement("div");
thead.className = "model-table-head";
thead.innerHTML = '<span class="col-name">名称</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">操作</span>';
thead.innerHTML = '<span class="col-name">名称</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-think">思考</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">操作</span>';
table.appendChild(thead);
cachedModels.forEach((m, mi) => {
const row = document.createElement("div");
row.className = "model-table-row";
row.innerHTML = `<span class="col-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</span>
<span class="col-model" title="${escapeHtml(m.model)}">${escapeHtml(m.model)}</span>
<span class="col-url" title="${escapeHtml(m.base_url)}">${escapeHtml(m.base_url)}</span>`;
<span class="col-url" title="${escapeHtml(m.base_url)}">${escapeHtml(m.base_url)}</span>
<span class="col-think" style="text-align:center;">${m.enable_thinking ? '<span title="已启用思考" style="color:#f59e0b;">ON</span>' : '<span title="思考已关闭" style="color:#94a3b8;">OFF</span>'}</span>`;
const actions = document.createElement("span");
actions.className = "col-actions";
const editBtn = document.createElement("button");
@@ -2676,6 +2826,27 @@
modalFields[key] = inp;
modalBody.appendChild(div);
});
// 思考模式开关
const thinkingDiv = document.createElement("div");
thinkingDiv.className = "modal-field";
const thinkingLabel = document.createElement("label");
thinkingLabel.textContent = "思考模式";
const thinkingWrap = document.createElement("label");
thinkingWrap.className = "toggle-switch";
const thinkingCheckbox = document.createElement("input");
thinkingCheckbox.type = "checkbox";
const thinkingSlider = document.createElement("span");
thinkingSlider.className = "toggle-slider";
thinkingWrap.appendChild(thinkingCheckbox);
thinkingWrap.appendChild(thinkingSlider);
const thinkingHint = document.createElement("span");
thinkingHint.className = "toggle-hint";
thinkingHint.textContent = " 关闭后禁用模型思考(默认关闭,减少延迟)";
thinkingDiv.appendChild(thinkingLabel);
thinkingDiv.appendChild(thinkingWrap);
thinkingDiv.appendChild(thinkingHint);
modalFields["enable_thinking"] = thinkingCheckbox;
modalBody.appendChild(thinkingDiv);
modalBox.appendChild(modalBody);
const modalActions = document.createElement("div");
modalActions.className = "model-modal-actions";
@@ -2697,6 +2868,7 @@
base_url: modalFields.base_url.value.trim(),
timeout: parseFloat(modalFields.timeout.value) || 60,
max_retries: 0,
enable_thinking: modalFields.enable_thinking.checked,
};
if (!data.name || !data.model) { alert("名称和模型名不能为空"); return; }
if (editingModelIdx >= 0) {
@@ -2758,12 +2930,14 @@
fields.api_key.value = m.api_key || "";
fields.base_url.value = m.base_url || "";
fields.timeout.value = m.timeout || 60;
fields.enable_thinking.checked = !!m.enable_thinking;
} else {
fields.name.value = "";
fields.model.value = "";
fields.api_key.value = "";
fields.base_url.value = "";
fields.timeout.value = 60;
fields.enable_thinking.checked = false;
}
overlay.classList.add("show");
}
+1
View File
@@ -125,6 +125,7 @@
<label class="output-mode-label" for="agentModeSelect">Agent 模式</label>
<select id="agentModeSelect" class="output-mode-select" aria-label="Agent 工作模式">
<option value="agent">agent</option>
<option value="plan">plan</option>
<option value="team">team</option>
</select>
<!-- 自动语音输入开关 -->
+48 -4
View File
@@ -580,6 +580,47 @@ def api_chat_complete(
return _chat_complete_core(session_id, output_type=output_type, agent_mode=agent_mode)
@app.post("/api/chat/abort")
def api_chat_abort(session_id: str = Form(...)) -> dict[str, Any]:
"""中止当前正在执行的 Agent 任务,回滚到最后一条消息之前的状态。
输入:
session_id: 表单字段,会话 ID。
输出:
{"sessionId": str, "sessions": list[str], "messages": list[dict]}
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
try:
from agent.core.runtime import set_abort_flag
set_abort_flag(session_id)
except Exception:
pass
# 回滚 manifest:移除最后一条 user 消息及后续 assistant
hist = ui.load_ui_messages_for_session(session_id)
rolled_back = list(hist or [])
while rolled_back and rolled_back[-1].get("role") != "user":
rolled_back.pop()
if rolled_back:
rolled_back.pop()
ui.sync_manifest_from_chat_history(session_id, rolled_back, "")
# 清除 todo store
try:
from agent.tools.tool import clear_todo_store_session
clear_todo_store_session(session_id)
except Exception:
pass
ids = ui.list_session_ids_ordered()
if session_id not in ids:
ids = [session_id, *ids]
return {
"sessionId": session_id,
"sessions": ids,
"messages": _serialize_messages(rolled_back, session_id),
}
@@ -859,12 +900,15 @@ def api_reload_configs() -> dict[str, Any]:
# ---------- 工具调用历史 API ----------
@app.get("/api/tool-calls/{session_id}/live")
def api_get_live_tool_calls(session_id: str) -> dict[str, Any]:
"""获取当前进行中的工具调用(前端执行期间实时轮询)。"""
"""获取当前进行中的工具调用与反思内容(前端执行期间实时轮询)。"""
try:
from agent.history.tool_call_recorder import get_live_tool_calls
return {"tool_calls": get_live_tool_calls(session_id)}
from agent.history.tool_call_recorder import get_live_tool_calls, get_live_reflections
return {
"tool_calls": get_live_tool_calls(session_id),
"reflections": get_live_reflections(session_id),
}
except Exception as e:
return {"tool_calls": [], "error": str(e)}
return {"tool_calls": [], "reflections": [], "error": str(e)}
@app.get("/api/tool-image")