From 1552ccf3c035d4ad90b8704f8bb700d00f4e8520 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 9 Sep 2026 01:29:19 +0800 Subject: [PATCH 1/2] feat(providers): unify model fields and isolate asynchronous updates --- .../app/src/components/SettingsModal.tsx | 15 +- .../app/src/components/ui/SelectLite.tsx | 8 +- .../app/src/lib/ipc/asr-credentials.ts | 8 +- .../lib/ipc/mock-provider-descriptors.json | 48 ++ openless-all/app/src/lib/ipc/providers.ts | 23 +- .../app/src/pages/settings/ChannelList.tsx | 16 +- .../pages/settings/LlmProtocolFields.test.ts | 6 +- .../src/pages/settings/LlmProtocolFields.tsx | 21 +- .../app/src/pages/settings/ProviderForm.tsx | 46 ++ .../pages/settings/ProvidersSection.test.ts | 18 +- .../src/pages/settings/ProvidersSection.tsx | 585 ++++-------------- .../pages/settings/credentialDraft.test.ts | 68 ++ .../app/src/pages/settings/credentialDraft.ts | 64 ++ .../src/pages/settings/modelCatalog.test.ts | 47 ++ .../app/src/pages/settings/modelCatalog.ts | 23 + 15 files changed, 472 insertions(+), 524 deletions(-) create mode 100644 openless-all/app/src/lib/ipc/mock-provider-descriptors.json create mode 100644 openless-all/app/src/pages/settings/ProviderForm.tsx create mode 100644 openless-all/app/src/pages/settings/credentialDraft.test.ts create mode 100644 openless-all/app/src/pages/settings/credentialDraft.ts create mode 100644 openless-all/app/src/pages/settings/modelCatalog.test.ts create mode 100644 openless-all/app/src/pages/settings/modelCatalog.ts diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index e8cf8b8c6..e36e08e45 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -8,6 +8,7 @@ // // 设计原则:每个可见控件都必须可用。 +import { ProviderLeaveContext, useProviderForm } from '../pages/settings/ProviderForm'; import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; @@ -57,6 +58,8 @@ const LINK_ITEMS: ModalNavItem[] = [ ]; export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) { + const providerForm = useProviderForm(); + const close = () => { void providerForm.finish(onClose); }; const { t } = useTranslation(); const mobile = useMobileLayout(); const conservative = useConservativeLayout(); @@ -78,8 +81,9 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett }, [section, mobile]); return ( +
+
); } diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 01bb432cd..a502c96f6 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -35,6 +35,8 @@ export interface SelectOption { value: string; label: string; disabled?: boolean; + /** 搜索期间仍保留操作项,例如自定义输入入口。 */ + alwaysVisible?: boolean; /** 可选:渲染在选项标签右侧、勾选标记左侧(如麦克风音量条)。 */ trailing?: ReactNode; } @@ -111,7 +113,7 @@ export function SelectLite({ const needle = query.trim().toLocaleLowerCase(); if (!needle) return options; return options.filter(option => ( - option.label.toLocaleLowerCase().includes(needle) + option.alwaysVisible || option.label.toLocaleLowerCase().includes(needle) || option.value.toLocaleLowerCase().includes(needle) )); }, [options, query]); @@ -263,6 +265,7 @@ export function SelectLite({ }; const selectIndex = (index: number) => { + if (disabled) return; const option = filteredOptions[index]; if (!option || option.disabled) return; onChange(option.value); @@ -319,6 +322,7 @@ export function SelectLite({ }; const handleSearchKeyDown = (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === 'Escape') { event.preventDefault(); closeMenu(); @@ -459,7 +463,7 @@ export function SelectLite({ role="listbox" style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }} > - {filteredOptions.length === 0 && ( + {!filteredOptions.some(option => !option.alwaysVisible) && (
{ + if (!isTauri) { + const descriptor = (await listProviderDescriptors(kind)).find(item => item.providerType === providerType) + if (descriptor?.staticModels.length) return { models: descriptor.staticModels } + } if (!isTauri && providerType === "orcarouter" && (kind === "llm" || kind === "asr")) { const endpointAccount = kind === "llm" ? "ark.endpoint" : "asr.endpoint" const endpoint = mockCredentialValues.get(`${channelId ?? ''}:${endpointAccount}`) @@ -124,6 +128,6 @@ export async function listProviderModels( models: kind === "llm" ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] - : ["whisper-1"], + : kind === "omni" ? ["gpt-4o-audio-preview", "qwen3-omni-flash"] : ["whisper-1"], })) } diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json new file mode 100644 index 000000000..f991e8fbb --- /dev/null +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -0,0 +1,48 @@ +[ + {"kind":"llm","providerType":"ark","labelKey":"ark","defaultEndpoint":"https://ark.cn-beijing.volces.com/api/v3","defaultModel":"deepseek-v3-2","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"deepseek","labelKey":"deepseek","defaultEndpoint":"https://api.deepseek.com/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"siliconflow","labelKey":"siliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"Qwen/Qwen2.5-7B-Instruct","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"atlascloud","labelKey":"atlascloud","defaultEndpoint":"https://api.atlascloud.ai/v1","defaultModel":"qwen/qwen3.5-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"openai","labelKey":"openai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"gemini","labelKey":"gemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"codex_oauth","labelKey":"codexOAuth","defaultEndpoint":null,"defaultModel":"gpt-5.5","authRequirement":"o_auth","validationProbe":"llm_text","staticModels":["gpt-5.5","gpt-5.3-codex","gpt-5.4","gpt-5.5"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"llm","providerType":"mimo","labelKey":"mimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"xiaomi/mimo-v2-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"cometapi","labelKey":"cometapi","defaultEndpoint":"https://api.cometapi.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"openrouterFree","labelKey":"openrouterFree","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"qwen/qwen3-coder:free","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"orcarouter/fusion-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"alibabaCoding","labelKey":"alibabaCoding","defaultEndpoint":"https://coding-intl.dashscope.aliyuncs.com/v1","defaultModel":"qwen3-coder-plus","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"codingPlanX","labelKey":"codingPlanX","defaultEndpoint":"https://api.codingplanx.ai/v1","defaultModel":"gpt-5-mini","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"minimax","labelKey":"minimax","defaultEndpoint":"https://api.minimaxi.com/v1","defaultModel":"MiniMax-M3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"stepfun","labelKey":"stepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"step-1o-turbo-vision","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"opencode","labelKey":"opencode","defaultEndpoint":"https://opencode.ai/zen/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom","labelKey":"customChatCompletions","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom_responses","labelKey":"customResponses","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"responses","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"llm","providerType":"custom_messages","labelKey":"customMessages","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"messages","supportedRequestFormats":["chat_completions","responses","messages"]}, + {"kind":"asr","providerType":"volcengine","labelKey":"asrVolcengine","defaultEndpoint":null,"defaultModel":null,"authRequirement":"volcengine","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"elevenlabs","labelKey":"asrElevenLabs","defaultEndpoint":"https://api.elevenlabs.io/v1","defaultModel":"scribe_v2","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["scribe_v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian","labelKey":"asrBailian","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/inference/","defaultModel":"fun-asr-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["fun-asr-realtime","fun-asr-flash-8k-realtime","qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27","qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15","qwen3-asr-flash","fun-asr","fun-asr-2025-11-07","fun-asr-2025-08-25","fun-asr-mtl","fun-asr-mtl-2025-08-25","paraformer-v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian-qwen3-realtime","labelKey":"asrBailianQwen3","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/realtime","defaultModel":"qwen3-asr-flash-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"bailian-fun-asr-flash","labelKey":"asrBailianFunAsrFlash","defaultEndpoint":"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation","defaultModel":"fun-asr-flash-2026-06-15","authRequirement":"api_key","validationProbe":"asr_non_silent","staticModels":["qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"siliconflow","labelKey":"asrSiliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"FunAudioLLM/SenseVoiceSmall","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"stepfun","labelKey":"asrStepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"stepaudio-2.5-asr","authRequirement":"api_key","validationProbe":"stepfun_no_speech","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"zhipu","labelKey":"asrZhipu","defaultEndpoint":"https://open.bigmodel.cn/api/paas/v4","defaultModel":"glm-asr-2512","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"groq","labelKey":"asrGroq","defaultEndpoint":"https://api.groq.com/openai/v1","defaultModel":"whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"whisper","labelKey":"asrWhisper","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"whisper-1","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"openrouter","labelKey":"asrOpenrouter","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"openai/whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"google/gemini-2.5-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"zenmux","labelKey":"asrZenmux","defaultEndpoint":"https://zenmux.ai/api/v1","defaultModel":"qwen/qwen3-asr-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"openai-compatible","labelKey":"asrOpenAiCompatible","defaultEndpoint":null,"defaultModel":null,"authRequirement":"endpoint_model_optional_api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"xiaomi-mimo-asr","labelKey":"asrXiaomiMimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"mimo-v2.5-asr","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["mimo-v2.5-asr"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"iflytek","labelKey":"asrIflytek","defaultEndpoint":null,"defaultModel":null,"authRequirement":"xfyun","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"foundry-local-whisper","labelKey":"asrFoundryLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-whisper","labelKey":"asrLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"sherpa-onnx-local","labelKey":"asrSherpaOnnxLocal","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3-mlx","labelKey":"asrLocalQwen3Mlx","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3-c","labelKey":"asrLocalQwen3C","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"local-qwen3","labelKey":"asrLocalQwen3","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"asr","providerType":"apple-speech","labelKey":"asrAppleSpeech","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"openai","labelKey":"omniOpenai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o-audio-preview","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"gemini","labelKey":"omniGemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"dashscope-omni","labelKey":"omniDashscope","defaultEndpoint":"https://dashscope.aliyuncs.com/compatible-mode/v1","defaultModel":"qwen3-omni-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, + {"kind":"omni","providerType":"custom","labelKey":"custom","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]} +] diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 8387f2849..f536d8ce8 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -1,3 +1,4 @@ +import mockDescriptors from './mock-provider-descriptors.json' import { invokeOrMock } from './shared' export type ProviderKind = 'asr' | 'llm' | 'omni' @@ -27,23 +28,7 @@ export interface ProviderDescriptor { /** Core owns protocol, defaults, and credential requirements. */ export function listProviderDescriptors(kind: ProviderKind): Promise { - return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ - ['opencode', 'opencode', 'chat_completions'], - ['orcarouter', 'orcarouter', 'chat_completions'], - ['custom', 'customChatCompletions', 'chat_completions'], - ['custom_responses', 'customResponses', 'responses'], - ['custom_messages', 'customMessages', 'messages'], - ].map(([providerType, labelKey, format]) => ({ - kind, providerType, labelKey, - defaultEndpoint: providerType === 'orcarouter' ? 'https://api.orcarouter.ai/v1' : providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null, - defaultModel: providerType === 'orcarouter' ? 'orcarouter/fusion-flash' : providerType === 'opencode' ? 'deepseek-v4-flash' : null, - authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [], - defaultRequestFormat: format as LlmRequestFormat, - supportedRequestFormats: ['chat_completions', 'responses', 'messages'], - })) : kind === 'asr' ? [{ - kind, providerType: 'orcarouter', labelKey: 'orcarouter', - defaultEndpoint: 'https://api.orcarouter.ai/v1', defaultModel: 'google/gemini-2.5-flash', - authRequirement: 'api_key', validationProbe: 'asr_silence', staticModels: [], - defaultRequestFormat: null, supportedRequestFormats: [], - }] : []) + // Snapshot of Core provider_rules::provider_descriptors (all three kinds). + return invokeOrMock('list_provider_descriptors', { kind }, + () => (mockDescriptors as ProviderDescriptor[]).filter(descriptor => descriptor.kind === kind)) } diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index b13b97d3a..7f5e23e65 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -30,6 +30,7 @@ import { type Channel, type ProviderDescriptor, } from '../../lib/ipc'; +import { ProviderFormContext, useProviderForm } from './ProviderForm'; import { emitSaved } from '../../lib/savedEvent'; import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -733,6 +734,7 @@ function ChannelModal({ onUserMutation: () => void; }) { const { t } = useTranslation(); + const form = useProviderForm(); const [name, setName] = useState(channel.name); const [providerType, setProviderType] = useState(channel.providerType); const [changingProvider, setChangingProvider] = useState(false); @@ -810,7 +812,8 @@ function ChannelModal({ const isLocalEngine = descriptor?.authRequirement === 'none'; return ( - + + void form.finish(onClose)} width={mobile ? 'min(560px, 100%)' : 'min(600px, 100%)'}>
{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
@@ -818,8 +821,8 @@ function ChannelModal({ void changeProvider(next)} + disabled={changingProvider || form.leaving} + onChange={next => void form.finish(() => changeProvider(next))} options={presets.map(p => ({ value: p.id, label: t(`settings.providers.presets.${p.nameKey}`), @@ -841,6 +844,7 @@ function ChannelModal({ /> {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} +
{!changingProvider && void onChanged()} onUserMutation={onUserMutation} />} +
{isLocalEngine && (
@@ -863,7 +868,7 @@ function ChannelModal({ {t('settings.channels.deleteConfirm')} - @@ -873,9 +878,10 @@ function ChannelModal({ {t('settings.channels.delete')} )} - +
+
); } diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index f6192f9e3..f36e915cd 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -24,7 +24,11 @@ assert(opencode?.defaultRequestFormat === 'chat_completions' && opencode.defaultEndpoint === 'https://opencode.ai/zen/v1' && opencode.defaultModel === 'deepseek-v4-flash', 'OpenCode browser preset must retain Core defaults'); assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults'); -for (const preset of presets) assert(preset.supportedRequestFormats?.length === 3, 'All compatibility presets allow switching'); +for (const preset of presets) { + assert(preset.supportedRequestFormats?.length === (preset.defaultRequestFormat ? 3 : 0), 'Picker must retain Core protocol capabilities'); +} +assert(presets.find(p => p.id === 'gemini')?.defaultRequestFormat == null, 'Native Gemini must not gain compatibility formats'); +assert(presets.find(p => p.id === 'codex_oauth')?.staticModels?.length, 'OAuth static models must be available without an API key'); const first = await createChannel('llm', 'opencode', 'first'); const second = await createChannel('llm', 'custom', 'second'); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx index ed3b99dd6..f238fe6a1 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectLite } from '../../components/ui/SelectLite'; import { readCredential, setCredential } from '../../lib/ipc'; import type { LlmRequestFormat } from '../../lib/ipc/providers'; import { emitSaved } from '../../lib/savedEvent'; import { SettingRow, inputStyle } from './shared'; +import { ProviderFormContext } from './ProviderForm'; const accounts = ['ark.request_format', 'ark.messages_thinking', 'ark.max_tokens', 'ark.thinking_budget'] as const; type Account = typeof accounts[number]; @@ -35,6 +36,7 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut onSaved?: (changedAccounts: readonly Account[]) => void; }) { const { t } = useTranslation(); + const form = useContext(ProviderFormContext); const [values, setValues] = useState(emptyValues); const [saved, setSaved] = useState(emptyValues); const [loaded, setLoaded] = useState(false); @@ -42,6 +44,8 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut const [error, setError] = useState<'read' | 'save' | null>(null); const mounted = useRef(true); const writing = useRef(false); + const pendingWrite = useRef>(Promise.resolve(true)); + const flushRef = useRef<() => Promise>(() => Promise.resolve(true)); const dirty = accounts.some(account => values[account] !== saved[account]); const validation = protocolValidationError(values, defaultFormat); @@ -60,11 +64,14 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut useEffect(() => { onBlockedChange('protocol', !loaded || saving || dirty || error !== null || validation !== null); }, [loaded, saving, dirty, error, validation, onBlockedChange]); + useEffect(() => form?.register('protocol', () => flushRef.current()), [form?.register]); - const save = async (next: ProtocolValues) => { - if (!loaded || writing.current || protocolValidationError(next, defaultFormat)) return; + const save = (next: ProtocolValues): Promise => { + if (writing.current) return pendingWrite.current; + if (!loaded || protocolValidationError(next, defaultFormat)) return Promise.resolve(false); writing.current = true; setSaving(true); setError(null); + pendingWrite.current = (async () => { try { const changedAccounts = accounts.filter(account => next[account] !== saved[account]); for (const account of changedAccounts) { @@ -75,15 +82,21 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut emitSaved('saved', t('common.saved')); onSaved?.(changedAccounts); } + return true; } catch { if (mounted.current) { setError('save'); emitSaved('failed', t('common.operationFailed')); } + return false; } finally { writing.current = false; if (mounted.current) setSaving(false); } + })(); + return pendingWrite.current; }; + flushRef.current = () => writing.current ? pendingWrite.current : dirty ? save(values) : Promise.resolve(true); const change = (account: Account, value: string, immediate = false) => { + if (!loaded || writing.current || form?.leaving) return; onUserMutation(); const next = { ...values, [account]: value }; setValues(next); @@ -91,7 +104,7 @@ export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMut }; const format = values['ark.request_format'] || defaultFormat; const mode = values['ark.messages_thinking'] || 'adaptive'; - const disabled = !loaded || saving; + const disabled = !loaded || saving || form?.leaving; const numberField = (account: Account, label: string, placeholder: string) => ( Promise) => () => void) | null>(null); + +/** 同一表单的写入在关闭/切换前收敛;版本只含计数,不携带凭据内容。 */ +export function useProviderForm() { + const parent = useContext(ProviderLeaveContext); + const id = useId(); + const version = useRef(0); + const [revision, setRevision] = useState(0); + const [blocked, setBlocked] = useState>({}); + const blockers = useRef>({}); + const flushers = useRef(new Map Promise>()); + const busy = useRef(false); + const [leaving, setLeaving] = useState(false); + const invalidate = useCallback((account = '') => { + if (!['ark.model_id', 'asr.model', 'omni.model'].includes(account)) version.current += 1; + setRevision(value => value + 1); + }, []); + const track = useCallback((account: string, next: boolean) => { + blockers.current[account] = next; + setBlocked(previous => previous[account] === next ? previous : { ...previous, [account]: next }); + }, []); + const register = useCallback((account: string, flush: () => Promise) => { + flushers.current.set(account, flush); + return () => { flushers.current.delete(account); delete blockers.current[account]; + setBlocked(previous => { const next = { ...previous }; delete next[account]; return next; }); }; + }, []); + const finish = useCallback(async (action: () => void | Promise) => { + if (busy.current) return false; + busy.current = true; + setLeaving(true); + invalidate(); + try { + const results = await Promise.all([...flushers.current.values()].map(flush => flush())); + if (!results.every(Boolean) || Object.entries(blockers.current).some(([account, blocked]) => blocked && !flushers.current.has(account))) return false; + await action(); + return true; + } finally { busy.current = false; setLeaving(false); } + }, [invalidate]); + useEffect(() => parent?.(id, () => finish(() => undefined)), [parent, id, finish]); + return useMemo(() => ({ version, revision, blocked, leaving, invalidate, track, register, finish }), + [revision, blocked, leaving, invalidate, track, register, finish]); +} + +export const ProviderFormContext = createContext | null>(null); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 88b964f16..a89e8a418 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -1,4 +1,4 @@ -import { LLM_LABELS, prioritizeOrcaRouterModels } from './ProvidersSection'; +import { LLM_LABELS } from './ProvidersSection'; import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; import { filterOrcaRouterModels } from '../../lib/ipc/asr-credentials'; @@ -41,22 +41,6 @@ if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optio throw new Error('Core provider descriptor must replace the browser fallback in the channel picker'); } -const prioritizedOrcaRouterModels = prioritizeOrcaRouterModels([ - 'openai/gpt-5-mini', - 'orcarouter/fusion-mini', - 'anthropic/claude-haiku-4.5', - 'orcarouter/fusion-flash', -]); - -if (prioritizedOrcaRouterModels.join(',') !== [ - 'orcarouter/fusion-flash', - 'orcarouter/fusion-mini', - 'anthropic/claude-haiku-4.5', - 'openai/gpt-5-mini', -].join(',')) { - throw new Error(`unexpected OrcaRouter model ordering: ${prioritizedOrcaRouterModels.join(',')}`); -} - const protocolCatalog = [ { id: 'model/chat', supported_endpoint_types: ['openai'] }, { id: 'model/responses', supported_endpoint_types: ['openai-response'] }, diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index a599b73e0..6a8fef8f4 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -1,7 +1,7 @@ // 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。 // 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。 -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../../components/Icon'; import { detectOS } from '../../components/WindowChrome'; @@ -15,6 +15,9 @@ import { validateProviderCredentials, type ProviderDescriptor, } from '../../lib/ipc'; +import { ModelCatalog } from './modelCatalog'; +import { CredentialDraft } from './credentialDraft'; +import { ProviderFormContext, useProviderForm } from './ProviderForm'; import { LlmProtocolFields } from './LlmProtocolFields'; import { emitSaved } from '../../lib/savedEvent'; import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; @@ -135,16 +138,17 @@ export function ChannelCredentialFields({ const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; - const [llmModelRevision, setLlmModelRevision] = useState(0); + const form = useContext(ProviderFormContext); const [configRevision, setConfigRevision] = useState(0); - const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0); + const [blockedFields, setBlockedFields] = useState>({}); const trackField = useCallback((account: string, blocked: boolean) => { + form?.track(account, blocked); setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked }); - }, []); + }, [form?.track]); const onLlmMutation = () => { onUserMutation?.(); setConfigRevision(value => value + 1); }; - const [asrModelRevision, setAsrModelRevision] = useState(0); + const unifiedBailian = providerType === 'bailian'; const [bailianModel, setBailianModel] = useState(''); const [volcengineAuthMode, setVolcengineAuthMode] = useState<'app_id_token' | 'api_key'>('app_id_token'); @@ -191,13 +195,8 @@ export function ChannelCredentialFields({ <> {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && ( { - if (providerType === 'orcarouter' && changedAccounts.includes('ark.request_format')) { - setOrcarouterCatalogRevision(value => value + 1); - } - onTested?.(); - }} /> + formats={descriptor.supportedRequestFormats} onUserMutation={() => { form?.invalidate(); onLlmMutation(); }} + onBlockedChange={trackField} onSaved={() => onTested?.()} /> )} {codexOAuthSelected ? (
@@ -234,28 +233,13 @@ export function ChannelCredentialFields({ )} )} - {providerType === 'orcarouter' ? ( - } /> - ) : ( - - )} - /> - )} - setLlmModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} showFetchModels={providerType !== 'orcarouter'} /> + trailing={} /> + ); } @@ -318,8 +302,8 @@ export function ChannelCredentialFields({ ? t('settings.providers.volcengineApiKeyNote') : t('settings.providers.volcengineMappingNote')}
- setAsrModelRevision(v => v + 1)} onTested={onTested} + ); @@ -335,8 +319,8 @@ export function ChannelCredentialFields({
{t('settings.providers.xfyunNote')}
- setAsrModelRevision(v => v + 1)} onTested={onTested} + ); @@ -352,29 +336,6 @@ export function ChannelCredentialFields({ ); } - if (providerType === 'orcarouter') { - return ( - <> - - - - setAsrModelRevision(v => v + 1)} onTested={onTested} - onUserMutation={onUserMutation} showFetchModels={false} /> - - ); - } - return ( <> - ({ value: model, label: model })) - : undefined} /> + onValueChange={unifiedBailian ? setBailianModel : undefined} /> {unifiedBailian && ( - + )} {unifiedBailian && bailianModelSupportsVocabulary(bailianModel) && ( <> @@ -422,9 +378,9 @@ export function ChannelCredentialFields({ {t('settings.providers.zenmuxVocabularyNote')}
)} - {/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */} - setAsrModelRevision(v => v + 1)} onTested={onTested} + {/* 连通性测试与目录拉取分开,不回填模型或 endpoint。 */} + {(providerType === 'openai-compatible' || providerType === 'zenmux') && ( @@ -593,22 +549,10 @@ function bailianModelSupportsVocabulary(model: string): boolean { } // 模型框下的一行协议提示,解决「三种模型看不出区别」——告诉用户当前模型是实时还是 -// 录音文件、行为差异如何。随 asrModelRevision(拉取/选择模型时)与挂载时重读 asr.model。 +// 录音文件、行为差异如何。只使用当前渠道模型字段的值。 function BailianProtocolHint({ currentModel }: { currentModel: string }) { const { t } = useTranslation(); - const [model, setModel] = useState(''); - - useEffect(() => { - let cancelled = false; - readCredential('asr.model') - .then(v => { if (!cancelled) setModel(v || 'fun-asr-realtime'); }) - .catch(() => { /* 读失败按默认实时提示 */ }); - return () => { cancelled = true; }; - }, []); - - useEffect(() => { - setModel(currentModel || 'fun-asr-realtime'); - }, [currentModel]); + const model = currentModel || 'fun-asr-realtime'; const protocol = bailianModelProtocol(model); const hint = protocol === 'realtime' @@ -626,187 +570,43 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -/** - * OrcaRouter exposes a large, changing catalog. Keep its own router models at - * the top, then sort the remaining vendor/model ids for predictable scanning. - */ -export function prioritizeOrcaRouterModels(models: string[]): string[] { - return [...models].sort((left, right) => { - const leftOwn = left.startsWith('orcarouter/'); - const rightOwn = right.startsWith('orcarouter/'); - if (leftOwn !== rightOwn) return leftOwn ? -1 : 1; - return left.localeCompare(right); - }); -} - -function CatalogModelField({ - kind, - provider, - baseUrl, - defaultModel, - trailing, - onUserMutation, - onBlockedChange, -}: { - kind: 'llm' | 'asr'; - provider: string; - baseUrl: string; - defaultModel: string; - trailing?: ReactNode; - onUserMutation?: () => void; - onBlockedChange?: (account: string, blocked: boolean) => void; +/** 目录只提供候选;CredentialField 是选择与手填唯一的写入入口。 */ +function ModelField({ kind, providerType, staticModels = [], ...field }: Omit & { + kind: 'llm' | 'asr' | 'omni'; providerType: string; staticModels?: string[]; }) { const { t } = useTranslation(); - const baseLayoutStack = useLayoutStack(); - const conservative = useConservativeLayout(); - const layoutStack = conservative || baseLayoutStack; - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(''); - const [status, setStatus] = useState('loading'); - const [message, setMessage] = useState(t('settings.providers.loadingModels')); - const requestRef = useRef(0); - const endpointAccount = kind === 'llm' ? 'ark.endpoint' : 'asr.endpoint'; - const modelAccount = kind === 'llm' ? 'ark.model_id' : 'asr.model'; - - useEffect(() => { - onBlockedChange?.(modelAccount, status !== 'success'); - }, [modelAccount, status, onBlockedChange]); - - const loadModels = async (initialize: boolean) => { - const requestId = ++requestRef.current; - setStatus('loading'); - setMessage(t('settings.providers.loadingModels')); - try { - // A newly created/migrated channel may not have received its preset yet. - // Fill only an empty endpoint here; an explicit endpoint edit remains - // respected, matching the behavior of the other named providers. - if (initialize) { - const endpoint = await readCredential(endpointAccount, provider); - if (!endpoint?.trim()) { - await setCredential(endpointAccount, baseUrl, provider); - } - } - const [savedModel, result] = await Promise.all([ - readCredential(modelAccount, provider), - listProviderModels(kind, provider, 'orcarouter'), - ]); - if (requestId !== requestRef.current) return; - const nextModels = prioritizeOrcaRouterModels(result.models); - setModels(nextModels); - if (nextModels.length === 0) { - setSelectedModel(''); - setStatus('empty'); - setMessage(t('settings.providers.modelsEmpty')); - return; - } - - const current = savedModel?.trim() ?? ''; - const nextModel = nextModels.includes(current) - ? current - : nextModels.includes(defaultModel) - ? defaultModel - : nextModels[0]; - if (nextModel !== current) { - await setCredential(modelAccount, nextModel, provider); - } - if (requestId !== requestRef.current) return; - setSelectedModel(nextModel); - setStatus('success'); - setMessage(t('settings.providers.modelsLoaded', { count: nextModels.length })); - } catch (error) { - if (requestId !== requestRef.current) return; - setModels([]); - setStatus('error'); - setMessage(providerErrorMessage(error, t)); - } - }; - - useEffect(() => { - void loadModels(true); - return () => { - requestRef.current += 1; - }; - // The channel id defines the credential scope; changing it must reload the catalog. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider]); - - const applyModel = async (model: string) => { - onUserMutation?.(); - setStatus('loading'); - setMessage(t('common.saving')); - try { - await setCredential(modelAccount, model, provider); - setSelectedModel(model); - setStatus('success'); - setMessage(t('settings.providers.modelSaved', { model })); - emitSaved('saved', t('common.saved')); - } catch (error) { - setStatus('error'); - setMessage(providerErrorMessage(error, t)); - emitSaved('failed', t('common.operationFailed')); - } + const form = useContext(ProviderFormContext); + const loader = useMemo(() => new ModelCatalog(), [kind, field.provider, providerType]); + const catalog = useSyncExternalStore(loader.subscribe, loader.snapshot, loader.snapshot); + const version = form?.version.current ?? 0; + useEffect(() => () => loader.invalidate(), [loader]); + const loadModels = async () => { + if (form?.leaving || Object.values(form?.blocked ?? {}).some(Boolean)) return; + await loader.load(version, () => listProviderModels(kind, field.provider, providerType), + () => form?.version.current ?? 0); }; - - return ( - -
-
- void applyModel(model)} - options={models.map(model => ({ value: model, label: model }))} - placeholder={status === 'loading' - ? t('settings.providers.loadingModels') - : t('settings.providers.selectModel')} - disabled={status === 'loading' || models.length === 0} - searchable - searchPlaceholder={t('settings.providers.searchModels')} - emptyMessage={t('settings.providers.noMatchingModels')} - ariaLabel={t('settings.providers.selectModel')} - style={{ - flex: layoutStack ? '1 1 100%' : 1, - width: '100%', - minWidth: 0, - maxWidth: '100%', - fontFamily: 'var(--ol-font-mono)', - }} - /> - - {trailing} -
- - {message} - - - {t(kind === 'asr' - ? 'settings.providers.orcarouterAsrCatalogHint' - : 'settings.providers.orcarouterCatalogHint')} - -
-
- ); + const active = catalog?.version === version ? catalog : null; + const models = [...new Set([...staticModels, ...(active?.models ?? [])])].sort((a, b) => a.localeCompare(b)); + const message = !active ? '' : active.status === 'loading' ? t('settings.providers.loadingModels') + : active.status === 'error' ? providerErrorMessage(active.error, t) + : active.models.length ? t('settings.providers.modelsLoaded', { count: active.models.length }) : t('settings.providers.modelsEmpty'); + return ({ value, label: value }))} + below={<> + + {message && {message}} + } />; } -function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, provider, onTested, onUserMutation, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; provider?: string; onTested?: () => void; onUserMutation?: () => void }) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); @@ -832,8 +632,6 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested const validate = async () => { if (disabled) return; onUserMutation?.(); - setModels([]); - setSelectedModel(''); setResult('loading', t('settings.providers.validating')); const started = performance.now(); try { @@ -861,57 +659,11 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested } }; - const loadModels = async () => { - if (disabled) return; - onUserMutation?.(); - setResult('loading', t('settings.providers.loadingModels')); - try { - const result = await listProviderModels(kind, provider); - setModels(result.models); - if (result.models.length === 0) { - setResult('empty', t('settings.providers.modelsEmpty')); - } else { - setSelectedModel(''); - setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length })); - } - } catch (error) { - setModels([]); - setResult('error', providerErrorMessage(error, t)); - } - }; - - const applyModel = async (model: string) => { - onUserMutation?.(); - setResult('loading', t('common.saving')); - try { - await setCredential(modelAccount, model, provider); - setSelectedModel(model); - onModelSelected(); - setResult('success', t('settings.providers.modelSaved', { model })); - } catch (error) { - setResult('error', providerErrorMessage(error, t)); - } - }; - return (
- {showFetchModels && ( - - )} - {showFetchModels && models.length > 0 && ( - ({ value: model, label: model }))} - placeholder={t('settings.providers.selectModel')} - ariaLabel={t('settings.providers.selectModel')} - style={{ flex: layoutStack ? '1 1 100%' : '1 1 180px', maxWidth: layoutStack ? '100%' : 220, minWidth: 0 }} - /> - )}
{message && ( @@ -955,8 +707,6 @@ function providerErrorMessage(error: unknown, t: ReturnType void; label: string; @@ -972,156 +722,56 @@ interface CredentialFieldProps { onUserMutation?: () => void; /** 提供则渲染为下拉(预设选择)代替输入框;当前值不在预设里时附加为自定义项。 */ options?: SelectOption[]; + below?: ReactNode; } -function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange }: CredentialFieldProps) { +function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange, below }: CredentialFieldProps) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; - const [value, setValue] = useState(''); + const form = useContext(ProviderFormContext); + const draft = useMemo(() => new CredentialDraft( + () => readCredential(account, provider), value => setCredential(account, value, provider), + ), [account, provider]); + const { value, loaded, dirty, status } = useSyncExternalStore(draft.subscribe, draft.snapshot, draft.snapshot); const [revealed, setRevealed] = useState(false); - const [loaded, setLoaded] = useState(false); - const [dirty, setDirty] = useState(false); - const [status, setStatus] = useState('idle'); - // 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。 const [customModelMode, setCustomModelMode] = useState(false); + const composing = useRef(false); + const blocked = !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError'; + useEffect(() => { void draft.load(); return () => draft.dispose(); }, [draft]); useEffect(() => { - onBlockedChange?.(account, !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError'); - }, [account, loaded, dirty, status, onBlockedChange]); - - const debounceRef = useRef(null); - const statusRef = useRef(null); - const mountedRef = useRef(true); - const editRevision = useRef(0); - const saveQueue = useRef>(Promise.resolve()); - const markMutation = () => { editRevision.current += 1; onUserMutation?.(); }; - + form?.track(account, blocked); + onBlockedChange?.(account, blocked); + }, [account, blocked, form?.track, onBlockedChange]); + useEffect(() => form?.register(account, draft.flush), [account, draft, form?.register]); + useEffect(() => { if (loaded) onValueChange?.(value); }, [loaded, value, onValueChange]); useEffect(() => { - let cancelled = false; - setLoaded(false); - setDirty(false); - setStatus('idle'); - setValue(''); - onValueChange?.(''); - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } - readCredential(account, provider) - .then(v => { - if (cancelled) return; - setValue(v ?? ''); - onValueChange?.(v ?? ''); - setLoaded(true); - }) - .catch(error => { - if (cancelled) return; - console.error('[settings] failed to read credential', account, error); - onValueChange?.(''); - setLoaded(true); - setStatus('readError'); - }); - return () => { - cancelled = true; - }; - }, [account, provider, onValueChange]); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (debounceRef.current) clearTimeout(debounceRef.current); - if (statusRef.current) clearTimeout(statusRef.current); - }; - }, []); - - // 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved / - // saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它 - // 页面 toast 风格不统一。 - const showTemporaryStatus = (next: CredentialFieldStatus) => { - if (next === 'saving') { - emitSaved('saving', t('common.saving')); - } else if (next === 'saved') { - emitSaved('saved', t('common.saved')); - } else if (next === 'saveError') { - emitSaved('failed', t('common.operationFailed')); - } else if (next === 'copied') { - emitSaved('saved', t('common.copied')); - } else if (next === 'copyError') { - emitSaved('failed', t('common.operationFailed')); - } - setStatus(next); - if (statusRef.current) clearTimeout(statusRef.current); - statusRef.current = window.setTimeout(() => setStatus('idle'), 1600); - }; - - const save = async (v: string, force = false) => { - if (!loaded || (!dirty && !force)) return; - if (!mountedRef.current) return; - const revision = editRevision.current; - setStatus('saving'); - emitSaved('saving', t('common.saving')); - try { - // 按编辑顺序写入,旧请求完成不能把新值标记为已保存。 - const write = saveQueue.current.catch(() => undefined).then(() => setCredential(account, v, provider)); - saveQueue.current = write; - await write; - if (!mountedRef.current || revision !== editRevision.current) return; - setDirty(false); - showTemporaryStatus('saved'); - } catch (error) { - if (!mountedRef.current || revision !== editRevision.current) return; - console.error('[settings] failed to save credential', account, error); - showTemporaryStatus('saveError'); - } - }; - - const handleChange = (e: React.ChangeEvent) => { - markMutation(); - const v = e.target.value; - setValue(v); - onValueChange?.(v); - if (!loaded) return; - setDirty(true); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = window.setTimeout(() => save(v, true), 300); - }; - - const onBlur = () => { - if (!loaded || !dirty) return; - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } - void save(value, true); - }; - - const fillDefault = async () => { - if (!loaded || !defaultValue) return; - markMutation(); - setValue(defaultValue); - onValueChange?.(defaultValue); - setDirty(true); - await save(defaultValue, true); + if (status === 'saving') emitSaved('saving', t('common.saving')); + if (status === 'saved') emitSaved('saved', t('common.saved')); + if (status === 'saveError') emitSaved('failed', t('common.operationFailed')); + }, [status, t]); + + const change = (next: string, immediate = false) => { + if (!loaded || form?.leaving) return; + form?.track(account, true); + form?.invalidate(account); + onUserMutation?.(); + draft.edit(next); + if (immediate) void draft.flush(); + else if (!composing.current) draft.schedule(); }; - + const onBlur = () => { if (!composing.current) void draft.flush(); }; const onCopy = async () => { if (!value || !loaded) return; try { - if (!navigator.clipboard?.writeText) { - throw new Error('Clipboard API unavailable'); - } await navigator.clipboard.writeText(value); - showTemporaryStatus('copied'); - } catch (error) { - console.error('[settings] failed to copy credential', account, error); - showTemporaryStatus('copyError'); - } + emitSaved('saved', t('common.copied')); + } catch { emitSaved('failed', t('common.operationFailed')); } }; const inputType = mask && !revealed ? 'password' : 'text'; - const disabled = !loaded; + const disabled = !loaded || form?.leaving; const showInsecureEndpointWarning = (account === 'ark.endpoint' || account === 'asr.endpoint' || account === 'omni.endpoint') && value.trim().toLowerCase().startsWith('http://'); @@ -1129,7 +779,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
- {options && !customModelMode ? ( + {!!options?.length && !customModelMode ? ( { @@ -1138,18 +788,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de setCustomModelMode(true); return; } - markMutation(); - setValue(v); - onValueChange?.(v); - if (!loaded) return; - setDirty(true); - void save(v, true); + change(v, true); }} options={[ ...(value && !options.some(o => o.value === value) ? [{ value, label: value }] : []), ...options, - { value: CUSTOM_MODEL_OPTION_VALUE, label: t('settings.providers.customModelLabel', 'Custom model…') }, + { value: CUSTOM_MODEL_OPTION_VALUE, label: t('settings.providers.customModelLabel'), alwaysVisible: true }, ]} + searchable + searchPlaceholder={t('settings.providers.searchModels')} + emptyMessage={t('settings.providers.noMatchingModels')} placeholder={loaded ? placeholder : t('common.loading')} disabled={disabled} ariaLabel={label} @@ -1160,13 +808,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de type={inputType} value={value} placeholder={loaded ? placeholder : t('common.loading')} - onChange={handleChange} + aria-label={label} + onChange={event => { if (options) setCustomModelMode(true); change(event.target.value); }} + onCompositionStart={() => { composing.current = true; draft.pause(); }} + onCompositionEnd={() => { composing.current = false; draft.schedule(); }} onBlur={onBlur} disabled={disabled} style={{ ...inputStyle, flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }} /> )} - {options && customModelMode && ( + {!!options?.length && customModelMode && ( )} {defaultValue && !value && loaded && ( - )} @@ -1215,6 +866,8 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de )}
+ {below &&
{below}
} + {status === 'saveError' && } {showInsecureEndpointWarning && ( {t('settings.providers.endpointHttpWarning')} @@ -1262,7 +915,7 @@ export function OmniChannelSection() { const [omniProvider, setOmniProvider] = useState('custom'); const [committedOmniProvider, setCommittedOmniProvider] = useState('custom'); const omniSwitchSeqRef = useRef(0); - const [omniModelRevision, setOmniModelRevision] = useState(0); + const form = useProviderForm(); useEffect(() => { void listProviderDescriptors('omni') @@ -1275,15 +928,16 @@ export function OmniChannelSection() { nameKey: descriptor.labelKey, baseUrl: descriptor.defaultEndpoint ?? '', modelPlaceholder: descriptor.defaultModel ?? '', + staticModels: descriptor.staticModels, })), [descriptors]); useEffect(() => { - if (!prefs) return; + if (!prefs || form.leaving) return; const knownOmni = omniPresets.find(x => x.id === prefs.activeOmniProvider); const omniId = knownOmni ? knownOmni.id : 'custom'; setOmniProvider(omniId); setCommittedOmniProvider(omniId); - }, [prefs, omniPresets]); + }, [prefs?.activeOmniProvider, omniPresets, form.leaving]); // 与 LLM 卡同语义:受控下拉立即反馈 + committed 控制 CredentialField remount // + seq 守卫防 stale 覆盖,只是凭据落到 omni.* 槽。 @@ -1341,7 +995,7 @@ export function OmniChannelSection() { const omniPreset = omniPresets.find(p => p.id === committedOmniProvider); return ( - <> +
( + )} {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} @@ -383,7 +422,7 @@ export function ChannelCredentialFields({ onTested={onTested} onUserMutation={onUserMutation} /> {(providerType === 'openai-compatible' || providerType === 'zenmux') && ( - + )} ); @@ -394,41 +433,25 @@ export function ChannelCredentialFields({ // zenmux 暴露 enable_itn(数字归一化)开关,verbose_json / 分片对其无意义。 function AsrAdvancedOptions({ provider, + providerType, onUserMutation, }: { provider: string; + providerType: 'openai-compatible' | 'zenmux'; onUserMutation?: () => void; }) { const { t } = useTranslation(); - const [verboseJson, setVerboseJson] = useState(false); + const advanced = useRegisteredCredentialDraft('asr.advanced_config', provider, onUserMutation); + const config = useMemo(() => parseAdvancedAsrConfig(advanced.loaded ? advanced.value : null), + [advanced.loaded, advanced.value]); const [chunkDraft, setChunkDraft] = useState(''); - const [enableItn, setEnableItn] = useState(true); - const [status, setStatus] = useState<'idle' | 'saving' | 'error'>('idle'); - const [error, setError] = useState(''); + const chunkInitialized = useRef(false); useEffect(() => { - let cancelled = false; - setStatus('idle'); - setError(''); - void (async () => { - try { - const raw = await readCredential('asr.advanced_config', provider); - if (cancelled) return; - const config = parseAdvancedAsrConfig(raw); - setVerboseJson(config.verboseJson); - setChunkDraft(config.chunkDurationMs ? String(config.chunkDurationMs) : ''); - setEnableItn(config.enableItn); - } catch (err) { - if (!cancelled) { - setStatus('error'); - setError(err instanceof Error ? err.message : String(err)); - } - } - })(); - return () => { - cancelled = true; - }; - }, [provider]); + if (!advanced.loaded || (chunkInitialized.current && advanced.status !== 'saved')) return; + chunkInitialized.current = true; + setChunkDraft(config.chunkDurationMs ? String(config.chunkDurationMs) : ''); + }, [advanced.loaded, advanced.status, config.chunkDurationMs]); const parseChunkDraft = (draft: string): number | null => { const value = Number(draft); @@ -436,32 +459,20 @@ function AsrAdvancedOptions({ return Math.floor(value); }; - const save = async (partial: { + const edit = (partial: { verboseJson?: boolean chunkDurationMs?: number | null enableItn?: boolean }) => { - onUserMutation?.(); - setStatus('saving'); - setError(''); const next: AdvancedAsrConfig = { - verboseJson: partial.verboseJson ?? verboseJson, + verboseJson: partial.verboseJson ?? config.verboseJson, chunkDurationMs: partial.chunkDurationMs !== undefined ? partial.chunkDurationMs : parseChunkDraft(chunkDraft), - enableItn: partial.enableItn ?? enableItn, + enableItn: partial.enableItn ?? config.enableItn, }; - try { - await setCredential('asr.advanced_config', serializeAdvancedAsrConfig(next), provider); - setVerboseJson(next.verboseJson); - setChunkDraft(next.chunkDurationMs ? String(next.chunkDurationMs) : ''); - setEnableItn(next.enableItn); - setStatus('idle'); - } catch (err) { - setStatus('error'); - setError(err instanceof Error ? err.message : String(err)); - } + return advanced.edit(serializeAdvancedAsrConfig(next)); }; return ( @@ -477,12 +488,15 @@ function AsrAdvancedOptions({ > {t('settings.providers.asrAdvancedNote')}
- {provider === 'zenmux' ? ( +
+ {providerType === 'zenmux' ? ( - void save({ enableItn: next })} /> + { + if (edit({ enableItn: next })) void advanced.draft.flush(); + }} /> ) : ( <> @@ -490,7 +504,9 @@ function AsrAdvancedOptions({ label={t('settings.providers.asrAdvancedVerboseJsonLabel')} desc={t('settings.providers.asrAdvancedVerboseJsonHint')} > - void save({ verboseJson: next })} /> + { + if (edit({ verboseJson: next })) void advanced.draft.flush(); + }} /> setChunkDraft(e.target.value)} - onBlur={() => void save({ chunkDurationMs: parseChunkDraft(chunkDraft) })} + disabled={advanced.disabled} + onChange={(event) => { + const value = event.target.value; + setChunkDraft(value); + if (edit({ chunkDurationMs: parseChunkDraft(value) })) advanced.draft.schedule(); + }} + onBlur={() => void advanced.draft.flush()} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} @@ -513,11 +533,15 @@ function AsrAdvancedOptions({ )} - {status === 'error' && ( +
+ {advanced.status === 'readError' && (
- {t('common.operationFailed')}: {error} + {t('settings.providers.readFailed')}
)} + {advanced.status === 'saveError' && ( + + )} ); } @@ -730,34 +754,15 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; - const form = useContext(ProviderFormContext); - const draft = useMemo(() => new CredentialDraft( - () => readCredential(account, provider), value => setCredential(account, value, provider), - ), [account, provider]); - const { value, loaded, dirty, status } = useSyncExternalStore(draft.subscribe, draft.snapshot, draft.snapshot); + const credential = useRegisteredCredentialDraft(account, provider, onUserMutation, onBlockedChange); + const { draft, value, loaded, status, disabled } = credential; const [revealed, setRevealed] = useState(false); const [customModelMode, setCustomModelMode] = useState(false); const composing = useRef(false); - const blocked = !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError'; - useEffect(() => { void draft.load(); return () => draft.dispose(); }, [draft]); - useEffect(() => { - form?.track(account, blocked); - onBlockedChange?.(account, blocked); - }, [account, blocked, form?.track, onBlockedChange]); - useEffect(() => form?.register(account, draft.flush), [account, draft, form?.register]); useEffect(() => { if (loaded) onValueChange?.(value); }, [loaded, value, onValueChange]); - useEffect(() => { - if (status === 'saving') emitSaved('saving', t('common.saving')); - if (status === 'saved') emitSaved('saved', t('common.saved')); - if (status === 'saveError') emitSaved('failed', t('common.operationFailed')); - }, [status, t]); const change = (next: string, immediate = false) => { - if (!loaded || form?.leaving) return; - form?.track(account, true); - form?.invalidate(account); - onUserMutation?.(); - draft.edit(next); + if (!credential.edit(next)) return; if (immediate) void draft.flush(); else if (!composing.current) draft.schedule(); }; @@ -771,7 +776,6 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de }; const inputType = mask && !revealed ? 'password' : 'text'; - const disabled = !loaded || form?.leaving; const showInsecureEndpointWarning = (account === 'ark.endpoint' || account === 'asr.endpoint' || account === 'omni.endpoint') && value.trim().toLowerCase().startsWith('http://'); diff --git a/openless-all/app/src/pages/settings/credentialDraft.test.ts b/openless-all/app/src/pages/settings/credentialDraft.test.ts index 611223b85..4a2c663c5 100644 --- a/openless-all/app/src/pages/settings/credentialDraft.test.ts +++ b/openless-all/app/src/pages/settings/credentialDraft.test.ts @@ -54,6 +54,29 @@ async function main() { failed = false; assert(await retry.flush() && !retry.snapshot().dirty, 'same edit must be retryable'); + const authWrite = deferred(); + const advancedWrite = deferred(); + let advancedAttempts = 0; + const authMode = new CredentialDraft(async () => '', () => authWrite.promise); + const advanced = new CredentialDraft(async () => '{}', () => ( + ++advancedAttempts === 1 ? advancedWrite.promise : Promise.resolve() + )); + await Promise.all([authMode.load(), advanced.load()]); + authMode.edit('api_key'); + advanced.edit('{"verboseJson":true,"chunkDurationMs":30000,"enableItn":false}'); + let leaveFinished = false; + const leave = Promise.all([authMode.flush(), advanced.flush()]) + .then(results => results.every(Boolean)) + .finally(() => { leaveFinished = true; }); + await tick(); + assert(!leaveFinished, 'leaving must wait for every registered credential write'); + authWrite.resolve(); + advancedWrite.reject(new Error('save')); + assert(!await leave, 'one failed credential write must block leaving'); + assert(authMode.snapshot().value === 'api_key' && !authMode.snapshot().dirty, 'successful auth mode must remain saved'); + assert(advanced.snapshot().value.includes('30000') && advanced.snapshot().dirty, 'failed advanced config must retain its draft'); + assert(await advanced.flush() && !advanced.snapshot().dirty, 'advanced config must retry the retained value'); + const oldRead = deferred(); let reads = 0; const reading = new CredentialDraft(() => ++reads === 1 ? oldRead.promise : Promise.resolve('new'), async () => undefined); @@ -63,6 +86,6 @@ async function main() { reading.dispose(); await reading.load(); reading.edit('typed'); oldRead.resolve('stale'); await initial; assert(reading.snapshot().value === 'typed', 'old read must not replace a new scope or edit'); - draft.dispose(); retry.dispose(); reading.dispose(); + draft.dispose(); retry.dispose(); authMode.dispose(); advanced.dispose(); reading.dispose(); } void main();