Merge pull request #133 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-14 18:43:39 +08:00
commit 63a4b74118
16 files changed
+579 -35

No files matched your search

+13
View File
@@ -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=="],
+3
View File
@@ -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",
+1 -1
View File
@@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.21"
version = "0.2.22"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -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"
+4 -1
View File
@@ -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::*;
+177 -5
View File
@@ -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<u64>,
#[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<u64>,
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::<serde_json::Value>(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::<serde_json::Value>(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<u64>,
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<ProviderHealthProbeResponse, String> {
@@ -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,
});
}
}
+33
View File
@@ -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
));
}
+12
View File
@@ -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<usize, String> {
let config = app.state::<GuiConfigState>().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() => {},
+4 -1
View File
@@ -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': '利用可能なモデルが見つかりません',
+4 -1
View File
@@ -673,10 +673,11 @@ export const en: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'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',
+4 -1
View File
@@ -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': '未发现可用模型',
+215 -12
View File
@@ -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<ProviderRow, 'section' | 'name' | 'apiKey' | 'baseUrl'>,
) => {
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 (
<article
ref={setNodeRef}
style={style}
className={`real-provider-row${isDragging ? ' dragging' : ''}${isDragOver && !isDragging ? ' drag-over' : ''}`}
>
<button
type="button"
className="icon-button quiet provider-drag-handle"
disabled={disabled}
aria-label={dragLabel}
title={dragLabel}
{...attributes}
{...listeners}
>
<GripVertical size={17} aria-hidden="true" />
</button>
{children}
</article>
);
}
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<string, unknown>[],
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<string, unknown>,
@@ -674,8 +805,13 @@ export function ApiAccessPage() {
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const [healthDialogRow, setHealthDialogRow] = useState<ProviderRow | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(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() {
<span>{filter ? t('apiAccess.empty.tryKeyword') : t('apiAccess.empty.addFirst')}</span>
</div>
) : (
<div className="real-provider-list">
{rows.map((row) => (
<article className="real-provider-row" key={`${row.section}-${row.index}-${row.authIndex}`}>
<DndContext
sensors={dragSensors}
collisionDetection={closestCenter}
onDragStart={() => setNotice('')}
onDragOver={({ over }) => setDragOverId(over ? String(over.id) : null)}
onDragCancel={() => setDragOverId(null)}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rows.map(providerDragId)}
strategy={verticalListSortingStrategy}
>
<div className="real-provider-list">
{rows.map((row) => (
<SortableProviderRow
key={providerDragId(row)}
row={row}
disabled={busy || rows.length < 2}
dragLabel={t('apiAccess.dragHandle', { remark: row.remark || row.name })}
isDragOver={dragOverId === providerDragId(row)}
>
<div className="provider-row-main">
<div className="provider-row-title">
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
@@ -1095,9 +1288,11 @@ export function ApiAccessPage() {
<Trash2 size={16} />
</button>
</div>
</article>
))}
</div>
</SortableProviderRow>
))}
</div>
</SortableContext>
</DndContext>
)}
</section>
</div>
@@ -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 (
<div className={`provider-health-model-row ${checked?.status === 'failed' ? 'failed' : ''}`} key={model.name}>
<div className="provider-health-model-name">
@@ -1284,12 +1489,10 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
</div>
<span
className={`state-pill provider-health-pill ${state?.status === 'checking' ? 'checking' : checked?.status === 'healthy' ? 'success' : checked?.status === 'failed' ? 'error' : ''}`}
title={checked?.firstTokenLatencyMs === undefined ? error || undefined : t('apiAccess.health.firstTokenLatencyResult', { latency: checked.firstTokenLatencyMs })}
title={latencyTitle}
>
{statusLabel(state)}
{checked?.firstTokenLatencyMs === undefined
? ''
: ` · ${t('apiAccess.health.firstTokenInline', { latency: checked.firstTokenLatencyMs })}`}
{latencyInline ? ` · ${latencyInline}` : ''}
</span>
<button
type="button"
+34 -6
View File
@@ -9,11 +9,15 @@ export type ProviderHealthProbe = {
header: Record<string, string>;
data: string;
protocol: 'openai-chat' | 'openai-responses' | 'claude' | 'gemini';
model: string;
source: string;
authIndex: string;
};
export type ProviderHealthProbeResult = {
success: boolean;
firstTokenLatencyMs?: number;
responseLatencyMs?: number;
error?: string;
timedOut?: boolean;
errorCode?: 'missing-direct-key';
@@ -101,13 +105,21 @@ export function buildProviderHealthProbe(
const root = endpointRoot(provider, baseUrl);
const headers = { ...customHeaders };
const key = apiKey.trim();
const normalizedModel = provider === 'gemini'
? model.trim().replace(/^models\//i, '')
: model.trim();
const metadata = {
model: normalizedModel,
source: key,
authIndex: authIndex.trim(),
};
setHeaderIfMissing(headers, 'Content-Type', 'application/json');
if (provider === 'gemini') {
if (key) setHeaderIfMissing(headers, 'x-goog-api-key', key);
else if (authIndex) setHeaderIfMissing(headers, 'x-goog-api-key', '$TOKEN$');
const normalizedModel = model.trim().replace(/^models\//i, '');
return {
...metadata,
url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
header: headers,
protocol: 'gemini',
@@ -125,11 +137,12 @@ export function buildProviderHealthProbe(
else if (authIndex) setHeaderIfMissing(headers, 'x-api-key', '$TOKEN$');
setHeaderIfMissing(headers, 'anthropic-version', '2023-06-01');
return {
...metadata,
url: `${root}/v1/messages`,
header: headers,
protocol: 'claude',
data: JSON.stringify({
model: model.trim(),
model: normalizedModel,
max_tokens: 16,
stream: true,
messages: [{ role: 'user', content: 'hi' }],
@@ -142,11 +155,12 @@ export function buildProviderHealthProbe(
if (provider === 'codex') {
return {
...metadata,
url: `${root}/v1/responses`,
header: headers,
protocol: 'openai-responses',
data: JSON.stringify({
model: model.trim(),
model: normalizedModel,
input: 'hi',
stream: true,
}),
@@ -154,11 +168,12 @@ export function buildProviderHealthProbe(
}
return {
...metadata,
url: openAIChatCompletionsEndpoint(baseUrl),
header: headers,
protocol: 'openai-chat',
data: JSON.stringify({
model: model.trim(),
model: normalizedModel,
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
@@ -197,18 +212,31 @@ export async function checkProviderHealthProbe(
errorCode: 'missing-direct-key',
};
}
const response = await invoke<{ firstTokenLatencyMs: number }>('provider_health_probe', {
const response = await invoke<{
firstTokenLatencyMs?: number;
responseLatencyMs: number;
}>('provider_health_probe', {
request: {
protocol: probe.protocol,
timeoutMs,
data: probe.data,
header: probe.header,
url: probe.url,
model: probe.model,
source: probe.source,
authIndex: probe.authIndex,
},
});
const firstTokenLatencyMs = Number.isFinite(response.firstTokenLatencyMs)
? Math.max(1, Math.round(response.firstTokenLatencyMs as number))
: undefined;
const responseLatencyMs = Number.isFinite(response.responseLatencyMs)
? Math.max(1, Math.round(response.responseLatencyMs))
: firstTokenLatencyMs;
return {
success: true,
firstTokenLatencyMs: Math.max(1, Math.round(response.firstTokenLatencyMs)),
...(firstTokenLatencyMs === undefined ? {} : { firstTokenLatencyMs }),
...(responseLatencyMs === undefined ? {} : { responseLatencyMs }),
};
} catch (error) {
const message = errorMessage(error);
+40 -6
View File
@@ -3684,12 +3684,45 @@ textarea:focus-visible {
.real-provider-row {
display: grid;
grid-template-columns: minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content;
grid-template-columns: 32px minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content;
align-items: start;
gap: 16px;
min-height: 88px;
padding: 14px 0;
border-bottom: 1px solid var(--theme-eeece6);
transition: background 180ms ease, box-shadow 180ms ease, opacity 160ms ease;
}
.real-provider-row.dragging {
border-radius: 10px;
background: var(--theme-faf9f5);
box-shadow: 0 10px 28px rgb(45 42 38 / 14%);
opacity: 0.96;
}
.real-provider-row.drag-over {
background: color-mix(in srgb, var(--theme-f4f1ea) 65%, transparent);
}
.provider-drag-handle {
margin-top: -2px;
cursor: grab;
color: var(--theme-9b958c);
touch-action: none;
}
.provider-drag-handle:hover {
color: var(--theme-2d2a26);
}
.provider-drag-handle:active {
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.real-provider-row {
transition: none;
}
}
.provider-row-main,
@@ -7535,7 +7568,7 @@ textarea:focus-visible {
}
.real-provider-row {
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-columns: 32px minmax(0, 1fr) max-content;
}
.provider-row-meta {
@@ -7655,7 +7688,7 @@ textarea:focus-visible {
}
.real-provider-row {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: 32px minmax(0, 1fr) auto;
}
.real-auth-file-row {
@@ -7979,10 +8012,11 @@ textarea:focus-visible {
}
.real-provider-row {
grid-template-columns: 1fr;
grid-template-columns: 32px minmax(0, 1fr);
}
.provider-row-actions {
grid-column: 2;
flex-wrap: wrap;
justify-content: flex-start;
}
@@ -8070,7 +8104,7 @@ textarea:focus-visible {
}
.real-provider-row {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: 32px minmax(0, 1fr);
}
.real-auth-file-row {
@@ -8078,7 +8112,7 @@ textarea:focus-visible {
}
.provider-row-actions {
grid-column: 1;
grid-column: 2;
justify-content: flex-start;
}
+32
View File
@@ -14,6 +14,7 @@ import {
providerCategoryMatchesRecord,
providerRecordWithDisabledState,
providerSectionOrder,
reorderProviderRecords,
resolveProviderRecordIndex,
sectionRecordsFromConfig,
stripResponseFields,
@@ -295,6 +296,37 @@ describe('API 接入配置合并', () => {
expect(index).toBe(1);
});
it('拖动筛选后的供应商时保留隐藏供应商所在槽位', () => {
const records = [
{ name: 'OpenAI A', 'auth-index': 'runtime-a' },
{ name: 'DeepSeek', 'base-url': 'https://api.deepseek.com' },
{ name: 'OpenAI B', 'auth-index': 'runtime-b' },
];
const openAiA = {
section: 'openai-compatibility' as const,
index: 0,
name: 'OpenAI A',
apiKey: '',
baseUrl: '',
};
const openAiB = {
section: 'openai-compatibility' as const,
index: 2,
name: 'OpenAI B',
apiKey: '',
baseUrl: '',
};
const reordered = reorderProviderRecords(records, [openAiA, openAiB], openAiB, openAiA);
expect(reordered?.map((record) => record.name)).toEqual([
'OpenAI B',
'DeepSeek',
'OpenAI A',
]);
expect(reordered?.[0]['auth-index']).toBeUndefined();
});
it('校验自定义请求头格式', () => {
expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({
Authorization: 'Bearer abc:def',
+2
View File
@@ -154,6 +154,8 @@ describe('API 接入健康检测', () => {
);
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
expect(probe.protocol).toBe('gemini');
expect(probe.model).toBe('gemini-2.5-flash');
expect(probe.source).toBe('gemini-key');
});
it('检测全部时每个模型只请求一次并保持结果顺序', async () => {