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 0acc320736b..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 @@ -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<(String, &str)>, ) -> Self { let client = fixture.client_at(address).await; let bindings = Bindings::new(client.clone(), tokio::runtime::Handle::current()); @@ -80,6 +91,32 @@ impl Desktop { ); config.assets = Some(bindings.assets()); config.chunk_loader = Some(bindings.chunk_loader()); + let previewing = preview.is_some(); + 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) + .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 +161,7 @@ impl Desktop { cx.new(|cx| { let view = AppView::new( AppOptions { + host: config.host.clone(), terminal_keyboard: true, ..Default::default() }, @@ -151,14 +189,45 @@ impl Desktop { window, client, bindings, + outcome, _foreground: foreground, wake, errors, }; - desktop.target_after(None).await; + if previewing { + desktop.control(close_label).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(); + 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(); + 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 +248,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 +266,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 +1145,299 @@ 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, "en")), + ) + .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; +} + +#[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, "en")), + ) + .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; +} + +#[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 new file mode 100644 index 00000000000..5d7d39fa3ea --- /dev/null +++ b/products/maka/terminal-shell/tests/real_host/model_settings_preview.tsx @@ -0,0 +1,355 @@ +/* + * 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 { 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. +function Preview() { + currentLocale.set(typeof events.params.locale === 'string' ? events.params.locale : '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; + 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, + revision, + signIns, + discoveries, + modelIds, + overrides, + defaultTarget, + selectedProviders, + }); + 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') { + 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') + 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: { 'zh-CN': '连接与模型', 'zh-TW': '連線與模型' }, + }, + }, + }; + function Page(props: { input: SettingsPageInput }) { + cleanup( + props.input.settings!.attach({ + title: () => + owner.page() === 'connection' + ? tr('Connection settings') + : 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, + 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..96ad1636b02 100644 --- a/products/maka/workbench/README.md +++ b/products/maka/workbench/README.md @@ -52,3 +52,11 @@ 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. + +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 71a8eeb2881..7e367670366 100644 --- a/products/maka/workbench/README.zh-CN.md +++ b/products/maka/workbench/README.zh-CN.md @@ -52,3 +52,13 @@ MAKA_NSH_EVIDENCE=/tmp/maka-nsh-evidence cargo nextest run --locked --workspace 只有通过 Maka 安装的技能显示更新按钮。更新从登记的来源替换整个目录及其资源,并覆盖本地修改。四个标准目录中的其他技能同样可以查看和删除。来源登记保存在 Host 数据库中,不创建 npx lockfile 或另一套私有技能副本。 **设置 → MCP 服务器**通过命令和 HTTP 表单管理上述四个目录的 `mcp.json`,保留其他条目和自定义字段。选择项目无需创建会话。下载、文件修改和来源登记均在所连接的 Host 上完成;保存项目 MCP 配置不会自动授予信任。 + +**设置 → 连接与模型** 将提供商登录、连接信息和模型列表放在一起。新建或修改连接后,可以一次完成保存并登录。设备登录会打开浏览器,对话框保留验证码、复制和取消操作。登录成功后,获取模型失败可以单独重试。高级连接与模型选项折叠显示;已保存的连接可以重新加载外部更改,返回时会确认是否放弃未保存的编辑。 + +提供商列表按账号登录、自定义接口和命名 API 服务分组,组内按名称排序。搜索支持多个词、方向键和 Enter,Esc 先清空搜索再返回。从新连接返回时保留搜索和选择;再次添加连接时,会在已加载的连接中避开重复标识。 + +![提供商列表:桌面测试使用实际内置目录渲染。](screenshots/provider-picker-dark.png) + +内置订阅、兼容接口和地区标签随界面语言显示;提供商搜索同时匹配原文和译文。已保存的连接名称、品牌名、模型 ID 和推理档位保留原样。 + +![简体中文订阅登录界面,来自桌面测试。](screenshots/subscription-sign-in-zh-CN.png) diff --git a/products/maka/workbench/app/CatalogModelAssociation.tsx b/products/maka/workbench/app/CatalogModelAssociation.tsx index 7bb79cbaf53..01c117ef68b 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.')} + + + @@ -144,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 { @@ -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..a8286229a1b 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'; @@ -25,12 +25,13 @@ import { ConnectionLogin } from './ConnectionLogin'; import { ChoiceIndicator } from './ChoiceIndicator'; import { Icon } from './Icon'; import { tr } from './locale'; +import { providerLabel } from './settingsLabels'; import { C, + Disclosure, bodyTextStyle, captionTextStyle, controlStyle, - fieldBoxStyle, controlHoverStyle, controlFocusStyle, controlDisabledStyle, @@ -38,263 +39,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..dac908775a2 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,325 @@ 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..bcd425c4195 100644 --- a/products/maka/workbench/app/ConnectionSettings.tsx +++ b/products/maka/workbench/app/ConnectionSettings.tsx @@ -24,6 +24,7 @@ import type { ListRef } from 'nsh'; import { Button } from 'nsh/base'; import type { ModelSettingsOwner } from './settings'; import { ConnectionForm } from './ConnectionForm'; +import { ProviderPicker } from './ProviderPicker'; import { ConnectionModel } from './ConnectionModel'; import { ChoiceIndicator } from './ChoiceIndicator'; import { Icon } from './Icon'; @@ -44,23 +45,77 @@ 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,65 +294,7 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - - {tr('Choose the API provider for this connection.')} - - { - owner.searchProviders(text); - providersList?.scrollToIndex({ index: 0, animated: false }); - }} - /> - { - providersList = node; - }} - each={owner.filteredProviders()} - keyExtractor={(item) => JSON.stringify(item.identity)} - estimatedItemSize={48} - onLoadMore={() => { - if (!owner.error()) void owner.loadProviders(true); - }} - empty={ - - - {tr('No matching providers.')} - - - } - style={{ flex: 1, minHeight: 0 }} - > - {(item) => ( - - )} - + @@ -267,27 +303,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 +385,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,8 +474,12 @@ export function ConnectionSettings(props: { owner: ModelSettingsOwner }) { - - {tr('Loading settings…')} + + + + {tr('Loading settings…')} + + ); @@ -418,15 +497,21 @@ 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, 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/ProviderPicker.tsx b/products/maka/workbench/app/ProviderPicker.tsx new file mode 100644 index 00000000000..2db19351d74 --- /dev/null +++ b/products/maka/workbench/app/ProviderPicker.tsx @@ -0,0 +1,347 @@ +/* + * 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, currentLocale } from './locale'; +import { providerLabel } from './settingsLabels'; +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) => + providerLabel(a.label).localeCompare(providerLabel(b.label), currentLocale()) || + 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/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..10cf82a045a 100644 --- a/products/maka/workbench/app/locale-settings.ts +++ b/products/maka/workbench/app/locale-settings.ts @@ -59,6 +59,78 @@ import { } from '../../../../plugins/shared/tui-messages.js'; export const settingsMessages = { + 'ChatGPT Subscription': ['ChatGPT Subscription', 'ChatGPT 订阅', 'ChatGPT 訂閱'], + 'Chat Completions': [ + 'Chat Completions', + 'Chat Completions 兼容接口', + 'Chat Completions 相容介面', + ], + Responses: ['Responses', 'Responses 兼容接口', 'Responses 相容介面'], + 'Anthropic Messages': [ + 'Anthropic Messages', + 'Anthropic Messages 兼容接口', + 'Anthropic Messages 相容介面', + ], + '{provider} (China)': ['{provider} (China)', '{provider}(中国站)', '{provider}(中國站)'], + '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?', + '放弃未保存的修改?', + '放棄未儲存的修改?', + ], + '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 +875,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..ac534c30faf 100644 --- a/products/maka/workbench/app/settings.ts +++ b/products/maka/workbench/app/settings.ts @@ -20,6 +20,8 @@ import { cleanup, computed, signal } from 'nsh'; import type { Host } from './host'; import { message, UiError } from './text'; +import { sameValue } from '../../../../plugins/shared/value'; +import { providerLabel } from './settingsLabels'; import type { CatalogModelReference, CatalogMutationResult, @@ -168,6 +170,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; @@ -184,6 +189,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, @@ -202,7 +208,13 @@ export function createModelSettings( const providerIndex = computed(() => providers().map((item) => ({ item, - text: [item.label, item.identity.name, item.identity.packageId, item.identity.entryId] + text: [ + item.label, + providerLabel(item.label), + item.identity.name, + item.identity.packageId, + item.identity.entryId, + ] .join('\n') .toLowerCase(), })), @@ -269,6 +281,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; @@ -327,6 +340,7 @@ export function createModelSettings( const generation = ++read; loading.set(true); error.set(''); + failedProvider.set(null); try { let rows = more ? providers() : []; for (;;) { @@ -359,6 +373,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(''); @@ -369,11 +385,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; @@ -390,11 +420,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, @@ -410,9 +436,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) => { @@ -509,10 +541,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 +601,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 +628,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 +689,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 +701,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 +730,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 +864,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 +1120,57 @@ export function createModelSettings( changed(); }); }; + const leaveConnection = () => { + cancelRead(); + editor.set(null); + confirmation.set(null); + 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; + if (form.baseline) return dirty(); + const defaults = form.provider.descriptor.configurationDefaults as Record< + string, + unknown + > | null; + const slug = suggestedSlug(form.provider.identity); + 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 +1180,19 @@ 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; + } + if (page() === 'connection') leaveEditor(); + else leaveConnection(); return false; } return true; }; const deactivate = () => { cancelRead(); + discardTarget.set(null); if (editor()) editor.set({ ...editor()!, instance: ++editorInstance }); }; const activate = () => { @@ -1151,6 +1266,9 @@ export function createModelSettings( providers, connectionQuery, providerQuery, + openingProvider, + selectedProvider, + failedProvider, modelQuery, filteredConnections, filteredProviders, @@ -1171,13 +1289,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') leaveEditor(); + }, + 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, @@ -1227,6 +1362,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/app/settingsLabels.ts b/products/maka/workbench/app/settingsLabels.ts new file mode 100644 index 00000000000..e95440b78d0 --- /dev/null +++ b/products/maka/workbench/app/settingsLabels.ts @@ -0,0 +1,34 @@ +/* + * 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 { tr } from './locale'; + +export function providerLabel(label: string): string { + switch (label) { + case 'ChatGPT Subscription': + case 'Chat Completions': + case 'Responses': + case 'Anthropic Messages': + return tr(label); + default: + return label.endsWith(' (China)') + ? tr('{provider} (China)', { provider: label.slice(0, -8) }) + : label; + } +} 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 00000000000..bacfd9ede39 Binary files /dev/null and b/products/maka/workbench/screenshots/provider-picker-dark.png differ 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 00000000000..54f53b2b0d0 Binary files /dev/null and b/products/maka/workbench/screenshots/provider-picker-light.png differ diff --git a/products/maka/workbench/screenshots/subscription-sign-in-zh-CN.png b/products/maka/workbench/screenshots/subscription-sign-in-zh-CN.png new file mode 100644 index 00000000000..f79dd7443ea Binary files /dev/null and b/products/maka/workbench/screenshots/subscription-sign-in-zh-CN.png differ diff --git a/products/maka/workbench/tests/connection-login.test.mjs b/products/maka/workbench/tests/connection-login.test.mjs index 50a29bde6c0..89fc9abd9c1 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,378 @@ 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, + api, + 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'); +}); + +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'); +});