diff --git a/crates/token_proxy_core/src/proxy/request_detail.rs b/crates/token_proxy_core/src/proxy/request_detail.rs index 33ec3f40..e72fe7cd 100644 --- a/crates/token_proxy_core/src/proxy/request_detail.rs +++ b/crates/token_proxy_core/src/proxy/request_detail.rs @@ -7,7 +7,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use super::request_body::ReplayableBody; const BODY_TOO_LARGE_MESSAGE: &str = "[body omitted: too large]"; -const REQUEST_DETAIL_CAPTURE_WINDOW: Duration = Duration::from_secs(30); +const DEFAULT_CAPTURE_WINDOW_SECS: u64 = 600; // 10 minutes +const PERMANENT_WINDOW_SECS: u64 = 0; const DISARMED_AT_MS: u64 = 0; #[derive(Clone, Default)] @@ -21,6 +22,7 @@ pub struct RequestDetailSnapshot { pub struct RequestDetailCaptureState { pub enabled: bool, pub expires_at_ms: Option, + pub is_permanent: bool, } impl RequestDetailCaptureState { @@ -28,19 +30,23 @@ impl RequestDetailCaptureState { Self { enabled: false, expires_at_ms: None, + is_permanent: false, } } - fn active(expires_at_ms: u64) -> Self { + fn active(expires_at_ms: u64, is_permanent: bool) -> Self { Self { enabled: true, expires_at_ms: Some(expires_at_ms), + is_permanent, } } } pub struct RequestDetailCapture { + #[allow(dead_code)] expires_at_ms: AtomicU64, + #[allow(dead_code)] window_ms: u64, now_ms: Arc u64 + Send + Sync>, on_change: Option>, @@ -50,7 +56,7 @@ impl RequestDetailCapture { pub fn new(on_change: Option>) -> Self { Self { expires_at_ms: AtomicU64::new(DISARMED_AT_MS), - window_ms: duration_to_millis(REQUEST_DETAIL_CAPTURE_WINDOW), + window_ms: duration_to_millis(Duration::from_secs(DEFAULT_CAPTURE_WINDOW_SECS)), now_ms: Arc::new(current_time_millis), on_change, } @@ -71,8 +77,21 @@ impl RequestDetailCapture { } pub fn arm(&self) -> RequestDetailCaptureState { - let expires_at_ms = (self.now_ms)().saturating_add(self.window_ms); - let state = RequestDetailCaptureState::active(expires_at_ms); + self.arm_with_window_secs(DEFAULT_CAPTURE_WINDOW_SECS) + } + + pub fn arm_permanent(&self) -> RequestDetailCaptureState { + self.arm_with_window_secs(PERMANENT_WINDOW_SECS) + } + + fn arm_with_window_secs(&self, window_secs: u64) -> RequestDetailCaptureState { + let window_ms = if window_secs == 0 { + u64::MAX + } else { + duration_to_millis(Duration::from_secs(window_secs)) + }; + let expires_at_ms = (self.now_ms)().saturating_add(window_ms); + let state = RequestDetailCaptureState::active(expires_at_ms, window_secs == 0); self.expires_at_ms.store(expires_at_ms, Ordering::SeqCst); self.notify(state); state @@ -100,8 +119,9 @@ impl RequestDetailCapture { return RequestDetailCaptureState::idle(); } - if (self.now_ms)() <= expires_at_ms { - return RequestDetailCaptureState::active(expires_at_ms); + let is_permanent = expires_at_ms == u64::MAX; + if is_permanent || (self.now_ms)() <= expires_at_ms { + return RequestDetailCaptureState::active(expires_at_ms, is_permanent); } // 窗口过期后仅第一个观察者负责清空并广播关闭,避免并发重复通知。 diff --git a/messages/en.json b/messages/en.json index ec4f662e..37b98f41 100644 --- a/messages/en.json +++ b/messages/en.json @@ -393,9 +393,17 @@ "logs_timing_upstream_first_body_chunk_ms": "Upstream first body chunk (ms)", "logs_timing_first_client_flush_ms": "First client flush (ms)", "logs_timing_first_output_ms": "First output (ms)", - "logs_capture_title": "Capture request details for 30 seconds", - "logs_capture_desc": "When enabled, captures request headers/bodies during a 30-second window; error responses are always recorded. Turns off automatically when the window ends.", - "logs_capture_status_ready": "Enabled: capture request details during the 30-second window", + "logs_capture_title": "Capture request details", + "logs_capture_desc": "When enabled, captures request headers/bodies for 10 minutes; error responses are always recorded.", + "logs_capture_idle_desc": "Select a mode (10 min or Permanent), then click the power button to start capturing.", + "logs_capture_permanent_desc": "Captures request headers/bodies permanently until disabled; error responses are always recorded.", + "logs_capture_mode_temporary": "10 min", + "logs_capture_mode_permanent": "Permanent", + "logs_capture_start": "Start capture", + "logs_capture_start_temporary": "Start capturing for 10 minutes", + "logs_capture_start_permanent": "Start permanent capture", + "logs_capture_stop": "Stop capture", + "logs_capture_status_ready": "Enabled: capture request details for 10 minutes", "logs_capture_status_idle": "Request details are not captured by default", "logs_capture_status_countdown": "{seconds}s left", "logs_detail_title": "Request details", diff --git a/messages/zh.json b/messages/zh.json index 99a692db..35b8e15f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -394,9 +394,17 @@ "logs_timing_upstream_first_body_chunk_ms": "上游首响应体 chunk (ms)", "logs_timing_first_client_flush_ms": "首次客户端 flush (ms)", "logs_timing_first_output_ms": "首输出 (ms)", - "logs_capture_title": "记录 30 秒内请求详情", - "logs_capture_desc": "开启后会在 30 秒窗口内记录请求的 header/body;错误响应始终记录,到时自动关闭。", - "logs_capture_status_ready": "已开启:30 秒窗口内记录详情", + "logs_capture_title": "记录请求详情", + "logs_capture_desc": "开启后记录 10 分钟内的请求 header/body;错误响应始终记录。", + "logs_capture_idle_desc": "选择一个模式(10 分钟或永久),然后点击电源按钮开始记录。", + "logs_capture_permanent_desc": "永久记录请求详情直到手动关闭;错误响应始终记录。", + "logs_capture_mode_temporary": "10 分钟", + "logs_capture_mode_permanent": "永久", + "logs_capture_start": "开始记录", + "logs_capture_start_temporary": "开始记录 10 分钟", + "logs_capture_start_permanent": "开始永久记录", + "logs_capture_stop": "停止记录", + "logs_capture_status_ready": "已开启:10 分钟窗口内记录详情", "logs_capture_status_idle": "默认不记录请求详情", "logs_capture_status_countdown": "剩余 {seconds}s", "logs_detail_title": "请求详情", diff --git a/src-tauri/src/commands/logs.rs b/src-tauri/src/commands/logs.rs index d1354f82..70222110 100644 --- a/src-tauri/src/commands/logs.rs +++ b/src-tauri/src/commands/logs.rs @@ -25,9 +25,14 @@ pub fn read_request_detail_capture( pub fn set_request_detail_capture( capture_state: tauri::State<'_, Arc>, enabled: bool, + permanent: bool, ) -> proxy::request_detail::RequestDetailCaptureState { if enabled { - capture_state.arm() + if permanent { + capture_state.arm_permanent() + } else { + capture_state.arm() + } } else { capture_state.disarm() } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 63994fc4..e643e726 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -29,7 +29,8 @@ pub use pricing::{ }; pub use providers::{providers_delete_accounts, providers_list_accounts_page}; pub use proxy::{ - prepare_relaunch, proxy_reload, proxy_restart, proxy_start, proxy_status, proxy_stop, + fetch_upstream_models, prepare_relaunch, proxy_reload, proxy_restart, proxy_start, + proxy_status, proxy_stop, }; #[derive(Clone, Copy)] diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 5d110fa9..8e6ce70e 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -1,6 +1,78 @@ use crate::{proxy, tray}; use tauri::Manager; +#[tauri::command] +pub async fn fetch_upstream_models( + provider: String, + base_url: String, + api_key: String, +) -> Result, String> { + let url = match provider.as_str() { + "openai" | "openai-response" => format!("{}/v1/models", base_url.trim_end_matches('/')), + "anthropic" => format!("{}/v1/models", base_url.trim_end_matches('/')), + "gemini" => format!("{}/v1beta/models", base_url.trim_end_matches('/')), + _ => { + return Err(format!("不支持的 provider: {}", provider)); + } + }; + + let client = reqwest::Client::new(); + let mut request = client.get(&url); + if !api_key.is_empty() { + request = request.header("Authorization", format!("Bearer {}", api_key)); + } + + let response = request + .send() + .await + .map_err(|err| format!("请求失败: {}", err))?; + + if !response.status().is_success() { + return Err(format!("返回错误: {}", response.status())); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|err| format!("解析失败: {}", err))?; + + let mut models: Vec = Vec::new(); + + if let Some(data) = body.get("data").and_then(|v| v.as_array()) { + for item in data { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + models.push(id.to_string()); + } + } + } + + if models.is_empty() { + if let Some(m) = body.get("models").and_then(|v| v.as_array()) { + for item in m { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + models.push(id.to_string()); + } + } + } + } + + if models.is_empty() { + if let Some(m) = body.get("modelNames").and_then(|v| v.as_array()) { + for item in m { + if let Some(id) = item.as_str() { + models.push(id.to_string()); + } + } + } + } + + if models.is_empty() { + return Err(format!("返回为空,body: {}", body)); + } + + Ok(models) +} + #[tauri::command] pub async fn proxy_status( proxy_service: tauri::State<'_, proxy::service::ProxyServiceHandle>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 223fe69a..0bf651ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,7 +26,7 @@ use commands::{ read_model_pricing_settings, read_proxy_config, read_request_detail_capture, read_request_log_detail, refresh_dashboard_model_discovery, reset_model_pricing_settings, save_model_pricing_settings, save_proxy_config, set_request_detail_capture, - write_claude_code_settings, write_codex_config, write_opencode_config, + write_claude_code_settings, write_codex_config, write_opencode_config,fetch_upstream_models, }; type ProxyServiceHandle = proxy::service::ProxyServiceHandle; @@ -262,6 +262,7 @@ pub fn run() { prepare_relaunch, proxy_restart, proxy_reload, + fetch_upstream_models, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/src/features/config/cards/upstreams/editor-dialog-form.tsx b/src/features/config/cards/upstreams/editor-dialog-form.tsx index 6ad38bea..e5062db6 100644 --- a/src/features/config/cards/upstreams/editor-dialog-form.tsx +++ b/src/features/config/cards/upstreams/editor-dialog-form.tsx @@ -1,4 +1,5 @@ -import { CirclePlus, HelpCircle } from "lucide-react"; +import { CirclePlus, HelpCircle, Loader2, RefreshCw } from "lucide-react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -27,6 +28,7 @@ import type { UpstreamForm, } from "@/features/config/types"; import { m } from "@/paraglide/messages.js"; +import { invoke } from "@tauri-apps/api/core"; const KIRO_ENDPOINT_INHERIT = "inherit"; @@ -394,10 +396,61 @@ function UpstreamModelMappingFields({ draft, onChangeDraft, }: UpstreamModelMappingFieldsProps) { + const [fetching, setFetching] = useState(false); + const [fetchError, setFetchError] = useState(""); + const handleAdd = () => { onChangeDraft({ modelMappings: [...draft.modelMappings, createModelMapping()] }); }; +const provider = draft.providers[0]?.trim() || ""; + const baseUrl = draft.baseUrl; + const apiKey = draft.apiKeys.trim(); + + const canFetch = + (provider === "openai" || + provider === "openai-response" || + provider === "anthropic" || + provider === "gemini") && + !!baseUrl; + + const handleFetchModels = async () => { + if (!baseUrl) { + setFetchError("请填写 Base URL"); + return; + } + setFetching(true); + setFetchError(""); + try { + const models = await invoke("fetch_upstream_models", { + provider, + baseUrl, + apiKey, + }); + if (models.length === 0) { + setFetchError("未获取到模型"); + return; + } + const existingTargets = new Set( + draft.modelMappings.map((m) => m.target).filter(Boolean) + ); + const newMappings = models + .filter((model) => !existingTargets.has(model)) + .map((model) => ({ + id: `mapping-${Date.now()}-${Math.random().toString(36).slice(2)}`, + pattern: model, + target: model, + })); + onChangeDraft({ + modelMappings: [...draft.modelMappings, ...newMappings], + }); + } catch (error) { + setFetchError(String(error)); + } finally { + setFetching(false); + } + }; + return (
@@ -412,6 +465,22 @@ function UpstreamModelMappingFields({ + {canFetch && ( + + )}
+ {fetchError && ( +

{fetchError}

+ )} {draft.modelMappings.length > 0 ? ( void + onToggle: (enabled: boolean, permanent: boolean) => void } } @@ -304,6 +304,10 @@ export function DashboardFilters({ onRefresh, capture, }: DashboardFiltersProps) { + const [captureMode, setCaptureMode] = useState<"temporary" | "permanent">( + capture?.isPermanent ? "permanent" : "temporary" + ); + return (