diff --git a/src/agent/agents/agent_runtime.py b/src/agent/agents/agent_runtime.py index 77fb687..18c1755 100644 --- a/src/agent/agents/agent_runtime.py +++ b/src/agent/agents/agent_runtime.py @@ -30,9 +30,9 @@ def _build_llm_for_agent(cfg: AgentConfig): """根据 Agent 配置中指定的 llm_model_id 构建 LLM 实例。 从 env_config.json 的 models 列表中查找匹配的模型配置, - 用其参数构造 ChatQwen 实例。若未找到匹配模型则回退到环境变量默认值。 + 用其参数构造 Multimodel_LLM 实例。若未找到匹配模型则回退到环境变量默认值。 """ - from agent.core.llm import ChatQwen + from agent.core.llm import Multimodel_LLM models = load_models() model_config = None @@ -44,10 +44,10 @@ def _build_llm_for_agent(cfg: AgentConfig): if model_config: # 深度思考由全局 THINKING_LEVEL 档位控制(low/high 关闭,xhigh/max/ultra 开启), # 取代原先 per-model 的 enable_thinking 开关。true 档位传空 extra_body 恢复模型 - # 原生行为(启用思考);否则不传,由 ChatQwen 使用禁用思考的默认值。 + # 原生行为(启用思考);否则不传,由 Multimodel_LLM 使用禁用思考的默认值。 from agent.utils.env_utils import get_thinking_extra_body extra_body = get_thinking_extra_body() - return ChatQwen( + return Multimodel_LLM( model=model_config.get("model", ""), api_key=model_config.get("api_key", ""), base_url=model_config.get("base_url", ""), @@ -55,13 +55,9 @@ def _build_llm_for_agent(cfg: AgentConfig): max_retries=int(model_config.get("max_retries", 0)), extra_body=extra_body, ) - # 回退:使用环境变量中已加载的默认 LLM 配置 - from agent.core.llm import llm as _default_llm - return ChatQwen( - model=_default_llm.model_name, - api_key=_default_llm.openai_api_key or "", - base_url=_default_llm.openai_api_base or "", - ) + # 回退:主 Agent 配置的模型兜底(与模块级 llm 单例一致,优先主 Agent 而非模型列表第一个) + from agent.core.llm import build_default_llm + return build_default_llm() def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime: diff --git a/src/agent/config/env_config_dist.json b/src/agent/config/env_config_dist.json index ed530a7..567e8b3 100644 --- a/src/agent/config/env_config_dist.json +++ b/src/agent/config/env_config_dist.json @@ -14,9 +14,7 @@ "RAG_CHUNK_OVERLAP": "50", "GROUNDING_WIDTH": "1000", "GROUNDING_HEIGHT": "1000", - "LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20", "LOOP_DETECT_REPEATED_TOOL_WARN": "3", - "LOOP_DETECT_REPEATED_TOOL_END": "5", "SEND_FILE_SIZE_LIMIT": "30", "EMAIL_ADDRESS": "", "EMAIL_AUTH_CODE": "", diff --git a/src/agent/core/__init__.py b/src/agent/core/__init__.py index f464605..0cb48a7 100644 --- a/src/agent/core/__init__.py +++ b/src/agent/core/__init__.py @@ -6,7 +6,7 @@ - runtime: 对外执行入口 execute_agent - human_request: 人工请求注册表(交互类工具的通用阻塞通道) - turn_runner: 后台线程轮次执行器 - - llm: ChatQwen 多模态网关 + - llm: Multimodel_LLM 多模态网关 - tool_policy: 工具执行安全策略 系统定位: diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 82e1aff..4403fa3 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -369,9 +369,7 @@ def _default_env_config() -> dict: "RAG_CHUNK_OVERLAP": "50", "GROUNDING_WIDTH": "1000", "GROUNDING_HEIGHT": "1000", - "LOOP_DETECT_TODOLIST_STALE_ROUNDS": "10", "LOOP_DETECT_REPEATED_TOOL_WARN": "3", - "LOOP_DETECT_REPEATED_TOOL_END": "5", "SEND_FILE_SIZE_LIMIT": "30", "STORAGE_BACKEND": "json", "MYSQL_HOST": "localhost", @@ -499,19 +497,19 @@ def get_gui_model_config() -> dict | None: def get_gui_llm(): """获取 GUI 工具专用的 LLM 实例。 - 若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 ChatQwen 实例; + 若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 Multimodel_LLM 实例; 否则返回 None(调用方应回退到全局 llm 单例)。 返回: - ChatQwen 实例或 None + Multimodel_LLM 实例或 None """ model_cfg = get_gui_model_config() if model_cfg is None: return None - from agent.core.llm import ChatQwen + from agent.core.llm import Multimodel_LLM # 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking from agent.utils.env_utils import get_thinking_extra_body - return ChatQwen( + return Multimodel_LLM( model=model_cfg["model"], api_key=model_cfg["api_key"], base_url=model_cfg["base_url"], diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py index 4f0627e..f3fd5b7 100644 --- a/src/agent/core/llm.py +++ b/src/agent/core/llm.py @@ -1,9 +1,10 @@ -"""ChatQwen:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。 +"""Multimodel_LLM:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。 系统定位: 项目默认 LLM 单例 ``llm``,被 agents 绑定工具,并承担: - 用户消息预处理(附件、ASR、图片 data URL) - 非标准 tool_call XML/JSON 解析 + - 深度思考归一化(独立 reasoning_content / 内联 块) - Vision 描述接口 依赖: @@ -25,10 +26,11 @@ from langchain_openai import ChatOpenAI from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT from agent.utils.agent_utils import assistant_text, Documents_process, audio_file_to_text, make_attachment_text from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS +from agent.utils.tool_call_utils import split_inline_thinking from agent.memory.system_prompt import VISION_SYSTEM_PROMPT -class ChatQwen(ChatOpenAI): - """Qwen 系列模型的 LangChain 封装,扩展多模态能力。 +class Multimodel_LLM(ChatOpenAI): + """基于 OpenAI 兼容 API 的多模态模型封装,扩展深度思考归一化能力。 系统定位: agents/main_agent 的 llm 与 bind_tools 基底。 @@ -47,7 +49,7 @@ class ChatQwen(ChatOpenAI): 无。 系统定位: - 模块级 ``llm = ChatQwen()`` 单例构造。 + 模块级 ``llm = Multimodel_LLM()`` 单例构造。 可扩展性: 可增加 default_headers、proxy 等 ChatOpenAI 参数。 @@ -219,7 +221,7 @@ class ChatQwen(ChatOpenAI): return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs) def _create_chat_result(self, response, generation_info=None) -> ChatResult: - """解析 API 响应:reasoning 合并、XML/JSON tool_call 提取。 + """解析 API 响应:深度思考归一化、XML/JSON tool_call 提取。 输入: response: OpenAI 兼容 API 原始响应。 @@ -229,7 +231,10 @@ class ChatQwen(ChatOpenAI): ChatResult,AIMessage 可能含 tool_calls。 系统定位: - 兼容非 OpenAI 标准 tool_calls 格式的关键适配点。 + 兼容非 OpenAI 标准 tool_calls 格式与多种深度思考格式的关键适配点: + - 独立 reasoning_content 字段(OpenAI 兼容 / Qwen 等)→ 存入 additional_kwargs + - 内联 ``...`` / ``思考\\n\\n\\n回复``(Qwen 开启思考) + → 拆出思考存入 additional_kwargs.reasoning_content,content 只留回复 可扩展性: 新增模型格式时在 regex/JSON 分支扩展。 @@ -243,7 +248,7 @@ class ChatQwen(ChatOpenAI): lc_msg = result.generations[i].message msg = choice.get("message") or {} ak = lc_msg.additional_kwargs - # 优先处理 reasoning 内容 + # 1) 独立 reasoning 字段(OpenAI 兼容 / Qwen reasoning_content 等) merged = "" for key in ("reasoning_content", "reasoning_details", "reasoning"): merged = assistant_text(msg.get(key)) @@ -251,6 +256,7 @@ class ChatQwen(ChatOpenAI): break if merged: ak.setdefault("reasoning_content", merged) + # 2) content 提取 raw_content = assistant_text(msg.get("content")) if not raw_content and merged: lc_msg.content = merged @@ -259,7 +265,17 @@ class ChatQwen(ChatOpenAI): content_text = lc_msg.content if isinstance(lc_msg.content, str) else "" - # 匹配 ... 块 + # 3) 内联深度思考归一化:Qwen 等将思考内联在 content + # ("思考\n\n\n回复" 或 "思考回复"), + # 拆出思考存入 reasoning_content,content 只留回复(无回复则置空) + if not ak.get("reasoning_content"): + thinking, reply = split_inline_thinking(content_text) + if thinking: + ak["reasoning_content"] = thinking + lc_msg.content = reply or "" + content_text = lc_msg.content + + # 4) 匹配 ... 块 pattern = re.compile(r'\s*(.*?)\s*', re.DOTALL) matches = pattern.findall(content_text) if matches: @@ -374,18 +390,50 @@ class ChatQwen(ChatOpenAI): result = self.invoke(messages) return result.content -llm = ChatQwen() - -def get_default_llm(): - """获取默认 LLM 实例,优先使用主 Agent 配置的模型。 +def build_default_llm() -> Multimodel_LLM: + """构建默认/兜底 LLM 实例:优先主 Agent 配置的模型,回退到环境变量(models[0] 派生)。 系统定位: - skill_router、GUI tool 等内部组件需要一个"兜底 LLM"来做推理。 + 模块级 ``llm`` 单例、``get_default_llm`` 的兜底以及各工具的兜底模型 + 均通过此函数构建,保证"兜底模型 = 主 Agent 的模型",而非简单取模型列表第一个。 + + 可扩展性: + 主 Agent 未配置模型或配置的模型缺失时,回退到环境变量(由 models[0] 派生)。 + """ + try: + from agent.core.config_manager import load_agent_configs, load_models + from agent.utils.env_utils import get_thinking_extra_body + cfgs = load_agent_configs() + main_cfg = next((c for c in cfgs if c.role == "main"), None) + if main_cfg and main_cfg.llm_model_id: + for m in load_models(): + if m.get("id") == main_cfg.llm_model_id: + return Multimodel_LLM( + model=m.get("model", ""), + api_key=m.get("api_key", ""), + base_url=m.get("base_url", ""), + timeout=float(m.get("timeout", 60)), + max_retries=int(m.get("max_retries", 0)), + extra_body=get_thinking_extra_body(), + ) + except Exception: + pass + return Multimodel_LLM() + + +llm = build_default_llm() + +def get_default_llm(): + """获取默认 LLM 实例,优先主 Agent 运行时使用的模型。 + + 系统定位: + skill_router、上下文压缩等内部组件需要一个"兜底 LLM"来做推理。 该函数会先尝试获取主 Agent Runtime 的 LLM(由 agent_config.json 驱动), - 若获取失败(模块未加载/主 Runtime 不存在)则回退到 models[0] 的默认 ChatQwen。 + 若获取失败(模块未加载/主 Runtime 不存在)则回退到模块级 ``llm``—— + 该单例同样以主 Agent 配置的模型优先构建(见 build_default_llm)。 输出: - ChatQwen 实例。 + Multimodel_LLM 实例。 """ try: from agent.agents.agent_runtime import get_main_agent_runtime @@ -398,7 +446,7 @@ def get_default_llm(): def reload_llm() -> None: - """热重载:重新从环境变量创建 LLM 实例。""" + """热重载:重新从配置创建默认 LLM 实例(优先主 Agent 配置的模型)。""" global llm - llm = ChatQwen() + llm = build_default_llm() diff --git a/src/agent/core/loop_detector.py b/src/agent/core/loop_detector.py index 69bc098..d0addf2 100644 --- a/src/agent/core/loop_detector.py +++ b/src/agent/core/loop_detector.py @@ -1,13 +1,11 @@ -"""Agent 死循环检测模块。 +"""Agent 重复工具调用检测模块。 系统定位: - 在 ReAct 循环的 agent 节点和路由节点之间,检测连续相同工具调用的异常模式, - 提供分级响应: - - Level 1: 注入反思提示引导模型自我纠正 - - Level 2: 强制终止图执行并向用户报告 + 在 ReAct 循环的 agent 节点调用 LLM 前,检测连续相同工具调用的异常模式, + 提供 Level 1 响应:注入反思提示引导模型自我纠正(不再强制终止,Level 2 已移除)。 配置来源: - env_config.json 中的 LOOP_DETECT_* 参数,可由前端动态修改。 + env_config.json 中的 LOOP_DETECT_REPEATED_TOOL_WARN 参数,可由前端动态修改。 可扩展性: 可增加更多循环模式检测器、自定义检测规则。 @@ -35,7 +33,6 @@ def get_loop_config() -> dict: env = {} return { "repeated_tool_warn": int(env.get("LOOP_DETECT_REPEATED_TOOL_WARN", 3)), - "repeated_tool_end": int(env.get("LOOP_DETECT_REPEATED_TOOL_END", 5)), } # ============================================================ @@ -123,8 +120,7 @@ def detect_repeated_tool_calls( """ cfg = get_loop_config() warn_threshold = max(cfg["repeated_tool_warn"], 2) - end_threshold = max(cfg["repeated_tool_end"], warn_threshold + 1) - lookback = end_threshold + 2 + lookback = warn_threshold + 2 recent = get_recent_tool_calls_from_messages(messages, count=lookback) consecutive = _compute_consecutive_same(recent) @@ -137,30 +133,6 @@ def detect_repeated_tool_calls( return consecutive, last_call.get("name", "unknown"), args_summary -def should_force_end( - messages: list, - session_id: str = "", -) -> tuple[bool, str, str | None]: - """检查是否应强制终止(Level 2 响应)。 - - 当前仅检测连续相同工具调用超过 end 阈值的情况。 - - 输入: - messages: 当前消息列表。 - session_id: 会话 ID。 - - 输出: - (是否强制终止, 工具名, 参数摘要) - """ - count, tool_name, args_summary = detect_repeated_tool_calls(messages, session_id) - cfg = get_loop_config() - end_threshold = max(cfg["repeated_tool_end"], cfg.get("repeated_tool_warn", 3) + 1) - - if count >= end_threshold: - return True, tool_name, args_summary - return False, "", None - - # ============================================================ # 反循环反思提示生成 # ============================================================ @@ -169,7 +141,7 @@ def build_loop_reflection_prompt( messages: list, session_id: str = "", ) -> str | None: - """根据当前状态构建反循环反思提示。 + """根据当前状态构建反循环反思提示(Level 1 响应)。 输入: messages: 当前消息列表。 @@ -199,26 +171,3 @@ def build_loop_reflection_prompt( return None return "\n\n".join(parts) - - -def build_force_end_message( - tool_name: str, - consecutive_count: int, - args_summary: str | None = None, -) -> str: - """构建强制终止时向前端输出的异常循环消息。 - - 输入: - tool_name: 循环调用的工具名称。 - consecutive_count: 连续调用次数。 - args_summary: 参数摘要。 - - 输出: - 用户可读的循环报告文本。 - """ - args_info = f"\n参数:{args_summary}" if args_summary else "" - return ( - f"**检测到异常循环**\n\n" - f"工具 `{tool_name}` 已连续调用 {consecutive_count} 次且参数相同,判定为死循环,已强制终止本次任务。{args_info}\n\n" - f"建议:请尝试换一种方式描述任务,或简化任务后重试。" - ) diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index 874fbad..7c7c8ef 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -21,7 +21,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from agent.core.loop_detector import build_loop_reflection_prompt from agent.core.state import AgentState -from agent.memory.system_prompt import REFLECTION_PROMPT +from agent.memory.system_prompt import REFLECTION_PROMPT, SUB_AGENT_OOM_PROMPT from agent.utils.image_utils import image_bytes_to_openai_image_url_part from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text from langchain_core.messages import SystemMessage @@ -115,61 +115,42 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: return cleaned -def _build_reflection_prompt(agent_mode: str, is_first_call: bool, messages: list, session_id: str = "") -> str | None: - """构建反思提示词,引导 Agent 在下一步执行前进行思考。 +def _build_reflection_prompt(messages: list, session_id: str = "") -> str | None: + """构建每轮注入模型上下文的提示文本,根据思考档位区分注入内容。 输入: - agent_mode: 运行模式("agent" | "plan")。 - is_first_call: 是否为当前任务的首次 agent 调用。 messages: 当前消息列表(已过滤短期记忆),供循环检测使用。 - session_id: 会话 ID,供循环检测使用。 + session_id: 会话 ID,供循环检测与 TodoList 状态重建使用。 输出: - 反思提示文本,不需要反思时返回 None。 + 注入文本;无需注入时返回 None。 + + 系统定位: + - 思考关闭(low/high):注入反思引导提示词 + TodoList 状态 + 循环警告, + 引导模型先一句话反思再调用工具; + - 思考开启(xhigh/max/ultra):仅注入 TodoList 状态(模型原生深度思考, + 不注入反思引导与循环警告)。 """ - # 非 plan 模式首次调用,不需要反思 - if is_first_call: - return None + from agent.utils.env_utils import thinking_enabled - # 后续调用:引导模型先思考再行动(TodoList 状态由工具返回值提供,无需额外注入) - parts = [REFLECTION_PROMPT] + parts: list[str] = [] - # 循环检测:注入反循环反思提示 - loop_warning = build_loop_reflection_prompt(messages, session_id) - if loop_warning: - parts.append(loop_warning) + # 思考关闭:反思引导 + 循环警告(Level 1 仅提醒,不强制终止) + if not thinking_enabled(): + parts.append(REFLECTION_PROMPT) + loop_warning = build_loop_reflection_prompt(messages, session_id) + if loop_warning: + parts.append(loop_warning) - return "\n".join(parts) + # TodoList 状态:思考开/关均注入(事实状态,供模型跟踪计划进度) + try: + from agent.tools.todo_list import get_todo_status_text + todo_status = get_todo_status_text(session_id) + except Exception: + todo_status = "" + parts.append(todo_status if todo_status else "当前无 TodoList。") - -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 + return "\n\n".join(parts) # ---------- ReAct 循环内上下文压缩 ---------- @@ -245,7 +226,7 @@ def _compute_next_cursor(session_id: str) -> int: return -1 -def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) -> tuple[str, int, int]: +def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None, model_name: str = "") -> tuple[str, int, int]: """根据 LLM 返回的 token 用量判断下一步动作。 返回值 (trigger, total_tokens, threshold): @@ -255,7 +236,8 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) -> 整理提示词,让子 Agent 整理任务进度直接返回主 Agent。 同时将 total_tokens 与估算的三类占比(消息/工具/系统提示词)存入 - tool_call_recorder 供前端环形指示展示(仅主 Agent)。 + tool_call_recorder 供前端环形指示展示(仅主 Agent),并累加进全局 + usage_stats(跨会话按小时/模型聚合,供欢迎区活跃度矩阵展示)。 """ total = _extract_total_tokens(response) if total <= 0: @@ -270,6 +252,12 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) -> if _sid and not is_sub: breakdown = _estimate_token_breakdown(messages or [], _extract_usage(response)) set_session_tokens(_sid, total, breakdown=breakdown) + # 全局用量统计(跨会话累加,按小时 + 模型维度) + try: + from agent.core.usage_stats import record_usage + record_usage(total, model=model_name) + except Exception: + pass except Exception: pass @@ -282,14 +270,6 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) -> return ("compress", total, threshold) -SUB_AGENT_OOM_PROMPT = """ -【系统指令 - 上下文超限】 -你的上下文 token 用量已达 {total_tokens},超过限制阈值 {threshold},继续调用工具可能导致溢出。 -请立即停止调用任何工具,整理当前任务的详细进度(已完成的工作、关键结果、遇到的问题及原因), -直接以文字形式回复主 Agent,不要再调用任何工具。 -""".strip() - - COMPRESSED_SUMMARY_PREFIX = "【历史工具调用摘要】" COMPRESSED_SUMMARY_MSG_ID = "compressed_summary" @@ -346,10 +326,10 @@ def maybe_compress_context(state: AgentState) -> dict: pass try: - from agent.core.llm import llm as compress_llm + from agent.core.llm import get_default_llm from agent.utils.agent_utils import get_session_meta, summarize_chat_text, update_session_meta - summary_text = summarize_chat_text(compress_llm, compress_text) + summary_text = summarize_chat_text(get_default_llm(), compress_text) # 累积压缩摘要并更新压缩游标(游标前历史已被摘要覆盖) if sid: @@ -399,10 +379,12 @@ def make_call_model_node( model_with_tools: Any, trajectory_rounds: int = 3, ) -> Callable[[AgentState], dict]: - """创建带反思机制 + 上下文中压缩的调用已绑定工具的 LLM 的图节点闭包。 + """创建带按思考档位注入提示 + 上下文中压缩的调用已绑定工具的 LLM 的图节点闭包。 - 每次调用前注入反思引导提示;调用后提取模型同时产出的文本思考内容(content), - 记录到 think store,供前端实时展示思维链。Plan 模式下首次调用强制要求生成 todolist。 + 每次调用 LLM 前按思考档位注入提示(思考关闭:反思引导 + TodoList 状态 + + 循环警告;思考开启:仅 TodoList 状态);调用后提取模型产出的思考/反思内容 + (思考开启取深度思考,思考关闭取一句话反思),记录到 think store, + 供前端实时展示思维链。 在每次 LLM 调用后,根据 response 的 ``usage_metadata.total_tokens`` 判断 是否触发消息列表就地压缩——确保 ReAct 循环中的上下文永不溢出。 @@ -410,7 +392,6 @@ def make_call_model_node( def call_model(state: AgentState) -> dict: all_messages = list(state["messages"]) - agent_mode = state.get("agent_mode", "agent") # 检查 abort 标志 try: @@ -426,9 +407,6 @@ def make_call_model_node( filtered_messages = _filter_short_term_memory(all_messages, trajectory_rounds) - # 判断是否为首轮 agent 调用(当前轮尚无 AIMessage) - is_first_call = _is_first_agent_call(filtered_messages) - # 获取 session_id 供循环检测使用 try: from agent.history.tool_call_recorder import get_current_session @@ -436,8 +414,8 @@ def make_call_model_node( except Exception: _sid = "" - # 构建反思提示并注入到消息列表(仅作引导,不记录到前端) - reflection = _build_reflection_prompt(agent_mode, is_first_call, filtered_messages, _sid) + # 构建提示文本并注入到消息列表(仅作引导,不记录到前端) + reflection = _build_reflection_prompt(filtered_messages, _sid) messages_for_llm = list(filtered_messages) if reflection: reflection_msg = HumanMessage(content=reflection, additional_kwargs={"synthetic": True, "reflection": True}) @@ -445,9 +423,16 @@ def make_call_model_node( response = model_with_tools.invoke(messages_for_llm) + # 模型名(用于全局用量统计按模型分账) + model_name = "" + try: + model_name = str(getattr(getattr(model_with_tools, "bound", None), "model_name", "") or "") + except Exception: + pass + # 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。 # total_tokens 同时存入 session_tokens 供前端轮询(仅主 Agent)。 - trigger, _total, _threshold = _maybe_trigger_compress(response, messages_for_llm) + trigger, _total, _threshold = _maybe_trigger_compress(response, messages_for_llm, model_name) if trigger == "sub_oom": # 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接 # 返回主 Agent(提示词内附 OOM 溢出信息)。 @@ -464,9 +449,10 @@ def make_call_model_node( need_compress = (trigger == "compress") state["_need_compress"] = need_compress - # 提取模型真实输出的思考内容(content),记录到前端 think 面板 - # 模型同时输出 content + tool_calls 时,content 就是其思维链 - # 部分模型(如 Qwen enable_thinking)将思考放在 additional_kwargs.reasoning_content + # 提取模型产出的思考/反思内容,记录到前端 think 面板与后端记录 + # - 思考关闭:模型在反思提示引导下输出的 content(一句话反思) + # - 思考开启:深度思考(additional_kwargs.reasoning_content 或内联 块, + # 由 _create_chat_result 归一化到 reasoning_content) response_content = extract_reasoning_text(response) if response_content and isinstance(response_content, str) and response_content.strip(): has_tool_calls = getattr(response, "tool_calls", None) diff --git a/src/agent/core/routing.py b/src/agent/core/routing.py index ecf8fa2..df3e7a4 100644 --- a/src/agent/core/routing.py +++ b/src/agent/core/routing.py @@ -16,7 +16,6 @@ from typing import Literal from langgraph.graph import END from agent.core.state import AgentState -from agent.core.loop_detector import should_force_end as _loop_should_force_end def should_continue(state: AgentState) -> Literal["tools", "__end__"]: @@ -26,12 +25,10 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]: state: 含 messages 的 AgentState。 输出: - ``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(无待执行工具调用、 - 循环检测强制终止)。 + ``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(无待执行工具调用)。 系统定位: ``graph`` 中 agent 节点的 conditional_edges 路由函数。 - 若检测到死循环(连续相同工具调用超阈值),则强制 END。 可扩展性: 可先执行 direct 类工具、仅对 confirm 类暂停(需拆分 tool_calls 处理)。 @@ -39,15 +36,6 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]: last_msg = state["messages"][-1] if hasattr(last_msg, "tool_calls") and last_msg.tool_calls: - # 循环检测 Level 2:连续相同工具调用超过 END 阈值 → 强制终止 - messages = state["messages"] - try: - from agent.history.tool_call_recorder import get_current_session - _sid = get_current_session() - except Exception: - _sid = "" - force_end, loop_tool, loop_args = _loop_should_force_end(messages, _sid) - if force_end: - return END + # 所有工具均 direct 自动执行,直接进入 ToolNode return "tools" return END diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py index 6d38f2c..3a3bf75 100644 --- a/src/agent/core/runtime.py +++ b/src/agent/core/runtime.py @@ -25,7 +25,6 @@ from langgraph.errors import GraphRecursionError from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, set_current_turn, set_current_session from agent.memory.system_prompt import get_plan_system_prompt, get_cron_system_prompt -from agent.core.loop_detector import should_force_end as loop_should_force_end, build_force_end_message from agent.utils.agent_utils import assistant_text, get_history from web.ui_session import rewrite_user_message_files_to_session_dir, sync_turn_from_chat_history @@ -159,19 +158,7 @@ def _finalize_turn( turn_id: str, agent_mode: str = "agent", ) -> list[dict[str, Any]]: - """处理图执行完成后的公共逻辑:检查循环强制终止、保存 turn、构建 assistant 消息。""" - # 循环检测 Level 2 - loop_force_end, loop_tool_name, loop_args_summary = loop_should_force_end(all_messages, session_id) - if loop_force_end: - end_live_turn(session_id) - from agent.core.loop_detector import detect_repeated_tool_calls as _detect_repeated - actual_count, _, _ = _detect_repeated(all_messages, session_id) - loop_msg = build_force_end_message(loop_tool_name, actual_count, loop_args_summary) - chat_history.append({"role": "assistant", "content": loop_msg, "meta": {"turn_id": turn_id}}) - if session_id: - sync_turn_from_chat_history(session_id, chat_history, "") - return chat_history - + """处理图执行完成后的公共逻辑:保存 turn、构建 assistant 消息。""" # 正常结束 end_live_turn(session_id) assistant_msg = all_messages[-1] if all_messages else AIMessage(content="") diff --git a/src/agent/core/usage_stats.py b/src/agent/core/usage_stats.py new file mode 100644 index 0000000..e2f55e4 --- /dev/null +++ b/src/agent/core/usage_stats.py @@ -0,0 +1,146 @@ +"""全局 Token 用量统计:跨会话、按小时聚合、按模型分账。 + +系统定位: + 独立于会话存储的全局诊断数据:每次主 Agent LLM 调用后累加 total_tokens, + 按 (日期, 小时) 与模型维度记录,供前端欢迎区的「活跃度矩阵 / 柱状图」展示。 + cron 任务同样计入(统一为真实消耗);子 Agent 不计入(与 set_session_tokens 一致)。 + +存储: + 固定落盘 ``/usage_stats.json``,不随存储后端(JSON/MySQL)切换, + 也不受 cron 存储作用域重定向影响——统计是本地诊断数据,独立 JSON 足够。 + + 文件写入采用「临时文件 + os.replace」原子替换;多进程(主进程 + cron 子进程) + 并发写时可能出现一次读-改-写竞态导致的少量丢失,可接受。 + +可扩展性: + 可增加按月汇总、导出、TTL 配置。 +""" +from __future__ import annotations + +import json +import os +import threading +import time +from datetime import datetime, timedelta +from typing import Any + +from agent.utils.agent_utils import HISTORY_ROOT + +_HOUR_FMT = "%Y-%m-%dT%H" # 小时粒度键 +_DAY_FMT = "%Y-%m-%d" +_RETAIN_DAYS = 200 # 存储保留天数(前端展示最近 182 天,留余量) +_CLEANUP_INTERVAL = 6 * 3600 # 裁剪检查间隔(秒) + +_FILE = os.path.join(HISTORY_ROOT, "usage_stats.json") + +_lock = threading.Lock() +_last_cleanup = 0.0 + + +def _load() -> dict[str, Any]: + """读取全局统计文件;缺失或损坏时返回空结构。""" + try: + with open(_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and isinstance(data.get("hours"), dict): + return data + except (OSError, ValueError): + pass + return {"hours": {}} + + +def _save(data: dict[str, Any]) -> None: + """原子写盘:临时文件 + os.replace。""" + tmp = _FILE + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, _FILE) + except OSError: + try: + os.remove(tmp) + except OSError: + pass + + +def _cleanup(data: dict[str, Any], now: float) -> None: + """裁剪 _RETAIN_DAYS 天之前的小时键。""" + global _last_cleanup + if now - _last_cleanup < _CLEANUP_INTERVAL: + return + _last_cleanup = now + cutoff = datetime.fromtimestamp(now) - timedelta(days=_RETAIN_DAYS) + cutoff_key = cutoff.strftime(_HOUR_FMT) + hours = data.get("hours", {}) + for key in [k for k in hours if k < cutoff_key]: + hours.pop(key, None) + + +def record_usage(total: int, model: str = "", ts: float | None = None) -> None: + """累加一次 LLM 调用的 token 用量到对应小时(按模型分账)。 + + 输入: + total: total_tokens(>0 才记录)。 + model: 模型名(用于柱状图按模型分色);空串时仅累计总量。 + ts: 调用时间戳;None 使用当前时间。 + """ + if total <= 0: + return + ts = ts if ts is not None else time.time() + hour = datetime.fromtimestamp(ts).strftime(_HOUR_FMT) + with _lock: + data = _load() + hours = data.setdefault("hours", {}) + entry = hours.setdefault(hour, {"total": 0, "by_model": {}}) + entry["total"] = entry.get("total", 0) + total + if model: + entry["by_model"][model] = entry["by_model"].get(model, 0) + total + _cleanup(data, ts) + _save(data) + + +def get_usage_summary(days: int = 182) -> dict[str, Any]: + """聚合最近 N 天的用量统计。 + + 输出: + { + "total": int, # 跨会话累计总量 + "by_model": {model: int}, # 全局按模型总量 + "days": [{"date": "YYYY-MM-DD", "total": int, # 最近 N 天(含 0 值, + "by_model": {model: int}}, ...], # 供活跃度矩阵完整网格) + "hours": {"YYYY-MM-DD": {"HH": {model: int}}} # 有数据的小时(非 0) + } + """ + now = datetime.now() + day_keys = [(now - timedelta(days=days - 1 - i)).strftime(_DAY_FMT) for i in range(days)] + day_floor = day_keys[0] + day_ceil = day_keys[-1] + + daily = {d: {"total": 0, "by_model": {}} for d in day_keys} + hours_out: dict[str, dict[str, dict[str, int]]] = {} + total = 0 + by_model: dict[str, int] = {} + + with _lock: + data = _load() + for hour, entry in data.get("hours", {}).items(): + date = hour[:10] + if date < day_floor or date > day_ceil: + continue + e_total = entry.get("total", 0) + e_model = entry.get("by_model", {}) or {} + total += e_total + for m, v in e_model.items(): + by_model[m] = by_model.get(m, 0) + v + d = daily[date] + d["total"] += e_total + for m, v in e_model.items(): + d["by_model"][m] = d["by_model"].get(m, 0) + v + hours_out.setdefault(date, {})[hour[11:]] = dict(e_model) + + return { + "total": total, + "by_model": by_model, + "days": [{"date": k, "total": v["total"], "by_model": v["by_model"]} for k, v in daily.items()], + "hours": hours_out, + } diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index 6e169d6..c06043e 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -710,8 +710,9 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s name_counter: dict[str, int] = {} for msg in messages: if isinstance(msg, AIMessage) and msg.tool_calls: - # 提取该 AIMessage 的 thinking(content),仅附加到第一个 tool_call - # 部分模型(如 Qwen enable_thinking)将思考放在 additional_kwargs.reasoning_content + # 提取该 AIMessage 的 thinking,仅附加到第一个 tool_call + # 提取规则见 extract_reasoning_text:开启思考取深度思考(reasoning_content/ + # 内联 块),关闭思考取 content 中的一句话反思 thinking = extract_reasoning_text(msg) first = True for tc in msg.tool_calls: diff --git a/src/agent/history/usage_stats.json b/src/agent/history/usage_stats.json new file mode 100644 index 0000000..fecc345 --- /dev/null +++ b/src/agent/history/usage_stats.json @@ -0,0 +1 @@ +{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}}} \ No newline at end of file diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index c480d04..1b1e7a6 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -202,6 +202,14 @@ REFLECTION_PROMPT = """ 如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。 """.strip() +# 子 Agent 上下文超限提示(由 agent 节点在子 Agent token 超阈值时注入,强制其整理进度并文字返回) +SUB_AGENT_OOM_PROMPT = """ +【系统指令 - 上下文超限】 +你的上下文 token 用量已达 {total_tokens},超过限制阈值 {threshold},继续调用工具可能导致溢出。 +请立即停止调用任何工具,整理当前任务的详细进度(已完成的工作、关键结果、遇到的问题及原因), +直接以文字形式回复主 Agent,不要再调用任何工具。 +""".strip() + # ---------- Skill Router Prompt ---------- SKILL_ROUTER_PROMPT = """你是一个 Skill 路由助手。根据用户 query,从下方 Skill 列表中选出最相关的 1~3 个 Skill。 只输出匹配的 Skill name,每个一行;没有匹配的则输出 "NONE"。 diff --git a/src/agent/tools/skill_router.py b/src/agent/tools/skill_router.py index 4f82616..eedaca2 100644 --- a/src/agent/tools/skill_router.py +++ b/src/agent/tools/skill_router.py @@ -109,7 +109,8 @@ class SkillRouter(BaseTool): def _call_llm(self, prompt: str) -> list[str] | None: """调用大模型解析 prompt,返回匹配的 skill name 列表。 - 优先使用主 Agent 配置的 LLM(get_default_llm),回退到 models[0] 兜底。 + 优先使用主 Agent 运行时配置的 LLM(get_default_llm), + 兜底亦以主 Agent 配置的模型构建(build_default_llm),不再回退到模型列表第一个。 输出: 匹配的 name 列表;解析失败或模型返回 NONE 时返回 None。 diff --git a/src/agent/tools/todo_list.py b/src/agent/tools/todo_list.py index 1efc850..131e42c 100644 --- a/src/agent/tools/todo_list.py +++ b/src/agent/tools/todo_list.py @@ -7,48 +7,75 @@ from langchain.tools import BaseTool from pydantic import BaseModel, Field, model_validator -def _iter_todo_records(records: list[dict[str, Any]] | None): - """遍历 live 工具记录中的 todo_list 调用(含子 Agent 嵌套在 call_subagent 内的记录)。""" +def _iter_todo_args(records: list[dict[str, Any]] | None): + """遍历工具调用记录中的 todo_list 调用参数(含子 Agent 嵌套在 call_subagent 内的记录)。""" for rec in records or []: if rec.get("name") == "todo_list": - yield rec + yield rec.get("args") or {} for sub in rec.get("sub_tool_calls") or []: if sub.get("name") == "todo_list": - yield sub + yield sub.get("args") or {} -def _reconstruct_state(sid: str, steps_arg: Optional[list[str]], done_step_arg: int) -> tuple[list[str], set[int]]: - """从当前轮的 live 工具记录重建累计 todo 状态。 +def _accumulate_todo_args(args_list: list[dict[str, Any]]) -> tuple[list[str], set[int]]: + """按时间顺序累积 todo_list 调用参数 → (steps, done_steps)。 - steps 取自最近一次传入完整步骤列表的调用;done_steps 累积全部 done_step。 - 本工具是纯函数、不维护任何全局 store——每次调用都从框架的 live 记录通道 - 读取自身调用历史,前端浮窗亦由同一通道(工具调用的 args)驱动更新。 + steps 取自最近一次传入完整步骤列表的调用(重新传入即修改/重建计划); + done_steps 累积全部 done_step。 """ steps: list[str] = [] done_steps: set[int] = set() - if sid: + for args in args_list: + raw_steps = args.get("steps") + if isinstance(raw_steps, str): + try: + raw_steps = json.loads(raw_steps) + except (json.JSONDecodeError, ValueError): + raw_steps = None + if isinstance(raw_steps, list) and raw_steps: + steps = [str(s) for s in raw_steps] + done_steps = set() try: - from agent.history.tool_call_recorder import get_live_tool_calls - for rec in _iter_todo_records(get_live_tool_calls(sid)): - args = rec.get("args") or {} - raw_steps = args.get("steps") - if isinstance(raw_steps, str): - try: - raw_steps = json.loads(raw_steps) - except (json.JSONDecodeError, ValueError): - raw_steps = None - if isinstance(raw_steps, list) and raw_steps: - steps = [str(s) for s in raw_steps] - done_steps = set() - try: - n = int(args.get("done_step", -1)) - except (TypeError, ValueError): - n = -1 - if steps and n >= 1: - done_steps.update(range(min(n, len(steps)))) - except Exception: - pass - # 本次调用的参数覆盖/追加(live 记录不可用时的兜底) + n = int(args.get("done_step", -1)) + except (TypeError, ValueError): + n = -1 + if steps and n >= 1: + done_steps.update(range(min(n, len(steps)))) + return steps, done_steps + + +def _collect_todo_args(sid: str) -> list[dict[str, Any]]: + """按时间顺序收集 todo_list 调用参数:已落盘 turn(正序)+ 当前 live 记录。 + + live 记录仅在 turn 执行期间存在(turn 结束后被清空),因此跨轮状态 + 需从已落盘 turn 的工具调用记录重建。 + """ + args_list: list[dict[str, Any]] = [] + if not sid: + return args_list + try: + from agent.history.tool_call_recorder import list_turns + turns = list_turns(sid, limit=100000) # 倒序(最新在前) + for t in reversed(turns): # 转为正序 + args_list.extend(_iter_todo_args(t.get("tool_calls") or [])) + except Exception: + pass + try: + from agent.history.tool_call_recorder import get_live_tool_calls + args_list.extend(_iter_todo_args(get_live_tool_calls(sid))) + except Exception: + pass + return args_list + + +def _reconstruct_state(sid: str, steps_arg: Optional[list[str]], done_step_arg: int) -> tuple[list[str], set[int]]: + """从已落盘 turn + 当前 live 记录重建累计 todo 状态,并应用本次调用参数。 + + 本工具是纯函数、不维护任何全局 store——每次调用都从框架的记录通道 + 读取自身调用历史,前端浮窗亦由同一通道(工具调用的 args)驱动更新。 + """ + steps, done_steps = _accumulate_todo_args(_collect_todo_args(sid)) + # 本次调用的参数覆盖/追加 if steps_arg: steps = [str(s) for s in steps_arg] done_steps = set() @@ -57,10 +84,33 @@ def _reconstruct_state(sid: str, steps_arg: Optional[list[str]], done_step_arg: return steps, done_steps +def get_todo_status_text(session_id: str) -> str: + """构建当前会话的 TodoList 状态文本(供每轮注入到模型上下文)。 + + 从已落盘 turn + 当前 live 记录重建累计状态,输出形如: + 当前TodoList进度:2/5 已完成 + 1. [已完成] 打开哔哩哔哩网站 + 2. [待完成] 点击顶部搜索框,输入"燕三嘤嘤嘤"并点击搜索 + 无计划时返回空字符串。 + """ + if not session_id: + return "" + steps, done_steps = _accumulate_todo_args(_collect_todo_args(session_id)) + if not steps: + return "" + total = len(steps) + done_count = len(done_steps) + lines = [f"当前TodoList进度:{done_count}/{total} 已完成"] + for i, s in enumerate(steps): + status = "[已完成]" if i in done_steps else "[待完成]" + lines.append(f" {i + 1}. {status} {s}") + return "\n".join(lines) + + class TodoListInput(BaseModel): steps: Optional[list[str]] = Field( default=None, - description="任务步骤列表,每项为一步的描述。仅在首次创建计划时传入,后续更新进度时请省略此参数以节省 token。", + description="任务步骤列表,每项为一步的描述。仅在首次创建计划或需要修改/重建计划时传入,后续更新进度时请省略此参数以节省 token。", ) done_step: int = Field( default=-1, @@ -85,10 +135,12 @@ class TodoList(BaseTool): """任务管理工具,用于拆分复杂任务为多个步骤并跟踪进度。 Plan 模式下此工具为**必须调用**,Agent 模式下为可选。 - 首次调用时传入完整步骤列表,后续仅传 done_step 标记进度。 + 首次调用时传入完整步骤列表,后续仅传 done_step 标记进度;重新传入 + steps 可修改或重建计划。 调用时会随 live 工具记录推送状态,前端据此实时渲染 Todo 浮窗; - 返回值是纯文本,不携带任何结构化前缀。 + 返回值只包含创建/更新结果(成功或失败+原因),**不返回完整步骤列表** + ——当前计划状态由系统每轮注入模型上下文(get_todo_status_text)。 """ args_schema: type[BaseModel] = TodoListInput @@ -97,7 +149,7 @@ class TodoList(BaseTool): "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。\n" "Plan 模式下必须首先调用此工具创建任务计划;Agent 模式下按需调用。\n" "参数说明:\n" - "- steps (list[str], 可选):任务步骤列表。仅在首次创建计划时传入所有步骤。\n" + "- steps (list[str], 可选):任务步骤列表。仅在首次创建计划或需要修改/重建计划时传入所有步骤。\n" " **步骤粒度要求**:同一页面/界面上的连续操作应合并为一条步骤,用\"并\"连接。例如:\n" ' * 推荐:"点击搜索框,输入XXX并点击搜索"(合并了点击、输入、搜索)\n' ' * 推荐:"在结果中点击目标进入主页"(合并了查找+点击)\n' @@ -105,16 +157,17 @@ class TodoList(BaseTool): "- done_step (int, 可选):当前已推进到的步骤编号(从1开始)。传入 n 则步骤 1~n 全部标记为已完成。不传或传 -1 则只查看当前状态。\n" "调用示例:\n" '- 首次创建(B站搜索示例):{"steps": ["打开哔哩哔哩网站", "点击顶部搜索框,输入\'燕三嘤嘤嘤\'并点击搜索", "在搜索结果中找到燕三嘤嘤嘤并点击头像或名称进入他的主页", "在他的主页点开视频列表里的第一个视频", "验证视频是否正在播放"], "done_step": -1}\n' + '- 修改/重建计划:重新传入 {"steps": [...]}\n' '- 已完成前2步:{"done_step": 2} (步骤1和2均标记为完成)\n' '- 已完成前4步:{"done_step": 4}\n' '- 查看状态:{"done_step": -1}\n' - "任务全部完成后无需再次调用。" + "任务全部完成后无需再次调用。\n" + "返回值说明:创建成功(共 N 步)/ 更新成功(已完成 X/N 步)或失败原因,不包含完整步骤列表;当前计划状态由系统每轮注入。" ) response_format: Literal["content", "content_and_artifact"] = "content" def _run(self, steps: list[str] | None = None, done_step: int = -1) -> str: try: - steps = steps or [] sid = "" try: from agent.history.tool_call_recorder import get_current_session @@ -122,15 +175,30 @@ class TodoList(BaseTool): except Exception: pass stored_steps, done_steps = _reconstruct_state(sid, steps, done_step) + + # 创建 / 修改 / 重建计划 + if steps is not None: + if not steps: + return "TodoList 创建失败:steps 不能为空列表。" + msg = f"TodoList 创建成功,共 {len(stored_steps)} 步。" + if done_step >= 1: + msg += f" 已完成 {len(done_steps)} 步。" + return msg + + # 更新进度 + if done_step >= 1: + if not stored_steps: + return "TodoList 更新失败:当前没有已创建的 TodoList,请先传入 steps 创建计划。" + if done_step > len(stored_steps): + return f"TodoList 更新失败:done_step={done_step} 超过总步数 {len(stored_steps)}。" + return f"TodoList 进度更新成功:已完成 {len(done_steps)}/{len(stored_steps)} 步。" + + # 仅查看状态 if not stored_steps: - return "[Todo List]\n(当前无任务)" - lines = ["[Todo List]"] - for i, s in enumerate(stored_steps): - status = "[Done]" if i in done_steps else "[Pending]" - lines.append(f"- [{i+1}/{len(stored_steps)}] {status}: {s}") - return "\n".join(lines) + return "当前无 TodoList。" + return f"当前进度:已完成 {len(done_steps)}/{len(stored_steps)} 步。" except Exception as e: - return f"[ERROR] todo_list 工具执行出错: {str(e)}" + return f"TodoList 更新失败:{str(e)}" async def _arun(self, steps: list[str] | None = None, done_step: int = -1) -> str: return self._run(steps, done_step) diff --git a/src/agent/utils/env_utils.py b/src/agent/utils/env_utils.py index c9ff702..aa88083 100644 --- a/src/agent/utils/env_utils.py +++ b/src/agent/utils/env_utils.py @@ -97,7 +97,7 @@ def thinking_enabled(level: str | None = None) -> bool: def get_thinking_extra_body(level: str | None = None) -> dict | None: - """根据思考档位返回 ChatQwen 的 extra_body。 + """根据思考档位返回 Multimodel_LLM 的 extra_body。 返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。 """ diff --git a/src/agent/utils/tool_call_utils.py b/src/agent/utils/tool_call_utils.py index cd0ccd4..2df5b13 100644 --- a/src/agent/utils/tool_call_utils.py +++ b/src/agent/utils/tool_call_utils.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re import threading from datetime import datetime from typing import Any @@ -236,29 +237,69 @@ def normalize_tool_call(tool_call: Any) -> dict[str, Any]: return {"id": call_id, "name": name, "args": args or {}} -def extract_reasoning_text(msg: Any) -> str: - """提取 AIMessage 的思考内容:优先 content,其次 additional_kwargs.reasoning_content。 +_THINK_END_TAG = "" +_THINK_OPEN_RE = re.compile(r"^\s*\s*", re.IGNORECASE) + + +def split_inline_thinking(text: str) -> tuple[str, str]: + """按最后一个 ```` 标记拆分内联深度思考与回复。 功能描述: - 部分模型(如 Qwen enable_thinking)将思维链放在 ``additional_kwargs.reasoning_content`` - 而非 ``content``。本函数统一兼容两种情况,返回去除首尾空白后的文本(无内容返回 "")。 + 部分模型(如 Qwen 开启思考)将深度思考内联在 content 中,形如 + ``"思考内容\n\n\n回复内容"`` 或 ``"思考内容回复内容"``。 + 本函数兼容两种写法,按最后一个 ```` 拆分,并去除开头的 + ```` 包裹标记。 + + 输入: + text: 模型返回的 content 文本。 + + 输出: + (思考内容, 回复内容);无 ```` 标记时返回 ("", text)。 + """ + if not text or not isinstance(text, str): + return "", text + idx = text.rfind(_THINK_END_TAG) + if idx < 0: + return "", text + thinking = _THINK_OPEN_RE.sub("", text[:idx]).strip() + reply = text[idx + len(_THINK_END_TAG):].strip() + return thinking, reply + + +def extract_reasoning_text(msg: Any) -> str: + """提取模型的思考/反思内容,根据思考档位区分提取来源。 + + 功能描述: + - 开启思考(xhigh/max/ultra):优先 ``additional_kwargs.reasoning_content`` + (独立字段,或由 ``_create_chat_result`` 从内联 ```` 块归一化而来), + 其次 content 内联 ```` 块;无标记返回 ""。 + - 关闭思考(low/high):模型在反思提示引导下,content 即一句话反思, + 直接返回 content。 输入: msg: AIMessage 对象(需有 content / additional_kwargs 属性)。 输出: - 思考内容文本(已 strip),无内容时返回空字符串。 + 思考/反思内容文本(已 strip),无内容时返回空字符串。 系统定位: 供 ``core/nodes`` 提取反思内容、``history/tool_call_recorder`` 提取思维链展示文本。 """ - content = getattr(msg, "content", "") - if content and isinstance(content, str) and content.strip(): - return content.strip() + from agent.utils.env_utils import thinking_enabled + extra = getattr(msg, "additional_kwargs", None) or {} rc = extra.get("reasoning_content") if isinstance(extra, dict) else None if rc and isinstance(rc, str) and rc.strip(): return rc.strip() + + content = getattr(msg, "content", "") + if content and isinstance(content, str) and content.strip(): + if thinking_enabled(): + # 开启思考:仅提取内联 块,无标记则无思考内容 + thinking, _ = split_inline_thinking(content) + return thinking if thinking else "" + # 关闭思考:content 即反思内容 + return content.strip() return "" diff --git a/src/web/app.js b/src/web/app.js index 040d625..5836b00 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -35,6 +35,10 @@ import { setSendDeps } from './js/send.js'; // ---- Todo ---- import { updateTodoOverlayFromRecords, clearTodoOverlay } from './js/todo.js'; +// ---- Token 活跃度卡 / 分时段标语 ---- +import { initUsageCard, refreshUsageCard } from './js/usage-stats.js'; +import { getSlogan } from './js/welcome-slogans.js'; + // ---- 布局 / 背景 ---- import { setSidebarCollapsed, setDrawerOpen, closeDrawerIfMobile, @@ -142,6 +146,8 @@ setRollbackDeps({ }); // ===== Composer 居中/底部动画逻辑 ===== +let _welcomeLeavingTimer = null; // 欢迎区上滑退出的隐藏定时器(防竞态) + export function setComposerCentered(centered) { const composer = document.querySelector(".composer"); const chatPlaceholder = document.getElementById("chatPlaceholder"); @@ -151,24 +157,42 @@ export function setComposerCentered(centered) { if (!composer) return; if (centered) { + if (_welcomeLeavingTimer) { clearTimeout(_welcomeLeavingTimer); _welcomeLeavingTimer = null; } composer.classList.add("composer--centered"); composer.classList.remove("composer--bottom"); if (chatPlaceholder) chatPlaceholder.style.display = "none"; if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed"); if (welcomeView) { welcomeView.hidden = false; - welcomeView.style.animation = "none"; + welcomeView.classList.remove("welcome-view--leaving"); + // 强制 reflow 后加 --visible,确保从隐藏基态淡入(transition 生效) void welcomeView.offsetWidth; - welcomeView.style.animation = ""; + welcomeView.classList.add("welcome-view--visible"); renderRecentFolders(); startClock(); + // 分时段随机欢迎标语 + const sloganEl = document.getElementById("welcomeSlogan"); + if (sloganEl) sloganEl.textContent = getSlogan(); + // 刷新 Token 活跃度卡(矩阵/柱状图,保持当前视图) + refreshUsageCard(); } } else { composer.classList.remove("composer--centered"); composer.classList.add("composer--bottom"); if (chatPlaceholder) chatPlaceholder.style.display = ""; if (mainContentWrap) mainContentWrap.classList.remove("main-content-wrap--collapsed"); - if (welcomeView) welcomeView.hidden = true; + if (welcomeView) { + // 欢迎区上滑淡出让位(而非瞬间隐藏导致整页重排、输入框跳动),动画结束后再隐藏 + if (!welcomeView.classList.contains("welcome-view--leaving")) { + welcomeView.classList.remove("welcome-view--visible"); + welcomeView.classList.add("welcome-view--leaving"); + _welcomeLeavingTimer = setTimeout(() => { + _welcomeLeavingTimer = null; + welcomeView.hidden = true; + welcomeView.classList.remove("welcome-view--leaving"); + }, 350); + } + } stopClock(); if (chatMessages && chatMessages.children.length === 0) { if (chatPlaceholder) chatPlaceholder.hidden = false; @@ -997,6 +1021,7 @@ initI18n(); // ===== 启动 ===== bootstrap().then(() => { initTokenRing(); + initUsageCard(); populateModelSelect(); initWorkspace(); initAccessMode(); diff --git a/src/web/css/chat.css b/src/web/css/chat.css index a4a7397..daad1db 100644 --- a/src/web/css/chat.css +++ b/src/web/css/chat.css @@ -441,15 +441,33 @@ } /* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */ +/* flex:1 占据 main-content-wrap 折叠后的剩余空间,欢迎区扩大、内容从顶部排布。 + 进出均用 transition(隐藏基态 opacity:0 → --visible 淡入;发送时 --leaving 上滑淡出), + 不使用 animation,避免 fill-mode 覆盖过渡起始值导致动画瞬间跳变 */ .welcome-view { - padding: 1rem 1.5rem 0; + padding: 1rem 1.5rem; display: flex; - justify-content: center; - align-items: flex-end; - flex-shrink: 0; + flex-direction: column; + justify-content: flex-start; + align-items: center; + flex: 1; + min-height: 0; + overflow-y: auto; opacity: 0; transform: translateY(10px); - animation: welcomeFadeIn 0.5s ease 0.15s forwards; + transition: opacity 0.35s ease, transform 0.35s ease; + pointer-events: auto; +} + +.welcome-view--visible { + opacity: 1; + transform: translateY(0); +} + +/* 退出:上滑淡出(发送消息时欢迎区平滑让位,输入框保持底部不动) */ +.welcome-view--leaving { + opacity: 0; + transform: translateY(-40px); pointer-events: none; } @@ -457,10 +475,6 @@ display: none; } -@keyframes welcomeFadeIn { - to { opacity: 1; transform: translateY(0); } -} - .welcome-content { display: flex; align-items: center; @@ -512,6 +526,144 @@ to { opacity: 1; transform: translateX(0); } } +.welcome-slogan { + margin: 0 0 0.6rem; + font-size: 0.85rem; + color: var(--muted); + min-height: 1.2em; +} + +/* ===== Token 用量活跃度卡(GitHub 风格矩阵 / 按小时柱状图) ===== */ +.usage-card { + margin-top: 1rem; + width: 100%; + max-width: 700px; + padding: 1rem 1.25rem; + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-radius: 16px; + box-sizing: border-box; +} + +.usage-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; + font-size: 0.8rem; + color: var(--muted); +} + +.usage-card-header .usage-back-btn { + display: none; + border: none; + background: transparent; + color: var(--text); + border-radius: 6px; + padding: 0.2rem; + cursor: pointer; + align-items: center; + justify-content: center; + transition: background 0.15s ease; +} +.usage-card-header .usage-back-btn:hover { background: var(--surface-hover); } + +.usage-back-icon { + width: 16px; + height: 16px; + display: block; +} + +.usage-legend { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + color: var(--muted); +} +.usage-legend .legend-cell { + width: 12px; + height: 12px; + border-radius: 3px; + background: var(--border); +} +.usage-legend .legend-cell.legend-accent { background: var(--accent); } + +.usage-matrix-body { width: 100%; } + +/* GitHub 风格矩阵:首列星期标签 + 26 列周(最新周在最右),每列 7 格周一到周日。 + 1fr 撑满卡片宽度(右侧不空),间距 5px 保持稀疏感 */ +.usage-grid { + display: grid; + grid-template-columns: 16px repeat(26, 1fr); + column-gap: 5px; + row-gap: 5px; + align-items: center; + width: 100%; +} + +.usage-col-label { + font-size: 0.5rem; + color: var(--text-tertiary); + text-align: center; + white-space: nowrap; + overflow: visible; /* 允许月标签轻微重叠(GitHub 同款) */ + line-height: 1.2; +} + +.usage-dow-label { + font-size: 0.55rem; + color: var(--text-tertiary); + text-align: center; +} + +.usage-cell { + aspect-ratio: 1; /* 正方形格子(宽度随 1fr 列自适应) */ + width: 100%; + border-radius: 2px; + background: var(--border); /* 0 级:灰色 */ + cursor: pointer; + transition: transform 0.12s ease; +} +.usage-cell:hover { transform: scale(1.3); } +.usage-cell--empty { opacity: 0.35; cursor: default; } +.usage-cell--empty:hover { transform: none; } + +/* 柱状图(点击方块后切换显示) */ +.usage-bars { + display: flex; + align-items: flex-end; + gap: 3px; + height: 130px; + padding-top: 0.5rem; +} +.usage-bar { + flex: 1; + height: 100%; /* 柱子占满容器高度,内部百分比高度(seg)才有解析基准 */ + display: flex; + flex-direction: column; + justify-content: flex-end; + border-radius: 2px 2px 0 0; + overflow: hidden; + min-height: 2px; + cursor: default; +} +.usage-bar:hover { opacity: 0.85; } +.usage-bar-axis { + display: flex; + justify-content: space-between; + margin-top: 0.3rem; + font-size: 0.65rem; + color: var(--text-tertiary); +} +.usage-empty { + padding: 1.2rem 0; + text-align: center; + font-size: 0.8rem; + color: var(--muted); +} + .recent-folders-label { font-size: 0.72rem; color: var(--muted); diff --git a/src/web/css/composer.css b/src/web/css/composer.css index d7923d8..a1f922c 100644 --- a/src/web/css/composer.css +++ b/src/web/css/composer.css @@ -7,7 +7,8 @@ background: transparent; padding: 0.4rem; box-shadow: none; - transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); + /* 仅过渡内边距/外边距,避免切换 centered/bottom 时其他属性(含布局)被动画化导致输入框跳动 */ + transition: padding 0.25s ease, margin-top 0.25s ease; } /* ===== 内联附件区域 ===== */ @@ -173,14 +174,15 @@ } /* ===== Composer 居中状态(空会话) ===== */ +/* 输入区固定在底部(flex:0),不再吞掉剩余空间——剩余空间归欢迎区 */ .composer--centered { - margin-top: 2rem; - padding: 2rem 0.4rem; + margin-top: 0.5rem; + padding: 0.4rem; display: flex; flex-direction: column; justify-content: center; align-items: center; - flex: 1; + flex: 0 0 auto; } .composer--centered .composer-row { @@ -1365,12 +1367,13 @@ /* ===== 图标型内联下拉触发器(访问模式 / 思考模式)===== */ .inline-drop-trigger--icon { - width: 1.7rem; + min-width: 1.7rem; height: 1.7rem; - padding: 0; + padding: 0 0.15rem; display: inline-flex; align-items: center; justify-content: center; + gap: 0.15rem; } .inline-drop-trigger--icon img { width: 0.95rem; @@ -1378,6 +1381,14 @@ display: block; } +/* 思考模式按钮右侧的小字:当前思考等级(low / high / ...) */ +.think-mode-badge { + font-size: 0.58rem; + line-height: 1; + color: var(--muted); + white-space: nowrap; +} + /* ===== 访问模式面板:三种模式彩色文字选项(与图标颜色一致:绿/黄/橙红)===== */ .access-mode-item--restricted { color: var(--success); } .access-mode-item--restricted.is-selected { background: color-mix(in srgb, var(--success) 14%, transparent); } diff --git a/src/web/index.html b/src/web/index.html index 6b677f1..198a8de 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -141,6 +141,7 @@

欢迎使用 minor Agent

+

+ +