From 1fd74276681df22f1929ee05b55f28ba399a71ea Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 25 May 2026 11:11:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(session):=20=E4=BB=8E=E9=A6=96?= =?UTF-8?q?=E4=B8=AA=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E6=8F=90=E5=8F=96?= =?UTF-8?q?=20Session=20=E6=A0=87=E9=A2=98=E5=B9=B6=E5=9C=A8=20Dashboard?= =?UTF-8?q?=20=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 session_meta 表持久化 session 级标题元数据 - 检测新 session 时从首个 user 消息提取文本截取前 30 字作为标题 - get_or_create_record() 返回 is_new 标志标识首次会话 - Dashboard Sessions 列表新增 Title 列,展开详情同步展示 - 更新相关测试适配返回值变更 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/coding/proxy/logging/db.py | 39 ++++++++++++++++++++- src/coding/proxy/routing/executor.py | 37 +++++++++++++++++-- src/coding/proxy/routing/session_manager.py | 13 ++++--- src/coding/proxy/routing/usage_recorder.py | 5 +++ src/coding/proxy/server/dashboard.py | 28 +++++++++------ tests/test_router_executor.py | 7 ++-- 6 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/coding/proxy/logging/db.py b/src/coding/proxy/logging/db.py index 9e87853..8470966 100644 --- a/src/coding/proxy/logging/db.py +++ b/src/coding/proxy/logging/db.py @@ -190,6 +190,14 @@ def _local_month_udf(ts_str: str) -> str: ); """ +_CREATE_SESSION_META = """ +CREATE TABLE IF NOT EXISTS session_meta ( + session_key TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +""" + _CREATE_INDEXES = """ CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_log(ts); CREATE INDEX IF NOT EXISTS idx_usage_vendor ON usage_log(vendor); @@ -245,6 +253,7 @@ async def init(self) -> None: self._db.row_factory = aiosqlite.Row await self._db.execute("PRAGMA journal_mode=WAL") await self._db.executescript(_CREATE_TABLES) + await self._db.executescript(_CREATE_SESSION_META) # 迁移必须在建索引之前执行,确保 vendor 列已存在 await self._migrate_rename_backend_to_vendor() await self._migrate_add_failover_from() @@ -316,6 +325,28 @@ async def _migrate_rename_backend_to_vendor(self) -> None: "Migration: renamed 'backend' column to 'vendor' in %s", table ) + async def set_session_title(self, session_key: str, title: str) -> None: + """为新 session 设置标题(幂等,仅首次写入).""" + if not self._db or not title or not session_key: + return + await self._db.execute( + "INSERT OR IGNORE INTO session_meta (session_key, title) VALUES (?, ?)", + (session_key, title), + ) + await self._db.commit() + + async def get_session_titles(self, session_keys: list[str]) -> dict[str, str]: + """批量查询 session 标题.""" + if not self._db or not session_keys: + return {} + placeholders = ",".join("?" for _ in session_keys) + cursor = await self._db.execute( + f"SELECT session_key, title FROM session_meta WHERE session_key IN ({placeholders})", + session_keys, + ) + rows = await cursor.fetchall() + return {row["session_key"]: row["title"] for row in rows} + async def log( self, vendor: str, @@ -621,7 +652,13 @@ async def query_recent_sessions( (cutoff_iso, limit), ) rows = await cursor.fetchall() - return [dict(row) for row in rows] + sessions = [dict(row) for row in rows] + if sessions: + keys = [s["session_key"] for s in sessions] + titles = await self.get_session_titles(keys) + for s in sessions: + s["title"] = titles.get(s["session_key"], "") + return sessions async def query_session_profile(self, session_key: str) -> dict | None: """查询单个会话的完整聚合数据.""" diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 74273af..537b2b0 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -43,10 +43,29 @@ # 向后兼容别名 BackendResponse = VendorResponse NoCompatibleBackendError = NoCompatibleVendorError -from ..compat.canonical import CompatibilityStatus, build_canonical_request +from ..compat.canonical import ( + CanonicalPartType, + CompatibilityStatus, + build_canonical_request, +) +from ..model.compat import CanonicalRequest logger = logging.getLogger(__name__) +_SESSION_TITLE_MAX_LEN = 30 + + +def _extract_session_title(request: CanonicalRequest) -> str: + """从规范化请求中提取首个用户消息文本作为 session 标题.""" + for part in request.messages: + if ( + part.role == "user" + and part.type == CanonicalPartType.TEXT + and part.text.strip() + ): + return part.text.strip()[:_SESSION_TITLE_MAX_LEN] + return "" + def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str: """构建语义拒绝的请求体诊断上下文. @@ -393,10 +412,16 @@ async def execute_stream( failed_tier_name: str | None = None request_caps = build_request_capabilities(body) canonical_request = build_canonical_request(body, headers) - session_record = await self._session_mgr.get_or_create_record( + session_record, is_new_session = await self._session_mgr.get_or_create_record( canonical_request.session_key, canonical_request.trace_id, ) + if is_new_session: + title = _extract_session_title(canonical_request) + if title: + await self._recorder.set_session_title( + canonical_request.session_key, title + ) incompatible_reasons: list[str] = [] effective_tiers = self._resolve_effective_tiers(canonical_request.session_key) last_idx = len(effective_tiers) - 1 @@ -564,10 +589,16 @@ async def execute_message( failed_tier_name: str | None = None request_caps = build_request_capabilities(body) canonical_request = build_canonical_request(body, headers) - session_record = await self._session_mgr.get_or_create_record( + session_record, is_new_session = await self._session_mgr.get_or_create_record( canonical_request.session_key, canonical_request.trace_id, ) + if is_new_session: + title = _extract_session_title(canonical_request) + if title: + await self._recorder.set_session_title( + canonical_request.session_key, title + ) incompatible_reasons: list[str] = [] effective_tiers = self._resolve_effective_tiers(canonical_request.session_key) last_idx = len(effective_tiers) - 1 diff --git a/src/coding/proxy/routing/session_manager.py b/src/coding/proxy/routing/session_manager.py index 845ac87..aaef0ba 100644 --- a/src/coding/proxy/routing/session_manager.py +++ b/src/coding/proxy/routing/session_manager.py @@ -19,13 +19,18 @@ def __init__(self, compat_session_store: CompatSessionStore | None = None) -> No async def get_or_create_record( self, session_key: str, trace_id: str - ) -> CompatSessionRecord | None: + ) -> tuple[CompatSessionRecord | None, bool]: + """获取或创建兼容性会话记录. + + Returns: + (record, is_new) — is_new 为 True 表示本次创建的新会话。 + """ if self._store is None: - return None + return None, False record = await self._store.get(session_key) if record is not None: - return record - return CompatSessionRecord(session_key=session_key, trace_id=trace_id) + return record, False + return CompatSessionRecord(session_key=session_key, trace_id=trace_id), True def apply_compat_context( self, diff --git a/src/coding/proxy/routing/usage_recorder.py b/src/coding/proxy/routing/usage_recorder.py index 525a6c1..8887c09 100644 --- a/src/coding/proxy/routing/usage_recorder.py +++ b/src/coding/proxy/routing/usage_recorder.py @@ -28,6 +28,11 @@ def __init__( def set_pricing_table(self, table: PricingTable) -> None: self._pricing_table = table + async def set_session_title(self, session_key: str, title: str) -> None: + """为新 session 设置标题(委托给 TokenLogger).""" + if self._token_logger: + await self._token_logger.set_session_title(session_key, title) + # ── 用量信息构建 ────────────────────────────────────── @staticmethod diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 07bd6a3..54533e6 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -411,6 +411,7 @@ def _build_favicon() -> bytes: .session-table td.cell-tags { white-space: normal; overflow: visible; text-overflow: clip; line-height: 1.8; vertical-align: middle; } .session-table tr:hover td { background: var(--bg-card-hover); } .session-table .session-key { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--accent-blue); cursor: default; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .session-table .session-title { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 0; } .session-id { display: flex; align-items: center; gap: 4px; } .session-id-text { overflow: hidden; text-overflow: ellipsis; } .copy-btn { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; border-radius: 4px; font-size: 12px; line-height: 1; opacity: .5; flex-shrink: 0; } @@ -676,20 +677,22 @@ def _build_favicon() -> bytes:
- - + + + + + + - - - - - - + + + + @@ -702,7 +705,7 @@ def _build_favicon() -> bytes: - +
Session IDTitle Last Active Requests Tokens
Loading...
Loading...
@@ -1573,7 +1576,7 @@ def _build_favicon() -> bytes: var tbody = document.getElementById('sessions-tbody'); if (!total) { - tbody.innerHTML = '
📭
No session data'; + tbody.innerHTML = '
📭
No session data'; } else { tbody.innerHTML = page.map(function(s) { var parsed = parseSessionKey(s.session_key); @@ -1582,6 +1585,7 @@ def _build_favicon() -> bytes: var modelsFull = (s.models || '').split(',').map(function(c){return c.trim();}); var vendorsFull = (s.vendors || '').split(',').map(function(v){return formatVendorLabel(v.trim());}); var sr = s.success_rate != null ? Math.round(s.success_rate) : null; + var sessionTitle = s.title || ''; return '' + '' + '
' + @@ -1592,6 +1596,7 @@ def _build_favicon() -> bytes: 'dev:' + escapeHtml(shortId(parsed.device_id, 8)) + ' · acct:' + escapeHtml(shortId(parsed.account_uuid, 8)) + '
' + '' + + '' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '' + '' + relativeTime(s.last_active_ts) + '' + '' + fmtNum(s.total_requests) + '' + '' + fmtTokens(s.total_tokens) + '' + @@ -1602,9 +1607,10 @@ def _build_favicon() -> bytes: '' + selectHtml + '' + '' + formatCategories(s.client_categories) + '' + '' + - '
' + + '
' + '
' + '
Session ID
' + escapeHtml(parsed.session_id || s.session_key) + '
' + + '
Title
' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '
' + '
Device
' + (parsed.device_id ? escapeHtml(parsed.device_id) : '–') + '
' + '
Account
' + (parsed.account_uuid ? escapeHtml(parsed.account_uuid) : '–') + '
' + '
' + diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 1e40ea6..fb4bf22 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -222,7 +222,7 @@ async def test_eligible_when_all_checks_pass(self): headers = {} caps = RequestCapabilities() req = build_canonical_request(body, headers) - session_record = await exec_inst._session_mgr.get_or_create_record( + session_record, _is_new = await exec_inst._session_mgr.get_or_create_record( req.session_key, req.trace_id ) reasons: list[str] = [] @@ -651,9 +651,10 @@ class TestRouteSessionManagerIntegration: @pytest.mark.asyncio async def test_get_or_create_without_store(self): mgr = RouteSessionManager(compat_session_store=None) - record = await mgr.get_or_create_record("sk_test", "trace_1") - # 无 store 时返回 None(由 executor 层面处理空 record 场景) + record, is_new = await mgr.get_or_create_record("sk_test", "trace_1") + # 无 store 时返回 (None, False) assert record is None + assert is_new is False @pytest.mark.asyncio async def test_persist_session_without_store_is_noop(self): From 7fda903f3cdff31f939bc65ddecbe52a8a1a453d Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 25 May 2026 11:21:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(test):=20=E8=A1=A5=E5=85=A8=20get=5For?= =?UTF-8?q?=5Fcreate=5Frecord=20=E8=BF=94=E5=9B=9E=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E9=81=97=E6=BC=8F=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E7=82=B9;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- tests/test_router_executor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index fb4bf22..dc37939 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -246,7 +246,7 @@ async def test_skip_when_capability_unsupported(self): body = {"model": "test"} headers = {} req = build_canonical_request(body, headers) - session_record = await exec_inst._session_mgr.get_or_create_record( + session_record, _is_new = await exec_inst._session_mgr.get_or_create_record( req.session_key, req.trace_id ) reasons: list[str] = [] @@ -275,7 +275,7 @@ async def test_skip_when_unsafe_compatibility(self): body = {"model": "test", "thinking": {"type": "enabled"}} headers = {} req = build_canonical_request(body, headers) - session_record = await exec_inst._session_mgr.get_or_create_record( + session_record, _is_new = await exec_inst._session_mgr.get_or_create_record( req.session_key, req.trace_id ) reasons: list[str] = []