From 813fbf97a823988b089920f54314581fbd1cc8be Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 14 Aug 2026 17:42:42 +0800 Subject: [PATCH 1/3] feat(api-access): add drag-and-drop provider reordering --- bun.lock | 13 +++ package.json | 3 + src/i18n/ja.ts | 2 + src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + src/pages/ApiAccessPage.tsx | 175 +++++++++++++++++++++++++++++++++-- src/styles.css | 38 ++++++-- tests/providerConfig.test.ts | 32 +++++++ 8 files changed, 253 insertions(+), 14 deletions(-) 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/i18n/ja.ts b/src/i18n/ja.ts index 2fa359b..3d3b1ff 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -652,6 +652,7 @@ export const jaOverrides = { 'apiAccess.notice.deleted': '接続を削除しました', 'apiAccess.notice.enabled': '接続を有効にしました', 'apiAccess.notice.disabled': '接続を無効にしました', + 'apiAccess.notice.reordered': 'プロバイダーの順序を更新しました', 'apiAccess.title': 'API 接続', 'apiAccess.count': '接続 {count} 件', 'apiAccess.add': '追加', @@ -673,6 +674,7 @@ 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}', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c656185..a25180f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -652,6 +652,7 @@ export const en: Record = { 'apiAccess.notice.deleted': 'Connection deleted', 'apiAccess.notice.enabled': 'Connection enabled', 'apiAccess.notice.disabled': 'Connection disabled', + 'apiAccess.notice.reordered': 'Provider order updated', 'apiAccess.title': 'API Access', 'apiAccess.count': '{count} connections', 'apiAccess.add': 'Add', @@ -673,6 +674,7 @@ 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}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 6e3c340..0b98b36 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -650,6 +650,7 @@ export const zhCN = { 'apiAccess.notice.deleted': '接入已删除', 'apiAccess.notice.enabled': '接入已启用', 'apiAccess.notice.disabled': '接入已停用', + 'apiAccess.notice.reordered': '供应商排序已更新', 'apiAccess.title': 'API 接入', 'apiAccess.count': '{count} 个接入', 'apiAccess.add': '新增', @@ -671,6 +672,7 @@ 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}', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 70f07b3..35b2b47 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,57 @@ type ProviderRow = { remark: string; }; +const providerDragId = (row: Pick) => + `${row.section}:${row.index}`; + +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 }); + const style: CSSProperties = { + transform: DndCss.Transform.toString(transform), + transition, + }; + + return ( +
+ + {children} +
+ ); +} + type ProviderSaveResult = | { saved: true } | { saved: false; error: string; target: 'form' | 'models' }; @@ -610,7 +687,7 @@ const providerIdentityMatches = ( ); }; -type ProviderRecordIdentity = Pick< +export type ProviderRecordIdentity = Pick< ProviderRow, 'section' | 'index' | 'name' | 'apiKey' | 'baseUrl' >; @@ -640,6 +717,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 +780,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 +1089,34 @@ 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); + setNotice(t('apiAccess.notice.reordered')); + await loadProviders(); + } catch (requestError) { + 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) void reorderProviders(source, target); + }; + const totalCount = Object.values(records).reduce((sum, items) => sum + items.length, 0); const countForDefinition = (definition: ProviderDefinition) => @@ -1046,9 +1185,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 +1252,11 @@ export function ApiAccessPage() {
-
- ))} -
+ + ))} + + + )} diff --git a/src/styles.css b/src/styles.css index fd0efef..80762cc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3684,12 +3684,37 @@ 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 120ms ease, box-shadow 120ms ease, opacity 120ms ease; +} + +.real-provider-row.dragging { + opacity: 0.45; +} + +.real-provider-row.drag-over { + background: var(--theme-f4f1ea); + box-shadow: inset 0 2px 0 var(--theme-2d2a26); +} + +.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; } .provider-row-main, @@ -7535,7 +7560,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 +7680,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 +8004,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 +8096,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 +8104,7 @@ textarea:focus-visible { } .provider-row-actions { - grid-column: 1; + grid-column: 2; justify-content: flex-start; } diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 97e9927..358f5da 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -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', From ee41ac4922e1333757e0add84d3dab562a7b8dcd Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 14 Aug 2026 18:03:01 +0800 Subject: [PATCH 2/3] feat(api-access): improve provider drag-and-drop sorting animations --- src/i18n/ja.ts | 1 - src/i18n/locales/en.ts | 1 - src/i18n/locales/zh-CN.ts | 1 - src/pages/ApiAccessPage.tsx | 52 +++++++++++++++++++++++++++++++------ src/styles.css | 16 +++++++++--- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3d3b1ff..8d5c821 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -652,7 +652,6 @@ export const jaOverrides = { 'apiAccess.notice.deleted': '接続を削除しました', 'apiAccess.notice.enabled': '接続を有効にしました', 'apiAccess.notice.disabled': '接続を無効にしました', - 'apiAccess.notice.reordered': 'プロバイダーの順序を更新しました', 'apiAccess.title': 'API 接続', 'apiAccess.count': '接続 {count} 件', 'apiAccess.add': '追加', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a25180f..e441ef7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -652,7 +652,6 @@ export const en: Record = { 'apiAccess.notice.deleted': 'Connection deleted', 'apiAccess.notice.enabled': 'Connection enabled', 'apiAccess.notice.disabled': 'Connection disabled', - 'apiAccess.notice.reordered': 'Provider order updated', 'apiAccess.title': 'API Access', 'apiAccess.count': '{count} connections', 'apiAccess.add': 'Add', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0b98b36..08171bf 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -650,7 +650,6 @@ export const zhCN = { 'apiAccess.notice.deleted': '接入已删除', 'apiAccess.notice.enabled': '接入已启用', 'apiAccess.notice.disabled': '接入已停用', - 'apiAccess.notice.reordered': '供应商排序已更新', 'apiAccess.title': 'API 接入', 'apiAccess.count': '{count} 个接入', 'apiAccess.add': '新增', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 35b2b47..277df70 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -105,8 +105,17 @@ type ProviderRow = { remark: string; }; -const providerDragId = (row: Pick) => - `${row.section}:${row.index}`; +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, @@ -128,10 +137,26 @@ function SortableProviderRow({ transform, transition, isDragging, - } = useSortable({ id: providerDragId(row), disabled }); + } = 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 = { - transform: DndCss.Transform.toString(transform), - transition, + position: 'relative', + zIndex: isDragging ? 2 : undefined, + transform: DndCss.Transform.toString(renderedTransform), + transition: isDragging ? undefined : transition, }; return ( @@ -1100,9 +1125,9 @@ export function ApiAccessPage() { const nextRows = reorderProviderRecords(latestRows, rows, source, target); if (!nextRows) throw new Error(t('apiAccess.error.stale')); await managementApi.put(`/${source.section}`, nextRows); - setNotice(t('apiAccess.notice.reordered')); await loadProviders(); } catch (requestError) { + await loadProviders(); setError(requestErrorMessage(requestError)); } finally { setDragOverId(null); @@ -1114,7 +1139,18 @@ export function ApiAccessPage() { 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) void reorderProviders(source, target); + 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); @@ -1200,7 +1236,7 @@ export function ApiAccessPage() {
{rows.map((row) => ( Date: Fri, 14 Aug 2026 18:43:12 +0800 Subject: [PATCH 3/3] fix(health-check): record Gemini thought-only responses as successful usage --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 5 +- src-tauri/src/provider_health.rs | 182 ++++++++++++++++++++++++- src-tauri/src/tests/provider_health.rs | 33 +++++ src-tauri/src/usage.rs | 12 ++ src/i18n/ja.ts | 4 +- src/i18n/locales/en.ts | 4 +- src/i18n/locales/zh-CN.ts | 4 +- src/pages/ApiAccessPage.tsx | 16 ++- src/services/providerHealthCheck.ts | 40 +++++- tests/providerHealthCheck.test.ts | 2 + 12 files changed, 285 insertions(+), 21 deletions(-) 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 8d5c821..17a03c7 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -677,7 +677,7 @@ export const jaOverrides = { '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}', @@ -696,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 e441ef7..0aa5544 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -677,7 +677,7 @@ export const en: Record = { '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}', @@ -696,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 08171bf..e0e9c6c 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -675,7 +675,7 @@ export const zhCN = { '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}', @@ -694,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 277df70..5ea13b3 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1467,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 (
@@ -1479,12 +1489,10 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
{statusLabel(state)} - {checked?.firstTokenLatencyMs === undefined - ? '' - : ` · ${t('apiAccess.health.firstTokenInline', { latency: checked.firstTokenLatencyMs })}`} + {latencyInline ? ` · ${latencyInline}` : ''}