update ultimately

This commit is contained in:
minorQAQ committed 2026-09-21 12:12:59 +08:00
1 parent 99f90c2ee8
commit ac4fb9631f
23 files changed
+763 -100

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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)),
)
+10
View File
@@ -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
+109 -4
View File
@@ -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:
+36 -5
View File
@@ -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,
}
+88 -6
View File
@@ -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)
+1 -1
View File
@@ -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}}}}
{"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}}}
+8
View File
@@ -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;
+25 -2
View File
@@ -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;
}
+24 -3
View File
@@ -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 {
+11 -21
View File
@@ -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 状态行 + 实时工具调用面板替代) */
+2
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+2
View File
@@ -76,6 +76,7 @@
</aside>
<!-- 右栏(Edit 文件树,由顶栏"右栏"按钮控制) -->
<aside class="right-panel" id="rightPanel" aria-hidden="true">
<div class="right-panel-resize-handle" id="rightPanelResizeHandle" title="拖动调整右栏宽度"></div>
<div class="right-panel-head">
<span class="right-panel-title">文件树</span>
<button type="button" class="right-panel-close" id="rightPanelClose" title="关闭右栏" aria-label="关闭右栏">&#x2715;</button>
@@ -271,6 +272,7 @@
<!-- 内置 PowerShell 终端(顶栏终端按钮控制;出现时输入区上移,聊天区自适应) -->
<!-- 命令行(提示符+输入)由 terminal.js 动态挂载到活动面板输出末尾,随内容滚动 -->
<div class="terminal-dock" id="terminalDock" hidden>
<div class="terminal-resize-handle" id="terminalResizeHandle" title="拖动调整终端高度"></div>
<div class="terminal-tabs" id="terminalTabs"></div>
<div class="terminal-body" id="terminalBody"></div>
</div>
+7 -9
View File
@@ -1758,15 +1758,13 @@ function appendThinkingIndicator(container) {
wrap.setAttribute("aria-live", "polite");
const bubble = document.createElement("div");
bubble.className = "msg-bubble";
// 三点加载动画,首次工具调用或 AI 回复后消失
const dots = document.createElement("div");
dots.className = "typing-dots";
for (let i = 0; i < 3; i++) {
const dot = document.createElement("span");
dot.textContent = ".";
dots.appendChild(dot);
}
bubble.appendChild(dots);
// 运行状态行(替代原三点加载动画):实时工具调用面板创建后由
// injectLiveToolCalls 移除。用户要求运行过程中直接看到工具调用列表,
// 不再以三个点作为唯一运行提示。
const status = document.createElement("div");
status.className = "live-running-status";
status.innerHTML = '<span class="live-running-status-text">正在执行…</span>';
bubble.appendChild(status);
wrap.appendChild(bubble);
const placeholder = _placeholderFor(target);
if (placeholder) placeholder.hidden = true;
+118
View File
@@ -233,6 +233,124 @@ function initSidebarResize() {
initSidebarResize();
// ===== 右栏拖动调整宽度 =====
const STORAGE_RIGHT_W = "minor_right_panel_width";
const RIGHT_MIN_W = 200;
const RIGHT_MAX_W = 720;
function initRightPanelResize() {
const panel = document.getElementById("rightPanel");
const handle = document.getElementById("rightPanelResizeHandle");
if (!panel || !handle) return;
function applyW(w) {
document.documentElement.style.setProperty("--right-w", w + "px");
}
// 恢复保存的宽度
try {
const saved = parseInt(localStorage.getItem(STORAGE_RIGHT_W), 10);
if (saved >= RIGHT_MIN_W) applyW(Math.min(saved, RIGHT_MAX_W, window.innerWidth * 0.6));
} catch { /* ignore */ }
let dragging = false;
let startX = 0;
let startW = 0;
function endDrag() {
if (!dragging) return;
dragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
try { localStorage.setItem(STORAGE_RIGHT_W, String(Math.round(panel.getBoundingClientRect().width))); } catch { /* ignore */ }
}
handle.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
startX = e.clientX;
startW = panel.getBoundingClientRect().width;
try { handle.setPointerCapture(e.pointerId); } catch { /* ignore */ }
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
});
handle.addEventListener("pointermove", (e) => {
if (!dragging) return;
// 手柄在左边缘:向左拖(clientX 减小)→ 变宽
let newW = startW - (e.clientX - startX);
newW = Math.max(RIGHT_MIN_W, Math.min(RIGHT_MAX_W, window.innerWidth * 0.6, newW));
applyW(newW);
});
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
window.addEventListener("blur", endDrag);
}
initRightPanelResize();
// ===== 终端底栏拖动调整高度 =====
const STORAGE_TERMINAL_H = "minor_terminal_height";
const TERM_MIN_H = 130;
function initTerminalResize() {
const dock = document.getElementById("terminalDock");
const handle = document.getElementById("terminalResizeHandle");
if (!dock || !handle) return;
// 上限:不高于窗口 60%,且为顶栏/输入区/聊天区至少留出约 340px
const maxH = () => Math.max(TERM_MIN_H, Math.min(window.innerHeight * 0.6, window.innerHeight - 340));
function applyH(h) {
document.documentElement.style.setProperty("--terminal-h", h + "px");
}
// 恢复保存的高度
try {
const saved = parseInt(localStorage.getItem(STORAGE_TERMINAL_H), 10);
if (saved >= TERM_MIN_H) applyH(Math.min(saved, maxH()));
} catch { /* ignore */ }
let dragging = false;
let startY = 0;
let startH = 0;
function endDrag() {
if (!dragging) return;
dragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
try { localStorage.setItem(STORAGE_TERMINAL_H, String(Math.round(dock.getBoundingClientRect().height))); } catch { /* ignore */ }
}
handle.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
startY = e.clientY;
startH = dock.getBoundingClientRect().height;
try { handle.setPointerCapture(e.pointerId); } catch { /* ignore */ }
document.body.style.cursor = "row-resize";
document.body.style.userSelect = "none";
});
handle.addEventListener("pointermove", (e) => {
if (!dragging) return;
// 手柄在上边缘:向上拖(clientY 减小)→ 变高
let newH = startH - (e.clientY - startY);
newH = Math.max(TERM_MIN_H, Math.min(maxH(), newH));
applyH(newH);
});
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
window.addEventListener("blur", endDrag);
}
initTerminalResize();
if (headerToggleBtn) {
headerToggleBtn.addEventListener("click", () => {
setHeaderCollapsed(!mainPanel.classList.contains("header-collapsed"));
+10 -27
View File
@@ -146,6 +146,9 @@ function renderSessionItem(id) {
selectSession(id);
});
// 整行可点击(图标/留白区域点击也切换会话;编辑/删除按钮自行 stopPropagation)
li.addEventListener("click", () => selectSession(id));
const actionsDiv = document.createElement("span");
actionsDiv.className = "session-item-actions";
@@ -300,7 +303,6 @@ async function deleteSessionItem(id) {
if (!await showConfirm(`确认删除会话「${displayName}」吗?该会话的所有记录和文件都将被永久删除。`)) return;
const deletingSessionId = id;
const deletedActive = id === state.sessionId; // 切换前记录:删除的是否为当前会话
const fd = new FormData();
fd.append("session_id", id);
// 告知服务端当前会话:删除非当前会话时无需返回全量历史,避免无谓序列化
@@ -324,31 +326,11 @@ async function deleteSessionItem(id) {
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
syncWorkspaceChip();
// 原地移除会话节点(避免全量重建列表 → 鼠标下方节点被替换导致 hover 失效、
// 列表闪烁);找不到对应节点(如列表未渲染)时回退全量渲染
let removedInPlace = false;
try {
const itemEl = sessionList.querySelector(`.session-item[data-session-id="${CSS.escape(deletingSessionId)}"]`);
if (itemEl) {
const groupLi = itemEl.closest(".ws-group");
itemEl.remove();
// 空的工作区组连组移除
if (groupLi) {
const inner = groupLi.querySelector(".ws-group-sessions");
if (inner && !inner.children.length) groupLi.remove();
}
removedInPlace = true;
// 删除的是当前会话:把高亮移到服务端回退的新会话
if (deletedActive && data.sessionId && data.sessionId !== deletingSessionId) {
const nextActive = sessionList.querySelector(`.session-item[data-session-id="${CSS.escape(data.sessionId)}"]`);
if (nextActive) {
nextActive.classList.add("active");
nextActive.setAttribute("aria-selected", "true");
}
}
}
} catch { /* ignore */ }
if (!removedInPlace) renderSessions();
// 全量重建会话列表:高亮由 renderSessionItem 按 state.sessionId 计算(保证
// 删除后高亮一定落在服务端回退会话上),分组按最新 session_workspaces 重建。
// 不再用"原地移除 + 猜测回退项"方案:服务端回退会话若不在当前 DOM(如分组
// 变化/列表未渲染),原地方案会出现高亮丢失或失效 ID 残留。
if (renderSessionsFnRef) renderSessionsFnRef();
// 清理 localStorage 中的名称记录
const names = loadSessionNames();
@@ -421,10 +403,11 @@ async function selectSession(id) {
const headers = cachedEtag ? { "If-None-Match": cachedEtag } : undefined;
const res = await apiWithHeaders(`/api/sessions/${encodeURIComponent(id)}/messages`, { headers });
if (res.status === 304) {
// 缓存与服务器一致:切换生效并补齐被跳过的副作用(chip / 通知条)
// 缓存与服务器一致:切换生效并补齐被跳过的副作用(列表高亮 / chip / 通知条)
const entry = state._msgCache.get(id);
if (entry) entry.etag = cachedEtag;
state.sessionId = id;
if (renderSessionsFnRef) renderSessionsFnRef(); // 移动左侧会话列表高亮
syncWorkspaceChip();
try {
const { switchStripSession } = await import('./notify-strip.js');
+31
View File
@@ -12,6 +12,8 @@ import { state } from './state.js';
let _pollTimer = null;
let _settings = null; // { context_window, compress_rate }
let _breakdown = { messages: 0, tools: 0, system: 0 };
let _cacheHit = null; // 最新 LLM 调用 prompt 缓存命中率(0~1);无上报为 null
let _cacheHitSrc = null; // "api"(provider 上报)| "estimate"(本地估算)| null
const _RING_R = 11;
const _RING_C = 2 * Math.PI * _RING_R;
@@ -51,6 +53,8 @@ async function pollTokens() {
compress_rate: data.compress_rate != null ? data.compress_rate : 0.6,
};
_breakdown = data.breakdown || { messages: 0, tools: 0, system: 0 };
_cacheHit = data.cache_hit != null ? Number(data.cache_hit) : null;
_cacheHitSrc = (data.cache_hit_src === "api" || data.cache_hit_src === "estimate") ? data.cache_hit_src : null;
updateRingUI();
} catch { /* 静默失败 */ }
}
@@ -118,6 +122,33 @@ function renderBreakdown() {
row.appendChild(val);
container.appendChild(row);
});
// 缓存命中率(有数据才显示,含 0%;为 prompt_tokens 的命中占比,独立于上方 100% 构成)
if (_cacheHit != null) {
const isEst = _cacheHitSrc === "estimate";
const row = document.createElement("div");
row.className = "token-ring-breakdown-row token-ring-breakdown-row--cache";
row.title = isEst
? "最近一次 LLM 调用的 prompt 缓存命中占比(估算:上次请求公共前缀复用占比 × prompt_tokens;模型服务未上报 cached_tokens)"
: "最近一次 LLM 调用的 prompt 缓存命中占比(cached_tokens / prompt_tokens)";
const dot = document.createElement("span");
dot.className = "token-ring-breakdown-dot";
dot.textContent = "·";
const key = document.createElement("span");
key.className = "token-ring-breakdown-key";
key.textContent = isEst ? "缓存命中率(估算)" : "缓存命中";
const val = document.createElement("span");
val.className = "token-ring-breakdown-val";
val.textContent = `${(_cacheHit * 100).toFixed(1)}%`;
row.appendChild(dot);
row.appendChild(key);
row.appendChild(val);
container.appendChild(row);
}
}
/** 绑定圆环 hover 显示/隐藏与点击切换面板(含连击保护) */
+39 -10
View File
@@ -830,7 +830,6 @@ export function setRenderAudioBubble(fn) { renderAudioBubbleFn = fn; }
/** 计算 live 模式下的实时总耗时:从轮次开始到当前时间(单条工具计时已移除) */
function _calcLiveDuration(toolCalls, turnStartedAt) {
if (!toolCalls || !toolCalls.length) return 0;
const now = Date.now() / 1000;
const start = turnStartedAt ? Number(turnStartedAt) : null;
return Math.max(0, now - (start || now));
@@ -878,7 +877,10 @@ function renderThinkRow(content, options = {}) {
}
function renderPersistentToolCalls(toolCalls, options = {}) {
if (!toolCalls || !toolCalls.length) return null;
// live 模式允许空列表创建面板(首帧即展示"调用 0 个工具"折叠条,
// 之后由增量 diff 就地追加/更新行,实现运行过程实时显示);
// 历史渲染(无 allowEmpty)仍以"无记录不渲染"为契约。
if ((!toolCalls || !toolCalls.length) && !options.allowEmpty) return null;
const wrap = document.createElement("div");
wrap.className = "tool-call-list-wrap tool-call-list-wrap--persistent";
@@ -908,11 +910,14 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
toggle.appendChild(toggleText);
// 计时器(live 模式:每秒刷新轮次总计时,单条工具计时已移除)
// 基准取 wrap._liveStartedAt(injectLiveToolCalls 随快照更新),
// 面板可能由轮次开始前置的空快照创建,此时 turnStartedAt 为 null
let timerId = null;
if (isLive) {
timerId = setInterval(() => {
const start = wrap._liveStartedAt != null ? wrap._liveStartedAt : turnStartedAt;
// 更新折叠条总计:从轮次开始到当前时间
updateToggleText(_calcLiveDuration(toolCalls, turnStartedAt));
updateToggleText(_calcLiveDuration(toolCalls, start));
}, 1000);
wrap._stopTimer = () => { if (timerId) { clearInterval(timerId); timerId = null; } };
}
@@ -926,7 +931,6 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
// body 容器
const body = document.createElement("div");
body.className = "tc-list-toggle-body";
// 将 reflections 渲染为思考/反思块,按位置穿插在工具调用之间
// (每个条目携带 kind:thought=深度思考 / reflection=反思,二者可同时存在)
const reflectionMap = new Map();
@@ -973,6 +977,10 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
});
wrap.appendChild(body);
// 初始计数在 body 挂载后刷新一次:创建时行尚未入 DOM,计数读到 0,
// 直接渲染出"调用 0 个工具"类文本(live 模式下 1s 计时器随后修正,
// 此处补一次避免首帧显示错误)
updateToggleText(isLive ? _calcLiveDuration(toolCalls, turnStartedAt) : totalDuration);
return wrap;
}
@@ -1006,19 +1014,35 @@ function _sameToolCallRow(oldRow, newRow) {
}
function injectLiveToolCalls(typingEl, liveCalls, turnStartedAt, _reflections) {
if (!typingEl || !liveCalls.length) return;
if (!typingEl) return;
const bubble = typingEl.querySelector(".msg-bubble") || typingEl;
// 首次工具调用到达,移除三点加载动画
// 实时计时基准随快照更新:首个快照可能早于轮次开始(started_at 为空),
// 面板已存在时把最新轮次起始时间写到面板上供计时器读取
const started = Number(turnStartedAt);
if (started > 0) {
const existing = bubble.querySelector(".tool-call-list-wrap");
if (existing) existing._liveStartedAt = started;
}
// 兼容旧布局:移除三点加载动画(现已由 .live-running-status 状态行替代)
const dots = bubble.querySelector(".typing-dots");
if (dots) dots.remove();
const oldWrap = bubble.querySelector(".tool-call-list-wrap");
if (!oldWrap) {
const wrap = renderPersistentToolCalls(liveCalls, { live: true, reflections: _reflections, turnStartedAt: turnStartedAt });
// 空快照也创建面板:首帧即展示折叠条("调用 0 个工具 · 用时 X",
// 计时器每秒刷新),此后增量 diff 就地追加/更新行,实现运行过程实时显示。
// 运行状态行随面板出现而移除——面板自身即为运行状态指示。
const status = bubble.querySelector(".live-running-status");
if (status) status.remove();
const wrap = renderPersistentToolCalls(liveCalls, {
live: true, reflections: _reflections, turnStartedAt: turnStartedAt, allowEmpty: true,
});
if (!wrap) return;
wrap.classList.add("tool-call-list-wrap--expanded");
bubble.appendChild(wrap);
if (started > 0) wrap._liveStartedAt = started;
return;
}
@@ -1400,6 +1424,7 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
let _lastInjectAt = 0; // 上次注入完成时刻
const _MIN_INJECT_INTERVAL_MS = 120; // 与后端 ~0.12s 推送防抖对齐,降低运行期 DOM diff 频率
let _panelCreated = false; // 实时面板是否已创建(空列表快照注入一次后置位)
const scheduleInject = (liveCalls) => {
_pendingInject = { typingEl, injectFn, liveCalls, turnStartedAt, reflections: collectedReflections, version: _liveVersion };
if (_injectRafId) return; // 已有待执行注入,等待中的快照已被最新值覆盖
@@ -1407,7 +1432,10 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
_injectRafId = 0;
const p = _pendingInject;
_pendingInject = null;
if (!p || !p.typingEl || !p.injectFn || !p.liveCalls || !p.liveCalls.length) return;
if (!p || !p.typingEl || !p.injectFn || !p.liveCalls) return;
// 空列表仅在面板尚未创建时注入一次(创建空面板);面板已存在则跳过,
// 避免每个仅含 human_requests/事件的快照反复空转
if (!p.liveCalls.length && _panelCreated) return;
// 内容无变化(仅人工请求等非工具字段变化)→ 跳过整表 diff
if (p.version === _lastInjectedVersion) return;
const wait = _MIN_INJECT_INTERVAL_MS - (performance.now() - _lastInjectAt);
@@ -1419,6 +1447,7 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
_lastInjectAt = performance.now();
_lastInjectedVersion = p.version;
p.injectFn(p.typingEl, p.liveCalls, p.turnStartedAt, p.reflections);
if (!p.liveCalls.length) _panelCreated = true;
};
_injectRafId = requestAnimationFrame(run);
};
@@ -1513,7 +1542,7 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
return;
}
// 处理工具调用(rAF 合并注入,增量 DOM 更新)
if (typingEl && injectFn && liveCalls.length) scheduleInject(liveCalls);
if (typingEl && injectFn && (liveCalls.length || !_panelCreated)) scheduleInject(liveCalls);
} else {
// ===== 全量:连接初始快照 / 轮询回退 / 重同步 =====
liveCalls = Array.isArray(liveData.tool_calls) ? liveData.tool_calls.slice() : [];
@@ -1524,7 +1553,7 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
if (liveUiDeps.updateTodoOverlayFromRecords) liveUiDeps.updateTodoOverlayFromRecords(liveCalls);
summarizerToast(liveCalls);
showLiveEvents(liveData.events);
if (typingEl && injectFn && liveCalls.length) scheduleInject(liveCalls);
if (typingEl && injectFn && (liveCalls.length || !_panelCreated)) scheduleInject(liveCalls);
}
};
+31 -3
View File
@@ -51,6 +51,14 @@ function _fmtDate(d) {
return `${d.getFullYear()}-${m}-${day}`;
}
/** token 数缩写(k/M) */
function _fmtTokens(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(n);
}
/** 所在周的周一(JS getDay 0=周日,转周一=0) */
function _startOfWeek(d) {
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
@@ -84,7 +92,9 @@ const DOW_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
* 每列 7 格 = 周一到周日(周一在最上)。每列 label 为月份(每月首周显示 "M月")。 */
function _buildColumns(days) {
const dayMap = {};
(days || []).forEach((d) => { dayMap[d.date] = d.total || 0; });
(days || []).forEach((d) => {
dayMap[d.date] = { total: d.total || 0, prompt: d.prompt || 0, cached: d.cached || 0 };
});
const today = new Date();
const todayKey = _fmtDate(today);
const lastMonday = _startOfWeek(today);
@@ -96,7 +106,8 @@ function _buildColumns(days) {
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 info = dayMap[key] || { total: 0, prompt: 0, cached: 0 };
cells.push({ date: key, total: info.total, prompt: info.prompt, cached: info.cached, future: key > todayKey });
}
const m = weekStart.getMonth();
columns.push({
@@ -153,6 +164,21 @@ function _renderMatrix() {
header.appendChild(legend);
card.appendChild(header);
// 缓存命中率汇总行(provider 上报缓存 token 后显示;缓存率 = cached / prompt,与总量区分)
const promptTotal = Number(_usage && _usage.prompt_total) || 0;
const cachedTotal = Number(_usage && _usage.cached_total) || 0;
if (promptTotal > 0) {
const summary = document.createElement("div");
summary.className = "usage-cache-summary";
const hitPct = (cachedTotal / promptTotal) * 100;
const estNote = _usage && _usage.estimated ? "估算," : "";
summary.textContent = `缓存命中率 ${hitPct.toFixed(1)}%(${estNote}最近半年,缓存 ${_fmtTokens(cachedTotal)} / 输入 ${_fmtTokens(promptTotal)})`;
if (_usage && _usage.estimated) {
summary.title = "模型服务未上报 cached_tokens,按两次请求公共前缀复用占比估算;接入上报缓存的服务后自动切换为精确值。";
}
card.appendChild(summary);
}
const body = document.createElement("div");
body.className = "usage-matrix-body";
@@ -209,7 +235,9 @@ function _renderMatrix() {
const color = _levelColor(lvl);
if (color) el.style.background = color;
// 任意日期都可点击查看当天每小时柱状图(0 值日期显示空态)
el.title = `${cell.date} · ${cell.total.toLocaleString()} tokens`;
let tip = `${cell.date} · ${cell.total.toLocaleString()} tokens`;
if (cell.prompt > 0) tip += ` · 缓存命中 ${((cell.cached / cell.prompt) * 100).toFixed(1)}%`;
el.title = tip;
el.addEventListener("click", () => _renderBars(cell.date));
grid.appendChild(el);
});
+53 -9
View File
@@ -177,11 +177,28 @@ def _clean_tts_text(text: str) -> str:
return '\n'.join(lines)
def _session_exists(session_id: str) -> bool:
"""会话是否真实存在(有 meta 或轮次记录)——跨后端(json/mysql/sqlite)判断。"""
try:
from agent.history import session_storage
if session_storage.load_session_extra(session_id):
return True
return bool(session_storage.list_turn_ids(session_id))
except Exception:
return False
def _ensure_session_in_list(session_id: str, ids: list[str]) -> list[str]:
"""确保 session_id 在会话 ID 列表中(置顶)。"""
if session_id not in ids:
ids = [session_id, *ids]
return ids
"""确保 session_id 在会话 ID 列表中(置顶)。
仅当会话确实存在时才置顶:防止前端在会话已被删除后点击失效 ID 时,
将已删除的会话"复活"为列表顶部无工作区的假会话。
"""
if session_id in ids:
return ids
if not _session_exists(session_id):
return ids
return [session_id, *ids]
def _guess_mime(fp: str) -> str:
@@ -850,6 +867,18 @@ def api_session_messages(session_id: str, request: Request):
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
# 会话已被删除(失效 ID):不复活假会话,回退到空会话引导态(sessionId 置空),
# 避免前端把已删除会话再次置顶显示为"无工作区的新会话"
if not _session_exists(session_id):
ids = ui.list_session_ids_ordered()
return {
"sessionId": "",
"sessions": ids,
"session_workspaces": _session_workspaces_map(ids),
"messages": [],
"tokens": 0,
"thinking_level": None,
}
etag = _messages_etag(session_id)
inm = request.headers.get("If-None-Match") if request else None
if etag and inm and (inm.strip() == etag or inm.strip().replace("W/", "") == etag.replace("W/", "")):
@@ -1093,9 +1122,14 @@ def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]:
ui.rewrite_user_files_for_last_turn(new_hist, session_id)
# 再保存 turn 消息(此时路径已指向会话目录,而非临时目录)
# turn_id 直接写回历史消息的 meta(而非仅写入副本):
# 响应中的 new_message 由此历史序列化而来,前端 appendNewMessages 依赖
# meta.turn_id 识别"服务端 canonical 用户消息"来替换乐观渲染的消息;
# 缺失时计数校验失败 → resyncMessages 全量重渲染 → 运行中的
# 实时工具调用面板/思考气泡被整体清空(表现为工具调用只在结束后显示)。
new_hist[-1]["meta"] = {**(new_hist[-1].get("meta") or {}), "turn_id": turn_id}
from web import ui_session as _ui
user_msg = dict(new_hist[-1])
user_msg["meta"] = {**(user_msg.get("meta") or {}), "turn_id": turn_id}
msg_item = _ui._serialize_message_for_turn(user_msg)
from agent.utils.agent_utils import save_turn_messages
save_turn_messages(session_id, turn_id, [msg_item] if msg_item else [])
@@ -2037,10 +2071,14 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
"""获取当前会话的 token 用量(来自 LLM response usage_metadata.total_tokens)。
输出: {"tokens": int, "context_window": int, "compress_threshold": int,
"compress_rate": float}
"compress_rate": float, "breakdown": {...},
"cache_hit": float|null, "cache_hit_src": "api"|"estimate"|null}
cache_hit: 最新一次 LLM 调用的 prompt 缓存命中率(0~1);无记录/无缓存信息时为 null。
cache_hit_src: "api"=provider 上报真实值;"estimate"=本地前缀复用估算兜底。
"""
from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE
from agent.history.tool_call_recorder import get_session_tokens, get_session_token_breakdown
from agent.history.tool_call_recorder import (get_session_tokens, get_session_token_breakdown,
get_session_cache_hit, get_session_cache_hit_src)
# 直接读取 LLM 每次调用后存储的真实 total_tokens
tokens = get_session_tokens(session_id)
@@ -2053,6 +2091,9 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
"compress_rate": COMPRESS_RATE,
# 三类估算占比(消息/工具/系统提示词),供前端环形指示展示
"breakdown": get_session_token_breakdown(session_id),
# prompt 缓存命中率(前端环形面板展示)
"cache_hit": get_session_cache_hit(session_id),
"cache_hit_src": get_session_cache_hit_src(session_id),
}
@@ -2062,9 +2103,12 @@ 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}}],
{"total": int, "prompt_total": int, "cached_total": int, "hit_rate": float,
"estimated": bool, "by_model": {model: int},
"days": [{"date": str, "total": int, "prompt": int, "cached": int,
"by_model": {model: int}}],
"hours": {"YYYY-MM-DD": {"HH": {model: int}}}}
estimated: 命中率是否含本地估算数据(provider 未上报 cached_tokens 时兜底)。
"""
from agent.core.usage_stats import get_usage_summary
return get_usage_summary(days=max(1, min(days, 366)))
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
MySQL 连接测试脚本
====================
测试目标 MySQL server 能否连接、登录,并读取表。
环境要求:
本脚本依赖 PyMySQL,请先在虚拟环境中安装:
python -m venv .venv
.venv\\Scripts\\activate # Windows
# source .venv/bin/activate # Linux/Mac
pip install pymysql
用法:
python test_mysql_connect.py # 使用脚本内置默认连接信息
python test_mysql_connect.py -h host -P port -u user # 自定义连接信息
说明:
- 该账号为只读账号,仅做 SELECT 测试,不做任何写操作。
"""
import argparse
import sys
try:
import pymysql
from pymysql.constants import CLIENT
except ImportError:
print("缺少 PyMySQL,请先执行: pip install pymysql")
sys.exit(1)
# 默认连接信息(可用命令行参数覆盖)
DEFAULT_HOST = "zkzh.ykz-ai.com"
DEFAULT_PORT = 60031
DEFAULT_USER = "reader"
DEFAULT_PASSWORD = "Reader@123456!"
DEFAULT_DATABASE = None # 不指定库,先列出所有库
CONNECT_TIMEOUT = 10 # 秒
READ_TIMEOUT = 30 # 秒
MAX_ROWS = 5 # 每个表最多读取的行数
def connect(host, port, user, password, database=None):
"""尝试建立 MySQL 连接,返回 (connection, error_msg)。"""
try:
conn = pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
connect_timeout=CONNECT_TIMEOUT,
read_timeout=READ_TIMEOUT,
# 部分服务端为 MariaDB,开启兼容标志避免认证方式报错
client_flag=CLIENT.MULTI_STATEMENTS,
)
return conn, None
except pymysql.err.OperationalError as e:
# 主机名解析失败 / 连接超时 / 拒绝访问 都在这类错误里
return None, "连接失败: {0}".format(e)
except Exception as e:
return None, "未知错误: {0}: {1}".format(type(e).__name__, e)
def safe_query(conn, sql, args=None):
"""执行查询,返回 (rows, columns, error_msg)。"""
try:
with conn.cursor() as cur:
cur.execute(sql, args)
columns = [d[0] for d in cur.description] if cur.description else None
rows = cur.fetchmany(MAX_ROWS)
return rows, columns, None
except Exception as e:
return None, None, "{0}: {1}".format(type(e).__name__, e)
def main():
parser = argparse.ArgumentParser(description="MySQL 连接与读取测试")
parser.add_argument("-H", "--host", default=DEFAULT_HOST, help="主机地址")
parser.add_argument("-P", "--port", type=int, default=DEFAULT_PORT, help="端口")
parser.add_argument("-u", "--user", default=DEFAULT_USER, help="用户名")
parser.add_argument("-p", "--password", default=DEFAULT_PASSWORD, help="密码")
parser.add_argument("-d", "--database", default=DEFAULT_DATABASE, help="指定数据库(默认列出所有库)")
args = parser.parse_args()
print("=" * 60)
print("MySQL 连接测试")
print(" 地址: {0}:{1}".format(args.host, args.port))
print(" 用户: {0}".format(args.user))
print("=" * 60)
# 1. 建立连接
conn, err = connect(args.host, args.port, args.user, args.password, args.database)
if conn is None:
print("\n[失败] {0}".format(err))
sys.exit(1)
print("\n[成功] 已连接到 MySQL 服务器")
# 2. 服务器信息
rows, cols, err = safe_query(conn, "SELECT VERSION(), NOW(), CURRENT_USER()")
if err:
print("[警告] 获取服务器信息失败: {0}".format(err))
else:
print(" 服务器版本: {0}".format(rows[0][0]))
print(" 服务器时间: {0}".format(rows[0][1]))
print(" 登录身份: {0}".format(rows[0][2]))
# 3. 列出数据库
rows, cols, err = safe_query(conn, "SHOW DATABASES")
if err:
print("[警告] 列出数据库失败: {0}".format(err))
else:
print("\n[信息] 可访问的数据库({0} 个):".format(len(rows)))
for r in rows:
print(" - {0}".format(r[0]))
# 4. 列出表并读取部分数据
if args.database:
db_list = [args.database]
else:
db_list = [r[0] for r in rows] if rows else []
for db in db_list:
rows, cols, err = safe_query(conn, "SHOW TABLES FROM `{0}`".format(db))
if err:
print("\n[失败] 读取数据库 \"{0}\" 的表列表失败: {1}".format(db, err))
continue
if not rows:
print("\n[信息] 数据库 \"{0}\" 没有可访问的表".format(db))
continue
print("\n" + "-" * 60)
print("数据库: {0} (表数量: {1})".format(db, len(rows)))
for tbl in rows:
table_name = tbl[0]
trows, tcols, terr = safe_query(conn, "SELECT * FROM `{0}`.`{1}` LIMIT {2}".format(db, table_name, MAX_ROWS))
if terr:
print(" [失败] 表 [{0}] 读取失败: {1}".format(table_name, terr))
continue
print(" [成功] 表 [{0}] 字段={1} 样例数据行数={2}".format(
table_name, tcols, len(trows) if trows else 0))
if trows:
print(" 字段: {0}".format(tcols))
for r in trows[:3]:
print(" 行: {0}".format(r))
conn.close()
print("\n" + "=" * 60)
print("测试结束")
if __name__ == "__main__":
main()