feat(providers): 统一模型字段、搜索与异步保存隔离 (#1044)

* feat(providers): unify model fields and isolate asynchronous updates

* fix(providers): preserve pending credential drafts
This commit is contained in:
Chris233 authored and GitHub committed 2026-09-09 14:57:35 +08:00
1 parent 66915405fd
commit 5113eaf245
16 files changed
+590 -602

No files matched your search

@@ -1084,6 +1084,19 @@ mod tests {
}
}
#[test]
fn browser_provider_descriptor_fixture_matches_core() {
let fixture: Vec<ProviderDescriptor> = serde_json::from_str(include_str!(
"../../../src/lib/ipc/mock-provider-descriptors.json"
))
.unwrap();
let actual = [ProviderKind::Llm, ProviderKind::Asr, ProviderKind::Omni]
.into_iter()
.flat_map(provider_descriptors)
.collect::<Vec<_>>();
assert_eq!(fixture, actual);
}
#[test]
fn secret_like_volc_resource_ids_are_not_attributed() {
assert_eq!(
@@ -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 (
<ProviderLeaveContext.Provider value={providerForm.register}>
<div
onClick={mobile ? undefined : onClose}
onClick={mobile ? undefined : close}
style={{
position: mobile ? 'fixed' : 'absolute',
inset: 0,
@@ -124,7 +128,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
}}>
<button
type="button"
onClick={onClose}
onClick={close}
aria-label={t('common.close')}
style={mobileHeaderBtnStyle}
>
@@ -140,7 +144,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
<button
key={it.id}
type="button"
onClick={() => setSection(it.id as SettingsSectionId)}
onClick={() => void providerForm.finish(() => setSection(it.id as SettingsSectionId))}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={mobileTabChipStyle(active)}
>
@@ -186,7 +190,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
<button
key={it.id}
ref={el => { tabRefs.current[idx] = el; }}
onClick={() => setSection(it.id as SettingsSectionId)}
onClick={() => void providerForm.finish(() => setSection(it.id as SettingsSectionId))}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
@@ -223,7 +227,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
/>
{!mobile && (
<button
onClick={onClose}
onClick={close}
style={{
position: 'absolute', top: 14, right: 14, zIndex: 2,
width: 28, height: 28, border: 0, borderRadius: 999,
@@ -288,6 +292,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
</div>
</div>
</div>
</ProviderLeaveContext.Provider>
);
}
@@ -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<HTMLInputElement>) => {
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) && (
<div
style={{
padding: '18px 10px',
@@ -2,7 +2,7 @@ import type { CredentialsStatus } from "../types"
import { invokeOrMock, isTauri } from "./shared"
import { mockCredentialsStatus, mockCredentialValues } from "./mock-data"
import { invalidateMockChannelTest } from "./channels"
import type { LlmRequestFormat } from "./providers"
import { listProviderDescriptors, type LlmRequestFormat } from "./providers"
export interface ProviderCheckResult {
ok: boolean
@@ -104,6 +104,10 @@ export async function listProviderModels(
channelId?: string,
providerType?: string,
): Promise<ProviderModelsResult> {
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"],
}))
}
@@ -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":[]}
]
+4 -19
View File
@@ -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<ProviderDescriptor[]> {
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))
}
@@ -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 (
<Modal onClose={onClose} width={mobile ? 'min(560px, 100%)' : 'min(600px, 100%)'}>
<ProviderFormContext.Provider value={form}>
<Modal onClose={() => void form.finish(onClose)} width={mobile ? 'min(560px, 100%)' : 'min(600px, 100%)'}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)', marginBottom: 14 }}>
{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
</div>
@@ -818,8 +821,8 @@ function ChannelModal({
<label style={fieldLabel}>{t('settings.channels.providerLabel')}</label>
<SelectLite
value={providerType}
disabled={changingProvider}
onChange={next => 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 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */}
<fieldset disabled={changingProvider || form.leaving} style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}>
{!changingProvider && <ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
@@ -850,6 +854,7 @@ function ChannelModal({
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>}
</fieldset>
{isLocalEngine && (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginTop: 6 }}>
@@ -863,7 +868,7 @@ function ChannelModal({
<span style={{ fontSize: 12, color: 'var(--ol-warn)' }}>
{t('settings.channels.deleteConfirm')}
</span>
<button onClick={() => void remove()} style={dangerBtn}>
<button onClick={() => void form.finish(remove)} disabled={form.leaving} style={dangerBtn}>
{t('settings.channels.confirmDelete')}
</button>
<button onClick={() => setConfirmDelete(false)} style={ghostBtn}>{t('common.cancel')}</button>
@@ -873,9 +878,10 @@ function ChannelModal({
{t('settings.channels.delete')}
</button>
)}
<button onClick={onClose} style={primaryBtn}>{t('common.close')}</button>
<button onClick={() => void form.finish(onClose)} disabled={form.leaving} style={primaryBtn}>{t('common.close')}</button>
</div>
</Modal>
</ProviderFormContext.Provider>
);
}
@@ -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');
@@ -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<ProtocolValues>(emptyValues);
const [saved, setSaved] = useState<ProtocolValues>(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<boolean>>(Promise.resolve(true));
const flushRef = useRef<() => Promise<boolean>>(() => 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<boolean> => {
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) => (
<SettingRow label={label}>
<input type="number" min={account === 'ark.thinking_budget' ? 1024 : 1} step={1}
@@ -0,0 +1,46 @@
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
export const ProviderLeaveContext = createContext<((id: string, flush: () => Promise<boolean>) => () => 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<Record<string, boolean>>({});
const blockers = useRef<Record<string, boolean>>({});
const flushers = useRef(new Map<string, () => Promise<boolean>>());
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<boolean>) => {
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<void>) => {
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<ReturnType<typeof useProviderForm> | null>(null);
@@ -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'] },
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
import { CredentialDraft } from './credentialDraft';
function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); }
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
async function main() {
const writes: string[] = [];
const first = deferred<void>();
const second = deferred<void>();
const draft = new CredentialDraft(async () => 'saved', value => {
writes.push(`channel-A:ark.model_id:${value}`);
return writes.length === 1 ? first.promise : second.promise;
});
await draft.load();
assert(draft.snapshot().value === 'saved' && writes.length === 0, 'reading must not write');
draft.edit('m1');
const save1 = draft.flush();
assert(draft.flush() === save1, 'blur and close must share the pending save');
await tick();
draft.edit('m2');
const save2 = draft.flush();
assert(writes.length === 1, 'm2 must wait for m1');
first.reject(new Error('old failure'));
await save1;
await tick();
assert(draft.snapshot().value === 'm2' && draft.snapshot().status === 'saving', 'old failure must not replace new status');
assert(writes.join(',') === 'channel-A:ark.model_id:m1,channel-A:ark.model_id:m2', 'writes must capture account and order');
second.resolve();
assert(await save2, 'new value must save after old failure');
assert(!draft.snapshot().dirty && draft.snapshot().value === 'm2', 'only latest save clears dirty');
const sequence: string[] = [];
const closeDraft = new CredentialDraft(async () => '', async value => { sequence.push(value); });
await closeDraft.load();
closeDraft.edit('debounced'); closeDraft.schedule();
closeDraft.edit('selected'); await closeDraft.flush();
await new Promise(resolve => setTimeout(resolve, 350));
assert(sequence.join(',') === 'selected', 'selection cancels an older debounce');
closeDraft.edit('before-switch'); closeDraft.schedule();
if (await closeDraft.flush()) sequence.push('new-provider-default');
closeDraft.dispose();
assert(sequence.slice(-2).join(',') === 'before-switch,new-provider-default', 'switch must follow the pending draft');
let failed = true;
const retry = new CredentialDraft(async () => 'original', async () => { if (failed) throw new Error('save'); });
await retry.load(); retry.edit('custom');
assert(!await retry.flush() && retry.snapshot().dirty, 'failed write must block close and retain the draft');
failed = false;
assert(await retry.flush() && !retry.snapshot().dirty, 'same edit must be retryable');
const authWrite = deferred<void>();
const advancedWrite = deferred<void>();
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<string | null>();
let reads = 0;
const reading = new CredentialDraft(() => ++reads === 1 ? oldRead.promise : Promise.resolve('new'), async () => undefined);
const initial = reading.load();
reading.edit('too-early');
assert(reading.snapshot().value === '', 'reading must disable editing');
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(); authMode.dispose(); advanced.dispose(); reading.dispose();
}
void main();
@@ -0,0 +1,64 @@
type Status = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError';
/** 一个凭据槽的草稿和串行写入;调用方在更换作用域前等待 flush。 */
export class CredentialDraft {
private state = { value: '', loaded: false, dirty: false, status: 'idle' as Status };
private listeners = new Set<() => void>();
private revision = 0;
private generation = 0;
private timer: ReturnType<typeof setTimeout> | undefined;
private queue: Promise<unknown> = Promise.resolve();
private pending: { revision: number; promise: Promise<boolean> } | undefined;
constructor(private read: () => Promise<string | null>, private write: (value: string) => Promise<void>) {}
snapshot = () => this.state;
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
private update(next: Partial<typeof this.state>) {
this.state = { ...this.state, ...next };
this.listeners.forEach(listener => listener());
}
async load() {
const generation = ++this.generation;
const revision = this.revision;
try {
const value = await this.read();
if (generation === this.generation && revision === this.revision) this.update({ value: value ?? '', loaded: true });
} catch {
if (generation === this.generation) this.update({ status: 'readError' });
}
}
edit(value: string) {
if (!this.state.loaded) return;
clearTimeout(this.timer);
this.revision += 1;
this.update({ value, dirty: true, status: 'idle' });
}
schedule() {
clearTimeout(this.timer);
const revision = this.revision;
this.timer = setTimeout(() => { if (revision === this.revision) void this.flush(); }, 300);
}
pause() { clearTimeout(this.timer); }
flush = (): Promise<boolean> => {
clearTimeout(this.timer);
if (!this.state.dirty) return Promise.resolve(true);
if (!this.state.loaded) return Promise.resolve(false);
const revision = this.revision;
if (this.pending?.revision === revision) return this.pending.promise;
const generation = this.generation;
const value = this.state.value;
this.update({ status: 'saving' });
const write = this.queue.catch(() => undefined).then(() => this.write(value));
this.queue = write;
const promise = write.then(() => {
if (revision !== this.revision || generation !== this.generation) return false;
this.update({ dirty: false, status: 'saved' });
return true;
}, () => {
if (revision === this.revision && generation === this.generation) this.update({ status: 'saveError' });
return false;
}).finally(() => { if (this.pending?.revision === revision) this.pending = undefined; });
this.pending = { revision, promise };
return promise;
};
dispose() { this.generation += 1; clearTimeout(this.timer); }
}
@@ -0,0 +1,47 @@
import { ModelCatalog } from './modelCatalog';
import { CredentialDraft } from './credentialDraft';
function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); }
function deferred() {
let resolve!: (value: { models: string[] }) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<{ models: string[] }>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
async function main() {
const catalog = new ModelCatalog();
let version = 0;
const current = () => version;
for (const failOld of [false, true]) {
const a = deferred(); const b = deferred();
const first = catalog.load(version, () => a.promise, current);
const second = catalog.load(version, () => b.promise, current);
b.resolve({ models: ['new'] }); await second;
if (failOld) a.reject(new Error('old authentication error')); else a.resolve({ models: ['old'] });
await first;
assert(catalog.snapshot()?.models.join() === 'new' && catalog.snapshot()?.status === 'success', 'old response or error must be ignored');
}
const editing = deferred();
const pending = catalog.load(version, () => editing.promise, current);
version += 1; // endpoint/key/protocol edit invalidates immediately, before saving.
editing.resolve({ models: ['stale-config'] }); await pending;
assert(catalog.snapshot()?.version !== version, 'old candidates must not belong to the edited configuration');
const unmounted = deferred();
const closed = catalog.load(version, () => unmounted.promise, current);
catalog.invalidate(); unmounted.reject(new Error('after close')); await closed;
assert(catalog.snapshot()?.status === 'loading', 'unmounted error must not publish');
const writes: string[] = [];
const draft = new CredentialDraft(async () => 'missing-from-catalog', async value => { writes.push(value); });
await draft.load();
const directory = deferred();
const fetching = catalog.load(version, () => directory.promise, current);
draft.edit('typed-during-request'); await draft.flush();
directory.resolve({ models: ['different-model'] }); await fetching;
await catalog.load(version, async () => ({ models: [] }), current);
await catalog.load(version, async () => { throw new Error('401'); }, current);
assert(draft.snapshot().value === 'typed-during-request' && writes.join() === 'typed-during-request', 'success, empty and failure cannot write or clear the model');
assert(!draft.snapshot().dirty, 'catalog failure cannot block a saved manual model');
draft.dispose();
}
void main();
@@ -0,0 +1,23 @@
type CatalogState = { version: number; models: string[]; status: 'loading' | 'success' | 'empty' | 'error'; error?: unknown };
/** 目录响应只改变候选;配置版本与请求序号共同决定响应是否有效。 */
export class ModelCatalog {
private state: CatalogState | null = null;
private request = 0;
private listeners = new Set<() => void>();
snapshot = () => this.state;
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
private update(state: CatalogState) { this.state = state; this.listeners.forEach(listener => listener()); }
invalidate() { this.request += 1; }
async load(version: number, read: () => Promise<{ models: string[] }>, currentVersion: () => number) {
const request = ++this.request;
const current = () => request === this.request && version === currentVersion();
this.update({ version, models: [], status: 'loading' });
try {
const result = await read();
if (current()) this.update({ version, models: result.models, status: result.models.length ? 'success' : 'empty' });
} catch (error) {
if (current()) this.update({ version, models: [], status: 'error', error });
}
}
}