From d94a05a528b4d73a1a905ebdd89fc0760da9834e Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 20 Jul 2026 01:28:35 +0800 Subject: [PATCH 1/4] fix(storage): aggregate usageStats byTool globally, not per-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → 使用统计 → 工具统计 repeated tool-name rows (multiple Bash rows) because byTool was built via sessions.flatMap(toolStatsFromMessages), producing one aggregate row per tool PER session. byProvider/byModel were already global (aggregateBy over the flattened modelLogs), so only byTool was affected. Replace toolStatsFromMessages with aggregateToolStats(sessions, since): tool_call.id ↔ tool_result.toolUseId matching stays session-scoped (ids are only unique within a session) while calls/success/errors/durations merge into a single row keyed by tool name. Rows sort by call count desc (tool name tie-break) to match the byProvider/byModel convention and give deterministic output. Row shape and the sole consumer (UsageToolsPanel) are unchanged. Regression test: two sessions each calling Bash (+ one Read) yield one merged Bash row (calls 2, success 1, errors 1, avg duration 30). --- .../__tests__/settings-store-usage.test.ts | 64 +++++++++++++++ packages/storage/src/settings-store.ts | 79 +++++++++++-------- 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/packages/storage/src/__tests__/settings-store-usage.test.ts b/packages/storage/src/__tests__/settings-store-usage.test.ts index 7067a52a4b..90127cd23a 100644 --- a/packages/storage/src/__tests__/settings-store-usage.test.ts +++ b/packages/storage/src/__tests__/settings-store-usage.test.ts @@ -131,6 +131,70 @@ describe('SettingsStore.usageStats request logs', () => { } }); + it('merges tool stats by tool name across sessions instead of one row per session', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-usage-bytool-')); + try { + // Two sessions each call Bash; the second also calls Read. Tool-call ids + // are only unique within a session, so both sessions reuse `bash`/`read` + // ids to prove result matching stays session-scoped after the merge. + const bashTurn = (sessionId: string, error: boolean, duration: number): StoredMessage[] => [ + { type: 'tool_call', id: 'bash', turnId: 't1', ts: 10, toolName: 'Bash', displayName: '终端', args: {} }, + { + type: 'tool_result', + id: 'bash-result', + turnId: 't1', + ts: 10 + duration, + toolUseId: 'bash', + isError: error, + durationMs: duration, + content: { kind: 'text', text: error ? 'failed' : 'ok' }, + }, + ]; + + await seedSession(workspaceRoot, makeHeader({ id: 'session-a' }), [ + ...bashTurn('session-a', false, 20), + { type: 'tool_call', id: 'read', turnId: 't1', ts: 12, toolName: 'Read', displayName: '读取', args: {} }, + { + type: 'tool_result', + id: 'read-result', + turnId: 't1', + ts: 40, + toolUseId: 'read', + isError: false, + durationMs: 28, + content: { kind: 'text', text: 'ok' }, + }, + ]); + await seedSession(workspaceRoot, makeHeader({ id: 'session-b' }), [ + ...bashTurn('session-b', true, 40), + ]); + + const stats = await createSettingsStore(workspaceRoot).usageStats('all'); + + // One row per tool name — never a duplicate Bash row per session. + assert.equal(stats.byTool.length, 2, 'byTool must have one row per unique tool'); + const bash = stats.byTool.find((row) => row.tool === 'Bash'); + assert.ok(bash, 'a single merged Bash row must exist'); + assert.equal(bash.calls, 2, 'Bash calls merge across sessions'); + assert.equal(bash.success, 1, 'the successful Bash call is counted'); + assert.equal(bash.errors, 1, 'the failed Bash call is counted'); + assert.equal(bash.avgDurationMs, 30, 'Bash duration averages (20 + 40) / 2 across sessions'); + + const read = stats.byTool.find((row) => row.tool === 'Read'); + assert.ok(read); + assert.equal(read.calls, 1); + assert.equal(read.avgDurationMs, 28); + + // Rows are ordered by call count desc so the busiest tool leads. + assert.deepEqual( + stats.byTool.map((row) => row.tool), + ['Bash', 'Read'], + ); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } + }); + it('keeps valid usage rows when one session message line is corrupt', async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-settings-usage-corrupt-line-')); try { diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts index e6a29af309..6605498f95 100644 --- a/packages/storage/src/settings-store.ts +++ b/packages/storage/src/settings-store.ts @@ -259,7 +259,7 @@ class FileSettingsStore implements SettingsStore { })); }); - const toolRows = sessions.flatMap(({ messages }) => toolStatsFromMessages(messages, since)); + const toolRows = aggregateToolStats(sessions, since); const toolLogs = sessions.flatMap(({ header, messages }) => toolLogRowsFromMessages(header, messages, since), ); @@ -460,48 +460,57 @@ function aggregateBy(logs: UsageStats['logs'], key: 'provider' | 'model') { .sort((a, b) => b.requests - a.requests) as never; } -function toolStatsFromMessages( - messages: UsageMessage[], +// Aggregate tool usage by tool name across EVERY session so 工具统计 shows one row +// per tool (not one row per tool-per-session, which repeated the same tool name). +// tool_call.id ↔ tool_result.toolUseId matching stays scoped to each session's +// messages — ids are only unique within a session — while the counts, failures, +// and durations merge into a single global row keyed by tool name. +function aggregateToolStats( + sessions: Array<{ messages: UsageMessage[] }>, since: number | null, ): UsageStats['byTool'] { - const calls = messages.filter( - (message): message is UsageToolCallMessage => message.type === 'tool_call', - ); - const results = new Map( - messages - .filter((message): message is UsageToolResultMessage => message.type === 'tool_result') - .map((message) => [message.toolUseId, message]), - ); const rows = new Map< string, { calls: number; success: number; errors: number; totalDuration: number; durationCount: number } >(); - for (const call of calls) { - if (since && call.ts < since) continue; - const result = results.get(call.id); - const current = rows.get(call.toolName) ?? { - calls: 0, - success: 0, - errors: 0, - totalDuration: 0, - durationCount: 0, - }; - current.calls += 1; - if (result?.isError) current.errors += 1; - else current.success += 1; - if (result?.durationMs !== undefined) { - current.totalDuration += result.durationMs; - current.durationCount += 1; + for (const { messages } of sessions) { + const results = new Map( + messages + .filter((message): message is UsageToolResultMessage => message.type === 'tool_result') + .map((message) => [message.toolUseId, message]), + ); + const calls = messages.filter( + (message): message is UsageToolCallMessage => message.type === 'tool_call', + ); + for (const call of calls) { + if (since && call.ts < since) continue; + const result = results.get(call.id); + const current = rows.get(call.toolName) ?? { + calls: 0, + success: 0, + errors: 0, + totalDuration: 0, + durationCount: 0, + }; + current.calls += 1; + if (result?.isError) current.errors += 1; + else current.success += 1; + if (result?.durationMs !== undefined) { + current.totalDuration += result.durationMs; + current.durationCount += 1; + } + rows.set(call.toolName, current); } - rows.set(call.toolName, current); } - return [...rows.entries()].map(([tool, row]) => ({ - tool, - calls: row.calls, - success: row.success, - errors: row.errors, - avgDurationMs: row.durationCount ? Math.round(row.totalDuration / row.durationCount) : 0, - })); + return [...rows.entries()] + .map(([tool, row]) => ({ + tool, + calls: row.calls, + success: row.success, + errors: row.errors, + avgDurationMs: row.durationCount ? Math.round(row.totalDuration / row.durationCount) : 0, + })) + .sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool)); } function toolLogRowsFromMessages( From 182520225582fe08ea19c1ce513c693e3c20be94 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 20 Jul 2026 01:49:31 +0800 Subject: [PATCH 2/4] refactor(settings): extract connection-detail controller + enabled-model manager (arch R7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider-connection-detail.tsx was the renderer's densest state cluster (983 lines, 12 useState in ConnectionDetailInner + a shared keyed action guard + lifecycle gate). Split following the settings house pattern: - use-connection-detail.ts (controller hook, ~520 lines): owns the whole mutually-exclusive action state machine (save / test / fetch-models / save-enabled-models / set-default / delete, all gated through one useKeyedActionGuard), the credential-presence probe, the prop-sync effects, every derived flag, and the pure snapshot/equality helpers + oauthLoginServiceFor. Returns a controller object; ZERO behavior change, every identifier and statement moved verbatim. - provider-enabled-model-manager.tsx (~187 lines): the roving-tabindex model-list editor (owns query + activeRowId) — an independent cluster. - provider-connection-detail.tsx (983 -> 373 lines): thin view that destructures the hook, plus ConnectionEndpointField / the OAuth & GitHub Copilot re-login notices (kept here as presentation). The connection/model/test/delete sub-clusters stay in one controller on purpose: they share a single keyed-action-guard state machine (mutual exclusion) and one lifecycle/current-ness gate, and cross-call (save auto-fetches models) — splitting them further would thread the guard and risk behavior drift. --- .../settings/provider-connection-detail.tsx | 698 ++---------------- .../provider-enabled-model-manager.tsx | 187 +++++ .../settings/use-connection-detail.ts | 522 +++++++++++++ 3 files changed, 753 insertions(+), 654 deletions(-) create mode 100644 apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx create mode 100644 apps/desktop/src/renderer/settings/use-connection-detail.ts diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index fbf8be403a..bd325e283f 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -1,15 +1,5 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; -import { - PROVIDER_DEFAULTS, - connectionEnabledModelIds, - generalizedErrorMessageChinese, - type ConnectionTestResult, - type LlmConnection, - type ModelCatalogEntry, - type ModelInfo, - type ProviderType, -} from '@maka/core'; -import { providerAuthRequiresSecret, providerAuthSupportsApiKey } from '@maka/core/llm-connections'; +import { useState } from 'react'; +import { PROVIDER_DEFAULTS, generalizedErrorMessageChinese } from '@maka/core'; import { Alert, AlertAction, @@ -19,67 +9,23 @@ import { FieldDescription, FieldRoot, Input, - Item, - ItemActions, - ItemContent, - ItemMedia, - ItemTitle, Label, - OverlayScrollArea, RelativeTime, useMountedRef, useToast, useUiLocale, } from '@maka/ui'; -import { Check } from '@maka/ui/icons'; import { PasswordInput } from './password-input'; -import { buildCatalogModelChoices } from '../model-catalog-choices'; import { providerDisplay } from './provider-display'; -import { connectionChipStatus } from './provider-connection-status'; -import { useActionGuard, useKeyedActionGuard } from './use-action-guard'; -import { useOAuthLoginFlow, type OAuthLoginFlowBridge } from './use-oauth-login-flow'; +import { EnabledModelManager } from './provider-enabled-model-manager'; +import { useActionGuard } from './use-action-guard'; +import { useOAuthLoginFlow } from './use-oauth-login-flow'; +import type { CredentialPresenceStatus } from './provider-panel-shared'; import { - connectionLastTestMessageDisplay, - connectionTestFailureMessage, - providerPanelActionErrorMessage, - type ConnectionsBridge, - type CredentialPresenceStatus, -} from './provider-panel-shared'; - -// Maps an OAuth model-connection provider type to the browser-loopback login -// service that can re-run its authorization from inside the connection dialog. Only -// the loopback / polling services (Codex, Antigravity) are one-button-drivable -// here; Claude's paste-code flow and plain API-key providers return null so the -// notice falls back to prose instead of rendering a dead button. -interface OAuthLoginService { - bridge: OAuthLoginFlowBridge; - display: { name: string; shortName: string }; -} - -function oauthLoginServiceFor(providerType: ProviderType): OAuthLoginService | null { - switch (providerType) { - case 'openai-codex': - return { - bridge: window.maka.openAiCodex as unknown as OAuthLoginFlowBridge, - display: { name: 'OpenAI Codex', shortName: 'Codex' }, - }; - case 'gemini-cli': - return { - bridge: window.maka.antigravitySubscription as unknown as OAuthLoginFlowBridge, - display: { name: 'Google Antigravity', shortName: 'Antigravity' }, - }; - default: - return null; - } -} - -interface ConnectionDetailProps { - bridge: ConnectionsBridge; - connection: LlmConnection; - isDefault: boolean; - onChanged(): Promise; - onDeleted(): Promise; -} + useConnectionDetail, + type ConnectionDetailProps, + type OAuthLoginService, +} from './use-connection-detail'; export function ConnectionDetail(props: ConnectionDetailProps) { const defaults = PROVIDER_DEFAULTS[props.connection.providerType]; @@ -135,369 +81,41 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { const { connection } = props; const defaults = PROVIDER_DEFAULTS[connection.providerType]; const display = providerDisplay(connection.providerType, locale); - const [apiKey, setApiKey] = useState(''); - const [hasSecret, setHasSecret] = useState( - defaults.authKind === 'none' ? true : 'loading', - ); - const [baseUrl, setBaseUrl] = useState(connection.baseUrl ?? defaults.baseUrl ?? ''); - const [models, setModels] = useState(connection.models ?? []); - const [enabledModelIds, setEnabledModelIds] = useState(() => connectionEnabledModelIds(connection)); - // Backend persists the model-list source alongside the model cache, so a - // Settings restart no longer has to infer "fetched" from a non-empty array. - // A successful provider response may legitimately contain 0 models; source - // and length remain separate facts. - const [modelSource, setModelSource] = useState<'fetched' | 'fallback'>( - connection.modelSource ?? 'fallback', - ); - const syncedConnectionSnapshotRef = useRef(connectionDetailSnapshot(connection, defaults.baseUrl)); - const [busy, setBusy] = useState(false); - const [testing, setTesting] = useState(false); - const [fetchingModels, setFetchingModels] = useState(false); - const [savingEnabledModels, setSavingEnabledModels] = useState(false); - const [settingDefault, setSettingDefault] = useState(false); - const [deleting, setDeleting] = useState(false); - const connectionDetailActionGuard = useKeyedActionGuard< - 'save' | 'test' | 'fetch-models' | 'save-enabled-models' | 'set-default' | 'delete' - >(); - const connectionDetailMountedRef = useMountedRef(); - const connectionDetailLifecycleRef = useRef(0); - const toast = useToast(); - const supportsApiKey = providerAuthSupportsApiKey(connection.providerType); - const needsOAuth = defaults.authKind === 'oauth_token'; - const oauthLoginService = needsOAuth ? oauthLoginServiceFor(connection.providerType) : null; - const usesGitHubCopilotLogin = connection.providerType === 'github-copilot'; - const hasFixedOAuthBaseUrl = needsOAuth && Boolean(defaults.baseUrl); - const requiresCredential = providerAuthRequiresSecret(connection.providerType); - const probesCredential = supportsApiKey || needsOAuth; - const credentialProbePending = requiresCredential && (hasSecret === 'loading' || hasSecret === 'error'); - const hasUsableCredential = !requiresCredential || hasSecret === true; - const credentialTroubleshootingCopy = needsOAuth - ? 'OAuth 登录 / 代理设置' - : '模型密钥 / 服务地址 / 代理设置'; - const savedBaseUrl = connection.baseUrl ?? defaults.baseUrl; - const draftBaseUrl = baseUrl; - const hasApiKeyChange = apiKey.length > 0; - const hasBaseUrlChange = draftBaseUrl !== savedBaseUrl; - // Persistent single-line credential hint. Rendered in every hasSecret state - // (including `false`) so the description row never adds or drops a line as the - // async secret probe resolves — the dialog height stays constant. - const apiKeyStatusHint = - hasSecret === true - ? '已设置,粘贴新值可替换' - : hasSecret === 'loading' - ? '正在读取状态' - : hasSecret === 'error' - ? '凭据状态未知' - : '尚未设置密钥'; - const detailActionBusy = busy || testing || fetchingModels || savingEnabledModels || settingDefault || deleting; - const issue = connectionChipStatus(connection); - const lastTestMessage = connectionLastTestMessageDisplay(connection.lastTestMessage); - const lastTestAtMs = connection.lastTestAt ? Date.parse(connection.lastTestAt) : NaN; - - useEffect(() => { - connectionDetailLifecycleRef.current += 1; - return () => { - connectionDetailLifecycleRef.current += 1; - connectionDetailActionGuard.reset(); - }; - }, [connection.slug]); - - function isConnectionDetailCurrent(lifecycle: number): boolean { - return connectionDetailMountedRef.current && connectionDetailLifecycleRef.current === lifecycle; - } - - useEffect(() => { - const lifecycle = connectionDetailLifecycleRef.current; - if (!probesCredential) { - if (isConnectionDetailCurrent(lifecycle)) setHasSecret(true); - return; - } - setHasSecret('loading'); - void props.bridge - .hasSecret(connection.slug) - .then((next) => { - if (isConnectionDetailCurrent(lifecycle)) setHasSecret(next); - }) - .catch((error) => { - if (!isConnectionDetailCurrent(lifecycle)) return; - setHasSecret('error'); - toast.error('读取模型凭据状态失败', providerPanelActionErrorMessage(error)); - }); - }, [props.bridge, connection.slug, probesCredential, toast]); - - useEffect(() => { - const nextSnapshot = connectionDetailSnapshot(connection, defaults.baseUrl); - const previousSnapshot = syncedConnectionSnapshotRef.current; - const localStillSynced = connectionDetailDraftMatchesSnapshot( - { baseUrl, models, modelSource }, - previousSnapshot, - ); - const localAlreadyMatchesNext = connectionDetailDraftMatchesSnapshot( - { baseUrl, models, modelSource }, - nextSnapshot, - ); - - if (connection.slug !== previousSnapshot.slug || (apiKey.length === 0 && localStillSynced)) { - setBaseUrl(nextSnapshot.baseUrl); - setModels(nextSnapshot.models); - setModelSource(nextSnapshot.modelSource); - syncedConnectionSnapshotRef.current = nextSnapshot; - return; - } - - if (localAlreadyMatchesNext) { - syncedConnectionSnapshotRef.current = nextSnapshot; - } - }, [ - apiKey.length, + const { + apiKey, + setApiKey, + hasSecret, baseUrl, - connection, - defaults.baseUrl, - modelSource, - models, - ]); - - useEffect(() => { - setEnabledModelIds(connectionEnabledModelIds(connection)); - }, [connection.defaultModel, connection.enabledModelIds, connection.slug]); - - // Picker entries come from the same catalog merge path as Chat and Daily - // Review, but use the local unsaved editor draft for model/default changes. - const modelChoices = buildCatalogModelChoices({ - slug: connection.slug, - providerType: connection.providerType, - defaultModel: connection.defaultModel, - models: modelSource === 'fetched' || models.length > 0 ? models : undefined, - modelSource, - modelsFetchedAt: connection.modelsFetchedAt, - }); - - async function save() { - const releaseSave = connectionDetailActionGuard.beginExclusive('save'); - if (!releaseSave) return; - const lifecycle = connectionDetailLifecycleRef.current; - setBusy(true); - let saved = false; - try { - await props.bridge.update(connection.slug, { - baseUrl, - ...(apiKey ? { apiKey } : {}), - }); - saved = true; - if (!isConnectionDetailCurrent(lifecycle)) return; - const wroteNewKey = apiKey.length > 0; - setApiKey(''); - const nextHasSecret = probesCredential ? await props.bridge.hasSecret(connection.slug) : true; - if (!isConnectionDetailCurrent(lifecycle)) return; - setHasSecret(nextHasSecret); - await props.onChanged(); - if (!isConnectionDetailCurrent(lifecycle)) return; - // Auto-fetch live model list as soon as the secret is in place. Without - // this, the user lands on a Settings · 模型 row whose `defaultModel` - // dropdown only contains the static fallback list (e.g. Z.ai → just - // glm-4.7 / 4.6 / 4.5), which looks like Maka doesn't support newer - // models. Auto-fetch on save closes that gap. - if ((!requiresCredential || nextHasSecret) && (wroteNewKey || models.length === 0)) { - void refreshModels({ silent: true }); - } - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - if (saved && probesCredential) { - setHasSecret('error'); - } - toast.error( - saved ? '刷新模型连接失败' : '保存模型连接失败', - providerPanelActionErrorMessage(error), - ); - } finally { - releaseSave(); - if (isConnectionDetailCurrent(lifecycle)) setBusy(false); - } - } - - async function updateEnabledModels(nextIds: string[]) { - if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return; - const next = connectionEnabledModelIds({ - defaultModel: connection.defaultModel, - enabledModelIds: nextIds, - }); - if (modelIdListsEqual(next, enabledModelIds)) return; - const previous = enabledModelIds; - const lifecycle = connectionDetailLifecycleRef.current; - const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models'); - if (!releaseSaveModels) return; - setSavingEnabledModels(true); - setEnabledModelIds(next); - let saved = false; - try { - await props.bridge.update(connection.slug, { enabledModelIds: next }); - saved = true; - if (!isConnectionDetailCurrent(lifecycle)) return; - await props.onChanged(); - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - if (!saved) setEnabledModelIds(previous); - toast.error( - saved ? '刷新模型连接失败' : '保存启用模型失败', - providerPanelActionErrorMessage(error), - ); - } finally { - releaseSaveModels(); - if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false); - } - } - - async function runTest() { - const releaseTest = connectionDetailActionGuard.beginExclusive('test'); - if (!releaseTest) return; - const lifecycle = connectionDetailLifecycleRef.current; - setTesting(true); - try { - const result: ConnectionTestResult = await props.bridge.test(connection.slug, { model: connection.defaultModel }); - if (!isConnectionDetailCurrent(lifecycle)) return; - if (result.ok) { - toast.success( - `连接成功 · ${connection.name}`, - `${result.modelTested} · ${result.latencyMs} ms`, - ); - } else { - toast.error( - `连接失败 · ${connection.name}`, - connectionTestFailureMessage(result, { - auth: `鉴权失败,请确认 ${credentialTroubleshootingCopy} 后重试。`, - recheck: `检查 ${credentialTroubleshootingCopy} 后重试。`, - }), - ); - } - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - const message = providerPanelActionErrorMessage(error); - toast.error(`连接测试出错 · ${connection.name}`, message); - } finally { - releaseTest(); - if (isConnectionDetailCurrent(lifecycle)) setTesting(false); - } - } - - async function refreshModels(opts: { silent?: boolean } = {}) { - // A silent refresh (the post-save auto-fetch) may overlap other actions; - // a manual one is gated on the whole sheet like the other buttons. - const releaseFetch = opts.silent - ? connectionDetailActionGuard.begin('fetch-models') - : connectionDetailActionGuard.beginExclusive('fetch-models'); - if (!releaseFetch) return; - const lifecycle = connectionDetailLifecycleRef.current; - setFetchingModels(true); - try { - // Backend (xuan `81ed044`) returns a `ModelDiscoveryResult` envelope — - // `{ models, source: 'fetched' | 'fallback', fetchedAt }` — and throws - // a generalizedErrorMessage on failure. We trust `result.source` - // verbatim instead of inferring from list length, so a provider that - // legitimately returns 0 models still reads as 'fetched'. - const result = await props.bridge.fetchModels(connection.slug); - if (!isConnectionDetailCurrent(lifecycle)) return; - setModels(result.models); - setModelSource(result.source); - await props.onChanged(); - if (!isConnectionDetailCurrent(lifecycle)) return; - if (!opts.silent) { - toast.success(`已拉取 ${result.models.length} 个模型 · ${connection.name}`); - } - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - const message = providerPanelActionErrorMessage(error); - // Leave the previously-known source / models intact (so the dropdown - // doesn't suddenly empty out), but downgrade the source label back to - // 'fallback' if we have nothing fresh to show — the failed fetch - // means whatever's on screen is not from the latest probe. - if (models.length === 0) setModelSource('fallback'); - toast.error( - `拉取模型失败 · ${connection.name}`, - `${message} · 当前继续显示静态列表,请确认 ${credentialTroubleshootingCopy} 后重试。`, - ); - } finally { - releaseFetch(); - if (isConnectionDetailCurrent(lifecycle)) setFetchingModels(false); - } - } - - async function setAsDefault() { - const releaseSetDefault = connectionDetailActionGuard.beginExclusive('set-default'); - if (!releaseSetDefault) return; - if (!connection.enabled) { - releaseSetDefault(); - toast.error('无法设为默认', '这个模型连接已禁用,请重新登录或启用后再设为默认。'); - return; - } - const lifecycle = connectionDetailLifecycleRef.current; - setSettingDefault(true); - try { - await props.bridge.setDefault(connection.slug); - if (!isConnectionDetailCurrent(lifecycle)) return; - await props.onChanged(); - if (!isConnectionDetailCurrent(lifecycle)) return; - toast.success(`已设为默认 · ${connection.name}`); - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - toast.error('切换默认失败', providerPanelActionErrorMessage(error)); - } finally { - releaseSetDefault(); - if (isConnectionDetailCurrent(lifecycle)) setSettingDefault(false); - } - } - - async function remove() { - const releaseDelete = connectionDetailActionGuard.beginExclusive('delete'); - if (!releaseDelete) return; - const lifecycle = connectionDetailLifecycleRef.current; - setDeleting(true); - const ok = await toast.confirm({ - title: `删除供应商 ${connection.name}?`, - description: '将从模型连接中移除这个供应商配置;如需再次使用,需要重新添加凭据。', - confirmLabel: '删除', - cancelLabel: '取消', - destructive: true, - }); - if (!isConnectionDetailCurrent(lifecycle)) return; - if (!ok) { - releaseDelete(); - setDeleting(false); - return; - } - let deleted = false; - try { - await props.bridge.delete(connection.slug); - deleted = true; - if (!isConnectionDetailCurrent(lifecycle)) return; - await props.onDeleted(); - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - toast.error( - deleted ? '刷新模型列表失败' : '删除模型连接失败', - providerPanelActionErrorMessage(error), - ); - } finally { - releaseDelete(); - if (isConnectionDetailCurrent(lifecycle)) setDeleting(false); - } - } - - // After a successful in-dialog OAuth re-login, re-probe the credential - // presence (an expired token still read hasSecret===true, so we must - // refresh it) and reload the connection so its status leaves 需要重新登录. - async function refreshAfterRelogin() { - const lifecycle = connectionDetailLifecycleRef.current; - try { - const nextHasSecret = await props.bridge.hasSecret(connection.slug); - if (!isConnectionDetailCurrent(lifecycle)) return; - setHasSecret(nextHasSecret); - } catch (error) { - if (!isConnectionDetailCurrent(lifecycle)) return; - setHasSecret('error'); - toast.error('读取模型凭据状态失败', providerPanelActionErrorMessage(error)); - } - await props.onChanged(); - } + setBaseUrl, + enabledModelIds, + modelChoices, + busy, + testing, + fetchingModels, + settingDefault, + deleting, + detailActionBusy, + supportsApiKey, + needsOAuth, + usesGitHubCopilotLogin, + oauthLoginService, + hasFixedOAuthBaseUrl, + credentialProbePending, + hasUsableCredential, + apiKeyStatusHint, + hasApiKeyChange, + hasBaseUrlChange, + issue, + lastTestMessage, + lastTestAtMs, + save, + updateEnabledModels, + runTest, + refreshModels, + setAsDefault, + remove, + refreshAfterRelogin, + } = useConnectionDetail(props); return (
@@ -753,231 +371,3 @@ function OAuthReloginNotice(props: { ); } - -type ConnectionDetailSnapshot = { - slug: string; - baseUrl: string; - models: ModelInfo[]; - modelSource: 'fetched' | 'fallback'; -}; - -function connectionDetailSnapshot( - connection: LlmConnection, - defaultBaseUrl: string | undefined, -): ConnectionDetailSnapshot { - return { - slug: connection.slug, - baseUrl: connection.baseUrl ?? defaultBaseUrl ?? '', - models: connection.models ?? [], - modelSource: connection.modelSource ?? 'fallback', - }; -} - -function connectionDetailDraftMatchesSnapshot( - draft: { - baseUrl: string; - models: ModelInfo[]; - modelSource: 'fetched' | 'fallback'; - }, - snapshot: ConnectionDetailSnapshot, -): boolean { - return draft.baseUrl === snapshot.baseUrl && - draft.modelSource === snapshot.modelSource && - modelListsEqual(draft.models, snapshot.models); -} - -function modelListsEqual(left: ModelInfo[], right: ModelInfo[]): boolean { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const leftModel = left[index]; - const rightModel = right[index]; - if (leftModel.id !== rightModel.id) return false; - if (leftModel.contextWindow !== rightModel.contextWindow) return false; - if (leftModel.maxOutputTokens !== rightModel.maxOutputTokens) return false; - if (leftModel.capabilities?.chat !== rightModel.capabilities?.chat) return false; - if (leftModel.capabilities?.vision !== rightModel.capabilities?.vision) return false; - if (leftModel.capabilities?.reasoning !== rightModel.capabilities?.reasoning) return false; - if (leftModel.capabilities?.functionCalling !== rightModel.capabilities?.functionCalling) return false; - if (leftModel.capabilities?.imageGeneration !== rightModel.capabilities?.imageGeneration) return false; - } - return true; -} - -/** - * Enabled-model editor. The full candidate catalog (live-fetched merged with - * the static fallback, via buildCatalogModelChoices) is shown persistently - * inside a fixed-height scroll region; enabled models read as checked. Clicking - * a row toggles it through the shared `enabledModelIds` path, so a newly - * enabled model reaches the chat model picker with no side state. The default - * model stays checked and locked (`connectionEnabledModelIds` always keeps it - * enabled). Search filters the same list in place, so neither the provider's - * model count nor an active filter changes the dialog height. - */ -function EnabledModelManager(props: { - modelChoices: ModelCatalogEntry[]; - enabledModelIds: string[]; - defaultModel: string; - disabled: boolean; - onChange(ids: string[]): void; -}) { - const [query, setQuery] = useState(''); - // Roving tabindex (composite-widget keyboard pattern): the whole list is ONE - // Tab stop. Without this every row button is a Tab stop, and a large catalog - // (OpenRouter's fallback list is 260+ rows) walls off everything below the - // list for keyboard users. Only the active row has tabIndex=0; ArrowUp/Down - // + Home/End move activity (focus scrolls the row into view), Space/Enter - // toggle via the button's native activation. - const [activeRowId, setActiveRowId] = useState(null); - const modelListRef = useRef(null); - const enabled = useMemo(() => new Set(props.enabledModelIds), [props.enabledModelIds]); - const rows = useMemo(() => { - const byId = new Map(props.modelChoices.map((model) => [model.id, model] as const)); - const seen = new Set(); - const list: Array<{ id: string; label: string }> = []; - for (const model of props.modelChoices) { - if (!model.canUseAsChatDefault) continue; - seen.add(model.id); - list.push({ id: model.id, label: modelDisplayLabel(model) }); - } - // Always surface an already-enabled model even if it is not a current - // chat-default candidate (a stale id, or a model dropped from the latest - // catalog), so the user can still toggle it off. - for (const id of props.enabledModelIds) { - if (seen.has(id)) continue; - seen.add(id); - const model = byId.get(id); - list.push({ id, label: model ? modelDisplayLabel(model) : id }); - } - return list; - }, [props.modelChoices, props.enabledModelIds]); - - const visibleRows = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return rows; - return rows.filter( - (row) => row.id.toLowerCase().includes(normalizedQuery) || row.label.toLowerCase().includes(normalizedQuery), - ); - }, [rows, query]); - - function toggle(id: string) { - if (props.disabled || id === props.defaultModel) return; - const next = enabled.has(id) - ? props.enabledModelIds.filter((candidate) => candidate !== id) - : [...props.enabledModelIds, id]; - props.onChange(next); - } - - // The default-model row is disabled (natively unfocusable), so arrow-key - // traversal skips it — consistent with Tab behavior. - const focusableRows = visibleRows.filter((row) => row.id !== props.defaultModel); - const resolvedActiveRowId = activeRowId !== null && focusableRows.some((row) => row.id === activeRowId) - ? activeRowId - : focusableRows[0]?.id ?? null; - - function onModelListKeyDown(event: KeyboardEvent) { - if (focusableRows.length === 0) return; - const currentIndex = Math.max(0, focusableRows.findIndex((row) => row.id === resolvedActiveRowId)); - let nextIndex: number; - switch (event.key) { - case 'ArrowDown': - nextIndex = Math.min(currentIndex + 1, focusableRows.length - 1); - break; - case 'ArrowUp': - nextIndex = Math.max(currentIndex - 1, 0); - break; - case 'Home': - nextIndex = 0; - break; - case 'End': - nextIndex = focusableRows.length - 1; - break; - default: - return; - } - event.preventDefault(); - const next = focusableRows[nextIndex]; - setActiveRowId(next.id); - // Focus scrolls the row into view inside the fixed-height scroll region. - modelListRef.current - ?.querySelector(`[data-model-id="${CSS.escape(next.id)}"]`) - ?.focus(); - } - - return ( -
-
- 启用模型 {props.enabledModelIds.length} - 勾选的模型会出现在模型选择器中。 -
- setQuery(event.currentTarget.value)} - placeholder="搜索模型" - autoComplete="off" - spellCheck={false} - disabled={props.disabled} - aria-label="搜索模型" - /> - -
    - {visibleRows.length === 0 ? ( -
  • - {rows.length === 0 ? '暂无可选模型,请先更新模型目录。' : '没有匹配的模型。'} -
  • - ) : ( - visibleRows.map((row) => { - const isEnabled = enabled.has(row.id); - const isDefault = row.id === props.defaultModel; - return ( -
  • - toggle(row.id)} - onFocus={() => setActiveRowId(row.id)} - /> - } - > - - - {row.label} - - {isDefault && ( - - 默认 - - )} - -
  • - ); - }) - )} -
