From 47cd4b5bd275fe3e936a60d8404fab4be4a7a8ed Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 27 Jul 2026 21:50:25 +0800 Subject: [PATCH] feat: add API access remarks functionality and validation across the application --- src-tauri/src/main.rs | 174 +++++++++++++++++++++++++++++++++ src-tauri/src/usage.rs | 91 +++++++++++++++-- src/i18n/ja.ts | 4 + src/i18n/resources.ts | 8 ++ src/pages/ApiAccessPage.tsx | 69 ++++++++++++- src/pages/UsageRecordsPage.tsx | 3 +- 6 files changed, 337 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2433a59..f88639b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::sync::Arc; use std::{ + collections::HashSet, env, fs, fs::File, io::{self, Read, Seek, SeekFrom, Write}, @@ -436,6 +437,7 @@ struct GuiConfigFile { auth_dir: String, #[serde(deserialize_with = "deserialize_gui_api_keys")] api_keys: Vec, + api_access_remarks: Vec, management_secret_key: String, usage_statistics_enabled: bool, plugins_enabled: bool, @@ -460,6 +462,30 @@ struct GuiApiKeyEntry { remark: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct GuiApiAccessRemark { + provider_section: String, + api_key_hash: String, + remark: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiAccessRemarkQuery { + provider_section: String, + api_keys: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiAccessRemarkUpdate { + provider_section: String, + previous_api_keys: Vec, + api_keys: Vec, + remark: String, +} + #[derive(Deserialize)] #[serde(untagged)] enum GuiApiKeyInput { @@ -500,6 +526,7 @@ impl Default for GuiConfigFile { .map(|path| path_to_string(&path)) .unwrap_or_else(|| OAUTH_DIR_NAME.to_string()), api_keys: vec![default_api_key_entry()], + api_access_remarks: Vec::new(), // Keep plaintext here for management API auth. Core hashes the // value written into config.yaml on startup. management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(), @@ -523,6 +550,7 @@ struct GuiConfigPresence { host: Option, auth_dir: Option, api_keys: Option>, + api_access_remarks: Option>, management_secret_key: Option, codex_session_repair_on_launch: Option, usage_statistics_enabled: Option, @@ -1391,6 +1419,75 @@ fn get_gui_settings( Ok(GuiSettings::from(&config)) } +#[tauri::command] +fn resolve_api_access_remarks( + queries: Vec, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + queries + .into_iter() + .map(|query| { + validate_api_access_provider_section(&query.provider_section)?; + Ok(query + .api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .find_map(|hash| { + config.api_access_remarks.iter().find(|entry| { + entry.provider_section == query.provider_section + && entry.api_key_hash == hash + }) + }) + .map(|entry| entry.remark.clone()) + .unwrap_or_default()) + }) + .collect() +} + +#[tauri::command] +fn save_api_access_remark( + update: ApiAccessRemarkUpdate, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result<(), String> { + validate_api_access_provider_section(&update.provider_section)?; + let remark = update.remark.trim().to_string(); + validate_api_key_remark(&remark)?; + let previous_hashes = update + .previous_api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .collect::>(); + let next_hashes = update + .api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .collect::>(); + let hashes_to_replace = previous_hashes + .union(&next_hashes) + .cloned() + .collect::>(); + let provider_section = update.provider_section; + + gui_config_state.update(|config| { + config.api_access_remarks.retain(|entry| { + entry.provider_section != provider_section + || !hashes_to_replace.contains(&entry.api_key_hash) + }); + if !remark.is_empty() { + config + .api_access_remarks + .extend(next_hashes.iter().map(|hash| GuiApiAccessRemark { + provider_section: provider_section.clone(), + api_key_hash: hash.clone(), + remark: remark.clone(), + })); + } + Ok(()) + })?; + Ok(()) +} + #[tauri::command] fn set_app_locale( app: tauri::AppHandle, @@ -10328,6 +10425,52 @@ fn validate_api_key_remark(remark: &str) -> Result<(), String> { Ok(()) } +fn validate_api_access_provider_section(section: &str) -> Result<(), String> { + if matches!( + section, + "gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility" + ) { + Ok(()) + } else { + Err("API 接入类型无效".to_string()) + } +} + +fn api_access_key_hash(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| sha256_bytes(value.as_bytes())) +} + +fn usage_provider_section(provider: &str) -> Option<&'static str> { + match provider.trim().to_ascii_lowercase().as_str() { + "codex" => Some("codex-api-key"), + "claude" => Some("claude-api-key"), + "gemini" | "aistudio" => Some("gemini-api-key"), + "openai" | "openai-compatibility" => Some("openai-compatibility"), + _ => None, + } +} + +impl GuiConfigFile { + fn api_access_remark_for_source(&self, provider: &str, source: &str) -> Option<&str> { + let hash = api_access_key_hash(source)?; + let preferred_section = usage_provider_section(provider); + self.api_access_remarks + .iter() + .find(|entry| { + preferred_section == Some(entry.provider_section.as_str()) + && entry.api_key_hash == hash + && !entry.remark.is_empty() + }) + .or_else(|| { + self.api_access_remarks + .iter() + .find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty()) + }) + .map(|entry| entry.remark.as_str()) + } +} + fn validate_management_secret_key(secret_key: &str) -> Result<(), String> { if secret_key.chars().count() > 512 { return Err("管理密钥不能超过 512 个字符".to_string()); @@ -11184,6 +11327,22 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul api_keys.push(Value::InlineTable(table)); } set_codex_table_item(root, "api-keys", Item::Value(Value::Array(api_keys))); + let mut api_access_remarks = Array::new(); + for entry in &config.api_access_remarks { + let mut table = InlineTable::new(); + table.insert( + "provider-section", + Value::from(entry.provider_section.as_str()), + ); + table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str())); + table.insert("remark", Value::from(entry.remark.as_str())); + api_access_remarks.push(Value::InlineTable(table)); + } + set_codex_table_item( + root, + "api-access-remarks", + Item::Value(Value::Array(api_access_remarks)), + ); let content = document.to_string(); toml::from_str::(&content) @@ -11208,6 +11367,18 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { validate_core_api_key(&entry.key)?; validate_api_key_remark(&entry.remark)?; } + for entry in &config.api_access_remarks { + validate_api_access_provider_section(&entry.provider_section)?; + if entry.api_key_hash.len() != 64 + || !entry + .api_key_hash + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("API 接入备注的密钥指纹无效".to_string()); + } + validate_api_key_remark(&entry.remark)?; + } validate_management_secret_key(&config.management_secret_key)?; validate_routing_strategy(config.routing_strategy.trim())?; if config.proxy_url.chars().any(char::is_control) { @@ -13397,6 +13568,8 @@ fn main() { detect_core_platform, get_core_status, get_gui_settings, + resolve_api_access_remarks, + save_api_access_remark, set_app_locale, resolve_windows_close_request, get_agent_config_statuses, @@ -15768,6 +15941,7 @@ custom_option = "keep-original" remark: "测试密钥".to_string(), }, ], + api_access_remarks: Vec::new(), management_secret_key: String::new(), usage_statistics_enabled: false, plugins_enabled: true, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 2bf9b18..b99e902 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -98,6 +98,8 @@ pub(crate) struct UsageRecord { #[serde(default)] source: String, #[serde(default)] + source_display: String, + #[serde(default)] auth_index: String, #[serde(default)] failed: bool, @@ -769,6 +771,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result { +pub(crate) fn get_usage_analysis( + query: UsageQuery, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { let connection = open_usage_database()?; - load_usage_analysis(&connection, &query) + let config = gui_config_state.snapshot()?; + load_usage_analysis(&connection, &query, &config) } fn load_usage_analysis( connection: &Connection, query: &UsageQuery, + config: &GuiConfigFile, ) -> Result { Ok(UsageAnalysis { models: load_simple_categories(connection, query, "model", "unknown")?, providers: load_simple_categories(connection, query, "provider", "未知 Provider")?, - sources: load_simple_categories(connection, query, "source", "未知来源")?, + sources: load_source_categories(connection, query, config)?, api_keys: load_api_key_categories(connection, query)?, }) } +fn load_source_categories( + connection: &Connection, + query: &UsageQuery, + config: &GuiConfigFile, +) -> Result, String> { + let mut categories = load_simple_categories(connection, query, "source", "未知来源")?; + for category in &mut categories { + category.label = usage_source_display(config, "", &category.key); + } + Ok(categories) +} + fn load_simple_categories( connection: &Connection, query: &UsageQuery, @@ -1073,14 +1093,19 @@ fn load_api_key_categories( } #[tauri::command] -pub(crate) fn get_usage_events(query: UsageQuery) -> Result { +pub(crate) fn get_usage_events( + query: UsageQuery, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { let connection = open_usage_database()?; - load_usage_events(&connection, &query) + let config = gui_config_state.snapshot()?; + load_usage_events(&connection, &query, &config) } fn load_usage_events( connection: &Connection, query: &UsageQuery, + config: &GuiConfigFile, ) -> Result { let filter = build_usage_filter(query); let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause); @@ -1117,11 +1142,14 @@ fn load_usage_events( let mut statement = connection .prepare(&sql) .map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?; - let items = statement + let mut items = statement .query_map(params_from_iter(values.iter()), usage_record_from_row) .map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))? .collect::, _>>() .map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?; + for item in &mut items { + item.source_display = usage_source_display(config, &item.provider, &item.source); + } Ok(UsageEventPage { items, total, @@ -1138,6 +1166,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result { latency_ms: from_sql_i64(row.get(2)?), ttft_ms: row.get::<_, Option>(3)?.map(from_sql_i64), source: row.get(4)?, + source_display: String::new(), auth_index: row.get(5)?, failed: row.get::<_, i64>(6)? != 0, provider: row.get(7)?, @@ -1296,6 +1325,28 @@ fn mask_api_key(value: &str) -> String { format!("{start}••••{end}") } +fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String { + let source = source.trim(); + if source.is_empty() { + return "未知来源".to_string(); + } + if let Some(remark) = config.api_access_remark_for_source(provider, source) { + return remark.to_string(); + } + let character_count = source.chars().count(); + let looks_like_secret = !source.contains('@') + && !source.chars().any(char::is_whitespace) + && (source.starts_with("sk-") + || source.starts_with("AIza") + || source.starts_with("key-") + || character_count >= 48); + if looks_like_secret { + mask_api_key(source) + } else { + source.to_string() + } +} + #[cfg(test)] mod tests { use super::*; @@ -1331,6 +1382,7 @@ mod tests { latency_ms: 100, ttft_ms: Some(20), source: "source".to_string(), + source_display: String::new(), auth_index: "auth".to_string(), failed: false, provider: "openai".to_string(), @@ -1363,6 +1415,27 @@ mod tests { assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890"); } + #[test] + fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() { + let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz"; + let mut config = GuiConfigFile::default(); + config.api_access_remarks.push(crate::GuiApiAccessRemark { + provider_section: "codex-api-key".to_string(), + api_key_hash: hash_text(key), + remark: "生产环境".to_string(), + }); + + assert_eq!(usage_source_display(&config, "codex", key), "生产环境"); + assert_eq!( + usage_source_display(&GuiConfigFile::default(), "codex", key), + "sk-1••••wxyz" + ); + assert_eq!( + usage_source_display(&config, "codex", "account@example.com"), + "account@example.com" + ); + } + #[test] fn local_hour_key_uses_year_month_day_and_hour() { let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model"); @@ -1381,6 +1454,7 @@ mod tests { window_height: None, auth_dir: String::new(), api_keys: Vec::new(), + api_access_remarks: Vec::new(), management_secret_key: "123456".to_string(), usage_statistics_enabled: true, plugins_enabled: false, @@ -1522,8 +1596,9 @@ mod tests { }; let overview = load_usage_overview(&connection, &query).unwrap(); - let analysis = load_usage_analysis(&connection, &query).unwrap(); - let events = load_usage_events(&connection, &query).unwrap(); + let config = GuiConfigFile::default(); + let analysis = load_usage_analysis(&connection, &query, &config).unwrap(); + let events = load_usage_events(&connection, &query, &config).unwrap(); assert_eq!(overview.total_requests, 1); assert_eq!(overview.failure_count, 1); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 27c944d..f528ce5 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -529,6 +529,7 @@ export const jaOverrides = { 'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください', 'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください', 'apiAccess.error.duplicate': '同じ接続設定がすでに存在します', + 'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください', 'apiAccess.notice.updated': '接続を更新しました', 'apiAccess.notice.added': '接続を追加しました', 'apiAccess.deleteConfirm': '「{name}」を削除しますか?', @@ -547,6 +548,7 @@ export const jaOverrides = { 'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください', 'apiAccess.keys.count': 'キー {count} 件', 'apiAccess.keys.summary': '{key} · 合計 {count} 件', + 'apiAccess.remarkValue': 'メモ:{remark}', 'apiAccess.defaultUrl': '既定のアドレスを使用', 'apiAccess.models.summary': 'モデル {count} 件 · {models}', 'apiAccess.priorityValue': '優先度 {priority}', @@ -572,6 +574,8 @@ export const jaOverrides = { 'apiAccess.field.name': '名前', 'apiAccess.field.keysMany': 'API キー(1 行に 1 件)', 'apiAccess.field.key': 'API キー', + 'apiAccess.field.remark': 'メモ', + 'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント', 'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用', 'apiAccess.preset.title': 'OpenAI 互換プリセット', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index db97f6a..e510e89 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -532,6 +532,7 @@ export const zhCN = { 'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥', 'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试', 'apiAccess.error.duplicate': '相同的接入配置已经存在', + 'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符', 'apiAccess.notice.updated': '接入已更新', 'apiAccess.notice.added': '接入已新增', 'apiAccess.deleteConfirm': '确定删除“{name}”吗?', @@ -550,6 +551,7 @@ export const zhCN = { 'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入', 'apiAccess.keys.count': '{count} 个密钥', 'apiAccess.keys.summary': '{key} · 共 {count} 个密钥', + 'apiAccess.remarkValue': '备注:{remark}', 'apiAccess.defaultUrl': '使用默认地址', 'apiAccess.models.summary': '模型 {count} 个 · {models}', 'apiAccess.priorityValue': '优先级 {priority}', @@ -575,6 +577,8 @@ export const zhCN = { 'apiAccess.field.name': '名称', 'apiAccess.field.keysMany': 'API 密钥(每行一个)', 'apiAccess.field.key': 'API 密钥', + 'apiAccess.field.remark': '备注', + 'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号', 'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址', 'apiAccess.preset.title': 'OpenAI 兼容预设', @@ -1344,6 +1348,7 @@ export const en: Record = { 'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.', 'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.', 'apiAccess.error.duplicate': 'An identical connection already exists', + 'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters', 'apiAccess.notice.updated': 'Connection updated', 'apiAccess.notice.added': 'Connection added', 'apiAccess.deleteConfirm': 'Delete “{name}”?', @@ -1362,6 +1367,7 @@ export const en: Record = { 'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection', 'apiAccess.keys.count': '{count} keys', 'apiAccess.keys.summary': '{key} · {count} keys total', + 'apiAccess.remarkValue': 'Remark: {remark}', 'apiAccess.defaultUrl': 'Using default address', 'apiAccess.models.summary': '{count} models · {models}', 'apiAccess.priorityValue': 'Priority {priority}', @@ -1387,6 +1393,8 @@ export const en: Record = { 'apiAccess.field.name': 'Name', 'apiAccess.field.keysMany': 'API Keys (one per line)', 'apiAccess.field.key': 'API Key', + 'apiAccess.field.remark': 'Remark', + 'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account', 'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com', 'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address', 'apiAccess.preset.title': 'OpenAI-Compatible Preset', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 96163e4..d09c9d9 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,4 +1,5 @@ import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { Check, Edit3, @@ -67,11 +68,13 @@ type ProviderRow = { disabled: boolean; priority: number | null; authIndex: string; + remark: string; }; export type ProviderDraft = { name: string; apiKey: string; + remark: string; baseUrl: string; priority: string; models: ModelOption[]; @@ -182,9 +185,13 @@ const rowFromRecord = ( authIndex: entry ? readString(entry, 'auth-index', 'authIndex') : readString(record, 'auth-index', 'authIndex'), + remark: '', }; }; +const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) => + `${section}\u0000${apiKeys.join('\u0000')}`; + export const stripResponseFields = (record: Record) => { const next = { ...record }; delete next['auth-index']; @@ -316,6 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => { const draftFromRow = (row: ProviderRow): ProviderDraft => ({ name: row.name, apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey, + remark: row.remark, baseUrl: row.baseUrl, priority: row.priority === null ? '' : String(row.priority), models: row.models, @@ -351,6 +359,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => ({ const emptyProviderDraft = (): ProviderDraft => ({ name: '', apiKey: '', + remark: '', baseUrl: '', priority: '', models: [], @@ -604,6 +613,7 @@ export function ApiAccessPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingRow, setEditingRow] = useState(null); const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); + const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); const activeDefinition = definitionFor(activeCategory); const activeSection = activeDefinition.section; @@ -647,20 +657,51 @@ export function ApiAccessPage() { void loadProviders(); }, [loadProviders]); + useEffect(() => { + const providerRows = (Object.entries(records) as [ProviderSection, Record[]][]) + .flatMap(([section, items]) => items.map((record, index) => rowFromRecord(section, record, index))); + if (providerRows.length === 0) { + setApiAccessRemarks({}); + return; + } + let disposed = false; + void invoke('resolve_api_access_remarks', { + queries: providerRows.map((row) => ({ + providerSection: row.section, + apiKeys: row.apiKeys, + })), + }).then((remarks) => { + if (disposed) return; + setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [ + providerRemarkIdentity(row.section, row.apiKeys), + remarks[index] ?? '', + ]))); + }).catch(() => { + if (!disposed) setApiAccessRemarks({}); + }); + return () => { + disposed = true; + }; + }, [records]); + const rows = useMemo( () => records[activeSection] .map((record, index) => rowFromRecord(activeSection, record, index)) + .map((row) => ({ + ...row, + remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '', + })) .filter((row) => providerCategoryMatchesRecord(activeCategory, row.record)) .filter((row) => { const query = filter.trim().toLowerCase(); if (!query) return true; - return [row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')] + return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')] .join(' ') .toLowerCase() .includes(query); }), - [activeCategory, activeSection, filter, records], + [activeCategory, activeSection, apiAccessRemarks, filter, records], ); const openCreate = () => { @@ -700,6 +741,10 @@ export function ApiAccessPage() { ); return false; } + if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) { + setError(t('apiAccess.error.remarkInvalid')); + return false; + } let baseUrl = preparedDraft.baseUrl.trim(); let providerHeaders: Record = {}; try { @@ -762,6 +807,14 @@ export function ApiAccessPage() { } await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields)); + await invoke('save_api_access_remark', { + update: { + providerSection: activeSection, + previousApiKeys: editingRow?.apiKeys ?? [], + apiKeys: parsedApiKeys, + remark: draftToSave.remark, + }, + }); setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added')); await loadProviders(); return true; @@ -785,6 +838,14 @@ export function ApiAccessPage() { query: { 'api-key': row.apiKey, 'base-url': row.baseUrl }, }); } + await invoke('save_api_access_remark', { + update: { + providerSection: row.section, + previousApiKeys: row.apiKeys, + apiKeys: [], + remark: '', + }, + }); setNotice(t('apiAccess.notice.deleted')); await loadProviders(); } catch (requestError) { @@ -915,6 +976,7 @@ export function ApiAccessPage() { ? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length }) : maskSecret(row.apiKey)} + {row.remark ? {t('apiAccess.remarkValue', { remark: row.remark })} : null} {row.baseUrl || t('apiAccess.defaultUrl')} {row.models.length > 0 ? model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })} : null} @@ -1016,7 +1078,7 @@ function ApiProviderDialog({ && visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase())); const updateTextField = ( - field: 'name' | 'apiKey' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', + field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { setDraft((current) => ({ ...current, [field]: value })); @@ -1189,6 +1251,7 @@ function ApiProviderDialog({ updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." /> )} + {activeCategory === 'deepseek' ? (
diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index b18e7c6..bc1590e 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -60,6 +60,7 @@ type UsageRecord = { latency_ms: number; ttft_ms: number | null; source: string; + source_display: string; failed: boolean; provider: string; model: string; @@ -384,7 +385,7 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string; function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) { const { t } = useI18n(); - return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ?
{events.items.map((record) => )}
{t('usage.column.time')}{t('usage.column.model')}Provider{t('usage.column.source')}API Key{t('usage.column.result')}{t('usage.column.latency')}TTFT{t('usage.column.input')}{t('usage.column.output')}{t('usage.column.reasoning')}{t('usage.column.cache')}{t('usage.column.total')}
{formatTime(record.timestamp)}{record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null}{record.provider || '—'}{record.source || '—'}{record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'}{record.failed ? t('usage.result.failed') : t('usage.result.success')}{record.latency_ms} ms{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}{compactNumber(record.tokens.input_tokens)}{compactNumber(record.tokens.output_tokens)}{compactNumber(record.tokens.reasoning_tokens)}{compactNumber(record.tokens.cache_read_tokens)}{compactNumber(record.tokens.total_tokens)}
: }
; + return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ?
{events.items.map((record) => )}
{t('usage.column.time')}{t('usage.column.model')}Provider{t('usage.column.source')}API Key{t('usage.column.result')}{t('usage.column.latency')}TTFT{t('usage.column.input')}{t('usage.column.output')}{t('usage.column.reasoning')}{t('usage.column.cache')}{t('usage.column.total')}
{formatTime(record.timestamp)}{record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null}{record.provider || '—'}{record.source_display || '—'}{record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'}{record.failed ? t('usage.result.failed') : t('usage.result.success')}{record.latency_ms} ms{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}{compactNumber(record.tokens.input_tokens)}{compactNumber(record.tokens.output_tokens)}{compactNumber(record.tokens.reasoning_tokens)}{compactNumber(record.tokens.cache_read_tokens)}{compactNumber(record.tokens.total_tokens)}
: }
; } function UsageEmpty() {