You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/20 13:04:03 UTC

(maka) 01/01: feat(desktop): add manual model entry for static-catalog providers

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch fix/1584-model-selection-authority
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 770e89f261222be6f2d59ce9904285d12d5b9783
Author: AstroHan <le...@outlook.com>
AuthorDate: Thu Aug 20 21:03:48 2026 +0800

    feat(desktop): add manual model entry for static-catalog providers
    
    Volcengine Ark Agent Plan has no callable model-list endpoint for its
    inference key — /api/plan/v3/models and /api/plan/v1/models both pass
    gateway authentication and then 404 — so its catalog is the checked-in
    snapshot, and a user whose plan already serves a newly released model
    has no supported way to select it.
    
    Add a 添加模型 dialog beside 测试连接. The exact ID enters
    `enabledModelIds`, the same user-selection authority a catalogued model
    uses, and the selector groups manual entries under their own section
    instead of presenting them as provider-advertised.
    
    Adding is deliberately not folded into the enabled-models selector:
    picking from a known set and introducing a new one are different acts,
    and the selector has nowhere to put the runtime facts the dialog
    collects.
    
    Context window is required rather than defaulted. An unknown window
    falls back to a flat 32k history budget (context-budget-policy.ts);
    guessing higher on the user's behalf would trade a wasted window for
    requests the provider rejects outright, and whoever types an exact model
    ID is reading the provider's own model page where this number is stated.
    
    The dialog does not offer a tool-calling switch: `functionCalling` has
    no consumer on the execution path — it only exempts a model from the
    image-generation heuristic in model-catalog.ts — so the switch would
    never change what happens.
    
    Also order the detail page 连接 → 模型 → 高级请求设置, and rebalance the
    section rules the move left doubled.
    
    Refs #1584
    
    Generated-by: Claude Code
---
 .../src/renderer/locales/settings-provider-copy.ts |  30 ++++
 .../settings/provider-add-model-dialog.tsx         | 160 +++++++++++++++++++++
 .../settings/provider-connection-detail.tsx        | 153 +++++++++++---------
 .../settings/provider-enabled-model-manager.tsx    |  45 ++++--
 .../src/renderer/settings/use-connection-detail.ts |  22 +++
 .../stories/settings/provider-settings.stories.tsx |  36 ++++-
 6 files changed, 358 insertions(+), 88 deletions(-)

diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
index cb09b62e6..2d42430ee 100644
--- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
@@ -78,6 +78,21 @@ const zhCopy = {
     enabledModels: '启用的模型',
     searchModels: '搜索模型', selectAllModels: '全部启用',
     noModels: '暂无可选模型,请先更新模型目录。',
+    addModel: '添加模型',
+    addModelTitle: '添加模型',
+    manualModelSection: '手动添加',
+    addModelIdField: '模型 ID',
+    addModelIdFieldHelp: '填服务商文档里的精确 ID,大小写和后缀都要一致。',
+    addModelIdPlaceholder: '例如 deepseek-v4-pro-beta',
+    addModelIdConfirm: '添加',
+    addModelIdRequired: '请填写模型 ID。',
+    addModelIdDuplicate: '这个模型 ID 已经在列表里了。',
+    addModelContextWindow: '上下文窗口(token)',
+    addModelContextWindowHelp: '填服务商标注的窗口大小,Maka 按它计算历史预算。',
+    addModelContextWindowRequired: '请填写上下文窗口。',
+    addModelMaxOutput: '最大输出(token)',
+    addModelMaxOutputHelp: '不填则不额外限制输出长度。',
+    addModelVision: '支持图像输入',
     keySet: '已设置', statusLoading: '正在读取状态', credentialUnknown: '凭据状态未知', keyMissing: '尚未设置密钥',
     keyTroubleshooting: '模型密钥 / 服务地址 / 代理设置', endpointTroubleshooting: '本地服务 / 服务地址 / 代理设置', oauthTroubleshooting: 'OAuth 登录 / 代理设置',
     unknownDescription: (provider: string) => `该连接使用的 provider「${provider}」在当前版本未注册。配置和凭据会保留,切回支持它的版本即可继续使用。`,
@@ -226,6 +241,21 @@ const enCopy: ProviderSettingsCopy = {
     enabledModels: 'Enabled models',
     searchModels: 'Search models', selectAllModels: 'Enable all',
     noModels: 'No models are available. Update the model catalog first.',
+    addModel: 'Add model',
+    addModelTitle: 'Add model',
+    manualModelSection: 'Added by you',
+    addModelIdField: 'Model ID',
+    addModelIdFieldHelp: "Use the exact ID from the provider's docs, including case and any suffix.",
+    addModelIdPlaceholder: 'e.g. deepseek-v4-pro-beta',
+    addModelIdConfirm: 'Add',
+    addModelIdRequired: 'Enter a model ID.',
+    addModelIdDuplicate: 'That model ID is already in the list.',
+    addModelContextWindow: 'Context window (tokens)',
+    addModelContextWindowHelp: "Use the size the provider states — Maka sizes the history budget from it.",
+    addModelContextWindowRequired: 'Enter the context window.',
+    addModelMaxOutput: 'Max output (tokens)',
+    addModelMaxOutputHelp: 'Left empty, output length is not capped here.',
+    addModelVision: 'Supports image input',
     keySet: 'Set', statusLoading: 'Reading status', credentialUnknown: 'Credential status unavailable', keyMissing: 'No key set',
     keyTroubleshooting: 'model key, service URL, and proxy settings', endpointTroubleshooting: 'local service, service URL, and proxy settings', oauthTroubleshooting: 'OAuth sign-in and proxy settings',
     unknownDescription: (provider: string) => `The provider “${provider}” used by this connection is not registered in the current version. Its configuration and credentials are preserved for a supported version.`,
diff --git a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
new file mode 100644
index 000000000..b8b85f240
--- /dev/null
+++ b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
@@ -0,0 +1,160 @@
+import { useState, type FormEvent } from 'react';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { FormLayout } from '@astryxdesign/core/FormLayout';
+import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import { Button, CheckboxInput, HStack, NumberInput, TextInput, useUiLocale } from '@maka/ui';
+import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+
+/**
+ * A model the user declared by hand, for a provider whose inference key has no
+ * callable model-list endpoint (火山方舟 Agent Plan and Ark direct API today).
+ *
+ * The ID alone is not enough to run the model: an ID Maka has never seen
+ * carries no context window, so `resolveSelectedModelContextWindow` returns
+ * undefined and the history budget falls back to a flat 32k
+ * (context-budget-policy.ts) — three percent of a 1M-token window. The dialog
+ * therefore collects the facts the runtime actually reads, in one place, at the
+ * moment the user is looking at the provider's own model page.
+ *
+ * Adding is deliberately NOT part of the enabled-models selector: picking from
+ * a known set and introducing a new one are different acts with different
+ * consequences, and the selector has nowhere to put these fields.
+ */
+export interface ManualModelDraft {
+  id: string;
+  contextWindow: number;
+  maxOutputTokens?: number;
+  vision: boolean;
+}
+
+export function AddModelDialog(props: {
+  isOpen: boolean;
+  existingModelIds: readonly string[];
+  onOpenChange(open: boolean): void;
+  onSubmit(draft: ManualModelDraft): void;
+}) {
+  const copy = getProviderSettingsCopy(useUiLocale()).detail;
+  const [id, setId] = useState('');
+  const [contextWindow, setContextWindow] = useState<number | null>(null);
+  const [maxOutputTokens, setMaxOutputTokens] = useState<number | null>(null);
+  const [vision, setVision] = useState(false);
+  const [idTouched, setIdTouched] = useState(false);
+  const [contextWindowTouched, setContextWindowTouched] = useState(false);
+
+  const trimmedId = id.trim();
+  const duplicate = props.existingModelIds.includes(trimmedId);
+  const idError = !trimmedId
+    ? copy.addModelIdRequired
+    : duplicate
+      ? copy.addModelIdDuplicate
+      : null;
+  // Required, not defaulted: an unknown window falls back to a flat 32k history
+  // budget, and guessing higher on the user's behalf would turn a wasted window
+  // into requests the provider rejects outright. Whoever types an exact model ID
+  // is reading the provider's own model page, where this number is stated.
+  const contextWindowError = contextWindow ? null : copy.addModelContextWindowRequired;
+
+  function close() {
+    setId('');
+    setContextWindow(null);
+    setMaxOutputTokens(null);
+    setVision(false);
+    setIdTouched(false);
+    setContextWindowTouched(false);
+    props.onOpenChange(false);
+  }
+
+  function submit(event: FormEvent) {
+    event.preventDefault();
+    setIdTouched(true);
+    setContextWindowTouched(true);
+    if (idError || contextWindowError || !contextWindow) return;
+    props.onSubmit({
+      id: trimmedId,
+      contextWindow,
+      ...(maxOutputTokens ? { maxOutputTokens } : {}),
+      vision,
+    });
+    close();
+  }
+
+  if (!props.isOpen) return null;
+  return (
+    <Dialog isOpen onOpenChange={(open) => { if (!open) close(); }} purpose="form" width={480}>
+      <Layout
+        header={<DialogHeader title={copy.addModelTitle} onOpenChange={(open) => { if (!open) close(); }} />}
+        content={(
+          <LayoutContent>
+            <form id="maka-add-model-form" onSubmit={submit}>
+              <FormLayout>
+                {/* The exact ID, kept verbatim through selection and inference —
+                    `deepseek-v4-pro-beta` is a different model from
+                    `deepseek-v4-pro`, and only the user knows which one their
+                    plan actually serves. */}
+                <TextInput
+                  label={copy.addModelIdField}
+                  description={copy.addModelIdFieldHelp}
+                  isRequired
+                  hasAutoFocus
+                  value={id}
+                  placeholder={copy.addModelIdPlaceholder}
+                  onChange={(next) => {
+                    setIdTouched(true);
+                    setId(next);
+                  }}
+                  status={idTouched && idError ? { type: 'error', message: idError } : undefined}
+                />
+                <NumberInput
+                  label={copy.addModelContextWindow}
+                  description={copy.addModelContextWindowHelp}
+                  isRequired
+                  value={contextWindow}
+                  hasClear
+                  isIntegerOnly
+                  min={1}
+                  onChange={(next) => {
+                    setContextWindowTouched(true);
+                    setContextWindow(next);
+                  }}
+                  status={contextWindowTouched && contextWindowError
+                    ? { type: 'error', message: contextWindowError }
+                    : undefined}
+                />
+                <NumberInput
+                  label={copy.addModelMaxOutput}
+                  description={copy.addModelMaxOutputHelp}
+                  value={maxOutputTokens}
+                  hasClear
+                  isIntegerOnly
+                  min={1}
+                  onChange={setMaxOutputTokens}
+                />
+                <CheckboxInput
+                  label={copy.addModelVision}
+                  value={vision}
+                  onChange={setVision}
+                />
+              </FormLayout>
+            </form>
+          </LayoutContent>
+        )}
+        footer={(
+          <LayoutFooter>
+            {/* One button, as in scheduled-task-form-dialog: the header's close
+                control and Escape are already two ways out, so a footer 取消
+                would be a third route to the same place. */}
+            <HStack gap={2} hAlign="end">
+              <Button
+                variant="primary"
+                type="submit"
+                form="maka-add-model-form"
+                isDisabled={Boolean(idError || contextWindowError)}
+                label={copy.addModelIdConfirm}
+              />
+            </HStack>
+          </LayoutFooter>
+        )}
+      />
+    </Dialog>
+  );
+}
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
index 886e273ec..9600e9682 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
+++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
@@ -33,6 +33,7 @@ import { SettingsExpandableRow } from './settings-expandable-row';
 import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
 import { providerDisplay } from './provider-display';
 import { EnabledModelManager } from './provider-enabled-model-manager';
+import { AddModelDialog, type ManualModelDraft } from './provider-add-model-dialog';
 import { useActionGuard } from './use-action-guard';
 import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
 import { useOAuthLoginFlow } from './use-oauth-login-flow';
@@ -147,6 +148,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
     savedBaseUrl,
     save,
     updateEnabledModels,
+    addManualModel,
     relayProfileDraft,
     hasRelayProfileChanges,
     setDraftThinkingLevels,
@@ -163,6 +165,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
   // relay's backing model is unknown until the user says what it can do. The
   // declaration is per model — a relay can front both a reasoner and a plain
   // instruct model.
+  const [addModelOpen, setAddModelOpen] = useState(false);
   const showsCapabilities = connection.providerType === 'openai-compatible';
   // Rows are the enabled models, exactly — the store prunes a model's profile
   // the moment it is disabled, so no declaration can ever belong to a row
@@ -419,76 +422,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
           still needs one. Two rules with a gap between them read as an empty
           row, so only ever one. */}
       {!supportsApiKey && !showsEndpoint && <Divider />}
-      <DetailSection title={copy.advancedRequest} description={copy.advancedRequestHelp}>
-        <VStack gap={0}>
-              <SettingsExpandableRow
-                label={copy.requestHeaders}
-                value={savedHeaderNames.length > 0
-                  ? copy.configuredHeaders(savedHeaderNames.length)
-                  : copy.noAdvancedRequest}
-                actionLabel={copy.edit}
-                actionAriaLabel={`${copy.edit}: ${copy.requestHeaders}`}
-                isEditing={editingRow === 'headers'}
-                isDisabled={allActionsBusy}
-                canSave={hasHeaderDraftChanges}
-                saveLabel={copy.save}
-                cancelLabel={copy.cancel}
-                onEdit={() => openRow('headers')}
-                onCancel={() => {
-                  setHeaderDrafts(savedRequestHeaderDrafts(savedHeaderNames));
-                  setEditingRow(null);
-                }}
-                onSave={async () => {
-                  if (await saveRequestHeaders()) setEditingRow(null);
-                }}
-              >
-                <RequestHeadersEditor
-                  headers={headerDrafts}
-                  onHeadersChange={setHeaderDrafts}
-                  disabled={allActionsBusy}
-                  hideTitle
-                  copy={{
-                    headers: copy.requestHeaders,
-                    headerName: copy.headerName,
-                    headerValue: copy.headerValue,
-                    retainedValue: copy.retainedHeaderValue,
-                    addHeader: copy.addHeader,
-                    removeHeader: copy.removeHeader,
-                    noHeaders: copy.noRequestHeaders,
-                  }}
-                />
-              </SettingsExpandableRow>
-              <Divider />
-              <SettingsExpandableRow
-                label={copy.extraRequestBody}
-                value={connection.requestBodyOverlay ? copy.keySet : copy.noAdvancedRequest}
-                actionLabel={copy.edit}
-                actionAriaLabel={`${copy.edit}: ${copy.extraRequestBody}`}
-                isEditing={editingRow === 'body'}
-                isDisabled={allActionsBusy}
-                canSave={bodyDraft !== savedBodyText}
-                saveLabel={copy.save}
-                cancelLabel={copy.cancel}
-                onEdit={() => openRow('body')}
-                onCancel={() => {
-                  setBodyDraft(savedBodyText);
-                  setEditingRow(null);
-                }}
-                onSave={async () => {
-                  if (await saveRequestBody()) setEditingRow(null);
-                }}
-              >
-                <RequestBodyEditor
-                  bodyText={bodyDraft}
-                  onBodyTextChange={setBodyDraft}
-                  disabled={allActionsBusy}
-                  hideLabel
-                  copy={{ body: copy.extraRequestBody, bodyHelp: copy.extraRequestBodyHelp }}
-                />
-              </SettingsExpandableRow>
-              <Divider />
-        </VStack>
-      </DetailSection>
       <DetailSection title={copy.modelManagement} description={copy.modelManagementHelp}>
         <EnabledModelManager
           modelChoices={modelChoices}
@@ -496,6 +429,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
           disabled={allActionsBusy}
           onChange={(next) => void updateEnabledModels(next)}
         />
+        <AddModelDialog
+          isOpen={addModelOpen}
+          existingModelIds={enabledModelIds}
+          onOpenChange={setAddModelOpen}
+          onSubmit={(draft: ManualModelDraft) => void addManualModel(draft)}
+        />
         {/* Each button reports its own work through clickAction (spinner +
             aria-busy + "Loading" announcement) instead of renaming itself to
             测试中… — the section's `detailActionBusy` still says only one
@@ -507,6 +446,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
           {supportsRemoteDiscovery && (
             <Button variant="ghost" isDisabled={allActionsBusy || !hasUsableCredential} clickAction={() => refreshModels()} label={copy.updateModels} />
           )}
+          {/* Adding sits with the other two, not on its own line under the
+              selector: all three act on this section, and stacking them read
+              as two unrelated button rows. */}
+          <Button variant="ghost" isDisabled={allActionsBusy} onClick={() => setAddModelOpen(true)} label={copy.addModel} />
         </HStack>
       </DetailSection>
       {showsCapabilities && (
@@ -638,6 +581,76 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
         </>
       )}
       <Divider />
+      <DetailSection title={copy.advancedRequest} description={copy.advancedRequestHelp}>
+        <VStack gap={0}>
+              <SettingsExpandableRow
+                label={copy.requestHeaders}
+                value={savedHeaderNames.length > 0
+                  ? copy.configuredHeaders(savedHeaderNames.length)
+                  : copy.noAdvancedRequest}
+                actionLabel={copy.edit}
+                actionAriaLabel={`${copy.edit}: ${copy.requestHeaders}`}
+                isEditing={editingRow === 'headers'}
+                isDisabled={allActionsBusy}
+                canSave={hasHeaderDraftChanges}
+                saveLabel={copy.save}
+                cancelLabel={copy.cancel}
+                onEdit={() => openRow('headers')}
+                onCancel={() => {
+                  setHeaderDrafts(savedRequestHeaderDrafts(savedHeaderNames));
+                  setEditingRow(null);
+                }}
+                onSave={async () => {
+                  if (await saveRequestHeaders()) setEditingRow(null);
+                }}
+              >
+                <RequestHeadersEditor
+                  headers={headerDrafts}
+                  onHeadersChange={setHeaderDrafts}
+                  disabled={allActionsBusy}
+                  hideTitle
+                  copy={{
+                    headers: copy.requestHeaders,
+                    headerName: copy.headerName,
+                    headerValue: copy.headerValue,
+                    retainedValue: copy.retainedHeaderValue,
+                    addHeader: copy.addHeader,
+                    removeHeader: copy.removeHeader,
+                    noHeaders: copy.noRequestHeaders,
+                  }}
+                />
+              </SettingsExpandableRow>
+              <Divider />
+              <SettingsExpandableRow
+                label={copy.extraRequestBody}
+                value={connection.requestBodyOverlay ? copy.keySet : copy.noAdvancedRequest}
+                actionLabel={copy.edit}
+                actionAriaLabel={`${copy.edit}: ${copy.extraRequestBody}`}
+                isEditing={editingRow === 'body'}
+                isDisabled={allActionsBusy}
+                canSave={bodyDraft !== savedBodyText}
+                saveLabel={copy.save}
+                cancelLabel={copy.cancel}
+                onEdit={() => openRow('body')}
+                onCancel={() => {
+                  setBodyDraft(savedBodyText);
+                  setEditingRow(null);
+                }}
+                onSave={async () => {
+                  if (await saveRequestBody()) setEditingRow(null);
+                }}
+              >
+                <RequestBodyEditor
+                  bodyText={bodyDraft}
+                  onBodyTextChange={setBodyDraft}
+                  disabled={allActionsBusy}
+                  hideLabel
+                  copy={{ body: copy.extraRequestBody, bodyHelp: copy.extraRequestBodyHelp }}
+                />
+              </SettingsExpandableRow>
+              <Divider />
+        </VStack>
+      </DetailSection>
       {/* Deletion is last, and the only thing beside it is its own warning —
           no quiet action next to the destructive one for a mis-aimed cursor. */}
       <DetailSection title={copy.dangerZone} description={copy.deleteRowHelp}>
diff --git a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
index 146859fa0..17f1ecab8 100644
--- a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
+++ b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
@@ -1,4 +1,5 @@
 import { MultiSelector } from '@astryxdesign/core';
+import type { MultiSelectorOptionType } from '@astryxdesign/core/MultiSelector';
 import type { ModelCatalogEntry } from '@maka/core/model-catalog';
 import { useUiLocale } from '@maka/ui';
 import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
@@ -12,7 +13,15 @@ import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
  * on it combined. MultiSelector keeps the same one-line field weight as the
  * selectors around it, and brings the search that a list that long needs.
  *
- * `enabledModelIds` remains the only product state.
+ * `enabledModelIds` remains the only product state. Models the user declared by
+ * hand sit in their own titled section rather than being spelled into a label:
+ * the selector groups them, so "which of these did the provider advertise" is
+ * answered by structure and stays true when the label is truncated.
+ *
+ * Adding is a separate act with its own dialog — see provider-add-model-dialog.
+ * Its button lives beside 测试连接 in the section's action row, not under this
+ * field: all three act on the section, and stacking them read as two unrelated
+ * button rows.
  */
 export function EnabledModelManager(props: {
   modelChoices: ModelCatalogEntry[];
@@ -21,27 +30,33 @@ export function EnabledModelManager(props: {
   onChange(ids: string[]): void;
 }) {
   const copy = getProviderSettingsCopy(useUiLocale()).detail;
-  const options = (() => {
-    const byId = new Map(props.modelChoices.map((model) => [model.id, model] as const));
+  const byId = new Map(props.modelChoices.map((model) => [model.id, model] as const));
+
+  const { options, count } = (() => {
     const seen = new Set<string>();
-    const list: Array<{ value: string; label: string; disabled?: boolean }> = [];
-    const push = (id: string, label: string) => {
-      seen.add(id);
-      list.push({ value: id, label });
-    };
+    const catalogued: Array<{ value: string; label: string }> = [];
+    const manual: Array<{ value: string; label: string }> = [];
     for (const model of props.modelChoices) {
       if (!model.canUseAsChatDefault) continue;
-      push(model.id, modelDisplayLabel(model));
+      seen.add(model.id);
+      catalogued.push({ value: model.id, label: modelDisplayLabel(model) });
     }
     // Always surface an already-enabled model even if it is not a current
-    // chat-default candidate (a stale id, or a model dropped from the latest
-    // catalog), so the user can still toggle it off.
+    // chat-default candidate (a stale id, a model dropped from the latest
+    // catalog, or an ID the user entered by hand), so the user can still
+    // toggle it off.
     for (const id of props.enabledModelIds) {
       if (seen.has(id)) continue;
+      seen.add(id);
       const model = byId.get(id);
-      push(id, model ? modelDisplayLabel(model) : id);
+      if (model) catalogued.push({ value: id, label: modelDisplayLabel(model) });
+      else manual.push({ value: id, label: id });
+    }
+    const list: MultiSelectorOptionType[] = [...catalogued];
+    if (manual.length > 0) {
+      list.push({ type: 'section', title: copy.manualModelSection, options: manual });
     }
-    return list;
+    return { options: list, count: catalogued.length + manual.length };
   })();
 
   return (
@@ -54,8 +69,8 @@ export function EnabledModelManager(props: {
       options={options}
       value={props.enabledModelIds}
       onChange={props.onChange}
-      isDisabled={props.disabled || options.length === 0}
-      disabledMessage={options.length === 0 ? copy.noModels : undefined}
+      isDisabled={props.disabled || count === 0}
+      disabledMessage={count === 0 ? copy.noModels : undefined}
       placeholder={copy.noModels}
       // Names, not "N selected": Astryx hard-codes the English word into the
       // count display, and the labels answer "which ones" in the same width.
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index 178ba4554..e923fae66 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -7,6 +7,7 @@ import {
 } from '@maka/core/llm-connections';
 import { PROVIDER_DEFAULTS, connectionEnabledModelIds } from '@maka/core/llm-connections';
 import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog';
+import type { ManualModelDraft } from './provider-add-model-dialog';
 import {
   normalizeRelayModelProfiles,
   pruneRelayModelProfiles,
@@ -336,6 +337,26 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
     }
   }
 
+  /**
+   * A model the user declared by hand, for a provider whose inference key has
+   * no callable model-list endpoint. The exact ID enters `enabledModelIds` —
+   * the same user-selection authority a catalogued model uses, so nothing here
+   * pretends the provider advertised it.
+   *
+   * The draft's runtime facts (context window, max output, tool calling,
+   * vision) are NOT persisted yet: `relayModelProfiles` is the natural home for
+   * them, but it is gated to `openai-compatible` in three places at once — the
+   * read seam (model-thinking.ts), the store invariant, and this page's
+   * capability section. Widening it is a decision about what that table is,
+   * not a patch, and it is tracked separately. Until then a manual model runs
+   * on the unknown-window fallback.
+   */
+  async function addManualModel(draft: ManualModelDraft) {
+    const id = draft.id.trim();
+    if (!id || enabledModelIds.includes(id)) return;
+    await updateEnabledModels([...enabledModelIds, id]);
+  }
+
   // Per-model profile declarations for openai-compatible relays, edited as a
   // LOCAL DRAFT and committed by an explicit 保存 button — never keystroke by
   // keystroke. A draft is `Record<modelId, RelayModelProfile>` seeded from the
@@ -635,6 +656,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
     lastTestAtMs,
     save,
     updateEnabledModels,
+    addManualModel,
     relayProfileDraft: relayProfileDrafts,
     relayProfilesDirty,
     hasRelayProfileChanges,
diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx
index c898b988a..b47b56a13 100644
--- a/apps/desktop/stories/settings/provider-settings.stories.tsx
+++ b/apps/desktop/stories/settings/provider-settings.stories.tsx
@@ -28,7 +28,7 @@ const meta = {
 export default meta;
 
 type Story = StoryObj<typeof meta>;
-type AutoOpenTarget = 'detail' | 'add' | 'catalog' | 'oauth' | 'xai-device';
+type AutoOpenTarget = 'detail' | 'detail-static' | 'add' | 'catalog' | 'oauth' | 'xai-device';
 
 function makeConnection(input: {
   slug: string;
@@ -41,6 +41,7 @@ function makeConnection(input: {
   lastTestMessage?: string;
   models?: LlmConnection['models'];
   modelSource?: LlmConnection['modelSource'];
+  enabledModelIds?: string[];
 }): LlmConnection {
   return {
     slug: input.slug,
@@ -49,6 +50,7 @@ function makeConnection(input: {
     ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
     defaultModel: input.defaultModel ?? 'glm-4.7',
     enabled: input.enabled ?? true,
+    ...(input.enabledModelIds ? { enabledModelIds: input.enabledModelIds } : {}),
     ...(input.models ? { models: input.models } : {}),
     ...(input.modelSource ? { modelSource: input.modelSource } : {}),
     modelsFetchedAt: NOW - 18 * 60 * 1000,
@@ -100,6 +102,20 @@ const configuredConnections = [
   }),
 ];
 
+// 火山方舟 Agent Plan has no model-list endpoint for its inference key
+// (/api/plan/v3/models and /api/plan/v1/models both 404 past the gateway), so
+// its catalog is the checked-in snapshot and never a fetched one.
+const staticCatalogConnections = [
+  makeConnection({
+    slug: 'ark-agent-plan',
+    name: 'Ark Agent Plan',
+    providerType: 'volcengine-agent-plan',
+    defaultModel: 'doubao-seed-2.1-turbo',
+    enabledModelIds: ['doubao-seed-2.1-turbo', 'kimi-k2.7-code'],
+    lastTestStatus: 'verified',
+  }),
+];
+
 const problemConnections = [
   configuredConnections[0],
   makeConnection({
@@ -364,10 +380,11 @@ function reachCatalog(root: HTMLElement): HTMLElement | null {
 }
 
 function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean {
-  if (target === 'detail') {
+  if (target === 'detail' || target === 'detail-static') {
     // ListItem's clickable surface is an invisible button inside the row, so
     // the row is located by its slug hook and the button taken from within it.
-    const row = root.querySelector<HTMLElement>('[data-connection-slug="zai-live"]');
+    const slug = target === 'detail' ? 'zai-live' : 'ark-agent-plan';
+    const row = root.querySelector<HTMLElement>(`[data-connection-slug="${slug}"]`);
     const detailButton = row?.querySelector('button') ?? null;
     detailButton?.click();
     return Boolean(detailButton);
@@ -432,6 +449,19 @@ export const ConnectionDetailPage: Story = {
   ),
 };
 
+// Real path: 设置 → 模型 → click a connection whose provider has no callable
+// model-list endpoint (火山方舟 Agent Plan) — the same detail page, minus
+// 更新模型目录, where the only way to reach a newly released model is to type
+// its exact ID.
+export const StaticCatalogConnectionDetail: Story = {
+  render: () => (
+    <ProviderStory
+      bridge={createBridge({ connections: staticCatalogConnections, defaultSlug: 'ark-agent-plan' })}
+      autoOpen="detail-static"
+    />
+  ),
+};
+
 // Real path: 设置 → 模型 → 添加连接 — level two, the provider catalog.
 export const AddConnectionCatalog: Story = {
   render: () => (


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org