diff --git a/src/agent/agents/agent_runtime.py b/src/agent/agents/agent_runtime.py index 27b4043..f12958b 100644 --- a/src/agent/agents/agent_runtime.py +++ b/src/agent/agents/agent_runtime.py @@ -54,6 +54,7 @@ def _build_llm_for_agent(cfg: AgentConfig): timeout=float(model_config.get("timeout", 60)), max_retries=int(model_config.get("max_retries", 0)), extra_body=extra_body, + verify_ssl=bool(model_config.get("verify_ssl", True)), ) # 回退:主 Agent 配置的模型兜底(与模块级 llm 单例一致,优先主 Agent 而非模型列表第一个) from agent.core.llm import build_default_llm diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 8e4173b..94bddea 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -636,6 +636,7 @@ def get_gui_llm(): timeout=model_cfg.get("timeout", 60), max_retries=model_cfg.get("max_retries", 0), extra_body=get_thinking_extra_body(), + verify_ssl=bool(model_cfg.get("verify_ssl", True)), ) diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py index d90a40f..a575c61 100644 --- a/src/agent/core/llm.py +++ b/src/agent/core/llm.py @@ -19,6 +19,8 @@ from typing import Any, List, Dict, Optional from pydantic import Field import re, json +import httpx + from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage, ToolCall from langchain_core.outputs import ChatResult from langchain_openai import ChatOpenAI @@ -115,6 +117,12 @@ class Multimodel_LLM(ChatOpenAI): 可扩展性: 可增加 default_headers、proxy 等 ChatOpenAI 参数。 """ + # verify_ssl=False:仅用于临时绕过服务端证书异常(如证书过期), + # 明确传入时以 httpx.Client(verify=False) 作 http_client,不验证 TLS 证书。 + verify_ssl = kwargs.pop("verify_ssl", True) + http_client = kwargs.pop("http_client", None) + if not verify_ssl and http_client is None: + http_client = httpx.Client(verify=False) # 允许调用方覆盖 extra_body: # - None(默认):使用禁用思考的默认值 # - 空 dict {}:清空 extra_body,恢复模型原生行为(含思考模式) @@ -135,6 +143,7 @@ class Multimodel_LLM(ChatOpenAI): timeout=kwargs.pop("timeout", float(os.environ.get("LLM_TIMEOUT", 60))), max_retries=kwargs.pop("max_retries", 0), extra_body=extra_body, + http_client=http_client, **kwargs ) @staticmethod @@ -518,6 +527,7 @@ def build_default_llm() -> Multimodel_LLM: timeout=float(m.get("timeout", 60)), max_retries=int(m.get("max_retries", 0)), extra_body=get_thinking_extra_body(), + verify_ssl=bool(m.get("verify_ssl", True)), ) except Exception: pass diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index cf6e06b..b15536b 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -126,6 +126,91 @@ def _extract_total_tokens(response: Any) -> int: return _extract_usage(response).get("total_tokens", 0) or 0 +def _extract_cached_tokens(usage: dict) -> int | None: + """取 provider 上报的 prompt 缓存命中 token 数;未上报缓存字段时返回 None。 + + 兼容三种字段形态: + OpenAI/DashScope: usage.prompt_tokens_details.cached_tokens + langchain usage_metadata: usage.input_token_details.cached + DeepSeek v1: usage.cache_read_input_tokens + """ + if not usage: + return None + details = usage.get("prompt_tokens_details") or {} + v = details.get("cached_tokens") + if v is None: + v = details.get("cached") + if v is None: + v = (usage.get("input_token_details") or {}).get("cached") + if v is None: + v = usage.get("cache_read_input_tokens") + if v is None: + return None + try: + return int(v or 0) + except (TypeError, ValueError): + return None + + +def _extract_cache_hit_rate(usage: dict) -> float | None: + """prompt 缓存命中率(cached_tokens / prompt_tokens)。 + + provider 未上报缓存字段或缺少 prompt_tokens 时返回 None(前端不展示); + 上报但命中为 0 时返回 0.0(前端展示 0%)。 + """ + prompt = usage.get("prompt_tokens") or 0 + cached = _extract_cached_tokens(usage) + if prompt <= 0 or cached is None: + return None + return round(min(cached / prompt, 1.0), 4) + + +# ---------- 前缀复用估算(provider 未上报 cached_tokens 时的兜底) ---------- +# 前缀缓存的行为模型:命中部分 = 本次请求与上一次请求序列化消息的公共前缀。 +# 部分网关(如智瀚 Qwen3.5)不上报 cached_tokens,此时用 +# 「公共前缀字符占比 × prompt_tokens」估算命中 token 数,仅供展示,非 provider 真实值。 +# 上次请求文本缓存在 tool_call_recorder(内存 + 会话存储),服务重启后估算仍连续。 +def _prompt_serial_text(messages: list[Any]) -> str: + """把消息序列化为前缀比较文本(与 _unit_to_text 同口径)。""" + parts = [] + for m in messages or []: + t = _unit_to_text(m) + if t: + parts.append(t) + return "\n".join(parts) + + +def _estimate_cached_tokens(session_id: str, prompt_tokens: int, messages: list[Any]) -> int: + """估算本次调用可命中前缀缓存的 prompt token 数。 + + 无前置请求(会话首次调用 / 上次文本缺失)时返回 0:没有任何可用复用前缀, + 即未命中——首次调用如实显示 0%,而不是隐藏不显示。 + """ + if not session_id or prompt_tokens <= 0: + return 0 + text = _prompt_serial_text(messages) + if not text: + return 0 + try: + from agent.history.tool_call_recorder import get_session_last_prompt, save_session_last_prompt + prev = get_session_last_prompt(session_id) + save_session_last_prompt(session_id, text) + except Exception: + return 0 + if not prev: + return 0 # 会话内第一次调用:没有可复用的前置请求 + # 公共前缀字符数(逐字符比较,CJK 约 1 字符/token,ASCII 约 4 字符/token, + # 前缀占比近似可映射为 token 占比) + lim = min(len(prev), len(text)) + n = 0 + while n < lim and prev[n] == text[n]: + n += 1 + if n <= 0: + return 0 + ratio = n / max(len(text), 1) + return max(0, int(prompt_tokens * min(ratio, 1.0))) + + def _estimate_token_breakdown(messages: list[Any], usage: dict) -> dict[str, float]: """估算三类上下文的 token 占用(按输入字符占比分摊 prompt_tokens)。 @@ -281,12 +366,32 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None, mo _sid = get_current_session() or "" is_sub = is_sub_agent_context() if _sid and not is_sub: - breakdown = _estimate_token_breakdown(messages or [], _extract_usage(response)) - set_session_tokens(_sid, total, breakdown=breakdown) - # 全局用量统计(跨会话累加,按小时 + 模型维度) + _usage = _extract_usage(response) + breakdown = _estimate_token_breakdown(messages or [], _usage) + _prompt_tokens = _usage.get("prompt_tokens", 0) or 0 + # 缓存命中:provider 上报时用真实值;否则用本地前缀复用估算兜底 + _cached = _extract_cached_tokens(_usage) + _cache_hit = None + _cache_hit_src = None + if _cached is not None: + _cache_hit = _extract_cache_hit_rate(_usage) + _cache_hit_src = "api" + else: + _cached = 0 + if _prompt_tokens > 0: + _est = _estimate_cached_tokens(_sid, _prompt_tokens, messages) + _cached = _est + _cache_hit = round(min(_est / _prompt_tokens, 1.0), 4) + _cache_hit_src = "estimate" + set_session_tokens(_sid, total, breakdown=breakdown, + cache_hit=_cache_hit, cache_hit_src=_cache_hit_src) + # 全局用量统计(跨会话累加,按小时 + 模型维度 + 缓存命中) try: from agent.core.usage_stats import record_usage - record_usage(total, model=model_name) + record_usage(total, model=model_name, + prompt=_prompt_tokens, + cached=_cached, + est=(_cache_hit_src == "estimate")) except Exception: pass except Exception: diff --git a/src/agent/core/usage_stats.py b/src/agent/core/usage_stats.py index e2f55e4..a735494 100644 --- a/src/agent/core/usage_stats.py +++ b/src/agent/core/usage_stats.py @@ -76,13 +76,17 @@ def _cleanup(data: dict[str, Any], now: float) -> None: hours.pop(key, None) -def record_usage(total: int, model: str = "", ts: float | None = None) -> None: +def record_usage(total: int, model: str = "", ts: float | None = None, + prompt: int = 0, cached: int = 0, est: bool = False) -> None: """累加一次 LLM 调用的 token 用量到对应小时(按模型分账)。 输入: total: total_tokens(>0 才记录)。 model: 模型名(用于柱状图按模型分色);空串时仅累计总量。 ts: 调用时间戳;None 使用当前时间。 + prompt: prompt_tokens(用于缓存命中率分母;<=0 不记录)。 + cached: 缓存命中的 prompt token 数(provider 上报或本地估算,用于缓存命中率分子)。 + est: 该次 cached 是否为本地估算(provider 未上报时兜底);小时内任一估算为真。 """ if total <= 0: return @@ -91,8 +95,14 @@ def record_usage(total: int, model: str = "", ts: float | None = None) -> None: with _lock: data = _load() hours = data.setdefault("hours", {}) - entry = hours.setdefault(hour, {"total": 0, "by_model": {}}) + entry = hours.setdefault(hour, {"total": 0, "prompt": 0, "cached": 0, "by_model": {}}) entry["total"] = entry.get("total", 0) + total + if prompt > 0: + entry["prompt"] = entry.get("prompt", 0) + prompt + if cached > 0: + entry["cached"] = entry.get("cached", 0) + cached + if est: + entry["est"] = True if model: entry["by_model"][model] = entry["by_model"].get(model, 0) + total _cleanup(data, ts) @@ -105,9 +115,14 @@ def get_usage_summary(days: int = 182) -> dict[str, Any]: 输出: { "total": int, # 跨会话累计总量 + "prompt_total": int, # 累计 prompt_tokens(缓存率分母) + "cached_total": int, # 累计缓存命中 token(缓存率分子) + "hit_rate": float, # cached_total / prompt_total(0~1) + "estimated": bool, # 命中率含本地估算数据 "by_model": {model: int}, # 全局按模型总量 "days": [{"date": "YYYY-MM-DD", "total": int, # 最近 N 天(含 0 值, - "by_model": {model: int}}, ...], # 供活跃度矩阵完整网格) + "prompt": int, "cached": int, # 供活跃度矩阵完整网格) + "by_model": {model: int}}, ...], "hours": {"YYYY-MM-DD": {"HH": {model: int}}} # 有数据的小时(非 0) } """ @@ -116,9 +131,12 @@ def get_usage_summary(days: int = 182) -> dict[str, Any]: day_floor = day_keys[0] day_ceil = day_keys[-1] - daily = {d: {"total": 0, "by_model": {}} for d in day_keys} + daily = {d: {"total": 0, "prompt": 0, "cached": 0, "by_model": {}} for d in day_keys} hours_out: dict[str, dict[str, dict[str, int]]] = {} total = 0 + prompt_total = 0 + cached_total = 0 + estimated = False by_model: dict[str, int] = {} with _lock: @@ -127,20 +145,33 @@ def get_usage_summary(days: int = 182) -> dict[str, Any]: date = hour[:10] if date < day_floor or date > day_ceil: continue + if entry.get("est"): + estimated = True e_total = entry.get("total", 0) + e_prompt = entry.get("prompt", 0) + e_cached = entry.get("cached", 0) e_model = entry.get("by_model", {}) or {} total += e_total + prompt_total += e_prompt + cached_total += e_cached for m, v in e_model.items(): by_model[m] = by_model.get(m, 0) + v d = daily[date] d["total"] += e_total + d["prompt"] += e_prompt + d["cached"] += e_cached 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, + "prompt_total": prompt_total, + "cached_total": cached_total, + "hit_rate": round(cached_total / prompt_total, 4) if prompt_total > 0 else 0.0, + "estimated": estimated, "by_model": by_model, - "days": [{"date": k, "total": v["total"], "by_model": v["by_model"]} for k, v in daily.items()], + "days": [{"date": k, "total": v["total"], "prompt": v["prompt"], "cached": v["cached"], + "by_model": v["by_model"]} for k, v in daily.items()], "hours": hours_out, } diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index 96270df..df9413d 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -147,16 +147,30 @@ _subscribers_lock = threading.Lock() _session_tokens_lock = threading.Lock() _session_tokens: dict[str, int] = {} # session_id → total_tokens _session_token_breakdowns: dict[str, dict] = {} # session_id → {messages, tools, system} +_session_cache_hits: dict[str, float] = {} # session_id → 最新调用 prompt 缓存命中率(0~1) +_session_cache_hit_srcs: dict[str, str] = {} # session_id → "api" | "estimate" +_session_last_prompts: dict[str, str] = {} # session_id → 上次 LLM 请求的序列化消息文本(估算兜底用) _session_tokens_loaded: set[str] = set() # 已从磁盘加载过的 session_id -def _persist_token(session_id: str, tokens: int, breakdown: dict | None = None) -> None: +def _persist_token(session_id: str, tokens: int, breakdown: dict | None = None, + cache_hit: float | None = None, cache_hit_src: str | None = None) -> None: """将 token 写入存储(后端无关:mysql 存 sessions.extra,json 写 _session_tokens.json)。""" from agent.history import session_storage existing = session_storage.load_session_extra(session_id) or {} existing["tokens"] = tokens if breakdown: existing["token_breakdown"] = breakdown + if cache_hit is not None: + existing["cache_hit"] = cache_hit + if cache_hit_src: + existing["cache_hit_src"] = cache_hit_src + else: + existing.pop("cache_hit", None) # 最新调用未上报缓存信息时不保留旧值 + existing.pop("cache_hit_src", None) + last_prompt = _session_last_prompts.get(session_id) + if last_prompt: + existing["last_prompt_text"] = last_prompt session_storage.save_session_extra(session_id, existing) @@ -166,17 +180,27 @@ def _load_token_from_disk(session_id: str) -> int: return int(session_storage.load_session_extra(session_id).get("tokens", 0) or 0) -def set_session_tokens(session_id: str, total_tokens: int, breakdown: dict | None = None) -> None: +def set_session_tokens(session_id: str, total_tokens: int, breakdown: dict | None = None, + cache_hit: float | None = None, cache_hit_src: str | None = None) -> None: """存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。 breakdown: {"messages": float, "tools": float, "system": float} 三类估算占比, 由 nodes._estimate_token_breakdown 计算。 + cache_hit: 本次调用的 prompt 缓存命中率(0~1);provider 未上报时传 None。 + cache_hit_src: "api"(provider 上报)| "estimate"(本地前缀复用估算)。 """ with _session_tokens_lock: _session_tokens[session_id] = total_tokens if breakdown: _session_token_breakdowns[session_id] = dict(breakdown) - _persist_token(session_id, total_tokens, breakdown) + if cache_hit is not None: + _session_cache_hits[session_id] = float(cache_hit) + _session_cache_hit_srcs[session_id] = cache_hit_src or "api" + else: + # 最新调用无缓存信息:清除旧值,避免展示过期数据 + _session_cache_hits.pop(session_id, None) + _session_cache_hit_srcs.pop(session_id, None) + _persist_token(session_id, total_tokens, breakdown, cache_hit, cache_hit_src) def get_session_tokens(session_id: str) -> int: @@ -211,11 +235,62 @@ def get_session_token_breakdown(session_id: str) -> dict: return {"messages": 0.0, "tools": 0.0, "system": 0.0} +def get_session_cache_hit(session_id: str) -> float | None: + """获取当前会话最新 LLM 调用的 prompt 缓存命中率(0~1);无记录返回 None。 + + 内存优先,回退存储(JSON 文件的 cache_hit / MySQL 的 cache_hit)。 + """ + with _session_tokens_lock: + if session_id in _session_cache_hits: + return _session_cache_hits[session_id] + from agent.history import session_storage + extra = session_storage.load_session_extra(session_id) or {} + v = extra.get("cache_hit") + return float(v) if isinstance(v, (int, float)) else None + + +def get_session_cache_hit_src(session_id: str) -> str | None: + """获取当前会话缓存命中率的来源("api" | "estimate");无记录返回 None。""" + with _session_tokens_lock: + if session_id in _session_cache_hits: + return _session_cache_hit_srcs.get(session_id) or "api" + from agent.history import session_storage + extra = session_storage.load_session_extra(session_id) or {} + src = extra.get("cache_hit_src") + return src if src in ("api", "estimate") else None + + +def save_session_last_prompt(session_id: str, text: str) -> None: + """记录本次 LLM 请求的序列化消息文本(前缀复用估算的基准,内存 + 下次落盘)。""" + if not session_id or not text: + return + with _session_tokens_lock: + _session_last_prompts[session_id] = text + + +def get_session_last_prompt(session_id: str) -> str: + """获取上次 LLM 请求的序列化消息文本;无记录返回 ""(内存 → 磁盘)。""" + with _session_tokens_lock: + if session_id in _session_last_prompts: + return _session_last_prompts[session_id] + from agent.history import session_storage + extra = session_storage.load_session_extra(session_id) or {} + t = extra.get("last_prompt_text") + if isinstance(t, str) and t: + with _session_tokens_lock: + _session_last_prompts[session_id] = t + return t + return "" + + def clear_session_tokens(session_id: str) -> None: """清除指定会话的 token 记录(内存 + 存储)。""" with _session_tokens_lock: _session_tokens.pop(session_id, None) _session_token_breakdowns.pop(session_id, None) + _session_cache_hits.pop(session_id, None) + _session_cache_hit_srcs.pop(session_id, None) + _session_last_prompts.pop(session_id, None) from agent.history import session_storage existing = session_storage.load_session_extra(session_id) or {} if not existing: @@ -224,6 +299,9 @@ def clear_session_tokens(session_id: str) -> None: return existing["tokens"] = 0 existing.pop("token_breakdown", None) + existing.pop("cache_hit", None) + existing.pop("cache_hit_src", None) + existing.pop("last_prompt_text", None) session_storage.save_session_extra(session_id, existing) # ---------- 待附加的 thinking / reflection ---------- @@ -701,6 +779,7 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]: _live_events_pushed.pop(session_id, None) with _live_lock: _active_sessions.discard(session_id) + _turn_start_times.pop(session_id, None) return _live_records.pop(session_id, []) @@ -1044,12 +1123,15 @@ def _records_with_image_urls(session_id: str, records: list[dict[str, Any]]) -> def get_turn_started_at(session_id: str) -> float | None: - """获取当前轮次的开始时间(Unix 时间戳),供前端实时计时使用。""" + """获取当前轮次的开始时间(Unix 时间戳),供前端实时计时使用。 + + 仅活跃轮次返回时间;轮次结束后返回 None(end_live_turn 已清空 + _turn_start_times,此处双保险:防止上一轮残留值被前端当作本轮基准)。 + """ with _live_lock: - if session_id in _turn_start_times: - return _turn_start_times[session_id] if session_id not in _active_sessions: return None + return _turn_start_times.get(session_id) # 内存为空但 session 仍在活跃中:回退到磁盘上最近的 tool_*.json try: latest = get_latest_turn(session_id) diff --git a/src/agent/history/usage_stats.json b/src/agent/history/usage_stats.json index 8b75bdc..2664a39 100644 --- a/src/agent/history/usage_stats.json +++ b/src/agent/history/usage_stats.json @@ -1 +1 @@ -{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}, "2026-08-19T16": {"total": 630061, "by_model": {"Qwen3.5-122B-A10B": 630061}}, "2026-08-19T17": {"total": 567154, "by_model": {"Qwen3.5-122B-A10B": 567154}}, "2026-08-19T18": {"total": 154588, "by_model": {"Qwen3.5-122B-A10B": 154588}}, "2026-08-19T19": {"total": 661541, "by_model": {"Qwen3.5-122B-A10B": 661541}}, "2026-08-20T08": {"total": 320021, "by_model": {"Qwen3.5-122B-A10B": 320021}}, "2026-08-20T10": {"total": 747256, "by_model": {"Qwen3.5-122B-A10B": 747256}}, "2026-08-20T11": {"total": 2172925, "by_model": {"Qwen3.5-122B-A10B": 2172925}}, "2026-08-20T17": {"total": 1628101, "by_model": {"Qwen3.5-122B-A10B": 1628101}}, "2026-08-20T18": {"total": 1290913, "by_model": {"Qwen3.5-122B-A10B": 1290913}}}} \ No newline at end of file +{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}, "2026-08-19T16": {"total": 630061, "by_model": {"Qwen3.5-122B-A10B": 630061}}, "2026-08-19T17": {"total": 567154, "by_model": {"Qwen3.5-122B-A10B": 567154}}, "2026-08-19T18": {"total": 154588, "by_model": {"Qwen3.5-122B-A10B": 154588}}, "2026-08-19T19": {"total": 661541, "by_model": {"Qwen3.5-122B-A10B": 661541}}, "2026-08-20T08": {"total": 320021, "by_model": {"Qwen3.5-122B-A10B": 320021}}, "2026-08-20T10": {"total": 747256, "by_model": {"Qwen3.5-122B-A10B": 747256}}, "2026-08-20T11": {"total": 2172925, "by_model": {"Qwen3.5-122B-A10B": 2172925}}, "2026-08-20T17": {"total": 1628101, "by_model": {"Qwen3.5-122B-A10B": 1628101}}, "2026-08-20T18": {"total": 1290913, "by_model": {"Qwen3.5-122B-A10B": 1290913}}, "2026-08-28T18": {"total": 18183, "by_model": {"Qwen3.5-122B-A10B": 18183}, "prompt": 9092}, "2026-08-28T19": {"total": 36569, "prompt": 36443, "cached": 9024, "by_model": {"Qwen3.5-122B-A10B": 36569}, "est": true}, "2026-09-07T11": {"total": 97625, "prompt": 95864, "cached": 17464, "by_model": {"Qwen3.5-122B-A10B": 97625}, "est": true}, "2026-09-07T12": {"total": 1314979, "prompt": 1310230, "cached": 894336, "by_model": {"Qwen3.5-122B-A10B": 1314979}, "est": true}, "2026-09-07T13": {"total": 36806, "prompt": 35760, "cached": 5239, "by_model": {"Qwen3.5-122B-A10B": 36806}, "est": true}, "2026-09-21T12": {"total": 24699, "prompt": 24108, "cached": 8850, "by_model": {"Qwen3.5-122B-A10B": 24699}, "est": true}}} \ No newline at end of file diff --git a/src/web/css/chat.css b/src/web/css/chat.css index a8de443..f391127 100644 --- a/src/web/css/chat.css +++ b/src/web/css/chat.css @@ -641,6 +641,14 @@ display: block; } +/* 缓存命中率汇总行(矩阵视图标题下方) */ +.usage-cache-summary { + margin: -0.15rem 0 0.6rem; + font-size: 0.72rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + .usage-legend { display: flex; align-items: center; diff --git a/src/web/css/composer.css b/src/web/css/composer.css index 8c6647b..4074a05 100644 --- a/src/web/css/composer.css +++ b/src/web/css/composer.css @@ -1428,6 +1428,11 @@ color: var(--muted); font-variant-numeric: tabular-nums; } +/* 缓存命中率行:百分比用主题色强调 */ +.token-ring-breakdown-row--cache .token-ring-breakdown-val { + color: var(--accent); + font-weight: 600; +} /* ===== 内置 PowerShell 终端(颜色全部随主题变量;铺满下方 UI,无圆角/边框色差) ===== */ .terminal-dock { @@ -1438,6 +1443,9 @@ overflow: hidden; display: flex; flex-direction: column; + /* 高度可拖动调整(--terminal-h 由 layout.js 拖拽写入并持久化) */ + height: var(--terminal-h, 260px); + position: relative; /* 先暗化 10%(保持原观感),再按 --terminal-alpha 与背景混合(透明度可调) */ background: color-mix(in srgb, color-mix(in srgb, var(--surface, #fff) 90%, #000) var(--terminal-alpha, 100%), @@ -1453,6 +1461,22 @@ display: none; } +/* 终端拖动调整高度手柄(贴上边缘,悬停/拖动时显示强调色) */ +.terminal-resize-handle { + position: absolute; + left: 0; + right: 0; + top: 0; + height: 6px; + cursor: row-resize; + z-index: 5; + transition: background 0.15s ease; +} +.terminal-resize-handle:hover, +.terminal-resize-handle:active { + background: color-mix(in srgb, var(--accent) 25%, transparent); +} + @keyframes terminal-dock-in { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } @@ -1561,8 +1585,7 @@ .terminal-body { flex: 1; - min-height: 140px; - max-height: 45vh; + min-height: 0; /* 高度由 .terminal-dock 的 --terminal-h 决定,body 填满剩余空间 */ overflow: hidden; position: relative; } diff --git a/src/web/css/layout.css b/src/web/css/layout.css index 2d254e0..b9fa9f7 100644 --- a/src/web/css/layout.css +++ b/src/web/css/layout.css @@ -312,7 +312,7 @@ top: calc(42px - 0.5rem); right: 0; bottom: 0; - width: 320px; + width: var(--right-w, 320px); max-width: 88vw; display: flex; flex-direction: column; @@ -690,9 +690,30 @@ .app-root.right-panel-open .main > .main-content-wrap, .app-root.right-panel-open .main > .welcome-view, -.app-root.right-panel-open .main > .composer, +.app-root.right-panel-open .main > .composer { + margin-right: var(--right-w, 320px); +} +/* 终端是负 margin 全出血面板:右缘须精确贴住右栏左缘。 + 若直接给 var(--right-w),-0.75rem 被覆盖后右缘与右栏左缘之间会留 12px 缝隙, + 因此补偿回 .main 的右内边距(0.75rem)。 */ .app-root.right-panel-open .main > .terminal-dock { - margin-right: 320px; + margin-right: calc(var(--right-w, 320px) - 0.75rem); +} + +/* 右栏拖动调整宽度手柄(贴左边缘) */ +.right-panel-resize-handle { + position: absolute; + left: -3px; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + z-index: 10; + transition: background 0.15s ease; +} +.right-panel-resize-handle:hover, +.right-panel-resize-handle:active { + background: color-mix(in srgb, var(--accent) 25%, transparent); } .main-header-text { diff --git a/src/web/css/toolcalls.css b/src/web/css/toolcalls.css index 504d72a..3c3a34f 100644 --- a/src/web/css/toolcalls.css +++ b/src/web/css/toolcalls.css @@ -6,6 +6,16 @@ overflow: hidden; } +/* ===== 运行状态行(替代原三点加载动画) ===== */ +.live-running-status { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.25rem 0; + font-size: 0.75rem; + color: var(--muted, #64748b); +} + /* ===== 新工具调用展示(无框单行 + 状态圆圈 + 图标 + 展开折叠) ===== */ /* 整个列表折叠条:显示工具数 + 用时 */ @@ -718,24 +728,4 @@ text-decoration: underline; } -/* ===== 三点加载动画(Agent 运行中,首次工具调用或回复后消失) ===== */ -.typing-dots { - display: inline-flex; - align-items: center; - gap: 2px; - padding: 0.3rem 0.5rem; - font-size: 1.2rem; - font-weight: 700; - color: var(--muted, #94a3b8); - letter-spacing: 1px; -} -.typing-dots span { - animation: typingDotBlink 1.4s infinite ease-in-out both; -} -.typing-dots span:nth-child(1) { animation-delay: 0s; } -.typing-dots span:nth-child(2) { animation-delay: 0.2s; } -.typing-dots span:nth-child(3) { animation-delay: 0.4s; } -@keyframes typingDotBlink { - 0%, 80%, 100% { opacity: 0.15; } - 40% { opacity: 1; } -} +/* 三点加载动画已移除(由 .live-running-status 状态行 + 实时工具调用面板替代) */ diff --git a/src/web/css/variables.css b/src/web/css/variables.css index 27d98fb..0143bec 100644 --- a/src/web/css/variables.css +++ b/src/web/css/variables.css @@ -5,6 +5,8 @@ --accent-hover: #155e75; --radius: 12px; --sidebar-w: 190px; + --right-w: 320px; /* 右栏宽度(可拖动调整,localStorage 持久化) */ + --terminal-h: 260px; /* 终端底栏高度(可拖动调整,localStorage 持久化) */ --bg-dof: 0px; --bg-vignette: 0.35; --bubble-user: rgba(236, 254, 255, 0.35); diff --git a/src/web/image/uimg_1788752953711.png b/src/web/image/uimg_1788752953711.png new file mode 100644 index 0000000..c13eb4d Binary files /dev/null and b/src/web/image/uimg_1788752953711.png differ diff --git a/src/web/image/uimg_1788758746434.png b/src/web/image/uimg_1788758746434.png new file mode 100644 index 0000000..c13eb4d Binary files /dev/null and b/src/web/image/uimg_1788758746434.png differ diff --git a/src/web/index.html b/src/web/index.html index cc05cfb..066e62c 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -76,6 +76,7 @@