优化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 实例。
|
"""根据 Agent 配置中指定的 llm_model_id 构建 LLM 实例。
|
||||||
|
|
||||||
从 env_config.json 的 models 列表中查找匹配的模型配置,
|
从 env_config.json 的 models 列表中查找匹配的模型配置,
|
||||||
用其参数构造 ChatQwen 实例。若未找到匹配模型则回退到环境变量默认值。
|
用其参数构造 Multimodel_LLM 实例。若未找到匹配模型则回退到环境变量默认值。
|
||||||
"""
|
"""
|
||||||
from agent.core.llm import ChatQwen
|
from agent.core.llm import Multimodel_LLM
|
||||||
|
|
||||||
models = load_models()
|
models = load_models()
|
||||||
model_config = None
|
model_config = None
|
||||||
@@ -44,10 +44,10 @@ def _build_llm_for_agent(cfg: AgentConfig):
|
|||||||
if model_config:
|
if model_config:
|
||||||
# 深度思考由全局 THINKING_LEVEL 档位控制(low/high 关闭,xhigh/max/ultra 开启),
|
# 深度思考由全局 THINKING_LEVEL 档位控制(low/high 关闭,xhigh/max/ultra 开启),
|
||||||
# 取代原先 per-model 的 enable_thinking 开关。true 档位传空 extra_body 恢复模型
|
# 取代原先 per-model 的 enable_thinking 开关。true 档位传空 extra_body 恢复模型
|
||||||
# 原生行为(启用思考);否则不传,由 ChatQwen 使用禁用思考的默认值。
|
# 原生行为(启用思考);否则不传,由 Multimodel_LLM 使用禁用思考的默认值。
|
||||||
from agent.utils.env_utils import get_thinking_extra_body
|
from agent.utils.env_utils import get_thinking_extra_body
|
||||||
extra_body = get_thinking_extra_body()
|
extra_body = get_thinking_extra_body()
|
||||||
return ChatQwen(
|
return Multimodel_LLM(
|
||||||
model=model_config.get("model", ""),
|
model=model_config.get("model", ""),
|
||||||
api_key=model_config.get("api_key", ""),
|
api_key=model_config.get("api_key", ""),
|
||||||
base_url=model_config.get("base_url", ""),
|
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)),
|
max_retries=int(model_config.get("max_retries", 0)),
|
||||||
extra_body=extra_body,
|
extra_body=extra_body,
|
||||||
)
|
)
|
||||||
# 回退:使用环境变量中已加载的默认 LLM 配置
|
# 回退:主 Agent 配置的模型兜底(与模块级 llm 单例一致,优先主 Agent 而非模型列表第一个)
|
||||||
from agent.core.llm import llm as _default_llm
|
from agent.core.llm import build_default_llm
|
||||||
return ChatQwen(
|
return build_default_llm()
|
||||||
model=_default_llm.model_name,
|
|
||||||
api_key=_default_llm.openai_api_key or "",
|
|
||||||
base_url=_default_llm.openai_api_base or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime:
|
def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime:
|
||||||
|
|||||||
@@ -14,9 +14,7 @@
|
|||||||
"RAG_CHUNK_OVERLAP": "50",
|
"RAG_CHUNK_OVERLAP": "50",
|
||||||
"GROUNDING_WIDTH": "1000",
|
"GROUNDING_WIDTH": "1000",
|
||||||
"GROUNDING_HEIGHT": "1000",
|
"GROUNDING_HEIGHT": "1000",
|
||||||
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20",
|
|
||||||
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
||||||
"LOOP_DETECT_REPEATED_TOOL_END": "5",
|
|
||||||
"SEND_FILE_SIZE_LIMIT": "30",
|
"SEND_FILE_SIZE_LIMIT": "30",
|
||||||
"EMAIL_ADDRESS": "",
|
"EMAIL_ADDRESS": "",
|
||||||
"EMAIL_AUTH_CODE": "",
|
"EMAIL_AUTH_CODE": "",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- runtime: 对外执行入口 execute_agent
|
- runtime: 对外执行入口 execute_agent
|
||||||
- human_request: 人工请求注册表(交互类工具的通用阻塞通道)
|
- human_request: 人工请求注册表(交互类工具的通用阻塞通道)
|
||||||
- turn_runner: 后台线程轮次执行器
|
- turn_runner: 后台线程轮次执行器
|
||||||
- llm: ChatQwen 多模态网关
|
- llm: Multimodel_LLM 多模态网关
|
||||||
- tool_policy: 工具执行安全策略
|
- tool_policy: 工具执行安全策略
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
|
|||||||
@@ -369,9 +369,7 @@ def _default_env_config() -> dict:
|
|||||||
"RAG_CHUNK_OVERLAP": "50",
|
"RAG_CHUNK_OVERLAP": "50",
|
||||||
"GROUNDING_WIDTH": "1000",
|
"GROUNDING_WIDTH": "1000",
|
||||||
"GROUNDING_HEIGHT": "1000",
|
"GROUNDING_HEIGHT": "1000",
|
||||||
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "10",
|
|
||||||
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
||||||
"LOOP_DETECT_REPEATED_TOOL_END": "5",
|
|
||||||
"SEND_FILE_SIZE_LIMIT": "30",
|
"SEND_FILE_SIZE_LIMIT": "30",
|
||||||
"STORAGE_BACKEND": "json",
|
"STORAGE_BACKEND": "json",
|
||||||
"MYSQL_HOST": "localhost",
|
"MYSQL_HOST": "localhost",
|
||||||
@@ -499,19 +497,19 @@ def get_gui_model_config() -> dict | None:
|
|||||||
def get_gui_llm():
|
def get_gui_llm():
|
||||||
"""获取 GUI 工具专用的 LLM 实例。
|
"""获取 GUI 工具专用的 LLM 实例。
|
||||||
|
|
||||||
若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 ChatQwen 实例;
|
若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 Multimodel_LLM 实例;
|
||||||
否则返回 None(调用方应回退到全局 llm 单例)。
|
否则返回 None(调用方应回退到全局 llm 单例)。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
ChatQwen 实例或 None
|
Multimodel_LLM 实例或 None
|
||||||
"""
|
"""
|
||||||
model_cfg = get_gui_model_config()
|
model_cfg = get_gui_model_config()
|
||||||
if model_cfg is None:
|
if model_cfg is None:
|
||||||
return None
|
return None
|
||||||
from agent.core.llm import ChatQwen
|
from agent.core.llm import Multimodel_LLM
|
||||||
# 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking
|
# 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking
|
||||||
from agent.utils.env_utils import get_thinking_extra_body
|
from agent.utils.env_utils import get_thinking_extra_body
|
||||||
return ChatQwen(
|
return Multimodel_LLM(
|
||||||
model=model_cfg["model"],
|
model=model_cfg["model"],
|
||||||
api_key=model_cfg["api_key"],
|
api_key=model_cfg["api_key"],
|
||||||
base_url=model_cfg["base_url"],
|
base_url=model_cfg["base_url"],
|
||||||
|
|||||||
+65
-17
@@ -1,9 +1,10 @@
|
|||||||
"""ChatQwen:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。
|
"""Multimodel_LLM:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
项目默认 LLM 单例 ``llm``,被 agents 绑定工具,并承担:
|
项目默认 LLM 单例 ``llm``,被 agents 绑定工具,并承担:
|
||||||
- 用户消息预处理(附件、ASR、图片 data URL)
|
- 用户消息预处理(附件、ASR、图片 data URL)
|
||||||
- 非标准 tool_call XML/JSON 解析
|
- 非标准 tool_call XML/JSON 解析
|
||||||
|
- 深度思考归一化(独立 reasoning_content / 内联 <think> 块)
|
||||||
- Vision 描述接口
|
- 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.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.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.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
|
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
|
||||||
|
|
||||||
class ChatQwen(ChatOpenAI):
|
class Multimodel_LLM(ChatOpenAI):
|
||||||
"""Qwen 系列模型的 LangChain 封装,扩展多模态能力。
|
"""基于 OpenAI 兼容 API 的多模态模型封装,扩展深度思考归一化能力。
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
agents/main_agent 的 llm 与 bind_tools 基底。
|
agents/main_agent 的 llm 与 bind_tools 基底。
|
||||||
@@ -47,7 +49,7 @@ class ChatQwen(ChatOpenAI):
|
|||||||
无。
|
无。
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
模块级 ``llm = ChatQwen()`` 单例构造。
|
模块级 ``llm = Multimodel_LLM()`` 单例构造。
|
||||||
|
|
||||||
可扩展性:
|
可扩展性:
|
||||||
可增加 default_headers、proxy 等 ChatOpenAI 参数。
|
可增加 default_headers、proxy 等 ChatOpenAI 参数。
|
||||||
@@ -219,7 +221,7 @@ class ChatQwen(ChatOpenAI):
|
|||||||
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||||
|
|
||||||
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
|
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
|
||||||
"""解析 API 响应:reasoning 合并、XML/JSON tool_call 提取。
|
"""解析 API 响应:深度思考归一化、XML/JSON tool_call 提取。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
response: OpenAI 兼容 API 原始响应。
|
response: OpenAI 兼容 API 原始响应。
|
||||||
@@ -229,7 +231,10 @@ class ChatQwen(ChatOpenAI):
|
|||||||
ChatResult,AIMessage 可能含 tool_calls。
|
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 分支扩展。
|
新增模型格式时在 regex/JSON 分支扩展。
|
||||||
@@ -243,7 +248,7 @@ class ChatQwen(ChatOpenAI):
|
|||||||
lc_msg = result.generations[i].message
|
lc_msg = result.generations[i].message
|
||||||
msg = choice.get("message") or {}
|
msg = choice.get("message") or {}
|
||||||
ak = lc_msg.additional_kwargs
|
ak = lc_msg.additional_kwargs
|
||||||
# 优先处理 reasoning 内容
|
# 1) 独立 reasoning 字段(OpenAI 兼容 / Qwen reasoning_content 等)
|
||||||
merged = ""
|
merged = ""
|
||||||
for key in ("reasoning_content", "reasoning_details", "reasoning"):
|
for key in ("reasoning_content", "reasoning_details", "reasoning"):
|
||||||
merged = assistant_text(msg.get(key))
|
merged = assistant_text(msg.get(key))
|
||||||
@@ -251,6 +256,7 @@ class ChatQwen(ChatOpenAI):
|
|||||||
break
|
break
|
||||||
if merged:
|
if merged:
|
||||||
ak.setdefault("reasoning_content", merged)
|
ak.setdefault("reasoning_content", merged)
|
||||||
|
# 2) content 提取
|
||||||
raw_content = assistant_text(msg.get("content"))
|
raw_content = assistant_text(msg.get("content"))
|
||||||
if not raw_content and merged:
|
if not raw_content and merged:
|
||||||
lc_msg.content = merged
|
lc_msg.content = merged
|
||||||
@@ -259,7 +265,17 @@ class ChatQwen(ChatOpenAI):
|
|||||||
|
|
||||||
content_text = lc_msg.content if isinstance(lc_msg.content, str) else ""
|
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)
|
pattern = re.compile(r'<tool_call>\s*(.*?)\s*</tool_call>', re.DOTALL)
|
||||||
matches = pattern.findall(content_text)
|
matches = pattern.findall(content_text)
|
||||||
if matches:
|
if matches:
|
||||||
@@ -374,18 +390,50 @@ class ChatQwen(ChatOpenAI):
|
|||||||
result = self.invoke(messages)
|
result = self.invoke(messages)
|
||||||
return result.content
|
return result.content
|
||||||
|
|
||||||
llm = ChatQwen()
|
def build_default_llm() -> Multimodel_LLM:
|
||||||
|
"""构建默认/兜底 LLM 实例:优先主 Agent 配置的模型,回退到环境变量(models[0] 派生)。
|
||||||
def get_default_llm():
|
|
||||||
"""获取默认 LLM 实例,优先使用主 Agent 配置的模型。
|
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
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 驱动),
|
该函数会先尝试获取主 Agent Runtime 的 LLM(由 agent_config.json 驱动),
|
||||||
若获取失败(模块未加载/主 Runtime 不存在)则回退到 models[0] 的默认 ChatQwen。
|
若获取失败(模块未加载/主 Runtime 不存在)则回退到模块级 ``llm``——
|
||||||
|
该单例同样以主 Agent 配置的模型优先构建(见 build_default_llm)。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
ChatQwen 实例。
|
Multimodel_LLM 实例。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from agent.agents.agent_runtime import get_main_agent_runtime
|
from agent.agents.agent_runtime import get_main_agent_runtime
|
||||||
@@ -398,7 +446,7 @@ def get_default_llm():
|
|||||||
|
|
||||||
|
|
||||||
def reload_llm() -> None:
|
def reload_llm() -> None:
|
||||||
"""热重载:重新从环境变量创建 LLM 实例。"""
|
"""热重载:重新从配置创建默认 LLM 实例(优先主 Agent 配置的模型)。"""
|
||||||
global llm
|
global llm
|
||||||
llm = ChatQwen()
|
llm = build_default_llm()
|
||||||
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Agent 死循环检测模块。
|
"""Agent 重复工具调用检测模块。
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
在 ReAct 循环的 agent 节点和路由节点之间,检测连续相同工具调用的异常模式,
|
在 ReAct 循环的 agent 节点调用 LLM 前,检测连续相同工具调用的异常模式,
|
||||||
提供分级响应:
|
提供 Level 1 响应:注入反思提示引导模型自我纠正(不再强制终止,Level 2 已移除)。
|
||||||
- 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 = {}
|
env = {}
|
||||||
return {
|
return {
|
||||||
"repeated_tool_warn": int(env.get("LOOP_DETECT_REPEATED_TOOL_WARN", 3)),
|
"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()
|
cfg = get_loop_config()
|
||||||
warn_threshold = max(cfg["repeated_tool_warn"], 2)
|
warn_threshold = max(cfg["repeated_tool_warn"], 2)
|
||||||
end_threshold = max(cfg["repeated_tool_end"], warn_threshold + 1)
|
lookback = warn_threshold + 2
|
||||||
lookback = end_threshold + 2
|
|
||||||
|
|
||||||
recent = get_recent_tool_calls_from_messages(messages, count=lookback)
|
recent = get_recent_tool_calls_from_messages(messages, count=lookback)
|
||||||
consecutive = _compute_consecutive_same(recent)
|
consecutive = _compute_consecutive_same(recent)
|
||||||
@@ -137,30 +133,6 @@ def detect_repeated_tool_calls(
|
|||||||
return consecutive, last_call.get("name", "unknown"), args_summary
|
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,
|
messages: list,
|
||||||
session_id: str = "",
|
session_id: str = "",
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""根据当前状态构建反循环反思提示。
|
"""根据当前状态构建反循环反思提示(Level 1 响应)。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
messages: 当前消息列表。
|
messages: 当前消息列表。
|
||||||
@@ -199,26 +171,3 @@ def build_loop_reflection_prompt(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return "\n\n".join(parts)
|
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.loop_detector import build_loop_reflection_prompt
|
||||||
from agent.core.state import AgentState
|
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.image_utils import image_bytes_to_openai_image_url_part
|
||||||
from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text
|
from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text
|
||||||
from langchain_core.messages import SystemMessage
|
from langchain_core.messages import SystemMessage
|
||||||
@@ -115,61 +115,42 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
|
|||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _build_reflection_prompt(agent_mode: str, is_first_call: bool, messages: list, session_id: str = "") -> str | None:
|
def _build_reflection_prompt(messages: list, session_id: str = "") -> str | None:
|
||||||
"""构建反思提示词,引导 Agent 在下一步执行前进行思考。
|
"""构建每轮注入模型上下文的提示文本,根据思考档位区分注入内容。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
agent_mode: 运行模式("agent" | "plan")。
|
|
||||||
is_first_call: 是否为当前任务的首次 agent 调用。
|
|
||||||
messages: 当前消息列表(已过滤短期记忆),供循环检测使用。
|
messages: 当前消息列表(已过滤短期记忆),供循环检测使用。
|
||||||
session_id: 会话 ID,供循环检测使用。
|
session_id: 会话 ID,供循环检测与 TodoList 状态重建使用。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
反思提示文本,不需要反思时返回 None。
|
注入文本;无需注入时返回 None。
|
||||||
|
|
||||||
|
系统定位:
|
||||||
|
- 思考关闭(low/high):注入反思引导提示词 + TodoList 状态 + 循环警告,
|
||||||
|
引导模型先一句话反思再调用工具;
|
||||||
|
- 思考开启(xhigh/max/ultra):仅注入 TodoList 状态(模型原生深度思考,
|
||||||
|
不注入反思引导与循环警告)。
|
||||||
"""
|
"""
|
||||||
# 非 plan 模式首次调用,不需要反思
|
from agent.utils.env_utils import thinking_enabled
|
||||||
if is_first_call:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 后续调用:引导模型先思考再行动(TodoList 状态由工具返回值提供,无需额外注入)
|
parts: list[str] = []
|
||||||
parts = [REFLECTION_PROMPT]
|
|
||||||
|
|
||||||
# 循环检测:注入反循环反思提示
|
# 思考关闭:反思引导 + 循环警告(Level 1 仅提醒,不强制终止)
|
||||||
loop_warning = build_loop_reflection_prompt(messages, session_id)
|
if not thinking_enabled():
|
||||||
if loop_warning:
|
parts.append(REFLECTION_PROMPT)
|
||||||
parts.append(loop_warning)
|
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。")
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
def _is_first_agent_call(messages: list) -> bool:
|
|
||||||
"""判断是否为当前用户任务的首轮 agent 调用。
|
|
||||||
|
|
||||||
规则:找到最后一条非 synthetic 的 HumanMessage(即用户最新输入)。
|
|
||||||
检查其后是否存在 AIMessage。若不存在,说明 agent 尚未对该输入做出响应。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
messages: 已过滤短期记忆的消息列表。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
True 表示首次调用。
|
|
||||||
"""
|
|
||||||
# 从后向前找到最后一条非 synthetic 的 HumanMessage
|
|
||||||
last_user_idx = -1
|
|
||||||
for i in range(len(messages) - 1, -1, -1):
|
|
||||||
msg = messages[i]
|
|
||||||
if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"):
|
|
||||||
last_user_idx = i
|
|
||||||
break
|
|
||||||
|
|
||||||
if last_user_idx < 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 检查该用户消息之后是否有 AIMessage
|
|
||||||
for i in range(last_user_idx + 1, len(messages)):
|
|
||||||
if isinstance(messages[i], AIMessage):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- ReAct 循环内上下文压缩 ----------
|
# ---------- ReAct 循环内上下文压缩 ----------
|
||||||
@@ -245,7 +226,7 @@ def _compute_next_cursor(session_id: str) -> int:
|
|||||||
return -1
|
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 用量判断下一步动作。
|
"""根据 LLM 返回的 token 用量判断下一步动作。
|
||||||
|
|
||||||
返回值 (trigger, total_tokens, threshold):
|
返回值 (trigger, total_tokens, threshold):
|
||||||
@@ -255,7 +236,8 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) ->
|
|||||||
整理提示词,让子 Agent 整理任务进度直接返回主 Agent。
|
整理提示词,让子 Agent 整理任务进度直接返回主 Agent。
|
||||||
|
|
||||||
同时将 total_tokens 与估算的三类占比(消息/工具/系统提示词)存入
|
同时将 total_tokens 与估算的三类占比(消息/工具/系统提示词)存入
|
||||||
tool_call_recorder 供前端环形指示展示(仅主 Agent)。
|
tool_call_recorder 供前端环形指示展示(仅主 Agent),并累加进全局
|
||||||
|
usage_stats(跨会话按小时/模型聚合,供欢迎区活跃度矩阵展示)。
|
||||||
"""
|
"""
|
||||||
total = _extract_total_tokens(response)
|
total = _extract_total_tokens(response)
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
@@ -270,6 +252,12 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) ->
|
|||||||
if _sid and not is_sub:
|
if _sid and not is_sub:
|
||||||
breakdown = _estimate_token_breakdown(messages or [], _extract_usage(response))
|
breakdown = _estimate_token_breakdown(messages or [], _extract_usage(response))
|
||||||
set_session_tokens(_sid, total, breakdown=breakdown)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -282,14 +270,6 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) ->
|
|||||||
return ("compress", total, threshold)
|
return ("compress", total, threshold)
|
||||||
|
|
||||||
|
|
||||||
SUB_AGENT_OOM_PROMPT = """
|
|
||||||
【系统指令 - 上下文超限】
|
|
||||||
你的上下文 token 用量已达 {total_tokens},超过限制阈值 {threshold},继续调用工具可能导致溢出。
|
|
||||||
请立即停止调用任何工具,整理当前任务的详细进度(已完成的工作、关键结果、遇到的问题及原因),
|
|
||||||
直接以文字形式回复主 Agent,不要再调用任何工具。
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
|
|
||||||
COMPRESSED_SUMMARY_PREFIX = "【历史工具调用摘要】"
|
COMPRESSED_SUMMARY_PREFIX = "【历史工具调用摘要】"
|
||||||
COMPRESSED_SUMMARY_MSG_ID = "compressed_summary"
|
COMPRESSED_SUMMARY_MSG_ID = "compressed_summary"
|
||||||
|
|
||||||
@@ -346,10 +326,10 @@ def maybe_compress_context(state: AgentState) -> dict:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
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
|
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:
|
if sid:
|
||||||
@@ -399,10 +379,12 @@ def make_call_model_node(
|
|||||||
model_with_tools: Any,
|
model_with_tools: Any,
|
||||||
trajectory_rounds: int = 3,
|
trajectory_rounds: int = 3,
|
||||||
) -> Callable[[AgentState], dict]:
|
) -> Callable[[AgentState], dict]:
|
||||||
"""创建带反思机制 + 上下文中压缩的调用已绑定工具的 LLM 的图节点闭包。
|
"""创建带按思考档位注入提示 + 上下文中压缩的调用已绑定工具的 LLM 的图节点闭包。
|
||||||
|
|
||||||
每次调用前注入反思引导提示;调用后提取模型同时产出的文本思考内容(content),
|
每次调用 LLM 前按思考档位注入提示(思考关闭:反思引导 + TodoList 状态 +
|
||||||
记录到 think store,供前端实时展示思维链。Plan 模式下首次调用强制要求生成 todolist。
|
循环警告;思考开启:仅 TodoList 状态);调用后提取模型产出的思考/反思内容
|
||||||
|
(思考开启取深度思考,思考关闭取一句话反思),记录到 think store,
|
||||||
|
供前端实时展示思维链。
|
||||||
|
|
||||||
在每次 LLM 调用后,根据 response 的 ``usage_metadata.total_tokens`` 判断
|
在每次 LLM 调用后,根据 response 的 ``usage_metadata.total_tokens`` 判断
|
||||||
是否触发消息列表就地压缩——确保 ReAct 循环中的上下文永不溢出。
|
是否触发消息列表就地压缩——确保 ReAct 循环中的上下文永不溢出。
|
||||||
@@ -410,7 +392,6 @@ def make_call_model_node(
|
|||||||
|
|
||||||
def call_model(state: AgentState) -> dict:
|
def call_model(state: AgentState) -> dict:
|
||||||
all_messages = list(state["messages"])
|
all_messages = list(state["messages"])
|
||||||
agent_mode = state.get("agent_mode", "agent")
|
|
||||||
|
|
||||||
# 检查 abort 标志
|
# 检查 abort 标志
|
||||||
try:
|
try:
|
||||||
@@ -426,9 +407,6 @@ def make_call_model_node(
|
|||||||
|
|
||||||
filtered_messages = _filter_short_term_memory(all_messages, trajectory_rounds)
|
filtered_messages = _filter_short_term_memory(all_messages, trajectory_rounds)
|
||||||
|
|
||||||
# 判断是否为首轮 agent 调用(当前轮尚无 AIMessage)
|
|
||||||
is_first_call = _is_first_agent_call(filtered_messages)
|
|
||||||
|
|
||||||
# 获取 session_id 供循环检测使用
|
# 获取 session_id 供循环检测使用
|
||||||
try:
|
try:
|
||||||
from agent.history.tool_call_recorder import get_current_session
|
from agent.history.tool_call_recorder import get_current_session
|
||||||
@@ -436,8 +414,8 @@ def make_call_model_node(
|
|||||||
except Exception:
|
except Exception:
|
||||||
_sid = ""
|
_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)
|
messages_for_llm = list(filtered_messages)
|
||||||
if reflection:
|
if reflection:
|
||||||
reflection_msg = HumanMessage(content=reflection, additional_kwargs={"synthetic": True, "reflection": True})
|
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)
|
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 超限整理。
|
# 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。
|
||||||
# total_tokens 同时存入 session_tokens 供前端轮询(仅主 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":
|
if trigger == "sub_oom":
|
||||||
# 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接
|
# 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接
|
||||||
# 返回主 Agent(提示词内附 OOM 溢出信息)。
|
# 返回主 Agent(提示词内附 OOM 溢出信息)。
|
||||||
@@ -464,9 +449,10 @@ def make_call_model_node(
|
|||||||
need_compress = (trigger == "compress")
|
need_compress = (trigger == "compress")
|
||||||
state["_need_compress"] = need_compress
|
state["_need_compress"] = need_compress
|
||||||
|
|
||||||
# 提取模型真实输出的思考内容(content),记录到前端 think 面板
|
# 提取模型产出的思考/反思内容,记录到前端 think 面板与后端记录
|
||||||
# 模型同时输出 content + tool_calls 时,content 就是其思维链
|
# - 思考关闭:模型在反思提示引导下输出的 content(一句话反思)
|
||||||
# 部分模型(如 Qwen enable_thinking)将思考放在 additional_kwargs.reasoning_content
|
# - 思考开启:深度思考(additional_kwargs.reasoning_content 或内联 <think> 块,
|
||||||
|
# 由 _create_chat_result 归一化到 reasoning_content)
|
||||||
response_content = extract_reasoning_text(response)
|
response_content = extract_reasoning_text(response)
|
||||||
if response_content and isinstance(response_content, str) and response_content.strip():
|
if response_content and isinstance(response_content, str) and response_content.strip():
|
||||||
has_tool_calls = getattr(response, "tool_calls", None)
|
has_tool_calls = getattr(response, "tool_calls", None)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from typing import Literal
|
|||||||
from langgraph.graph import END
|
from langgraph.graph import END
|
||||||
|
|
||||||
from agent.core.state import AgentState
|
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__"]:
|
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
|
||||||
@@ -26,12 +25,10 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
|
|||||||
state: 含 messages 的 AgentState。
|
state: 含 messages 的 AgentState。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(无待执行工具调用、
|
``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(无待执行工具调用)。
|
||||||
循环检测强制终止)。
|
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
``graph`` 中 agent 节点的 conditional_edges 路由函数。
|
``graph`` 中 agent 节点的 conditional_edges 路由函数。
|
||||||
若检测到死循环(连续相同工具调用超阈值),则强制 END。
|
|
||||||
|
|
||||||
可扩展性:
|
可扩展性:
|
||||||
可先执行 direct 类工具、仅对 confirm 类暂停(需拆分 tool_calls 处理)。
|
可先执行 direct 类工具、仅对 confirm 类暂停(需拆分 tool_calls 处理)。
|
||||||
@@ -39,15 +36,6 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
|
|||||||
last_msg = state["messages"][-1]
|
last_msg = state["messages"][-1]
|
||||||
|
|
||||||
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
|
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
|
||||||
# 循环检测 Level 2:连续相同工具调用超过 END 阈值 → 强制终止
|
# 所有工具均 direct 自动执行,直接进入 ToolNode
|
||||||
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
|
|
||||||
return "tools"
|
return "tools"
|
||||||
return END
|
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.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.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 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
|
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,
|
turn_id: str,
|
||||||
agent_mode: str = "agent",
|
agent_mode: str = "agent",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""处理图执行完成后的公共逻辑:检查循环强制终止、保存 turn、构建 assistant 消息。"""
|
"""处理图执行完成后的公共逻辑:保存 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
|
|
||||||
|
|
||||||
# 正常结束
|
# 正常结束
|
||||||
end_live_turn(session_id)
|
end_live_turn(session_id)
|
||||||
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
|
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] = {}
|
name_counter: dict[str, int] = {}
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if isinstance(msg, AIMessage) and msg.tool_calls:
|
if isinstance(msg, AIMessage) and msg.tool_calls:
|
||||||
# 提取该 AIMessage 的 thinking(content),仅附加到第一个 tool_call
|
# 提取该 AIMessage 的 thinking,仅附加到第一个 tool_call
|
||||||
# 部分模型(如 Qwen enable_thinking)将思考放在 additional_kwargs.reasoning_content
|
# 提取规则见 extract_reasoning_text:开启思考取深度思考(reasoning_content/
|
||||||
|
# 内联 <think> 块),关闭思考取 content 中的一句话反思
|
||||||
thinking = extract_reasoning_text(msg)
|
thinking = extract_reasoning_text(msg)
|
||||||
first = True
|
first = True
|
||||||
for tc in msg.tool_calls:
|
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 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
|
如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
# 子 Agent 上下文超限提示(由 agent 节点在子 Agent token 超阈值时注入,强制其整理进度并文字返回)
|
||||||
|
SUB_AGENT_OOM_PROMPT = """
|
||||||
|
【系统指令 - 上下文超限】
|
||||||
|
你的上下文 token 用量已达 {total_tokens},超过限制阈值 {threshold},继续调用工具可能导致溢出。
|
||||||
|
请立即停止调用任何工具,整理当前任务的详细进度(已完成的工作、关键结果、遇到的问题及原因),
|
||||||
|
直接以文字形式回复主 Agent,不要再调用任何工具。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
# ---------- Skill Router Prompt ----------
|
# ---------- Skill Router Prompt ----------
|
||||||
SKILL_ROUTER_PROMPT = """你是一个 Skill 路由助手。根据用户 query,从下方 Skill 列表中选出最相关的 1~3 个 Skill。
|
SKILL_ROUTER_PROMPT = """你是一个 Skill 路由助手。根据用户 query,从下方 Skill 列表中选出最相关的 1~3 个 Skill。
|
||||||
只输出匹配的 Skill name,每个一行;没有匹配的则输出 "NONE"。
|
只输出匹配的 Skill name,每个一行;没有匹配的则输出 "NONE"。
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ class SkillRouter(BaseTool):
|
|||||||
def _call_llm(self, prompt: str) -> list[str] | None:
|
def _call_llm(self, prompt: str) -> list[str] | None:
|
||||||
"""调用大模型解析 prompt,返回匹配的 skill name 列表。
|
"""调用大模型解析 prompt,返回匹配的 skill name 列表。
|
||||||
|
|
||||||
优先使用主 Agent 配置的 LLM(get_default_llm),回退到 models[0] 兜底。
|
优先使用主 Agent 运行时配置的 LLM(get_default_llm),
|
||||||
|
兜底亦以主 Agent 配置的模型构建(build_default_llm),不再回退到模型列表第一个。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
匹配的 name 列表;解析失败或模型返回 NONE 时返回 None。
|
匹配的 name 列表;解析失败或模型返回 NONE 时返回 None。
|
||||||
|
|||||||
+112
-44
@@ -7,48 +7,75 @@ from langchain.tools import BaseTool
|
|||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
def _iter_todo_records(records: list[dict[str, Any]] | None):
|
def _iter_todo_args(records: list[dict[str, Any]] | None):
|
||||||
"""遍历 live 工具记录中的 todo_list 调用(含子 Agent 嵌套在 call_subagent 内的记录)。"""
|
"""遍历工具调用记录中的 todo_list 调用参数(含子 Agent 嵌套在 call_subagent 内的记录)。"""
|
||||||
for rec in records or []:
|
for rec in records or []:
|
||||||
if rec.get("name") == "todo_list":
|
if rec.get("name") == "todo_list":
|
||||||
yield rec
|
yield rec.get("args") or {}
|
||||||
for sub in rec.get("sub_tool_calls") or []:
|
for sub in rec.get("sub_tool_calls") or []:
|
||||||
if sub.get("name") == "todo_list":
|
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]]:
|
def _accumulate_todo_args(args_list: list[dict[str, Any]]) -> tuple[list[str], set[int]]:
|
||||||
"""从当前轮的 live 工具记录重建累计 todo 状态。
|
"""按时间顺序累积 todo_list 调用参数 → (steps, done_steps)。
|
||||||
|
|
||||||
steps 取自最近一次传入完整步骤列表的调用;done_steps 累积全部 done_step。
|
steps 取自最近一次传入完整步骤列表的调用(重新传入即修改/重建计划);
|
||||||
本工具是纯函数、不维护任何全局 store——每次调用都从框架的 live 记录通道
|
done_steps 累积全部 done_step。
|
||||||
读取自身调用历史,前端浮窗亦由同一通道(工具调用的 args)驱动更新。
|
|
||||||
"""
|
"""
|
||||||
steps: list[str] = []
|
steps: list[str] = []
|
||||||
done_steps: set[int] = set()
|
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:
|
try:
|
||||||
from agent.history.tool_call_recorder import get_live_tool_calls
|
n = int(args.get("done_step", -1))
|
||||||
for rec in _iter_todo_records(get_live_tool_calls(sid)):
|
except (TypeError, ValueError):
|
||||||
args = rec.get("args") or {}
|
n = -1
|
||||||
raw_steps = args.get("steps")
|
if steps and n >= 1:
|
||||||
if isinstance(raw_steps, str):
|
done_steps.update(range(min(n, len(steps))))
|
||||||
try:
|
return steps, done_steps
|
||||||
raw_steps = json.loads(raw_steps)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
raw_steps = None
|
def _collect_todo_args(sid: str) -> list[dict[str, Any]]:
|
||||||
if isinstance(raw_steps, list) and raw_steps:
|
"""按时间顺序收集 todo_list 调用参数:已落盘 turn(正序)+ 当前 live 记录。
|
||||||
steps = [str(s) for s in raw_steps]
|
|
||||||
done_steps = set()
|
live 记录仅在 turn 执行期间存在(turn 结束后被清空),因此跨轮状态
|
||||||
try:
|
需从已落盘 turn 的工具调用记录重建。
|
||||||
n = int(args.get("done_step", -1))
|
"""
|
||||||
except (TypeError, ValueError):
|
args_list: list[dict[str, Any]] = []
|
||||||
n = -1
|
if not sid:
|
||||||
if steps and n >= 1:
|
return args_list
|
||||||
done_steps.update(range(min(n, len(steps))))
|
try:
|
||||||
except Exception:
|
from agent.history.tool_call_recorder import list_turns
|
||||||
pass
|
turns = list_turns(sid, limit=100000) # 倒序(最新在前)
|
||||||
# 本次调用的参数覆盖/追加(live 记录不可用时的兜底)
|
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:
|
if steps_arg:
|
||||||
steps = [str(s) for s in steps_arg]
|
steps = [str(s) for s in steps_arg]
|
||||||
done_steps = set()
|
done_steps = set()
|
||||||
@@ -57,10 +84,33 @@ def _reconstruct_state(sid: str, steps_arg: Optional[list[str]], done_step_arg:
|
|||||||
return steps, done_steps
|
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):
|
class TodoListInput(BaseModel):
|
||||||
steps: Optional[list[str]] = Field(
|
steps: Optional[list[str]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="任务步骤列表,每项为一步的描述。仅在首次创建计划时传入,后续更新进度时请省略此参数以节省 token。",
|
description="任务步骤列表,每项为一步的描述。仅在首次创建计划或需要修改/重建计划时传入,后续更新进度时请省略此参数以节省 token。",
|
||||||
)
|
)
|
||||||
done_step: int = Field(
|
done_step: int = Field(
|
||||||
default=-1,
|
default=-1,
|
||||||
@@ -85,10 +135,12 @@ class TodoList(BaseTool):
|
|||||||
"""任务管理工具,用于拆分复杂任务为多个步骤并跟踪进度。
|
"""任务管理工具,用于拆分复杂任务为多个步骤并跟踪进度。
|
||||||
|
|
||||||
Plan 模式下此工具为**必须调用**,Agent 模式下为可选。
|
Plan 模式下此工具为**必须调用**,Agent 模式下为可选。
|
||||||
首次调用时传入完整步骤列表,后续仅传 done_step 标记进度。
|
首次调用时传入完整步骤列表,后续仅传 done_step 标记进度;重新传入
|
||||||
|
steps 可修改或重建计划。
|
||||||
|
|
||||||
调用时会随 live 工具记录推送状态,前端据此实时渲染 Todo 浮窗;
|
调用时会随 live 工具记录推送状态,前端据此实时渲染 Todo 浮窗;
|
||||||
返回值是纯文本,不携带任何结构化前缀。
|
返回值只包含创建/更新结果(成功或失败+原因),**不返回完整步骤列表**
|
||||||
|
——当前计划状态由系统每轮注入模型上下文(get_todo_status_text)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
args_schema: type[BaseModel] = TodoListInput
|
args_schema: type[BaseModel] = TodoListInput
|
||||||
@@ -97,7 +149,7 @@ class TodoList(BaseTool):
|
|||||||
"任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。\n"
|
"任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。\n"
|
||||||
"Plan 模式下必须首先调用此工具创建任务计划;Agent 模式下按需调用。\n"
|
"Plan 模式下必须首先调用此工具创建任务计划;Agent 模式下按需调用。\n"
|
||||||
"参数说明:\n"
|
"参数说明:\n"
|
||||||
"- steps (list[str], 可选):任务步骤列表。仅在首次创建计划时传入所有步骤。\n"
|
"- steps (list[str], 可选):任务步骤列表。仅在首次创建计划或需要修改/重建计划时传入所有步骤。\n"
|
||||||
" **步骤粒度要求**:同一页面/界面上的连续操作应合并为一条步骤,用\"并\"连接。例如:\n"
|
" **步骤粒度要求**:同一页面/界面上的连续操作应合并为一条步骤,用\"并\"连接。例如:\n"
|
||||||
' * 推荐:"点击搜索框,输入XXX并点击搜索"(合并了点击、输入、搜索)\n'
|
' * 推荐:"点击搜索框,输入XXX并点击搜索"(合并了点击、输入、搜索)\n'
|
||||||
' * 推荐:"在结果中点击目标进入主页"(合并了查找+点击)\n'
|
' * 推荐:"在结果中点击目标进入主页"(合并了查找+点击)\n'
|
||||||
@@ -105,16 +157,17 @@ class TodoList(BaseTool):
|
|||||||
"- done_step (int, 可选):当前已推进到的步骤编号(从1开始)。传入 n 则步骤 1~n 全部标记为已完成。不传或传 -1 则只查看当前状态。\n"
|
"- done_step (int, 可选):当前已推进到的步骤编号(从1开始)。传入 n 则步骤 1~n 全部标记为已完成。不传或传 -1 则只查看当前状态。\n"
|
||||||
"调用示例:\n"
|
"调用示例:\n"
|
||||||
'- 首次创建(B站搜索示例):{"steps": ["打开哔哩哔哩网站", "点击顶部搜索框,输入\'燕三嘤嘤嘤\'并点击搜索", "在搜索结果中找到燕三嘤嘤嘤并点击头像或名称进入他的主页", "在他的主页点开视频列表里的第一个视频", "验证视频是否正在播放"], "done_step": -1}\n'
|
'- 首次创建(B站搜索示例):{"steps": ["打开哔哩哔哩网站", "点击顶部搜索框,输入\'燕三嘤嘤嘤\'并点击搜索", "在搜索结果中找到燕三嘤嘤嘤并点击头像或名称进入他的主页", "在他的主页点开视频列表里的第一个视频", "验证视频是否正在播放"], "done_step": -1}\n'
|
||||||
|
'- 修改/重建计划:重新传入 {"steps": [...]}\n'
|
||||||
'- 已完成前2步:{"done_step": 2} (步骤1和2均标记为完成)\n'
|
'- 已完成前2步:{"done_step": 2} (步骤1和2均标记为完成)\n'
|
||||||
'- 已完成前4步:{"done_step": 4}\n'
|
'- 已完成前4步:{"done_step": 4}\n'
|
||||||
'- 查看状态:{"done_step": -1}\n'
|
'- 查看状态:{"done_step": -1}\n'
|
||||||
"任务全部完成后无需再次调用。"
|
"任务全部完成后无需再次调用。\n"
|
||||||
|
"返回值说明:创建成功(共 N 步)/ 更新成功(已完成 X/N 步)或失败原因,不包含完整步骤列表;当前计划状态由系统每轮注入。"
|
||||||
)
|
)
|
||||||
response_format: Literal["content", "content_and_artifact"] = "content"
|
response_format: Literal["content", "content_and_artifact"] = "content"
|
||||||
|
|
||||||
def _run(self, steps: list[str] | None = None, done_step: int = -1) -> str:
|
def _run(self, steps: list[str] | None = None, done_step: int = -1) -> str:
|
||||||
try:
|
try:
|
||||||
steps = steps or []
|
|
||||||
sid = ""
|
sid = ""
|
||||||
try:
|
try:
|
||||||
from agent.history.tool_call_recorder import get_current_session
|
from agent.history.tool_call_recorder import get_current_session
|
||||||
@@ -122,15 +175,30 @@ class TodoList(BaseTool):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
stored_steps, done_steps = _reconstruct_state(sid, steps, done_step)
|
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:
|
if not stored_steps:
|
||||||
return "[Todo List]\n(当前无任务)"
|
return "当前无 TodoList。"
|
||||||
lines = ["[Todo List]"]
|
return f"当前进度:已完成 {len(done_steps)}/{len(stored_steps)} 步。"
|
||||||
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)
|
|
||||||
except Exception as e:
|
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:
|
async def _arun(self, steps: list[str] | None = None, done_step: int = -1) -> str:
|
||||||
return self._run(steps, done_step)
|
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:
|
def get_thinking_extra_body(level: str | None = None) -> dict | None:
|
||||||
"""根据思考档位返回 ChatQwen 的 extra_body。
|
"""根据思考档位返回 Multimodel_LLM 的 extra_body。
|
||||||
|
|
||||||
返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。
|
返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
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 {}}
|
return {"id": call_id, "name": name, "args": args or {}}
|
||||||
|
|
||||||
|
|
||||||
def extract_reasoning_text(msg: Any) -> str:
|
_THINK_END_TAG = "</think>"
|
||||||
"""提取 AIMessage 的思考内容:优先 content,其次 additional_kwargs.reasoning_content。
|
_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``
|
部分模型(如 Qwen 开启思考)将深度思考内联在 content 中,形如
|
||||||
而非 ``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 属性)。
|
msg: AIMessage 对象(需有 content / additional_kwargs 属性)。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
思考内容文本(已 strip),无内容时返回空字符串。
|
思考/反思内容文本(已 strip),无内容时返回空字符串。
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
供 ``core/nodes`` 提取反思内容、``history/tool_call_recorder`` 提取思维链展示文本。
|
供 ``core/nodes`` 提取反思内容、``history/tool_call_recorder`` 提取思维链展示文本。
|
||||||
"""
|
"""
|
||||||
content = getattr(msg, "content", "")
|
from agent.utils.env_utils import thinking_enabled
|
||||||
if content and isinstance(content, str) and content.strip():
|
|
||||||
return content.strip()
|
|
||||||
extra = getattr(msg, "additional_kwargs", None) or {}
|
extra = getattr(msg, "additional_kwargs", None) or {}
|
||||||
rc = extra.get("reasoning_content") if isinstance(extra, dict) else None
|
rc = extra.get("reasoning_content") if isinstance(extra, dict) else None
|
||||||
if rc and isinstance(rc, str) and rc.strip():
|
if rc and isinstance(rc, str) and rc.strip():
|
||||||
return 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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -35,6 +35,10 @@ import { setSendDeps } from './js/send.js';
|
|||||||
// ---- Todo ----
|
// ---- Todo ----
|
||||||
import { updateTodoOverlayFromRecords, clearTodoOverlay } from './js/todo.js';
|
import { updateTodoOverlayFromRecords, clearTodoOverlay } from './js/todo.js';
|
||||||
|
|
||||||
|
// ---- Token 活跃度卡 / 分时段标语 ----
|
||||||
|
import { initUsageCard, refreshUsageCard } from './js/usage-stats.js';
|
||||||
|
import { getSlogan } from './js/welcome-slogans.js';
|
||||||
|
|
||||||
// ---- 布局 / 背景 ----
|
// ---- 布局 / 背景 ----
|
||||||
import {
|
import {
|
||||||
setSidebarCollapsed, setDrawerOpen, closeDrawerIfMobile,
|
setSidebarCollapsed, setDrawerOpen, closeDrawerIfMobile,
|
||||||
@@ -142,6 +146,8 @@ setRollbackDeps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== Composer 居中/底部动画逻辑 =====
|
// ===== Composer 居中/底部动画逻辑 =====
|
||||||
|
let _welcomeLeavingTimer = null; // 欢迎区上滑退出的隐藏定时器(防竞态)
|
||||||
|
|
||||||
export function setComposerCentered(centered) {
|
export function setComposerCentered(centered) {
|
||||||
const composer = document.querySelector(".composer");
|
const composer = document.querySelector(".composer");
|
||||||
const chatPlaceholder = document.getElementById("chatPlaceholder");
|
const chatPlaceholder = document.getElementById("chatPlaceholder");
|
||||||
@@ -151,24 +157,42 @@ export function setComposerCentered(centered) {
|
|||||||
if (!composer) return;
|
if (!composer) return;
|
||||||
|
|
||||||
if (centered) {
|
if (centered) {
|
||||||
|
if (_welcomeLeavingTimer) { clearTimeout(_welcomeLeavingTimer); _welcomeLeavingTimer = null; }
|
||||||
composer.classList.add("composer--centered");
|
composer.classList.add("composer--centered");
|
||||||
composer.classList.remove("composer--bottom");
|
composer.classList.remove("composer--bottom");
|
||||||
if (chatPlaceholder) chatPlaceholder.style.display = "none";
|
if (chatPlaceholder) chatPlaceholder.style.display = "none";
|
||||||
if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed");
|
if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed");
|
||||||
if (welcomeView) {
|
if (welcomeView) {
|
||||||
welcomeView.hidden = false;
|
welcomeView.hidden = false;
|
||||||
welcomeView.style.animation = "none";
|
welcomeView.classList.remove("welcome-view--leaving");
|
||||||
|
// 强制 reflow 后加 --visible,确保从隐藏基态淡入(transition 生效)
|
||||||
void welcomeView.offsetWidth;
|
void welcomeView.offsetWidth;
|
||||||
welcomeView.style.animation = "";
|
welcomeView.classList.add("welcome-view--visible");
|
||||||
renderRecentFolders();
|
renderRecentFolders();
|
||||||
startClock();
|
startClock();
|
||||||
|
// 分时段随机欢迎标语
|
||||||
|
const sloganEl = document.getElementById("welcomeSlogan");
|
||||||
|
if (sloganEl) sloganEl.textContent = getSlogan();
|
||||||
|
// 刷新 Token 活跃度卡(矩阵/柱状图,保持当前视图)
|
||||||
|
refreshUsageCard();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
composer.classList.remove("composer--centered");
|
composer.classList.remove("composer--centered");
|
||||||
composer.classList.add("composer--bottom");
|
composer.classList.add("composer--bottom");
|
||||||
if (chatPlaceholder) chatPlaceholder.style.display = "";
|
if (chatPlaceholder) chatPlaceholder.style.display = "";
|
||||||
if (mainContentWrap) mainContentWrap.classList.remove("main-content-wrap--collapsed");
|
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();
|
stopClock();
|
||||||
if (chatMessages && chatMessages.children.length === 0) {
|
if (chatMessages && chatMessages.children.length === 0) {
|
||||||
if (chatPlaceholder) chatPlaceholder.hidden = false;
|
if (chatPlaceholder) chatPlaceholder.hidden = false;
|
||||||
@@ -997,6 +1021,7 @@ initI18n();
|
|||||||
// ===== 启动 =====
|
// ===== 启动 =====
|
||||||
bootstrap().then(() => {
|
bootstrap().then(() => {
|
||||||
initTokenRing();
|
initTokenRing();
|
||||||
|
initUsageCard();
|
||||||
populateModelSelect();
|
populateModelSelect();
|
||||||
initWorkspace();
|
initWorkspace();
|
||||||
initAccessMode();
|
initAccessMode();
|
||||||
|
|||||||
+161
-9
@@ -441,15 +441,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */
|
/* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */
|
||||||
|
/* flex:1 占据 main-content-wrap 折叠后的剩余空间,欢迎区扩大、内容从顶部排布。
|
||||||
|
进出均用 transition(隐藏基态 opacity:0 → --visible 淡入;发送时 --leaving 上滑淡出),
|
||||||
|
不使用 animation,避免 fill-mode 覆盖过渡起始值导致动画瞬间跳变 */
|
||||||
.welcome-view {
|
.welcome-view {
|
||||||
padding: 1rem 1.5rem 0;
|
padding: 1rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
justify-content: flex-start;
|
||||||
flex-shrink: 0;
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,10 +475,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes welcomeFadeIn {
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-content {
|
.welcome-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -512,6 +526,144 @@
|
|||||||
to { opacity: 1; transform: translateX(0); }
|
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 {
|
.recent-folders-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
box-shadow: none;
|
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 居中状态(空会话) ===== */
|
/* ===== Composer 居中状态(空会话) ===== */
|
||||||
|
/* 输入区固定在底部(flex:0),不再吞掉剩余空间——剩余空间归欢迎区 */
|
||||||
.composer--centered {
|
.composer--centered {
|
||||||
margin-top: 2rem;
|
margin-top: 0.5rem;
|
||||||
padding: 2rem 0.4rem;
|
padding: 0.4rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer--centered .composer-row {
|
.composer--centered .composer-row {
|
||||||
@@ -1365,12 +1367,13 @@
|
|||||||
|
|
||||||
/* ===== 图标型内联下拉触发器(访问模式 / 思考模式)===== */
|
/* ===== 图标型内联下拉触发器(访问模式 / 思考模式)===== */
|
||||||
.inline-drop-trigger--icon {
|
.inline-drop-trigger--icon {
|
||||||
width: 1.7rem;
|
min-width: 1.7rem;
|
||||||
height: 1.7rem;
|
height: 1.7rem;
|
||||||
padding: 0;
|
padding: 0 0.15rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
.inline-drop-trigger--icon img {
|
.inline-drop-trigger--icon img {
|
||||||
width: 0.95rem;
|
width: 0.95rem;
|
||||||
@@ -1378,6 +1381,14 @@
|
|||||||
display: block;
|
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 { color: var(--success); }
|
||||||
.access-mode-item--restricted.is-selected { background: color-mix(in srgb, var(--success) 14%, transparent); }
|
.access-mode-item--restricted.is-selected { background: color-mix(in srgb, var(--success) 14%, transparent); }
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="welcome-right">
|
<div class="welcome-right">
|
||||||
<h2 class="welcome-greeting" id="welcomeGreeting">欢迎使用 minor Agent</h2>
|
<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-wrap" id="recentFoldersWrap" hidden>
|
||||||
<div class="recent-folders-label">最近打开</div>
|
<div class="recent-folders-label">最近打开</div>
|
||||||
<ul class="recent-folders-list" id="recentFoldersList"></ul>
|
<ul class="recent-folders-list" id="recentFoldersList"></ul>
|
||||||
@@ -150,6 +151,8 @@
|
|||||||
<span class="welcome-clock" id="welcomeClock"></span>
|
<span class="welcome-clock" id="welcomeClock"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Token 用量活跃度卡(GitHub 风格矩阵 / 按小时柱状图) -->
|
||||||
|
<div class="usage-card" id="usageCard" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="composer">
|
<footer class="composer">
|
||||||
<div class="composer-refs" id="composerRefs" hidden></div>
|
<div class="composer-refs" id="composerRefs" hidden></div>
|
||||||
@@ -219,6 +222,7 @@
|
|||||||
<div class="inline-drop-wrap" id="thinkModeWrap">
|
<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">
|
<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" />
|
<img src="/image/思考模式.svg" alt="思考" id="thinkModeIcon" />
|
||||||
|
<span class="think-mode-badge" id="thinkModeBadge"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="inline-drop-panel think-mode-panel" id="thinkModePanel" hidden>
|
<div class="inline-drop-panel think-mode-panel" id="thinkModePanel" hidden>
|
||||||
<div class="think-mode-labels" id="thinkModeLabels"></div>
|
<div class="think-mode-labels" id="thinkModeLabels"></div>
|
||||||
|
|||||||
@@ -632,9 +632,7 @@ const ENV_DESC = {
|
|||||||
RAG_CHUNK_OVERLAP: "RAG 分块重叠大小(字符)。",
|
RAG_CHUNK_OVERLAP: "RAG 分块重叠大小(字符)。",
|
||||||
GROUNDING_WIDTH: "GUI 定位图像宽度(像素)。",
|
GROUNDING_WIDTH: "GUI 定位图像宽度(像素)。",
|
||||||
GROUNDING_HEIGHT: "GUI 定位图像高度(像素)。",
|
GROUNDING_HEIGHT: "GUI 定位图像高度(像素)。",
|
||||||
LOOP_DETECT_TODOLIST_STALE_ROUNDS: "TodoList 过期检测轮数,超过该轮数未更新视为失效。",
|
|
||||||
LOOP_DETECT_REPEATED_TOOL_WARN: "同一工具连续重复调用超过该次数时输出警告。",
|
LOOP_DETECT_REPEATED_TOOL_WARN: "同一工具连续重复调用超过该次数时输出警告。",
|
||||||
LOOP_DETECT_REPEATED_TOOL_END: "同一工具连续重复调用超过该次数时强制终止任务。",
|
|
||||||
SEND_FILE_SIZE_LIMIT: "发送文件大小上限(MB)。",
|
SEND_FILE_SIZE_LIMIT: "发送文件大小上限(MB)。",
|
||||||
STORAGE_BACKEND: "存储后端:json(本地文件)或 mysql(数据库)。",
|
STORAGE_BACKEND: "存储后端:json(本地文件)或 mysql(数据库)。",
|
||||||
MYSQL_HOST: "MySQL 主机地址。",
|
MYSQL_HOST: "MySQL 主机地址。",
|
||||||
|
|||||||
@@ -718,6 +718,9 @@ export function applyTheme(themeId) {
|
|||||||
const isDark = theme.dark ?? !(themeId.includes("light") || themeId.includes("solarized"));
|
const isDark = theme.dark ?? !(themeId.includes("light") || themeId.includes("solarized"));
|
||||||
document.body.classList.toggle("theme-dark", isDark);
|
document.body.classList.toggle("theme-dark", isDark);
|
||||||
document.body.classList.toggle("theme-light", !isDark);
|
document.body.classList.toggle("theme-light", !isDark);
|
||||||
|
|
||||||
|
// 通知依赖主题色的组件重绘(如 Token 活跃度矩阵)
|
||||||
|
document.dispatchEvent(new CustomEvent("theme-changed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
|
/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
|
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
|
||||||
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
|
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
|
||||||
// 持久化在后端 env_config.json 的 THINKING_LEVEL(/api/config/thinking-level),
|
// 持久化在后端 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 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
|
// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
|
||||||
|
|
||||||
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
|
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
|
||||||
@@ -84,6 +84,9 @@ export function syncThinkLevelUI() {
|
|||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式";
|
trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式";
|
||||||
trigger.dataset.value = 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 ----------
|
# ---------- 工具调用历史 API ----------
|
||||||
@app.get("/api/tool-calls/{session_id}/live")
|
@app.get("/api/tool-calls/{session_id}/live")
|
||||||
def api_get_live_tool_calls(session_id: str) -> dict[str, Any]:
|
def api_get_live_tool_calls(session_id: str) -> dict[str, Any]:
|
||||||
|
|||||||
Reference in new issue
Block a user