diff --git a/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts new file mode 100644 index 0000000000..220c2d12c5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + applyBulkThinkingLevel, + bulkThinkingLevelStates, + relayProfileWithThinkingLevels, +} from '../../renderer/settings/relay-thinking-bulk.js'; +import { DECLARABLE_RELAY_THINKING_LEVELS } from '@maka/core/model-thinking'; +import type { RelayModelProfile } from '@maka/core/model-thinking'; + +const MODELS = ['alpha', 'beta', 'gamma']; + +test('a level nobody declares reads as absent, and one everybody declares ticks the box', () => { + const draft: Record = { + alpha: { thinkingLevels: ['high'] }, + beta: { thinkingLevels: ['high'] }, + gamma: { thinkingLevels: ['high'] }, + }; + const states = bulkThinkingLevelStates(MODELS, draft, ['high', 'low']); + assert.deepEqual(states[0], { level: 'high', declaredCount: 3, total: 3, checked: true }); + assert.deepEqual(states[1], { level: 'low', declaredCount: 0, total: 3, checked: false }); +}); + +test('partial coverage does not tick the box — only the count separates it from none', () => { + // The box is the affordance for "give this to everyone". Ticking at + // partial coverage would make the next click take the level AWAY from the + // rows that have it, which is the opposite of what the user just asked for. + const draft: Record = { + alpha: { thinkingLevels: ['high'] }, + beta: { thinkingLevels: ['high'] }, + }; + const [state] = bulkThinkingLevelStates(MODELS, draft, ['high']); + assert.equal(state?.checked, false); + assert.equal(state?.declaredCount, 2); + assert.equal(state?.total, 3); +}); + +test('a repeated model id is one model, not two', () => { + const draft: Record = { alpha: { thinkingLevels: ['high'] } }; + const [state] = bulkThinkingLevelStates(['alpha', 'alpha'], draft, ['high']); + assert.deepEqual(state, { level: 'high', declaredCount: 1, total: 1, checked: true }); +}); + +test('an empty selection ticks nothing rather than reading as fully covered', () => { + // 0 === 0 is the trap: `declaredCount === total` is true of an empty + // selection, which would present every level as declared everywhere. + for (const state of bulkThinkingLevelStates([], {}, DECLARABLE_RELAY_THINKING_LEVELS)) { + assert.equal(state.checked, false); + assert.equal(state.total, 0); + } +}); + +test('ticking a level adds it to every model, including ones with no entry yet', () => { + const next = applyBulkThinkingLevel(MODELS, { alpha: { vision: true } }, 'high', true); + assert.deepEqual(next, { + alpha: { vision: true, thinkingLevels: ['high'] }, + beta: { thinkingLevels: ['high'] }, + gamma: { thinkingLevels: ['high'] }, + }); +}); + +test('a bulk add leaves the levels a model already declared alone', () => { + const next = applyBulkThinkingLevel( + MODELS, + { alpha: { thinkingLevels: ['low', 'medium'] } }, + 'high', + true, + ); + assert.deepEqual(next.alpha?.thinkingLevels, ['low', 'medium', 'high']); +}); + +test('ticking a level a model already has does not duplicate it', () => { + const next = applyBulkThinkingLevel( + ['alpha'], + { alpha: { thinkingLevels: ['high'] } }, + 'high', + true, + ); + assert.deepEqual(next.alpha?.thinkingLevels, ['high']); +}); + +test('unticking removes only that level, and only from the selection', () => { + const next = applyBulkThinkingLevel( + ['alpha', 'beta'], + { + alpha: { thinkingLevels: ['low', 'high'] }, + beta: { thinkingLevels: ['high'] }, + gamma: { thinkingLevels: ['high'] }, + }, + 'high', + false, + ); + assert.deepEqual(next.alpha?.thinkingLevels, ['low']); + // beta held nothing but `high`: an entry with no keys left is not an + // entry, or the row keeps reading as declared and 保存 stays armed. + assert.equal('beta' in next, false); + // gamma is outside the selection — a bulk edit is scoped to the rows the + // control sits above. + assert.deepEqual(next.gamma?.thinkingLevels, ['high']); +}); + +test('unticking keeps the other declarations on a model whose levels it empties', () => { + const next = applyBulkThinkingLevel( + ['alpha'], + { alpha: { thinkingLevels: ['high'], vision: true, contextWindow: 128_000 } }, + 'high', + false, + ); + assert.deepEqual(next.alpha, { vision: true, contextWindow: 128_000 }); +}); + +test('a bulk edit does not reshuffle the draft under the rows being edited', () => { + const next = applyBulkThinkingLevel( + ['gamma', 'alpha'], + { alpha: { vision: true }, beta: { vision: false }, gamma: { vision: true } }, + 'high', + true, + ); + assert.deepEqual(Object.keys(next), ['alpha', 'beta', 'gamma']); +}); + +test('a model id colliding with a prototype key stores an entry, not a prototype write', () => { + // Ids come off the relay's /models response. `draft['constructor']` on a + // plain object answers with Object's constructor rather than "absent", + // and assigning `__proto__` writes through the prototype. + const ids = ['__proto__', 'constructor', 'toString']; + const next = applyBulkThinkingLevel(ids, {}, 'high', true); + for (const id of ids) { + assert.deepEqual(Object.getOwnPropertyDescriptor(next, id)?.value, { + thinkingLevels: ['high'], + }); + } + assert.equal(({} as Record).thinkingLevels, undefined); + // And the read side sees all three as declaring it, rather than answering + // "absent" for keys that resolve on Object.prototype. + const [state] = bulkThinkingLevelStates(ids, next, ['high']); + assert.deepEqual(state, { level: 'high', declaredCount: 3, total: 3, checked: true }); +}); + +test('an emptied declaration collapses to undefined so the caller drops the key', () => { + assert.equal(relayProfileWithThinkingLevels({ thinkingLevels: ['high'] }, []), undefined); + assert.equal(relayProfileWithThinkingLevels({ thinkingLevels: ['high'] }, undefined), undefined); + assert.deepEqual(relayProfileWithThinkingLevels({ vision: true }, ['high']), { + vision: true, + thinkingLevels: ['high'], + }); +}); + +test('clearing a level a model never declared leaves the draft untouched', () => { + const draft: Record = { alpha: { vision: true } }; + const next = applyBulkThinkingLevel(MODELS, draft, 'high', false); + assert.deepEqual(next, { alpha: { vision: true } }); +}); + +test('the bulk edit does not mutate the draft it was handed', () => { + const draft: Record = { alpha: { thinkingLevels: ['low'] } }; + applyBulkThinkingLevel(MODELS, draft, 'high', true); + assert.deepEqual(draft, { alpha: { thinkingLevels: ['low'] } }); +}); diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 57fb39e33e..bc9ad3a411 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -15,6 +15,11 @@ const zhCapabilitiesCopy = { thinkingEffortHelp: '勾选需要的思考强度档位,不勾选即为不声明。', thinkingUndeclared: '未声明', thinkingSelectedCount: (count: number) => `已选择 ${count} 个`, + thinkingBulk: '批量设置思考档位', + thinkingBulkHelp: '勾选写入下方全部已启用模型,取消勾选则从全部模型移除;其余档位不受影响。', + thinkingBulkTrigger: '应用到全部模型', + thinkingBulkCoverage: (declared: number, total: number) => + declared === 0 ? '全部未声明' : `${declared}/${total} 个模型`, visionInput: '视觉输入(vision)', visionInputHelp: '「自动」跟随内置元数据;「启用/禁用」是显式声明,覆盖自动判断。', visionAuto: '自动', @@ -34,6 +39,12 @@ const enCapabilitiesCopy = { thinkingEffortHelp: 'Tick the thinking levels this model supports; none ticked means undeclared.', thinkingUndeclared: 'Undeclared', thinkingSelectedCount: (count: number) => `${count} selected`, + thinkingBulk: 'Set thinking levels for all models', + thinkingBulkHelp: + 'Ticking adds the level to every enabled model below; unticking removes it from all of them. Other levels are left alone.', + thinkingBulkTrigger: 'Apply to all models', + thinkingBulkCoverage: (declared: number, total: number) => + declared === 0 ? 'On no model' : `On ${declared} of ${total} models`, visionInput: 'Vision input', visionInputHelp: 'Auto follows built-in metadata; Enabled/Disabled overrides it explicitly.', visionAuto: 'Auto', diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index b6c632abc7..ef6c5998d4 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -58,6 +58,7 @@ import { savedRequestHeaderDrafts, type RequestHeaderDraft, } from './request-customization-editor'; +import { bulkThinkingLevelStates } from './relay-thinking-bulk'; export function ConnectionDetail(props: ConnectionDetailProps) { const defaults = PROVIDER_DEFAULTS[props.connection.providerType]; @@ -155,6 +156,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { relayProfileDraft, hasRelayProfileChanges, setDraftThinkingLevels, + setDraftThinkingLevelForAll, setDraftVision, setDraftContextWindow, setDraftServiceTier, @@ -186,6 +188,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { !hasModelMetadata(connection.providerType, modelId), ); const showsCapabilities = capabilityModelIds.length > 0; + // The bulk control shares the 思考档位 row's relay gate — it edits exactly + // that row — and needs repetition to be worth a control at all: with one + // row it would be a second widget doing what the row under it already does. + const showsThinkingBulk = isRelay && capabilityModelIds.length > 1; // One row is a form at a time, the way the settings-sidebar template does it. // Opening a row discards the other's draft: leaving an abandoned draft in // state meant it reappeared when the user came back to that row, and — until @@ -568,6 +574,68 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { + {/* One control for the whole table, above the rows it edits. A + relay usually fronts one model family that accepts the same + reasoning_effort values, and declaring that per row was + models × levels clicks for a single fact. */} + {showsThinkingBulk && ( + + + {/* The declarable vocabulary, which is the whole of what + a draft can hold: the seed sanitizes through + `normalizeRelayModelProfiles`, so `off` — a disable + wire no generic relay is presumed to speak — cannot + reach a row here either. */} + {bulkThinkingLevelStates( + capabilityModelIds, + relayProfileDraft, + DECLARABLE_RELAY_THINKING_LEVELS, + ).map((state) => ( + { + setDraftThinkingLevelForAll(capabilityModelIds, state.level, checked); + }} + isDisabled={allActionsBusy} + /> + ))} + + + )} {capabilityModelIds.map((modelId, modelIndex) => { const declared: RelayModelProfile | undefined = relayProfileDraft[modelId]; // Vision resolves to one of three states: absent (Auto), @@ -598,7 +666,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { ); return ( - {modelIndex > 0 && } + {(modelIndex > 0 || showsThinkingBulk) && } {modelId} {/* One row per declaration: label + what it does on the left, one compact control on the right (the 模型功能 @@ -677,7 +745,11 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { setDraftContextWindow(modelId, value ?? undefined) } diff --git a/apps/desktop/src/renderer/settings/relay-thinking-bulk.ts b/apps/desktop/src/renderer/settings/relay-thinking-bulk.ts new file mode 100644 index 0000000000..5e6a3de60d --- /dev/null +++ b/apps/desktop/src/renderer/settings/relay-thinking-bulk.ts @@ -0,0 +1,134 @@ +import type { RelayModelProfile, ThinkingLevel } from '@maka/core/model-thinking'; + +/** + * Bulk thinking-level editing for the capability section's relay-profile + * draft. + * + * A relay commonly fronts a whole family of models that accept the same + * `reasoning_effort` values, and the per-model rows made declaring that an + * N-models × M-levels click exercise. This module is the shared, testable + * half of the one control that does it in one gesture; the hook owns the + * state and the component owns the menu. + * + * The bulk control is **per level**, not per level *set*: ticking `high` + * adds `high` everywhere and unticking removes it everywhere, leaving every + * other level on every model exactly as the user left it. A replace-the-set + * design would have been one fewer concept, but it silently discards + * whatever a row already declared — the destructive reading of a control + * whose whole point is to save clicks. + */ + +/** + * Pin or clear one model's declared levels, shared by the single-row setter + * and the bulk path so both agree on what an emptied declaration *is*. + * + * An entry with no keys left is not an entry: `{}` would keep a row reading + * as declared (and `hasRelayProfileChanges` reading as dirty) after the user + * cleared its last field, so it collapses to `undefined` and the caller drops + * the key. + */ +export function relayProfileWithThinkingLevels( + current: RelayModelProfile | undefined, + levels: readonly ThinkingLevel[] | undefined, +): RelayModelProfile | undefined { + if (levels === undefined || levels.length === 0) { + if (!current) return current; + const { thinkingLevels: _dropped, ...rest } = current; + return Object.keys(rest).length > 0 ? rest : undefined; + } + return { ...(current ?? {}), thinkingLevels: [...levels] }; +} + +/** + * How much of the current selection declares one level. `declaredCount` is + * what the menu shows; `checked` is the box, and it is `true` only at full + * coverage — a partially covered level reads as unticked with its count + * beside it, so one more click means "give it to everyone" rather than + * "take it from the rows that have it". + */ +export interface BulkThinkingLevelState { + readonly level: ThinkingLevel; + readonly declaredCount: number; + readonly total: number; + readonly checked: boolean; +} + +function draftIndex( + draft: Readonly>, +): ReadonlyMap { + // Object.entries, not `draft[modelId]`: model ids come from the relay and + // may collide with prototype keys, where a plain index read answers with + // `Object.prototype.constructor` instead of "absent". + return new Map(Object.entries(draft)); +} + +export function bulkThinkingLevelStates( + modelIds: readonly string[], + draft: Readonly>, + levels: readonly ThinkingLevel[], +): readonly BulkThinkingLevelState[] { + const index = draftIndex(draft); + // A duplicated id is one model, not two: it must not inflate the + // denominator past what the rows below actually show. + const targets = [...new Set(modelIds)]; + return levels.map((level) => { + const declaredCount = targets.filter((modelId) => + index.get(modelId)?.thinkingLevels?.includes(level), + ).length; + return { + level, + declaredCount, + total: targets.length, + checked: targets.length > 0 && declaredCount === targets.length, + }; + }); +} + +/** + * Add or remove one level across every model in the current selection, + * returning the next draft. + * + * Models outside `modelIds` keep their entries untouched. The draft may hold + * declarations for models the user has since disabled — reads prune against + * the enabled set rather than the draft doing it — and a bulk edit is not + * the place to decide those are gone. + */ +export function applyBulkThinkingLevel( + modelIds: readonly string[], + draft: Readonly>, + level: ThinkingLevel, + checked: boolean, +): Record { + const targets = new Set(modelIds); + const entries: [string, RelayModelProfile][] = []; + const placed = new Set(); + const next = (current: RelayModelProfile | undefined): RelayModelProfile | undefined => { + const declared = current?.thinkingLevels ?? []; + if (checked) { + return declared.includes(level) + ? current + : relayProfileWithThinkingLevels(current, [...declared, level]); + } + return relayProfileWithThinkingLevels( + current, + declared.filter((existing) => existing !== level), + ); + }; + // Existing entries first, in place: a bulk edit must not reshuffle the + // draft under the rows the user is looking at. + for (const [modelId, profile] of Object.entries(draft)) { + placed.add(modelId); + const updated = targets.has(modelId) ? next(profile) : profile; + if (updated !== undefined) entries.push([modelId, updated]); + } + for (const modelId of modelIds) { + if (placed.has(modelId)) continue; + placed.add(modelId); + const updated = next(undefined); + if (updated !== undefined) entries.push([modelId, updated]); + } + // fromEntries, not assignment onto an object literal, for the same reason + // the core normalizer uses it: a relay-supplied `__proto__` key would + // otherwise write through the prototype instead of storing an entry. + return Object.fromEntries(entries); +} diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index b3016aee1f..639dc2bc65 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -23,6 +23,7 @@ import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; import { connectionChipStatus } from './provider-connection-status'; import { relayProfileDraftReseedPlan, relayProfileDraftSeed } from './relay-profile-draft'; +import { applyBulkThinkingLevel, relayProfileWithThinkingLevels } from './relay-thinking-bulk'; import { useKeyedActionGuard } from './use-action-guard'; import type { OAuthLoginFlowBridge } from './use-oauth-login-flow'; import { @@ -378,16 +379,24 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // One shape for all three fields: the field setter pins or removes its key, // and an entry with no keys left IS the undeclared state — storing it would - // keep the row looking edited after the user emptied every field. + // keep the row looking edited after the user emptied every field. The rule + // lives in relay-thinking-bulk so the row setter and the bulk control + // cannot drift on what an emptied declaration collapses to. function setDraftThinkingLevels(modelId: string, levels: ThinkingLevel[] | undefined): void { - updateRelayProfileDraft(modelId, (current) => { - if (levels === undefined || levels.length === 0) { - if (!current) return current; - const { thinkingLevels: _dropped, ...rest } = current; - return Object.keys(rest).length > 0 ? rest : undefined; - } - return { ...(current ?? {}), thinkingLevels: levels }; - }); + updateRelayProfileDraft(modelId, (current) => relayProfileWithThinkingLevels(current, levels)); + } + + // The same edit across every enabled model, as ONE state update rather than + // a loop of per-model setters: a bulk tick is a single user gesture, and + // committing it in N steps would let a re-render land mid-way and paint a + // half-applied table. + function setDraftThinkingLevelForAll( + modelIds: readonly string[], + level: ThinkingLevel, + checked: boolean, + ): void { + setRelayProfilesDirty(true); + setRelayProfileDrafts((current) => applyBulkThinkingLevel(modelIds, current, level, checked)); } // Tri-state vision: undefined = Auto (relay/metadata decides), true/false @@ -723,6 +732,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) { relayProfilesDirty, hasRelayProfileChanges, setDraftThinkingLevels, + setDraftThinkingLevelForAll, setDraftVision, setDraftContextWindow, setDraftServiceTier, diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index 25131b56fa..b30e1b8b8a 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core'; import { ToastProvider } from '@maka/ui'; import type { @@ -28,7 +29,14 @@ const meta = { export default meta; type Story = StoryObj; -type AutoOpenTarget = 'detail' | 'detail-static' | 'add' | 'catalog' | 'oauth' | 'xai-device'; +type AutoOpenTarget = + | 'detail' + | 'detail-static' + | 'detail-relay' + | 'add' + | 'catalog' + | 'oauth' + | 'xai-device'; function makeConnection(input: { slug: string; @@ -120,6 +128,34 @@ const staticCatalogConnections = [ }, ]; +// A custom relay fronting one model family. Capability declarations are a +// relay-only surface — a built-in provider's thinking support comes from +// bundled metadata — and the family shares one `reasoning_effort` vocabulary, +// which is the case the bulk control exists for. `deepseek-r2` already +// declares two levels so the story shows partial coverage, not just the +// all-or-nothing ends. +const relayConnections = [ + { + ...makeConnection({ + slug: 'relay-house', + name: 'House Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example.com/v1', + defaultModel: 'deepseek-r2', + lastTestStatus: 'verified', + models: [ + { id: 'deepseek-r2' }, + { id: 'deepseek-v4' }, + { id: 'qwen3-max-thinking' }, + { id: 'kimi-k2.6' }, + ], + modelSource: 'fetched', + }), + enabledModelIds: ['deepseek-r2', 'deepseek-v4', 'qwen3-max-thinking', 'kimi-k2.6'], + relayModelProfiles: { 'deepseek-r2': { thinkingLevels: ['low', 'high'] as const } }, + }, +]; + const problemConnections = [ configuredConnections[0], makeConnection({ @@ -371,10 +407,11 @@ function reachCatalog(root: HTMLElement): HTMLElement | null { } function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean { - if (target === 'detail' || target === 'detail-static') { + if (target === 'detail' || target === 'detail-static' || target === 'detail-relay') { // 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 slug = target === 'detail' ? 'zai-live' : 'ark-plan'; + const slug = + target === 'detail' ? 'zai-live' : target === 'detail-static' ? 'ark-plan' : 'relay-house'; const row = root.querySelector(`[data-connection-slug="${slug}"]`); const detailButton = row?.querySelector('button') ?? null; detailButton?.click(); @@ -452,6 +489,45 @@ export const StaticCatalogConnectionDetail: Story = { ), }; +// Real path: 设置 → 模型 → click a custom relay — the capability section with +// several enabled models, where 批量设置思考档位 sits above the per-model rows it +// writes into. Opening its menu shows each level's coverage across the table: +// `low` and `high` on 1 of 4, everything else on none. +export const RelayConnectionDetail: Story = { + render: () => ( + + ), + // Opens the batch menu and asserts that partial coverage reaches assistive + // technology, not only the eye. The item carries its own `aria-label`, which + // replaces the accessible name the visible description would otherwise have + // joined — and the menu item does not wire `description` to + // `aria-describedby`. Without an explicit description, "1/4 个模型" and + // "全部未声明" both reach a screen reader as an unchecked box with the same + // name, which is exactly the state the count exists to distinguish. + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const trigger = await body.findByRole('button', { name: /批量设置思考档位/ }); + await userEvent.click(trigger); + + // `low` is declared by one of the four models; `minimal` by none. + const partial = await body.findByRole('menuitemcheckbox', { + name: '批量设置思考档位 low', + }); + const none = await body.findByRole('menuitemcheckbox', { + name: '批量设置思考档位 minimal', + }); + + // Both are unchecked — coverage is the only thing separating them. + await expect(partial).toHaveAttribute('aria-checked', 'false'); + await expect(none).toHaveAttribute('aria-checked', 'false'); + await expect(partial).toHaveAttribute('aria-description', '1/4 个模型'); + await expect(none).toHaveAttribute('aria-description', '全部未声明'); + }, +}; + // Real path: 设置 → 模型 → 添加连接 — level two, the provider catalog. export const AddConnectionCatalog: Story = { render: () => (