From 526114a810e7e0a5e7ba3e78c12d2fc9dbd3c085 Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 1 Nov 2026 10:32:13 +0800 Subject: [PATCH 1/3] fix(settings): make provider sign-in and model editing reliable Compare connection and credential facts structurally so provider JSON field order cannot reject a successful login. Adopt the new basis before model discovery, allow save-and-sign-in, and provide working reload and independent discovery retry. Reorganize connection and model settings around authentication, model choices and compact advanced controls. Preserve edits on Back, validate model limits inline, keep footer actions reachable, and verify the actual desktop flow in light and dark at narrow and wide sizes. Generated-by: OpenAI Codex --- .../tests/real_host/desktop_new_session.rs | 172 ++++- .../real_host/model_settings_preview.tsx | 308 +++++++++ products/maka/workbench/README.md | 2 + products/maka/workbench/README.zh-CN.md | 2 + .../workbench/app/CatalogModelAssociation.tsx | 61 +- .../maka/workbench/app/ConnectionForm.tsx | 514 ++++++++------- .../maka/workbench/app/ConnectionLogin.tsx | 201 ++++-- .../maka/workbench/app/ConnectionModel.tsx | 589 ++++++++++-------- .../maka/workbench/app/ConnectionSettings.tsx | 370 +++++++---- products/maka/workbench/app/SettingsField.tsx | 11 +- .../workbench/app/connectionLoginState.ts | 130 ++-- .../maka/workbench/app/locale-settings.ts | 55 +- products/maka/workbench/app/settings.ts | 128 +++- .../workbench/tests/connection-login.test.mjs | 359 ++++++++++- 14 files changed, 2148 insertions(+), 754 deletions(-) create mode 100644 products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index 0acc320736b..3c6480e2841 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -50,6 +50,7 @@ struct Desktop { window: WindowHandle, client: Client, bindings: Bindings, + outcome: Arc>, _foreground: watch::Sender, wake: Arc, errors: Rc>>, @@ -71,6 +72,16 @@ impl Desktop { address: &Path, dimensions: gpui::Size, appearance: nsh_core::platform::Appearance, + ) -> Self { + Self::open_config(fixture, address, dimensions, appearance, None).await + } + + async fn open_config( + fixture: &Fixture, + address: &Path, + dimensions: gpui::Size, + appearance: nsh_core::platform::Appearance, + preview: Option, ) -> Self { let client = fixture.client_at(address).await; let bindings = Bindings::new(client.clone(), tokio::runtime::Handle::current()); @@ -80,6 +91,24 @@ impl Desktop { ); config.assets = Some(bindings.assets()); config.chunk_loader = Some(bindings.chunk_loader()); + let previewing = preview.is_some(); + if let Some(source) = preview { + let mut capabilities = nsh_engine::Capabilities::all(); + capabilities.host = vec!["*".into()]; + config.app = nsh_engine::Bundle::source(source) + .with_policy(nsh_engine::Policy { + declared: Some(capabilities), + ..nsh_engine::Policy::development() + }) + .into(); + config.host.handle_open_url(|request| request.handled()); + } + let outcome = Arc::new(Mutex::new(Value::Null)); + let observed = outcome.clone(); + config.host.handle("fixture.outcome", move |mut call| { + *observed.lock().unwrap() = call.take_data(); + call.resolve(Value::Null); + }); let (foreground, receiver) = watch::channel(true); config.context = config .context @@ -124,6 +153,7 @@ impl Desktop { cx.new(|cx| { let view = AppView::new( AppOptions { + host: config.host.clone(), terminal_keyboard: true, ..Default::default() }, @@ -151,14 +181,43 @@ impl Desktop { window, client, bindings, + outcome, _foreground: foreground, wake, errors, }; - desktop.target_after(None).await; + if previewing { + desktop.control("Close settings").await; + } else { + desktop.target_after(None).await; + } desktop } + fn event(&self, name: &str, value: Value) { + self.app.update(|cx| { + self.window + .update(cx, |view, _, _| view.host().send(name, value).unwrap()) + .unwrap(); + }); + } + + fn artifact(&self, name: &str) { + let Some(directory) = std::env::var_os("MAKA_UI_ARTIFACT_DIR") else { + return; + }; + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + let guard = self.frame.lock().unwrap(); + let frame = guard.as_ref().unwrap(); + let size = frame.size(); + let mut ppm = format!("P6\n{} {}\n255\n", size.width.0, size.height.0).into_bytes(); + for pixel in frame.pixels().chunks_exact(4) { + ppm.extend_from_slice(&pixel[..3]); + } + std::fs::write(directory.join(format!("{name}.ppm")), ppm).unwrap(); + } + fn pump(&self) { self.platform.pump(); assert!( @@ -179,7 +238,10 @@ impl Desktop { } }) .await - .unwrap_or_else(|_| panic!("{stage}")) + .unwrap_or_else(|_| { + self.artifact("failure"); + panic!("{stage}; observed: {:?}", self.outcome.lock().unwrap()); + }) } async fn wait(&self, stage: &str, future: impl Future) -> T { @@ -194,7 +256,10 @@ impl Desktop { } }) .await - .unwrap_or_else(|_| panic!("{stage}")) + .unwrap_or_else(|_| { + self.artifact("failure"); + panic!("{stage}; observed: {:?}", self.outcome.lock().unwrap()); + }) } fn key(&self, name: &str, control: bool) { @@ -1070,3 +1135,104 @@ async fn desktop_unknown_control_can_be_dismissed_and_feedback_expires() { drop(create); fixture.finish().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn desktop_model_settings_keeps_successful_sign_in_and_retries_models() { + let fixture = Fixture::open().await; + let entry = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../terminal-shell/tests/real_host/model_settings_preview.tsx"); + let output = fixture.directory.path().join("settings-preview"); + let source = tokio::task::spawn_blocking(move || { + let compiled = maka_ui_build::compile(&entry, &output).unwrap(); + std::fs::read_to_string(compiled.module).unwrap() + }) + .await + .unwrap(); + let (theme, appearance, layout, width, height) = visual_case(); + let desktop = Desktop::open_config( + &fixture, + &fixture.address, + size(gpui::DevicePixels(width), gpui::DevicePixels(height)), + appearance, + Some(source), + ) + .await; + desktop.press("Edit connection ChatGPT Subscription").await; + let reload = desktop.control("Reload connection").await; + let delete = desktop.control("Delete connection").await; + assert_eq!( + reload.center().y, + delete.center().y, + "connection actions must share one baseline" + ); + assert!(reload.bottom() <= px(height as f32) && reload.bottom() > px(height as f32 - 128.0)); + desktop.artifact(&format!("connection-before-login-{theme}-{layout}")); + desktop.press("Sign in with ChatGPT").await; + desktop.control("Copy sign-in code").await; + desktop.artifact(&format!("connection-signing-in-{theme}-{layout}")); + desktop.press("Copy sign-in code").await; + let copied = desktop + .until("the sign-in code was not copied", || { + desktop + .platform + .take_clipboard_write() + .and_then(|item| item.text()) + }) + .await; + assert_eq!(copied, "ABCD-EFGH"); + desktop.event("fixture.finishLogin", json!({"result":"saved"})); + desktop.control("Sign in again").await; + desktop.control("Fetch models").await; + assert_eq!( + desktop.outcome.lock().unwrap()["configured"], + true, + "model discovery failure must not undo a completed sign-in" + ); + desktop.artifact(&format!("connection-model-retry-{theme}-{layout}")); + desktop.press("Fetch models").await; + desktop.control("Manage models").await; + { + let outcome = desktop.outcome.lock().unwrap(); + assert_eq!( + outcome["signIns"], 1, + "retrying discovery must not repeat provider sign-in" + ); + assert_eq!(outcome["discoveries"], 2); + } + desktop.artifact(&format!("connection-ready-{theme}-{layout}")); + desktop.press("Manage models").await; + desktop.control("Edit model gpt-4.1").await; + desktop.press("Set default gpt-4.1").await; + desktop + .until("the selected default was not saved", || { + (desktop.outcome.lock().unwrap()["defaultTarget"]["modelId"] == "gpt-4.1").then_some(()) + }) + .await; + desktop.control("Set default gpt-4.1").await; + desktop.artifact(&format!("connection-models-{theme}-{layout}")); + desktop.press("Edit model gpt-4.1").await; + let associate = desktop.control("Associate catalog model").await; + desktop.artifact(&format!("connection-model-editor-{theme}-{layout}")); + desktop.press("Associate catalog model").await; + desktop.control("openai / gpt-4.1").await; + desktop.artifact(&format!("connection-model-catalog-{theme}-{layout}")); + desktop.press("openai / gpt-4.1").await; + let save = desktop.control("Save model").await; + assert_eq!( + associate.right(), + save.right(), + "model controls must share the reading edge" + ); + desktop.press("Back").await; + desktop.control("Keep editing").await; + desktop.artifact(&format!("connection-unsaved-model-{theme}-{layout}")); + desktop.press("Keep editing").await; + desktop.press("Save model").await; + desktop.control("Edit model gpt-4.1").await; + assert_eq!( + desktop.outcome.lock().unwrap()["overrides"]["gpt-4.1"]["catalogModel"]["modelId"], + "gpt-4.1" + ); + desktop.close().await; + fixture.finish().await; +} diff --git a/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx new file mode 100644 index 00000000000..faf34bdc4bc --- /dev/null +++ b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx @@ -0,0 +1,308 @@ +/* + * 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. + */ + +import { cleanup, host as events, signal, mount, type JSX } from 'nsh'; +import type { UiContext, UiHost } from '../../../../../packages/plugin-sdk/src/workbench'; +import type { SettingsPageInput } from '../../../../../packages/plugin-sdk/src/workbench-views'; +import { ModelSettings } from '../../../workbench/app/ModelSettings'; +import { ConnectionSettings } from '../../../workbench/app/ConnectionSettings'; +import { createModelSettings } from '../../../workbench/app/settings'; +import { createSettingsOperations } from '../../../workbench/app/settingsOperations'; +import { HostProvider, type Host } from '../../../workbench/app/host'; +import { currentLocale, tr } from '../../../workbench/app/locale'; + +// The provider boundary is controlled by the test; every displayed control is production UI. +function Preview() { + currentLocale.set('en'); + const provider = { + entryId: 'maka.codex', + name: 'chatgpt', + packageId: 'maka.codex', + scope: 'profile', + }; + const definition = { + identity: provider, + descriptor: { + label: 'ChatGPT Subscription', + anonymous: false, + customEndpoint: false, + discovery: true, + configurationDefaults: { baseUrl: 'https://chatgpt.com/backend-api/codex' }, + authentication: [{ id: 'device', interactive: true, label: 'Sign in with ChatGPT' }], + }, + }; + let revision = 1; + let catalogRevision = 1; + let configured = false; + let name = 'ChatGPT Subscription'; + let enabled = true; + let modelIds: string[] = []; + let overrides: Record = {}; + let overlay: unknown = null; + let defaultTarget: { connectionId: string; modelId: string } | null = null; + let finishLogin: ((value: string) => void) | null = null; + let signIns = 0; + let discoveries = 0; + const report = () => + void events.call('fixture.outcome', { + configured, + revision, + signIns, + discoveries, + modelIds, + overrides, + defaultTarget, + }); + cleanup( + events.on<{ result: string }>('fixture.finishLogin', ({ result }) => finishLogin?.(result)), + ); + const basis = () => ({ connectionId: 'connection', revision }); + const detail = () => ({ + kind: 'ready', + name, + enabled, + target: { + ...basis(), + slug: 'chatgpt', + provider, + configuration: definition.descriptor.configurationDefaults, + }, + editing: { enabledModelIds: modelIds, modelOverrides: overrides, requestBodyOverlay: overlay }, + }); + const receipt = (value: unknown) => ({ + reply: { + receipt: { + kind: 'recorded', + outcome: { kind: 'returned', value }, + cleanup: { kind: 'confirmed' }, + }, + }, + }); + const api = { + pendingSettings: async () => [], + async query(_endpoint: string, method: string, input: any) { + if (method === 'connections') + return { + kind: 'page', + revision: catalogRevision, + next: null, + defaultTarget, + connections: [ + { + basis: basis(), + name, + slug: 'chatgpt', + provider, + enabled, + enabledModelCount: modelIds.length, + editable: true, + }, + ], + }; + if (method === 'providers') + return { + providers: [{ identity: provider, label: definition.descriptor.label }], + next: null, + }; + if (method === 'provider') return definition; + if (method === 'connection') + return input.revision === revision ? detail() : { kind: 'stale', actual: basis() }; + if (method === 'credentialStatus') + return { + kind: 'status', + status: { + locator: { scope: 'connection', connectionId: 'connection', kind: 'provider' }, + configured, + credentialId: configured ? 'fixture-credential' : null, + revision: configured ? 1 : null, + updatedAt: null, + }, + }; + if (method === 'resolveModel') + return { + kind: 'ready', + defaults: { + label: 'GPT-4.1', + contextWindow: 128000, + inputLimit: 128000, + maxOutputTokens: 16384, + compactionThreshold: null, + thinkingLevels: ['off', 'medium', 'high'], + defaultThinkingLevel: 'medium', + codeMode: true, + applyPatch: true, + catalogMatched: input.modelId === 'gpt-4.1' || !!input.overrides?.catalogModel, + }, + choice: { + selection: { ...basis(), modelId: input.modelId }, + thinkingLevels: ['off', 'medium', 'high'], + defaultThinkingLevel: 'medium', + limits: { contextWindow: 128000, maxOutputTokens: 16384 }, + }, + }; + if (method === 'catalogModels') + return { + models: [{ reference: { provider: 'openai', modelId: 'gpt-4.1' }, label: 'GPT-4.1' }], + after: null, + }; + throw new Error(`Unexpected fixture query ${method}`); + }, + async beginSetting(_endpoint: string, method: string, input: any) { + if (method !== 'loginCredential' || input.expected.revision !== revision) + throw new Error('Sign-in reused a stale connection'); + signIns++; + report(); + let progress = true; + const completion = new Promise((resolve) => { + finishLogin = resolve; + }); + return { + cancel() { + finishLogin?.('cancelled'); + }, + async next() { + if (progress) { + progress = false; + return { + kind: 'progress', + value: { url: 'https://auth.openai.com/codex/device', userCode: 'ABCD-EFGH' }, + }; + } + const result = await completion; + finishLogin = null; + if (result === 'saved') { + configured = true; + revision++; + catalogRevision++; + } + report(); + const value = + result === 'saved' + ? { + kind: 'saved', + connection: { + connectionId: 'connection', + slug: 'chatgpt', + provider: { + packageId: 'maka.codex', + entryId: 'maka.codex', + scope: 'profile', + name: 'chatgpt', + }, + }, + } + : { kind: 'failed', reason: result, message: '' }; + return { kind: 'final', ...receipt(value), cleanupError: null }; + }, + }; + }, + async settingsMutation(_endpoint: string, method: string, input: any) { + if (method === 'discoverModels') { + if (input.revision !== revision) throw new Error('Discovery reused a stale connection'); + discoveries++; + if (discoveries === 1) { + report(); + return receipt({ kind: 'failed' }); + } + modelIds = ['gpt-4.1', 'gpt-4.1-mini']; + revision++; + catalogRevision++; + report(); + return receipt({ kind: 'ready', connection: basis() }); + } + if (method === 'setDefault') { + if (input.expectedCatalogRevision !== catalogRevision) + throw new Error('Default selection reused a stale catalog'); + defaultTarget = input.target; + catalogRevision++; + report(); + return receipt({ kind: 'committed' }); + } + if (method === 'update') { + if (input.expected.revision !== revision) + throw new Error('Editing reused a stale connection'); + name = input.changes.name ?? name; + enabled = input.changes.enabled ?? enabled; + modelIds = input.changes.enabledModelIds ?? modelIds; + overrides = input.changes.modelOverrides ?? overrides; + if ('requestBodyOverlay' in input.changes) overlay = input.changes.requestBodyOverlay; + revision++; + catalogRevision++; + report(); + return receipt({ kind: 'committed', connection: basis() }); + } + throw new Error(`Unexpected fixture mutation ${method}`); + }, + } as unknown as Host; + const operations = createSettingsOperations(api); + const owner = createModelSettings( + api, + () => {}, + operations, + () => true, + ); + const instance = ['settings']; + const contribution = { + key: 'connections', + id: 'connections', + instance: { instance, path: instance }, + declaration: { + kind: 'settings', + title: { fallback: 'Connections & models', translations: {} }, + }, + }; + function Page(props: { input: SettingsPageInput }) { + cleanup( + props.input.settings!.attach({ + title: () => + owner.page() === 'connection' + ? tr('Connection settings') + : owner.page() === 'model' + ? tr('Model settings') + : tr('Connections & models'), + nested: () => owner.page() !== 'connections', + navigation: owner.navigation, + back: owner.back, + busy: owner.busy, + }), + ); + return ; + } + const context = { + runtime: { + ready: () => true, + settings: () => [contribution], + render: (_: unknown, props: { input: SettingsPageInput }) => , + }, + } as unknown as UiContext; + return ( + + true, close() {}, changed() {} }} + defaultRoute={{ instance, id: 'connections' }} + route={signal({ instance, id: 'connections' })} + operations={operations} + recover={() => {}} + /> + + ); +} + +mount(Preview); diff --git a/products/maka/workbench/README.md b/products/maka/workbench/README.md index 712c83787a1..34f9a59894a 100644 --- a/products/maka/workbench/README.md +++ b/products/maka/workbench/README.md @@ -52,3 +52,5 @@ Use **Pin conversation** in a conversation’s action menu to keep it in the **P Only skills installed through Maka have an update button. Updating replaces their entire directory from the recorded source, including resources, and overwrites local edits. Other skills in the four standard directories can still be inspected and deleted. The source registry lives in the Host database; no npx lockfile or private copy of the skills is created. **Settings → MCP servers** manages the four corresponding `mcp.json` files through command/HTTP forms. It preserves other entries and custom fields. Selecting a project does not create a conversation. Downloads, file changes and registrations run on the connected Host; saving a project MCP file does not grant it trust. + +**Settings → Connections & models** keeps provider sign-in, connection details and the model list together. A new or edited connection can be saved and signed in with one action. Device sign-in opens the browser and keeps the code, copy controls and cancellation available in the dialog. Model discovery can be retried independently after successful sign-in. Advanced connection and model options stay behind disclosures; saved connections can be reloaded after external changes, and Back asks before discarding edits. diff --git a/products/maka/workbench/README.zh-CN.md b/products/maka/workbench/README.zh-CN.md index 71a8eeb2881..53a150d6d9e 100644 --- a/products/maka/workbench/README.zh-CN.md +++ b/products/maka/workbench/README.zh-CN.md @@ -52,3 +52,5 @@ MAKA_NSH_EVIDENCE=/tmp/maka-nsh-evidence cargo nextest run --locked --workspace 只有通过 Maka 安装的技能显示更新按钮。更新从登记的来源替换整个目录及其资源,并覆盖本地修改。四个标准目录中的其他技能同样可以查看和删除。来源登记保存在 Host 数据库中,不创建 npx lockfile 或另一套私有技能副本。 **设置 → MCP 服务器**通过命令和 HTTP 表单管理上述四个目录的 `mcp.json`,保留其他条目和自定义字段。选择项目无需创建会话。下载、文件修改和来源登记均在所连接的 Host 上完成;保存项目 MCP 配置不会自动授予信任。 + +**设置 → 连接与模型** 将提供商登录、连接信息和模型列表放在一起。新建或修改连接后,可以一次完成保存并登录。设备登录会打开浏览器,对话框保留验证码、复制和取消操作。登录成功后,获取模型失败可以单独重试。高级连接与模型选项折叠显示;已保存的连接可以重新加载外部更改,返回时会确认是否放弃未保存的编辑。 diff --git a/products/maka/workbench/app/CatalogModelAssociation.tsx b/products/maka/workbench/app/CatalogModelAssociation.tsx index 7bb79cbaf53..ded6621077a 100644 --- a/products/maka/workbench/app/CatalogModelAssociation.tsx +++ b/products/maka/workbench/app/CatalogModelAssociation.tsx @@ -55,32 +55,42 @@ export function CatalogModelAssociation(props: { const reference = () => owner.model()?.catalogModel; return ( - {tr('Catalog model')} - - {reference() - ? `${reference()!.provider} / ${reference()!.modelId}` - : tr('Automatic matching')} + + {tr('Catalog model')} - - - {reference() - ? tr('Associated catalog model is unavailable.') - : tr('No catalog model matched.')} - - - + + + + {reference() + ? `${reference()!.provider} / ${reference()!.modelId}` + : tr('Automatic matching')} + + + + {reference() + ? tr('Associated catalog model is unavailable.') + : tr('No catalog model matched.')} + + + @@ -157,6 +168,7 @@ function CatalogModelPicker(props: { props.close(); }; const move = (delta: number) => { + if (models().length === 0) return; const index = Math.max(0, Math.min(models().length - 1, selected() + delta)); selected.set(index); list?.scrollToIndex({ index, animated: false, viewPosition: delta > 0 ? 1 : 0 }); @@ -179,14 +191,14 @@ function CatalogModelPicker(props: { {tr('Close')} @@ -210,6 +223,7 @@ function CatalogModelPicker(props: { { void search(value); @@ -221,13 +235,13 @@ function CatalogModelPicker(props: { submitBehavior="submit" style={{ color: C.text, - height: 40, + height: 32, backgroundColor: C.background, paddingHorizontal: 12, borderWidth: 1, borderColor: C.line, borderRadius: 8, - fontSize: 14, + ...captionTextStyle, }} focusStyle={{ borderColor: C.accent }} ref={(node) => { @@ -267,7 +281,9 @@ function CatalogModelPicker(props: { backgroundColor: selected() === index() ? C.raised : C.surface, }} hoverStyle={controlHoverStyle()} + activeStyle={{ backgroundColor: C.line }} focusStyle={controlFocusStyle()} + disabledStyle={controlDisabledStyle} > diff --git a/products/maka/workbench/app/ConnectionForm.tsx b/products/maka/workbench/app/ConnectionForm.tsx index 7935e13ae61..3e2abb12267 100644 --- a/products/maka/workbench/app/ConnectionForm.tsx +++ b/products/maka/workbench/app/ConnectionForm.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { inputAction, Scroll, Text, View } from 'nsh'; +import { inputAction, platform, Scroll, signal, Text, View } from 'nsh'; import { Button } from 'nsh/base'; import type { ModelSettingsOwner } from './settings'; import { SettingsField } from './SettingsField'; @@ -27,10 +27,10 @@ import { Icon } from './Icon'; import { tr } from './locale'; import { C, + Disclosure, bodyTextStyle, captionTextStyle, controlStyle, - fieldBoxStyle, controlHoverStyle, controlFocusStyle, controlDisabledStyle, @@ -38,263 +38,337 @@ import { export function ConnectionForm(props: { settings: ModelSettingsOwner }) { const owner = props.settings; + const advanced = signal(false); const form = () => owner.editor()!; const disabled = () => owner.busy() || owner.loading() || !owner.pendingKnown() || owner.pending().length > 0; const models = () => form().baseline?.editing?.enabledModelIds ?? []; + const interactive = () => + form().provider.descriptor.authentication.some((method) => method.interactive); + const keyed = () => + form().provider.descriptor.authentication.some( + (method) => method.id === 'api-key' && !method.interactive, + ); + const canDiscover = () => + !disabled() && + !owner.dirty() && + (form().provider.descriptor.anonymous || !!owner.credential()?.configured); return ( - - - - {form().provider.descriptor.label} - - owner.edit('name', text)} - disabled={disabled()} - /> - owner.edit('slug', text)} - disabled={disabled()} - readonly={!!form().baseline} - hint={tr( - 'A unique lowercase name, such as openrouter-work. It stays fixed after creation.', - )} - /> - - owner.edit('baseUrl', text)} - disabled={disabled()} - hint={tr('Use the API endpoint supplied by your provider.')} - /> - - - method.id === 'api-key' && !method.interactive, - )} - > - owner.edit('apiKey', text)} - disabled={disabled()} - hint={ - owner.credential()?.configured - ? tr( - 'A provider credential is stored. Leave blank to keep it; enter a key to replace it.', - ) - : tr('Your key is stored securely and is never displayed again.') - } - /> - - - - - - - {tr('Save or discard connection edits before removing the stored credential.')} - + + + owner.edit('apiKey', text)} + disabled={disabled()} + hint={ + owner.credential()?.configured + ? tr( + 'A provider credential is stored. Leave blank to keep it; enter a key to replace it.', + ) + : tr('Your key is stored securely and is never displayed again.') + } + /> - - owner.edit('overlay', text)} - disabled={disabled()} - hint={tr( - 'Merged into model requests. For OpenRouter: {"provider":{"only":["openrouter","deepseek"]}}. Use null to clear.', - )} - /> - - - - - - {tr('Models · {count}', { count: models().length })} - + + + owner.edit('baseUrl', text)} + disabled={disabled()} + /> + + owner.edit('name', text)} + disabled={disabled()} + /> - + + + + + + {tr('Models · {count}', { count: models().length })} + + + + + + 0}> + + + + + {tr('No models added yet.')} + + + + + + advanced.set(!advanced())} + > + {tr('Advanced')} + + + owner.edit('slug', text)} + disabled={disabled()} + readonly={!!form().baseline} + hint={ + form().baseline + ? undefined + : tr( + 'A unique lowercase name, such as openrouter-work. It stays fixed after creation.', + ) + } + /> + + owner.edit('baseUrl', text)} + disabled={disabled()} + /> + + owner.edit('overlay', text)} + disabled={disabled()} + hint={tr('Merged into model requests. Use null to clear.')} + /> + + + + + + - - {tr('Fetching models…')} - - - - {tr( - 'Model fetch failed. The connection remains saved. Retry fetching models or add a model manually.', - )} - - - - - {tr('Models fetched and enabled.')} - - - - - {tr('Fetch the provider’s catalog or add its exact model ID.')} - - + + + + - - - {tr('Save changes before fetching or editing models.')} - - - + + + - - - - - + + - + ); } diff --git a/products/maka/workbench/app/ConnectionLogin.tsx b/products/maka/workbench/app/ConnectionLogin.tsx index 1a11fd12add..19579407fed 100644 --- a/products/maka/workbench/app/ConnectionLogin.tsx +++ b/products/maka/workbench/app/ConnectionLogin.tsx @@ -20,110 +20,179 @@ import { inputAction, Text, View } from 'nsh'; import { Button } from 'nsh/base'; import type { ModelSettingsOwner } from './settings'; +import { Icon } from './Icon'; import { tr } from './locale'; -import { C, controlStyle, controlHoverStyle, controlFocusStyle, controlDisabledStyle } from './ui'; +import { + C, + bodyTextStyle, + captionTextStyle, + controlStyle, + controlHoverStyle, + controlFocusStyle, + controlDisabledStyle, +} from './ui'; export function ConnectionLogin(props: { owner: ModelSettingsOwner; disabled: boolean }) { const owner = props.owner; const methods = () => owner.editor()!.provider.descriptor.authentication.filter((m) => m.interactive); - const login = () => owner.login.state(); + const authorization = owner.login.authorization; + const needsSave = () => !owner.editor()!.baseline || owner.dirty(); + const label = (method: { label: string }) => + needsSave() + ? tr('Save and sign in') + : owner.credential()?.configured + ? tr('Sign in again') + : method.label === 'Sign in with ChatGPT' + ? tr('Sign in with ChatGPT') + : method.label; return ( 0}> - - {tr('Provider sign-in')} - - - {tr('Save this connection before starting sign-in.')} - - - - - {owner.credential()?.configured - ? tr('A provider credential is stored. Sign in again to replace it.') - : tr('Complete sign-in with your provider. Credentials stay in the Host vault.')} + + + + + {owner.credential()?.configured ? tr('Signed in') : tr('Provider sign-in')} + + method.id}> {(method) => ( )} - - - {tr('Save or discard connection edits before signing in.')} - - - - - {login()!.cancelling - ? tr('Cancelling sign-in; waiting for the original result…') - : login()!.prompt - ? login()!.prompt!.userCode - ? tr('Open this URL in your browser and enter the code. Waiting for sign-in…') - : tr('Open this URL in your browser. Waiting for sign-in…') - : tr('Starting sign-in…')} + + {tr('Loading models…')} + + + + {authorization()!.cancelling + ? tr('Cancelling sign-in…') + : !authorization()!.prompt + ? tr('Starting sign-in…') + : authorization()!.browser === 'failed' + ? tr('Copy the URL and open it in your browser.') + : tr('Complete sign-in in your browser.')} - - {login()!.prompt!.url} - - - {login()!.prompt!.userCode} - - - - - + + {authorization()!.prompt!.userCode} + - + + + + {authorization()!.prompt!.url} + + + + diff --git a/products/maka/workbench/app/ConnectionModel.tsx b/products/maka/workbench/app/ConnectionModel.tsx index 5dd8cb1527e..29feeb70d4c 100644 --- a/products/maka/workbench/app/ConnectionModel.tsx +++ b/products/maka/workbench/app/ConnectionModel.tsx @@ -17,15 +17,17 @@ * under the License. */ -import { inputAction, Scroll, Text, View } from 'nsh'; +import { inputAction, platform, Scroll, signal, Text, View } from 'nsh'; import { Button } from 'nsh/base'; import { modelLimitFields, thinkingLevels, type ModelSettingsOwner } from './settings'; import { SettingsField } from './SettingsField'; import { CatalogModelAssociation } from './CatalogModelAssociation'; import { ChoiceIndicator } from './ChoiceIndicator'; import { tr } from './locale'; +import { renderText } from './text'; import { C, + Disclosure, bodyTextStyle, captionTextStyle, controlStyle, @@ -36,6 +38,7 @@ import { export function ConnectionModel(props: { settings: ModelSettingsOwner }) { const owner = props.settings; + const capabilitiesOpen = signal(false); const model = () => owner.model()!; const disabled = () => owner.busy() || owner.loading() || !owner.pendingKnown() || owner.pending().length > 0; @@ -50,273 +53,324 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) { return typeof value === 'boolean' ? tr(value ? 'Enabled' : 'Disabled') : String(value); }; return ( - - - owner.changeModel({ id })} - /> - - owner.changeModel({ label })} - /> - - {tr('Context & output')} - - field.key}> - {(field) => ( + + + + - owner.changeModel({ limits: { ...model().limits, [field().key]: value } }) - } - hint={limitHint(field())} + change={(id) => owner.changeModel({ id })} /> - )} - - {tr('Tools')} - tool.key} - > - {(tool) => ( - + owner.changeModel({ label })} + /> + + + + + {tr('Context & output')} + + field.key}> + {(field) => ( + + owner.changeModel({ limits: { ...model().limits, [field().key]: value } }) + } + hint={limitHint(field())} + error={ + owner.modelProblem()?.field === field().label + ? renderText(owner.modelProblem()!.text) + : undefined + } + /> + )} + + + + + {tr('Connection default reasoning effort')} + + + + level}> + {(level) => ( + + )} + + + + + capabilitiesOpen.set(!capabilitiesOpen())} + > - {tr(tool().label)} + {tr('Model capabilities')} - + + + item.label} + key={(tool) => tool.key} > - {(item) => ( - + + item.label} + > + {(item) => ( + + )} + + + )} - - - )} - - - {tr('Code Mode language')} - - - language.label} - > - {(language) => ( - - )} - - - - {tr('Supported reasoning efforts')} - - - - level}> - {(level) => ( - - )} - - - - {tr('Connection default reasoning effort')} - - - - level}> - {(level) => ( - + )} + + + + {tr('Supported reasoning efforts')} + + - )} - + selected={model().thinkingLevels === null} + accessibilityLabel={tr('Use provider defaults ({value})', { + value: inherited(model().defaults?.thinkingLevels.join(', ') || tr('None')), + })} + onPress={inputAction(owner.useProviderThinking, owner.modelAction())} + style={{ ...controlStyle, flexDirection: 'row', alignItems: 'center', gap: 12 }} + activeStyle={{ backgroundColor: C.line }} + hoverStyle={controlHoverStyle()} + focusStyle={controlFocusStyle()} + disabledStyle={controlDisabledStyle} + > + + + {tr('Use provider defaults ({value})', { + value: inherited(model().defaults?.thinkingLevels.join(', ') || tr('None')), + })} + + + + level}> + {(level) => ( + + )} + + + + + - + + + + - + ); } diff --git a/products/maka/workbench/app/ConnectionSettings.tsx b/products/maka/workbench/app/ConnectionSettings.tsx index 839c593e92b..55822d1159b 100644 --- a/products/maka/workbench/app/ConnectionSettings.tsx +++ b/products/maka/workbench/app/ConnectionSettings.tsx @@ -44,23 +44,78 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { const owner = props.owner; const unavailable = () => owner.busy() || !owner.pendingKnown() || owner.pending().length > 0; const disabled = () => unavailable() || owner.loading(); + const removalLabel = () => + owner.confirmation() === 'connection' + ? tr('Delete connection') + : owner.confirmation() === 'key' + ? tr('Remove stored provider credential') + : tr('Remove model'); + const removalName = () => + owner.confirmation() === 'model' + ? owner.model()?.label.trim() || owner.model()?.originalId || '' + : owner.editor()?.name || ''; let connectionsList: ListRef | undefined; let providersList: ListRef | undefined; let modelsList: ListRef | undefined; return ( + + + + {tr('Discard unsaved changes?')} + + + {owner.discardTarget() === 'model' + ? owner.model()?.label || owner.model()?.id + : owner.editor()?.name} + + + + + + + - + + + {removalLabel()} + + {removalName()} + + + {owner.model()?.originalId} + + + + {owner.confirmation() === 'connection' ? tr( 'Delete this connection, its stored provider credential and model settings? Existing conversations keep their saved selection.', @@ -69,54 +124,86 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { ? tr('Remove the stored provider credential? This connection may stop working.') : tr('Remove this model from the connection?')} - - + + + - + @@ -126,12 +213,16 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { value={owner.connectionQuery} change={(text) => { owner.searchConnections(text); - connectionsList?.scrollToIndex({ index: 0, animated: false }); + if (owner.filteredConnections().length) + connectionsList?.scrollToIndex({ index: 0, animated: false }); }} /> @@ -149,7 +240,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { if (!owner.error()) void owner.loadConnections(true); }} empty={ - + {tr('No matching connections.')} @@ -170,24 +261,27 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { gap: 12, justifyContent: 'center', borderRadius: 8, - borderWidth: 1, - borderColor: C.line, - backgroundColor: C.background, }} - focusStyle={{ backgroundColor: C.raised }} - hoverStyle={{ backgroundColor: C.raised }} + focusStyle={controlFocusStyle()} + hoverStyle={controlHoverStyle()} + activeStyle={{ backgroundColor: C.line }} disabledStyle={controlDisabledStyle} > {item().name} - + {tr('{status} · {count} models', { status: item().enabled ? tr('Enabled') : tr('Disabled'), count: item().enabledModelCount, @@ -200,16 +294,14 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - - {tr('Choose the API provider for this connection.')} - { owner.searchProviders(text); - providersList?.scrollToIndex({ index: 0, animated: false }); + if (owner.filteredProviders().length) + providersList?.scrollToIndex({ index: 0, animated: false }); }} /> JSON.stringify(item.identity)} + keyExtractor={(item) => + JSON.stringify([ + item.identity.packageId, + item.identity.entryId, + item.identity.scope, + item.identity.name, + ]) + } estimatedItemSize={48} onLoadMore={() => { if (!owner.error()) void owner.loadProviders(true); }} empty={ - + - {tr('No matching providers.')} + {owner.providerQuery().trim() + ? tr('No matching providers.') + : tr('No providers available.')} } @@ -246,12 +347,16 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { borderRadius: 8, backgroundColor: C.background, }} - focusStyle={{ backgroundColor: C.raised }} - hoverStyle={{ backgroundColor: C.raised }} + focusStyle={controlFocusStyle()} + hoverStyle={controlHoverStyle()} + activeStyle={{ backgroundColor: C.line }} disabledStyle={controlDisabledStyle} > {item().label} @@ -259,6 +364,19 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { )} + + + @@ -267,27 +385,50 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - + + + + + + { owner.modelQuery.set(text); - modelsList?.scrollToIndex({ index: 0, animated: false }); + if (owner.filteredModels().length) + modelsList?.scrollToIndex({ index: 0, animated: false }); }} /> id} estimatedItemSize={64} empty={ - + - {tr('No matching models.')} + {owner.modelQuery().trim() ? tr('No matching models.') : tr('No models added.')} } @@ -320,19 +467,55 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { alignItems: 'center', gap: 8, height: 64, - padding: 8, + paddingHorizontal: 8, + borderRadius: 8, + backgroundColor: isDefault() ? C.background : undefined, }} > + - ); @@ -399,9 +556,11 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - - {tr('Loading settings…')} - + + + {tr('Loading settings…')} + + ); } @@ -423,10 +582,13 @@ function Search(props: { color: C.text, placeholderColor: C.muted, backgroundColor: C.background, - height: 40, + height: 32, paddingHorizontal: 12, borderRadius: inputBorderRadius, + borderWidth: 1, + borderColor: C.line, }} + focusStyle={{ borderColor: C.accent }} /> ); } diff --git a/products/maka/workbench/app/SettingsField.tsx b/products/maka/workbench/app/SettingsField.tsx index 92bbed402d9..fa130d1a1f6 100644 --- a/products/maka/workbench/app/SettingsField.tsx +++ b/products/maka/workbench/app/SettingsField.tsx @@ -39,7 +39,7 @@ const inputStyle = (disabled?: boolean) => ({ backgroundColor: C.background, color: disabled ? C.muted : C.text, paddingHorizontal: 12, - paddingVertical: compact() ? 4 : 8, + paddingVertical: 4, ...inputTextStyle, }); @@ -71,6 +71,7 @@ export function SettingsField( hint?: string; masked?: boolean; multiline?: boolean; + error?: string; }, ) { return ( @@ -89,13 +90,17 @@ export function SettingsField( autoCorrect={false} style={{ ...inputStyle(props.disabled), + ...(props.error ? { borderColor: C.danger } : {}), placeholderColor: C.muted, - height: props.multiline ? undefined : compact() ? 32 : 40, - minHeight: compact() ? 32 : 40, + height: props.multiline ? undefined : 32, + minHeight: props.multiline && !compact() ? 96 : 32, maxHeight: props.multiline ? windowSize().height / 2 : undefined, }} focusStyle={{ borderColor: C.accent }} /> + + {props.error} + {props.hint} diff --git a/products/maka/workbench/app/connectionLoginState.ts b/products/maka/workbench/app/connectionLoginState.ts index 13010b45cdf..3694d3426ac 100644 --- a/products/maka/workbench/app/connectionLoginState.ts +++ b/products/maka/workbench/app/connectionLoginState.ts @@ -17,8 +17,7 @@ * under the License. */ -import { cleanup, signal } from 'nsh'; -import { Clipboard } from 'nsh'; +import { cleanup, signal, Clipboard, openUrl } from 'nsh'; import type { SettingsCredentialLogin, SettingsCredentialLoginResult, @@ -31,11 +30,17 @@ import { settingsRejection, settingsValue, type SettingsOperations } from './set export type LoginAction = { editor: number; input: SettingsCredentialLogin }; type Saved = Extract; type Original = Awaited>; -type Login = { occurrence: number; prompt: SettingsLoginPrompt | null; cancelling: boolean }; +type Authorization = { + kind: 'authorizing'; + occurrence: number; + prompt: SettingsLoginPrompt | null; + cancelling: boolean; + browser: 'idle' | 'opening' | 'opened' | 'failed'; +}; +type Login = Authorization | { kind: 'models'; occurrence: number }; +type PromptAction = { occurrence: number; url: string }; type CopyAction = { occurrence: number; field: 'url' | 'userCode'; value: string }; -// The native Mutation owns the original call, receipt and cleanup. This is only -// the progress shown by its one consumer in the existing connection editor. export function createConnectionLogin( host: Host, operations: SettingsOperations, @@ -46,20 +51,54 @@ export function createConnectionLogin( }, ) { const state = signal(null); + const copied = signal(null); + let copyTimer: ReturnType | undefined; let alive = true; let occurrence = 0; let original: Original | null = null; cleanup(() => { alive = false; + clearTimeout(copyTimer); original?.cancel(); }); + const authorization = () => { + const current = state(); + return current?.kind === 'authorizing' ? current : null; + }; + const open = async (action: PromptAction) => { + const current = authorization(); + if ( + !alive || + current?.occurrence !== action.occurrence || + current.cancelling || + current.prompt?.url !== action.url || + current.browser === 'opening' + ) + return; + state.set({ ...current, browser: 'opening' }); + let opened = false; + try { + opened = (await openUrl(action.url)).opened; + } catch { + // The same sign-in can still finish after the user opens the copied URL. + } + const latest = authorization(); + if (alive && latest?.occurrence === action.occurrence) + state.set({ ...latest, browser: opened ? 'opened' : 'failed' }); + }; const start = async (action: LoginAction) => { if (!alive || !owner.accepts(action)) return; - let saved = false; await operations.run(async () => { if (!alive || !owner.accepts(action)) return; const id = ++occurrence; - state.set({ occurrence: id, prompt: null, cancelling: false }); + copied.set(null); + state.set({ + kind: 'authorizing', + occurrence: id, + prompt: null, + cancelling: false, + browser: 'idle', + }); try { const mutation = await host.beginSetting( 'authentication', @@ -68,7 +107,8 @@ export function createConnectionLogin( null, ); original = mutation; - if (!alive || state()?.cancelling) mutation.cancel(); + if (!alive || authorization()?.cancelling) mutation.cancel(); + let saved = false; for (;;) { const event = await mutation.next(); if (!event) @@ -80,60 +120,71 @@ export function createConnectionLogin( return; } if (event.kind === 'progress') { - // This exact method's progress and output are validated against - // settingsAuthentication by the Host before native delivery. const prompt = event.value as SettingsLoginPrompt; - if (alive && state()?.occurrence === id) state.set({ ...state()!, prompt }); + const current = authorization(); + if (alive && current?.occurrence === id) { + const first = current.prompt === null; + state.set({ ...current, prompt }); + if (first) void open({ occurrence: id, url: prompt.url }); + } continue; } const result = settingsValue(event.reply) as SettingsCredentialLoginResult; let failure: UiError | null = null; if (result.kind === 'saved') { - // A saved business receipt remains saved if cleanup fails. Retire - // its old editable basis before reporting that independent error. await owner.saved(action, result); - if (alive) - operations.notice.set(message('Sign-in saved in the Host credential vault.')); + if (alive) { + operations.error.set(''); + } + saved = true; } else if (result.kind === 'failed') { - failure = new UiError( - result.reason === 'cancelled' - ? 'Sign-in cancelled.' - : result.reason === 'expired' + if (result.reason === 'cancelled') { + if (alive) operations.notice.set(message('Sign-in cancelled.')); + } else { + failure = new UiError( + result.reason === 'expired' ? 'The sign-in code expired. Start a new sign-in to try again.' : 'Sign-in failed. Check the provider and network, then try again.', - ); + ); + } } else failure = settingsRejection(result); if (event.cleanupError) throw new UiError( '{result} Its original request could not finish its resource cleanup.', - { result: failure ? errorText(failure) : message('Sign-in was saved.') }, + { + result: failure + ? errorText(failure) + : message(saved ? 'Sign-in was saved.' : 'Sign-in cancelled.'), + }, ); if (failure) throw failure; - saved = result.kind === 'saved'; - return; + break; + } + original = null; + if (alive && saved) { + state.set({ kind: 'models', occurrence: id }); + try { + await owner.discover(action); + } catch (reason) { + throw new UiError('Signed in, but models could not be loaded. {reason}', { + reason: errorText(reason), + }); + } } } finally { original = null; if (alive && state()?.occurrence === id) state.set(null); } }); - if (alive && saved) { - await operations.run(async () => { - operations.notice.set(message('Sign-in saved in the Host credential vault.')); - await owner.discover(action); - }); - } }; const cancel = (id: number) => { - const current = state(); + const current = authorization(); if (!alive || current?.occurrence !== id || current.cancelling) return; state.set({ ...current, cancelling: true }); - // Request cancellation; keep observing this same original until its real - // business outcome and cleanup arrive. It may already have been saved. original?.cancel(); }; const copy = async (action: CopyAction) => { - const current = state(); + const current = authorization(); if ( !alive || current?.occurrence !== action.occurrence || @@ -142,15 +193,16 @@ export function createConnectionLogin( return; try { await Clipboard.writeText(action.value); - if (alive && state()?.occurrence === action.occurrence) - operations.notice.set( - action.field === 'url' ? message('Sign-in URL copied.') : message('Sign-in code copied.'), - ); + if (alive && authorization()?.occurrence === action.occurrence) { + clearTimeout(copyTimer); + copied.set(action.field); + copyTimer = setTimeout(() => copied.set(null), 4000); + } } catch (reason) { - if (alive && state()?.occurrence === action.occurrence) operations.report(reason); + if (alive && authorization()?.occurrence === action.occurrence) operations.report(reason); } }; - return { state, start, cancel, copy }; + return { state, authorization, copied, start, cancel, copy, open }; } export type ConnectionLoginOwner = ReturnType; diff --git a/products/maka/workbench/app/locale-settings.ts b/products/maka/workbench/app/locale-settings.ts index 34843c764c3..42dc7faf58b 100644 --- a/products/maka/workbench/app/locale-settings.ts +++ b/products/maka/workbench/app/locale-settings.ts @@ -59,6 +59,56 @@ import { } from '../../../../plugins/shared/tui-messages.js'; export const settingsMessages = { + 'Clear default selection': ['Clear default selection', '清除默认选择', '清除預設選擇'], + 'Discard unsaved changes?': [ + 'Discard unsaved changes?', + '放弃未保存的修改?', + '放棄未儲存的修改?', + ], + 'Discard changes': ['Discard changes', '放弃修改', '放棄修改'], + 'Keep editing': ['Keep editing', '继续编辑', '繼續編輯'], + 'Model capabilities': ['Model capabilities', '模型能力', '模型能力'], + '{action}: {name}': ['{action}: {name}', '{action}:{name}', '{action}:{name}'], + 'No providers available.': [ + 'No providers available.', + '没有可用的提供商。', + '沒有可用的供應商。', + ], + 'No models added.': ['No models added.', '尚未添加模型。', '尚未加入模型。'], + 'Save and sign in': ['Save and sign in', '保存并登录', '儲存並登入'], + 'Sign in again': ['Sign in again', '重新登录', '重新登入'], + 'Sign in with ChatGPT': ['Sign in with ChatGPT', '使用 ChatGPT 登录', '使用 ChatGPT 登入'], + 'Signed in': ['Signed in', '已登录', '已登入'], + 'Loading models…': ['Loading models…', '正在加载模型…', '正在載入模型…'], + 'Cancelling sign-in…': ['Cancelling sign-in…', '正在取消登录…', '正在取消登入…'], + 'Copy the URL and open it in your browser.': [ + 'Copy the URL and open it in your browser.', + '请复制链接,在浏览器中打开。', + '請複製連結,在瀏覽器中開啟。', + ], + 'Complete sign-in in your browser.': [ + 'Complete sign-in in your browser.', + '请在浏览器中完成登录。', + '請在瀏覽器中完成登入。', + ], + 'Open sign-in page': ['Open sign-in page', '打开登录页', '開啟登入頁面'], + 'Opening browser…': ['Opening browser…', '正在打开浏览器…', '正在開啟瀏覽器…'], + 'Connection reloaded.': ['Connection reloaded.', '已重新加载连接。', '已重新載入連線。'], + 'Reload connection': ['Reload connection', '重新加载连接', '重新載入連線'], + 'No models added yet.': ['No models added yet.', '尚未添加模型。', '尚未加入模型。'], + 'Advanced connection settings': ['Advanced connection settings', '高级连接设置', '進階連線設定'], + Advanced: ['Advanced', '高级', '進階'], + 'Merged into model requests. Use null to clear.': [ + 'Merged into model requests. Use null to clear.', + '合并到模型请求中,填写 null 可清除。', + '合併至模型請求,填寫 null 可清除。', + ], + 'Sign out': ['Sign out', '退出登录', '登出'], + 'Signed in, but models could not be loaded. {reason}': [ + 'Signed in, but models could not be loaded. {reason}', + '已登录,但模型加载失败。{reason}', + '已登入,但模型載入失敗。{reason}', + ], 'Load more': ['Load more', '加载更多', '載入更多'], 'Catalog model': ['Catalog model', '目录模型', '目錄模型'], 'Automatic matching': ['Automatic matching', '自动匹配', '自動配對'], @@ -803,11 +853,6 @@ export const settingsMessages = { '登录失败,请检查提供商和网络后重试。', '登入失敗,請檢查提供商和網路後再試。', ], - 'Sign-in saved in the Host credential vault.': [ - 'Sign-in saved in the Host credential vault.', - '登录凭据已保存到 Host 凭据库。', - '登入憑據已儲存至 Host 憑據庫。', - ], 'Sign-in was saved.': ['Sign-in was saved.', '登录已保存。', '登入已儲存。'], 'Starting sign-in…': msg_loginStarting, 'Stored provider credential removed.': [ diff --git a/products/maka/workbench/app/settings.ts b/products/maka/workbench/app/settings.ts index 7d68b350497..da9c64c61db 100644 --- a/products/maka/workbench/app/settings.ts +++ b/products/maka/workbench/app/settings.ts @@ -20,6 +20,7 @@ import { cleanup, computed, signal } from 'nsh'; import type { Host } from './host'; import { message, UiError } from './text'; +import { sameValue } from '../../../../plugins/shared/value'; import type { CatalogModelReference, CatalogMutationResult, @@ -184,6 +185,7 @@ export function createModelSettings( const confirmation = signal<'connection' | 'key' | 'model' | null>( restored?.confirmation ?? null, ); + const discardTarget = signal<'connection' | 'model' | null>(null); const connectionIndex = computed(() => connections().map((item) => ({ item, @@ -509,10 +511,8 @@ export function createModelSettings( !!form.apiKey || form.name !== form.baseline.name || form.enabled !== form.baseline.enabled || - JSON.stringify(configuration(form)) !== - JSON.stringify(form.baseline.target.configuration) || - JSON.stringify(overlay(form.overlay)) !== - JSON.stringify(form.baseline.editing!.requestBodyOverlay) + !sameValue(configuration(form), form.baseline.target.configuration) || + !sameValue(overlay(form.overlay), form.baseline.editing!.requestBodyOverlay) ); } catch { return true; @@ -571,9 +571,7 @@ export function createModelSettings( const body = overlay(form.overlay); const config = configuration(form); const refreshModels = - !form.baseline || - !!form.apiKey || - JSON.stringify(config) !== JSON.stringify(form.baseline.target.configuration); + !form.baseline || !!form.apiKey || !sameValue(config, form.baseline.target.configuration); let result: CatalogMutationResult | null = null; const original = form.baseline; const expectedCredential = credentialBasis(credential()); @@ -600,13 +598,13 @@ export function createModelSettings( const changes: Record = {}; if (form.name !== original.name) changes.name = form.name; if (form.enabled !== original.enabled) changes.enabled = form.enabled; - if (JSON.stringify(config) !== JSON.stringify(original.target.configuration)) { + if (!sameValue(config, original.target.configuration)) { changes.configuration = config; // A connection endpoint change resets discovery in Host. Preserve the // explicit per-model choices from the exact editing baseline. changes.modelOverrides = original.editing!.modelOverrides; } - if (JSON.stringify(body) !== JSON.stringify(original.editing!.requestBodyOverlay)) + if (!sameValue(body, original.editing!.requestBodyOverlay)) changes.requestBodyOverlay = body; if (Object.keys(changes).length) result = await mutate('settings', 'update', { @@ -661,8 +659,7 @@ export function createModelSettings( revision: request.input.expected.revision, }) && form.baseline.target.connectionId === request.input.expected.connectionId && - JSON.stringify(credentialBasis(credential())) === - JSON.stringify(request.input.expectedCredential) && + sameValue(credentialBasis(credential()), request.input.expectedCredential) && form.provider.descriptor.authentication.some( (method) => method.interactive && method.id === request.input.method, ) && @@ -674,7 +671,7 @@ export function createModelSettings( if ( result.connection.connectionId !== target.connectionId || result.connection.slug !== target.slug || - JSON.stringify(result.connection.provider) !== JSON.stringify(target.provider) + !sameValue(result.connection.provider, target.provider) ) throw new UiError( 'The sign-in receipt refers to another connection. Reload before continuing.', @@ -703,6 +700,24 @@ export function createModelSettings( method, }, }); + const signIn = async (request: ConnectionAction & { method: string }) => { + if (!current(request) || busy()) return; + if (!editor()?.baseline || dirty()) await saveConnection(request); + if (!alive || editor()?.instance !== request.editor || !editor()?.baseline || dirty()) return; + await login.start(loginAction(request.method)); + }; + const reloadConnection = (request: ConnectionAction) => { + if (!current(request) || !editor()?.baseline) return; + return run(async () => { + const form = editor()!; + const detail = await adopt(basis(form.baseline!), form.instance, 'committed'); + if (!detail || !alive || editor()?.instance !== form.instance) return; + editor.set({ ...editor()!, apiKey: '' }); + discovery.set('idle'); + await loadConnections(); + if (alive) notice.set(message('Connection reloaded.')); + }); + }; const toggleEnabled = (request: ConnectionAction & { enabled: boolean }) => { const form = editor(); if (current(request) && form && !busy()) editor.set({ ...form, enabled: request.enabled }); @@ -819,6 +834,30 @@ export function createModelSettings( } return { kind: 'valid', value }; }; + const modelProblem = () => { + const value = model(); + if (!value) return null; + const parsed = modelOverride(value); + return parsed.kind === 'invalid' + ? { + field: parsed.field, + text: message('{field} must be a positive whole number of tokens.', { + field: message(parsed.field), + }), + } + : null; + }; + const modelDirty = () => { + const value = model(); + if (!value) return false; + const parsed = modelOverride(value); + if (parsed.kind === 'invalid') return true; + if (value.originalId === null) return !!value.id.trim() || Object.keys(parsed.value).length > 0; + return !sameValue( + parsed.value, + editor()?.baseline?.editing?.modelOverrides?.[value.originalId] ?? {}, + ); + }; const loadModelMetadata = async (request: ModelAction) => { const connectionId = editor()!.baseline!.target.connectionId; const generation = ++read; @@ -1051,13 +1090,50 @@ export function createModelSettings( changed(); }); }; + const leaveConnection = () => { + cancelRead(); + editor.set(null); + confirmation.set(null); + page.set('connections'); + void loadConnections(); + }; + const connectionEdited = () => { + const form = editor(); + if (!form) return false; + if (form.baseline) return dirty(); + const defaults = form.provider.descriptor.configurationDefaults as Record< + string, + unknown + > | null; + const slug = + form.provider.identity.name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'connection'; + return ( + !!form.apiKey || + form.name !== form.provider.descriptor.label || + form.slug !== slug || + !form.enabled || + form.overlay.trim() !== 'null' || + form.baseUrl !== (defaults?.baseUrl ?? '') + ); + }; const back = () => { if (busy()) return false; + if (discardTarget()) { + discardTarget.set(null); + return false; + } if (confirmation()) { confirmation.set(null); return false; } if (page() === 'model') { + if (modelDirty()) { + discardTarget.set('model'); + return false; + } model.set(null); page.set('models'); return false; @@ -1067,17 +1143,18 @@ export function createModelSettings( return false; } if (page() !== 'connections') { - cancelRead(); - editor.set(null); - confirmation.set(null); - page.set('connections'); - void loadConnections(); + if (page() === 'connection' && connectionEdited()) { + discardTarget.set('connection'); + return false; + } + leaveConnection(); return false; } return true; }; const deactivate = () => { cancelRead(); + discardTarget.set(null); if (editor()) editor.set({ ...editor()!, instance: ++editorInstance }); }; const activate = () => { @@ -1171,13 +1248,30 @@ export function createModelSettings( pending, pendingKnown, confirmation, + discardTarget, + discardEdits(request: ConnectionAction) { + if (!current(request) || busy()) return; + const target = discardTarget(); + discardTarget.set(null); + if (target === 'model') { + model.set(null); + page.set('models'); + } else if (target === 'connection') leaveConnection(); + }, + keepEditing() { + discardTarget.set(null); + }, dirty, action, login, loginAction, + signIn, + reloadConnection, editableBaseUrl: () => !!editor()?.provider.descriptor.customEndpoint || 'baseUrl' in configuration(editor()!), modelAction, + modelDirty, + modelProblem, removalAction, loadConnections, loadProviders, diff --git a/products/maka/workbench/tests/connection-login.test.mjs b/products/maka/workbench/tests/connection-login.test.mjs index 50a29bde6c0..c7298d72443 100644 --- a/products/maka/workbench/tests/connection-login.test.mjs +++ b/products/maka/workbench/tests/connection-login.test.mjs @@ -24,7 +24,7 @@ import test from 'node:test'; const boundary = { pending: [], event: null }; const appRoot = new URL('../app/', import.meta.url); const native = { - nsh: `export const signal = initial => { let value = initial; const read = () => value; read.set = next => { value = next; }; return read; }; export const cleanup = () => {}; export const Clipboard = { writeText: async () => {} };`, + nsh: `export const signal = initial => { let value = initial; const read = () => value; read.set = next => { value = next; }; return read; }; export const computed = fn => fn; export const cleanup = () => {}; export const browser = { opened: [], available: true }; export const openUrl = async url => { browser.opened.push(url); return {opened: browser.available}; }; export const Clipboard = { writeText: async () => {} };`, locale: `export const tr = (key, values) => values ? key.replace(/\\{(\\w+)\\}/g, (_, name) => values[name]) : key;`, }; const hooks = registerHooks({ @@ -46,6 +46,8 @@ const hooks = registerHooks({ }); const { createConnectionLogin } = await import(new URL('connectionLoginState.ts', appRoot)); const { createSettingsOperations } = await import(new URL('settingsOperations.ts', appRoot)); +const { createModelSettings } = await import(new URL('settings.ts', appRoot)); +const { browser } = await import('nsh'); hooks.deregister(); const host = { @@ -76,7 +78,7 @@ function setup({ events.push(`adopt:${value.connection.connectionId}`); }, async discover() { - assert.equal(login.state(), null); + assert.equal(login.state().kind, 'models'); assert.equal(operations.busy(), true); events.push('fetch'); if (fetchError) throw fetchError; @@ -92,7 +94,6 @@ test('saved login with cleanup failure preserves the cleanup diagnostic and neve }); await login.start(action); assert.deepEqual(events, ['adopt:connection']); - assert.equal(operations.notice(), 'Sign-in saved in the Host credential vault.'); assert.equal( operations.error(), 'Sign-in was saved. Its original request could not finish its resource cleanup.', @@ -101,25 +102,6 @@ test('saved login with cleanup failure preserves the cleanup diagnostic and neve assert.equal(operations.busy(), false); }); -test('successful login adopts the saved credential before one discovery', async () => { - const { login, operations, events } = setup(); - await login.start(action); - assert.deepEqual(events, ['adopt:connection', 'fetch']); - assert.equal(operations.notice(), 'Sign-in saved in the Host credential vault.'); - assert.equal(operations.error(), ''); -}); - -test('independent discovery failure reports its cause while retaining the saved login', async () => { - const { login, operations, events } = setup({ - fetchError: new Error('discovery result unknown; recover the action'), - }); - await login.start(action); - assert.deepEqual(events, ['adopt:connection', 'fetch']); - assert.equal(operations.notice(), 'Sign-in saved in the Host credential vault.'); - assert.equal(operations.error(), 'discovery result unknown; recover the action'); - assert.equal(login.state(), null); -}); - test('a failed login and cleanup report both boundaries without discovery or saved adoption', async () => { const { login, operations, events } = setup({ cleanupError: 'cleanup failed', @@ -133,3 +115,336 @@ test('a failed login and cleanup report both boundaries without discovery or sav ); assert.equal(operations.notice(), ''); }); + +function connectionEditor({ wrongProvider = false, failDiscovery = false, existing = true } = {}) { + const provider = { + entryId: 'maka.codex', + name: 'chatgpt', + packageId: 'maka.codex', + scope: 'profile', + }; + const receiptProvider = { + packageId: 'maka.codex', + entryId: 'maka.codex', + scope: 'profile', + name: wrongProvider ? 'other' : 'chatgpt', + }; + let version = existing ? 1 : 0; + let configured = false; + const locator = { scope: 'connection', connectionId: 'connection', kind: 'provider' }; + const status = () => ({ + locator, + configured, + credentialId: configured ? 'credential' : null, + revision: configured ? 1 : null, + updatedAt: null, + }); + const detail = () => ({ + kind: 'ready', + target: { + connectionId: 'connection', + revision: version, + slug: 'chatgpt', + provider, + configuration: { baseUrl: 'https://chatgpt.com/backend-api/codex' }, + }, + name: 'ChatGPT Subscription', + enabled: true, + editing: { enabledModelIds: [], modelOverrides: null, requestBodyOverlay: null }, + }); + const calls = []; + const original = detail(); + const api = { + pendingSettings: async () => [], + async query(endpoint, method, input) { + if (method === 'connection') + return input.revision === version + ? detail() + : { kind: 'stale', actual: { connectionId: 'connection', revision: version } }; + if (method === 'providers') return { providers: [], next: null }; + if (method === 'provider') + return { + identity: provider, + descriptor: { + label: 'ChatGPT Subscription', + anonymous: false, + discovery: true, + configurationDefaults: original.target.configuration, + authentication: [{ id: 'device', interactive: true, label: 'Sign in with ChatGPT' }], + }, + }; + if (method === 'credentialStatus') return { kind: 'status', status: status() }; + if (method === 'connections') + return { + kind: 'page', + connections: [], + revision: version, + next: null, + defaultTarget: null, + }; + throw new Error(`unexpected query ${endpoint}.${method}`); + }, + async beginSetting(endpoint, method, input) { + calls.push({ endpoint, method, input }); + assert.equal(input.expected.revision, version); + version++; + configured = true; + return { + next: async () => ({ + kind: 'final', + cleanupError: null, + reply: { + receipt: { + kind: 'recorded', + outcome: { + kind: 'returned', + value: { + kind: 'saved', + connection: { + connectionId: 'connection', + slug: 'chatgpt', + provider: receiptProvider, + }, + }, + }, + }, + }, + }), + cancel() {}, + }; + }, + async settingsMutation(endpoint, method, input) { + calls.push({ endpoint, method, input }); + if (method === 'create') { + version++; + return { + reply: { + receipt: { + kind: 'recorded', + outcome: { + kind: 'returned', + value: { + kind: 'committed', + connection: { connectionId: 'connection', revision: version }, + }, + }, + }, + }, + }; + } + assert.equal(input.revision, version, 'model discovery must use the post-login basis'); + return { + reply: { + receipt: { + kind: 'recorded', + outcome: { + kind: 'returned', + value: failDiscovery + ? { kind: 'failed' } + : { kind: 'ready', connection: { connectionId: 'connection', revision: version } }, + }, + }, + }, + }; + }, + }; + const operations = createSettingsOperations(api); + operations.pendingKnown.set(true); + const owner = createModelSettings( + api, + () => {}, + operations, + () => true, + undefined, + { + page: 'connection', + connections: [], + providers: [], + connectionQuery: '', + providerQuery: '', + modelQuery: '', + revision: 1, + defaultTarget: null, + next: null, + providerNext: null, + editor: { + instance: 1, + baseline: original, + provider: { + identity: provider, + descriptor: { + label: 'ChatGPT Subscription', + anonymous: false, + discovery: true, + authentication: [{ id: 'device', interactive: true, label: 'Sign in with ChatGPT' }], + }, + }, + name: original.name, + slug: 'chatgpt', + baseUrl: original.target.configuration.baseUrl, + enabled: true, + overlay: 'null', + apiKey: '', + }, + model: null, + credential: status(), + discovery: 'idle', + confirmation: null, + initialModel: null, + }, + ); + return { + owner, + operations, + calls, + provider, + changeElsewhere() { + version++; + configured = true; + }, + }; +} + +test('connection login accepts reordered provider fields and refreshes both connection and credential bases', async () => { + const { owner, operations, calls } = connectionEditor(); + await owner.login.start(owner.loginAction('device')); + assert.equal(operations.error(), ''); + assert.equal(owner.credential().configured, true); + assert.equal(owner.action().revision, 2); + assert.equal(owner.loginAction('device').input.expectedCredential.credentialId, 'credential'); + assert.deepEqual( + calls.map((call) => call.method), + ['loginCredential', 'discoverModels'], + ); + await owner.login.start(owner.loginAction('device')); + assert.equal(operations.error(), ''); + assert.equal( + owner.action().revision, + 3, + 'another intentional sign-in must not retain the first basis', + ); +}); + +test('a genuinely different provider receipt remains rejected', async () => { + const { owner, operations, calls } = connectionEditor({ wrongProvider: true }); + await owner.login.start(owner.loginAction('device')); + assert.equal( + operations.error(), + 'The sign-in receipt refers to another connection. Reload before continuing.', + ); + assert.equal(owner.action().revision, 1); + assert.deepEqual( + calls.map((call) => call.method), + ['loginCredential'], + ); +}); + +test('discovery failure keeps the completed login and refreshed editor usable', async () => { + const { owner, operations } = connectionEditor({ failDiscovery: true }); + await owner.login.start(owner.loginAction('device')); + assert.equal(owner.credential().configured, true); + assert.equal(owner.action().revision, 2); + assert.equal(owner.discovery(), 'failed'); + assert.match(operations.error(), /^Signed in, but models could not be loaded\./); + assert.equal(owner.login.state(), null); + assert.equal(owner.busy(), false); +}); + +test('reload advances an externally changed connection instead of querying the stale basis again', async () => { + const { owner, operations, changeElsewhere } = connectionEditor(); + changeElsewhere(); + await owner.reloadConnection(owner.action()); + assert.equal(operations.error(), ''); + assert.equal(owner.action().revision, 2); + assert.equal(owner.credential().configured, true); + assert.equal(owner.dirty(), false); +}); + +test('save and sign in creates the connection before authorizing it, without an intermediate user action', async () => { + const { owner, operations, calls, provider } = connectionEditor({ existing: false }); + owner.newConnection(); + await owner.chooseProvider(provider); + assert.equal(owner.editor().baseline, null); + await owner.signIn({ ...owner.action(), method: 'device' }); + assert.equal(operations.error(), ''); + assert.equal(owner.credential().configured, true); + assert.deepEqual( + calls.map((call) => call.method), + ['create', 'loginCredential', 'discoverModels'], + ); +}); + +test('browser opening failure leaves the same device sign-in cancellable and cancellation is not an error', async () => { + browser.opened = []; + browser.available = false; + const ready = Promise.withResolvers(); + const completion = Promise.withResolvers(); + let progress = true; + const api = { + pendingSettings: async () => [], + async beginSetting() { + return { + cancel() { + completion.resolve({ + kind: 'final', + cleanupError: null, + reply: { + receipt: { + kind: 'recorded', + outcome: { kind: 'returned', value: { kind: 'failed', reason: 'cancelled' } }, + }, + }, + }); + }, + async next() { + if (progress) { + progress = false; + return { + kind: 'progress', + value: { url: 'https://auth.openai.com/codex/device', userCode: 'ABCD-EFGH' }, + }; + } + ready.resolve(); + return completion.promise; + }, + }; + }, + }; + const operations = createSettingsOperations(api); + operations.pendingKnown.set(true); + const login = createConnectionLogin(api, operations, { + accepts: () => true, + async saved() { + assert.fail('cancelled sign-in must not be presented as saved'); + }, + async discover() { + assert.fail('cancelled sign-in must not fetch models'); + }, + }); + try { + const running = login.start(action); + await ready.promise; + assert.deepEqual(browser.opened, ['https://auth.openai.com/codex/device']); + assert.equal(login.authorization().browser, 'failed'); + assert.equal(login.authorization().prompt.userCode, 'ABCD-EFGH'); + login.cancel(login.authorization().occurrence); + await running; + assert.equal(operations.error(), ''); + assert.equal(operations.notice(), 'Sign-in cancelled.'); + assert.equal(operations.busy(), false); + } finally { + browser.available = true; + } +}); + +test('back navigation offers a way to keep unsaved connection edits', async () => { + const { owner } = connectionEditor(); + owner.edit('name', 'A personal name'); + owner.back(); + assert.equal(owner.discardTarget(), 'connection'); + owner.keepEditing(); + assert.equal(owner.editor().name, 'A personal name'); + owner.back(); + owner.discardEdits(owner.action()); + assert.equal(owner.page(), 'connections'); +}); From 787ea727cae6ffea7b9a0d94aba1303322fb406a Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 1 Nov 2026 11:02:30 +0800 Subject: [PATCH 2/3] fix(settings): complete provider picker interactions and presentation Group actual provider facts into account sign-in, custom endpoints and named services. Use flat, sorted rows with keyboard search, clear and contextual Escape, preserve selection on return, and show accurate opening and retry states without duplicate dispatch. Avoid known connection ID collisions, restore search focus after retry, and keep virtual headers and provider rows correct while filtering. Verify against the native provider catalog in both themes and window sizes, include desktop screenshots, and cover late reads and new-connection navigation. Generated-by: OpenAI Codex --- packages/plugin-sdk/src/contracts.ts | 5 +- packages/plugin-sdk/src/generated/runtime.ts | 1 + plugins/settings/native/src/connections.rs | 1 + plugins/settings/native/src/contract.rs | 3 +- .../tests/real_host/desktop_new_session.rs | 123 +++++++ .../real_host/model_settings_preview.tsx | 62 +++- products/maka/workbench/README.md | 4 + products/maka/workbench/README.zh-CN.md | 4 + .../workbench/app/CatalogModelAssociation.tsx | 2 +- .../maka/workbench/app/ConnectionModel.tsx | 1 + .../maka/workbench/app/ConnectionSettings.tsx | 101 +---- .../maka/workbench/app/ProviderPicker.tsx | 344 ++++++++++++++++++ .../maka/workbench/app/locale-settings.ts | 9 + products/maka/workbench/app/settings.ts | 72 +++- .../screenshots/provider-picker-dark.png | Bin 0 -> 32409 bytes .../screenshots/provider-picker-light.png | Bin 0 -> 32734 bytes .../workbench/tests/connection-login.test.mjs | 42 +++ 17 files changed, 658 insertions(+), 116 deletions(-) create mode 100644 products/maka/workbench/app/ProviderPicker.tsx create mode 100644 products/maka/workbench/screenshots/provider-picker-dark.png create mode 100644 products/maka/workbench/screenshots/provider-picker-light.png diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts index 3b7dad0cce1..2b12c395d17 100644 --- a/packages/plugin-sdk/src/contracts.ts +++ b/packages/plugin-sdk/src/contracts.ts @@ -40942,6 +40942,9 @@ export const contracts = { SettingsProviderSummary: { type: 'object', properties: { + customEndpoint: { + type: 'boolean', + }, identity: { $ref: '#/$defs/Identity', }, @@ -40956,7 +40959,7 @@ export const contracts = { }, }, additionalProperties: false, - required: ['identity', 'label', 'authentication'], + required: ['customEndpoint', 'identity', 'label', 'authentication'], }, Identity: { description: diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts index 3ecbe89f19d..db89d44dbaa 100644 --- a/packages/plugin-sdk/src/generated/runtime.ts +++ b/packages/plugin-sdk/src/generated/runtime.ts @@ -6858,6 +6858,7 @@ export interface SettingsProvidersPage { next: number | null; } export interface SettingsProviderSummary { + customEndpoint: boolean; identity: Identity; label: string; authentication: SettingsProviderAuthenticationSummary[]; diff --git a/plugins/settings/native/src/connections.rs b/plugins/settings/native/src/connections.rs index aaa26102750..a1b9cfb3718 100644 --- a/plugins/settings/native/src/connections.rs +++ b/plugins/settings/native/src/connections.rs @@ -164,6 +164,7 @@ pub(crate) fn providers(input: Value, snapshot: Arc) -> Result, diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index 3c6480e2841..ac232d62ee0 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -208,6 +208,8 @@ impl Desktop { }; let directory = std::path::PathBuf::from(directory); std::fs::create_dir_all(&directory).unwrap(); + self.app.update(|cx| cx.refresh_windows()); + self.pump(); let guard = self.frame.lock().unwrap(); let frame = guard.as_ref().unwrap(); let size = frame.size(); @@ -1236,3 +1238,124 @@ async fn desktop_model_settings_keeps_successful_sign_in_and_retries_models() { desktop.close().await; fixture.finish().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn desktop_provider_picker_searches_real_catalog_and_preserves_selection() { + let fixture = Fixture::open().await; + let entry = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../terminal-shell/tests/real_host/model_settings_preview.tsx"); + let output = fixture.directory.path().join("settings-preview"); + let source = tokio::task::spawn_blocking(move || { + let compiled = maka_ui_build::compile(&entry, &output).unwrap(); + std::fs::read_to_string(compiled.module).unwrap() + }) + .await + .unwrap(); + let (theme, appearance, layout, width, height) = visual_case(); + let desktop = Desktop::open_config( + &fixture, + &fixture.address, + size(gpui::DevicePixels(width), gpui::DevicePixels(height)), + appearance, + Some(source), + ) + .await; + desktop.press("Add connection").await; + desktop.target_after(None).await; + desktop.control("Connect ChatGPT Subscription").await; + desktop.artifact(&format!("provider-picker-{theme}-{layout}")); + desktop.type_text("alibaba china").await; + desktop + .until("search retained unrelated providers", || { + desktop + .controls("Connect ChatGPT Subscription") + .is_empty() + .then_some(()) + }) + .await; + let first = desktop.control("Connect Alibaba (China)").await; + let second = desktop.control("Connect Alibaba Coding Plan (China)").await; + assert_eq!(first.left(), second.left()); + assert_eq!(first.right(), second.right()); + assert_eq!(first.size.height, px(40.)); + assert_eq!(second.top() - first.top(), px(40.)); + desktop.artifact(&format!("provider-search-{theme}-{layout}")); + desktop.key("down", false); + desktop.key("enter", false); + desktop.control("Save connection").await; + assert_eq!( + desktop.outcome.lock().unwrap()["selectedProviders"] + .as_array() + .unwrap() + .last() + .unwrap(), + "alibaba-coding-plan-cn" + ); + desktop.press("Back").await; + desktop.control("Clear provider search").await; + desktop.expect_text("alibaba china").await; + desktop.artifact(&format!("provider-back-{theme}-{layout}")); + desktop.type_text("no-such-provider-1793500567423").await; + desktop.key("enter", false); + desktop.control("Clear provider search").await; + assert!(desktop.controls("Connect Alibaba (China)").is_empty()); + desktop.artifact(&format!("provider-empty-{theme}-{layout}")); + desktop.key("escape", false); + desktop.control("Connect ChatGPT Subscription").await; + assert!(desktop.controls("Clear provider search").is_empty()); + desktop.key("escape", false); + desktop.control("Add connection").await; + desktop.event("fixture.failProviders", Value::Null); + desktop.press("Add connection").await; + desktop.control("Retry loading providers").await; + desktop.artifact(&format!("provider-error-{theme}-{layout}")); + desktop.press("Retry loading providers").await; + desktop.control("Connect ChatGPT Subscription").await; + desktop.type_text("responses").await; + desktop.control("Connect Responses").await; + desktop.artifact(&format!("provider-custom-{theme}-{layout}")); + desktop.event("fixture.failProvider", Value::Null); + desktop.key("enter", false); + desktop.control("Retry opening provider").await; + desktop.artifact(&format!("provider-opening-error-{theme}-{layout}")); + desktop.press("Retry opening provider").await; + desktop.control("Save connection").await; + assert_eq!( + desktop.outcome.lock().unwrap()["selectedProviders"] + .as_array() + .unwrap() + .last() + .unwrap(), + "openai-responses-compatible" + ); + desktop.press("Back").await; + desktop.target_after(None).await; + desktop.type_text("chatgpt").await; + desktop.event("fixture.holdProvider", Value::Null); + desktop.key("enter", false); + desktop + .until("provider read did not start", || { + (desktop.outcome.lock().unwrap()["selectedProviders"] + .as_array() + .unwrap() + .last() + .unwrap() + == "chatgpt") + .then_some(()) + }) + .await; + desktop + .until("the opening provider remained actionable", || { + desktop + .controls("Connect ChatGPT Subscription") + .is_empty() + .then_some(()) + }) + .await; + desktop.artifact(&format!("provider-opening-{theme}-{layout}")); + desktop.press("Back").await; + desktop.event("fixture.releaseProvider", Value::Null); + desktop.control("Add connection").await; + desktop.close().await; + fixture.finish().await; +} diff --git a/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx index faf34bdc4bc..48af98fe83a 100644 --- a/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx +++ b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx @@ -25,6 +25,7 @@ import { ConnectionSettings } from '../../../workbench/app/ConnectionSettings'; import { createModelSettings } from '../../../workbench/app/settings'; import { createSettingsOperations } from '../../../workbench/app/settingsOperations'; import { HostProvider, type Host } from '../../../workbench/app/host'; +import { host as native } from 'maka/host'; import { currentLocale, tr } from '../../../workbench/app/locale'; // The provider boundary is controlled by the test; every displayed control is production UI. @@ -59,6 +60,32 @@ function Preview() { let finishLogin: ((value: string) => void) | null = null; let signIns = 0; let discoveries = 0; + let failProviders = false; + let failProvider = false; + cleanup( + events.on('fixture.failProvider', () => { + failProvider = true; + }), + ); + let holdProvider = false; + let releaseProvider: (() => void) | null = null; + const selectedProviders: string[] = []; + cleanup( + events.on('fixture.failProviders', () => { + failProviders = true; + }), + ); + cleanup( + events.on('fixture.holdProvider', () => { + holdProvider = true; + }), + ); + cleanup( + events.on('fixture.releaseProvider', () => { + holdProvider = false; + releaseProvider?.(); + }), + ); const report = () => void events.call('fixture.outcome', { configured, @@ -68,6 +95,7 @@ function Preview() { modelIds, overrides, defaultTarget, + selectedProviders, }); cleanup( events.on<{ result: string }>('fixture.finishLogin', ({ result }) => finishLogin?.(result)), @@ -115,12 +143,26 @@ function Preview() { }, ], }; - if (method === 'providers') - return { - providers: [{ identity: provider, label: definition.descriptor.label }], - next: null, - }; - if (method === 'provider') return definition; + if (method === 'providers') { + if (failProviders) { + failProviders = false; + throw new Error('Provider catalog temporarily unavailable.'); + } + return native.query('settings', 'providers', input); + } + if (method === 'provider') { + selectedProviders.push(input.name); + report(); + if (failProvider) { + failProvider = false; + throw new Error('Provider temporarily unavailable.'); + } + if (holdProvider) + await new Promise((resolve) => { + releaseProvider = resolve; + }); + return native.query('authentication', 'provider', input); + } if (method === 'connection') return input.revision === revision ? detail() : { kind: 'stale', actual: basis() }; if (method === 'credentialStatus') @@ -273,9 +315,11 @@ function Preview() { title: () => owner.page() === 'connection' ? tr('Connection settings') - : owner.page() === 'model' - ? tr('Model settings') - : tr('Connections & models'), + : owner.page() === 'providers' + ? tr('Add connection') + : owner.page() === 'model' + ? tr('Model settings') + : tr('Connections & models'), nested: () => owner.page() !== 'connections', navigation: owner.navigation, back: owner.back, diff --git a/products/maka/workbench/README.md b/products/maka/workbench/README.md index 34f9a59894a..91e50e37d28 100644 --- a/products/maka/workbench/README.md +++ b/products/maka/workbench/README.md @@ -54,3 +54,7 @@ Only skills installed through Maka have an update button. Updating replaces thei **Settings → MCP servers** manages the four corresponding `mcp.json` files through command/HTTP forms. It preserves other entries and custom fields. Selecting a project does not create a conversation. Downloads, file changes and registrations run on the connected Host; saving a project MCP file does not grant it trust. **Settings → Connections & models** keeps provider sign-in, connection details and the model list together. A new or edited connection can be saved and signed in with one action. Device sign-in opens the browser and keeps the code, copy controls and cancellation available in the dialog. Model discovery can be retried independently after successful sign-in. Advanced connection and model options stay behind disclosures; saved connections can be reloaded after external changes, and Back asks before discarding edits. + +The provider picker separates account sign-in, custom endpoints and named API services, with alphabetical choices within each section. Search accepts multiple terms, supports arrow keys and Enter, and Escape clears the query before returning. Returning from a new connection preserves the provider search and selection; adding another connection suggests an unused ID among the loaded connections. + +![Provider picker, rendered in the desktop test fixture against the built-in catalog.](screenshots/provider-picker-light.png) diff --git a/products/maka/workbench/README.zh-CN.md b/products/maka/workbench/README.zh-CN.md index 53a150d6d9e..8330e262bf2 100644 --- a/products/maka/workbench/README.zh-CN.md +++ b/products/maka/workbench/README.zh-CN.md @@ -54,3 +54,7 @@ MAKA_NSH_EVIDENCE=/tmp/maka-nsh-evidence cargo nextest run --locked --workspace **设置 → MCP 服务器**通过命令和 HTTP 表单管理上述四个目录的 `mcp.json`,保留其他条目和自定义字段。选择项目无需创建会话。下载、文件修改和来源登记均在所连接的 Host 上完成;保存项目 MCP 配置不会自动授予信任。 **设置 → 连接与模型** 将提供商登录、连接信息和模型列表放在一起。新建或修改连接后,可以一次完成保存并登录。设备登录会打开浏览器,对话框保留验证码、复制和取消操作。登录成功后,获取模型失败可以单独重试。高级连接与模型选项折叠显示;已保存的连接可以重新加载外部更改,返回时会确认是否放弃未保存的编辑。 + +提供商列表按账号登录、自定义接口和命名 API 服务分组,组内按名称排序。搜索支持多个词、方向键和 Enter,Esc 先清空搜索再返回。从新连接返回时保留搜索和选择;再次添加连接时,会在已加载的连接中避开重复标识。 + +![提供商列表:桌面测试使用实际内置目录渲染。](screenshots/provider-picker-dark.png) diff --git a/products/maka/workbench/app/CatalogModelAssociation.tsx b/products/maka/workbench/app/CatalogModelAssociation.tsx index ded6621077a..01c117ef68b 100644 --- a/products/maka/workbench/app/CatalogModelAssociation.tsx +++ b/products/maka/workbench/app/CatalogModelAssociation.tsx @@ -155,7 +155,7 @@ function CatalogModelPicker(props: { if (!alive || request !== generation) return; models.set(more ? [...models(), ...result.models] : result.models); after.set(result.after); - if (!more) list?.scrollToIndex({ index: 0, animated: false }); + if (!more && result.models.length) list?.scrollToIndex({ index: 0, animated: false }); } catch (reason) { if (alive && request === generation) failure.set(renderText(errorText(reason))); } finally { diff --git a/products/maka/workbench/app/ConnectionModel.tsx b/products/maka/workbench/app/ConnectionModel.tsx index 29feeb70d4c..dac908775a2 100644 --- a/products/maka/workbench/app/ConnectionModel.tsx +++ b/products/maka/workbench/app/ConnectionModel.tsx @@ -62,6 +62,7 @@ export function ConnectionModel(props: { settings: ModelSettingsOwner }) { @@ -294,89 +294,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - { - owner.searchProviders(text); - if (owner.filteredProviders().length) - providersList?.scrollToIndex({ index: 0, animated: false }); - }} - /> - { - providersList = node; - }} - each={owner.filteredProviders()} - keyExtractor={(item) => - JSON.stringify([ - item.identity.packageId, - item.identity.entryId, - item.identity.scope, - item.identity.name, - ]) - } - estimatedItemSize={48} - onLoadMore={() => { - if (!owner.error()) void owner.loadProviders(true); - }} - empty={ - - - {owner.providerQuery().trim() - ? tr('No matching providers.') - : tr('No providers available.')} - - - } - style={{ flex: 1, minHeight: 0 }} - > - {(item) => ( - - )} - - - - + @@ -556,11 +474,13 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - - - {tr('Loading settings…')} - - + + + + {tr('Loading settings…')} + + + ); } @@ -577,6 +497,9 @@ function Search(props: { value={props.value()} onChange={({ value }) => props.change(value)} placeholder={props.placeholder} + autoCapitalize="none" + autoCorrect={false} + submitBehavior="submit" style={{ ...inputTextStyle, color: C.text, diff --git a/products/maka/workbench/app/ProviderPicker.tsx b/products/maka/workbench/app/ProviderPicker.tsx new file mode 100644 index 00000000000..6852b369e5c --- /dev/null +++ b/products/maka/workbench/app/ProviderPicker.tsx @@ -0,0 +1,344 @@ +/* + * 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. + */ + +import { + cleanup, + computed, + effect, + inputAction, + keymap, + List, + platform, + Text, + View, + type InputRef, + type ListRef, +} from 'nsh'; +import { Button, Input } from 'nsh/base'; +import type { SettingsProviderSummary } from '../../../../packages/plugin-sdk/src/index'; +import type { ModelSettingsOwner } from './settings'; +import { Icon } from './Icon'; +import { tr } from './locale'; +import { + C, + captionTextStyle, + controlStyle, + controlHoverStyle, + controlFocusStyle, + controlDisabledStyle, + inputTextStyle, + textTooltip, +} from './ui'; +import { sameValue } from '../../../../plugins/shared/value'; + +type Row = + | { kind: 'heading'; key: string; label: string } + | { kind: 'provider'; key: string; provider: SettingsProviderSummary }; +const identityKey = (provider: SettingsProviderSummary) => + JSON.stringify([ + provider.identity.packageId, + provider.identity.entryId, + provider.identity.scope, + provider.identity.name, + ]); + +export function ProviderPicker(props: { owner: ModelSettingsOwner }) { + const owner = props.owner; + let list: ListRef | undefined; + let input: InputRef | undefined; + let removeKeys = () => {}; + cleanup(() => removeKeys()); + const disabled = () => + owner.busy() || + !owner.pendingKnown() || + owner.pending().length > 0 || + owner.openingProvider() !== null; + const rows = computed(() => { + const providers = [...owner.filteredProviders()].sort( + (a, b) => a.label.localeCompare(b.label) || identityKey(a).localeCompare(identityKey(b)), + ); + const groups = [ + { + key: 'account', + label: tr('Account sign-in'), + accepts: (p: SettingsProviderSummary) => + !p.customEndpoint && p.authentication.some((a) => a.interactive), + }, + { + key: 'custom', + label: tr('Custom endpoints'), + accepts: (p: SettingsProviderSummary) => p.customEndpoint, + }, + { + key: 'api', + label: tr('API providers'), + accepts: (p: SettingsProviderSummary) => + !p.customEndpoint && !p.authentication.some((a) => a.interactive), + }, + ]; + return groups.flatMap((group): Row[] => { + const items = providers.filter(group.accepts); + return items.length + ? [ + { kind: 'heading', key: group.key, label: group.label }, + ...items.map( + (provider): Row => ({ kind: 'provider', key: identityKey(provider), provider }), + ), + ] + : []; + }); + }); + const choices = () => + rows().flatMap((row, index) => + row.kind === 'provider' ? [{ provider: row.provider, index }] : [], + ); + effect(() => { + const items = choices(); + if (!items.some(({ provider }) => sameValue(provider.identity, owner.selectedProvider()))) { + owner.selectedProvider.set(items[0]?.provider.identity ?? null); + if (items.length) list?.scrollToIndex({ index: 0, animated: false }); + } + }); + const selectedIndex = () => + choices().findIndex(({ provider }) => sameValue(provider.identity, owner.selectedProvider())); + const reveal = () => { + const row = choices()[selectedIndex()]; + if (row) list?.scrollToIndex({ index: row.index, animated: false }); + }; + const move = (delta: number) => { + if (disabled()) return; + const items = choices(); + const row = items[Math.max(0, Math.min(items.length - 1, selectedIndex() + delta))]; + if (!row) return; + owner.selectedProvider.set(row.provider.identity); + reveal(); + }; + const choose = () => { + if (disabled()) return; + const row = choices()[selectedIndex()]; + if (row) void owner.chooseProvider(row.provider.identity); + }; + const search = (text: string) => { + owner.searchProviders(text); + owner.selectedProvider.set(null); + if (rows().length) list?.scrollToIndex({ index: 0, animated: false }); + }; + return ( + + + search(value)} + onSubmit={choose} + submitBehavior="submit" + style={{ + ...inputTextStyle, + flex: 1, + minWidth: 0, + height: 32, + paddingHorizontal: 12, + color: C.text, + placeholderColor: C.muted, + backgroundColor: C.background, + borderWidth: 1, + borderColor: C.line, + borderRadius: 8, + }} + focusStyle={{ borderColor: C.accent }} + ref={(node) => { + input = node; + node.focus(); + removeKeys(); + const remove = [ + keymap.bind('up', () => move(-1), { within: node, when: 'always', repeat: true }), + keymap.bind('down', () => move(1), { within: node, when: 'always', repeat: true }), + keymap.bind( + 'escape', + () => { + owner.cancel(); + if (owner.page() === 'providers') input?.focus(); + }, + { within: node, when: 'always' }, + ), + ]; + removeKeys = () => remove.forEach((unbind) => unbind()); + }} + /> + + + + + { + list = node; + reveal(); + }} + each={rows()} + keyExtractor={(row) => row.key} + estimatedItemSize={40} + onLoadMore={() => { + if (!owner.error() && !owner.openingProvider()) void owner.loadProviders(true); + }} + empty={ + + + {owner.providerQuery().trim() + ? tr('No matching providers.') + : tr('No providers available.')} + + + } + contentStyle={{ paddingRight: platform.isTerminal ? 0 : 8 }} + style={{ flex: 1, minHeight: 0 }} + > + {(row) => { + const heading = () => { + const item = row(); + return item.kind === 'heading' ? [item] : []; + }; + const option = () => { + const item = row(); + return item.kind === 'provider' ? [item] : []; + }; + return ( + + item.key}> + {(item) => ( + + {item().label} + + )} + + item.key}> + {(item) => ( + + )} + + + ); + }} + + + + + + } + > + + {owner.openingProvider() ? '' : tr('Loading providers…')} + + + + + ); +} + +function ProviderOption(props: { + owner: ModelSettingsOwner; + provider: SettingsProviderSummary; + disabled: boolean; +}) { + const opening = () => sameValue(props.provider.identity, props.owner.openingProvider()); + return ( + + ); +} diff --git a/products/maka/workbench/app/locale-settings.ts b/products/maka/workbench/app/locale-settings.ts index 42dc7faf58b..26fca577090 100644 --- a/products/maka/workbench/app/locale-settings.ts +++ b/products/maka/workbench/app/locale-settings.ts @@ -59,6 +59,15 @@ import { } from '../../../../plugins/shared/tui-messages.js'; export const settingsMessages = { + 'Retry opening provider': ['Retry opening provider', '重试打开提供商', '重試開啟提供商'], + 'Account sign-in': ['Account sign-in', '账号登录', '帳號登入'], + 'Custom endpoints': ['Custom endpoints', '自定义接口', '自訂介面'], + 'API providers': ['API providers', 'API 提供商', 'API 提供商'], + 'Clear provider search': ['Clear provider search', '清除提供商搜索', '清除提供商搜尋'], + 'Retry loading providers': ['Retry loading providers', '重试加载提供商', '重試載入提供商'], + 'Loading providers…': ['Loading providers…', '正在加载提供商…', '正在載入提供商…'], + 'Opening…': ['Opening…', '正在打开…', '正在開啟…'], + 'Clear default selection': ['Clear default selection', '清除默认选择', '清除預設選擇'], 'Discard unsaved changes?': [ 'Discard unsaved changes?', diff --git a/products/maka/workbench/app/settings.ts b/products/maka/workbench/app/settings.ts index da9c64c61db..69bb2b4847c 100644 --- a/products/maka/workbench/app/settings.ts +++ b/products/maka/workbench/app/settings.ts @@ -169,6 +169,9 @@ export function createModelSettings( const connectionQuery = signal(restored?.connectionQuery ?? ''); const providerQuery = signal(restored?.providerQuery ?? ''); const modelQuery = signal(restored?.modelQuery ?? ''); + const openingProvider = signal(null); + const selectedProvider = signal(null); + const failedProvider = signal(null); const revision = signal(restored?.revision ?? null); const defaultTarget = signal<{ connectionId: string; @@ -271,6 +274,7 @@ export function createModelSettings( read += 1; loading.set(false); modelLoading.set(false); + openingProvider.set(null); }; const loadConnections = async (more = false) => { if (!alive || !active() || loading() || (more && next() === null)) return; @@ -329,6 +333,7 @@ export function createModelSettings( const generation = ++read; loading.set(true); error.set(''); + failedProvider.set(null); try { let rows = more ? providers() : []; for (;;) { @@ -361,6 +366,8 @@ export function createModelSettings( const newConnection = () => { if (busy()) return; editor.set(null); + providerQuery.set(''); + selectedProvider.set(null); discovery.set('idle'); model.set(null); modelQuery.set(''); @@ -371,11 +378,25 @@ export function createModelSettings( page.set('providers'); void loadProviders(); }; + const suggestedSlug = (identity: SettingsProviderSummary['identity']) => { + const base = + identity.name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'connection'; + const used = new Set(connections().map((connection) => connection.slug)); + let slug = base; + for (let suffix = 2; used.has(slug); suffix++) slug = `${base}-${suffix}`; + return slug; + }; const chooseProvider = async (identity: SettingsProviderSummary['identity']) => { - if (!alive || !active() || busy() || page() !== 'providers') return; + if (!alive || !active() || busy() || openingProvider() || page() !== 'providers') return; + selectedProvider.set(identity); + openingProvider.set(identity); const generation = ++read; loading.set(true); error.set(''); + failedProvider.set(null); try { const provider: SettingsProvider = await host.query('authentication', 'provider', identity); if (!alive || !active() || generation !== read) return; @@ -392,11 +413,7 @@ export function createModelSettings( string, unknown > | null; - const slug = - provider.identity.name - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'connection'; + const slug = suggestedSlug(provider.identity); editor.set({ instance: ++editorInstance, baseline: null, @@ -412,9 +429,15 @@ export function createModelSettings( discovery.set('idle'); page.set('connection'); } catch (reason) { - if (generation === read) report(reason); + if (generation === read) { + failedProvider.set(identity); + report(reason); + } } finally { - if (alive && generation === read) loading.set(false); + if (alive && generation === read) { + loading.set(false); + openingProvider.set(null); + } } }; const readDetail = async (target: ConnectionVersionBasis, afterCredential = false) => { @@ -1097,6 +1120,17 @@ export function createModelSettings( page.set('connections'); void loadConnections(); }; + const leaveEditor = () => { + if (editor()?.baseline) leaveConnection(); + else { + cancelRead(); + editor.set(null); + confirmation.set(null); + page.set('providers'); + error.set(''); + notice.set(''); + } + }; const connectionEdited = () => { const form = editor(); if (!form) return false; @@ -1105,11 +1139,7 @@ export function createModelSettings( string, unknown > | null; - const slug = - form.provider.identity.name - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'connection'; + const slug = suggestedSlug(form.provider.identity); return ( !!form.apiKey || form.name !== form.provider.descriptor.label || @@ -1147,7 +1177,8 @@ export function createModelSettings( discardTarget.set('connection'); return false; } - leaveConnection(); + if (page() === 'connection') leaveEditor(); + else leaveConnection(); return false; } return true; @@ -1228,6 +1259,9 @@ export function createModelSettings( providers, connectionQuery, providerQuery, + openingProvider, + selectedProvider, + failedProvider, modelQuery, filteredConnections, filteredProviders, @@ -1256,7 +1290,7 @@ export function createModelSettings( if (target === 'model') { model.set(null); page.set('models'); - } else if (target === 'connection') leaveConnection(); + } else if (target === 'connection') leaveEditor(); }, keepEditing() { discardTarget.set(null); @@ -1321,6 +1355,14 @@ export function createModelSettings( activate, deactivate, reset, + cancel() { + if (page() === 'providers' && providerQuery() && !openingProvider()) { + searchProviders(''); + selectedProvider.set(null); + return false; + } + return back(); + }, back, }; } diff --git a/products/maka/workbench/screenshots/provider-picker-dark.png b/products/maka/workbench/screenshots/provider-picker-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..bacfd9ede396bcd71bccdfd125621d3da1015f4b GIT binary patch literal 32409 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%0D+esQ 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 wo||;PREo@)c6j;xcX(R=uXO~&HhV!Z literal 0 HcmV?d00001 diff --git a/products/maka/workbench/screenshots/provider-picker-light.png b/products/maka/workbench/screenshots/provider-picker-light.png new file mode 100644 index 0000000000000000000000000000000000000000..54f53b2b0d024615f05af853ce349cd5240b391a GIT binary patch literal 32734 zcmd?RbyU`C*ENcpEnrY0C@mr&AR*l-2uMpyE8X2GB`PThNOzZXcXxMpclTNB=XuBZ z^1R;}FBDH zY3k8G&jj94{`KSK^Ow>_hiD=p2=~s2a+Xj3IMUv^ydm0|*0HhCu{oGt+UeA3KtV$z zhch8qJ&G@~zWwEIH29&1@_X*~%{YnK?aLMh8~prYgF*l=Z#e(iB#!VeWO&z$=7tC_ zdfv5n;H8j%7JmN!cDLx4SE?BI?tPcjA9cd zC2dAp+B0!hvp-`-Uc(|rERj7FMl3#E8sGEZ351JbV*VU2_#1ZlJbQ4)%*?Dp6rkLN zor#0>ButC`y_}rfTNe}fDRZCT(W6J%%G3wv0=u!8nnygtz zw2CG@D`Q^P?6;T{63N*ayOo&Tjo@b58Z9rttC$1nP6Bu%qUiP2a> zPkA4+m5M(xg!BHw^)N$<$ z^3G7gzju$)-d-^Y6{1V$jkGkMnwmTxpNL$xxaK{A3S;3tqwY7;Xr2Mn{S2#_ZNWqwfNIO;iLxTSPexu@2Uoq-`9sE0d z+ghkm(3G5<>}4~MmKDYKfRZvR369F7X@nzME+VJ7Sb z!^5IUdZAn*Z&F$-b>V2_`CeGqMLi7m>U}IMEWAI{`~~Sg!Ts!z7i5iloRsi6FE%D7 zrtm#;h>01?OH7M|$7m44o`HlDC1Vp4 zuGQCL1-j18SHFJ!;t8Rs{usZue{}Gml<0$Fyp2(+NR)TLHy@vksmzhKUYJ6d$j~oe5U7kX_;SwFpB$Uv6%GE9z1bMK%3YwkgHVfpP|#_1dVYSt*u%p^y~>4# zf|_~|lbP%ZHyw&23S4L*bAPS*;i<6jy*4RQ@0-Bk`T2R6JoiXwNd=>7%;NL&8D7$x zo$lTkPL7P6?EXybiRBFo4z@JM-J9E@SI_z&sin0T-yo!+p;4-m^W(>lY`?D2Jk7`0 z*y&XPMBE$>o2ONdCqI0Aw6(P_4%Ze~?4?I?RMV@d6>>yGC3i-8KIU0(R9zhFYH6jV zr~Biv8%&nw4H8zUXT3q$JE*YPA53p-d`BLfo;aYnpCp`;lA=y99T?A_ZYY4VHkrKP zxwmk0s=4MZN_As?3FE67&AoD#GT*iMeL??<%h<$Cb7lMLZ5;_>G=Yn($DKQ@C?sO! zmdBflbtEwD&qi|2Mo&a^GB>-a(jcp>mbk-m=Wj+U%-iXI#DDGL}Cv`=HR$UO#Ev?@8SMEVk$5 z=z!Go>B&}RR+f>mu>i{VQb$%&Qjd%eYS%ggDP5izG&I>8*ViY{cdm<^?5(Yf;=JhO z)4W?2vK1@*JC+Re_3x^j)VR`F{D{OSdBH<6cO1>>#N)cuE(gz}y}dn*PSv;4@nmw0 zF+V>avV`GqPWnVQ+|HJfaf$Ji2%!W@+4aqdoKps5xAtM<2^123?}y)EpB0%BQy6`RsC zpu^liQp$0#+BwBg|y zqmXDem#XbnKKrP6s^R58#O?4aG*l49oRC{dPA&w4!DR9ynKl{Ux2UYmtF*KgIyDSF za>}+wAzJURJPMgjg)e22b;wEnU{wihG3_>!2ChXsJJ z9XE@yzJz|mWz6HY(3*#3r4s$+%ebi>L%5iwWx=>T+Cp`vhFUKp2&&qoBQH0I)y52k6db9WE_{!NH`SDAjrgXZPLjust-(VlDplYmn8H>h_96geA_T766=_GYBP$;QLXVRUYu0 zQe|ajRoZmLDlPx{lcg$#X?M6PB`qzz(j8qkq*ABh_KB{hTWA5Uf%NpFHvPNm{dZ@J zq$P3rA2)lVCsL`Mbd0d0e&iBNKe;;DLqw@xuEDiW&3TV!HeuadjUnn;;)Gsa>rsAysD0MtxY}gnZ zSUvD#t-YN)ywl;&ey^NL#in*`rS#cb>JC?|Eb%79sFA_s#Wbg&eC>8FD)!BEs+^gm zab>H4-Qj4RjEqdKBE|CXqk{udY>(~9?t8II+pdu!UY9Uwdoodr{HM<4lnj|E87}EIvqx60tTEo>s0KHL*o`=IQ`mA|fIn)yc`r z@7q-#;j#zu_Io*4HkJuf}`Qsq@)+u3w}ic z0s?MsZ#@I2s~m@_2!rZA{XX2VJ!=nHTwFv(Cwhv9N2^q5*7PHUPQAKtV@6#>B%`oU zRzhNXC{sb!nO$+LMPZT>f z1%>V^y)G{9T0+8j6^&Bi$LdYVeNwBne(mAT@QRa7cIWfMWYMtSVmkWzlq6cGX1e_R z{Cuq|-Xr;Pq8WsYmOkIHC}>sp9jBVZ#mJ}rxgi(0p-V2~7&nh{L1~&7$uDkp)F)<0 z=;^;T%wh0#b{SBFtuwG}JHq{pD~p&;GBGh(!L;S$o8O%yO;1mU*%85GJf1m{>rcc@ zo_5o8|L7q;i!4B?#x|Mn_wEs(rHY2pc6T>4mzGy=RGH3TQTP*cI&4l~UBC^(q;VL3B@Moz@OG%17jlld#_*k;oi*i%^ZOe5W52@jaUAZ#p54&U5RADU%S!}= z!QklNu$WM>i1UZYOh}z2p4?Ex=#k@%h$r5HgIE7;>45N0kr2Vt`Cp}+h+efNfa@1( zcn{YaH+CLV8=sK%{$KC(PyN%8U!}tr|L28|-#$BmdSXh-*N&w0>w>{uaXAlLIhf!q6cMb|5a*aBIL zyrS%gnWZ?tIEK~rdZDxwiKsSFD8S)ljo+9~vE1tB`ii&G2R>2ysKoK`soBPGHt}?6 zoKMZo^@WThoq8F=SeIW)4uD@g$JyPJlbeMe-e>~C+_SUjz1`lvzHoL+9Sx1=m_bJy z!(G2$FSHcn#s-=#Zz@7^Y4X7_o-ABzVFC2oyV!fAOch%{XU1YY&1Jhi`MKe%6q(HA zj zUtFFY*gk&_Q0?mS^2Li6IG5*{9v+A!i%mXHB3VrRzkipKm$!-JvY)MW^9j zuCF$5Or{I<$MUs3F>KKTc6WCXFHS0Ad7?w2tDIZVGTd9j8R1GsE-%Lo3~tc{)k9QN zIa%4mqdi2+2 z$jQmo$}Ezn_19NdTRS==qnPwt{0R}Gqj;>DD=RBmOvekYH-=?WB`?m;2uEvN8@{4) z+H7}rbWqdLsTbPiWoKvK6d6Lm;M^Q5TUaoVXl<2SQ&CYVR8^)(gj;mY#2T!O7a30T z@Ko4rA08eqjtpk0*HoL(RoLuoPgh-Z;j<0{M6#W!hB3M+Il+Jo(TD6G6-99O?p<8m z@PKV@%jM3)wE<4+^`xvUF6Z&Wjxaiz0mQ4VO&A5hMG!`6)mM{bp76{|#baL4&}=QP zew3B{6&Yqr&GhRQqp{)Q;#x8?Dl1O}1_nN%Q}dju=5{`}f=}`BBmD_Ed3?X0{n>yo zCaawtrzgt*ph5=sCuCgiPZk}_L3fmtl+ zFE5&9x|))Mo>5k^i%t-5uVrRTO-m0(XdrJokmWEMr zQj%7{_EZBOzvJQR-qsF2WT9XsX{cn+1{n)PMMNzvEI4ep#i}+3(`CfP#g&xsNZ!eb z`%yjCETE>QPB*q@W=8UOi}Lf=udJ&+HZ%${_Or8tm2)H{cb1Ejo!HROl(@KOA2rzQ z=l`}I(5sr3k&%JbEaca(v@qlGLSaeC)MDPvsp)B#>B}>J35jJLon;)H6moLw$oYe_ zn#*%)?}l&|%F0Vfn0+rD8iLz_Cww2KqJfT< zR#H+50M#OssR$~g)Z~z$p!$`ASYFp^t+jqK(XjjLD=RAxiiOHcjYdoN&W|=ybaYsT zhsVdqV_jS!u|^2Yevp@^ef8=UC5`b=esY{y&vezjy*<~fqbEmwqS*3gY(=qq8X85h zb*3Hy+z)@A4=@g?nB=NepbLFL_hnr&u$6FKepFdMb#!Qx&s!Nh4 zLiGU41bJmrLTV}ngz?4oG0J){&$DN>kX@vt^5in*d($?S6twf)cOJ1KFFC)3s~S^9z)BkzG7i zr)CgC3oQZ2Xn2b|JGqI81L-oUP{C9IqTUaoE@NjG{QC73j!(zZa8+NtPymr?xz$*e zqph0SvZPE`|j=@{0;SK z?U1&&iHP7Z@W`esOlMG@JgM_&D^tlS^J;=;G*fARaF`84>^g7@dm^TT?vrS(_5%Hp z-y#|HvA`z#X{52q=RC_6G?qwl6!S=~v!jg>%@#jRhH#tWVwqHltCUcv99T_8|L7kq z0~CO)>ThKZ#S4Y!<bBoqAE)wJYfm^U1-_UP-cnM{?BjgC)On)92T&bVF`xilc9 zx*-Cw6CqR74@^sNW7&yODwzkd&^N|Jcq;_$X0vI^Us>y6x+a+@tE zrVPPRlmb&DW`u@oNQl@+^5u09MOwKmY zG?tbY28b=+Fo6kS)a}a1$zkaoQ7^Hfv9^7{e3NIf)DD?4OSMEoNy(wKpddMUiB(k& zg8s%IlYrn-9J#QsS6WK`ay5t5l;^tko?ZeFf}Y$!(CYc-TkiJ{Ogrlz!n~CIzwy+6 z&yX{v*OHv-{LO@Ti@f4~BB7B7pCKwE&ec}{JL<<|_U*@WGbZBy_!0^zQwRE&OiBp} z3D&NKK*r7Tp4Smm8L>Q&*%K!WZqq7ItiR>xn5cQL)$=qoj5;luRAFOCCIR74^q#uu zDc<>9pqP*#MjO_$u=jV(b0j}pAnh2!;iG>DPr}vmCBAp>JiBm6{$@P5y>4Vz00{_J zyW+RJ^M5^N|DAAyLy+3mO&r%cJ4Iq#ghGSq>@-}li;fuP*YdwQ(mYD5;$=%%vUKp- zid87fqd&av7t<+Bj!Jp1{Hsl`^`C7mRET)Va;aYBIGwGzyQq@kp#9YUDoXVr0B84J z!wPY>aIpoUY-1n`JORAlFN6Eu3K#`tNA>V3In{r(D~v0<>rbQbLw50_YZ(=(E^LGn z{W5qjEHnQHfni5T!ZH?HOp?NkMB1rlk~HGsEs3hW7VuM~1d9qjXAQ2MyNPp33V*o# z&+)OP$TgF~eQ!MWbsn)L#8`?|h%&zsf_-+y;a36fmB3|8Hir8d%-Cb7y_oR16PCDu zjo@2LMd`Vr-Mkn`X1yri8E{b#K4u9XCiSu4Mbs1BDn?8`6n-vh_GghD3RgivF=3%0 zqVK;M7D^Qq(y#TWBN93ja#>`QM{qhezu>^T5lamTk+ak-8=_V()A4bu_8&l)_(}lf zNUbCun?&qhX9x9D9-~hcHW7f&?y&Ov6CHd!-4G*d8ctIDz04vp$`7Z+a2G60;zP0D zd*0OgK|vu^xdTUfT$NKCk2fQuoa(p_>vI4Q&M5(BuruYldDiimT;^S> z_c7ZD=23IO>WQoG#j#UR;NiO5Pxs2G>>|b77^EXSTSDeni^E&HoF{6)ACr+Krw$me zVzfA(Bu@3HL@l%U<1Hq(?0Ngg#4tN+jBdR*5MJtv{4t=*1*y4?BFQk!A6Ra83Fq9Y zO5&qGrAe~#g5!;)VIj$sMav24W8^e6!Cg9Cv5ZH*yjKcz>B_iF4DZ@=DvKgk5usiP z-Xip++Si{a->cW)BL_rj6^vz2iL4uBIA1)aZ0*40lPHgMJ<8CV{EgY4tpw{RkMr_8 zZ^x57#aE^?2v`eh#ly=2O-%S0$y=<=Ck&U@?hdm8pZ;zThn6D8U?an)H{&^1qorAC8B(vZ4M7 z2@FI*t*OxOh-pmiu{_w1ZKN0*Q?gncd@G=+pS58|6ySJb@;JC{vhsST+S!+gSJ>{5 z&8;E*mFt0EZ$!B84BA#vTF-?V_fduI#&a?{2VI+7= z9z0f&`gSJP@tC;6awW!Jf|alJyUdD26bD6`ytI;h-5ddj1qU+?e_oM$Wo6c^ zW@LZ9_SS;9dJV0Ej8vb}+v^5@rD7wJQJl};cI|F*3{hI!+V2;`lL!q788VK-kd~F5 zOg^ZYN&FcUL`9eDESP0ZoH${-EzrW{a*&cx9${!QuJD0^m98c~U*zrE?J37+RO1O= zaYaFYb}K&5+1?FNBvc&yn$%cPfX`z01;qpNVzte$HZYV%X?5L(m}hWpgO&yc*^DWt z_VvjK@3qRlPkzwzBWZEDP?Q zv(^_@>*lPsCf|aX`61QX@Gg#M z;h)`*xTT<=6ZRCZU-53+vu?+O9W@FjvI;WkBMbgh-h)GOH>b`nkbpUD6LZc_ZPy0l z9ZP7{&D~(QJQE>;fkLf_EjwkSy;Ysan6EN+js^=KZqY_Fji(h27~q>RzF?*yrQts4 z$5!`aF@Qvfjh(Hk-r7O;l9qN49mVCCE4*WAX!mC4yZ-ZsUu{OZf1V^@1;=P>uS}NW zj!R@_XJvINpi!RBB{nAM-u23z>`}{_8+nR5T+@~aF?i~kG#j`(KOe+tyVJtsKQfY8 zwd9VGFu~8cw&tVT6YTf!XdN}(u_OocCpruGR?8F27E9r zd9%E{v=!01-gquLiB{IeZim9<@is41BTOK33!##FevCK~6&B~++HS-6Y-Kdl-B;J# zveOLd9Y?3xI+BoGqt#wASCgU#I5rw_zqy9$&v*DlWfnTiI-1CmMSf%pRXmh4$_`Zj z@Ug@_y9n32;oiNfg{1{LGP0qL_oiQ}P>VVSlX{LKN%Hgi%T2>PuT9@KAjcjYayT~! z_fT+5Rb-?OclToOeMKYs(6FLjx~h7;?3a+>zR~)bm=+23PYES0CAaGymA4EfH7WOK zdQEu*6^YhN-2y!4P&(B&au?ct`_)x`g8om%Q0}4Kt65xPb?ug2uf~GdMrsDL#dQke zxSn46%NphVQ<3JH@R%?5#(1kRW0W2F$?*{zwzWs)PsqrDQjAz}_)eP$`oQQipyMHS7 zlaBXAg8tuV)_3Ja)UH}?mFq9P$%}I7)Rm6)TEXZ9f`p9?^9ID#w{pFo$VZhjDdar> zCYz|q-Cj7KcFIUDwT1+Y}&MCs*o+^`%T(cN3I-#||5s$x0U!%7_A)@MjnYKJM zTagdfi|S2T|3Tpst~G`_vwZw#d1=q%*<2Ijk8mNqn_vt;V0vU%$PTanZ=EW7Ht5&4 zkSbo2{^=l6?93a zjI6BHP71eWd1Pg!laBmw9e>L_)+69XzM|r4&CG;i%x_tCbxfp!c&6!njMWmLKT|!` z@OxCwkVU0@*JN5_-IqNlQRNj)XR~C)`r;~v1@$XBx*E|H2_KwJUZ=x%Vw;J-M}=UW zv()Lqu)3OW>L6{AsJ1)Hm+O#c84e{?je=$Svdrmj2>bTCcQIT`d+SGrh|X2>^BTb} z&V(VnQPHz@38yLvu2(#R)Uc-vgS zUCHRL;i4xHJ4CUwnslySIocE&XXa^N(9(uogjfIN!MEHQQEk649yO_Qv%!i(^E)*) zlhLR(u3X;(!=n8vD>QVukN*NTa%t+|BtQQ7NkK|#Z`v6d?tpl-LPkx3h2-^`jGwE{ z(*UBDwzl@|nSzGl=Jth7cA|h2;vCRl-e5huti>cE>MOC$Wm#X}nlSX5yq|dA!=3;9 zaBZj}>}P&ZkhAMVi{yMPkK;Al_V*3FCyI)=ocPq%%tJx}3noZNCmSOplb!Db1s(c5 z^FYHc*_pf&Q%AenDy*>SGs=w593bVSSdkgBIN1OAN{W(_Qgw<*cQRmOZ5_qtuGb{6 zi3F>Q�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 J7QO!9{{a}vv(^9r literal 0 HcmV?d00001 diff --git a/products/maka/workbench/tests/connection-login.test.mjs b/products/maka/workbench/tests/connection-login.test.mjs index c7298d72443..89fc9abd9c1 100644 --- a/products/maka/workbench/tests/connection-login.test.mjs +++ b/products/maka/workbench/tests/connection-login.test.mjs @@ -297,6 +297,7 @@ function connectionEditor({ wrongProvider = false, failDiscovery = false, existi owner, operations, calls, + api, provider, changeElsewhere() { version++; @@ -448,3 +449,44 @@ test('back navigation offers a way to keep unsaved connection edits', async () = owner.discardEdits(owner.action()); assert.equal(owner.page(), 'connections'); }); + +test('a new connection returns to its provider search without treating its suggested unique ID as an edit', async () => { + const { owner, provider } = connectionEditor(); + owner.connections.set([{ slug: 'chatgpt' }, { slug: 'chatgpt-2' }]); + owner.newConnection(); + owner.searchProviders('chatgpt'); + await owner.chooseProvider(provider); + assert.equal(owner.editor().slug, 'chatgpt-3'); + owner.back(); + assert.equal(owner.discardTarget(), null); + assert.equal(owner.page(), 'providers'); + assert.equal(owner.providerQuery(), 'chatgpt'); + assert.deepEqual(owner.selectedProvider(), provider); + owner.cancel(); + assert.equal(owner.providerQuery(), ''); + assert.equal(owner.page(), 'providers'); + owner.back(); + assert.equal(owner.page(), 'connections'); +}); + +test('returning while a provider is opening discards its late reply and allows choosing again', async () => { + const { owner, api, provider } = connectionEditor(); + const queried = api.query.bind(api); + const release = Promise.withResolvers(); + api.query = async (endpoint, method, input) => { + if (method === 'provider') await release.promise; + return queried(endpoint, method, input); + }; + owner.newConnection(); + const opening = owner.chooseProvider(provider); + assert.deepEqual(owner.openingProvider(), provider); + owner.back(); + release.resolve(); + await opening; + assert.equal(owner.page(), 'connections'); + assert.equal(owner.editor(), null); + assert.equal(owner.openingProvider(), null); + owner.newConnection(); + await owner.chooseProvider(provider); + assert.equal(owner.page(), 'connection'); +}); From d9e51f039559d7bc69405ddb90c45db4ec382884 Mon Sep 17 00:00:00 2001 From: Wang Date: Sun, 1 Nov 2026 11:13:02 +0800 Subject: [PATCH 3/3] fix(settings): localize built-in provider display and search labels Translate subscription, compatible-endpoint and region labels consistently in the picker and editor, and search both original and translated provider names. Preserve provider identity, saved connection names and reasoning-effort values. Exercise the actual ChatGPT sign-in, copy/cancel and translated provider-search controls in Simplified and Traditional Chinese using the real desktop renderer. Include a Chinese sign-in screenshot. Generated-by: OpenAI Codex --- .../tests/real_host/desktop_new_session.rs | 92 +++++++++++++++++- .../real_host/model_settings_preview.tsx | 7 +- products/maka/workbench/README.md | 2 + products/maka/workbench/README.zh-CN.md | 4 + .../maka/workbench/app/ConnectionForm.tsx | 3 +- .../maka/workbench/app/ProviderPicker.tsx | 13 ++- .../maka/workbench/app/locale-settings.ts | 13 +++ products/maka/workbench/app/settings.ts | 9 +- products/maka/workbench/app/settingsLabels.ts | 34 +++++++ .../subscription-sign-in-zh-CN.png | Bin 0 -> 39594 bytes 10 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 products/maka/workbench/app/settingsLabels.ts create mode 100644 products/maka/workbench/screenshots/subscription-sign-in-zh-CN.png diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index ac232d62ee0..03f5da7f6e8 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -81,7 +81,7 @@ impl Desktop { address: &Path, dimensions: gpui::Size, appearance: nsh_core::platform::Appearance, - preview: Option, + preview: Option<(String, &str)>, ) -> Self { let client = fixture.client_at(address).await; let bindings = Bindings::new(client.clone(), tokio::runtime::Handle::current()); @@ -92,7 +92,15 @@ impl Desktop { config.assets = Some(bindings.assets()); config.chunk_loader = Some(bindings.chunk_loader()); let previewing = preview.is_some(); - if let Some(source) = preview { + let close_label = match preview.as_ref().map(|(_, locale)| *locale) { + Some("zh-CN") => "关闭设置", + Some("zh-TW") => "關閉設定", + _ => "Close settings", + }; + if let Some((source, locale)) = preview { + config + .host + .set_params(json!({"locale":locale}).as_object().unwrap().clone()); let mut capabilities = nsh_engine::Capabilities::all(); capabilities.host = vec!["*".into()]; config.app = nsh_engine::Bundle::source(source) @@ -187,7 +195,7 @@ impl Desktop { errors, }; if previewing { - desktop.control("Close settings").await; + desktop.control(close_label).await; } else { desktop.target_after(None).await; } @@ -1156,7 +1164,7 @@ async fn desktop_model_settings_keeps_successful_sign_in_and_retries_models() { &fixture.address, size(gpui::DevicePixels(width), gpui::DevicePixels(height)), appearance, - Some(source), + Some((source, "en")), ) .await; desktop.press("Edit connection ChatGPT Subscription").await; @@ -1257,7 +1265,7 @@ async fn desktop_provider_picker_searches_real_catalog_and_preserves_selection() &fixture.address, size(gpui::DevicePixels(width), gpui::DevicePixels(height)), appearance, - Some(source), + Some((source, "en")), ) .await; desktop.press("Add connection").await; @@ -1359,3 +1367,77 @@ async fn desktop_provider_picker_searches_real_catalog_and_preserves_selection() desktop.close().await; fixture.finish().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn desktop_settings_translate_subscription_sign_in_and_provider_search() { + let traditional = std::env::var("MAKA_UI_LOCALE").as_deref() == Ok("zh-TW"); + let (locale, edit, sign_in, copy, cancel, add, search, connect, compatibility) = if traditional + { + ( + "zh-TW", + "編輯連線 ChatGPT Subscription", + "使用 ChatGPT 登入", + "複製登入代碼", + "取消供應商登入", + "新增連線", + "訂閱", + "連線至 ChatGPT 訂閱", + "連線至 Responses 相容介面", + ) + } else { + ( + "zh-CN", + "编辑连接 ChatGPT Subscription", + "使用 ChatGPT 登录", + "复制登录代码", + "取消提供商登录", + "添加连接", + "订阅", + "连接 ChatGPT 订阅", + "连接 Responses 兼容接口", + ) + }; + let fixture = Fixture::open().await; + let entry = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../terminal-shell/tests/real_host/model_settings_preview.tsx"); + let output = fixture.directory.path().join("settings-preview"); + let source = tokio::task::spawn_blocking(move || { + let compiled = maka_ui_build::compile(&entry, &output).unwrap(); + std::fs::read_to_string(compiled.module).unwrap() + }) + .await + .unwrap(); + let (theme, appearance, layout, width, height) = visual_case(); + let desktop = Desktop::open_config( + &fixture, + &fixture.address, + size(gpui::DevicePixels(width), gpui::DevicePixels(height)), + appearance, + Some((source, locale)), + ) + .await; + desktop.press(edit).await; + desktop.control(sign_in).await; + desktop.artifact(&format!("connection-localized-{locale}-{theme}-{layout}")); + desktop.press(sign_in).await; + desktop.control(copy).await; + desktop.artifact(&format!("sign-in-localized-{locale}-{theme}-{layout}")); + desktop.press(cancel).await; + desktop.control(sign_in).await; + desktop.press("返回").await; + desktop.press(add).await; + desktop.target_after(None).await; + desktop.type_text(search).await; + desktop.control(connect).await; + desktop.artifact(&format!("provider-localized-{locale}-{theme}-{layout}")); + desktop + .type_text(if traditional { + "相容介面" + } else { + "兼容接口" + }) + .await; + desktop.control(compatibility).await; + desktop.close().await; + fixture.finish().await; +} diff --git a/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx index 48af98fe83a..5d7d39fa3ea 100644 --- a/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx +++ b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx @@ -30,7 +30,7 @@ import { currentLocale, tr } from '../../../workbench/app/locale'; // The provider boundary is controlled by the test; every displayed control is production UI. function Preview() { - currentLocale.set('en'); + currentLocale.set(typeof events.params.locale === 'string' ? events.params.locale : 'en'); const provider = { entryId: 'maka.codex', name: 'chatgpt', @@ -306,7 +306,10 @@ function Preview() { instance: { instance, path: instance }, declaration: { kind: 'settings', - title: { fallback: 'Connections & models', translations: {} }, + title: { + fallback: 'Connections & models', + translations: { 'zh-CN': '连接与模型', 'zh-TW': '連線與模型' }, + }, }, }; function Page(props: { input: SettingsPageInput }) { diff --git a/products/maka/workbench/README.md b/products/maka/workbench/README.md index 91e50e37d28..96ad1636b02 100644 --- a/products/maka/workbench/README.md +++ b/products/maka/workbench/README.md @@ -58,3 +58,5 @@ Only skills installed through Maka have an update button. Updating replaces thei The provider picker separates account sign-in, custom endpoints and named API services, with alphabetical choices within each section. Search accepts multiple terms, supports arrow keys and Enter, and Escape clears the query before returning. Returning from a new connection preserves the provider search and selection; adding another connection suggests an unused ID among the loaded connections. ![Provider picker, rendered in the desktop test fixture against the built-in catalog.](screenshots/provider-picker-light.png) + +Built-in subscription, compatible-endpoint and region labels follow the interface language. Provider search matches both the original and localized labels. Saved connection names, brand names, model IDs and reasoning-effort values keep their original spelling. diff --git a/products/maka/workbench/README.zh-CN.md b/products/maka/workbench/README.zh-CN.md index 8330e262bf2..7e367670366 100644 --- a/products/maka/workbench/README.zh-CN.md +++ b/products/maka/workbench/README.zh-CN.md @@ -58,3 +58,7 @@ MAKA_NSH_EVIDENCE=/tmp/maka-nsh-evidence cargo nextest run --locked --workspace 提供商列表按账号登录、自定义接口和命名 API 服务分组,组内按名称排序。搜索支持多个词、方向键和 Enter,Esc 先清空搜索再返回。从新连接返回时保留搜索和选择;再次添加连接时,会在已加载的连接中避开重复标识。 ![提供商列表:桌面测试使用实际内置目录渲染。](screenshots/provider-picker-dark.png) + +内置订阅、兼容接口和地区标签随界面语言显示;提供商搜索同时匹配原文和译文。已保存的连接名称、品牌名、模型 ID 和推理档位保留原样。 + +![简体中文订阅登录界面,来自桌面测试。](screenshots/subscription-sign-in-zh-CN.png) diff --git a/products/maka/workbench/app/ConnectionForm.tsx b/products/maka/workbench/app/ConnectionForm.tsx index 3e2abb12267..a8286229a1b 100644 --- a/products/maka/workbench/app/ConnectionForm.tsx +++ b/products/maka/workbench/app/ConnectionForm.tsx @@ -25,6 +25,7 @@ import { ConnectionLogin } from './ConnectionLogin'; import { ChoiceIndicator } from './ChoiceIndicator'; import { Icon } from './Icon'; import { tr } from './locale'; +import { providerLabel } from './settingsLabels'; import { C, Disclosure, @@ -61,7 +62,7 @@ export function ConnectionForm(props: { settings: ModelSettingsOwner }) { > - {form().provider.descriptor.label} + {providerLabel(form().provider.descriptor.label)} diff --git a/products/maka/workbench/app/ProviderPicker.tsx b/products/maka/workbench/app/ProviderPicker.tsx index 6852b369e5c..2db19351d74 100644 --- a/products/maka/workbench/app/ProviderPicker.tsx +++ b/products/maka/workbench/app/ProviderPicker.tsx @@ -34,7 +34,8 @@ import { Button, Input } from 'nsh/base'; import type { SettingsProviderSummary } from '../../../../packages/plugin-sdk/src/index'; import type { ModelSettingsOwner } from './settings'; import { Icon } from './Icon'; -import { tr } from './locale'; +import { tr, currentLocale } from './locale'; +import { providerLabel } from './settingsLabels'; import { C, captionTextStyle, @@ -71,7 +72,9 @@ export function ProviderPicker(props: { owner: ModelSettingsOwner }) { owner.openingProvider() !== null; const rows = computed(() => { const providers = [...owner.filteredProviders()].sort( - (a, b) => a.label.localeCompare(b.label) || identityKey(a).localeCompare(identityKey(b)), + (a, b) => + providerLabel(a.label).localeCompare(providerLabel(b.label), currentLocale()) || + identityKey(a).localeCompare(identityKey(b)), ); const groups = [ { @@ -309,8 +312,8 @@ function ProviderOption(props: { const opening = () => sameValue(props.provider.identity, props.owner.openingProvider()); return (