-
-
- ); -} - -function modelDisplayLabel(model: Pick): string { - return model.displayName?.trim() || model.id; -} - -function modelIdListsEqual(left: string[], right: string[]): boolean { - return left.length === right.length && left.every((id, index) => id === right[index]); -} diff --git a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx new file mode 100644 index 0000000000..60d92ae95b --- /dev/null +++ b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx @@ -0,0 +1,187 @@ +import { useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import type { ModelCatalogEntry } from '@maka/core'; +import { + Input, + Item, + ItemActions, + ItemContent, + ItemMedia, + ItemTitle, + OverlayScrollArea, +} from '@maka/ui'; +import { Check } from '@maka/ui/icons'; + +/** + * Enabled-model editor. The full candidate catalog (live-fetched merged with + * the static fallback, via buildCatalogModelChoices) is shown persistently + * inside a fixed-height scroll region; enabled models read as checked. Clicking + * a row toggles it through the shared `enabledModelIds` path, so a newly + * enabled model reaches the chat model picker with no side state. The default + * model stays checked and locked (`connectionEnabledModelIds` always keeps it + * enabled). Search filters the same list in place, so neither the provider's + * model count nor an active filter changes the dialog height. + */ +export function EnabledModelManager(props: { + modelChoices: ModelCatalogEntry[]; + enabledModelIds: string[]; + defaultModel: string; + disabled: boolean; + onChange(ids: string[]): void; +}) { + const [query, setQuery] = useState(''); + // Roving tabindex (composite-widget keyboard pattern): the whole list is ONE + // Tab stop. Without this every row button is a Tab stop, and a large catalog + // (OpenRouter's fallback list is 260+ rows) walls off everything below the + // list for keyboard users. Only the active row has tabIndex=0; ArrowUp/Down + // + Home/End move activity (focus scrolls the row into view), Space/Enter + // toggle via the button's native activation. + const [activeRowId, setActiveRowId] = useState(null); + const modelListRef = useRef(null); + const enabled = useMemo(() => new Set(props.enabledModelIds), [props.enabledModelIds]); + const rows = useMemo(() => { + const byId = new Map(props.modelChoices.map((model) => [model.id, model] as const)); + const seen = new Set(); + const list: Array<{ id: string; label: string }> = []; + for (const model of props.modelChoices) { + if (!model.canUseAsChatDefault) continue; + seen.add(model.id); + list.push({ id: model.id, label: modelDisplayLabel(model) }); + } + // Always surface an already-enabled model even if it is not a current + // chat-default candidate (a stale id, or a model dropped from the latest + // catalog), so the user can still toggle it off. + for (const id of props.enabledModelIds) { + if (seen.has(id)) continue; + seen.add(id); + const model = byId.get(id); + list.push({ id, label: model ? modelDisplayLabel(model) : id }); + } + return list; + }, [props.modelChoices, props.enabledModelIds]); + + const visibleRows = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return rows; + return rows.filter( + (row) => row.id.toLowerCase().includes(normalizedQuery) || row.label.toLowerCase().includes(normalizedQuery), + ); + }, [rows, query]); + + function toggle(id: string) { + if (props.disabled || id === props.defaultModel) return; + const next = enabled.has(id) + ? props.enabledModelIds.filter((candidate) => candidate !== id) + : [...props.enabledModelIds, id]; + props.onChange(next); + } + + // The default-model row is disabled (natively unfocusable), so arrow-key + // traversal skips it — consistent with Tab behavior. + const focusableRows = visibleRows.filter((row) => row.id !== props.defaultModel); + const resolvedActiveRowId = activeRowId !== null && focusableRows.some((row) => row.id === activeRowId) + ? activeRowId + : focusableRows[0]?.id ?? null; + + function onModelListKeyDown(event: KeyboardEvent) { + if (focusableRows.length === 0) return; + const currentIndex = Math.max(0, focusableRows.findIndex((row) => row.id === resolvedActiveRowId)); + let nextIndex: number; + switch (event.key) { + case 'ArrowDown': + nextIndex = Math.min(currentIndex + 1, focusableRows.length - 1); + break; + case 'ArrowUp': + nextIndex = Math.max(currentIndex - 1, 0); + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = focusableRows.length - 1; + break; + default: + return; + } + event.preventDefault(); + const next = focusableRows[nextIndex]; + setActiveRowId(next.id); + // Focus scrolls the row into view inside the fixed-height scroll region. + modelListRef.current + ?.querySelector(`[data-model-id="${CSS.escape(next.id)}"]`) + ?.focus(); + } + + return ( +
+
+ 启用模型 {props.enabledModelIds.length} + 勾选的模型会出现在模型选择器中。 +
+ setQuery(event.currentTarget.value)} + placeholder="搜索模型" + autoComplete="off" + spellCheck={false} + disabled={props.disabled} + aria-label="搜索模型" + /> + +
    + {visibleRows.length === 0 ? ( +
  • + {rows.length === 0 ? '暂无可选模型,请先更新模型目录。' : '没有匹配的模型。'} +
  • + ) : ( + visibleRows.map((row) => { + const isEnabled = enabled.has(row.id); + const isDefault = row.id === props.defaultModel; + return ( +
  • + toggle(row.id)} + onFocus={() => setActiveRowId(row.id)} + /> + } + > + + + {row.label} + + {isDefault && ( + + 默认 + + )} + +
  • + ); + }) + )} +
