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:
| Session ID | +Title | Last Active | Requests | Tokens | @@ -702,7 +705,7 @@ def _build_favicon() -> bytes:||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Loading... | ||||||||||
| Loading... | ||||||||||