diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d23898ba63..97f56d09cc 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -163,7 +163,6 @@ "src/renderer/settings/provider-connection-status.ts", "src/renderer/settings/provider-display-copy.ts", "src/renderer/settings/provider-display.tsx", - "src/renderer/settings/provider-enabled-model-manager.tsx", "src/renderer/settings/provider-endpoint-presentation.ts", "src/renderer/settings/provider-oauth-section.tsx", "src/renderer/settings/providers-panel.tsx", @@ -3428,7 +3427,6 @@ "./password-input": 1, "./provider-add-model-dialog": 1, "./provider-display": 1, - "./provider-enabled-model-manager": 1, "./provider-endpoint-presentation": 1, "./relay-thinking-bulk": 1, "./request-customization-editor": 1, @@ -3440,7 +3438,7 @@ "@astryxdesign/core": 1, "@maka/core/llm-connections": 1, "@maka/core/model-thinking": 1, - "@maka/ui": 2, + "@maka/ui": 1, "react": 1 } }, @@ -3484,22 +3482,6 @@ "@maka/core/llm-connections": 1 } }, - "src/renderer/settings/provider-enabled-model-manager.tsx": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../features/connection-settings": 1, - "@astryxdesign/core": 1, - "@maka/core/model-catalog": 1, - "@maka/ui": 1 - } - }, "src/renderer/settings/provider-endpoint-presentation.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index fa93449442..d9c5757d2d 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -32,11 +32,11 @@ export type { RuntimeHostSettingsConnectionsBridge, } from './ports.js'; export { - categoryLabel, connectionLastTestMessageDisplay, connectionTestFailureMessage, providerPanelActionErrorMessage, } from './provider-panel-shared.js'; +export { OnboardingStepForm } from './onboarding-step-form.js'; export { getProviderSettingsCopy } from './settings-provider-copy.js'; export type { ProviderSettingsCopy } from './settings-provider-copy.js'; export type { diff --git a/apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx b/apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx new file mode 100644 index 0000000000..1fd75ffbc8 --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// A wizard step that takes focus when it appears. +// +// A step replaces the whole form in place: the button the user pressed unmounts +// with the step it belonged to, and focus falls to `document.body`. The +// settings route has not moved, so the page's level focus never re-runs. The +// step that arrives is the only thing that knows it arrived, so it owns the +// move, and it owns it on mount — which is exactly when it arrives. +// +// Focus lands on the step itself rather than its first control, so a screen +// reader announces the step the user is now in. A step whose first control is a +// text field can say so with that field's `hasAutoFocus` instead and skip this. +import { useEffect, useRef, type FormEvent, type ReactNode } from 'react'; +import { VStack } from '@astryxdesign/core'; + +export function OnboardingStepForm(props: { + /** Names the step for assistive technology; focus lands here. */ + label: string; + /** The story DOM contract for this step. */ + contract: string; + onSubmit(event: FormEvent): void; + children: ReactNode; +}) { + const stepRef = useRef(null); + + useEffect(() => { + // `preventScroll` because the step renders at the top of the content area + // already: scrolling to the focus target would push its header out of view. + stepRef.current?.focus({ preventScroll: true }); + }, []); + + return ( +
+ {props.children} +
+ ); +} diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index 92dc3a9231..0a470b680d 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -18,10 +18,7 @@ */ import { generalizedErrorMessage, generalizedErrorMessageChinese, redactSecrets } from '@maka/core/redaction'; -import { - type ConnectionTestResult, - type ProviderCategory, -} from '@maka/core/llm-connections'; +import { type ConnectionTestResult } from '@maka/core/llm-connections'; import { type UiLocale } from '@maka/core/ui-locale'; import { getProviderSettingsCopy } from './settings-provider-copy.js'; import { cleanErrorMessage } from '../../application/contracts/connection-error-cleaner.js'; @@ -104,7 +101,3 @@ export function connectionLastTestMessageDisplay(message: string | undefined, lo : generalizedErrorMessage(new Error(trimmed), ''); return classified || copy.statusUnavailable; } - -export function categoryLabel(category: ProviderCategory, locale: UiLocale = 'zh'): string { - return getProviderSettingsCopy(locale).shared.categories[category]; -} 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..28cfe0f7a9 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', @@ -120,7 +113,7 @@ const zhCopy = { credentialsHelpAccount: '登录令牌只保存在本机。', modelManagementHelp: '这些模型会出现在任务的模型选择器里。', ...zhCapabilitiesCopy, - capabilitiesHelp: '声明每个已启用模型的思考档位、视觉与上下文窗口;保存后生效。', + capabilitiesHelp: '配置这个模型的上下文长度、视觉支持与思考档位,保存后生效。', // Row affordances (settings-sidebar 的 InfoRow / ExpandableRow 语言):一行 // 只报状态,改的时候才展开成输入框。 change: '更换', set: '设置', edit: '编辑', save: '保存', @@ -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: '缺少该模型的参数信息,请手动配置。', + visionToken: '视觉', thinkingToken: '思考', contextToken: (value: string) => `${value} 上下文`, noModels: '暂无可选模型,请先更新模型目录。', keySet: '已设置', statusLoading: '正在读取状态', credentialUnknown: '凭据状态未知', keyMissing: '尚未设置密钥', keyTroubleshooting: '模型密钥 / 服务地址 / 代理设置', endpointTroubleshooting: '本地服务 / 服务地址 / 代理设置', oauthTroubleshooting: 'OAuth 登录 / 代理设置', @@ -164,7 +162,9 @@ const zhCopy = { actionFallback: '模型连接服务暂时不可用,请稍后重试。', rateLimit: '当前账号或模型服务触发速率限制,请稍后重试。', timeout: '请求超时,请检查网络或代理后重试。', unavailable: '模型服务暂时不可用,请稍后重试。', network: '网络错误,请检查服务地址或代理设置后重试。', statusUnavailable: '连接测试状态暂时无法显示,请重新测试。', - categories: { oauth: 'OAuth', domestic: '国内', overseas: '海外', local: '本地', custom: 'Custom' }, + // Every list that a search box narrows says how many rows are left, so the + // change is spoken rather than only shown. + filterMatches: (count: number) => (count === 0 ? '没有匹配的结果' : `${count} 个匹配结果`), connectionStatuses: { retired: '已停用 · 请删除', reauth: '需要重新登录', disabledFailed: '暂不可用 · 上次连接失败', disabled: '暂不可用', failed: '上次连接失败' }, lastTest: { '连接已验证': '连接已验证', '鉴权失败': '鉴权失败', '请求超时': '请求超时', '网络错误': '网络错误', '模型服务返回错误': '模型服务返回错误', '连接测试失败': '连接测试失败', @@ -176,17 +176,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 +207,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, @@ -282,7 +287,7 @@ const enCopy: ProviderSettingsCopy = { credentialsHelpAccount: 'The sign-in token stays on this machine.', modelManagementHelp: 'These models appear in the chat model picker.', ...enCapabilitiesCopy, - capabilitiesHelp: 'Declares thinking levels, vision, and context window per enabled model; applies on save.', + capabilitiesHelp: "Set this model's context length, vision support, and thinking levels; applies on save.", change: 'Change', set: 'Set', edit: 'Edit', save: 'Save', endpointManaged: 'Managed by account sign-in or the provider', endpointMissing: 'No service URL configured', @@ -293,8 +298,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', + modelsSummary: (enabled: number, total: number) => `${enabled} of ${total} enabled`, + filterModels: 'Search models', noModelsMatch: 'No matching models', + enableModelAria: (name: string) => `Enable model ${name}`, + declareCapabilities: 'Set parameters', declareCapabilitiesAria: (name: string) => `Set model parameters: ${name}`, + modelUndescribed: 'No parameters known for this model. Set them by hand.', + 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', @@ -324,7 +334,7 @@ const enCopy: ProviderSettingsCopy = { actionFallback: 'The model connection service is temporarily unavailable. Try again later.', rateLimit: 'This account or model service is rate-limited. Try again later.', timeout: 'The request timed out. Check the network or proxy and try again.', unavailable: 'The model service is temporarily unavailable. Try again later.', network: 'Network error. Check the service URL or proxy settings and try again.', statusUnavailable: 'The connection test status is temporarily unavailable. Test again.', - categories: { oauth: 'OAuth', domestic: 'China', overseas: 'Global', local: 'Local', custom: 'Custom' }, + filterMatches: (count: number) => (count === 0 ? 'No matches' : count === 1 ? '1 match' : `${count} matches`), connectionStatuses: { retired: 'Retired · delete it', reauth: 'Sign-in required', disabledFailed: 'Unavailable · last connection failed', disabled: 'Unavailable', failed: 'Last connection failed' }, lastTest: { '连接已验证': 'Connection verified', '鉴权失败': 'Authentication failed', '请求超时': 'Request timed out', '网络错误': 'Network error', '模型服务返回错误': 'Model service returned an error', '连接测试失败': 'Connection test failed', @@ -336,17 +346,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: 'Subscription 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: 'Start with a common provider 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 +377,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: 'Deselect all', 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..dbab46b5bd 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -24,11 +24,22 @@ 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 { Button, FormLayout, + Selector, TextInput, useMountedRef, useUiLocale, @@ -39,7 +50,7 @@ import { PasswordInput } from './password-input'; import { providerDisplay } from './provider-display'; import { useActionGuard } from './use-action-guard'; import { - categoryLabel, + OnboardingStepForm, getProviderSettingsCopy, providerPanelActionErrorMessage, type ApiKeyOnboardingBridge, @@ -79,8 +90,15 @@ 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; + /** The picker's search text; it belongs to this step and leaves with it. */ + readonly filter: 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; @@ -94,6 +112,7 @@ export function AddProviderForm(props: { }) { const locale = useUiLocale(); const copy = getProviderSettingsCopy(locale).add; + const sharedCopy = getProviderSettingsCopy(locale).shared; const defaults = PROVIDER_REGISTRY[props.providerType]; const display = providerDisplay(props.providerType, locale); const recommendedDefaultModel = buildCatalogRecommendedDefaultModel(props.providerType); @@ -223,7 +242,15 @@ 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]!, + filter: '', + }); } catch (err) { if (addProviderMountedRef.current) { setError({ field: 'form', message: providerPanelActionErrorMessage(err, locale) }); @@ -243,14 +270,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 +475,127 @@ 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 = managedPhase.filter.trim().toLocaleLowerCase(); + const setFilter = (filter: string) => setManagedPhase({ ...managedPhase, filter }); + 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)} + + +