From 6e35bf422580e213458b217abe1a7f87f1be68da Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 02:27:41 +0800 Subject: [PATCH 1/7] feat(desktop): redesign the models settings pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 设置 → 模型 surface had four levels that each spoke a different dialect: a list with a bare count header, a catalog of 59 flat rows behind a category picker, an add form that sprang a second step on the user after they thought they were done, and a detail page laid out on a bespoke two-column grid with the enabled models hidden inside a MultiSelector and every capability declaration expanded into a wall of controls. Rebuild all four on the settings kit the other pages already use, following Astryx's own settings and settings-sidebar templates: - List: a SettingsSection with the add action in its header; an empty list shows the recommended providers as rows so a first run is one click from a provider's form. - Catalog: one search field over everything, and the providers below it as labeled groups (推荐 / 模型计划 / API / 聚合 / 本地). The category picker is gone because scrolling past a heading is the filter; typing collapses the groups into one flat result list. - Add (managed API-key route): a Stepper names the two steps up front (密钥 → 选择模型). The model step is a CheckboxList with 全选 / 清空, a filter past eight models, and a 默认模型 selector limited to the ticked models; the chosen default is placed first in the saved list, which is where the Host reads it from. - Detail: SettingsSection rows throughout. 连接 holds the name / key / endpoint rows plus a 连接状态 row (StatusDot + last test + 测试连接). 模型 lists every chat-capable model as a row with an enable Switch and fact tokens (context, 视觉, 思考); models Maka cannot describe carry a 声明能力 expandable editor on their own row, and the bulk thinking menu lives in the section's action cluster and saves on tick. 高级请求设置 and 删除连接 follow as sections. The hook gains `resetDraftProfile` (per-row Cancel) and lets `saveRelayProfiles` take an explicit table so a bulk tick applies and saves in one gesture. `SettingsExpandableRow` accepts a `beforeAction` node so a row can carry its Switch beside the built-in trigger, and a ReactNode label so the default badge survives on a declaring row. The MultiSelector-based EnabledModelManager, the DetailSection grid, and the copy those relied on are removed. Generated-by: Claude Code --- .../settings-provider-copy.ts | 55 +- .../renderer/settings/provider-add-form.tsx | 208 ++- .../settings/provider-catalog-page.tsx | 313 ++-- .../settings/provider-connection-detail.tsx | 1276 +++++++++-------- .../provider-enabled-model-manager.tsx | 93 -- .../src/renderer/settings/providers-panel.tsx | 203 +-- .../settings/settings-expandable-row.tsx | 29 +- .../settings/use-connection-detail.ts | 33 +- .../src/renderer/styles/settings/models.css | 10 - .../settings/provider-settings.stories.tsx | 13 +- 10 files changed, 1193 insertions(+), 1040 deletions(-) delete mode 100644 apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index e1aa4bfd62..b3264e30c3 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -35,8 +35,6 @@ const zhCapabilitiesCopy = { thinkingUndeclared: '未声明', thinkingSelectedCount: (count: number) => `已选择 ${count} 个`, thinkingBulk: '批量设置思考档位', - thinkingBulkHelp: '勾选写入下方全部已启用模型,取消勾选则从全部模型移除;其余档位不受影响。', - thinkingBulkTrigger: '应用到全部模型', thinkingBulkCoverage: (declared: number, total: number) => declared === 0 ? '全部未声明' : `${declared}/${total} 个模型`, visionInput: '视觉输入(vision)', @@ -48,7 +46,6 @@ const zhCapabilitiesCopy = { contextWindowHelp: '设置后作为 Maka 压缩的触发阈值。留空则不主动压缩,由供应商决定何时超限。', contextWindowHint: (tokens: number) => `该模型声明的窗口为 ${tokens} tokens`, contextWindowApplyHint: '填入', - saveCapabilities: '保存能力声明', fastMode: 'Fast 模式', fastModeHelp: '使用 OpenAI 的 fast service tier;留空跟随服务商默认值。', fastAuto: '自动', @@ -61,9 +58,6 @@ const enCapabilitiesCopy = { thinkingUndeclared: 'Undeclared', thinkingSelectedCount: (count: number) => `${count} selected`, thinkingBulk: 'Set thinking levels for all models', - thinkingBulkHelp: - 'Ticking adds the level to every enabled model below; unticking removes it from all of them. Other levels are left alone.', - thinkingBulkTrigger: 'Apply to all models', thinkingBulkCoverage: (declared: number, total: number) => declared === 0 ? 'On no model' : `On ${declared} of ${total} models`, visionInput: 'Vision input', @@ -75,7 +69,6 @@ const enCapabilitiesCopy = { contextWindowHelp: 'When set, Maka compacts once the previous request\'s real usage exceeds it. Leave empty to never compact proactively; the provider decides.', contextWindowHint: (tokens: number) => `This model declares a ${tokens}-token window`, contextWindowApplyHint: 'Use it', - saveCapabilities: 'Save capability declarations', fastMode: 'Fast mode', fastModeHelp: "Use OpenAI's fast service tier; empty follows the provider default.", fastAuto: 'Auto', @@ -133,8 +126,13 @@ const zhCopy = { login: '登录', loggingIn: '登录中…', relogin: '重新登录', oauthReloginDetail: '若请求提示需要重新登录,点这里重新走一遍授权。', deviceCode: '登录码:', oauthStartDetail: '点下方按钮打开浏览器完成登录,授权成功后会自动刷新这里的状态。', - enabledModels: '启用的模型', - searchModels: '搜索模型', selectAllModels: '全部启用', + status: '连接状态', statusHealthy: '正常', statusUntested: '尚未测试', + modelsSummary: (enabled: number, total: number) => `已启用 ${enabled} / ${total}`, + filterModels: '筛选模型', noModelsMatch: '没有匹配的模型', + enableModelAria: (name: string) => `启用模型 ${name}`, + declareCapabilities: '声明能力', declareCapabilitiesAria: (name: string) => `声明能力:${name}`, + modelUndescribed: 'Maka 不认识这个模型,请声明它的能力。', + visionToken: '视觉', thinkingToken: '思考', contextToken: (value: string) => `${value} 上下文`, noModels: '暂无可选模型,请先更新模型目录。', keySet: '已设置', statusLoading: '正在读取状态', credentialUnknown: '凭据状态未知', keyMissing: '尚未设置密钥', keyTroubleshooting: '模型密钥 / 服务地址 / 代理设置', endpointTroubleshooting: '本地服务 / 服务地址 / 代理设置', oauthTroubleshooting: 'OAuth 登录 / 代理设置', @@ -176,17 +174,19 @@ const zhCopy = { }, }, panel: { - tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, + groups: { recommended: '推荐', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接', + connectionsHelp: '这些连接和它们启用的模型会出现在任务的模型选择器里。', retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原连接已被删除或移除,已返回模型连接列表。', connectedLoading: '连接已添加,正在载入详情…', connectedLoadFailed: '连接已添加,但暂时无法刷新连接列表。', connectionIdentityChanged: '新连接的身份与登录结果不一致,请返回连接列表后重试。', - emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。', - categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索', + emptyHelp: '先连接一个服务商。常用的几个就在下方,也可以浏览全部。', recommended: '推荐的服务商', browseAll: '查看全部服务商', + default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。', + searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索', createSubtitle: '完成必要配置后,连接会出现在模型页上方。', connection: '模型连接', - count: (value: number) => `· ${value}`, connectTitle: (name: string) => `连接 ${name}`, + count: (value: number) => `· ${value}`, modelCount: (value: number) => `${value} 个模型`, connectTitle: (name: string) => `连接 ${name}`, chipAria: (name: string, provider: string, isDefault: boolean, status?: string) => `模型连接:${name},供应商:${provider}${isDefault ? ',默认连接' : ''}${status ? `,${status}` : ''}`, - addConnection: '添加连接', category: '分类', backToList: '返回模型连接', backToCatalog: '返回服务商列表', + addConnection: '添加连接', backToList: '返回模型连接', backToCatalog: '返回服务商列表', }, catalog: { unavailable: '未开放', @@ -205,8 +205,11 @@ const zhCopy = { saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址', defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。', + stepsAria: '添加连接的步骤', stepCredentials: '密钥', stepModels: '选择模型', onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…', onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改', + onboardingSelectedCount: (selected: number, total: number) => `已选 ${selected} / ${total}`, onboardingSelectAll: '全选', onboardingClearAll: '清空', onboardingNoModelsMatch: '没有匹配的模型', + onboardingDefaultModel: '默认模型', onboardingDefaultModelHelp: '新任务默认使用这个模型;只能从已勾选的模型里选。', onboardingAuthFailed: '密钥验证失败,请检查后重试。', onboardingTimeout: '验证超时,请检查网络或代理后重试。', onboardingNetwork: '无法连接模型服务,请检查网络、代理或服务地址。', onboardingUnavailable: '模型连接服务暂时不可用,请稍后重试。', onboardingInvalidResponse: '模型服务返回了无法识别的结果,请稍后重试。', onboardingCatalogFull: '连接数量已达上限,请先删除不再使用的连接。', onboardingModelsChanged: '可用模型已发生变化,请重新验证后选择。', onboardingNoModels: '没有发现可用模型,当前未创建连接。', onboardingSelectModel: '请至少启用一个模型。', onboardingOutcomeUnknown: '保存结果暂时无法确认', onboardingOutcomeUnknownDetail: '请勿再次添加,以免创建重复连接。重新加载连接列表并检查该连接是否已经出现;仍不确定时请先重连 Runtime Host。', onboardingReloadConnections: '重新加载连接列表', onboardingRestart: '仍要添加另一个连接', ...zhCapabilitiesCopy, @@ -293,8 +296,13 @@ const enCopy: ProviderSettingsCopy = { login: 'Sign in', loggingIn: 'Signing in…', relogin: 'Sign in again', oauthReloginDetail: 'If a request asks you to sign in again, restart authorization here.', deviceCode: 'Sign-in code:', oauthStartDetail: 'Open the browser below to sign in. This status refreshes automatically after authorization.', - enabledModels: 'Enabled models', - searchModels: 'Search models', selectAllModels: 'Enable all', + status: 'Connection status', statusHealthy: 'Healthy', statusUntested: 'Not tested yet', + modelsSummary: (enabled: number, total: number) => `${enabled} of ${total} enabled`, + filterModels: 'Filter models', noModelsMatch: 'No matching models', + enableModelAria: (name: string) => `Enable model ${name}`, + declareCapabilities: 'Declare capabilities', declareCapabilitiesAria: (name: string) => `Declare capabilities: ${name}`, + modelUndescribed: 'Maka does not know this model. Declare its capabilities.', + visionToken: 'Vision', thinkingToken: 'Thinking', contextToken: (value: string) => `${value} context`, noModels: 'No models are available. Update the model catalog first.', keySet: 'Set', statusLoading: 'Reading status', credentialUnknown: 'Credential status unavailable', keyMissing: 'No key set', keyTroubleshooting: 'model key, service URL, and proxy settings', endpointTroubleshooting: 'local service, service URL, and proxy settings', oauthTroubleshooting: 'OAuth sign-in and proxy settings', @@ -336,17 +344,19 @@ const enCopy: ProviderSettingsCopy = { }, }, panel: { - tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, + groups: { recommended: 'Recommended', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections', + connectionsHelp: 'These connections and their enabled models appear in the chat model picker.', retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original connection was deleted or removed. Returned to the connection list.', connectedLoading: 'Connection added. Loading its details…', connectedLoadFailed: 'Connection added, but the connection list could not be refreshed yet.', connectionIdentityChanged: 'The new connection identity did not match the sign-in result. Return to the connection list and try again.', - emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.', - categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search', + emptyHelp: 'Connect a provider first. The common ones are below, or browse them all.', recommended: 'Recommended providers', browseAll: 'Browse all providers', + default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.', + searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search', createSubtitle: 'After required setup, the connection appears above on the Models page.', connection: 'Model connection', - count: (value: number) => `· ${value}`, connectTitle: (name: string) => `Connect ${name}`, + count: (value: number) => `· ${value}`, modelCount: (value: number) => `${value} ${value === 1 ? 'model' : 'models'}`, connectTitle: (name: string) => `Connect ${name}`, chipAria: (name: string, provider: string, isDefault: boolean, status?: string) => `Model connection: ${name}; provider: ${provider}${isDefault ? '; default connection' : ''}${status ? `; ${status}` : ''}`, - addConnection: 'Add connection', category: 'Category', backToList: 'Back to model connections', backToCatalog: 'Back to the provider list', + addConnection: 'Add connection', backToList: 'Back to model connections', backToCatalog: 'Back to the provider list', }, catalog: { unavailable: 'Unavailable', @@ -365,8 +375,11 @@ const enCopy: ProviderSettingsCopy = { saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`, apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL', defaultModel: 'Default model', defaultModelPlaceholder: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.', + stepsAria: 'Steps to add the connection', stepCredentials: 'Key', stepModels: 'Choose models', onboardingVerifyAndChoose: 'Verify and choose models', onboardingVerifying: 'Verifying the key and loading models…', onboardingChooseModels: 'Choose models for this connection', onboardingChooseModelsHelp: 'You can enable more models from the connection details later.', onboardingEnabledModels: 'Enabled models', onboardingSearchModels: 'Search models', onboardingAddConnection: 'Add connection', onboardingBack: 'Back to edit', + onboardingSelectedCount: (selected: number, total: number) => `${selected} of ${total} selected`, onboardingSelectAll: 'Select all', onboardingClearAll: 'Clear', onboardingNoModelsMatch: 'No matching models', + onboardingDefaultModel: 'Default model', onboardingDefaultModelHelp: 'New chats start on this model. Only selected models can be chosen.', onboardingAuthFailed: 'The key could not be verified. Check it and try again.', onboardingTimeout: 'Verification timed out. Check the network or proxy and try again.', onboardingNetwork: 'Could not reach the model provider. Check the network, proxy, or service URL.', onboardingUnavailable: 'The model connection service is unavailable. Try again later.', onboardingInvalidResponse: 'The model provider returned an unrecognized response. Try again later.', onboardingCatalogFull: 'The connection limit has been reached. Remove an unused connection first.', onboardingModelsChanged: 'The available models changed. Verify again and make a new selection.', onboardingNoModels: 'No usable models were found. No connection was created.', onboardingSelectModel: 'Enable at least one model.', onboardingOutcomeUnknown: 'The save result cannot be confirmed', onboardingOutcomeUnknownDetail: 'Do not add it again yet, because that could create a duplicate connection. Reload the connection list and check whether this connection appeared; reconnect the Runtime Host if the result is still unclear.', onboardingReloadConnections: 'Reload connection list', onboardingRestart: 'Add another connection anyway', ...enCapabilitiesCopy, diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 78b0ecc6de..d9d39e2367 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -24,11 +24,23 @@ import { providerAuthRequiresSecret, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; -import { Banner, HStack, MultiSelector, Text, VStack } from '@astryxdesign/core'; +import { + Banner, + CheckboxList, + CheckboxListItem, + EmptyState, + HStack, + Step, + Stepper, + Text, + VStack, +} from '@astryxdesign/core'; import { Collapsible } from '@astryxdesign/core/Collapsible'; +import { Search } from '@maka/ui/icons'; import { Button, FormLayout, + Selector, TextInput, useMountedRef, useUiLocale, @@ -39,7 +51,6 @@ import { PasswordInput } from './password-input'; import { providerDisplay } from './provider-display'; import { useActionGuard } from './use-action-guard'; import { - categoryLabel, getProviderSettingsCopy, providerPanelActionErrorMessage, type ApiKeyOnboardingBridge, @@ -79,8 +90,13 @@ type ManagedOnboardingPhase = readonly kind: 'models'; readonly models: ReturnType; readonly selectedIds: readonly string[]; + /** The model new chats start on. Always one of `selectedIds`. */ + readonly defaultId: string; }; +/** Past this many models the picker needs a filter to be usable. */ +const MODEL_FILTER_THRESHOLD = 8; + export function AddProviderForm(props: { bridge: ConnectionsBridge; apiKeyOnboardingBridge?: ApiKeyOnboardingBridge; @@ -108,6 +124,7 @@ export function AddProviderForm(props: { const [requestHeaders, setRequestHeaders] = useState([]); const [requestBodyText, setRequestBodyText] = useState(''); const [advancedOpen, setAdvancedOpen] = useState(false); + const [modelFilter, setModelFilter] = useState(''); const [formState, setFormState] = useState<{ readonly managedPhase: ManagedOnboardingPhase; readonly error: ProviderFormError | null; @@ -146,6 +163,7 @@ export function AddProviderForm(props: { function resetManagedVerification(options?: { clearKey?: boolean }) { setManagedPhase({ kind: 'input' }); + setModelFilter(''); if (options?.clearKey) setApiKey(''); } @@ -223,7 +241,14 @@ export function AddProviderForm(props: { setError({ field: 'form', message: copy.onboardingNoModels }); return; } - setManagedPhase({ kind: 'models', models, selectedIds }); + setManagedPhase({ + kind: 'models', + models, + selectedIds, + defaultId: selectedIds.includes(recommendedDefaultModel) + ? recommendedDefaultModel + : selectedIds[0]!, + }); } catch (err) { if (addProviderMountedRef.current) { setError({ field: 'form', message: providerPanelActionErrorMessage(err, locale) }); @@ -243,14 +268,13 @@ export function AddProviderForm(props: { setError({ field: 'form', message: copy.onboardingSelectModel }); return; } + // Catalog order, with the chosen default first: the Host reads the head of + // this list as the connection's default model. const selected = new Set(phase.selectedIds); const stableIds = phase.models .map((model) => model.id) - .filter((modelId) => selected.has(modelId)); - if (selected.has(recommendedDefaultModel)) { - stableIds.splice(stableIds.indexOf(recommendedDefaultModel), 1); - stableIds.unshift(recommendedDefaultModel); - } + .filter((modelId) => selected.has(modelId) && modelId !== phase.defaultId); + if (selected.has(phase.defaultId)) stableIds.unshift(phase.defaultId); submitGuard.begin('submit'); setBusy(true); try { @@ -449,30 +473,117 @@ export function AddProviderForm(props: { ); } + // The managed route is two steps — the key, then the models it unlocked — + // and the page says so up front rather than springing a second form on a + // user who thought they were done. A single-step route shows no stepper: + // one step is not progress. + const managedStepper = quickUsesManagedOnboarding ? ( + + + + + ) : null; + if (usesApiKeyDialog && managedPhase.kind === 'models') { - const options = managedPhase.models.map((model) => ({ - value: model.id, - label: model.displayName?.trim() || model.id, - })); + const normalizedFilter = modelFilter.trim().toLocaleLowerCase(); + const showsFilter = managedPhase.models.length > MODEL_FILTER_THRESHOLD; + const visibleModels = managedPhase.models.filter((model) => + !normalizedFilter || + [model.id, model.displayName ?? ''] + .some((value) => value.toLocaleLowerCase().includes(normalizedFilter))); + const modelLabel = (model: (typeof managedPhase.models)[number]) => + model.displayName?.trim() || model.id; + const selectModels = (selectedIds: readonly string[]) => { + // The default follows the selection: unticking it hands the role to the + // first model still ticked, so the head of the saved list is never a + // model the user just removed. + const defaultId = selectedIds.includes(managedPhase.defaultId) + ? managedPhase.defaultId + : selectedIds[0] ?? ''; + setManagedPhase({ ...managedPhase, selectedIds, defaultId }); + clearFieldError('form'); + }; + const selectedOptions = managedPhase.models + .filter((model) => managedPhase.selectedIds.includes(model.id)) + .map((model) => ({ value: model.id, label: modelLabel(model) })); return ( - + + {managedStepper} {copy.onboardingChooseModels} {copy.onboardingChooseModelsHelp} - { - setManagedPhase({ ...managedPhase, selectedIds }); - clearFieldError('form'); - }} - isDisabled={busy} + + + {copy.onboardingSelectedCount(managedPhase.selectedIds.length, managedPhase.models.length)} + + +