优化CoT,并修复无法开启深度思考模式的问题

This commit is contained in:
minorQAQ committed 2026-08-13 11:30:22 +08:00
1 parent a238f523b5
commit 25a811ea62
27 files changed
+1155 -270

No files matched your search

+7 -11
View File
@@ -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:
-2
View File
@@ -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": "",
+1 -1
View File
@@ -6,7 +6,7 @@
- runtime: 对外执行入口 execute_agent
- human_request: 人工请求注册表(交互类工具的通用阻塞通道)
- turn_runner: 后台线程轮次执行器
- llm: ChatQwen 多模态网关
- llm: Multimodel_LLM 多模态网关
- tool_policy: 工具执行安全策略
系统定位:
+4 -6
View File
@@ -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"],
+65 -17
View File
@@ -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 / 内联 <think> 块)
- 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
- 内联 ``<think>...</think>`` / ``思考\\n</think>\\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 ""
# 匹配 <tool_call>...</tool_call> 块
# 3) 内联深度思考归一化:Qwen 等将思考内联在 content
# ("思考\n</think>\n\n回复" 或 "<think>思考</think>回复"),
# 拆出思考存入 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) 匹配 <tool_call>...</tool_call> 块
pattern = re.compile(r'<tool_call>\s*(.*?)\s*</tool_call>', 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()
+6 -57
View File
@@ -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"建议:请尝试换一种方式描述任务,或简化任务后重试。"
)
+57 -71
View File
@@ -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 或内联 <think> 块,
# 由 _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)
+2 -14
View File
@@ -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
+1 -14
View File
@@ -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="")
+146
View File
@@ -0,0 +1,146 @@
"""全局 Token 用量统计:跨会话、按小时聚合、按模型分账。
系统定位:
独立于会话存储的全局诊断数据:每次主 Agent LLM 调用后累加 total_tokens,
按 (日期, 小时) 与模型维度记录,供前端欢迎区的「活跃度矩阵 / 柱状图」展示。
cron 任务同样计入(统一为真实消耗);子 Agent 不计入(与 set_session_tokens 一致)。
存储:
固定落盘 ``<HISTORY_ROOT>/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,
}
+3 -2
View File
@@ -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/
# 内联 <think> 块),关闭思考取 content 中的一句话反思
thinking = extract_reasoning_text(msg)
first = True
for tc in msg.tool_calls:
+1
View File
@@ -0,0 +1 @@
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}}}
+8
View File
@@ -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"。
+2 -1
View File
@@ -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。
+112 -44
View File
@@ -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)
+1 -1
View File
@@ -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 表示禁用思考(默认)。
"""
+49 -8
View File
@@ -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>"
_THINK_OPEN_RE = re.compile(r"^\s*<think>\s*", re.IGNORECASE)
def split_inline_thinking(text: str) -> tuple[str, str]:
"""按最后一个 ``</think>`` 标记拆分内联深度思考与回复。
功能描述:
部分模型(如 Qwen enable_thinking)将思维链放在 ``additional_kwargs.reasoning_content``
而非 ``content``。本函数统一兼容两种情况,返回去除首尾空白后的文本(无内容返回 "")。
部分模型(如 Qwen 开启思考)将深度思考内联在 content 中,形如
``"思考内容\n</think>\n\n回复内容"`` 或 ``"<think>思考内容</think>回复内容"``。
本函数兼容两种写法,按最后一个 ``</think>`` 拆分,并去除开头的
``<think>`` 包裹标记。
输入:
text: 模型返回的 content 文本。
输出:
(思考内容, 回复内容);无 ``</think>`` 标记时返回 ("", 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`` 从内联 ``<think>`` 块归一化而来),
其次 content 内联 ``<think>`` 块;无标记返回 ""。
- 关闭思考(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():
# 开启思考:仅提取内联 <think> 块,无标记则无思考内容
thinking, _ = split_inline_thinking(content)
return thinking if thinking else ""
# 关闭思考:content 即反思内容
return content.strip()
return ""
+28 -3
View File
@@ -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();
+161 -9
View File
@@ -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);
+17 -6
View File
@@ -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); }
+4
View File
@@ -141,6 +141,7 @@
</div>
<div class="welcome-right">
<h2 class="welcome-greeting" id="welcomeGreeting">欢迎使用 minor Agent</h2>
<p class="welcome-slogan" id="welcomeSlogan"></p>
<div class="recent-folders-wrap" id="recentFoldersWrap" hidden>
<div class="recent-folders-label">最近打开</div>
<ul class="recent-folders-list" id="recentFoldersList"></ul>
@@ -150,6 +151,8 @@
<span class="welcome-clock" id="welcomeClock"></span>
</div>
</div>
<!-- Token 用量活跃度卡(GitHub 风格矩阵 / 按小时柱状图) -->
<div class="usage-card" id="usageCard" hidden></div>
</div>
<footer class="composer">
<div class="composer-refs" id="composerRefs" hidden></div>
@@ -219,6 +222,7 @@
<div class="inline-drop-wrap" id="thinkModeWrap">
<button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="thinkModeTrigger" aria-label="思考模式" title="思考模式:low / high / xhigh / max / ultra">
<img src="/image/思考模式.svg" alt="思考" id="thinkModeIcon" />
<span class="think-mode-badge" id="thinkModeBadge"></span>
</button>
<div class="inline-drop-panel think-mode-panel" id="thinkModePanel" hidden>
<div class="think-mode-labels" id="thinkModeLabels"></div>
-2
View File
@@ -632,9 +632,7 @@ const ENV_DESC = {
RAG_CHUNK_OVERLAP: "RAG 分块重叠大小(字符)。",
GROUNDING_WIDTH: "GUI 定位图像宽度(像素)。",
GROUNDING_HEIGHT: "GUI 定位图像高度(像素)。",
LOOP_DETECT_TODOLIST_STALE_ROUNDS: "TodoList 过期检测轮数,超过该轮数未更新视为失效。",
LOOP_DETECT_REPEATED_TOOL_WARN: "同一工具连续重复调用超过该次数时输出警告。",
LOOP_DETECT_REPEATED_TOOL_END: "同一工具连续重复调用超过该次数时强制终止任务。",
SEND_FILE_SIZE_LIMIT: "发送文件大小上限(MB)。",
STORAGE_BACKEND: "存储后端:json(本地文件)或 mysql(数据库)。",
MYSQL_HOST: "MySQL 主机地址。",
+3
View File
@@ -718,6 +718,9 @@ export function applyTheme(themeId) {
const isDark = theme.dark ?? !(themeId.includes("light") || themeId.includes("solarized"));
document.body.classList.toggle("theme-dark", isDark);
document.body.classList.toggle("theme-light", !isDark);
// 通知依赖主题色的组件重绘(如 Token 活跃度矩阵)
document.dispatchEvent(new CustomEvent("theme-changed"));
}
/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
+4 -1
View File
@@ -3,7 +3,7 @@
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
// 持久化在后端 env_config.json 的 THINKING_LEVEL(/api/config/thinking-level),
// 由 agent_runtime._build_llm_for_agent 决定 ChatQwen 的 extra_body。
// 由 agent_runtime._build_llm_for_agent 决定 Multimodel_LLM 的 extra_body。
// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
@@ -84,6 +84,9 @@ export function syncThinkLevelUI() {
if (trigger) {
trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式";
trigger.dataset.value = state.thinkingLevel;
// 按钮右侧小字:当前思考等级
const badge = trigger.querySelector(".think-mode-badge");
if (badge) badge.textContent = state.thinkingLevel;
}
}
+354
View File
@@ -0,0 +1,354 @@
// usage-stats.js -- Token 用量活跃度卡
// GitHub 风格矩阵(横=周一到周日,竖=周,近半年)+ 点击方块切换为
// 该日按小时柱状图(同一柱内按模型分深浅)。颜色随主题:0 级灰色(--border),
// 1~5 级基于 --accent 的透明度阶梯。
import { api } from './api.js';
const GRID_DAYS = 182; // 最近半年
const WEEKS = 26; // 182 / 7
let _rgb = null; // 缓存的 --accent RGB
let _usage = null; // 最近一次 /api/stats/usage 数据
let _view = "matrix"; // matrix | bars
let _selectedDate = ""; // 柱状图选中的日期 YYYY-MM-DD
let _models = []; // 全局模型列表(按用量降序,用于柱内分色稳定)
function _accentRgb() {
if (_rgb) return _rgb;
let hex = "";
try {
hex = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
} catch { /* ignore */ }
if (!hex) hex = "#4f8ff7";
hex = hex.replace("#", "");
if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
const n = parseInt(hex, 16);
if (Number.isNaN(n)) { _rgb = [79, 143, 247]; return _rgb; }
_rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
return _rgb;
}
/** 等级颜色:0 返回 null(走 CSS 灰色 var(--border)),1~5 为 accent 透明度阶梯 */
function _levelColor(level) {
if (level <= 0) return null;
const [r, g, b] = _accentRgb();
const alpha = 0.18 + level * 0.16; // 0.34 ~ 0.98
return `rgba(${r}, ${g}, ${b}, ${Math.min(alpha, 0.98)})`;
}
/** 模型在柱内的深浅(0.35 ~ 0.95,模型越多区分度越大) */
function _modelColor(index, count) {
const [r, g, b] = _accentRgb();
const alpha = count <= 1 ? 0.85 : 0.35 + (index / Math.max(count - 1, 1)) * 0.6;
return `rgba(${r}, ${g}, ${b}, ${Math.min(alpha, 0.95)})`;
}
function _fmtDate(d) {
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
/** 所在周的周一(JS getDay 0=周日,转周一=0) */
function _startOfWeek(d) {
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const dow = (x.getDay() + 6) % 7;
x.setDate(x.getDate() - dow);
return x;
}
/** 按非 0 用量的分位数计算 total -> level(0..5) 的映射函数 */
function _buildLeveler(totals) {
const vals = [...new Set(totals.filter((t) => t > 0))].sort((a, b) => a - b);
if (!vals.length) return () => 0;
const p = (q) => vals[Math.min(vals.length - 1, Math.floor(vals.length * q))];
// 去重后取 5 个严格递增的阈值
const thresholds = [];
for (const q of [0.25, 0.5, 0.75, 0.9, 1]) {
const v = p(q);
if (!thresholds.length || v > thresholds[thresholds.length - 1]) thresholds.push(v);
}
return (t) => {
if (t <= 0) return 0;
let lvl = 1;
for (let i = 0; i < thresholds.length; i++) if (t >= thresholds[i]) lvl = i + 2;
return Math.min(lvl, 5);
};
}
const DOW_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
/** 按周组织 182 天网格(GitHub 风格):columns[0] 最老、columns[25] 最新(最右),
* 每列 7 格 = 周一到周日(周一在最上)。每列 label 为月份(每月首周显示 "M月")。 */
function _buildColumns(days) {
const dayMap = {};
(days || []).forEach((d) => { dayMap[d.date] = d.total || 0; });
const today = new Date();
const todayKey = _fmtDate(today);
const lastMonday = _startOfWeek(today);
const columns = [];
let lastMonth = -1;
for (let w = WEEKS - 1; w >= 0; w--) {
const weekStart = new Date(lastMonday.getTime() - w * 7 * 86400000);
const cells = [];
for (let dow = 0; dow < 7; dow++) {
const d = new Date(weekStart.getTime() + dow * 86400000);
const key = _fmtDate(d);
cells.push({ date: key, total: dayMap[key] || 0, future: key > todayKey });
}
const m = weekStart.getMonth();
columns.push({
label: m !== lastMonth ? `${m + 1}月` : "",
startDate: _fmtDate(weekStart),
cells,
});
lastMonth = m;
}
return columns;
}
function _card() {
return document.getElementById("usageCard");
}
async function _loadUsage() {
try {
const data = await api("/api/stats/usage");
_usage = data || {};
_models = Object.entries((data && data.by_model) || {})
.sort((a, b) => b[1] - a[1])
.map(([m]) => m);
} catch {
_usage = null;
}
}
function _renderMatrix() {
const card = _card();
if (!card) return;
_view = "matrix";
card.innerHTML = "";
const header = document.createElement("div");
header.className = "usage-card-header";
const title = document.createElement("span");
title.textContent = "Token 活跃度(近半年,点击方块查看每小时用量)";
header.appendChild(title);
const legend = document.createElement("div");
legend.className = "usage-legend";
legend.innerHTML = '<span>少</span>' +
'<span class="legend-cell"></span>' +
'<span class="legend-cell legend-cell--l2"></span>' +
'<span class="legend-cell legend-cell--l4"></span>' +
'<span class="legend-cell legend-cell--l5"></span>' +
'<span>多</span>';
header.appendChild(legend);
card.appendChild(header);
const body = document.createElement("div");
body.className = "usage-matrix-body";
if (!_usage || !Array.isArray(_usage.days) || _usage.days.length === 0) {
const empty = document.createElement("div");
empty.className = "usage-empty";
empty.textContent = "暂无 Token 用量记录,发送消息后这里会出现你的活跃度。";
body.appendChild(empty);
card.appendChild(body);
return;
}
// 等级色样(legend 与格子一致,随主题)
card.querySelectorAll(".legend-cell").forEach((el) => {
const lvl = el.classList.contains("legend-cell--l2") ? 2 : el.classList.contains("legend-cell--l4") ? 4 : el.classList.contains("legend-cell--l5") ? 5 : 1;
const color = _levelColor(lvl);
if (color) el.style.background = color;
});
const leveler = _buildLeveler(_usage.days.map((d) => d.total || 0));
const columns = _buildColumns(_usage.days);
// 网格:第一列星期标签 + 26 列周;第一行月标签
const grid = document.createElement("div");
grid.className = "usage-grid";
// 第一行:空角 + 每列月标签
const corner = document.createElement("div");
corner.className = "usage-dow-label";
grid.appendChild(corner);
columns.forEach((col) => {
const lbl = document.createElement("div");
lbl.className = "usage-col-label";
lbl.textContent = col.label;
lbl.title = col.startDate;
grid.appendChild(lbl);
});
// 7 行:星期标签 + 26 格(横=周,竖=周一到周日,周一在最上)
for (let dow = 0; dow < 7; dow++) {
const dowLabel = document.createElement("div");
dowLabel.className = "usage-dow-label";
dowLabel.textContent = DOW_NAMES[dow];
grid.appendChild(dowLabel);
columns.forEach((col) => {
const cell = col.cells[dow];
const el = document.createElement("div");
el.className = "usage-cell" + (cell.future ? " usage-cell--empty" : "");
if (cell.future) {
grid.appendChild(el);
return;
}
const lvl = leveler(cell.total);
const color = _levelColor(lvl);
if (color) el.style.background = color;
// 任意日期都可点击查看当天每小时柱状图(0 值日期显示空态)
el.title = `${cell.date} · ${cell.total.toLocaleString()} tokens`;
el.addEventListener("click", () => _renderBars(cell.date));
grid.appendChild(el);
});
}
body.appendChild(grid);
card.appendChild(body);
}
function _renderBars(date) {
const card = _card();
if (!card) return;
_view = "bars";
_selectedDate = date;
card.innerHTML = "";
const hours = (_usage && _usage.hours && _usage.hours[date]) || {};
const header = document.createElement("div");
header.className = "usage-card-header";
const title = document.createElement("span");
title.textContent = `${date} · 每小时 Token 用量(同柱按模型分色)`;
const backBtn = document.createElement("button");
backBtn.type = "button";
backBtn.className = "usage-back-btn";
backBtn.title = "返回矩阵";
backBtn.style.display = "inline-flex"; // 柱状图视图下显示(CSS 默认隐藏)
const backIcon = document.createElement("img");
backIcon.src = "/image/撤回.svg";
backIcon.alt = "返回矩阵";
backIcon.className = "usage-back-icon";
backBtn.appendChild(backIcon);
backBtn.addEventListener("click", () => _renderMatrix());
header.appendChild(title);
header.appendChild(backBtn);
card.appendChild(header);
const body = document.createElement("div");
body.className = "usage-bars-wrap";
const entries = Object.entries(hours).map(([h, byModel]) => {
let total = 0;
const parts = [];
// 柱内模型顺序与全局一致(用量多的排下),深浅递增
_models.forEach((m) => {
const v = byModel[m] || 0;
if (v > 0) parts.push({ model: m, value: v });
});
// 未在全局列表中的模型(异常)兜底
Object.entries(byModel).forEach(([m, v]) => {
if (v > 0 && !_models.includes(m)) parts.push({ model: m, value: v });
});
total = parts.reduce((s, p) => s + p.value, 0);
return { hour: h, total, parts };
});
if (!entries.length) {
const empty = document.createElement("div");
empty.className = "usage-empty";
empty.textContent = "这一天没有 Token 用量记录。";
body.appendChild(empty);
card.appendChild(body);
return;
}
const maxTotal = Math.max(...entries.map((e) => e.total), 1);
const barsRow = document.createElement("div");
barsRow.className = "usage-bars";
// 24 小时补齐空柱(视觉连续)
for (let h = 0; h < 24; h++) {
const key = String(h).padStart(2, "0");
const e = entries.find((x) => x.hour === key);
const bar = document.createElement("div");
bar.className = "usage-bar";
if (!e) {
bar.style.background = "transparent";
bar.title = `${key}:00 · 0 tokens`;
barsRow.appendChild(bar);
continue;
}
bar.style.background = "rgba(0,0,0,0.03)";
const segTotal = e.total;
const heightPct = (segTotal / maxTotal) * 100;
const segContainer = document.createElement("div");
segContainer.style.cssText = `height:${Math.max(heightPct, 1)}%;display:flex;flex-direction:column;justify-content:flex-end;`;
e.parts.forEach((p, idx) => {
const seg = document.createElement("div");
seg.style.cssText = `background:${_modelColor(idx, e.parts.length)};flex:${p.value};`;
seg.title = `${key}:00 · ${p.model} ${p.value.toLocaleString()} tokens`;
segContainer.appendChild(seg);
});
bar.appendChild(segContainer);
bar.title = `${key}:00 · 共 ${segTotal.toLocaleString()} tokens`;
barsRow.appendChild(bar);
}
body.appendChild(barsRow);
const axis = document.createElement("div");
axis.className = "usage-bar-axis";
[0, 6, 12, 18, 23].forEach((h) => {
const s = document.createElement("span");
s.textContent = `${h}:00`;
axis.appendChild(s);
});
body.appendChild(axis);
const legend = document.createElement("div");
legend.className = "usage-legend";
legend.style.marginTop = "0.5rem";
if (_models.length > 1) {
legend.innerHTML = _models.map((m, i) =>
`<span class="legend-cell" style="background:${_modelColor(i, _models.length)};display:inline-block"></span><span>${m}</span>`
).join("<span style='width:0.6rem'></span>");
} else {
legend.textContent = "模型:" + (_models[0] || "未知");
}
body.appendChild(legend);
card.appendChild(body);
}
/**
* 加载并渲染活跃度卡(保持当前视图:矩阵 / 柱状图的选中日期)。
* 仅当欢迎区可见时渲染;渲染完成后取消卡的 hidden,使其显示。
*/
export async function refreshUsageCard() {
const card = _card();
if (!card) return;
const welcome = document.getElementById("welcomeView");
if (welcome && welcome.hidden) return;
await _loadUsage();
if (_view === "bars" && _selectedDate) _renderBars(_selectedDate);
else _renderMatrix();
card.hidden = false;
}
/**
* 初始化:绑定主题切换重绘;若欢迎区可见则加载一次。
*/
export async function initUsageCard() {
document.addEventListener("theme-changed", () => {
_rgb = null;
refreshUsageCard();
});
refreshUsageCard();
}
+105
View File
@@ -0,0 +1,105 @@
// welcome-slogans.js -- 分时段欢迎标语
// 按当前小时分组随机展示一条;支持 {h}(如 "23")与 {hh}(如 "23")占位符,
// 渲染时替换为当前小时,实现随实时时间变化的标语。
const SLOGANS = {
dawn: { // 00:00 - 04:59
range: [0, 5],
items: [
"夜深了,注意休息,让灵感在梦里继续生长 🌙",
"现在是 {h} 点,深夜的代码最安静,但也要记得睡觉",
"深夜模式开启:理性思考,理性入睡",
"月亮都困了,任务可以留给明天",
"万籁俱寂,此刻的专注格外珍贵",
"深夜不是赶工的借口,休息也是效率的一部分",
"把难题留给白天的自己,晚安",
],
},
morning: { // 05:00 - 07:59
range: [5, 8],
items: [
"早上好,新的一天从一杯水开始 ☀️",
"清晨 {h} 点,头脑最清醒的时刻,适合处理最难的问题",
"早起的鸟儿有虫吃,早起的你有 Agent 帮忙",
"今天的效率,从清晨的一杯咖啡开始",
"早安,世界和你都值得被温柔以待",
"清晨的阳光和待办清单,都排好队了",
"新的一天,新的任务,新的可能",
],
},
forenoon: { // 08:00 - 10:59
range: [8, 11],
items: [
"上午好,活力满满的黄金时段 ✨",
"现在是 {h} 点,专注的上午适合攻坚",
"上午的效率决定一天的节奏,加油",
"把最重要的事放在上午做",
"元气满满的上午,适合处理大事",
"上午好!Agent 已经就绪,随时可以开工",
"趁精力充沛,先把硬骨头啃下来",
],
},
noon: { // 11:00 - 12:59
range: [11, 13],
items: [
"中午好,记得吃午饭 🍚",
"现在是 {h} 点,午休一下,下午更高效",
"午饭时间到,工作暂时放一放",
"中午好!吃饱才有力气干活",
"忙碌一上午,是时候犒劳自己了",
"午间小憩 20 分钟,下午满血复活",
"中午好,别忘了喝水",
],
},
afternoon: { // 13:00 - 16:59
range: [13, 17],
items: [
"下午好,继续加油 💪",
"现在是 {h} 点,下午的节奏由你掌控",
"下午茶时间,效率与悠闲可以并存",
"下午好!午睡醒来又是一条好汉",
"午后时光,适合处理琐碎但重要的小事",
"阳光正好,任务进度也要跟上",
"下午好,坚持就是胜利",
],
},
evening: { // 17:00 - 18:59
range: [17, 19],
items: [
"傍晚好,今天辛苦啦 🌆",
"现在是 {h} 点,日落时分适合复盘今天",
"傍晚好!总结一下今天的收获吧",
"夕阳无限好,只是任务还没完",
"傍晚是一天中最温柔的时分",
"辛苦一天了,晚餐值得好好吃",
],
},
night: { // 19:00 - 23:59
range: [19, 24],
items: [
"晚上好,夜生活从此刻开始 🌃",
"现在是 {h} 点,晚上的灵感常常不请自来",
"晚上好!可以处理一些轻松的收尾工作",
"夜幕降临,专注力回笼",
"晚上好,记得留点时间给自己",
"深夜将至,任务进度过半了吗?",
"晚上好!Agent 陪你把事情做完",
],
},
};
/**
* 按当前小时返回一条随机标语(已替换时间占位符)。
* @param {Date} [now]
* @returns {string}
*/
export function getSlogan(now = new Date()) {
const h = now.getHours();
const groups = Object.values(SLOGANS);
const group = groups.find((g) => h >= g.range[0] && h < g.range[1]) || SLOGANS.night;
const items = group.items;
const text = items[Math.floor(Math.random() * items.length)];
return text
.replace(/\{hh\}/g, String(h).padStart(2, "0"))
.replace(/\{h\}/g, String(h));
}
+14
View File
@@ -1668,6 +1668,20 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
}
# ---------- 全局 Token 用量统计 API ----------
@app.get("/api/stats/usage")
def api_get_usage_stats(days: int = 182) -> dict[str, Any]:
"""获取全局 token 用量统计(跨会话累加,欢迎区活跃度矩阵 / 柱状图数据源)。
输出:
{"total": int, "by_model": {model: int},
"days": [{"date": str, "total": int, "by_model": {model: int}}],
"hours": {"YYYY-MM-DD": {"HH": {model: int}}}}
"""
from agent.core.usage_stats import get_usage_summary
return get_usage_summary(days=max(1, min(days, 366)))
# ---------- 工具调用历史 API ----------
@app.get("/api/tool-calls/{session_id}/live")
def api_get_live_tool_calls(session_id: str) -> dict[str, Any]: