feat(api-access): unify DeepSeek model discovery and selection

- require explicit model discovery before saving DeepSeek connections
- preserve selected models when refreshing the catalog
- mark model lists stale when connection details change
- add model refresh support to easy mode
- apply built-in DeepSeek reasoning levels consistently
This commit is contained in:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent c5f68157e5
commit 2a99db2908
10 files changed
+287 -68

No files matched your search

+6 -1
View File
@@ -26,6 +26,7 @@ export const jaOverrides = {
'easyMode.api.fetchingModels': 'モデル一覧を取得中…',
'easyMode.api.modelListTitle': 'モデルを選択',
'easyMode.api.modelListHint': '接続するモデルを選択してください',
'easyMode.api.modelListStale': '接続情報が変更されました。保存する前にモデル一覧を更新してください',
'easyMode.api.credentialsRequired': '先に API の URL と API キーを入力してください',
'easyMode.api.modelRequired': 'モデル一覧を取得し、モデルを選択してください',
'easyMode.api.testSuccess': '接続に成功しました。{count} 件のモデルが見つかりました',
@@ -1126,6 +1127,7 @@ export const jaOverrides = {
'apiAccess.error.requiredKey': 'API キーを入力してください',
'apiAccess.error.baseRequired': '{provider} 接続には Base URL が必要です',
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
'apiAccess.error.fetchModelsBeforeSave': 'モデル一覧を正常に取得し、少なくとも 1 件選択してください',
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください',
@@ -1186,6 +1188,9 @@ export const jaOverrides = {
'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません',
'apiAccess.error.baseBeforeModels': 'モデルを取得する前に Base URL を入力してください',
'apiAccess.models.selected': '{count} 件のモデルを選択済み',
'apiAccess.models.selectionRequired': 'モデルが選択されていません',
'apiAccess.models.selectionRequiredHint': 'モデル一覧を取得し、少なくとも 1 件選択してください',
'apiAccess.models.staleHint': '接続情報が変更されました。モデル一覧を再取得してください',
'apiAccess.models.restricted': 'モデル制限を設定済み',
'apiAccess.models.autoAll': '保存時にすべてのモデルを既定で公開',
'apiAccess.models.upstreamDefault': '上流の既定モデルを使用',
@@ -1201,7 +1206,7 @@ export const jaOverrides = {
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
'apiAccess.preset.title': 'OpenAI 互換プリセット',
'apiAccess.preset.description': 'DeepSeek 公式アドレスを入力済みです。保存時にすべてのモデルを自動検出して公開します',
'apiAccess.preset.description': 'DeepSeek 公式アドレスを入力済みです。モデル一覧を取得し、公開するモデルを選択してください',
'apiAccess.thinking.builtIn': '組み込み推論レベル',
'apiAccess.thinking.builtInDescription': '現在公開中のすべてのモデルに自動適用します',
'apiAccess.thinking.title': '推論レベル',
+6 -1
View File
@@ -244,6 +244,7 @@ export const en: Record<MessageKey, string> = {
'easyMode.api.fetchingModels': 'Fetching model list...',
'easyMode.api.modelListTitle': 'Select models',
'easyMode.api.modelListHint': 'Select the models to connect',
'easyMode.api.modelListStale': 'Connection details changed. Refresh the model list before saving.',
'easyMode.api.credentialsRequired': 'Enter an API URL and API key first',
'easyMode.api.modelRequired': 'Fetch and select a model',
'easyMode.api.testSuccess': 'Connection successful! Found {count} models',
@@ -1126,6 +1127,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.error.requiredKey': 'API key is required',
'apiAccess.error.baseRequired': 'A Base URL is required for {provider}',
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
'apiAccess.error.fetchModelsBeforeSave': 'Fetch the model list successfully and select at least one model',
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
'apiAccess.error.duplicate': 'An identical connection already exists',
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
@@ -1186,6 +1188,9 @@ export const en: Record<MessageKey, string> = {
'apiAccess.error.noAvailableModels': 'No available models found',
'apiAccess.error.baseBeforeModels': 'Enter a Base URL before fetching models',
'apiAccess.models.selected': '{count} models selected',
'apiAccess.models.selectionRequired': 'No models selected',
'apiAccess.models.selectionRequiredHint': 'Fetch the model list and select at least one model',
'apiAccess.models.staleHint': 'Connection details changed. Fetch the model list again.',
'apiAccess.models.restricted': 'Model restrictions configured',
'apiAccess.models.autoAll': 'All models will be opened by default when saved',
'apiAccess.models.upstreamDefault': 'Using upstream default models',
@@ -1201,7 +1206,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
'apiAccess.preset.description': 'The official DeepSeek address is prefilled; all discovered models are opened when saved',
'apiAccess.preset.description': 'The official DeepSeek address is prefilled; fetch the list and select the models to open',
'apiAccess.thinking.builtIn': 'Built-in Reasoning Levels',
'apiAccess.thinking.builtInDescription': 'Applied automatically to all currently open models',
'apiAccess.thinking.title': 'Reasoning Levels',
+6 -1
View File
@@ -243,6 +243,7 @@ export const zhCN = {
'easyMode.api.fetchingModels': '正在获取模型列表...',
'easyMode.api.modelListTitle': '选择模型',
'easyMode.api.modelListHint': '勾选需要接入的模型',
'easyMode.api.modelListStale': '连接信息已变化,请刷新模型列表后再保存',
'easyMode.api.credentialsRequired': '请先填写 API URL 和 API 密钥',
'easyMode.api.modelRequired': '请先获取模型列表并选择模型',
'easyMode.api.testSuccess': '连接成功!发现 {count} 个模型',
@@ -1125,6 +1126,7 @@ export const zhCN = {
'apiAccess.error.requiredKey': 'API 密钥不能为空',
'apiAccess.error.baseRequired': '{provider} 接入必须填写 Base URL',
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
'apiAccess.error.fetchModelsBeforeSave': '请先成功获取模型列表,并至少选择一个模型',
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
'apiAccess.error.duplicate': '相同的接入配置已经存在',
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
@@ -1185,6 +1187,9 @@ export const zhCN = {
'apiAccess.error.noAvailableModels': '未发现可用模型',
'apiAccess.error.baseBeforeModels': '请先填写 Base URL,再获取模型',
'apiAccess.models.selected': '已选择 {count} 个模型',
'apiAccess.models.selectionRequired': '尚未选择模型',
'apiAccess.models.selectionRequiredHint': '请先获取模型列表,并选择至少一个模型',
'apiAccess.models.staleHint': '连接信息已变化,请重新获取模型列表',
'apiAccess.models.restricted': '已配置模型限制',
'apiAccess.models.autoAll': '保存时默认开放全部模型',
'apiAccess.models.upstreamDefault': '使用上游默认模型',
@@ -1200,7 +1205,7 @@ export const zhCN = {
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
'apiAccess.preset.title': 'OpenAI 兼容预设',
'apiAccess.preset.description': '已预填 DeepSeek 官方地址,保存时自动发现并开放全部模型',
'apiAccess.preset.description': '已预填 DeepSeek 官方地址;请获取模型列表并选择需要开放的模型',
'apiAccess.thinking.builtIn': '内置思考等级',
'apiAccess.thinking.builtInDescription': '自动应用到当前开放的全部模型',
'apiAccess.thinking.title': '思考等级',
+71 -38
View File
@@ -1,3 +1,4 @@
import { createPortal } from 'react-dom';
import { useConfirmation } from '../components/ConfirmationDialog';
import {
type CSSProperties,
@@ -53,9 +54,14 @@ import {
responseList,
} from '../services/managementApi';
import {
applyDeepSeekModelPreset,
DEEPSEEK_BASE_URL,
DEEPSEEK_THINKING_LEVELS,
fetchModels,
mergeModelOptions,
modelsFromRecord,
normalizeBaseUrl,
reconcileModelSelection,
type ModelOption,
type ModelProvider,
} from '../services/modelService';
@@ -80,9 +86,8 @@ export type ProviderSection =
export type ProviderCategory = ProviderSection | 'deepseek';
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
export const OPENAI_THINKING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
export const DEEPSEEK_THINKING_LEVELS = ['low', 'high', 'max'] as const;
export { DEEPSEEK_BASE_URL, DEEPSEEK_THINKING_LEVELS };
type ProviderDefinition = {
id: ProviderCategory;
@@ -430,9 +435,6 @@ export const modelSelectionForDiscovery = (
);
};
export const allModelSelectionForDiscovery = (models: ModelOption[]) =>
new Set(models.map((model) => model.name.trim().toLowerCase()).filter(Boolean));
export const parseProviderApiKeys = (value: string) => value
.split(/\r?\n/)
.map((item) => item.trim())
@@ -556,10 +558,15 @@ export const applyProviderPreset = (
category: ProviderCategory,
draft: ProviderDraft,
): ProviderDraft => {
if (category === 'deepseek') {
return {
...draft,
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
models: applyDeepSeekModelPreset(draft.models),
};
}
if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft;
const levels = category === 'deepseek'
? [...DEEPSEEK_THINKING_LEVELS]
: draft.thinkingLevels;
const levels = draft.thinkingLevels;
return {
...draft,
models: draft.models.map((model) => {
@@ -805,7 +812,6 @@ export function ApiAccessPage() {
const [error, setError] = useState('');
const feedback = useAppNotice();
const { showNotice: setNotice } = feedback;
const [feedbackRow, setFeedbackRow] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
@@ -908,7 +914,6 @@ export function ApiAccessPage() {
const openCreate = () => {
feedback.clearNotice();
setFeedbackRow(null);
setError('');
setEditingRow(null);
setDialogDraft(createProviderDraft(activeCategory));
@@ -917,7 +922,6 @@ export function ApiAccessPage() {
const openEdit = (row: ProviderRow) => {
feedback.clearNotice();
setFeedbackRow(null);
setError('');
setEditingRow(row);
const draft = draftFromRow(row);
@@ -927,8 +931,10 @@ export function ApiAccessPage() {
setDialogOpen(true);
};
const saveProvider = async (nextDraft: ProviderDraft): Promise<ProviderSaveResult> => {
setFeedbackRow(null);
const saveProvider = async (
nextDraft: ProviderDraft,
modelDiscoveryReady: boolean,
): Promise<ProviderSaveResult> => {
const definition = activeDefinition;
const preparedDraft = applyProviderRemarkIdentity(
activeCategory,
@@ -956,6 +962,16 @@ export function ApiAccessPage() {
: t('apiAccess.error.requiredKey'),
};
}
if (
activeCategory === 'deepseek'
&& (!modelDiscoveryReady || preparedDraftForSave.models.length === 0)
) {
return {
saved: false,
target: 'models',
error: t('apiAccess.error.fetchModelsBeforeSave'),
};
}
if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') };
}
@@ -972,7 +988,11 @@ export function ApiAccessPage() {
setError('');
try {
let draftToSave = { ...preparedDraftForSave, baseUrl };
if (definition.openAi && draftToSave.models.length === 0) {
if (
definition.openAi
&& activeCategory !== 'deepseek'
&& draftToSave.models.length === 0
) {
let fetchedModels: ModelOption[];
try {
fetchedModels = await fetchModels(
@@ -1057,7 +1077,6 @@ export function ApiAccessPage() {
const deleteRow = async (row: ProviderRow) => {
if (!await askConfirmation({ title: t('common.delete'), message: t('apiAccess.deleteConfirm', { remark: row.remark || row.name }), confirmText: t('common.delete'), variant: 'danger' })) return;
setFeedbackRow(providerDragId(row));
feedback.clearNotice();
setBusy(true);
setError('');
@@ -1077,7 +1096,6 @@ export function ApiAccessPage() {
remark: '',
},
});
setFeedbackRow(null);
setNotice({ key: 'apiAccess.notice.deleted' });
await loadProviders();
} catch (requestError) {
@@ -1088,7 +1106,6 @@ export function ApiAccessPage() {
};
const toggleProvider = async (row: ProviderRow) => {
setFeedbackRow(providerDragId(row));
setBusy(true);
setError('');
setNotice('');
@@ -1131,7 +1148,6 @@ export function ApiAccessPage() {
const reorderProviders = async (source: ProviderRow, target: ProviderRow) => {
if (source.section !== target.section || source.index === target.index) return;
setFeedbackRow(providerDragId(source));
setBusy(true);
setError('');
setNotice('');
@@ -1197,7 +1213,6 @@ export function ApiAccessPage() {
</header>
{error ? <div className="management-alert error">{error}</div> : null}
<div className="provider-workbench real-provider-workbench">
<aside className="panel provider-category-panel">
{providerDefinitions.map((definition) => (
@@ -1208,7 +1223,6 @@ export function ApiAccessPage() {
onClick={() => {
setActiveCategory(definition.id);
feedback.clearNotice();
setFeedbackRow(null);
}}
disabled={busy}
>
@@ -1231,7 +1245,6 @@ export function ApiAccessPage() {
</div>
</div>
{feedbackRow === null ? <InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} /> : null}
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('apiAccess.loading')}</div>
) : rows.length === 0 ? (
@@ -1273,7 +1286,6 @@ export function ApiAccessPage() {
</code>
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
{row.models.length > 0 ? <span className="provider-row-models">{t('apiAccess.models.summary', { count: row.models.length })}</span> : null}
{feedbackRow === providerDragId(row) ? <InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} /> : null}
</div>
{row.priority === null ? null : (
<div className="provider-row-meta">
@@ -1335,6 +1347,17 @@ export function ApiAccessPage() {
onClose={() => setHealthDialogRow(null)}
/>
) : null}
{typeof document === 'undefined'
? null
: createPortal(
<InlineNotice
key={feedback.revision}
notice={feedback.notice}
onDismiss={feedback.clearNotice}
className="api-access-page-notice"
/>,
document.body,
)}
</section>
);
}
@@ -1563,7 +1586,7 @@ type ApiProviderDialogProps = {
initialDraft: ProviderDraft;
busy: boolean;
onClose: () => void;
onSave: (draft: ProviderDraft) => Promise<ProviderSaveResult>;
onSave: (draft: ProviderDraft, modelDiscoveryReady: boolean) => Promise<ProviderSaveResult>;
};
function ApiProviderDialog({
@@ -1583,21 +1606,22 @@ function ApiProviderDialog({
const [formError, setFormError] = useState('');
const [discoveredModels, setDiscoveredModels] = useState<ModelOption[]>([]);
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryReady, setModelDiscoveryReady] = useState(
() => activeCategory !== 'deepseek'
|| Boolean(editingRow && initialDraft.models.some((model) => model.name.trim())),
);
const [modelSearch, setModelSearch] = useState('');
const [thinkingLevelInput, setThinkingLevelInput] = useState('');
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
);
const modelCardRef = useRef<HTMLDivElement>(null);
const discoverySelectionInitializedRef = useRef(false);
const modelOptions = useMemo(() => {
const options = new Map<string, ModelOption>();
[...discoveredModels, ...draft.models].forEach((model) => {
const name = model.name.trim();
if (name) options.set(name.toLowerCase(), { ...model, name });
});
return Array.from(options.values());
}, [discoveredModels, draft.models]);
const modelOptions = useMemo(
() => mergeModelOptions(discoveredModels, draft.models),
[discoveredModels, draft.models],
);
const configuredModels = useMemo(
() => draft.models.filter((model) => model.name.trim()),
@@ -1622,6 +1646,7 @@ function ApiProviderDialog({
setFormError('');
if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') {
setModelError('');
if (activeCategory === 'deepseek') setModelDiscoveryReady(false);
}
setDraft((current) => ({ ...current, [field]: value }));
};
@@ -1707,12 +1732,12 @@ function ApiProviderDialog({
activeCategory,
{ ...draft, models: fetchedModels },
).models;
const selectionMode = discoverySelectionInitializedRef.current ? 'refresh' : 'initial';
setDiscoveredModels(models);
setSelectedModelNames(new Set(
[...models, ...draft.models]
.map((model) => model.name.trim().toLowerCase())
.filter(Boolean),
));
setSelectedModelNames((current) =>
reconcileModelSelection(models, draft.models, current, selectionMode));
discoverySelectionInitializedRef.current = true;
if (activeCategory === 'deepseek') setModelDiscoveryReady(true);
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
} catch (requestError) {
setModelError(requestErrorMessage(requestError));
@@ -1729,6 +1754,7 @@ function ApiProviderDialog({
return;
}
setModelSearch('');
discoverySelectionInitializedRef.current = false;
setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase())));
setModelDiscoveryOpen(true);
void discoverModels();
@@ -1777,7 +1803,7 @@ function ApiProviderDialog({
const submit = async (event: FormEvent) => {
event.preventDefault();
setFormError('');
const result = await onSave(draft);
const result = await onSave(draft, modelDiscoveryReady);
if (result.saved) {
onClose();
return;
@@ -1794,15 +1820,22 @@ function ApiProviderDialog({
const hasModelExclusions = activeSection !== 'openai-compatibility'
&& Boolean(draft.excludedModelsText?.trim());
const deepSeekModelsStale = activeCategory === 'deepseek' && !modelDiscoveryReady;
const modelSummaryTitle = configuredModels.length > 0
? t('apiAccess.models.selected', { count: configuredModels.length })
: activeCategory === 'deepseek'
? t('apiAccess.models.selectionRequired')
: hasModelExclusions
? t('apiAccess.models.restricted')
: activeSection === 'openai-compatibility'
? t('apiAccess.models.autoAll')
: t('apiAccess.models.upstreamDefault');
const modelSummaryDetail = configuredModels.length > 0
? configuredModels.slice(0, 3).map((model) => model.name).join('、')
? deepSeekModelsStale
? t('apiAccess.models.staleHint')
: configuredModels.slice(0, 3).map((model) => model.name).join('、')
: activeCategory === 'deepseek'
? t('apiAccess.models.selectionRequiredHint')
: hasModelExclusions
? t('apiAccess.models.hiddenHint')
: activeSection === 'openai-compatibility'
+62 -11
View File
@@ -17,6 +17,7 @@ import {
LoaderCircle,
Monitor,
Moon,
RefreshCw,
Sun,
X,
} from "lucide-react";
@@ -30,8 +31,12 @@ import {
responseList,
} from "../services/managementApi";
import {
applyDeepSeekModelPreset,
DEEPSEEK_BASE_URL,
fetchModels,
mergeModelOptions,
normalizeBaseUrl,
reconcileModelSelection,
type ModelOption,
type ModelProvider,
} from "../services/modelService";
@@ -84,7 +89,7 @@ const apiSectionOptions: ApiSectionOption[] = [
{ id: "claude", managementSection: "claude-api-key", nameKey: "easyMode.api.platformName.claude", provider: "claude", defaultBaseUrl: "", icon: claudeIcon },
{ id: "codex", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.codex", provider: "codex", defaultBaseUrl: "", icon: codexIcon },
{ id: "gemini", managementSection: "gemini-api-key", nameKey: "easyMode.api.platformName.gemini", provider: "gemini", defaultBaseUrl: "", icon: geminiIcon },
{ id: "deepseek", managementSection: "openai-compatibility", nameKey: "easyMode.api.platformName.deepseek", provider: "openai", defaultBaseUrl: "https://api.deepseek.com", icon: deepseekIcon },
{ id: "deepseek", managementSection: "openai-compatibility", nameKey: "easyMode.api.platformName.deepseek", provider: "openai", defaultBaseUrl: DEEPSEEK_BASE_URL, icon: deepseekIcon },
];
const isDeepSeekRecord = (record: Record<string, unknown>) => {
@@ -135,6 +140,7 @@ export function EasyModePage({
const [apiTesting, setApiTesting] = useState(false);
const [apiTestedModels, setApiTestedModels] = useState<ModelOption[]>([]);
const [apiSelectedModels, setApiSelectedModels] = useState<ModelOption[]>([]);
const [apiModelsReady, setApiModelsReady] = useState(false);
const [apiErrorMessage, setApiTestError] = useState<NoticeMessage>("");
const apiTestError = typeof apiErrorMessage === "string"
? apiErrorMessage
@@ -321,6 +327,7 @@ export function EasyModePage({
if (opt) setApiBaseUrl(opt.defaultBaseUrl);
setApiTestedModels([]);
setApiSelectedModels([]);
setApiModelsReady(false);
setApiTestError("");
clearApiNotice();
setGuideApiSaved(false);
@@ -338,10 +345,7 @@ export function EasyModePage({
}
setApiTesting(true);
setApiTestError("");
setApiTestedModels([]);
setApiSelectedModels([]);
setGuideApiSaved(false);
setGuideApiModelsFetched(false);
const opt = apiSectionOptions.find((o) => o.id === selectedApiSection);
const providerType = opt ? opt.provider : "openai";
@@ -356,8 +360,18 @@ export function EasyModePage({
10000,
);
if (models.length > 0) {
setApiTestedModels(models);
setApiSelectedModels(models);
const mergedModels = mergeModelOptions(models);
const selectedNames = reconcileModelSelection(
mergedModels,
[],
apiSelectedModels.map((model) => model.name),
apiModelsReady ? "refresh" : "initial",
);
setApiTestedModels(mergedModels);
setApiSelectedModels(
mergedModels.filter((model) => selectedNames.has(model.name.trim().toLowerCase())),
);
setApiModelsReady(true);
setGuideApiModelsFetched(true);
} else {
setApiTestError({ key: "easyMode.api.noModelsFound" });
@@ -390,6 +404,10 @@ export function EasyModePage({
setApiTestError({ key: "easyMode.api.apiKeyRequired" });
return;
}
if (selectedApiSection === "deepseek" && !apiModelsReady) {
setApiTestError({ key: "easyMode.api.fetchListFirst" });
return;
}
if (apiSelectedModels.length === 0) {
setApiTestError({ key: "easyMode.api.modelRequired" });
return;
@@ -408,7 +426,10 @@ export function EasyModePage({
const managementSection = selectedOption?.managementSection ?? "openai-compatibility";
const configPayload = await managementApi.get("/config");
const list = responseList(configPayload, managementSection);
const models = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
const selectedModels = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
const models = selectedApiSection === "deepseek"
? applyDeepSeekModelPreset(selectedModels)
: selectedModels;
const newEntry = managementSection === "openai-compatibility"
? {
name: apiRemark.trim() || `${selectedApiSection} (${list.length + 1})`,
@@ -923,7 +944,12 @@ export function EasyModePage({
type="text"
className="text-input"
value={apiBaseUrl}
onChange={(e) => { setApiBaseUrl(e.target.value); setGuideApiSaved(false); }}
onChange={(e) => {
setApiBaseUrl(e.target.value);
setApiModelsReady(false);
setGuideApiModelsFetched(false);
setGuideApiSaved(false);
}}
placeholder="https://..."
/>
</div>
@@ -933,7 +959,12 @@ export function EasyModePage({
type="password"
className="text-input"
value={apiKey}
onChange={(e) => { setApiKey(e.target.value); setGuideApiSaved(false); }}
onChange={(e) => {
setApiKey(e.target.value);
setApiModelsReady(false);
setGuideApiModelsFetched(false);
setGuideApiSaved(false);
}}
placeholder="sk-..."
/>
</div>
@@ -963,11 +994,24 @@ export function EasyModePage({
<>
<div className="simple-mode-api-model-heading">
<strong>{t("easyMode.api.modelListTitle")}</strong>
<button
type="button"
className="secondary-button compact-button"
disabled={apiTesting || !apiBaseUrl.trim() || !apiKey.trim()}
onClick={() => void handleTestApi()}
>
<RefreshCw size={14} className={apiTesting ? "spin" : ""} />
{t("common.refresh")}
</button>
</div>
<div className="simple-mode-api-model-selection">
<div className="simple-mode-api-model-selection-heading">
<span>{t("easyMode.api.modelListHint")}</span>
<span>
{selectedApiSection === "deepseek" && !apiModelsReady
? t("easyMode.api.modelListStale")
: t("easyMode.api.modelListHint")}
</span>
</div>
<div className="simple-mode-api-model-options">
{apiTestedModels.map((model) => {
@@ -983,6 +1027,7 @@ export function EasyModePage({
<input
type="checkbox"
checked={selected}
disabled={apiTesting}
onChange={() => handleToggleApiModel(model)}
/>
<span title={model.name}>{model.name}</span>
@@ -996,7 +1041,13 @@ export function EasyModePage({
<button
type="button"
className="primary-button"
disabled={apiSaving || !apiBaseUrl.trim() || !apiKey.trim() || apiSelectedModels.length === 0}
disabled={
apiSaving
|| !apiBaseUrl.trim()
|| !apiKey.trim()
|| apiSelectedModels.length === 0
|| (selectedApiSection === "deepseek" && !apiModelsReady)
}
onClick={() => void handleSaveApi()}
>
{t("easyMode.api.saveAndConnect")}
+50
View File
@@ -13,8 +13,58 @@ export type ModelOption = {
};
export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai';
export type ModelSelectionMode = 'initial' | 'refresh';
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com';
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
export const DEEPSEEK_THINKING_LEVELS = ['low', 'high', 'max'] as const;
const modelKey = (name: string) => name.trim().toLowerCase();
export function mergeModelOptions(...groups: ModelOption[][]): ModelOption[] {
const merged = new Map<string, ModelOption>();
groups.flat().forEach((model) => {
const name = model.name.trim();
if (!name) return;
merged.set(modelKey(name), { ...model, name });
});
return Array.from(merged.values());
}
export function reconcileModelSelection(
discoveredModels: ModelOption[],
configuredModels: ModelOption[],
selectedModelNames: Iterable<string>,
mode: ModelSelectionMode,
): Set<string> {
const availableNames = new Set(
mergeModelOptions(discoveredModels, configuredModels).map((model) => modelKey(model.name)),
);
const configuredNames = new Set(
configuredModels.map((model) => modelKey(model.name)).filter(Boolean),
);
const previousSelection = new Set(
Array.from(selectedModelNames, modelKey).filter(Boolean),
);
const requestedSelection = mode === 'refresh'
? previousSelection
: configuredNames.size > 0
? configuredNames
: new Set(discoveredModels.map((model) => modelKey(model.name)).filter(Boolean));
return new Set(Array.from(requestedSelection).filter((name) => availableNames.has(name)));
}
export function applyDeepSeekModelPreset(models: ModelOption[]): ModelOption[] {
return models.map((model) => ({
...model,
thinking: {
...model.thinking,
levels: [...DEEPSEEK_THINKING_LEVELS],
},
}));
}
export function normalizeBaseUrl(value: string): string {
let raw = value.trim();
+10
View File
@@ -4284,6 +4284,16 @@ textarea:focus-visible {
color: var(--theme-2f6b3f);
}
.action-feedback.inline-notice.api-access-page-notice {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 80;
width: min(360px, calc(100vw - 32px));
margin: 0;
box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18);
}
.real-provider-workbench {
grid-template-columns: 240px minmax(0, 1fr);
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'bun:test';
import {
applyDeepSeekModelPreset,
DEEPSEEK_THINKING_LEVELS,
mergeModelOptions,
reconcileModelSelection,
} from '../src/services/modelService';
describe('model discovery selection', () => {
const discovered = [
{ name: 'deepseek-chat' },
{ name: 'deepseek-reasoner' },
{ name: 'deepseek-new-model' },
];
it('selects every discovered model on the first fetch for a new connection', () => {
expect(Array.from(reconcileModelSelection(discovered, [], [], 'initial'))).toEqual([
'deepseek-chat',
'deepseek-reasoner',
'deepseek-new-model',
]);
});
it('keeps saved selections and leaves newly discovered models unchecked', () => {
expect(Array.from(reconcileModelSelection(
discovered,
[{ name: 'deepseek-chat' }],
['deepseek-chat'],
'initial',
))).toEqual(['deepseek-chat']);
});
it('preserves refresh selections, drops unavailable discoveries, and keeps configured models', () => {
expect(Array.from(reconcileModelSelection(
[{ name: 'deepseek-reasoner' }, { name: 'deepseek-new-model' }],
[{ name: 'custom-model' }],
['DEEPSEEK-REASONER', 'removed-model', 'custom-model'],
'refresh',
))).toEqual(['deepseek-reasoner', 'custom-model']);
});
it('deduplicates model names case-insensitively and keeps configured metadata', () => {
expect(mergeModelOptions(
[{ name: 'deepseek-chat' }],
[{ name: ' DEEPSEEK-CHAT ', alias: 'Chat' }],
)).toEqual([{ name: 'DEEPSEEK-CHAT', alias: 'Chat' }]);
});
});
describe('DeepSeek model preset', () => {
it('adds built-in thinking levels while preserving existing metadata', () => {
expect(applyDeepSeekModelPreset([{
name: 'deepseek-reasoner',
thinking: { custom: true, levels: ['legacy'] },
}])).toEqual([{
name: 'deepseek-reasoner',
thinking: { custom: true, levels: [...DEEPSEEK_THINKING_LEVELS] },
}]);
});
});
+15
View File
@@ -37,6 +37,21 @@ describe('控件自身反馈', () => {
expect(nonEmptyCalls[0].arguments[1].getText()).toBe("'error'");
});
it('API 接入提示固定在右下角,不插入页面布局', () => {
const source = readFileSync(new URL('../src/pages/ApiAccessPage.tsx', import.meta.url), 'utf8');
expect(source).toContain("import { createPortal } from 'react-dom';");
expect(source).toContain('className="api-access-page-notice"');
expect(source).toContain('createPortal(');
expect(source).toContain('document.body');
expect(source).not.toContain('feedbackRow');
expect(source.indexOf('createPortal(')).toBeGreaterThan(source.indexOf('provider-workbench real-provider-workbench'));
expect(source.slice(source.indexOf('provider-workbench real-provider-workbench'), source.lastIndexOf('createPortal('))).not.toContain('<InlineNotice');
const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?position:\s*fixed;/);
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?right:\s*24px;/);
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?bottom:\s*24px;/);
});
it('凭证文件的启用停用由按钮与状态标签反馈', () => {
expect(actionCalls('AuthFileManagementPage.tsx', 'toggleStatus', 'showNotice').calls).toHaveLength(0);
});
+1 -16
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'bun:test';
import {
allModelSelectionForDiscovery,
applyProviderRemarkIdentity,
applyProviderPreset,
buildProviderRecord,
@@ -51,7 +50,7 @@ describe('API 接入配置合并', () => {
]);
});
it('DeepSeek 新增预设默认发现全部模型并应用内置思考等级', () => {
it('DeepSeek 对选定模型应用内置思考等级', () => {
expect(DEEPSEEK_THINKING_LEVELS).toEqual(['low', 'high', 'max']);
const draft = createProviderDraft('deepseek');
const discovered = [
@@ -387,18 +386,4 @@ describe('API 接入配置合并', () => {
expect(Array.from(selected)).toEqual(['model-a', 'model-b']);
});
it('模型选择窗口每次打开都以接口返回的全部模型作为默认选择', () => {
const selected = allModelSelectionForDiscovery([
{ name: 'deepseek-chat' },
{ name: 'deepseek-reasoner' },
{ name: 'deepseek-new-model' },
]);
expect(Array.from(selected)).toEqual([
'deepseek-chat',
'deepseek-reasoner',
'deepseek-new-model',
]);
});
});