+
+
+ ); +} + +function modelDisplayLabel(model: Pick): string { + return model.displayName?.trim() || model.id; +} diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts new file mode 100644 index 0000000000..d391cab363 --- /dev/null +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -0,0 +1,522 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + PROVIDER_DEFAULTS, + connectionEnabledModelIds, + type ConnectionTestResult, + type LlmConnection, + type ModelInfo, + type ProviderType, +} from '@maka/core'; +import { providerAuthRequiresSecret, providerAuthSupportsApiKey } from '@maka/core/llm-connections'; +import { useMountedRef, useToast } from '@maka/ui'; +import { buildCatalogModelChoices } from '../model-catalog-choices'; +import { connectionChipStatus } from './provider-connection-status'; +import { useKeyedActionGuard } from './use-action-guard'; +import type { OAuthLoginFlowBridge } from './use-oauth-login-flow'; +import { + connectionLastTestMessageDisplay, + connectionTestFailureMessage, + providerPanelActionErrorMessage, + type ConnectionsBridge, + type CredentialPresenceStatus, +} from './provider-panel-shared'; + +// Maps an OAuth model-connection provider type to the browser-loopback login +// service that can re-run its authorization from inside the connection dialog. Only +// the loopback / polling services (Codex, Antigravity) are one-button-drivable +// here; Claude's paste-code flow and plain API-key providers return null so the +// notice falls back to prose instead of rendering a dead button. +export interface OAuthLoginService { + bridge: OAuthLoginFlowBridge; + display: { name: string; shortName: string }; +} + +export function oauthLoginServiceFor(providerType: ProviderType): OAuthLoginService | null { + switch (providerType) { + case 'openai-codex': + return { + bridge: window.maka.openAiCodex as unknown as OAuthLoginFlowBridge, + display: { name: 'OpenAI Codex', shortName: 'Codex' }, + }; + case 'gemini-cli': + return { + bridge: window.maka.antigravitySubscription as unknown as OAuthLoginFlowBridge, + display: { name: 'Google Antigravity', shortName: 'Antigravity' }, + }; + default: + return null; + } +} + +export interface ConnectionDetailProps { + bridge: ConnectionsBridge; + connection: LlmConnection; + isDefault: boolean; + onChanged(): Promise; + onDeleted(): Promise; +} + +// Controller for the API/OAuth model connection detail sheet. Owns the whole +// mutually-exclusive action state machine (save / test / fetch-models / +// save-enabled-models / set-default / delete, all gated through one keyed +// action guard) plus the credential-presence probe and the prop-sync effects. +// The sheet view (provider-connection-detail.tsx) is a thin render over this +// return; extracting it kept the 12 useState + 4 refs + 4 effects together so +// the guard, lifecycle gate, and cross-calls (save auto-fetches models) stay in +// one place with zero behavior change. +export function useConnectionDetail(props: ConnectionDetailProps) { + const { connection } = props; + const defaults = PROVIDER_DEFAULTS[connection.providerType]; + const [apiKey, setApiKey] = useState(''); + const [hasSecret, setHasSecret] = useState( + defaults.authKind === 'none' ? true : 'loading', + ); + const [baseUrl, setBaseUrl] = useState(connection.baseUrl ?? defaults.baseUrl ?? ''); + const [models, setModels] = useState(connection.models ?? []); + const [enabledModelIds, setEnabledModelIds] = useState(() => connectionEnabledModelIds(connection)); + // Backend persists the model-list source alongside the model cache, so a + // Settings restart no longer has to infer "fetched" from a non-empty array. + // A successful provider response may legitimately contain 0 models; source + // and length remain separate facts. + const [modelSource, setModelSource] = useState<'fetched' | 'fallback'>( + connection.modelSource ?? 'fallback', + ); + const syncedConnectionSnapshotRef = useRef(connectionDetailSnapshot(connection, defaults.baseUrl)); + const [busy, setBusy] = useState(false); + const [testing, setTesting] = useState(false); + const [fetchingModels, setFetchingModels] = useState(false); + const [savingEnabledModels, setSavingEnabledModels] = useState(false); + const [settingDefault, setSettingDefault] = useState(false); + const [deleting, setDeleting] = useState(false); + const connectionDetailActionGuard = useKeyedActionGuard< + 'save' | 'test' | 'fetch-models' | 'save-enabled-models' | 'set-default' | 'delete' + >(); + const connectionDetailMountedRef = useMountedRef(); + const connectionDetailLifecycleRef = useRef(0); + const toast = useToast(); + const supportsApiKey = providerAuthSupportsApiKey(connection.providerType); + const needsOAuth = defaults.authKind === 'oauth_token'; + const oauthLoginService = needsOAuth ? oauthLoginServiceFor(connection.providerType) : null; + const usesGitHubCopilotLogin = connection.providerType === 'github-copilot'; + const hasFixedOAuthBaseUrl = needsOAuth && Boolean(defaults.baseUrl); + const requiresCredential = providerAuthRequiresSecret(connection.providerType); + const probesCredential = supportsApiKey || needsOAuth; + const credentialProbePending = requiresCredential && (hasSecret === 'loading' || hasSecret === 'error'); + const hasUsableCredential = !requiresCredential || hasSecret === true; + const credentialTroubleshootingCopy = needsOAuth + ? 'OAuth 登录 / 代理设置' + : '模型密钥 / 服务地址 / 代理设置'; + const savedBaseUrl = connection.baseUrl ?? defaults.baseUrl; + const draftBaseUrl = baseUrl; + const hasApiKeyChange = apiKey.length > 0; + const hasBaseUrlChange = draftBaseUrl !== savedBaseUrl; + // Persistent single-line credential hint. Rendered in every hasSecret state + // (including `false`) so the description row never adds or drops a line as the + // async secret probe resolves — the dialog height stays constant. + const apiKeyStatusHint = + hasSecret === true + ? '已设置,粘贴新值可替换' + : hasSecret === 'loading' + ? '正在读取状态' + : hasSecret === 'error' + ? '凭据状态未知' + : '尚未设置密钥'; + const detailActionBusy = busy || testing || fetchingModels || savingEnabledModels || settingDefault || deleting; + const issue = connectionChipStatus(connection); + const lastTestMessage = connectionLastTestMessageDisplay(connection.lastTestMessage); + const lastTestAtMs = connection.lastTestAt ? Date.parse(connection.lastTestAt) : NaN; + + useEffect(() => { + connectionDetailLifecycleRef.current += 1; + return () => { + connectionDetailLifecycleRef.current += 1; + connectionDetailActionGuard.reset(); + }; + }, [connection.slug]); + + function isConnectionDetailCurrent(lifecycle: number): boolean { + return connectionDetailMountedRef.current && connectionDetailLifecycleRef.current === lifecycle; + } + + useEffect(() => { + const lifecycle = connectionDetailLifecycleRef.current; + if (!probesCredential) { + if (isConnectionDetailCurrent(lifecycle)) setHasSecret(true); + return; + } + setHasSecret('loading'); + void props.bridge + .hasSecret(connection.slug) + .then((next) => { + if (isConnectionDetailCurrent(lifecycle)) setHasSecret(next); + }) + .catch((error) => { + if (!isConnectionDetailCurrent(lifecycle)) return; + setHasSecret('error'); + toast.error('读取模型凭据状态失败', providerPanelActionErrorMessage(error)); + }); + }, [props.bridge, connection.slug, probesCredential, toast]); + + useEffect(() => { + const nextSnapshot = connectionDetailSnapshot(connection, defaults.baseUrl); + const previousSnapshot = syncedConnectionSnapshotRef.current; + const localStillSynced = connectionDetailDraftMatchesSnapshot( + { baseUrl, models, modelSource }, + previousSnapshot, + ); + const localAlreadyMatchesNext = connectionDetailDraftMatchesSnapshot( + { baseUrl, models, modelSource }, + nextSnapshot, + ); + + if (connection.slug !== previousSnapshot.slug || (apiKey.length === 0 && localStillSynced)) { + setBaseUrl(nextSnapshot.baseUrl); + setModels(nextSnapshot.models); + setModelSource(nextSnapshot.modelSource); + syncedConnectionSnapshotRef.current = nextSnapshot; + return; + } + + if (localAlreadyMatchesNext) { + syncedConnectionSnapshotRef.current = nextSnapshot; + } + }, [ + apiKey.length, + baseUrl, + connection, + defaults.baseUrl, + modelSource, + models, + ]); + + useEffect(() => { + setEnabledModelIds(connectionEnabledModelIds(connection)); + }, [connection.defaultModel, connection.enabledModelIds, connection.slug]); + + // Picker entries come from the same catalog merge path as Chat and Daily + // Review, but use the local unsaved editor draft for model/default changes. + const modelChoices = buildCatalogModelChoices({ + slug: connection.slug, + providerType: connection.providerType, + defaultModel: connection.defaultModel, + models: modelSource === 'fetched' || models.length > 0 ? models : undefined, + modelSource, + modelsFetchedAt: connection.modelsFetchedAt, + }); + + async function save() { + const releaseSave = connectionDetailActionGuard.beginExclusive('save'); + if (!releaseSave) return; + const lifecycle = connectionDetailLifecycleRef.current; + setBusy(true); + let saved = false; + try { + await props.bridge.update(connection.slug, { + baseUrl, + ...(apiKey ? { apiKey } : {}), + }); + saved = true; + if (!isConnectionDetailCurrent(lifecycle)) return; + const wroteNewKey = apiKey.length > 0; + setApiKey(''); + const nextHasSecret = probesCredential ? await props.bridge.hasSecret(connection.slug) : true; + if (!isConnectionDetailCurrent(lifecycle)) return; + setHasSecret(nextHasSecret); + await props.onChanged(); + if (!isConnectionDetailCurrent(lifecycle)) return; + // Auto-fetch live model list as soon as the secret is in place. Without + // this, the user lands on a Settings · 模型 row whose `defaultModel` + // dropdown only contains the static fallback list (e.g. Z.ai → just + // glm-4.7 / 4.6 / 4.5), which looks like Maka doesn't support newer + // models. Auto-fetch on save closes that gap. + if ((!requiresCredential || nextHasSecret) && (wroteNewKey || models.length === 0)) { + void refreshModels({ silent: true }); + } + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + if (saved && probesCredential) { + setHasSecret('error'); + } + toast.error( + saved ? '刷新模型连接失败' : '保存模型连接失败', + providerPanelActionErrorMessage(error), + ); + } finally { + releaseSave(); + if (isConnectionDetailCurrent(lifecycle)) setBusy(false); + } + } + + async function updateEnabledModels(nextIds: string[]) { + if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return; + const next = connectionEnabledModelIds({ + defaultModel: connection.defaultModel, + enabledModelIds: nextIds, + }); + if (modelIdListsEqual(next, enabledModelIds)) return; + const previous = enabledModelIds; + const lifecycle = connectionDetailLifecycleRef.current; + const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models'); + if (!releaseSaveModels) return; + setSavingEnabledModels(true); + setEnabledModelIds(next); + let saved = false; + try { + await props.bridge.update(connection.slug, { enabledModelIds: next }); + saved = true; + if (!isConnectionDetailCurrent(lifecycle)) return; + await props.onChanged(); + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + if (!saved) setEnabledModelIds(previous); + toast.error( + saved ? '刷新模型连接失败' : '保存启用模型失败', + providerPanelActionErrorMessage(error), + ); + } finally { + releaseSaveModels(); + if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false); + } + } + + async function runTest() { + const releaseTest = connectionDetailActionGuard.beginExclusive('test'); + if (!releaseTest) return; + const lifecycle = connectionDetailLifecycleRef.current; + setTesting(true); + try { + const result: ConnectionTestResult = await props.bridge.test(connection.slug, { model: connection.defaultModel }); + if (!isConnectionDetailCurrent(lifecycle)) return; + if (result.ok) { + toast.success( + `连接成功 · ${connection.name}`, + `${result.modelTested} · ${result.latencyMs} ms`, + ); + } else { + toast.error( + `连接失败 · ${connection.name}`, + connectionTestFailureMessage(result, { + auth: `鉴权失败,请确认 ${credentialTroubleshootingCopy} 后重试。`, + recheck: `检查 ${credentialTroubleshootingCopy} 后重试。`, + }), + ); + } + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + const message = providerPanelActionErrorMessage(error); + toast.error(`连接测试出错 · ${connection.name}`, message); + } finally { + releaseTest(); + if (isConnectionDetailCurrent(lifecycle)) setTesting(false); + } + } + + async function refreshModels(opts: { silent?: boolean } = {}) { + // A silent refresh (the post-save auto-fetch) may overlap other actions; + // a manual one is gated on the whole sheet like the other buttons. + const releaseFetch = opts.silent + ? connectionDetailActionGuard.begin('fetch-models') + : connectionDetailActionGuard.beginExclusive('fetch-models'); + if (!releaseFetch) return; + const lifecycle = connectionDetailLifecycleRef.current; + setFetchingModels(true); + try { + // Backend (xuan `81ed044`) returns a `ModelDiscoveryResult` envelope — + // `{ models, source: 'fetched' | 'fallback', fetchedAt }` — and throws + // a generalizedErrorMessage on failure. We trust `result.source` + // verbatim instead of inferring from list length, so a provider that + // legitimately returns 0 models still reads as 'fetched'. + const result = await props.bridge.fetchModels(connection.slug); + if (!isConnectionDetailCurrent(lifecycle)) return; + setModels(result.models); + setModelSource(result.source); + await props.onChanged(); + if (!isConnectionDetailCurrent(lifecycle)) return; + if (!opts.silent) { + toast.success(`已拉取 ${result.models.length} 个模型 · ${connection.name}`); + } + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + const message = providerPanelActionErrorMessage(error); + // Leave the previously-known source / models intact (so the dropdown + // doesn't suddenly empty out), but downgrade the source label back to + // 'fallback' if we have nothing fresh to show — the failed fetch + // means whatever's on screen is not from the latest probe. + if (models.length === 0) setModelSource('fallback'); + toast.error( + `拉取模型失败 · ${connection.name}`, + `${message} · 当前继续显示静态列表,请确认 ${credentialTroubleshootingCopy} 后重试。`, + ); + } finally { + releaseFetch(); + if (isConnectionDetailCurrent(lifecycle)) setFetchingModels(false); + } + } + + async function setAsDefault() { + const releaseSetDefault = connectionDetailActionGuard.beginExclusive('set-default'); + if (!releaseSetDefault) return; + if (!connection.enabled) { + releaseSetDefault(); + toast.error('无法设为默认', '这个模型连接已禁用,请重新登录或启用后再设为默认。'); + return; + } + const lifecycle = connectionDetailLifecycleRef.current; + setSettingDefault(true); + try { + await props.bridge.setDefault(connection.slug); + if (!isConnectionDetailCurrent(lifecycle)) return; + await props.onChanged(); + if (!isConnectionDetailCurrent(lifecycle)) return; + toast.success(`已设为默认 · ${connection.name}`); + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + toast.error('切换默认失败', providerPanelActionErrorMessage(error)); + } finally { + releaseSetDefault(); + if (isConnectionDetailCurrent(lifecycle)) setSettingDefault(false); + } + } + + async function remove() { + const releaseDelete = connectionDetailActionGuard.beginExclusive('delete'); + if (!releaseDelete) return; + const lifecycle = connectionDetailLifecycleRef.current; + setDeleting(true); + const ok = await toast.confirm({ + title: `删除供应商 ${connection.name}?`, + description: '将从模型连接中移除这个供应商配置;如需再次使用,需要重新添加凭据。', + confirmLabel: '删除', + cancelLabel: '取消', + destructive: true, + }); + if (!isConnectionDetailCurrent(lifecycle)) return; + if (!ok) { + releaseDelete(); + setDeleting(false); + return; + } + let deleted = false; + try { + await props.bridge.delete(connection.slug); + deleted = true; + if (!isConnectionDetailCurrent(lifecycle)) return; + await props.onDeleted(); + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + toast.error( + deleted ? '刷新模型列表失败' : '删除模型连接失败', + providerPanelActionErrorMessage(error), + ); + } finally { + releaseDelete(); + if (isConnectionDetailCurrent(lifecycle)) setDeleting(false); + } + } + + // After a successful in-dialog OAuth re-login, re-probe the credential + // presence (an expired token still read hasSecret===true, so we must + // refresh it) and reload the connection so its status leaves 需要重新登录. + async function refreshAfterRelogin() { + const lifecycle = connectionDetailLifecycleRef.current; + try { + const nextHasSecret = await props.bridge.hasSecret(connection.slug); + if (!isConnectionDetailCurrent(lifecycle)) return; + setHasSecret(nextHasSecret); + } catch (error) { + if (!isConnectionDetailCurrent(lifecycle)) return; + setHasSecret('error'); + toast.error('读取模型凭据状态失败', providerPanelActionErrorMessage(error)); + } + await props.onChanged(); + } + + return { + apiKey, + setApiKey, + hasSecret, + baseUrl, + setBaseUrl, + enabledModelIds, + modelChoices, + busy, + testing, + fetchingModels, + settingDefault, + deleting, + detailActionBusy, + supportsApiKey, + needsOAuth, + usesGitHubCopilotLogin, + oauthLoginService, + hasFixedOAuthBaseUrl, + credentialProbePending, + hasUsableCredential, + apiKeyStatusHint, + hasApiKeyChange, + hasBaseUrlChange, + issue, + lastTestMessage, + lastTestAtMs, + save, + updateEnabledModels, + runTest, + refreshModels, + setAsDefault, + remove, + refreshAfterRelogin, + }; +} + +type ConnectionDetailSnapshot = { + slug: string; + baseUrl: string; + models: ModelInfo[]; + modelSource: 'fetched' | 'fallback'; +}; + +function connectionDetailSnapshot( + connection: LlmConnection, + defaultBaseUrl: string | undefined, +): ConnectionDetailSnapshot { + return { + slug: connection.slug, + baseUrl: connection.baseUrl ?? defaultBaseUrl ?? '', + models: connection.models ?? [], + modelSource: connection.modelSource ?? 'fallback', + }; +} + +function connectionDetailDraftMatchesSnapshot( + draft: { + baseUrl: string; + models: ModelInfo[]; + modelSource: 'fetched' | 'fallback'; + }, + snapshot: ConnectionDetailSnapshot, +): boolean { + return draft.baseUrl === snapshot.baseUrl && + draft.modelSource === snapshot.modelSource && + modelListsEqual(draft.models, snapshot.models); +} + +function modelListsEqual(left: ModelInfo[], right: ModelInfo[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftModel = left[index]; + const rightModel = right[index]; + if (leftModel.id !== rightModel.id) return false; + if (leftModel.contextWindow !== rightModel.contextWindow) return false; + if (leftModel.maxOutputTokens !== rightModel.maxOutputTokens) return false; + if (leftModel.capabilities?.chat !== rightModel.capabilities?.chat) return false; + if (leftModel.capabilities?.vision !== rightModel.capabilities?.vision) return false; + if (leftModel.capabilities?.reasoning !== rightModel.capabilities?.reasoning) return false; + if (leftModel.capabilities?.functionCalling !== rightModel.capabilities?.functionCalling) return false; + if (leftModel.capabilities?.imageGeneration !== rightModel.capabilities?.imageGeneration) return false; + } + return true; +} + +function modelIdListsEqual(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]); +} From 3f7cd89021bb29b7398218f29410bc863495eaf0 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 20 Jul 2026 01:49:48 +0800 Subject: [PATCH 3/4] test(settings): re-pin provider contracts after R7 controller extraction The connection-detail state machine + helpers moved into use-connection-detail.ts and the enabled-model editor into provider-enabled-model-manager.tsx, so the model-oauth-section contract's ConnectionDetail slices no longer live wholly in the view file. - provider-contract-source-helpers: join use-connection-detail.ts + provider-enabled-model-manager.tsx into the combined source, adjacent to the detail view, so 'function ConnectionDetail ... function modelIdListsEqual' slices span view + controller contiguously. Added detailController / enabledModelManager slices to the sources interface. - model-oauth-section-contract: widen the ConnectionDetail controller slices' terminator from 'function GitHubCopilotReloginNotice' to 'function modelIdListsEqual(' so handler bodies / derived flags / effects / snapshot helpers (now in the hook) are covered; the ConnectionDetailInner view-order slice is unchanged. Re-point the last-test-message helper assertion to the combined source (the derivation moved to the controller). Every behavior invariant is preserved, none deleted. - web-search-boundary: add the two new renderer files to the scanned set. Desktop 2744 + ui 196 suites green; knip x2 exit 0. --- .../model-oauth-section-contract.test.ts | 28 ++++++++-------- .../provider-contract-source-helpers.ts | 33 ++++++++++++++++++- .../__tests__/web-search-boundary.test.ts | 2 ++ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts b/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts index b461f639e9..40e05a8355 100644 --- a/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts @@ -127,7 +127,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('provider detail actions localize and sanitize model-test / model-fetch failures', async () => { const providers = await readProviderSettingsCombinedSource(); const main = await readMainProcessCombinedSource(); - const detail = providers.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = providers.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; const addForm = providers.match(/function AddProviderForm[\s\S]*?function nextSlug/)?.[0] ?? ''; assert.match( @@ -605,7 +605,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('OAuth model connection detail treats Base URL as fixed provider metadata, not an editable endpoint', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( detail, @@ -645,14 +645,16 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO assert.ok(advanced > credential, 'credentials must remain the primary task before advanced settings'); assert.ok(models > advanced, 'enabled-model management must stay inside advanced settings'); assert.doesNotMatch(detail, /[\s\S]*\{connection\.name\}/, 'the shared DialogHeader must be the only title header'); }); it('does not let disabled OAuth connections become the default model', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( detail, @@ -668,7 +670,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('keeps each Save action beside its field, disabled until that field is dirty', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( detail, @@ -694,7 +696,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('forwards an empty service-address draft so the stored override can be cleared', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( detail, @@ -744,7 +746,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('automatically persists enabled-model edits without a second Save action', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; const enabledModels = src.match(/function EnabledModelManager[\s\S]*?function modelDisplayLabel/)?.[0] ?? ''; assert.match( @@ -760,7 +762,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('surfaces provider detail save/delete failures instead of leaking rejected promises from actions', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( detail, @@ -781,7 +783,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('surfaces provider detail credential-presence probe failures', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match(src, /type CredentialPresenceStatus = boolean \| 'loading' \| 'error'/); assert.match(detail, /useState\([\s\S]*defaults\.authKind === 'none' \? true : 'loading'/); @@ -809,7 +811,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('shows but does not require LocalAI optional credentials', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match(detail, /const supportsApiKey = providerAuthSupportsApiKey\(connection\.providerType\)/); assert.match(detail, /const requiresCredential = providerAuthRequiresSecret\(connection\.providerType\)/); @@ -818,7 +820,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('provider detail async actions stop writing UI after the detail sheet is closed or switched', async () => { const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( detail, @@ -868,7 +870,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO // props via useState would otherwise keep showing stale models / // defaultModel until the sheet is closed and reopened. const src = await readProviderSettingsCombinedSource(); - const detail = src.match(/function ConnectionDetail[\s\S]*?function GitHubCopilotReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; assert.match( src, @@ -1377,7 +1379,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO const src = await readProviderSettingsCombinedSource(); const mapping = src.match(/function oauthLoginServiceFor\(providerType: ProviderType\): OAuthLoginService \| null \{[\s\S]*?\n\}/)?.[0] ?? ''; const notice = src.match(/function OAuthReloginNotice\([\s\S]*?\ntype ConnectionDetailSnapshot/)?.[0] ?? ''; - const detail = src.match(/function ConnectionDetail[\s\S]*?function OAuthReloginNotice/)?.[0] ?? ''; + const detail = src.match(/function ConnectionDetail[\s\S]*?function modelIdListsEqual\(/)?.[0] ?? ''; // Loopback services (Codex, Antigravity) get a bridge; Claude's paste flow // and plain API-key providers fall through to null so the notice renders diff --git a/apps/desktop/src/main/__tests__/provider-contract-source-helpers.ts b/apps/desktop/src/main/__tests__/provider-contract-source-helpers.ts index 98054bd40a..f02c132c99 100644 --- a/apps/desktop/src/main/__tests__/provider-contract-source-helpers.ts +++ b/apps/desktop/src/main/__tests__/provider-contract-source-helpers.ts @@ -14,6 +14,13 @@ export interface ProviderSettingsSources { displayCopy: string; addForm: string; detail: string; + // R7: the connection detail sheet's controller state machine + its + // enabled-model editor were extracted out of provider-connection-detail.tsx. + // Both stay part of the provider settings surface the contract tests pin, and + // are joined right after the detail view so the ConnectionDetail → controller + // slices stay contiguous. + detailController: string; + enabledModelManager: string; shared: string; combined: string; } @@ -31,11 +38,26 @@ const sourcePaths = { displayCopy: resolve(SETTINGS_ROOT, 'provider-display-copy.ts'), addForm: resolve(SETTINGS_ROOT, 'provider-add-form.tsx'), detail: resolve(SETTINGS_ROOT, 'provider-connection-detail.tsx'), + detailController: resolve(SETTINGS_ROOT, 'use-connection-detail.ts'), + enabledModelManager: resolve(SETTINGS_ROOT, 'provider-enabled-model-manager.tsx'), shared: resolve(SETTINGS_ROOT, 'provider-panel-shared.ts'), } as const; export async function readProviderSettingsSources(): Promise { - const [panel, dialog, catalog, oauth, claudeCard, display, displayCopy, addForm, detail, shared] = await Promise.all([ + const [ + panel, + dialog, + catalog, + oauth, + claudeCard, + display, + displayCopy, + addForm, + detail, + detailController, + enabledModelManager, + shared, + ] = await Promise.all([ readFile(sourcePaths.panel, 'utf8'), readFile(sourcePaths.dialog, 'utf8'), readFile(sourcePaths.catalog, 'utf8'), @@ -45,6 +67,8 @@ export async function readProviderSettingsSources(): Promise Date: Mon, 20 Jul 2026 02:00:23 +0800 Subject: [PATCH 4/4] docs(arch): mark R7 shipped + campaign closing summary R7 detail-controller decomposition (983 -> 373) recorded with blade plan, extraction table, contract re-pins, and gate totals. Closing summary: main.ts 1903 -> 1175 (R4-R6), provider-connection-detail 983 -> 373 (R7), visual-smoke split (R3), files-split inventory, and the byTool per-session aggregation fix shipped alongside. --- notes/frontend-architecture-map-2026-07-19.md | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/notes/frontend-architecture-map-2026-07-19.md b/notes/frontend-architecture-map-2026-07-19.md index e1914abe6d..4de917ee34 100644 --- a/notes/frontend-architecture-map-2026-07-19.md +++ b/notes/frontend-architecture-map-2026-07-19.md @@ -193,10 +193,47 @@ workspaces are exit 0 today; R1 makes them also report ZERO hints. Storybook sto closes only after cleanup completes, so this proves the moved teardown ran end-to-end); confirmed behavior-identical to the pre-R6 baseline. Campaign main.ts total: 1903 → 1175 (−728 across R4–R6). -- [ ] **R7 — provider-connection-detail.tsx controller-hook decomposition.** 983 lines, - 17 useState — the renderer's densest remaining state cluster. Needs its own blade plan - (which state clusters extract into which controller hooks, which contracts pin the file) - before any extraction; not a mechanical move like R2. +- [x] **R7 — provider-connection-detail.tsx controller-hook decomposition (shipped + `chore/arch-round-7-final`, detail view 983 → 373, −610).** Blade plan (from reading the + file): the sheet was one entangled controller — 12 useState in `ConnectionDetailInner` + plus a single `useKeyedActionGuard` covering save/test/fetch-models/save-enabled-models/ + set-default/delete (all mutually exclusive), one lifecycle/`isConnectionDetailCurrent` + gate, an aggregated `detailActionBusy`, and a cross-call (`save` auto-fetches models). That + interlock is one cohesive cluster, so it extracted whole into **one** controller hook + rather than splitting per sub-cluster (which would have to thread the guard + lifecycle ref + between hooks and risk behavior drift). Two extractions under + `apps/desktop/src/renderer/settings/`: + `use-connection-detail.ts` (522 lines) — `useConnectionDetail(props)` owns every useState, + the 4 refs, all 4 effects (lifecycle reset, credential-presence probe, snapshot prop-sync, + enabledModelIds sync), every derived flag (supportsApiKey / needsOAuth / oauthLoginService / + hasFixedOAuthBaseUrl / credentialProbePending / hasUsableCredential / detailActionBusy / + apiKeyStatusHint / issue / lastTestMessage / …), the 7 handlers (save / updateEnabledModels + / runTest / refreshModels / setAsDefault / remove / refreshAfterRelogin), `oauthLoginServiceFor` + + the `OAuthLoginService`/`ConnectionDetailProps` types, and the pure snapshot/equality + helpers; returns a controller object. + `provider-enabled-model-manager.tsx` (187 lines) — the roving-tabindex model-list editor + (owns `query` + `activeRowId`), an independent cluster. + `provider-connection-detail.tsx` (373 lines) is now a thin view that destructures the hook, + keeping `ConnectionDetail` / `UnknownConnectionDetail` / `ConnectionDetailInner` (JSX) plus + the presentation-only `ConnectionEndpointField` / `GitHubCopilotReloginNotice` / + `OAuthReloginNotice`. Every identifier and statement moved verbatim — ZERO behavior change, + stable identities preserved (destructured under the same names so `onClick={save}` etc. stay + literal). Contract re-pins (maintainer-authorized): `provider-contract-source-helpers` joins + `use-connection-detail.ts` + `provider-enabled-model-manager.tsx` into the combined source, + adjacent to the detail view, so `function ConnectionDetail … function modelIdListsEqual(` + slices span view + controller contiguously; the `model-oauth-section-contract` ConnectionDetail + controller-slice terminators widened from `function GitHubCopilotReloginNotice` to + `function modelIdListsEqual(` (the handler bodies / flags / effects / snapshot helpers now + live in the hook), the `ConnectionDetailInner` view-order slice is unchanged, and the + last-test-message-helper assertion re-points to the combined source; `web-search-boundary` + adds the two new renderer files to its scanned set. Every behavior invariant preserved, none + deleted. No entangled remainder — the whole controller moved. Gates: desktop 2744 + ui 196 + + storage 396 suites green, 5-tsconfig (preload/main/renderer/storybook + ui) typecheck clean, + check-console/a11y/copy clean, check-dead-css clean, knip ×2 exit 0 (zero hints), + AUDIT_PORT_BASE=25300 alignment auditor exit 0 (all 13 fixtures clean incl. settings-usage). + CDP: the **oauth-relogin** fixture (which opens this component's `codex-oauth` detail sheet) + renders identically pre/post — OAuthReloginNotice with the 登录 button, EnabledModelManager + (启用模型 1 · GPT-5.5 默认), test/refresh/delete actions, no error boundary. - [ ] **R8 — CSS raw-hex residue (this PR). VERIFIED CLEAN — no residue on this tip.** Audit premise was stale: prose.css + sidebar.css carry NO raw hex/rgb/rgba/hsl color literals on d48183c2. The `#618`/`#546`/`#739` matches a naive grep surfaces are all @@ -220,3 +257,40 @@ Update checkboxes as rounds ship. Every round: suite + typecheck + dead-css + al auditor + CDP spot captures, exit-code gated. R4–R6 are gated additionally on maintainer-approved contract re-pins; R7 needs its own blade plan; R3 is unblocked only after the concurrent visual-smoke fixture branch lands. + +## Campaign closing summary (2026-07-19 → 07-20) + +The two mega-modules the campaign targeted are decomposed and the config/CSS rot is +resolved: + +- **main.ts: 1903 → 1175 (−728)** across R4 (tool-assembly + tool-artifact-persistence), + R5 (settings-runtime-effects + session-stream core), R6 (app-lifecycle) — each a pure + move behind injected seams, contract-re-pinned through the + `main-process-contract-source-helpers` aggregator. +- **provider-connection-detail.tsx: 983 → 373 (−610)** via R7 — the densest renderer state + cluster extracted into `use-connection-detail.ts` (one controller hook for the whole keyed- + action-guard state machine) + `provider-enabled-model-manager.tsx` (the model-list editor). +- **visual-smoke-fixture.ts: 2538 → 689 barrel + 6 domain modules** (R3), the #1 hotspot. +- **app-shell.tsx: 1654** (R2 resume-cluster extraction — the `use-shell-resume.ts` mechanical + move — remains open/optional; the file is no longer a campaign hotspot after the 2026-07-13 + simplification pass). +- **R1 (knip de-rot)** and **R8 (CSS raw-hex / byte-formatter residue)** are analysis-complete: + R8 verified no code change needed; R1 clears the 13 config hints when scheduled. knip ×2 + already reports zero hints on the current tip. + +Files split this campaign (new modules): `main/tool-assembly.ts`, +`main/tool-artifact-persistence.ts`, `main/settings-runtime-effects.ts`, +`main/session-stream.ts`, `main/app-lifecycle.ts`, the 6 `main/visual-smoke/*` seeders + +barrel, `renderer/settings/use-connection-detail.ts`, +`renderer/settings/provider-enabled-model-manager.tsx`. + +**Also shipped this round (out-of-band bug from #1252's report):** `settingsStore.usageStats` +aggregated `byTool` PER-SESSION (`sessions.flatMap(toolStatsFromMessages)`), so Settings → +使用统计 → 工具统计 showed the same tool name on multiple rows (several Bash rows). +`byProvider`/`byModel` were already global (`aggregateBy` over the flattened `modelLogs`) — +only `byTool` was affected. Fixed by `aggregateToolStats(sessions, since)`: tool_call ↔ +tool_result matching stays session-scoped (ids are only unique within a session) while +counts/success/errors/durations merge into one row per tool name, sorted by call count desc. +Regression test added (two sessions × Bash → one merged row). Verified on the settings-usage +fixture via CDP (`window.maka.settings.usageStats('all')`): `byTool` = 6 unique rows (Bash 6, +Read 4, Grep 2 [1✓1✗], Write 2, Edit 1, WebSearch 1), no duplicate tool names.