diff --git a/bun.lock b/bun.lock index abf4563..a9153b2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,9 @@ "": { "name": "cpa-gui", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2.0.0", "lucide-react": "^1.24.0", "react": "^19.0.0", @@ -59,6 +62,14 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -277,6 +288,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], diff --git a/package.json b/package.json index 9a905ed..4a1c659 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "tauri": "tauri" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2.0.0", "lucide-react": "^1.24.0", "react": "^19.0.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 055678d..74b07e4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.21" +version = "0.2.22" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9205527..4dbcfef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.21" +version = "0.2.22" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d618cab..900e587 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -24,7 +24,10 @@ use management_api::{ }; use oauth_browser::*; #[cfg(test)] -use provider_health::{provider_health_content_type_is_streaming, provider_health_stream_has_text}; +use provider_health::{ + provider_health_content_type_is_streaming, provider_health_stream_has_terminal_success, + provider_health_stream_has_text, +}; use agents::*; use app_settings::*; diff --git a/src-tauri/src/provider_health.rs b/src-tauri/src/provider_health.rs index bd30c49..f6dbc54 100644 --- a/src-tauri/src/provider_health.rs +++ b/src-tauri/src/provider_health.rs @@ -1,4 +1,7 @@ -use super::{build_http_client_with_proxy, is_loopback_host, GuiConfigState, APP_USER_AGENT}; +use super::{ + build_http_client_with_proxy, is_loopback_host, usage, GuiConfigState, APP_USER_AGENT, +}; +use chrono::Local; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use std::{ @@ -16,12 +19,29 @@ pub(crate) struct ProviderHealthProbeRequest { data: String, protocol: String, timeout_ms: Option, + #[serde(default)] + model: String, + #[serde(default)] + source: String, + #[serde(default)] + auth_index: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ProviderHealthProbeResponse { - first_token_latency_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + first_token_latency_ms: Option, + response_latency_ms: u64, +} + +#[derive(Default)] +struct ProviderHealthUsageTokens { + input_tokens: u64, + output_tokens: u64, + reasoning_tokens: u64, + cache_read_tokens: u64, + total_tokens: u64, } fn provider_health_value_has_text(value: Option<&serde_json::Value>) -> bool { @@ -122,6 +142,139 @@ pub(crate) fn provider_health_stream_has_text(protocol: &str, bytes: &[u8]) -> b }) } +fn provider_health_json_has_terminal_success(protocol: &str, value: &serde_json::Value) -> bool { + if protocol != "gemini" { + return false; + } + let exhausted_thinking_budget = value + .get("candidates") + .and_then(serde_json::Value::as_array) + .is_some_and(|candidates| { + candidates.iter().any(|candidate| { + candidate + .get("finishReason") + .and_then(serde_json::Value::as_str) + == Some("MAX_TOKENS") + }) + }); + let thoughts = value + .pointer("/usageMetadata/thoughtsTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(); + let total = value + .pointer("/usageMetadata/totalTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(); + exhausted_thinking_budget && thoughts > 0 && total >= thoughts +} + +pub(crate) fn provider_health_stream_has_terminal_success(protocol: &str, bytes: &[u8]) -> bool { + let text = String::from_utf8_lossy(bytes); + text.lines().any(|line| { + let line = line.trim(); + let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line); + if data.is_empty() || data == "[DONE]" { + return false; + } + serde_json::from_str::(data) + .ok() + .is_some_and(|value| provider_health_json_has_terminal_success(protocol, &value)) + }) +} + +fn provider_health_usage_tokens(protocol: &str, bytes: &[u8]) -> ProviderHealthUsageTokens { + let mut tokens = ProviderHealthUsageTokens::default(); + if protocol != "gemini" { + return tokens; + } + let text = String::from_utf8_lossy(bytes); + for line in text.lines() { + let line = line.trim(); + let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line); + let Some(usage) = serde_json::from_str::(data) + .ok() + .and_then(|value| value.get("usageMetadata").cloned()) + else { + continue; + }; + tokens.input_tokens = tokens.input_tokens.max( + usage + .get("promptTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(), + ); + tokens.output_tokens = tokens.output_tokens.max( + usage + .get("candidatesTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(), + ); + tokens.reasoning_tokens = tokens.reasoning_tokens.max( + usage + .get("thoughtsTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(), + ); + tokens.cache_read_tokens = tokens.cache_read_tokens.max( + usage + .get("cachedContentTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(), + ); + tokens.total_tokens = tokens.total_tokens.max( + usage + .get("totalTokenCount") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(), + ); + } + tokens +} + +fn provider_health_usage_provider(protocol: &str) -> &str { + match protocol { + "openai-responses" => "codex", + "openai-chat" => "openai", + "claude" => "claude", + "gemini" => "gemini", + _ => "unknown", + } +} + +fn persist_provider_health_success( + app: &tauri::AppHandle, + request: &ProviderHealthProbeRequest, + endpoint: &str, + latency_ms: u64, + ttft_ms: Option, + received: &[u8], +) { + let tokens = provider_health_usage_tokens(&request.protocol, received); + let event = serde_json::json!({ + "timestamp": Local::now().to_rfc3339(), + "latency_ms": latency_ms, + "ttft_ms": ttft_ms, + "source": request.source.as_str(), + "auth_index": request.auth_index.as_str(), + "failed": false, + "provider": provider_health_usage_provider(&request.protocol), + "model": request.model.as_str(), + "executor_type": "DesktopProviderHealthCheck", + "endpoint": endpoint, + "generate": false, + "tokens": { + "input_tokens": tokens.input_tokens, + "output_tokens": tokens.output_tokens, + "reasoning_tokens": tokens.reasoning_tokens, + "cache_read_tokens": tokens.cache_read_tokens, + "total_tokens": tokens.total_tokens, + }, + }); + if let Err(error) = usage::persist_local_usage_event(app, "desktop_health_check", event) { + eprintln!("保存桌面健康检测使用记录失败: {error}"); + } +} + pub(crate) fn provider_health_content_type_is_streaming(content_type: &str) -> bool { let content_type = content_type.to_ascii_lowercase(); content_type.contains("text/event-stream") @@ -131,6 +284,7 @@ pub(crate) fn provider_health_content_type_is_streaming(content_type: &str) -> b #[tauri::command] pub(crate) async fn provider_health_probe( + app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, request: ProviderHealthProbeRequest, ) -> Result { @@ -145,6 +299,7 @@ pub(crate) async fn provider_health_probe( ) { return Err("不支持的健康检测协议".to_string()); } + let endpoint = format!("POST {}", url.path()); if request.data.len() > 64 * 1024 { return Err("健康检测请求体过大".to_string()); } @@ -164,7 +319,7 @@ pub(crate) async fn provider_health_probe( "创建健康检测客户端失败", )?; let mut headers = reqwest::header::HeaderMap::new(); - for (name, value) in request.header { + for (name, value) in &request.header { let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) .map_err(|error| format!("健康检测请求头名称无效: {error}"))?; let value = reqwest::header::HeaderValue::from_str(&value) @@ -182,7 +337,7 @@ pub(crate) async fn provider_health_probe( let response = client .post(url) .headers(headers) - .body(request.data) + .body(request.data.clone()) .send() .await .map_err(|error| format!("健康检测请求失败: {error}"))?; @@ -214,9 +369,26 @@ pub(crate) async fn provider_health_probe( return Err("健康检测在限制范围内未收到模型首字".to_string()); } received.extend_from_slice(&chunk); + let elapsed_ms = started_at.elapsed().as_millis().max(1) as u64; if provider_health_stream_has_text(&request.protocol, &received) { + persist_provider_health_success( + &app, + &request, + &endpoint, + elapsed_ms, + Some(elapsed_ms), + &received, + ); return Ok(ProviderHealthProbeResponse { - first_token_latency_ms: started_at.elapsed().as_millis().max(1) as u64, + first_token_latency_ms: Some(elapsed_ms), + response_latency_ms: elapsed_ms, + }); + } + if provider_health_stream_has_terminal_success(&request.protocol, &received) { + persist_provider_health_success(&app, &request, &endpoint, elapsed_ms, None, &received); + return Ok(ProviderHealthProbeResponse { + first_token_latency_ms: None, + response_latency_ms: elapsed_ms, }); } } diff --git a/src-tauri/src/tests/provider_health.rs b/src-tauri/src/tests/provider_health.rs index e0f3e7b..25afc1e 100644 --- a/src-tauri/src/tests/provider_health.rs +++ b/src-tauri/src/tests/provider_health.rs @@ -63,4 +63,37 @@ data: {"delta":{"type":"text_delta","text":"H"}} "#; assert!(provider_health_stream_has_text("gemini", gemini_text)); + + let gemini_thought_only = br#"data: {"candidates":[{"content":{},"finishReason":"MAX_TOKENS","index":0}],"usageMetadata":{"promptTokenCount":1,"totalTokenCount":14,"thoughtsTokenCount":13},"modelVersion":"gemini-3.7-flash"} + +"#; + assert!(!provider_health_stream_has_text( + "gemini", + gemini_thought_only + )); + assert!(provider_health_stream_has_terminal_success( + "gemini", + gemini_thought_only + )); + + let gemini_empty_max_tokens = + br#"data: {"candidates":[{"finishReason":"MAX_TOKENS"}],"usageMetadata":{"totalTokenCount":1,"thoughtsTokenCount":0}} + +"#; + assert!(!provider_health_stream_has_terminal_success( + "gemini", + gemini_empty_max_tokens + )); + + let gemini_safety = br#"data: {"candidates":[{"finishReason":"SAFETY"}],"usageMetadata":{"totalTokenCount":14,"thoughtsTokenCount":13}} + +"#; + assert!(!provider_health_stream_has_terminal_success( + "gemini", + gemini_safety + )); + assert!(!provider_health_stream_has_terminal_success( + "openai-chat", + gemini_thought_only + )); } diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index f2815e1..bab32f0 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1106,6 +1106,18 @@ fn publish_collected_records(app: &tauri::AppHandle, saved: usize, message: &str } } +pub(crate) fn persist_local_usage_event( + app: &tauri::AppHandle, + collector_source: &str, + value: Value, +) -> Result { + let config = app.state::().snapshot()?; + let root = usage_root_dir()?; + let saved = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?; + publish_collected_records(app, saved, "已保存桌面健康检测使用记录"); + Ok(saved) +} + async fn wait_or_cancel(token: &CancellationToken, seconds: u64) { tokio::select! { _ = token.cancelled() => {}, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 2fa359b..17a03c7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -673,10 +673,11 @@ export const jaOverrides = { 'apiAccess.status.disabled': '無効', 'apiAccess.status.enabled': '有効', 'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ', + 'apiAccess.dragHandle': '「{remark}」をドラッグして並べ替えます。キーボードでは Space の後に矢印キーを使用します', 'apiAccess.health.action': 'ヘルスチェック', 'apiAccess.health.title': 'モデルヘルスチェック', 'apiAccess.health.description': '{provider}', - 'apiAccess.health.warning': '各モデルへ最小限のストリーミングリクエストを 1 回送信し、最初の出力文字を受信した時点で停止します。少額の料金が発生する場合があります。', + 'apiAccess.health.warning': '各モデルへ最小限のストリーミングリクエストを 1 回送信し、最初の出力文字または検証可能な正常終了応答を受信した時点で停止します。少額の料金が発生する場合があります。', 'apiAccess.health.search': 'モデル名またはエイリアスを検索', 'apiAccess.health.loadingModels': 'モデル一覧を読み込んでいます…', 'apiAccess.health.modelLoadFailed': 'モデル一覧を更新できませんでした:{error}', @@ -695,6 +696,8 @@ export const jaOverrides = { 'apiAccess.health.missingDirectKey': '初字遅延を測定するには、直接使用できる平文の API キーが必要です', 'apiAccess.health.firstTokenInline': '初字 {latency} ms', 'apiAccess.health.firstTokenLatencyResult': '初字遅延:{latency} ms', + 'apiAccess.health.responseInline': '応答 {latency} ms', + 'apiAccess.health.responseLatencyResult': '応答遅延:{latency} ms', 'common.edit': '編集', 'apiAccess.error.enterBaseUrl': '先に Base URL を入力してください', 'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c656185..0aa5544 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -673,10 +673,11 @@ export const en: Record = { 'apiAccess.status.disabled': 'Disabled', 'apiAccess.status.enabled': 'Enabled', 'apiAccess.toggleAria': '{remark} connection {action} switch', + 'apiAccess.dragHandle': 'Drag to reorder {remark}; with a keyboard, press Space then use the arrow keys', 'apiAccess.health.action': 'Health Check', 'apiAccess.health.title': 'Model Health Check', 'apiAccess.health.description': '{provider}', - 'apiAccess.health.warning': 'The check sends one minimal streaming request to each model, stops at the first output token, and may incur a small charge.', + 'apiAccess.health.warning': 'The check sends one minimal streaming request to each model, stops at the first output token or a verifiable successful terminal response, and may incur a small charge.', 'apiAccess.health.search': 'Search model names or aliases', 'apiAccess.health.loadingModels': 'Loading the model list…', 'apiAccess.health.modelLoadFailed': 'Could not refresh the model list: {error}', @@ -695,6 +696,8 @@ export const en: Record = { 'apiAccess.health.missingDirectKey': 'A directly usable plaintext API key is required to measure first-token latency', 'apiAccess.health.firstTokenInline': 'TTFT {latency} ms', 'apiAccess.health.firstTokenLatencyResult': 'Time to first token: {latency} ms', + 'apiAccess.health.responseInline': 'Response {latency} ms', + 'apiAccess.health.responseLatencyResult': 'Response latency: {latency} ms', 'common.edit': 'Edit', 'apiAccess.error.enterBaseUrl': 'Enter a Base URL first', 'apiAccess.error.noAvailableModels': 'No available models found', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 6e3c340..e0e9c6c 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -671,10 +671,11 @@ export const zhCN = { 'apiAccess.status.disabled': '已停用', 'apiAccess.status.enabled': '已启用', 'apiAccess.toggleAria': '{remark} 接入{action}开关', + 'apiAccess.dragHandle': '拖动“{remark}”调整排序;键盘可按空格后使用方向键', 'apiAccess.health.action': '健康检测', 'apiAccess.health.title': '模型健康检测', 'apiAccess.health.description': '{provider}', - 'apiAccess.health.warning': '检测会对每个模型发送一次最小流式请求,收到模型首字后立即停止,并可能产生少量费用。', + 'apiAccess.health.warning': '检测会对每个模型发送一次最小流式请求,收到模型首字或可验证的正常终止响应后立即停止,并可能产生少量费用。', 'apiAccess.health.search': '搜索模型名称或别名', 'apiAccess.health.loadingModels': '正在获取模型列表…', 'apiAccess.health.modelLoadFailed': '模型列表刷新失败:{error}', @@ -693,6 +694,8 @@ export const zhCN = { 'apiAccess.health.missingDirectKey': '缺少可直接使用的明文 API 密钥,无法测量首字延迟', 'apiAccess.health.firstTokenInline': '首字 {latency} ms', 'apiAccess.health.firstTokenLatencyResult': '首字延迟:{latency} ms', + 'apiAccess.health.responseInline': '响应 {latency} ms', + 'apiAccess.health.responseLatencyResult': '响应延迟:{latency} ms', 'common.edit': '编辑', 'apiAccess.error.enterBaseUrl': '请先填写 Base URL', 'apiAccess.error.noAvailableModels': '未发现可用模型', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 70f07b3..5ea13b3 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,9 +1,35 @@ -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type CSSProperties, + FormEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + sortableKeyboardCoordinates, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS as DndCss } from '@dnd-kit/utilities'; import { Check, Edit3, Filter, + GripVertical, LoaderCircle, Plus, RefreshCw, @@ -79,6 +105,82 @@ type ProviderRow = { remark: string; }; +const providerDragId = ( + row: Pick, +) => { + const identity = `${row.section}\u0000${row.name}\u0000${row.apiKey}\u0000${row.baseUrl}`; + let hash = 2166136261; + for (let index = 0; index < identity.length; index += 1) { + hash ^= identity.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `${row.section}:${(hash >>> 0).toString(36)}`; +}; + +function SortableProviderRow({ + row, + disabled, + dragLabel, + isDragOver, + children, +}: { + row: ProviderRow; + disabled: boolean; + dragLabel: string; + isDragOver: boolean; + children: ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: providerDragId(row), + disabled, + transition: { + duration: 220, + easing: 'cubic-bezier(0.2, 0, 0, 1)', + }, + }); + const renderedTransform = transform + ? { + ...transform, + scaleX: isDragging ? 1.012 : transform.scaleX, + scaleY: isDragging ? 1.012 : transform.scaleY, + } + : transform; + const style: CSSProperties = { + position: 'relative', + zIndex: isDragging ? 2 : undefined, + transform: DndCss.Transform.toString(renderedTransform), + transition: isDragging ? undefined : transition, + }; + + return ( +
+ + {children} +
+ ); +} + type ProviderSaveResult = | { saved: true } | { saved: false; error: string; target: 'form' | 'models' }; @@ -610,7 +712,7 @@ const providerIdentityMatches = ( ); }; -type ProviderRecordIdentity = Pick< +export type ProviderRecordIdentity = Pick< ProviderRow, 'section' | 'index' | 'name' | 'apiKey' | 'baseUrl' >; @@ -640,6 +742,35 @@ export const resolveProviderRecordIndex = ( return primaryMatches.length === 1 ? primaryMatches[0].index : -1; }; +export const reorderProviderRecords = ( + records: Record[], + scopeRows: ProviderRecordIdentity[], + source: ProviderRecordIdentity, + target: ProviderRecordIdentity, +) => { + const sourceIndex = resolveProviderRecordIndex(records, source); + const targetIndex = resolveProviderRecordIndex(records, target); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return null; + + const scopedIndexes = scopeRows + .map((row) => resolveProviderRecordIndex(records, row)) + .filter((index, position, indexes) => index >= 0 && indexes.indexOf(index) === position) + .sort((left, right) => left - right); + const sourcePosition = scopedIndexes.indexOf(sourceIndex); + const targetPosition = scopedIndexes.indexOf(targetIndex); + if (sourcePosition < 0 || targetPosition < 0) return null; + + const reordered = scopedIndexes.map((index) => stripResponseFields(records[index])); + const [moved] = reordered.splice(sourcePosition, 1); + reordered.splice(targetPosition, 0, moved); + + const next = records.map(stripResponseFields); + scopedIndexes.forEach((recordIndex, position) => { + next[recordIndex] = reordered[position]; + }); + return next; +}; + export const providerRecordWithDisabledState = ( section: ProviderSection, record: Record, @@ -674,8 +805,13 @@ export function ApiAccessPage() { const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); const [healthDialogRow, setHealthDialogRow] = useState(null); + const [dragOverId, setDragOverId] = useState(null); const activeDefinition = definitionFor(activeCategory); const activeSection = activeDefinition.section; + const dragSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const loadProviders = useCallback(async () => { setLoading(true); @@ -978,6 +1114,45 @@ export function ApiAccessPage() { } }; + const reorderProviders = async (source: ProviderRow, target: ProviderRow) => { + if (source.section !== target.section || source.index === target.index) return; + setBusy(true); + setError(''); + setNotice(''); + try { + const latestConfig = await managementApi.get('/config'); + const latestRows = sectionRecordsFromConfig(latestConfig, source.section); + const nextRows = reorderProviderRecords(latestRows, rows, source, target); + if (!nextRows) throw new Error(t('apiAccess.error.stale')); + await managementApi.put(`/${source.section}`, nextRows); + await loadProviders(); + } catch (requestError) { + await loadProviders(); + setError(requestErrorMessage(requestError)); + } finally { + setDragOverId(null); + setBusy(false); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + setDragOverId(null); + const source = rows.find((row) => providerDragId(row) === String(event.active.id)); + const target = rows.find((row) => providerDragId(row) === String(event.over?.id ?? '')); + if (!source || !target || source.index === target.index) return; + + const optimisticRows = reorderProviderRecords( + records[source.section], + rows, + source, + target, + ); + if (optimisticRows) { + setRecords((current) => ({ ...current, [source.section]: optimisticRows })); + } + void reorderProviders(source, target); + }; + const totalCount = Object.values(records).reduce((sum, items) => sum + items.length, 0); const countForDefinition = (definition: ProviderDefinition) => @@ -1046,9 +1221,27 @@ export function ApiAccessPage() { {filter ? t('apiAccess.empty.tryKeyword') : t('apiAccess.empty.addFirst')} ) : ( -
- {rows.map((row) => ( -
+ setNotice('')} + onDragOver={({ over }) => setDragOverId(over ? String(over.id) : null)} + onDragCancel={() => setDragOverId(null)} + onDragEnd={handleDragEnd} + > + +
+ {rows.map((row) => ( +
{row.remark || row.name} @@ -1095,9 +1288,11 @@ export function ApiAccessPage() {
-
- ))} -
+ + ))} + + + )} @@ -1272,6 +1467,16 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) { ? t('apiAccess.health.timeout') : checked.error || t('apiAccess.health.failed') : ''; + const latencyTitle = checked?.firstTokenLatencyMs !== undefined + ? t('apiAccess.health.firstTokenLatencyResult', { latency: checked.firstTokenLatencyMs }) + : checked?.responseLatencyMs !== undefined + ? t('apiAccess.health.responseLatencyResult', { latency: checked.responseLatencyMs }) + : error || undefined; + const latencyInline = checked?.firstTokenLatencyMs !== undefined + ? t('apiAccess.health.firstTokenInline', { latency: checked.firstTokenLatencyMs }) + : checked?.responseLatencyMs !== undefined + ? t('apiAccess.health.responseInline', { latency: checked.responseLatencyMs }) + : ''; return (
@@ -1284,12 +1489,10 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
{statusLabel(state)} - {checked?.firstTokenLatencyMs === undefined - ? '' - : ` · ${t('apiAccess.health.firstTokenInline', { latency: checked.firstTokenLatencyMs })}`} + {latencyInline ? ` · ${latencyInline}` : ''}