Browse Source

fix(ui-settings-plugins): align Subagent configuration card

Dudu-0223 1 month ago
parent
commit
0b2f476071

+ 2 - 2
apps/web/tests/expected/plugin-config/section.expected.md

@@ -25,8 +25,8 @@
   - tabpanel "插件配置":
     - list:
       - listitem:
-        - 'button "展开设置: Subagent 自选模型"':
-          - text: Subagent 自选模型 选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。
+        - 'button "展开设置: Subagent"':
+          - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
           - img
       - listitem:
         - 'button "展开设置: 终端"':

+ 7 - 6
apps/web/tests/plugin-config.e2e.ts

@@ -77,8 +77,8 @@ describe('web e2e: plugin configuration section', () => {
 
     // Every card the shipped web composition exposes: subagent selection, the
     // shell executor, the agent loop, and the DeepSeek search provider.
-    await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 })
-    expect(await dialog.getByRole('button', { name: '展开设置: Subagent 自选模型' }).count()).toBe(1)
+    await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 })
+    expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1)
     await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
     expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
     expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
@@ -93,11 +93,11 @@ describe('web e2e: plugin configuration section', () => {
   it('persists selected adapter routes as the subagent model allowlist', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
     const dialog = await openPlugins()
-    await dialog.getByText('Subagent 自选模型', { exact: true }).click()
-    const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' })
+    await dialog.getByText('Subagent', { exact: true }).click()
+    const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
 
     await toggle.click()
-    const models = dialog.getByRole('group', { name: '允许的模型' })
+    const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' })
     await models.waitFor({ timeout: 10_000 })
     const firstModel = models.getByRole('checkbox').first()
     await firstModel.check()
@@ -110,7 +110,8 @@ describe('web e2e: plugin configuration section', () => {
     expect(await settingsDocument()).toContain('allowedModels:')
     expect(await settingsDocument()).toContain('provider:')
     expect(await settingsDocument()).toContain('model:')
-    expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。')
+    await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
+    expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0)
 
     await toggle.click()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()

+ 19 - 6
packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css

@@ -54,8 +54,7 @@
 
 .hint,
 .notice,
-.invalid,
-.status {
+.invalid {
   margin: 0;
   font-size: 12px;
   line-height: 1.5;
@@ -70,10 +69,6 @@
   color: var(--dsw-alias-label-error);
 }
 
-.status {
-  color: var(--dsw-alias-state-success-primary);
-}
-
 .catalogError {
   display: flex;
   align-items: center;
@@ -109,6 +104,24 @@
   color: var(--dsw-alias-label-secondary);
 }
 
+.modelGroup {
+  display: grid;
+  gap: 6px;
+}
+
+.modelGroup + .modelGroup {
+  margin-top: 4px;
+  padding-top: 10px;
+  border-top: 1px solid var(--dsw-alias-border-l3);
+}
+
+.providerName {
+  padding: 0 6px;
+  font-size: 11px;
+  font-weight: 500;
+  color: var(--dsw-alias-label-tertiary);
+}
+
 .model {
   display: grid;
   grid-template-columns: auto minmax(0, 1fr) auto;

+ 55 - 19
packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx

@@ -2,7 +2,10 @@
 
 import clsx from 'clsx'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts'
+import type {
+  SubagentModelCandidate,
+  SubagentModelSelectionCardFace,
+} from './subagent-model-selection-card-controller.ts'
 import type {} from './slot-contract.ts'
 import { PluginCard } from './PluginCard.tsx'
 import css from './SubagentModelSelectionCard.module.css'
@@ -21,6 +24,43 @@ export type SubagentModelSelectionCardProps =
 export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) {
   const { t } = props
   const state = props.useSubagentModelSelectionCard(snapshot => snapshot)
+  const availableGroups = new Map<string, {
+    providerName: string
+    candidates: SubagentModelCandidate[]
+  }>()
+  const unavailable: SubagentModelCandidate[] = []
+  for (const candidate of state.candidates) {
+    if (!candidate.available) {
+      unavailable.push(candidate)
+      continue
+    }
+    const group = availableGroups.get(candidate.provider)
+    if (group === undefined) {
+      availableGroups.set(candidate.provider, {
+        providerName: candidate.providerName,
+        candidates: [candidate],
+      })
+    } else {
+      group.candidates.push(candidate)
+    }
+  }
+  const renderCandidate = (candidate: SubagentModelCandidate) => (
+    <label key={candidate.key} className={css.model}>
+      <input
+        type="checkbox"
+        checked={candidate.selected}
+        disabled={!state.writable || state.saving}
+        onChange={() => { props.toggleModel(candidate.key) }}
+      />
+      <span>
+        <span className={css.modelName}>{candidate.modelName}</span>
+        <span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
+      </span>
+      {!candidate.available
+        ? <span className={css.unavailable}>{t('subagentModelSelectionUnavailable')}</span>
+        : null}
+    </label>
+  )
   return (
     <PluginCard
       t={t}
@@ -61,30 +101,27 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
                 </div>
               )
               : null}
-            {state.catalogFailures.length > 0
+            {state.catalogPartial
               ? <p className={css.notice}>{t('subagentModelSelectionPartial')}</p>
               : null}
             {state.candidates.length > 0
               ? (
                 <fieldset className={css.models}>
                   <legend>{t('subagentModelSelectionAllowed')}</legend>
-                  {state.candidates.map(candidate => (
-                    <label key={candidate.key} className={css.model}>
-                      <input
-                        type="checkbox"
-                        checked={candidate.selected}
-                        disabled={!state.writable || state.saving}
-                        onChange={() => { props.toggleModel(candidate.key) }}
-                      />
-                      <span>
-                        <span className={css.modelName}>{candidate.modelName}</span>
-                        <span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
-                      </span>
-                      {!candidate.available
-                        ? <span className={css.unavailable}>{t('subagentModelSelectionUnavailable')}</span>
-                        : null}
-                    </label>
+                  {[...availableGroups].map(([provider, group]) => (
+                    <div key={provider} className={css.modelGroup}>
+                      <div className={css.providerName}>{group.providerName}</div>
+                      {group.candidates.map(renderCandidate)}
+                    </div>
                   ))}
+                  {unavailable.length > 0
+                    ? (
+                      <div className={css.modelGroup}>
+                        <div className={css.providerName}>{t('subagentModelSelectionUnavailableGroup')}</div>
+                        {unavailable.map(renderCandidate)}
+                      </div>
+                    )
+                    : null}
                 </fieldset>
               )
               : state.catalogStatus === 'ready'
@@ -94,7 +131,6 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
           </div>
         )
         : <p className={css.hint}>{t('subagentModelSelectionOff')}</p>}
-      {state.saved ? <p className={css.status} role="status">{t('subagentModelSelectionSaved')}</p> : null}
     </PluginCard>
   )
 }

+ 27 - 26
packages/client/ui-settings-plugins/src/client/locales.ts

@@ -14,8 +14,9 @@ export type PluginsSettingsLocaleKey =
   | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription'
   | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed'
   | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry'
-  | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionEmpty'
-  | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' | 'subagentModelSelectionSaved'
+  | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable'
+  | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty'
+  | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff'
 
 /** English copy. */
 export const en: Record<PluginsSettingsLocaleKey, string> = {
@@ -56,20 +57,20 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
   webSearchBaseUrlHint: 'Leave blank to use the provider default.',
   webSearchMaxUses: 'Max searches per request',
   webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
-  subagentModelSelectionTitle: 'Subagent model selection',
-  subagentModelSelectionDescription: 'Choose which child models new sessions may select. Running sessions do not change.',
-  subagentModelSelectionToggle: 'Allow subagents to choose models',
-  subagentModelSelectionChoose: 'Select at least one model. Only these adapter routes appear in subagent discovery.',
-  subagentModelSelectionAllowed: 'Allowed models',
-  subagentModelSelectionLoading: 'Loading adapter models…',
-  subagentModelSelectionLoadFailed: 'Adapter models could not be loaded.',
+  subagentModelSelectionTitle: 'Subagent',
+  subagentModelSelectionDescription: 'Control which models agents may choose for subagents.',
+  subagentModelSelectionToggle: 'Allow agents to choose models for subagents',
+  subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.',
+  subagentModelSelectionAllowed: 'Models agents may choose',
+  subagentModelSelectionLoading: 'Loading models…',
+  subagentModelSelectionLoadFailed: 'Models could not be loaded.',
   subagentModelSelectionRetry: 'Retry',
-  subagentModelSelectionPartial: 'Some providers could not list their models; stored choices remain removable.',
-  subagentModelSelectionUnavailable: 'Unavailable',
-  subagentModelSelectionEmpty: 'No adapter currently advertises a model.',
+  subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.',
+  subagentModelSelectionUnavailable: 'Currently unavailable',
+  subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable',
+  subagentModelSelectionEmpty: 'No model provider currently advertises a model.',
   subagentModelSelectionRequired: 'Select at least one model before saving.',
-  subagentModelSelectionOff: 'New sessions inherit the configured or parent model without choosing another route.',
-  subagentModelSelectionSaved: 'Saved. New sessions use this setting.',
+  subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.',
 }
 
 /** Simplified Chinese copy. */
@@ -111,18 +112,18 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
   webSearchBaseUrlHint: '留空则使用提供方默认地址。',
   webSearchMaxUses: '单次请求最多搜索次数',
   webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
-  subagentModelSelectionTitle: 'Subagent 自选模型',
-  subagentModelSelectionDescription: '选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。',
-  subagentModelSelectionToggle: '允许 subagent 自选模型',
-  subagentModelSelectionChoose: '请至少选择一个模型。Subagent 发现工具只会列出这些 adapter 路由。',
-  subagentModelSelectionAllowed: '允许的模型',
-  subagentModelSelectionLoading: '正在加载 adapter 模型…',
-  subagentModelSelectionLoadFailed: '无法加载 adapter 模型。',
+  subagentModelSelectionTitle: 'Subagent',
+  subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。',
+  subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型',
+  subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。',
+  subagentModelSelectionAllowed: 'Agent 可选择的模型',
+  subagentModelSelectionLoading: '正在加载模型…',
+  subagentModelSelectionLoadFailed: '无法加载模型。',
   subagentModelSelectionRetry: '重试',
-  subagentModelSelectionPartial: '部分提供方无法列出模型;仍可移除已保存的选项。',
-  subagentModelSelectionUnavailable: '不可用',
-  subagentModelSelectionEmpty: '当前没有 adapter 公布模型。',
+  subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。',
+  subagentModelSelectionUnavailable: '当前不可用',
+  subagentModelSelectionUnavailableGroup: '已保存但当前不可用',
+  subagentModelSelectionEmpty: '当前没有模型提供方公布模型。',
   subagentModelSelectionRequired: '保存前请至少选择一个模型。',
-  subagentModelSelectionOff: '新会话会使用配置值或继承父 Agent 模型,不会自主选择其他路由。',
-  subagentModelSelectionSaved: '已保存,新会话将使用此设置。',
+  subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。',
 }

+ 7 - 19
packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts

@@ -2,7 +2,6 @@
 
 import type {
   IApiClient,
-  ModelCatalogFailure,
   ModelProviderGroup,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -48,10 +47,8 @@ export interface SubagentModelSelectionCardState extends CardShell {
   candidates: readonly SubagentModelCandidate[]
   /** Adapter-directory request state. */
   catalogStatus: 'idle' | 'loading' | 'ready' | 'error'
-  /** Provider-local failures that did not block other candidates. */
-  catalogFailures: readonly ModelCatalogFailure[]
-  /** Whether the latest save landed. */
-  saved: boolean
+  /** Whether any provider-local catalog request failed. */
+  catalogPartial: boolean
 }
 
 /** Registration-side face for the subagent model-selection card. */
@@ -130,13 +127,12 @@ function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly Allow
 /** Bridges one settings scope and the live adapter directory onto a staged card. */
 export class SubagentModelSelectionCardController {
   private catalogGroups: readonly ModelProviderGroup[] = []
-  private catalogFailures: readonly ModelCatalogFailure[] = []
+  private catalogPartial = false
   private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle'
   private draftEnabled: boolean | undefined
   private draftSelected: Set<string> | undefined
   private draftRevision: number | undefined
   private saving = false
-  private saved = false
   private failed = false
   private disposed = false
   private saveGeneration = 0
@@ -156,7 +152,6 @@ export class SubagentModelSelectionCardController {
     this.unsubscribe = scope.subscribe(() => {
       if (!this.saving && this.draftSelected !== undefined
         && this.scope.getSnapshot().revision !== this.draftRevision) {
-        this.saved = false
         this.failed = true
       }
       if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog()
@@ -219,7 +214,6 @@ export class SubagentModelSelectionCardController {
     if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return
     this.beginDraft()
     this.draftEnabled = !this.draftEnabled
-    this.saved = false
     this.failed = false
     if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog()
     this.publish()
@@ -231,7 +225,6 @@ export class SubagentModelSelectionCardController {
     const selected = this.beginDraft()
     if (selected.has(key)) selected.delete(key)
     else selected.add(key)
-    this.saved = false
     this.failed = false
     this.publish()
   }
@@ -241,7 +234,6 @@ export class SubagentModelSelectionCardController {
     this.draftEnabled = undefined
     this.draftSelected = undefined
     this.draftRevision = undefined
-    this.saved = false
     this.failed = false
     this.publish()
   }
@@ -264,14 +256,12 @@ export class SubagentModelSelectionCardController {
       || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired))
       || (desiredEnabled && desired.length === 0)) return
     if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) {
-      this.saved = false
       this.failed = true
       this.publish()
       return
     }
     const generation = this.saveGeneration
     this.saving = true
-    this.saved = false
     this.failed = false
     this.publish()
     await this.scope.mutate([
@@ -281,7 +271,6 @@ export class SubagentModelSelectionCardController {
     if (generation !== this.saveGeneration) return
     const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)
     this.saving = false
-    this.saved = landed
     this.failed = !landed
     if (landed) {
       this.draftEnabled = undefined
@@ -296,7 +285,7 @@ export class SubagentModelSelectionCardController {
     if (this.disposed) return
     this.catalogGeneration += 1
     this.catalogStatus = 'idle'
-    this.catalogFailures = []
+    this.catalogPartial = false
     if (this.enabled()) void this.loadCatalog()
     else this.publish()
   }
@@ -313,14 +302,14 @@ export class SubagentModelSelectionCardController {
     const generation = this.catalogGeneration
     this.catalogStatus = 'loading'
     this.catalogGroups = []
-    this.catalogFailures = []
+    this.catalogPartial = false
     this.publish()
     try {
       const response = await this.api.llm.models({})
       if (generation !== this.catalogGeneration) return
       if (!response.result.ok) throw new Error(response.result.error.message)
       this.catalogGroups = response.result.value.groups
-      this.catalogFailures = response.result.value.failures
+      this.catalogPartial = response.result.value.failures.length > 0
       this.catalogStatus = 'ready'
     } catch {
       if (generation !== this.catalogGeneration) return
@@ -344,8 +333,7 @@ export class SubagentModelSelectionCardController {
       enabled,
       candidates: this.candidates(),
       catalogStatus: this.catalogStatus,
-      catalogFailures: this.catalogFailures,
-      saved: this.saved,
+      catalogPartial: this.catalogPartial,
     }
   }
 

+ 5 - 6
packages/client/ui-settings-plugins/tests/section.client.spec.tsx

@@ -88,8 +88,7 @@ function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardS
     enabled: false,
     candidates: [],
     catalogStatus: 'idle',
-    catalogFailures: [],
-    saved: false,
+    catalogPartial: false,
     ...state,
   })
   const actions = {
@@ -335,10 +334,9 @@ describe('SubagentModelSelectionCard', () => {
     expect(actions.toggleEnabled).toHaveBeenCalledOnce()
   })
 
-  it('renders adapter candidates and reports a successful save', () => {
+  it('groups available adapter candidates by provider', () => {
     const actions = renderSubagentModelSelection({
       enabled: true,
-      saved: true,
       candidates: [{
         key: 'alpha\0fast',
         provider: 'alpha',
@@ -353,7 +351,7 @@ describe('SubagentModelSelectionCard', () => {
     fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
 
     expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
-    expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved)
+    expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy()
     fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ }))
     expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast')
   })
@@ -374,7 +372,7 @@ describe('SubagentModelSelectionCard', () => {
     renderSubagentModelSelection({
       enabled: true,
       catalogStatus: 'ready',
-      catalogFailures: [{ id: 'beta', name: 'Beta', message: 'offline' }],
+      catalogPartial: true,
       candidates: [{
         key: 'legacy\0old',
         provider: 'legacy',
@@ -388,6 +386,7 @@ describe('SubagentModelSelectionCard', () => {
     fireEvent.click(screen.getByText(en.subagentModelSelectionTitle))
     expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy()
     expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy()
+    expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy()
 
     cleanup()
     renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' })

+ 2 - 3
packages/client/ui-settings-plugins/tests/stores.client.spec.ts

@@ -480,7 +480,6 @@ describe('SubagentModelSelectionCardController', () => {
       enabled: true,
       dirty: false,
       saving: false,
-      saved: true,
       failed: false,
     })
   })
@@ -521,7 +520,6 @@ describe('SubagentModelSelectionCardController', () => {
       enabled: true,
       dirty: true,
       saving: false,
-      saved: false,
     })
   })
 
@@ -539,6 +537,7 @@ describe('SubagentModelSelectionCardController', () => {
     const face = controller.inject()
     const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
     await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
+    expect(state().catalogPartial).toBe(true)
 
     face.toggleModel('missing')
     expect(state().dirty).toBe(false)
@@ -576,7 +575,7 @@ describe('SubagentModelSelectionCardController', () => {
       ], 5)
     })
     expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
-      enabled: false, dirty: false, saved: true,
+      enabled: false, dirty: false,
     })
   })