优化CoT,并修复无法开启深度思考模式的问题
This commit is contained in:
1 parent
a238f523b5
commit
25a811ea62
27 files changed
+1155
-270
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- runtime: 对外执行入口 execute_agent
|
||||
- human_request: 人工请求注册表(交互类工具的通用阻塞通道)
|
||||
- turn_runner: 后台线程轮次执行器
|
||||
- llm: ChatQwen 多模态网关
|
||||
- llm: Multimodel_LLM 多模态网关
|
||||
- tool_policy: 工具执行安全策略
|
||||
|
||||
系统定位:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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="")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}}}
|
||||
@@ -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"。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 表示禁用思考(默认)。
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 主机地址。",
|
||||
|
||||
@@ -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 恢复全部设置 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
Reference in new issue
Block a user