diff --git a/.gitignore b/.gitignore index c5b9f6cf..2f9babfb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ cpa-core/ /portable-app.json agent_docs/ +.codex/ *.log *.tmp diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 8bdb87e9..b6f09577 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -1,5 +1,111 @@ use super::*; +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentTerminalOption { + pub(crate) id: String, + pub(crate) label: String, +} + +fn terminal_option(id: &str, label: &str) -> AgentTerminalOption { + AgentTerminalOption { + id: id.to_string(), + label: label.to_string(), + } +} + +fn program_on_path(program: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path).find_map(|directory| { + let candidate = directory.join(program); + candidate.is_file().then_some(candidate) + }) +} + +#[cfg(target_os = "macos")] +fn macos_iterm2_installed() -> bool { + Path::new("/Applications/iTerm.app").is_dir() + || env::var_os("HOME") + .map(PathBuf::from) + .is_some_and(|home| home.join("Applications/iTerm.app").is_dir()) +} + +#[cfg(target_os = "linux")] +fn linux_terminal_definitions() -> &'static [( + &'static str, + &'static str, + &'static [&'static str], + &'static str, +)] { + &[ + ( + "x-terminal-emulator", + "x-terminal-emulator", + &["-e"], + "System terminal", + ), + ( + "gnome-terminal", + "gnome-terminal", + &["--"], + "GNOME Terminal", + ), + ("konsole", "konsole", &["-e"], "Konsole"), + ("xfce4-terminal", "xfce4-terminal", &["-e"], "Xfce Terminal"), + ("mate-terminal", "mate-terminal", &["--"], "MATE Terminal"), + ("kitty", "kitty", &["-e"], "Kitty"), + ("alacritty", "alacritty", &["-e"], "Alacritty"), + ("ghostty", "ghostty", &["-e"], "Ghostty"), + ("xterm", "xterm", &["-e"], "XTerm"), + ] +} + +pub(crate) fn available_agent_terminals() -> Vec { + let mut options = vec![terminal_option("auto", "Automatic")]; + #[cfg(target_os = "macos")] + { + options.push(terminal_option("terminal", "Terminal")); + if macos_iterm2_installed() { + options.push(terminal_option("iterm2", "iTerm2")); + } + } + #[cfg(target_os = "windows")] + { + if program_on_path("wt.exe").is_some() { + options.push(terminal_option("windows-terminal", "Windows Terminal")); + } + let powershell = windows_powershell_executable(); + if powershell.is_file() { + options.push(terminal_option("powershell", "PowerShell")); + } + let command_prompt = windows_command_processor(); + if command_prompt.is_file() { + options.push(terminal_option("cmd", "Command Prompt")); + } + } + #[cfg(target_os = "linux")] + { + for (id, program, _, label) in linux_terminal_definitions() { + if program_on_path(program).is_some() { + options.push(terminal_option(id, label)); + } + } + } + options +} + +pub(crate) fn normalize_agent_terminal(value: &str) -> String { + let value = value.trim(); + if available_agent_terminals() + .iter() + .any(|option| option.id == value) + { + value.to_string() + } else { + DEFAULT_AGENT_TERMINAL.to_string() + } +} + #[tauri::command] pub(crate) fn launch_agent( app: tauri::AppHandle, @@ -13,6 +119,7 @@ pub(crate) fn launch_agent( .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let config = gui_config_state.snapshot()?; + let terminal = normalize_agent_terminal(&config.default_terminal); let requested_target = target .as_deref() .map(str::trim) @@ -30,7 +137,13 @@ pub(crate) fn launch_agent( let executable = find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?; let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?; - return launch_cli_agent(&executable, PI_AGENT_NAME, &launch_directory, &[]); + return launch_cli_agent( + &executable, + PI_AGENT_NAME, + &launch_directory, + &[], + &terminal, + ); } let client = AgentClient::parse(&client)?; @@ -73,6 +186,7 @@ pub(crate) fn launch_agent( client.name(), &launch_directory, environment_to_remove, + &terminal, ) } (_, "app") => Err(format!("{} 不支持桌面 App 启动方式", client.name())), @@ -602,6 +716,7 @@ fn launch_cli_agent( label: &str, working_directory: &Path, environment_to_remove: &[&str], + terminal: &str, ) -> Result<(), String> { let removals = environment_to_remove .iter() @@ -614,10 +729,19 @@ fn launch_cli_agent( removals, shell_single_quote(&path_to_string(executable)), ); - let script = format!( - "tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell", - command_line.replace('\\', "\\\\").replace('"', "\\\"") - ); + let script = if terminal == "iterm2" { + format!( + "tell application \"iTerm2\"\nactivate\nset newWindow to (create window with default profile)\ntell current session of newWindow\nwrite text \"{}\"\nend tell\nend tell", + command_line.replace('\\', "\\\\").replace('"', "\\\"") + ) + } else if matches!(terminal, "auto" | "terminal") { + format!( + "tell application \"Terminal\"\nactivate\ndo script \"{}\"\nend tell", + command_line.replace('\\', "\\\\").replace('"', "\\\"") + ) + } else { + return Err(format!("启动 {label} 失败:不支持所选终端")); + }; let output = Command::new("osascript") .arg("-e") .arg(script) @@ -637,27 +761,21 @@ fn launch_cli_agent( label: &str, working_directory: &Path, environment_to_remove: &[&str], + terminal: &str, ) -> Result<(), String> { - let terminals: &[(&str, &[&str])] = &[ - ("x-terminal-emulator", &["-e"]), - ("gnome-terminal", &["--"]), - ("konsole", &["-e"]), - ("xfce4-terminal", &["-e"]), - ("mate-terminal", &["--"]), - ("kitty", &["-e"]), - ("alacritty", &["-e"]), - ("ghostty", &["-e"]), - ("xterm", &["-e"]), - ]; - let path = env::var_os("PATH").unwrap_or_default(); + let definitions = linux_terminal_definitions(); let mut last_error = None; - for (terminal, arguments) in terminals { - if !env::split_paths(&path).any(|directory| directory.join(terminal).is_file()) { + for definition in definitions { + let (id, program, arguments, _) = *definition; + if terminal != "auto" && id != terminal { + continue; + } + if program_on_path(program).is_none() { continue; } - let mut command = Command::new(terminal); + let mut command = Command::new(program); command - .args(*arguments) + .args(arguments) .arg(executable) .current_dir(working_directory) .stdin(Stdio::null()) @@ -671,6 +789,9 @@ fn launch_cli_agent( Err(error) => last_error = Some(error.to_string()), } } + if terminal != "auto" && last_error.is_none() { + return Err(format!("启动 {label} 失败:未找到所选终端")); + } Err(match last_error { Some(error) => format!("启动 {label} 失败: {error}"), None => format!("启动 {label} 失败:未找到可用的终端程序"), @@ -683,14 +804,66 @@ fn launch_cli_agent( label: &str, working_directory: &Path, environment_to_remove: &[&str], + terminal: &str, ) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; - let mut command = windows_command_for_executable(executable, true); - command - .current_dir(working_directory) - .creation_flags(CREATE_NEW_CONSOLE); + let is_batch_script = executable + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") + }); + + let mut command = match terminal { + "auto" => { + let mut command = windows_command_for_executable(executable, true); + command + .current_dir(working_directory) + .creation_flags(CREATE_NEW_CONSOLE); + command + } + "windows-terminal" => { + let terminal_executable = program_on_path("wt.exe") + .ok_or_else(|| format!("启动 {label} 失败:未找到 Windows Terminal"))?; + let directory = path_to_string(working_directory); + let mut command = Command::new(terminal_executable); + command.args(["-d", &directory, "--"]); + if is_batch_script { + command + .arg(windows_command_processor()) + .args(["/D", "/K", "call"]) + .arg(windows_batch_executable_argument(executable)); + } else { + command.arg(executable); + } + command + } + "powershell" => { + let mut command = Command::new(windows_powershell_executable()); + let script = format!( + "Set-Location -LiteralPath {}; & {}", + windows_powershell_single_quoted_literal(&path_to_string(working_directory)), + windows_powershell_single_quoted_literal(&path_to_string(executable)), + ); + command.args(["-NoLogo", "-NoProfile", "-NoExit", "-Command", &script]); + command.creation_flags(CREATE_NEW_CONSOLE); + command + } + "cmd" => { + let mut command = Command::new(windows_command_processor()); + let command_line = format!( + "cd /d \"{}\" && call \"{}\"", + path_to_string(working_directory).replace('"', "\"\""), + path_to_string(executable).replace('"', "\"\"") + ); + command.args(["/D", "/K", &command_line]); + command.creation_flags(CREATE_NEW_CONSOLE); + command + } + _ => return Err(format!("启动 {label} 失败:不支持所选终端")), + }; for key in environment_to_remove { command.env_remove(key); } @@ -706,6 +879,7 @@ fn launch_cli_agent( label: &str, _working_directory: &Path, _environment_to_remove: &[&str], + _terminal: &str, ) -> Result<(), String> { Err(format!("当前平台不支持启动 {label}")) } @@ -714,6 +888,15 @@ fn launch_cli_agent( mod tests { use super::*; + #[test] + fn unknown_terminal_defaults_to_automatic() { + assert_eq!( + normalize_agent_terminal("missing-terminal"), + DEFAULT_AGENT_TERMINAL + ); + assert_eq!(normalize_agent_terminal(" auto "), "auto"); + } + #[test] fn relative_launch_directory_is_rejected() { let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback")) diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 54edabed..0ce9e36b 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -180,6 +180,8 @@ pub(crate) fn software_settings( autostart_enabled: app_autostart_enabled(app)?, start_core_on_launch: config.start_core_on_launch, silent_start_enabled: config.silent_start, + default_terminal: normalize_agent_terminal(&config.default_terminal), + available_terminals: available_agent_terminals(), }) } @@ -201,6 +203,7 @@ pub(crate) fn save_software_settings( let previous_config = gui_config_state.snapshot()?; let previous_autostart_enabled = app_autostart_enabled(&app)?; let autostart_changed = previous_autostart_enabled != settings.autostart_enabled; + let default_terminal = normalize_agent_terminal(&settings.default_terminal); if autostart_changed { set_app_autostart_enabled(&app, settings.autostart_enabled)?; @@ -209,6 +212,7 @@ pub(crate) fn save_software_settings( let config = if previous_config.close_behavior == settings.close_behavior && previous_config.start_core_on_launch == settings.start_core_on_launch && previous_config.silent_start == settings.silent_start_enabled + && previous_config.default_terminal == default_terminal { previous_config } else { @@ -216,6 +220,7 @@ pub(crate) fn save_software_settings( settings.close_behavior, settings.start_core_on_launch, settings.silent_start_enabled, + default_terminal.clone(), ) { Ok(config) => config, Err(error) => { diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 2fcf2ad6..52272b11 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1237,6 +1237,9 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.silent_start.is_none() { changed = true; } + if presence.default_terminal.is_none() { + changed = true; + } if presence.download_source.is_none() { config.download_source = if config.prefer_gitcode_downloads { VersionDownloadSource::Gitcode @@ -1493,6 +1496,11 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result, window_height: Option, auth_dir: String, @@ -868,6 +870,7 @@ impl Default for GuiConfigFile { start_core_on_launch: true, silent_start: false, close_behavior: WindowsCloseBehavior::Ask, + default_terminal: DEFAULT_AGENT_TERMINAL.to_string(), window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH), window_height: Some(DEFAULT_MAIN_WINDOW_HEIGHT), auth_dir: DEFAULT_AUTH_DIR.to_string(), @@ -906,6 +909,7 @@ struct GuiConfigPresence { api_access_remarks: Option>, management_secret_key: Option, close_behavior: Option, + default_terminal: Option, start_core_on_launch: Option, silent_start: Option, usage_statistics_enabled: Option, @@ -949,6 +953,8 @@ struct SoftwareSettings { autostart_enabled: bool, start_core_on_launch: bool, silent_start_enabled: bool, + default_terminal: String, + available_terminals: Vec, } #[derive(Deserialize)] @@ -958,6 +964,7 @@ struct SoftwareSettingsInput { autostart_enabled: bool, start_core_on_launch: bool, silent_start_enabled: bool, + default_terminal: String, } #[derive(Clone, Serialize)] @@ -1911,11 +1918,13 @@ impl GuiConfigState { close_behavior: WindowsCloseBehavior, start_core_on_launch: bool, silent_start: bool, + default_terminal: String, ) -> Result { self.update(|config| { config.close_behavior = close_behavior; config.start_core_on_launch = start_core_on_launch; config.silent_start = silent_start; + config.default_terminal = normalize_agent_terminal(&default_terminal); Ok(()) }) } diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 745b8cd4..6adba21e 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -84,6 +84,8 @@ fn gui_config_defaults_are_stable() { assert!(content.contains("start-core-on-launch = true")); assert!(content.contains("silent-start = false")); assert!(content.contains("close-behavior = \"ask\"")); + assert!(content.contains("default-terminal = \"auto\"")); + assert_eq!(config.default_terminal, DEFAULT_AGENT_TERMINAL); assert!(content.contains("window-width = 1280")); assert!(content.contains("window-height = 800")); assert!(content.contains("auth-dir = \"../oauth\"")); diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index b8e98f70..cefc7aef 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1023,6 +1023,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { start_core_on_launch: true, silent_start: false, close_behavior: WindowsCloseBehavior::Ask, + default_terminal: DEFAULT_AGENT_TERMINAL.to_string(), window_width: None, window_height: None, auth_dir: path_to_string(&fixed_oauth_dir().unwrap()), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 40e43799..0dc1a5d1 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3763,6 +3763,7 @@ mod tests { start_core_on_launch: true, silent_start: false, close_behavior: crate::WindowsCloseBehavior::Ask, + default_terminal: crate::DEFAULT_AGENT_TERMINAL.to_string(), window_width: None, window_height: None, auth_dir: String::new(), diff --git a/src/App.tsx b/src/App.tsx index e721b0c8..73e259f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,6 @@ const pages = [ id: 'easy', labelKey: 'app.nav.easy', icon: Sparkles, - // The easy page is rendered separately so it can receive navigation callbacks. component: HomePage, }, { @@ -419,7 +418,7 @@ function AppContent() {
{isAlwaysAvailablePage(activePage.id) || coreRunning ? ( activePage.id === 'easy' ? ( - + select('home')} /> ) : ( ) diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 260c2825..0ea53752 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -36,6 +36,9 @@ export const jaOverrides = { 'app.close.exit': 'アプリを終了', 'app.close.remember': 'この選択を記憶', 'easyMode.steps.label': '接続ウィザードの手順', + 'easyMode.steps.current': '{total} ステップ中 {current} ステップ目', + 'easyMode.steps.step1Description': 'アカウントにログインするか API を接続', + 'easyMode.steps.step2Description': 'クライアントとモデルを選んで起動', 'easyMode.overview.coreTitle': '使い方', 'easyMode.overview.providerTitle': 'モデルを接続', 'easyMode.overview.agentTitle': 'エージェントを接続', @@ -61,6 +64,44 @@ export const jaOverrides = { 'easyMode.guide.step3Description': '「利用可能なモデル」一覧からモデルを選び、インストール済みのエージェントクライアントを選択して設定を適用します。', 'easyMode.navigation.back': '前へ', 'easyMode.navigation.next': '次へ', + 'easyMode.status.ready': '準備完了', + 'easyMode.status.connectedModels': '{count} 個の利用可能なモデルを検出しました', + 'easyMode.status.noModelsYet': 'モデルがまだ接続されていません。アカウントにログインするか API を追加してください', + 'easyMode.oauth.loggedIn': 'ログイン済み', + 'easyMode.oauth.relogin': '再ログイン', + 'easyMode.oauth.loggingIn': 'ブラウザでの認証を待機中...', + 'easyMode.api.quickAdd': 'API クイック追加', + 'easyMode.api.testAndSave': 'テストして保存', + 'easyMode.api.testing': '接続テスト中...', + 'easyMode.api.fetchModels': 'モデル一覧を取得', + 'easyMode.api.fetchingModels': 'モデル一覧を取得中...', + 'easyMode.api.modelListTitle': 'モデルを選択', + 'easyMode.api.modelListHint': '接続するモデルを選択してください', + 'easyMode.api.credentialsRequired': '先に API URL と API キーを入力してください', + 'easyMode.api.modelRequired': '先にモデル一覧を取得してモデルを選択してください', + 'easyMode.api.testSuccess': '接続成功!{count} 個のモデルが見つかりました', + 'easyMode.api.testFailed': '接続テストに失敗しました', + 'easyMode.api.platform': 'プラットフォーム', + 'easyMode.api.baseUrl': 'API Base URL', + 'easyMode.api.apiKey': 'API キー (API Key)', + 'easyMode.api.name': '備考名(任意)', + 'easyMode.api.viewExisting': '接続済み一覧を表示 ({count})', + 'easyMode.api.backToQuick': 'クイック追加に戻る', + 'easyMode.api.savedSuccess': 'API 接続を保存しました!', + 'easyMode.scenario.title': '使用方法を選択', + 'easyMode.scenario.agentTab': 'AI エージェントを接続(推奨)', + 'easyMode.scenario.apiTab': 'ローカル API エンドポイントをコピー', + 'easyMode.agent.installedBadge': 'インストール済み', + 'easyMode.agent.notDetected': '未検出', + 'easyMode.agent.chooseModel': '使用するモデルを選択', + 'easyMode.agent.applyConfig': '⚡ 設定を一括適用', + 'easyMode.agent.applySuccess': '設定が正常に適用されました!', + 'easyMode.agent.launchClient': '🚀 クライアント起動 / ターミナルを開く', + 'easyMode.agent.launching': '起動中...', + 'easyMode.complete.title': '設定完了!', + 'easyMode.complete.enterApp': 'ダッシュボードに入る', + 'easyMode.apiCopy.desc': 'ローカルプロキシのアドレスを、OpenAI / Claude / Gemini 互換の任意のツールに直接設定することもできます。', + 'easyMode.apiCopy.copied': 'クリップボードにコピーしました', 'error.render.title': 'ページの描画中にエラーが発生しました', 'error.unknown': '不明なエラー', 'error.reload': 'ページを再読み込み', @@ -234,8 +275,15 @@ export const jaOverrides = { 'usage.analysis.sources': 'リクエスト元', 'usage.analysis.keys': '認証キー', 'usage.analysis.hours': '時間帯分布', - 'usage.analysis.sortedByTokens': 'Token 順', - 'usage.analysis.itemMeta': '{requests} 回 · {percent}% · {tokens} Token', + 'usage.analysis.metricLabel': '分析指標', + 'usage.analysis.metric.tokens': 'Token', + 'usage.analysis.metric.requests': 'リクエスト数', + 'usage.analysis.metric.failureRate': '失敗率', + 'usage.analysis.sortedByMetric': '{metric}順', + 'usage.analysis.failureSamples': '{failures} 件失敗 / {requests} 件', + 'usage.analysis.value.tokens': '{value} Token', + 'usage.analysis.value.requests': '{value} 件', + 'usage.analysis.value.failureRate': '{value}% 失敗', 'usage.events.total': '合計 {count} 件', 'usage.events.page': '{page} / {total} ページ', 'usage.events.resetColumns': '列幅をリセット', @@ -437,6 +485,9 @@ export const jaOverrides = { 'config.software.startCoreOnLaunchDescription': 'EasyCLIProxyAPI の起動時に CPA コアを自動的に開始します', 'config.software.silentStart': 'サイレント起動', 'config.software.silentStartDescription': 'メインウィンドウを表示せず、システムトレイで直接起動します', + 'config.software.defaultTerminal': 'デフォルトターミナル', + 'config.software.defaultTerminalDescription': 'CLI エージェント起動時に使用するターミナルプログラム', + 'config.software.terminal.auto': '自動選択', 'config.software.closeBehavior': '終了動作', 'config.software.closeBehaviorDescription': 'ウィンドウを閉じたときにアプリを終了するか、システムトレイで実行を続けるかを選択します', 'config.software.behavior.ask': '毎回確認', @@ -602,6 +653,8 @@ export const jaOverrides = { 'agents.status.applied': '適用済み', 'agents.status.unconfigured': '未設定', 'agents.title': 'エージェント設定', + 'agents.embedded.title': 'クライアントとモデルを選んで起動', + 'agents.embedded.subtitle': 'ない場合は先にインストールしてください', 'agents.redetect': '再検出', 'agents.tabs.label': 'エージェント設定ページ', 'agents.tabs.core': 'コア設定', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a6c0ba1a..533442fa 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -36,6 +36,9 @@ export const en: Record = { 'app.close.exit': 'Exit App', 'app.close.remember': 'Remember this choice', 'easyMode.steps.label': 'Connection wizard steps', + 'easyMode.steps.current': 'Step {current} of {total}', + 'easyMode.steps.step1Description': 'Sign in or connect an API', + 'easyMode.steps.step2Description': 'Select a client and model, then start', 'easyMode.overview.coreTitle': 'How to use', 'easyMode.overview.providerTitle': 'Connect a model', 'easyMode.overview.agentTitle': 'Connect an agent', @@ -61,6 +64,44 @@ export const en: Record = { 'easyMode.guide.step3Description': 'Choose a model from “Available models”, select an installed agent client, and apply the configuration.', 'easyMode.navigation.back': 'Previous', 'easyMode.navigation.next': 'Next', + 'easyMode.status.ready': 'Ready', + 'easyMode.status.connectedModels': '{count} available models detected', + 'easyMode.status.noModelsYet': 'No models connected yet. Please sign in or add an API key first.', + 'easyMode.oauth.loggedIn': 'Signed In', + 'easyMode.oauth.relogin': 'Re-login', + 'easyMode.oauth.loggingIn': 'Waiting for browser authorization...', + 'easyMode.api.quickAdd': 'Quick Add API', + 'easyMode.api.testAndSave': 'Test & Save', + 'easyMode.api.testing': 'Testing connection...', + 'easyMode.api.fetchModels': 'Fetch model list', + 'easyMode.api.fetchingModels': 'Fetching model list...', + 'easyMode.api.modelListTitle': 'Select models', + 'easyMode.api.modelListHint': 'Select the models to connect', + 'easyMode.api.credentialsRequired': 'Enter an API URL and API key first', + 'easyMode.api.modelRequired': 'Fetch and select a model', + 'easyMode.api.testSuccess': 'Connection successful! Found {count} models', + 'easyMode.api.testFailed': 'Connection test failed', + 'easyMode.api.platform': 'Platform', + 'easyMode.api.baseUrl': 'API Base URL', + 'easyMode.api.apiKey': 'API Key', + 'easyMode.api.name': 'Remark Name (Optional)', + 'easyMode.api.viewExisting': 'View all connected providers ({count})', + 'easyMode.api.backToQuick': 'Back to Quick Add', + 'easyMode.api.savedSuccess': 'API provider saved successfully!', + 'easyMode.scenario.title': 'Choose how to use', + 'easyMode.scenario.agentTab': 'Connect AI Agent (Recommended)', + 'easyMode.scenario.apiTab': 'Copy Local API Endpoints', + 'easyMode.agent.installedBadge': 'Installed on this machine', + 'easyMode.agent.notDetected': 'Not detected', + 'easyMode.agent.chooseModel': 'Select Model', + 'easyMode.agent.applyConfig': '⚡ Apply Configuration', + 'easyMode.agent.applySuccess': 'Configuration applied successfully!', + 'easyMode.agent.launchClient': '🚀 Launch Client / Open Terminal', + 'easyMode.agent.launching': 'Launching...', + 'easyMode.complete.title': 'Setup Complete!', + 'easyMode.complete.enterApp': 'Enter Dashboard', + 'easyMode.apiCopy.desc': 'You can also use the local proxy URL in any OpenAI / Claude / Gemini compatible tool.', + 'easyMode.apiCopy.copied': 'Copied to clipboard', 'error.render.title': 'Something went wrong while rendering this page', 'error.unknown': 'Unknown error', 'error.reload': 'Reload Page', @@ -234,8 +275,15 @@ export const en: Record = { 'usage.analysis.sources': 'Request Sources', 'usage.analysis.keys': 'Authentication Keys', 'usage.analysis.hours': 'Hourly Distribution', - 'usage.analysis.sortedByTokens': 'Sorted by tokens', - 'usage.analysis.itemMeta': '{requests} requests · {percent}% · {tokens} tokens', + 'usage.analysis.metricLabel': 'Analysis metric', + 'usage.analysis.metric.tokens': 'Tokens', + 'usage.analysis.metric.requests': 'Requests', + 'usage.analysis.metric.failureRate': 'Failure rate', + 'usage.analysis.sortedByMetric': 'Sorted by {metric}', + 'usage.analysis.failureSamples': '{failures} failed / {requests} requests', + 'usage.analysis.value.tokens': '{value} tokens', + 'usage.analysis.value.requests': '{value} requests', + 'usage.analysis.value.failureRate': '{value}% failed', 'usage.events.total': '{count} records', 'usage.events.page': 'Page {page} of {total}', 'usage.events.resetColumns': 'Reset Widths', @@ -372,6 +420,9 @@ export const en: Record = { 'config.software.startCoreOnLaunchDescription': 'Automatically start the CPA core whenever EasyCLIProxyAPI launches', 'config.software.silentStart': 'Silent Launch', 'config.software.silentStartDescription': 'Start without showing the main window and run directly in the system tray', + 'config.software.defaultTerminal': 'Default terminal', + 'config.software.defaultTerminalDescription': 'Terminal program used when launching CLI agents', + 'config.software.terminal.auto': 'Automatic', 'config.software.closeBehavior': 'Close behavior', 'config.software.closeBehaviorDescription': 'Choose whether closing the window exits the app or keeps it running in the system tray', 'config.software.behavior.ask': 'Ask every time', @@ -602,6 +653,8 @@ export const en: Record = { 'agents.status.applied': 'Applied', 'agents.status.unconfigured': 'Not Configured', 'agents.title': 'Agent Configuration', + 'agents.embedded.title': 'Select a client and model, then start', + 'agents.embedded.subtitle': 'Install it first if it is not available', 'agents.redetect': 'Detect Again', 'agents.tabs.label': 'Agent configuration sections', 'agents.tabs.core': 'Core Configuration', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 12e6e90a..021edc66 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -34,10 +34,13 @@ export const zhCN = { 'app.close.exit': '退出程序', 'app.close.remember': '记住选项', 'easyMode.steps.label': '接入向导步骤', + 'easyMode.steps.current': '第 {current} 步 / 共 {total} 步', + 'easyMode.steps.step1Description': '先登录账号或接入 API', + 'easyMode.steps.step2Description': '选择客户端和模型后启动', 'easyMode.overview.coreTitle': '使用指导', 'easyMode.overview.providerTitle': '接入模型', 'easyMode.overview.agentTitle': '接入智能体', - 'easyMode.setup.providerDescription': '可以通过 OAuth 登录自己的账号并使用账号额度,也可以通过 API 接入任意平台。', + 'easyMode.setup.providerDescription': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入', 'easyMode.oauth.title': 'OAuth 登录', 'easyMode.oauth.description': '通过浏览器授权登录自己账号,并使用账号的额度。', 'easyMode.oauth.nextHint': '账号登录完成后,点击页面上方的“下一步”继续。', @@ -52,13 +55,51 @@ export const zhCN = { 'easyMode.guide.title': '使用指导', 'easyMode.guide.description': '先确定连接方式,完成模型连接后,再从“可用模型”列表中选择智能体要使用的模型。', 'easyMode.guide.step1Title': '选择连接方式', - 'easyMode.guide.step1Description': '可以通过 OAuth 登录自己的账号并使用账号额度,也可以通过 API 接入任意平台。', + 'easyMode.guide.step1Description': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入', 'easyMode.guide.step2Title': '连接模型', 'easyMode.guide.step2Description': '这里可以通过 OAuth 完成账号授权,也可以填写第三方平台提供的 Base URL 和 API 密钥。保存后,模型会出现在“可用模型”列表中。', 'easyMode.guide.step3Title': '选择模型并接入智能体', 'easyMode.guide.step3Description': '在“可用模型”列表中选定模型,再选择已安装的智能体客户端并应用配置。', 'easyMode.navigation.back': '上一步', 'easyMode.navigation.next': '下一步', + 'easyMode.status.ready': '已就绪', + 'easyMode.status.connectedModels': '已检测到 {count} 个可用模型', + 'easyMode.status.noModelsYet': '尚未连接模型,请先登录账号或添加 API', + 'easyMode.oauth.loggedIn': '已登录', + 'easyMode.oauth.relogin': '重新登录', + 'easyMode.oauth.loggingIn': '等待浏览器授权...', + 'easyMode.api.quickAdd': '快速接入 API', + 'easyMode.api.testAndSave': '测试并保存', + 'easyMode.api.testing': '正在测试连接...', + 'easyMode.api.fetchModels': '获取模型列表', + 'easyMode.api.fetchingModels': '正在获取模型列表...', + 'easyMode.api.modelListTitle': '选择模型', + 'easyMode.api.modelListHint': '勾选需要接入的模型', + 'easyMode.api.credentialsRequired': '请先填写 API URL 和 API 密钥', + 'easyMode.api.modelRequired': '请先获取模型列表并选择模型', + 'easyMode.api.testSuccess': '连接成功!发现 {count} 个模型', + 'easyMode.api.testFailed': '连接测试失败', + 'easyMode.api.platform': '平台类型', + 'easyMode.api.baseUrl': 'API Base URL', + 'easyMode.api.apiKey': 'API 密钥 (API Key)', + 'easyMode.api.name': '备注名称 (选填)', + 'easyMode.api.viewExisting': '查看全部已接入列表 ({count})', + 'easyMode.api.backToQuick': '返回快速接入', + 'easyMode.api.savedSuccess': 'API 接入已保存!', + 'easyMode.scenario.title': '选择你的使用方式', + 'easyMode.scenario.agentTab': '接入 AI 智能体客户端 (推荐)', + 'easyMode.scenario.apiTab': '复制本地 API 接口地址', + 'easyMode.agent.installedBadge': '本机已安装', + 'easyMode.agent.notDetected': '未检测到', + 'easyMode.agent.chooseModel': '选择使用的模型', + 'easyMode.agent.applyConfig': '⚡ 一键应用配置', + 'easyMode.agent.applySuccess': '配置已成功应用!', + 'easyMode.agent.launchClient': '🚀 启动客户端 / 打开终端', + 'easyMode.agent.launching': '正在启动...', + 'easyMode.complete.title': '配置就绪!', + 'easyMode.complete.enterApp': '进入完整控制台', + 'easyMode.apiCopy.desc': '你也可以直接将本地代理地址填入任何支持 OpenAI / Claude / Gemini 协议的工具中。', + 'easyMode.apiCopy.copied': '已复制到剪贴板', 'error.render.title': '页面渲染出现异常', 'error.unknown': '未知错误', 'error.reload': '重新加载页面', @@ -232,8 +273,15 @@ export const zhCN = { 'usage.analysis.sources': '请求来源', 'usage.analysis.keys': '鉴权密钥', 'usage.analysis.hours': '时段分布', - 'usage.analysis.sortedByTokens': '按 Token 排序', - 'usage.analysis.itemMeta': '{requests} 次 · {percent}% · {tokens} Token', + 'usage.analysis.metricLabel': '分析指标', + 'usage.analysis.metric.tokens': 'Token', + 'usage.analysis.metric.requests': '请求数', + 'usage.analysis.metric.failureRate': '失败率', + 'usage.analysis.sortedByMetric': '按{metric}排序', + 'usage.analysis.failureSamples': '{failures} 失败 / {requests} 请求', + 'usage.analysis.value.tokens': '{value} Token', + 'usage.analysis.value.requests': '{value} 次', + 'usage.analysis.value.failureRate': '{value}% 失败', 'usage.events.total': '共 {count} 条记录', 'usage.events.page': '第 {page} / {total} 页', 'usage.events.resetColumns': '重置列宽', @@ -370,6 +418,9 @@ export const zhCN = { 'config.software.startCoreOnLaunchDescription': '每次启动 EasyCLIProxyAPI 时自动启动 CPA 内核', 'config.software.silentStart': '静默启动', 'config.software.silentStartDescription': '启动应用时不显示主窗口,直接在系统托盘运行', + 'config.software.defaultTerminal': '默认终端', + 'config.software.defaultTerminalDescription': '启动 CLI 智能体时使用的终端程序', + 'config.software.terminal.auto': '自动选择', 'config.software.closeBehavior': '关闭行为', 'config.software.closeBehaviorDescription': '决定关闭窗口时是退出程序还是继续在系统托盘运行', 'config.software.behavior.ask': '每次询问', @@ -600,6 +651,8 @@ export const zhCN = { 'agents.status.applied': '已应用', 'agents.status.unconfigured': '尚未配置', 'agents.title': '智能体配置', + 'agents.embedded.title': '选择客户端和模型后启动', + 'agents.embedded.subtitle': '如果没有客户端请自行安装', 'agents.redetect': '重新检测', 'agents.tabs.label': '智能体配置子页面', 'agents.tabs.core': '核心配置', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index f169daf7..9cb2d4e6 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -629,7 +629,11 @@ function AgentModelPicker({ ); } -export function AgentsPage() { +type AgentsPageProps = { + embedded?: boolean; +}; + +export function AgentsPage({ embedded = false }: AgentsPageProps = {}) { const { t } = useI18n(); const [selected, setSelected] = useState(readSelectedAgentClient); const [activeSubpage, setActiveSubpage] = useState(DEFAULT_AGENT_SUBPAGE); @@ -982,6 +986,16 @@ export function AgentsPage() { void loadModels(selected); }; + const runEmbeddedPrimaryAction = () => { + if (isPiClient) { + void (activeStatus?.pluginInstalled ? repairPiProvider() : installPiProvider()); + return; + } + void (configurationAction === 'close' + ? closeConfigurationChanges() + : applyConfigurationChanges()); + }; + const reloadStatusesAfterAction = async () => { setDetectionError(''); try { @@ -1002,6 +1016,24 @@ export function AgentsPage() { }); }; + const selectEmbeddedModel = (value: string) => { + const model = findAgentModel(models, value); + if (!model) return; + if (isClaudeModelMappingClient) { + claudeModelMappingsDirtyRef.current[selected] = true; + setClaudeModelMappingsDraftByClient((current) => ({ + ...current, + [selected]: { + ...current[selected], + opus: model.name, + sonnet: model.name, + haiku: model.name, + }, + })); + } + selectModel(model.name); + }; + const selectClaudeModelMapping = ( role: 'opus' | 'sonnet' | 'haiku', value: string, @@ -1442,11 +1474,20 @@ export function AgentsPage() { ) : oauthLoginRequiredAction ? t(`agents.oauthLoginRequired.${oauthLoginRequiredAction}Description`) : ''; return ( -
+
-
- Agent Clients -

{t('agents.title')}

+
+ {embedded ? ( + <> +

{t('agents.embedded.title')}

+

{t('agents.embedded.subtitle')}

+ + ) : ( + <> + Agent Clients +

{t('agents.title')}

+ + )}
{detectionError ? ( @@ -1496,6 +1537,82 @@ export function AgentsPage() {
+ {embedded ? ( +
+
+ +
+ {activeDefinition.name} + {activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')} +
+ + {activeStatus?.version ?? activeStatus?.appVersion ?? activeStatus?.cliVersion ?? t('agents.notFetched')} + +
+ + {activeStatus?.error || activeStatus?.warnings.length ? ( +
+ {activeStatus.error ? ( + {activeStatus.error} + ) : ( + {activeStatus.warnings.join(';')} + )} +
+ ) : null} + +
+ + +
+ +
+ + +
+ + {configurationError || modelSelectionError || launchError ? ( +
+ {configurationError || modelSelectionError || launchError} +
+ ) : null} +
+ ) : ( + <>
{availableSubpages.map((subpage) => (
) : null} + + )}
diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index cf258df0..2dd5dad6 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -22,6 +22,7 @@ import { ShieldCheck, Sparkles, Power, + Terminal, Trash2, X, } from 'lucide-react'; @@ -79,11 +80,18 @@ type DraftRefreshMode = 'replace' | 'preserve'; type NetworkDraftDirty = Record; +type AgentTerminalOption = { + id: string; + label: string; +}; + type SoftwareSettings = { closeBehavior: CloseBehavior; autostartEnabled: boolean; startCoreOnLaunch: boolean; silentStartEnabled: boolean; + defaultTerminal: string; + availableTerminals: AgentTerminalOption[]; }; type CoreTlsSettings = { @@ -119,6 +127,7 @@ export function ConfigPanelPage() { const [softwareAutostartDraft, setSoftwareAutostartDraft] = useState(false); const [softwareStartCoreDraft, setSoftwareStartCoreDraft] = useState(true); const [softwareSilentStartDraft, setSoftwareSilentStartDraft] = useState(false); + const [softwareDefaultTerminalDraft, setSoftwareDefaultTerminalDraft] = useState('auto'); const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false); const [tlsSettings, setTlsSettings] = useState(null); const [tlsSettingsLoading, setTlsSettingsLoading] = useState(true); @@ -264,6 +273,7 @@ export function ConfigPanelPage() { setSoftwareAutostartDraft(result.autostartEnabled); setSoftwareStartCoreDraft(result.startCoreOnLaunch); setSoftwareSilentStartDraft(result.silentStartEnabled); + setSoftwareDefaultTerminalDraft(result.defaultTerminal); } catch (error) { setSoftwareSettings(null); showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); @@ -690,6 +700,7 @@ export function ConfigPanelPage() { && softwareAutostartDraft === softwareSettings.autostartEnabled && softwareStartCoreDraft === softwareSettings.startCoreOnLaunch && softwareSilentStartDraft === softwareSettings.silentStartEnabled + && softwareDefaultTerminalDraft === softwareSettings.defaultTerminal ) return; setBusyAction('software'); @@ -700,6 +711,7 @@ export function ConfigPanelPage() { autostartEnabled: softwareAutostartDraft, startCoreOnLaunch: softwareStartCoreDraft, silentStartEnabled: softwareSilentStartDraft, + defaultTerminal: softwareDefaultTerminalDraft, }, }); setSoftwareSettings(result); @@ -707,6 +719,7 @@ export function ConfigPanelPage() { setSoftwareAutostartDraft(result.autostartEnabled); setSoftwareStartCoreDraft(result.startCoreOnLaunch); setSoftwareSilentStartDraft(result.silentStartEnabled); + setSoftwareDefaultTerminalDraft(result.defaultTerminal); setSoftwareSavedStatusVisible(true); showNotice(t('config.notice.softwareUpdated'), 'success'); } catch (error) { @@ -714,6 +727,7 @@ export function ConfigPanelPage() { setSoftwareAutostartDraft(softwareSettings.autostartEnabled); setSoftwareStartCoreDraft(softwareSettings.startCoreOnLaunch); setSoftwareSilentStartDraft(softwareSettings.silentStartEnabled); + setSoftwareDefaultTerminalDraft(softwareSettings.defaultTerminal); setSoftwareSavedStatusVisible(false); showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); void loadSoftwareSettings(); @@ -746,10 +760,13 @@ export function ConfigPanelPage() { && softwareSilentStartDraft !== softwareSettings.silentStartEnabled; const softwareStartCoreDirty = softwareSettings !== null && softwareStartCoreDraft !== softwareSettings.startCoreOnLaunch; + const softwareDefaultTerminalDirty = softwareSettings !== null + && softwareDefaultTerminalDraft !== softwareSettings.defaultTerminal; const softwareSettingsDirty = softwareCloseBehaviorDirty || softwareAutostartDirty || softwareStartCoreDirty - || softwareSilentStartDirty; + || softwareSilentStartDirty + || softwareDefaultTerminalDirty; const softwareStatusLabel = softwareSettingsLoading ? t('common.loading') : softwareSettings === null @@ -1660,6 +1677,35 @@ export function ConfigPanelPage() {
+
+
+ +
+ {t('config.software.defaultTerminal')} + {t('config.software.defaultTerminalDescription')} +
+
+ +
- ) : null} + - {view === 1 ? ( + {/* 第一步:接入模型 */} + {activeStep === 1 ? (
-

{t('easyMode.overview.providerTitle')}

-

{t('easyMode.setup.providerDescription')}

+

{t("easyMode.overview.providerTitle")}

+

{t("easyMode.setup.providerDescription")}

+
+
-
- ) : null} - {view === 'oauth' || view === 'api' ? ( -
-
- {view === 'oauth' ? : } + {authMethod === "oauth" ? ( +
+ {oauthNotice ? ( +
+ {oauthNotice.tone === "success" ? : } + {oauthNotice.message} +
+ ) : null} + +
+ {oauthProviders.map((provider) => { + const loggedIn = isOAuthLoggedIn(provider.id); + const isLogging = oauthLoggingIn === provider.id; + + return ( +
+
+ + + +
+ {provider.name} + + {provider.description} + +
+
+ +
+ {loggedIn ? ( + + + {t("easyMode.oauth.loggedIn")} + + ) : ( + 未授权 + )} + + +
+
+ ); + })} +
+
+ ) : null} + + {authMethod === "api" ? ( +
+ {apiSaveNotice ? ( +
+ + {apiSaveNotice} +
+ ) : null} + + {apiTestError ? ( +
+ + {apiTestError} +
+ ) : null} + +
+
+ {apiSectionOptions.map((opt) => ( + + ))} +
+ +
+ + setApiRemark(e.target.value)} + placeholder="例如:主力 DeepSeek V3、中转 API" + /> +
+ +
+
+ + setApiBaseUrl(e.target.value)} + placeholder="https://..." + /> +
+
+ + setApiKey(e.target.value)} + placeholder="sk-..." + /> +
+
+ +
+ {apiTestedModels.length === 0 ? ( +
+ +
+ ) : ( + <> +
+ {t("easyMode.api.modelListTitle")} +
+ +
+
+ {t("easyMode.api.modelListHint")} +
+
+ {apiTestedModels.map((model) => { + const key = model.name.trim().toLowerCase(); + const selected = apiSelectedModels.some( + (item) => item.name.trim().toLowerCase() === key, + ); + return ( + + ); + })} +
+
+ +
+ +
+ + )} +
+
+
+ ) : null} + +
+
+ {hasConnectedSource ? ( + + {t("easyMode.status.connectedModels", { count: Math.max(availableModels.length, 1) })} + + ) : ( + {t("easyMode.status.noModelsYet")} + )} +
+ +
) : null} - {view === 'agents' ? ( -
-
- + {/* 第二步:接入智能体 */} + {activeStep === 2 ? ( +
+ + +
+ + +
) : null} diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index a6b8fa76..8574d741 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -27,6 +27,12 @@ import { } from 'lucide-react'; import { getCurrentLocale, useI18n } from '../i18n'; import type { MessageKey } from '../i18n/resources'; +import { + categoryMetricValue, + failureRatePercent, + sortCategoriesByMetric, + type UsageAnalysisMetric, +} from '../services/usageAnalysis'; import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics'; import { formatUsageNumber } from '../services/usageNumber'; @@ -1011,6 +1017,7 @@ function TokenMetric({ function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overview: UsageOverview | null }) { const { t } = useI18n(); + const [metric, setMetric] = useState('tokens'); const hours = (overview?.timeline ?? []) .map((point) => ({ key: point.hour, @@ -1021,40 +1028,91 @@ function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overvie })) .sort((left, right) => right.tokens - left.tokens); return ( -
- - - - - +
+
+ {t('usage.analysis.metricLabel')} +
+ {analysisMetricOptions.map((option) => ( + + ))} +
+
+
+ + + + + +
); } +const analysisMetricOptions: Array<{ key: UsageAnalysisMetric; labelKey: MessageKey }> = [ + { key: 'tokens', labelKey: 'usage.analysis.metric.tokens' }, + { key: 'requests', labelKey: 'usage.analysis.metric.requests' }, + { key: 'failureRate', labelKey: 'usage.analysis.metric.failureRate' }, +]; + +const analysisMetricLabelKey = (metric: UsageAnalysisMetric): MessageKey => { + if (metric === 'requests') return 'usage.analysis.metric.requests'; + if (metric === 'failureRate') return 'usage.analysis.metric.failureRate'; + return 'usage.analysis.metric.tokens'; +}; + function CategoryPanel({ title, items, + metric, compactLabels = false, }: { title: string; items: UsageCategory[]; + metric: UsageAnalysisMetric; compactLabels?: boolean; }) { const { t } = useI18n(); - const max = Math.max(...items.map((item) => item.tokens), 1); - const total = items.reduce((sum, item) => sum + item.tokens, 0); + const sortedItems = sortCategoriesByMetric(items, metric); + const max = Math.max(...sortedItems.map((item) => categoryMetricValue(item, metric)), 1); + const total = sortedItems.reduce((sum, item) => sum + categoryMetricValue(item, metric), 0); + const metricLabel = t(analysisMetricLabelKey(metric)); return (
{title} - {t('usage.analysis.sortedByTokens')} + {t('usage.analysis.sortedByMetric', { metric: metricLabel })}
- {items.length ? ( + {sortedItems.length ? (
- {items.slice(0, 10).map((item, idx) => { - const percent = total ? ((item.tokens * 100) / total).toFixed(1) : '0.0'; + {sortedItems.slice(0, 10).map((item, idx) => { + const value = categoryMetricValue(item, metric); + const share = metric === 'failureRate' || total === 0 ? 0 : (value * 100) / total; + const failureRate = failureRatePercent(item); + const barWidth = metric === 'failureRate' ? failureRate : (value * 100) / max; + const valueLabel = metric === 'tokens' + ? t('usage.analysis.value.tokens', { value: compactNumber(item.tokens) }) + : metric === 'requests' + ? t('usage.analysis.value.requests', { value: compactNumber(item.requests) }) + : t('usage.analysis.value.failureRate', { value: failureRate.toFixed(1) }); return (
@@ -1065,13 +1123,23 @@ function CategoryPanel({
- {compactNumber(item.requests)} requests - {percent}% - {compactNumber(item.tokens)} Token + 0 ? 'usage-category-failures has-failures' : 'usage-category-failures'}> + {t('usage.analysis.failureSamples', { + failures: compactNumber(item.failures), + requests: compactNumber(item.requests), + })} + + {metric === 'failureRate' ? null : ( + {share.toFixed(1)}% + )} + {valueLabel}
-
+
); diff --git a/src/services/usageAnalysis.ts b/src/services/usageAnalysis.ts new file mode 100644 index 00000000..888990ac --- /dev/null +++ b/src/services/usageAnalysis.ts @@ -0,0 +1,36 @@ +export type UsageAnalysisMetric = 'tokens' | 'requests' | 'failureRate'; + +export type UsageCategoryMetrics = { + key: string; + requests: number; + failures: number; + tokens: number; +}; + +const nonNegativeNumber = (value: number) => ( + Number.isFinite(value) ? Math.max(value, 0) : 0 +); + +export const failureRatePercent = (category: UsageCategoryMetrics) => { + const requests = nonNegativeNumber(category.requests); + if (requests === 0) return 0; + return Math.min((nonNegativeNumber(category.failures) * 100) / requests, 100); +}; + +export const categoryMetricValue = ( + category: UsageCategoryMetrics, + metric: UsageAnalysisMetric, +) => { + if (metric === 'failureRate') return failureRatePercent(category); + return nonNegativeNumber(category[metric]); +}; + +export const sortCategoriesByMetric = ( + categories: readonly T[], + metric: UsageAnalysisMetric, +) => [...categories].sort((left, right) => ( + categoryMetricValue(right, metric) - categoryMetricValue(left, metric) + || nonNegativeNumber(right.tokens) - nonNegativeNumber(left.tokens) + || nonNegativeNumber(right.requests) - nonNegativeNumber(left.requests) + || left.key.localeCompare(right.key) +)); diff --git a/src/styles.css b/src/styles.css index f092d752..aa509c99 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5019,6 +5019,10 @@ textarea:focus-visible { gap: 14px; } +.agents-page-embedded .agent-client-list { + min-height: 0; +} + .agent-header-actions { display: flex; min-width: 0; @@ -7021,6 +7025,58 @@ textarea:focus-visible { } /* Analysis Tab */ +.usage-analysis-layout { + display: grid; + gap: 14px; +} + +.usage-analysis-toolbar { + display: flex; + min-height: 36px; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.usage-analysis-toolbar > span { + color: var(--theme-7a746c); + font-size: var(--font-size-caption); + font-weight: 600; +} + +.usage-analysis-metric-switch { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3px; + padding: 3px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 7px; + background: var(--theme-f0eee8); +} + +.usage-analysis-metric-switch button { + min-height: 28px; + padding: 0 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--theme-6d6760); + font-size: var(--font-size-caption); + font-weight: 700; + white-space: nowrap; +} + +.usage-analysis-metric-switch button:hover:not(.active) { + background: var(--theme-e8e5dc); + color: var(--theme-2d2a26); +} + +.usage-analysis-metric-switch button.active { + background: var(--theme-fffdf8); + color: var(--theme-2d2a26); + box-shadow: 0 1px 2px rgba(45, 42, 38, 0.12); +} + .usage-analysis-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); @@ -7097,6 +7153,11 @@ textarea:focus-visible { font-weight: 600; } +.usage-category-failures.has-failures { + color: var(--theme-9f2d20); + font-weight: 600; +} + .usage-category-meta strong { color: var(--theme-5f5a53); font-weight: 600; @@ -7117,6 +7178,10 @@ textarea:focus-visible { transition: width 0.3s ease; } +.usage-category-fill.failure-rate { + background: var(--theme-9f2d20); +} + /* Events Tab (请求明细) */ .usage-events-panel { display: flex; @@ -9134,6 +9199,25 @@ body.table-col-resizing * { .agent-modification-buttons.codex { grid-template-columns: 1fr; } + + .usage-analysis-toolbar { + align-items: stretch; + flex-direction: column; + } + + .usage-analysis-metric-switch { + width: 100%; + } + + .usage-category-header { + align-items: flex-start; + flex-direction: column; + } + + .usage-category-meta { + width: 100%; + flex-wrap: wrap; + } } @media (max-width: 520px) { @@ -9978,6 +10062,21 @@ body.table-col-resizing * { } @media (max-width: 680px) { + .simple-mode-step-status-track { + grid-template-columns: 1fr; + gap: 4px; + } + + .simple-mode-step-status-connector { + width: 1px; + height: 10px; + margin-left: 12px; + } + + .simple-mode-step-status-copy small { + white-space: normal; + } + .easy-mode-hero { flex-direction: column; gap: 14px; @@ -10038,6 +10137,114 @@ body.table-col-resizing * { margin-inline: auto; } +.simple-mode-step-status { + display: grid; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--theme-e3e1db); + border-radius: 10px; + background: var(--theme-fffdf8); +} + +.simple-mode-step-status-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--theme-7a746c); + font-size: var(--font-size-meta); +} + +.simple-mode-step-status-heading strong { + color: var(--theme-2d2a26); + font-size: var(--font-size-label); +} + +.simple-mode-step-status-track { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.simple-mode-step-status-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 9px; + min-width: 0; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--theme-7a746c); +} + +.simple-mode-step-status-item.active { + border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--theme-e3e1db)); + background: var(--ui-accent-soft); +} + +.simple-mode-step-status-item.complete { + color: var(--theme-5f5a53); +} + +.simple-mode-step-status-number { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border: 1px solid var(--theme-d5d2cb); + border-radius: 999px; + background: var(--theme-faf9f5); + color: var(--theme-7a746c); + font-size: var(--font-size-meta); + font-weight: 800; +} + +.simple-mode-step-status-item.active .simple-mode-step-status-number { + border-color: var(--ui-accent); + background: var(--ui-accent); + color: #ffffff; +} + +.simple-mode-step-status-item.complete .simple-mode-step-status-number { + border-color: var(--theme-b6d3bd); + background: var(--theme-f1f8f1); + color: var(--theme-2f6b3a); +} + +.simple-mode-step-status-copy { + display: grid; + min-width: 0; + gap: 2px; +} + +.simple-mode-step-status-copy strong, +.simple-mode-step-status-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-step-status-copy strong { + color: var(--theme-2d2a26); + font-size: var(--font-size-label); +} + +.simple-mode-step-status-copy small { + color: var(--theme-7a746c); + font-size: var(--font-size-caption); +} + +.simple-mode-step-status-connector { + height: 1px; + background: var(--theme-d5d2cb); +} + +.simple-mode-step-status-connector.complete { + background: var(--theme-b6d3bd); +} + .simple-mode-core-alert { display: flex; align-items: center; @@ -10143,6 +10350,7 @@ body.table-col-resizing * { margin: 4px 0 0; color: var(--theme-5f5a53); line-height: var(--line-height-body); + white-space: pre-line; } .simple-mode-instruction { @@ -10457,82 +10665,1370 @@ body.table-col-resizing * { margin-left: auto; } -@media (max-width: 680px) { - .simple-mode-core-alert, - .simple-mode-action-card, - .simple-mode-task-footer { - align-items: stretch; - flex-direction: column; - } +.simple-mode-progress { + position: relative; + gap: 12px; +} - .simple-mode-core-alert .secondary-button, - .simple-mode-action-card .primary-button, - .simple-mode-task-footer > .primary-button, - .simple-mode-task-footer > .secondary-button { - width: 100%; - } +.simple-mode-progress-item { + position: relative; + min-height: 76px; + padding: 13px 16px; + border-radius: 13px; + background: color-mix(in srgb, var(--theme-fffdf8) 90%, var(--theme-faf9f5)); + transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} - .simple-mode-progress, - .simple-mode-choice-grid, - .simple-mode-guide-grid { - grid-template-columns: 1fr; - } +.simple-mode-progress-item:hover:not(:disabled) { + transform: translateY(-1px); +} - .simple-mode-progress-item { - min-height: 54px; - } +.simple-mode-progress-item-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-width: 0; +} - .simple-mode-path-row { - flex-wrap: wrap; - } +.simple-mode-step-badge { + display: grid; + width: 29px; + height: 29px; + place-items: center; + border: 1px solid var(--theme-d5d2cb); + border-radius: 999px; + background: var(--theme-faf9f5); + color: var(--theme-7a746c); + font-size: var(--font-size-label); + font-weight: 800; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease; +} - .simple-mode-path-row .text-button { - width: 100%; - margin-left: 0; - text-align: left; - } +.simple-mode-progress-item.active .simple-mode-step-badge { + border-color: var(--ui-accent); + background: var(--ui-accent); + color: #ffffff; +} - .simple-mode-actions { - display: grid; - grid-template-columns: 1fr; - } +.simple-mode-progress-copy { + display: grid; + min-width: 0; + gap: 3px; +} - .simple-mode-actions button { - width: 100%; - } +.simple-mode-progress-copy strong { + min-width: 0; + overflow: hidden; + color: var(--theme-2d2a26); + font-size: var(--font-size-body); + text-overflow: ellipsis; + white-space: nowrap; +} - .simple-mode-embedded-heading { - align-items: stretch; - flex-direction: column; - } +.simple-mode-progress-copy small { + overflow: hidden; + color: var(--theme-7a746c); + font-size: var(--font-size-caption); + text-overflow: ellipsis; + white-space: nowrap; +} - .simple-mode-embedded-heading .text-button { +.simple-mode-task { + gap: 22px; + padding: clamp(22px, 3vw, 32px); + border-radius: 16px; + box-shadow: 0 14px 34px color-mix(in srgb, var(--ui-accent-shadow) 58%, transparent); +} + +.simple-mode-task-heading { + align-items: center; + gap: 12px; +} + +.simple-mode-heading-icon { + display: grid; + flex: none; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--ui-accent) 27%, var(--theme-e3e1db)); + border-radius: 11px; + background: var(--ui-accent-soft); + color: var(--ui-accent-strong); +} + +.simple-mode-task-heading > div { + min-width: 0; +} + +.simple-mode-task-heading h2 { + font-size: clamp(20px, 2.2vw, 24px); + line-height: 1.2; +} + +.simple-mode-task-heading p { + max-width: 700px; + margin-top: 6px; + font-size: var(--font-size-body); +} + +.simple-mode-choice-grid { + gap: 14px; +} + +.simple-mode-choice { + gap: 10px; + min-height: 132px; + padding: 18px; + border-radius: 13px; + background: var(--theme-fffdf8); + transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.simple-mode-choice:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(45, 42, 38, 0.08); +} + +.simple-mode-choice-heading { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.simple-mode-choice-icon { + display: grid; + flex: none; + width: 32px; + height: 32px; + place-items: center; + border: 1px solid var(--theme-e3e1db); + border-radius: 9px; + background: var(--theme-faf9f5); + color: var(--theme-7a746c); +} + +.simple-mode-choice.selected .simple-mode-choice-icon { + border-color: color-mix(in srgb, var(--ui-accent) 30%, var(--theme-e3e1db)); + background: var(--theme-fffdf8); + color: var(--ui-accent-strong); +} + +.simple-mode-choice-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; + flex: 1; +} + +.simple-mode-choice-title strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-choice-title .state-pill { + flex: none; +} + +.simple-mode-choice > span:not(.simple-mode-choice-icon) { + padding-left: 42px; +} + +.simple-mode-choice > small { + padding-left: 42px; +} + +.simple-mode-embedded-box { + display: grid; + gap: 14px; + padding: 14px; + border: 1px solid var(--theme-e3e1db); + border-radius: 13px; + background: color-mix(in srgb, var(--theme-faf9f5) 82%, var(--theme-fffdf8)); +} + +.simple-mode-provider-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); + gap: 12px; +} + +.simple-mode-provider-card { + grid-template-rows: minmax(0, 1fr) auto; + gap: 16px; + min-height: 158px; + height: auto; + padding: 16px; + border-radius: 12px; + background: var(--theme-fffdf8); + transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.simple-mode-provider-card:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--ui-accent) 30%, var(--theme-e3e1db)); + box-shadow: 0 8px 20px rgba(45, 42, 38, 0.07); +} + +.simple-mode-provider-card.connected { + border-color: var(--theme-b6d3bd); + background: var(--ui-accent-soft); +} + +.simple-mode-provider-card-head { + display: flex; + align-items: flex-start; + gap: 11px; + min-width: 0; +} + +.simple-mode-provider-logo { + width: 36px; + height: 36px; + flex: none; + border-radius: 10px; +} + +.simple-mode-provider-logo img { + width: 22px; + height: 22px; +} + +.simple-mode-provider-copy { + display: grid; + min-width: 0; + gap: 4px; +} + +.simple-mode-provider-copy strong { + overflow: hidden; + color: var(--theme-2d2a26); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-provider-copy span { + color: var(--theme-5f5a53); + font-size: var(--font-size-meta); + line-height: var(--line-height-body); +} + +.simple-mode-provider-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.simple-mode-provider-card-foot .primary-button, +.simple-mode-provider-card-foot .secondary-button { + min-height: 34px; + padding-inline: 13px; + font-size: var(--font-size-label); +} + +.simple-mode-api-form { + display: grid; + gap: 18px; + padding: 20px; + border: 1px solid var(--theme-d8d4cc); + border-radius: 12px; + background: var(--theme-fffdf8); +} + +.simple-mode-api-model-card { + display: grid; + gap: 11px; + padding-top: 2px; +} + +.simple-mode-api-model-fetch { + display: flex; + justify-content: flex-start; +} + +.simple-mode-api-fetch-button { + min-height: 36px; + padding-inline: 14px; + white-space: nowrap; +} + +.simple-mode-api-model-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.simple-mode-api-model-heading strong { + color: var(--theme-2d2a26); + font-size: var(--font-size-label); + font-weight: 700; +} + +.simple-mode-api-model-heading .secondary-button { + min-height: 36px; + flex: none; + padding-inline: 11px; + white-space: nowrap; +} + +.simple-mode-api-model-selection { + display: grid; + gap: 8px; +} + +.simple-mode-api-model-selection-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.simple-mode-api-model-selection-heading strong { + color: var(--theme-2d2a26); + font-size: var(--font-size-label); +} + +.simple-mode-api-model-selection-heading span { + color: var(--theme-7a746c); + font-size: var(--font-size-caption); +} + +.simple-mode-api-model-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 18px; + max-height: 220px; + overflow-y: auto; + padding: 6px 0; + border-top: 1px solid var(--theme-e3e1db); + border-bottom: 1px solid var(--theme-e3e1db); +} + +.simple-mode-api-model-option { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 33px; + padding: 5px 7px; + border-radius: 6px; + background: transparent; + color: var(--theme-2d2a26); + font-size: var(--font-size-label); + cursor: pointer; +} + +.simple-mode-api-model-option:hover, +.simple-mode-api-model-option.selected { + background: var(--theme-faf9f5); +} + +.simple-mode-api-model-option input { + flex: none; + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--ui-accent); +} + +.simple-mode-api-model-option > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-api-platforms { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px; + border: 1px solid var(--theme-e3e1db); + border-radius: 10px; + background: var(--theme-faf9f5); +} + +.simple-mode-api-platform { + flex: 1 1 132px; + min-width: 0; + min-height: 38px; + gap: 7px; + padding-inline: 12px; + border-color: transparent; + background: transparent; + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + font-weight: 700; +} + +.simple-mode-api-platform:hover:not(:disabled) { + border-color: transparent; + background: var(--theme-e8e5dc); +} + +.simple-mode-api-platform.active { + border-color: var(--ui-accent); + background: var(--theme-fffdf8); + color: var(--theme-2d2a26); + box-shadow: 0 2px 6px rgba(45, 42, 38, 0.08); +} + +.simple-mode-api-platform-icon { + width: 16px; + height: 16px; + object-fit: contain; +} + +.simple-mode-api-fields { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.9fr); + gap: 14px; +} + +.simple-mode-field { + display: grid; + gap: 8px; + min-width: 0; +} + +.simple-mode-field label { + color: var(--theme-2d2a26); + font-size: var(--font-size-label); + font-weight: 700; + letter-spacing: 0.01em; +} + +.simple-mode-field .text-input, +.simple-mode-field .select-input { + box-sizing: border-box; + width: 100%; + min-height: 44px; + padding: 0 13px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 9px; + background: var(--theme-faf9f5); + color: var(--theme-2d2a26); + font-size: var(--font-size-body); + box-shadow: inset 0 1px 2px rgba(45, 42, 38, 0.04); + transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; +} + +.simple-mode-field .text-input:hover:not(:disabled), +.simple-mode-field .select-input:hover:not(:disabled) { + border-color: var(--theme-9b958c); + background: var(--theme-fffdf8); +} + +.simple-mode-field .text-input:focus, +.simple-mode-field .select-input:focus { + outline: none; + border-color: var(--ui-accent); + background: var(--theme-fffdf8); + box-shadow: 0 0 0 3px var(--ui-accent-shadow); +} + +.simple-mode-field .text-input::placeholder { + color: var(--theme-9b958c); +} + +.simple-mode-api-actions, +.simple-mode-agent-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.simple-mode-api-actions .primary-button, +.simple-mode-api-actions .secondary-button, +.simple-mode-agent-actions .primary-button, +.simple-mode-agent-actions .secondary-button { + gap: 7px; +} + +.simple-mode-api-result { + display: grid; + gap: 9px; + margin-top: 1px; + padding: 12px; + border: 1px solid var(--theme-b6d3bd); + border-radius: 9px; + background: var(--ui-accent-soft); +} + +.simple-mode-api-result-heading { + display: flex; + align-items: center; + gap: 7px; + color: var(--theme-2d2a26); + font-size: var(--font-size-label); + font-weight: 700; +} + +.simple-mode-model-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.simple-mode-model-tag { + padding: 3px 8px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 5px; + background: var(--theme-fffdf8); + color: var(--theme-4f4a43); + font-size: var(--font-size-meta); +} + +.simple-mode-more-models { + align-self: center; + color: var(--theme-7a746c); + font-size: var(--font-size-meta); +} + +.simple-mode-agent-layout { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + gap: 14px; + margin-top: 2px; +} + +.simple-mode-agent-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 460px; + padding: 8px; + overflow-y: auto; + border: 1px solid var(--theme-e3e1db); + border-radius: 12px; + background: var(--theme-faf9f5); +} + +.simple-mode-agent-option { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 10px 11px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--theme-2d2a26); + text-align: left; + transition: border-color 140ms ease, background 140ms ease; +} + +.simple-mode-agent-option:hover { + border-color: var(--theme-d5d2cb); + background: var(--theme-fffdf8); +} + +.simple-mode-agent-option.selected { + border-color: var(--ui-accent); + background: var(--ui-accent-soft); + box-shadow: 0 3px 10px var(--ui-accent-shadow); +} + +.simple-mode-agent-option-icon { + width: 23px; + height: 23px; + flex: none; + object-fit: contain; +} + +.simple-mode-agent-option-copy { + display: grid; + min-width: 0; + flex: 1; + gap: 3px; +} + +.simple-mode-agent-option-copy strong { + overflow: hidden; + color: var(--theme-2d2a26); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-agent-option-copy span { + overflow: hidden; + color: var(--theme-7a746c); + font-size: var(--font-size-caption); + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-agent-option-copy span.installed { + color: var(--ui-accent-strong); +} + +.simple-mode-agent-config { + display: grid; + align-content: start; + gap: 16px; + min-width: 0; + padding: 20px; + border: 1px solid var(--theme-e3e1db); + border-radius: 12px; + background: var(--theme-fffdf8); +} + +.simple-mode-agent-identity { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} + +.simple-mode-agent-logo { + width: 44px; + height: 44px; + flex: none; + border-radius: 11px; +} + +.simple-mode-agent-logo img { + width: 28px; + height: 28px; +} + +.simple-mode-agent-identity-copy { + display: grid; + min-width: 0; + gap: 4px; +} + +.simple-mode-agent-identity-copy h3 { + margin: 0; + overflow: hidden; + color: var(--theme-2d2a26); + font-size: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-agent-identity-copy span { + overflow: hidden; + color: var(--theme-5f5a53); + font-size: var(--font-size-label); + line-height: var(--line-height-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.simple-mode-agent-detection { + padding: 10px 13px; + border: 1px solid var(--theme-e3e1db); + border-radius: 8px; + background: var(--theme-faf9f5); +} + +.simple-mode-agent-detection span { + display: block; + color: var(--theme-7a746c); + font-size: var(--font-size-meta); + line-height: var(--line-height-body); +} + +.simple-mode-agent-actions { + margin-top: 1px; +} + +.simple-mode-agent-actions .primary-button { + flex: 1; +} + +.simple-mode-agent-actions .primary-button, +.simple-mode-agent-actions .secondary-button { + min-height: 42px; + font-size: var(--font-size-label); +} + +.simple-mode-task-footer { + gap: 16px; + padding-top: 16px; +} + +.simple-mode-selection { + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 760px) { + .simple-mode-agent-layout { + grid-template-columns: 1fr; + } + + .simple-mode-agent-list { + max-height: 260px; + } +} + +@media (max-width: 680px) { + .simple-mode-progress-item { + min-height: 62px; + } + + .simple-mode-progress-item-head { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + } + + .simple-mode-progress-item-head > .state-pill { + grid-column: 2; + justify-self: start; + } + + .simple-mode-choice-title { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .simple-mode-provider-card-foot { + align-items: flex-start; + flex-direction: column; + } + + .simple-mode-provider-card-foot .primary-button, + .simple-mode-provider-card-foot .secondary-button { + width: 100%; + } + + .simple-mode-task { + gap: 18px; + padding: 18px; + border-radius: 13px; + } + + .simple-mode-task-heading { + align-items: flex-start; + } + + .simple-mode-heading-icon { + width: 34px; + height: 34px; + } + + .simple-mode-choice > span:not(.simple-mode-choice-icon), + .simple-mode-choice > small { + padding-left: 0; + } + + .simple-mode-api-form { + padding: 14px; + } + + .simple-mode-api-fields { + grid-template-columns: 1fr; + } + + .simple-mode-api-actions, + .simple-mode-agent-actions { + display: grid; + grid-template-columns: 1fr; + align-items: stretch; + } + + .simple-mode-api-actions button, + .simple-mode-agent-actions button { + width: 100%; + } + + .simple-mode-agent-config { + padding: 16px; + } + + .simple-mode-core-alert, + .simple-mode-action-card, + .simple-mode-task-footer { + align-items: stretch; + flex-direction: column; + } + + .simple-mode-core-alert .secondary-button, + .simple-mode-action-card .primary-button, + .simple-mode-task-footer > .primary-button, + .simple-mode-task-footer > .secondary-button { + width: 100%; + } + + .simple-mode-progress, + .simple-mode-choice-grid, + .simple-mode-guide-grid { + grid-template-columns: 1fr; + } + + .simple-mode-progress-item { + min-height: 54px; + } + + .simple-mode-path-row { + flex-wrap: wrap; + } + + .simple-mode-path-row .text-button { + width: 100%; + margin-left: 0; + text-align: left; + } + + .simple-mode-actions { + display: grid; + grid-template-columns: 1fr; + } + + .simple-mode-actions button { + width: 100%; + } + + .simple-mode-embedded-heading { + align-items: stretch; + flex-direction: column; + } + + .simple-mode-embedded-heading .text-button { + width: 100%; + margin-left: 0; + text-align: left; + } + + .simple-mode-embedded-content { + padding: 10px; + } + + .simple-mode-flow-navigation { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .simple-mode-flow-navigation > button { + width: 100%; + margin-left: 0; + } + + .simple-mode-flow-navigation > .secondary-button:only-child { + grid-column: 1; + } + + .simple-mode-flow-navigation > .primary-button:only-child { + grid-column: 2; + } + +} + +.simple-mode-page { + max-width: 900px; + gap: 14px; +} + +.simple-mode-progress { + gap: 8px; +} + +.simple-mode-progress-item, +.simple-mode-progress-item:hover:not(:disabled) { + min-height: 58px; + padding: 10px 12px; + border-radius: 9px; + background: var(--theme-fffdf8); + box-shadow: none; + transform: none; +} + +.simple-mode-progress-item.active { + border-color: color-mix(in srgb, var(--ui-accent) 32%, var(--theme-e3e1db)); + background: var(--ui-accent-soft); + box-shadow: none; +} + +.simple-mode-progress-item-head { + gap: 8px; +} + +.simple-mode-step-badge { + width: 26px; + height: 26px; +} + +.simple-mode-task { + gap: 16px; + padding: 20px; + border-radius: 10px; + box-shadow: none; +} + +.simple-mode-task-heading { + align-items: flex-start; + gap: 0; +} + +.simple-mode-task-heading h2 { + font-size: 21px; +} + +.simple-mode-task-heading p { + margin-top: 4px; + font-size: var(--font-size-label); +} + +.simple-mode-choice-grid { + gap: 8px; +} + +.simple-mode-choice, +.simple-mode-choice:hover { + min-height: 88px; + gap: 5px; + padding: 13px 14px; + border-radius: 8px; + box-shadow: none; + transform: none; +} + +.simple-mode-choice.selected { + box-shadow: none; +} + +.simple-mode-choice-heading { + gap: 0; +} + +.simple-mode-choice-title { + justify-content: flex-start; + gap: 8px; +} + +.simple-mode-choice > span:not(.simple-mode-choice-icon), +.simple-mode-choice > small { + padding-left: 0; +} + +.simple-mode-embedded-box { + gap: 10px; + padding: 0; + border: 0; + background: transparent; +} + +.simple-mode-provider-grid { + gap: 8px; +} + +.simple-mode-provider-card, +.simple-mode-provider-card:hover { + min-height: 0; + gap: 12px; + padding: 12px; + border-radius: 8px; + box-shadow: none; + transform: none; +} + +.simple-mode-provider-card-foot .primary-button, +.simple-mode-provider-card-foot .secondary-button { + min-height: 32px; +} + +.simple-mode-api-form { + gap: 12px; + padding: 14px; + border-radius: 8px; + box-shadow: none; +} + +.simple-mode-api-platforms { + gap: 6px; +} + +.simple-mode-api-platform { + min-height: 32px; + padding-inline: 10px; + font-weight: 600; +} + +.simple-mode-api-result { + border-radius: 8px; +} + +.simple-mode-agent-layout { + gap: 10px; +} + +.simple-mode-agent-list, +.simple-mode-agent-config { + border-radius: 8px; + box-shadow: none; +} + +.simple-mode-agent-list { + padding: 6px; +} + +.simple-mode-agent-config { + padding: 16px; +} + +.simple-mode-task-footer { + gap: 12px; + padding-top: 12px; +} + +@media (max-width: 760px) { + .simple-mode-agent-layout { + grid-template-columns: 1fr; + } + + .simple-mode-agent-list { + max-height: 260px; + } +} + +@media (max-width: 680px) { + .simple-mode-progress-item { + min-height: 62px; + } + + .simple-mode-progress-item-head { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + } + + .simple-mode-progress-item-head > .state-pill { + grid-column: 2; + justify-self: start; + } + + .simple-mode-choice-title { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .simple-mode-provider-card-foot { + align-items: flex-start; + flex-direction: column; + } + + .simple-mode-provider-card-foot .primary-button, + .simple-mode-provider-card-foot .secondary-button { width: 100%; - margin-left: 0; - text-align: left; } - .simple-mode-embedded-content { - padding: 10px; + .simple-mode-task { + gap: 18px; + padding: 18px; } - .simple-mode-flow-navigation { + .simple-mode-api-form { + padding: 14px; + } + + .simple-mode-api-fields { + grid-template-columns: 1fr; + } + + .simple-mode-api-actions, + .simple-mode-agent-actions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; + grid-template-columns: 1fr; + align-items: stretch; } - .simple-mode-flow-navigation > button { + .simple-mode-api-actions button, + .simple-mode-agent-actions button { width: 100%; - margin-left: 0; } - .simple-mode-flow-navigation > .secondary-button:only-child { - grid-column: 1; + .simple-mode-agent-config { + padding: 16px; } +} - .simple-mode-flow-navigation > .primary-button:only-child { - grid-column: 2; +@media (max-width: 680px) { + .simple-mode-api-model-heading { + align-items: flex-start; + } + + .simple-mode-api-model-heading .secondary-button { + width: 100%; + } + + .simple-mode-api-model-options { + grid-template-columns: 1fr; + } +} + +.agents-page-embedded .agent-workbench { + grid-template-columns: minmax(280px, 310px) minmax(0, 1fr); + gap: 18px; +} + +.agents-page-embedded .management-header { + align-items: center; + min-height: 48px; +} + +.agents-page-embedded .agent-embedded-header-copy { + display: grid; + gap: 4px; +} + +.agents-page-embedded .agent-embedded-header-copy h1 { + font-size: var(--font-size-panel-title); + line-height: var(--line-height-tight); + white-space: normal; +} + +.agents-page-embedded .agent-embedded-header-copy p { + margin: 0; + color: var(--theme-8a847c); + font-size: var(--font-size-meta); + line-height: var(--line-height-body); +} + +.agents-page-embedded .agent-client-list { + min-height: 0; + max-height: min(560px, calc(100vh - 260px)); + overflow: hidden; + gap: 16px; + padding: 16px; +} + +.agents-page-embedded .agent-list-heading { + gap: 11px; + min-height: 48px; + padding: 0 6px 13px; +} + +.agents-page-embedded .agent-list-heading > div, +.agents-page-embedded .agent-list-items button > span { + gap: 5px; +} + +.agents-page-embedded .agent-list-heading strong, +.agents-page-embedded .agent-list-items strong { + line-height: 1.3; +} + +.agents-page-embedded .agent-list-heading span, +.agents-page-embedded .agent-list-items small { + line-height: 1.4; +} + +.agents-page-embedded .agent-list-items { + min-height: 0; + max-height: min(466px, calc(100vh - 350px)); + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 4px; + scrollbar-gutter: stable; +} + +.agents-page-embedded .agent-list-items button { + grid-template-columns: 30px minmax(0, 1fr) 8px; + gap: 11px; + min-height: 66px; + padding: 10px 11px; +} + +@media (max-width: 760px) { + .agents-page-embedded .agent-workbench { + grid-template-columns: 1fr; + gap: 14px; + } + + .agents-page-embedded .agent-client-list { + max-height: 270px; + } + + .agents-page-embedded .agent-list-items { + grid-template-columns: 1fr; + max-height: 202px; + overflow-y: auto; + } +} + +.agents-page-embedded .agent-config-panel { + max-height: min(720px, calc(100vh - 260px)); + overflow-y: auto; + padding: 14px; + scrollbar-gutter: stable; +} + +.agents-page-embedded .agent-config-panel { + display: block; + overflow: hidden; + padding: 0; + background: var(--theme-fffdf8); +} + +.agents-page-embedded .agent-minimal-config { + display: grid; + min-width: 0; + gap: 18px; + padding: 20px; +} + +.agents-page-embedded .agent-minimal-client-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-width: 0; + padding-bottom: 16px; + border-bottom: 1px solid var(--theme-e3e1db); +} + +.agents-page-embedded .agent-minimal-client-icon { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid var(--theme-e3e1db); + border-radius: 10px; + background: var(--theme-faf9f5); + color: var(--theme-59544d); +} + +.agents-page-embedded .agent-minimal-client-icon .provider-logo { + width: 24px; + height: 24px; +} + +.agents-page-embedded .agent-minimal-client-summary > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.agents-page-embedded .agent-minimal-client-summary strong { + overflow: hidden; + color: var(--theme-2d2a26); + font-size: var(--font-size-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agents-page-embedded .agent-minimal-client-summary span:not(.agent-minimal-client-icon):not(.agent-minimal-version) { + overflow: hidden; + color: var(--theme-8a847c); + font-size: var(--font-size-meta); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agents-page-embedded .agent-minimal-version { + max-width: 120px; + overflow: hidden; + color: var(--theme-8a847c); + font-size: var(--font-size-meta); + text-overflow: ellipsis; + white-space: nowrap; +} + +.agents-page-embedded .agent-minimal-field { + display: grid; + gap: 8px; +} + +.agents-page-embedded .agent-minimal-field > label { + color: var(--theme-59544d); + font-size: var(--font-size-label); + font-weight: 700; +} + +.agents-page-embedded .agent-minimal-field .agent-model-picker, +.agents-page-embedded .agent-minimal-field .agent-model-trigger { + width: 100%; +} + +.agents-page-embedded .agent-minimal-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding-top: 2px; +} + +.agents-page-embedded .agent-minimal-actions button { + min-height: 42px; + min-width: 0; +} + +.agents-page-embedded .agent-minimal-message { + color: var(--theme-7a746c); + font-size: var(--font-size-meta); + line-height: var(--line-height-body); + overflow-wrap: anywhere; +} + +.agents-page-embedded .agent-core-config { + gap: 10px; +} + +.agents-page-embedded .agent-status-grid { + min-height: 0; + gap: 6px; +} + +.agents-page-embedded .agent-status-grid > div { + gap: 3px; + padding: 9px 10px; +} + +.agents-page-embedded .agent-core-setting-section { + gap: 9px; + padding: 12px; +} + +.agents-page-embedded .agent-section-heading { + gap: 8px; +} + +.agents-page-embedded .agent-section-heading strong { + font-size: var(--font-size-label); +} + +.agents-page-embedded .agent-model-trigger { + min-height: 40px; + padding: 6px 10px; +} + +.agents-page-embedded .agent-modification-buttons button { + min-height: 34px; + padding: 6px 8px; +} + +.agents-page-embedded .agent-config-footer { + min-height: 40px; + gap: 10px; + padding-top: 10px; +} + +@media (max-width: 760px) { + .agents-page-embedded .agent-config-panel { + max-height: calc(100vh - 180px); + } + + .agents-page-embedded .agent-minimal-config { + padding: 16px; } + .agents-page-embedded .agent-minimal-actions { + grid-template-columns: 1fr; + } } diff --git a/tests/usageAnalysis.test.ts b/tests/usageAnalysis.test.ts new file mode 100644 index 00000000..5fca3ecf --- /dev/null +++ b/tests/usageAnalysis.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { + categoryMetricValue, + failureRatePercent, + sortCategoriesByMetric, +} from '../src/services/usageAnalysis'; + +const categories = [ + { key: 'steady', requests: 100, failures: 2, tokens: 500 }, + { key: 'busy', requests: 200, failures: 1, tokens: 2_000 }, + { key: 'unhealthy', requests: 20, failures: 5, tokens: 100 }, +]; + +describe('usage analysis metrics', () => { + test('calculates an absolute failure-rate percentage', () => { + expect(failureRatePercent(categories[2])).toBe(25); + }); + + test('returns zero failure rate when there are no requests', () => { + expect(failureRatePercent({ key: 'empty', requests: 0, failures: 3, tokens: 0 })).toBe(0); + }); + + test.each([ + ['tokens', 500], + ['requests', 100], + ['failureRate', 2], + ] as const)('returns the %s metric value', (metric, expected) => { + expect(categoryMetricValue(categories[0], metric)).toBe(expected); + }); + + test.each([ + ['tokens', ['busy', 'steady', 'unhealthy']], + ['requests', ['busy', 'steady', 'unhealthy']], + ['failureRate', ['unhealthy', 'steady', 'busy']], + ] as const)('sorts categories by %s without mutating input', (metric, expected) => { + const original = [...categories]; + expect(sortCategoriesByMetric(categories, metric).map((item) => item.key)).toEqual(expected); + expect(categories).toEqual(original); + }); +});