From 2cc362ca24c54fec1ec9fdc83c9bef955ddcdf51 Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Wed, 16 Sep 2026 18:59:24 +0800 Subject: [PATCH 1/2] feat(panel): add Host model picker under the composer Map session.models to session/modelCatalog so connected Hosts expose their routable models, and replace chip tags with an upward menu on a dedicated row beneath the input. --- extensions/dsh-browser/src/panel/App.tsx | 241 +++++++++++++++++- extensions/dsh-browser/src/panel/strings.ts | 6 + extensions/dsh-browser/src/panel/styles.css | 160 ++++++++++++ .../dsh-browser/tests/panel-styles.spec.ts | 13 + .../bridge-browser/src/remote-host-api.ts | 46 ++++ .../bridge-browser/src/session-deferral.ts | 168 ++++++++++++ .../tests/remote-host-api.spec.ts | 32 ++- .../tests/session-deferral.spec.ts | 60 +++++ 8 files changed, 724 insertions(+), 2 deletions(-) diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 51c455f7c..56d1cac20 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -110,6 +110,21 @@ interface RelayProfileDraft { /** Route keys managed by the relay editor; core-owned routes are never touched. */ const RELAY_ROUTE_PREFIX = 'relay-' +/** One selectable entry from `session.models` for the composer chip list. */ +interface SessionModelOption { + provider: string + providerName: string + id: string + name: string +} + +/** Advisory directory snapshot used by the composer model switcher. */ +interface SessionModelDirectory { + current: { provider: string; model: string; reasoningEffort?: string } | null + options: SessionModelOption[] + routable: boolean | null +} + /** * Display names are free-form (CJK included); the route key needs the * ASCII shape the wire and credential refs expect, so CJK-heavy names @@ -640,6 +655,10 @@ export function App(): React.JSX.Element { const [relayLoaded, setRelayLoaded] = useState(false) const [relayNotice, setRelayNotice] = useState(null) const [relayBusy, setRelayBusy] = useState(false) + const [sessionModels, setSessionModels] = useState(null) + const [modelSelecting, setModelSelecting] = useState(false) + const [showModelPicker, setShowModelPicker] = useState(false) + const modelPickerRef = useRef(null) const [sessionTitle, setSessionTitle] = useState(null) const [resumeHint, setResumeHint] = useState<{ ready: boolean; sessionId: string | null }>({ ready: false, sessionId: null }) const [questions, setQuestions] = useState([]) @@ -787,6 +806,9 @@ export function App(): React.JSX.Element { setDraft((current) => ({ ...current, images: [] })) setImageLimits(null) imageProjectionRef.current = { sessionId: null, seq: Number.NEGATIVE_INFINITY, limits: null } + setSessionModels(null) + setModelSelecting(false) + setShowModelPicker(false) setSessionTitle(null) setWorking(false) setStopping(false) @@ -845,6 +867,24 @@ export function App(): React.JSX.Element { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) }, [rows, streamRow, working]) + useEffect(() => { + if (!showModelPicker) return + const onPointerDown = (event: PointerEvent): void => { + const root = modelPickerRef.current + if (root !== null && event.target instanceof Node && root.contains(event.target)) return + setShowModelPicker(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setShowModelPicker(false) + } + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('keydown', onKeyDown, true) + return () => { + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('keydown', onKeyDown, true) + } + }, [showModelPicker]) + function applyImageProjection(sessionId: string, seq: number, value: unknown): void { if (sessionRef.current !== sessionId || !Number.isSafeInteger(seq)) return const previous = imageProjectionRef.current @@ -1102,6 +1142,124 @@ export function App(): React.JSX.Element { setSessionTitle(null) sessionRuntimeRef.current.seedRunning(created.sessionId, false) applyHistory(created.sessionId, await readHistory(created.sessionId)) + await refreshSessionModels(created.sessionId) + } + + /** Pull the advisory model directory for the active session into the composer menu. */ + async function refreshSessionModels(sessionId: string | null = sessionRef.current): Promise { + if (sessionId === null) { + setSessionModels(null) + setShowModelPicker(false) + return + } + const fromSettings = await loadModelDirectoryFromSettings().catch(() => null) + try { + const result = await api.rpc<{ + current?: { provider: string; model: string; reasoningEffort?: string } + routable?: boolean + groups?: Array<{ id: string; name: string; models: Array<{ id: string; name: string }> }> + }>('session.models', { sessionId }) + if (sessionRef.current !== sessionId) return + const sessionOptions = (result.groups ?? []).flatMap((group) => group.models.map((model) => ({ + provider: group.id, + providerName: group.name, + id: model.id, + name: model.name, + }))) + const options = sessionOptions.length > 0 ? sessionOptions : (fromSettings?.options ?? []) + setSessionModels({ + current: result.current + ?? fromSettings?.current + ?? (options[0] === undefined + ? null + : { provider: options[0].provider, model: options[0].id }), + routable: result.routable ?? fromSettings?.routable ?? null, + options, + }) + } catch { + if (sessionRef.current !== sessionId) return + // Host-wide settings still list configured relays before a session exists. + setSessionModels(fromSettings) + } + } + + /** Build a composer directory from saved llm-pi-ai routes (works without a Host session). */ + async function loadModelDirectoryFromSettings(): Promise { + const described = await api.rpc<{ + namespaces?: Array<{ ns: string; value?: Record }> + }>('settings.describe', {}) + const providers = (described.namespaces?.find((candidate) => candidate.ns === 'llm-pi-ai') + ?.value?.providers ?? {}) as Record + }> + const defaults = (described.namespaces?.find((candidate) => candidate.ns === 'agent-default-model') + ?.value ?? {}) as { provider?: unknown; model?: unknown; reasoningEffort?: unknown } + const options = Object.entries(providers).flatMap(([provider, route]) => ( + (route.models ?? []).map((model) => ({ + provider, + providerName: typeof route.displayName === 'string' && route.displayName !== '' + ? route.displayName + : provider, + id: model.id, + name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id, + })) + )).filter((option) => option.id !== '') + if (options.length === 0) return { current: null, options: [], routable: false } + const defaultProvider = typeof defaults.provider === 'string' ? defaults.provider : '' + const defaultModel = typeof defaults.model === 'string' ? defaults.model : '' + const matched = options.find((option) => ( + option.provider === defaultProvider && option.id === defaultModel + )) ?? options[0]! + return { + current: { + provider: matched.provider, + model: matched.id, + ...(typeof defaults.reasoningEffort === 'string' && defaults.reasoningEffort !== '' + ? { reasoningEffort: defaults.reasoningEffort } + : {}), + }, + options, + routable: true, + } + } + + /** Switch the next assembled turn onto the chosen provider/model. */ + async function selectSessionModel(option: SessionModelOption): Promise { + const sessionId = sessionRef.current + if (sessionId === null || modelSelecting || busy || working) return + setShowModelPicker(false) + if (sessionModels?.current?.provider === option.provider + && sessionModels.current.model === option.id) return + setModelSelecting(true) + setError(null) + try { + const result = await api.rpc<{ + selected: { provider: string; model: string; reasoningEffort?: string } + }>('session.selectModel', { + sessionId, + provider: option.provider, + model: option.id, + }) + if (sessionRef.current !== sessionId) return + setSessionModels((current) => current === null + ? { + current: result.selected, + routable: true, + options: [option], + } + : { + ...current, + current: result.selected, + routable: true, + }) + } catch (cause) { + if (sessionRef.current === sessionId) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + } finally { + setModelSelecting(false) + } } /** Load the raw host index plus workspace archive state once. */ @@ -1152,6 +1310,7 @@ export function App(): React.JSX.Element { await api.setActiveSession(hinted) setSessionTitle(projectedSessionTitle(entry) ?? sessionDisplayTitle(entry)) applyHistory(hinted, history) + await refreshSessionModels(hinted) return } } catch { @@ -1203,6 +1362,7 @@ export function App(): React.JSX.Element { sessionRef.current = entry.sessionId setSessionTitle(projectedSessionTitle(entry) ?? sessionDisplayTitle(entry)) await refreshHistory(entry.sessionId) + await refreshSessionModels(entry.sessionId) } catch (cause) { if (sessionTransitionRef.current === transition) { setError(cause instanceof Error ? cause.message : String(cause)) @@ -1251,6 +1411,7 @@ export function App(): React.JSX.Element { setSessionTitle(sessionId) if (history !== undefined) applyHistory(sessionId, history) else setError(historyError instanceof Error ? historyError.message : String(historyError)) + await refreshSessionModels(sessionId) } finally { finishSessionTransition(transition) } @@ -1263,6 +1424,8 @@ export function App(): React.JSX.Element { try { sessionRef.current = null setSessionTitle(null) + setSessionModels(null) + setShowModelPicker(false) prepareSessionSwitch(false) await createSession(transition) } catch (cause) { @@ -1371,6 +1534,8 @@ export function App(): React.JSX.Element { ), ...(clientTimeZone === undefined ? {} : { clientTimeZone }), }) + // Deferred sessions materialize on the first prompt; refresh so chips appear. + void refreshSessionModels(id) if (submittedSelection !== null) { // Keep the background authoritative while the prompt is in flight. // Conditional clearing cannot consume a newer highlight captured in @@ -1422,6 +1587,7 @@ export function App(): React.JSX.Element { if (!relaySaved) return await api.updateSettings(settings) setShowSettings(false) + await refreshSessionModels(sessionRef.current) } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } @@ -1698,6 +1864,30 @@ export function App(): React.JSX.Element { // 状态栏只显示连接状态;快照上限是技术细节,在设置页说明(见 hint)。 const statusText = copy.status[state] const sessionMenuTitle = sessionTitle ?? copy.app.newSession + const currentModelOption = sessionModels?.options.find((option) => ( + option.provider === sessionModels.current?.provider + && option.id === sessionModels.current.model + )) + const currentModelLabel = currentModelOption?.name + ?? (sessionModels !== null && sessionModels.options.length === 0 + ? copy.app.noModelsConfigured + : copy.app.selectModel) + const modelMenuGroups = (() => { + const groups: Array<{ provider: string; providerName: string; models: SessionModelOption[] }> = [] + for (const option of sessionModels?.options ?? []) { + const existing = groups.find((group) => group.provider === option.provider) + if (existing === undefined) { + groups.push({ + provider: option.provider, + providerName: option.providerName, + models: [option], + }) + continue + } + existing.models.push(option) + } + return groups + })() const approvalDialog = !approvalReadyForSession(queuedApproval, sessionRef.current, sessionChanging) ? null : @@ -1948,7 +2138,10 @@ export function App(): React.JSX.Element { aria-expanded={showTextSize} aria-label={copy.textSize.open} title={copy.textSize.open}> - @@ -2139,6 +2332,52 @@ export function App(): React.JSX.Element { )} + {state === 'connected' ? ( +
+ + {showModelPicker && sessionModels !== null && sessionModels.options.length > 0 ? ( +
+ {modelMenuGroups.map((group) => ( +
+ {modelMenuGroups.length > 1 ? ( +
{group.providerName}
+ ) : null} + {group.models.map((option) => { + const selected = sessionModels.current?.provider === option.provider + && sessionModels.current.model === option.id + return ( + + ) + })} +
+ ))} +
+ ) : null} +
+ ) : null} {approvalDialog} ) diff --git a/extensions/dsh-browser/src/panel/strings.ts b/extensions/dsh-browser/src/panel/strings.ts index d255d342a..e7458a060 100644 --- a/extensions/dsh-browser/src/panel/strings.ts +++ b/extensions/dsh-browser/src/panel/strings.ts @@ -181,6 +181,8 @@ export interface PanelCopy { disconnectedPlaceholder: string composerHelp: string sendMessage: string + selectModel: string + noModelsConfigured: string stopTurn: string stoppingTurn: string addImages: string @@ -411,6 +413,8 @@ const EN: PanelCopy = { disconnectedPlaceholder: 'Connect to dsh to get started', composerHelp: 'Enter to send · Shift + Enter for a new line', sendMessage: 'Send message', + selectModel: 'Model', + noModelsConfigured: 'No models yet — Host has no routable models.', stopTurn: 'Stop generating', stoppingTurn: 'Stopping…', addImages: 'Add images', @@ -641,6 +645,8 @@ const ZH: PanelCopy = { disconnectedPlaceholder: '连接 dsh 后即可开始', composerHelp: 'Enter 发送 · Shift + Enter 换行', sendMessage: '发送消息', + selectModel: '模型', + noModelsConfigured: '暂无模型 — Host 尚未提供可用模型。', stopTurn: '停止生成', stoppingTurn: '正在停止…', addImages: '添加图片', diff --git a/extensions/dsh-browser/src/panel/styles.css b/extensions/dsh-browser/src/panel/styles.css index 2f22fc12a..9b410fb06 100644 --- a/extensions/dsh-browser/src/panel/styles.css +++ b/extensions/dsh-browser/src/panel/styles.css @@ -1499,6 +1499,138 @@ textarea:focus-visible { cursor: not-allowed; } +.composer-model { + position: relative; + display: flex; + min-width: 0; + margin-top: 8px; + padding: 0 2px; +} + +.composer-model-trigger { + display: inline-flex; + max-width: min(280px, 100%); + min-height: 28px; + align-items: center; + gap: 4px; + padding: 0 8px 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--canvas); + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: calc(11px * var(--ui-scale)); + font-weight: 600; + line-height: 1.2; +} + +.composer-model-trigger > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-model-trigger svg { + width: 13px; + height: 13px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; + transition: transform 150ms ease; +} + +.composer-model-trigger:hover:not(:disabled), +.composer-model-trigger[aria-expanded="true"] { + border-color: var(--line-strong); + background: var(--surface); + color: var(--ink); +} + +.composer-model-trigger[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +.composer-model-trigger:disabled { + opacity: .55; + cursor: default; +} + +.composer-model-menu { + position: absolute; + left: 2px; + bottom: calc(100% + 6px); + z-index: 40; + display: flex; + min-width: max(200px, 100%); + max-width: min(320px, calc(100vw - 24px)); + max-height: min(280px, 42vh); + flex-direction: column; + gap: 2px; + overflow: auto; + padding: 4px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 10px 28px rgba(18, 28, 56, 0.12); +} + +.composer-model-group + .composer-model-group { + margin-top: 2px; + padding-top: 2px; + border-top: 1px solid var(--line); +} + +.composer-model-group-title { + color: var(--faint); + font-size: calc(10px * var(--ui-scale)); + font-weight: 600; + line-height: 1.3; + padding: 5px 8px 3px; +} + +.composer-model-menu button { + display: flex; + width: 100%; + min-height: 32px; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink); + cursor: pointer; + font: inherit; + font-size: calc(12px * var(--ui-scale)); + font-weight: 560; + line-height: 1.3; + text-align: left; +} + +.composer-model-menu button > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-model-menu button:hover, +.composer-model-menu button:focus-visible { + background: var(--blue-wash); + color: var(--blue-ink); + outline: none; +} + +.composer-model-menu button.isSelected { + background: var(--blue-wash); + color: var(--blue-ink); + font-weight: 680; +} + .composer-actions { display: flex; min-height: 37px; @@ -2667,6 +2799,34 @@ textarea:focus-visible { gap: 3px; } +.relay-profile label.setting-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 4px 0; + border-bottom: 0; + cursor: pointer; +} + +.relay-profile label.setting-toggle > .setting-toggle-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.relay-profile label.setting-toggle > .setting-toggle-copy > strong { + color: var(--ink); + font-size: calc(10.5px * var(--ui-scale)); + font-weight: 640; + line-height: 1.35; +} + +.relay-profile label.setting-toggle > .setting-toggle-copy > small { + text-align: left; +} + .relay-profile label > span { color: var(--ink); font-size: calc(10.5px * var(--ui-scale)); diff --git a/extensions/dsh-browser/tests/panel-styles.spec.ts b/extensions/dsh-browser/tests/panel-styles.spec.ts index f676d9a98..b1ac3c797 100644 --- a/extensions/dsh-browser/tests/panel-styles.spec.ts +++ b/extensions/dsh-browser/tests/panel-styles.spec.ts @@ -21,4 +21,17 @@ describe('panel layout styles', () => { expect(settingsChildrenRule).toBeDefined() expect(settingsChildrenRule).toMatch(/(?:^|\n)\s*flex-shrink:\s*0;/) }) + + it('places an upward model menu on its own row under the composer', () => { + const styles = readFileSync(`${process.cwd()}/src/panel/styles.css`, 'utf8') + const modelRule = styles.match(/\.composer-model\s*\{([^}]*)\}/)?.[1] + const menuRule = styles.match(/\.composer-model-menu\s*\{([^}]*)\}/)?.[1] + + expect(modelRule).toBeDefined() + expect(modelRule).toMatch(/(?:^|\n)\s*position:\s*relative;/) + expect(modelRule).toMatch(/(?:^|\n)\s*margin-top:\s*8px;/) + expect(menuRule).toBeDefined() + expect(menuRule).toMatch(/(?:^|\n)\s*position:\s*absolute;/) + expect(menuRule).toMatch(/(?:^|\n)\s*bottom:\s*calc\(100% \+ 6px\);/) + }) }) diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index af4e406d4..532981914 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -622,6 +622,15 @@ function invokeTarget(call: HostRpcCall): InvokeTarget | { readonly error: HostR switch (call.method) { case 'session.list': return { namespace: 'session', method: 'list', args: { _request: call.payload } } + case 'session.models': + // Host Typert exposes session/modelCatalog (no args). Adapt ModelCatalog + // into the panel's { current, routable, groups } directory shape. + return { + namespace: 'session', + method: 'modelCatalog', + args: {}, + adapt: adaptModelCatalog, + } case 'session.create': case 'session.selectModel': case 'session.attachment': @@ -885,6 +894,43 @@ function sessionIdOf(payload: unknown): string | undefined { : undefined } +/** Map Host ModelCatalog into the extension's session.models directory shape. */ +function adaptModelCatalog(value: unknown): unknown { + if (!isRecord(value)) return value + const selection = modelSelectionOf(value.default) + const groups = Array.isArray(value.groups) ? value.groups : [] + const failures = Array.isArray(value.failures) ? value.failures : [] + const routableProviders = Array.isArray(value.routableProviders) + ? value.routableProviders.filter((entry): entry is string => typeof entry === 'string') + : [] + const current = selection ?? { provider: 'none', model: 'none' } + return { + current, + routable: selection !== undefined && routableProviders.includes(selection.provider), + groups, + failures, + } +} + +function modelSelectionOf(value: unknown): { + provider: string + model: string + reasoningEffort?: string +} | undefined { + if (!isRecord(value)) return undefined + const provider = typeof value.provider === 'string' ? value.provider.trim() : '' + const model = typeof value.model === 'string' ? value.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== '' + ? value.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + function badRequest(message: string): HostRpcResult { return { ok: false, error: badRequestFailure(message) } } diff --git a/packages/browser/bridge-browser/src/session-deferral.ts b/packages/browser/bridge-browser/src/session-deferral.ts index 9f0dbe103..896586894 100644 --- a/packages/browser/bridge-browser/src/session-deferral.ts +++ b/packages/browser/bridge-browser/src/session-deferral.ts @@ -9,6 +9,11 @@ * the first `session.prompt` for that id. Abandoned provisional ids are * pruned after {@link PROVISIONAL_TTL_MS}. * + * Provisional sessions also answer `session.models` from the host-wide + * `session.modelCatalog` (via the Host API adapter, plus a pending switch) + * and remember `session.selectModel` until materialization, so the composer can + * show a model switcher before the first message. + * * @module @yuxianglin/dsh-bridge-browser/src/session-deferral */ @@ -19,10 +24,18 @@ import { isRecord } from './host-api.ts' /** Provisional entries older than this are dropped on the next create. */ const PROVISIONAL_TTL_MS = 30 * 60_000 +interface ModelSelection { + provider: string + model: string + reasoningEffort?: string +} + interface ProvisionalEntry { /** The original create payload, replayed at materialization (keeps cwd/workspaceId). */ payload: Record createdAt: number + /** Composer switch chosen before the session exists on the Host. */ + selection?: ModelSelection } /** @@ -81,6 +94,29 @@ export function withSessionDeferral( }, } } + if (call.method === 'session.models') { + const sessionId = sessionIdOf(call.payload) + if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call) + return provisionalModels(api, provisional.get(sessionId)!, call.signal) + } + if (call.method === 'session.selectModel') { + const sessionId = sessionIdOf(call.payload) + if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call) + const entry = provisional.get(sessionId)! + const selected = selectionOf(call.payload) + if (selected === undefined) { + return { + ok: false, + error: { + code: 'bad-request', + message: 'session.selectModel requires provider and model', + details: {}, + }, + } + } + entry.selection = selected + return { ok: true, value: { selected: { ...selected } } } + } if (call.method !== 'session.prompt') return api.call(call) const sessionId = sessionIdOf(call.payload) if (sessionId === undefined) return api.call(call) @@ -102,7 +138,20 @@ export function withSessionDeferral( } const created = await pending if (!created.ok) return created + const selection = entry.selection provisional.delete(sessionId) + if (selection !== undefined) { + try { + await api.call({ + rpcId: crypto.randomUUID(), + method: 'session.selectModel', + payload: { sessionId, ...selection }, + signal: call.signal, + }) + } catch { + // The prompt still proceeds; the Host keeps its deployment default. + } + } return api.call(call) }, events: signal => api.events(signal), @@ -114,3 +163,122 @@ function sessionIdOf(payload: unknown): string | undefined { if (!isRecord(payload)) return undefined return typeof payload.sessionId === 'string' ? payload.sessionId : undefined } + +function selectionOf(payload: unknown): ModelSelection | undefined { + if (!isRecord(payload)) return undefined + const provider = typeof payload.provider === 'string' ? payload.provider.trim() : '' + const model = typeof payload.model === 'string' ? payload.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof payload.reasoningEffort === 'string' && payload.reasoningEffort.trim() !== '' + ? payload.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + +/** Build a session.models-shaped answer from the host catalog for a provisional id. */ +async function provisionalModels( + api: BrowserHostApi, + entry: ProvisionalEntry, + signal: AbortSignal, +): Promise { + // Inner API (not the deferral wrapper): session.models → session/modelCatalog. + const catalog = await api.call({ + rpcId: crypto.randomUUID(), + method: 'session.models', + payload: {}, + signal, + }) + if (!catalog.ok) return catalog + const groups = isRecord(catalog.value) && Array.isArray(catalog.value.groups) + ? catalog.value.groups + : [] + const failures = isRecord(catalog.value) && Array.isArray(catalog.value.failures) + ? catalog.value.failures + : [] + const catalogCurrent = isRecord(catalog.value) ? modelSelectionOf(catalog.value.current) : undefined + const current = entry.selection + ?? catalogCurrent + ?? await defaultSelection(api, signal) + ?? firstCatalogSelection(groups) + if (current === undefined) { + return { + ok: true, + value: { + current: { provider: 'none', model: 'none' }, + routable: false, + groups, + failures, + }, + } + } + return { + ok: true, + value: { + current: { ...current }, + routable: true, + groups, + failures, + }, + } +} + +function modelSelectionOf(value: unknown): ModelSelection | undefined { + if (!isRecord(value)) return undefined + const provider = typeof value.provider === 'string' ? value.provider.trim() : '' + const model = typeof value.model === 'string' ? value.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== '' + ? value.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + +async function defaultSelection( + api: BrowserHostApi, + signal: AbortSignal, +): Promise { + const described = await api.call({ + rpcId: crypto.randomUUID(), + method: 'settings.describe', + payload: {}, + signal, + }) + if (!described.ok || !isRecord(described.value) || !Array.isArray(described.value.namespaces)) { + return undefined + } + const defaults = described.value.namespaces.find((candidate) => ( + isRecord(candidate) && candidate.ns === 'agent-default-model' + )) + const value = isRecord(defaults) && isRecord(defaults.value) ? defaults.value : undefined + if (value === undefined) return undefined + const provider = typeof value.provider === 'string' ? value.provider.trim() : '' + const model = typeof value.model === 'string' ? value.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== '' + ? value.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + +function firstCatalogSelection(groups: unknown[]): ModelSelection | undefined { + for (const group of groups) { + if (!isRecord(group) || typeof group.id !== 'string' || !Array.isArray(group.models)) continue + for (const model of group.models) { + if (!isRecord(model) || typeof model.id !== 'string' || model.id.trim() === '') continue + return { provider: group.id, model: model.id } + } + } + return undefined +} diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 37e7b1a80..f24cbe2e9 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -110,12 +110,37 @@ describe('dsh 0.1.5 Remote Host adapter', () => { invoke: async ({ namespace, method }) => { if (`${namespace}/${method}` === 'credentials/describe') return { TOKEN: { configured: true } } if (`${namespace}/${method}` === 'llm/discoverModels') return [{ id: 'deepseek-chat' }] + if (`${namespace}/${method}` === 'session/modelCatalog') { + return { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official'], + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + } + } return { accepted: true } }, }) await api.call(call('session.list', {})) await api.call(call('session.prompt', { sessionId: 'session-1', mode: 'queue', content: [] }, 'prompt-id')) + await expect(api.call(call('session.models', { sessionId: 'session-1' }))).resolves.toEqual({ + ok: true, + value: { + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + }, + }) await api.call(call('settings.mutate', { ns: 'llm-pi-ai', ops: [] })) await expect(api.call(call('credentials.describe', { refs: ['TOKEN'] }))).resolves.toEqual({ ok: true, value: { credentials: { TOKEN: { configured: true } } }, @@ -133,9 +158,14 @@ describe('dsh 0.1.5 Remote Host adapter', () => { args: { request: { requestId: 'prompt-id', sessionId: 'session-1', mode: 'queue', content: [] } }, })) expect(invoke).toHaveBeenNthCalledWith(3, expect.objectContaining({ + namespace: 'session', + method: 'modelCatalog', + args: {}, + })) + expect(invoke).toHaveBeenNthCalledWith(4, expect.objectContaining({ namespace: 'settings', method: 'mutate', args: { ns: 'llm-pi-ai', ops: [] }, })) - expect(invoke).toHaveBeenNthCalledWith(5, expect.objectContaining({ + expect(invoke).toHaveBeenNthCalledWith(6, expect.objectContaining({ namespace: 'llm', method: 'discoverModels', args: { diff --git a/packages/browser/bridge-browser/tests/session-deferral.spec.ts b/packages/browser/bridge-browser/tests/session-deferral.spec.ts index eb2b1608d..5a535658c 100644 --- a/packages/browser/bridge-browser/tests/session-deferral.spec.ts +++ b/packages/browser/bridge-browser/tests/session-deferral.spec.ts @@ -153,6 +153,66 @@ describe('withSessionDeferral', () => { expect(sessionPrompt).toHaveBeenCalledOnce() }) + it('serves provisional model catalogs from session.models and defers selectModel', async () => { + const { api, call } = apiHarness() + call.mockImplementation(async (request: HostRpcCall): Promise => { + if (request.method === 'session.create') { + return { ok: true, value: { sessionId: (request.payload as { sessionId: string }).sessionId } } + } + if (request.method === 'session.prompt') return { ok: true, value: { accepted: true } } + if (request.method === 'session.models') { + return { + ok: true, + value: { + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + }, + } + } + if (request.method === 'session.selectModel') { + return { ok: true, value: { selected: request.payload } } + } + return { ok: false, error: { code: 'not-found', message: request.method, details: {} } } + }) + const wrapped = withSessionDeferral(api, true) + const id = await provisionalId(wrapped) + + await expect(wrapped.call(request('session.models', { sessionId: id }))).resolves.toEqual({ + ok: true, + value: { + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + }, + }) + + await expect(wrapped.call(request('session.selectModel', { + sessionId: id, + provider: 'deepseek-official', + model: 'deepseek-v4-pro', + }))).resolves.toEqual({ + ok: true, + value: { selected: { provider: 'deepseek-official', model: 'deepseek-v4-pro' } }, + }) + + await wrapped.call(request('session.prompt', { sessionId: id, mode: 'queue', content: [] }, 'prompt')) + expect(call).toHaveBeenCalledWith(expect.objectContaining({ + method: 'session.selectModel', + payload: { sessionId: id, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + })) + }) + it('prunes stale provisional entries and returns the original API when disabled', async () => { vi.useFakeTimers() const { api, sessionHistory } = apiHarness() From 146b6bcfc2bd15b743b9a9bb1022945c7c6eb510 Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Wed, 16 Sep 2026 19:44:05 +0800 Subject: [PATCH 2/2] fix(content): clear selection watcher timers after tests Pending settle timeouts survived useRealTimers() and later hit document after jsdom was torn down, which made the full vitest run fail CI with an unhandled ReferenceError. --- .../dsh-browser/src/content/selection.ts | 19 +++++-- .../tests/content-selection.spec.ts | 49 +++++++++++++------ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/extensions/dsh-browser/src/content/selection.ts b/extensions/dsh-browser/src/content/selection.ts index ef8baaeb0..b2ec3b98b 100644 --- a/extensions/dsh-browser/src/content/selection.ts +++ b/extensions/dsh-browser/src/content/selection.ts @@ -28,6 +28,7 @@ const FIELD_SELECTOR = 'input, textarea, select, [contenteditable]' /** The deepest focused element, following shadow roots the page may use. */ function deepActiveElement(): Element | null { + if (typeof document === 'undefined') return null let active: Element | null = document.activeElement while (active?.shadowRoot?.activeElement != null) active = active.shadowRoot.activeElement return active @@ -106,6 +107,7 @@ function hasUserGesture(): boolean { * @returns the capture, or null when nothing quotable is selected. */ export function readSelectionCapture(): SelectionCapture | null { + if (typeof document === 'undefined') return null const raw = selectedText() if (raw === '') return null const { text, truncated } = normalizeSelectionText(raw) @@ -147,14 +149,18 @@ export class SelectionWatcher { if (next === this.enabled) return false this.enabled = next if (next) { - document.addEventListener('selectionchange', this.onSelectionChange) + if (typeof document !== 'undefined') { + document.addEventListener('selectionchange', this.onSelectionChange) + } // Opening the panel arms the watcher, and the text the user highlighted // just before opening it fires no further selectionchange. Reading it // needs no page gesture: the user acted on the extension to get here. this.flushNow(false) return true } - document.removeEventListener('selectionchange', this.onSelectionChange) + if (typeof document !== 'undefined') { + document.removeEventListener('selectionchange', this.onSelectionChange) + } this.cancel() this.lastEmitted = null return true @@ -170,7 +176,14 @@ export class SelectionWatcher { /** Release page listeners left behind by a replaced content script. */ dispose(): void { - this.setEnabled(false) + // Bypass the revision gate: a replaced script must drop its timer even + // when the last arm used a higher sequence than the default disarm. + this.enabled = false + if (typeof document !== 'undefined') { + document.removeEventListener('selectionchange', this.onSelectionChange) + } + this.cancel() + this.lastEmitted = null } private cancel(): void { diff --git a/extensions/dsh-browser/tests/content-selection.spec.ts b/extensions/dsh-browser/tests/content-selection.spec.ts index 74e131d74..0f0991d25 100644 --- a/extensions/dsh-browser/tests/content-selection.spec.ts +++ b/extensions/dsh-browser/tests/content-selection.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { MAX_SELECTION_CHARS } from '../src/selection.ts' +import { MAX_SELECTION_CHARS, type SelectionCapture } from '../src/selection.ts' import { SelectionWatcher, readSelectionCapture } from '../src/content/selection.ts' function selectText(value: string, anchor: Node | null = document.body): void { @@ -28,6 +28,9 @@ afterEach(() => { document.title = '' Reflect.deleteProperty(navigator, 'userActivation') vi.unstubAllGlobals() + // Drop pending settle timers before restoring real clocks. Otherwise + // useRealTimers() hands them to Node, and they fire after jsdom is gone. + vi.clearAllTimers() vi.useRealTimers() }) @@ -96,10 +99,25 @@ describe('reading a page selection', () => { }) describe('selection watcher', () => { + const watchers: SelectionWatcher[] = [] + + afterEach(() => { + for (const watcher of watchers.splice(0)) watcher.dispose() + }) + + function createWatcher( + emit: (capture: SelectionCapture) => void, + settleMs = 10, + ): SelectionWatcher { + const watcher = new SelectionWatcher(emit, settleMs) + watchers.push(watcher) + return watcher + } + it('ignores a selection the page moved without a user gesture', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) // An iframe calling getSelection().selectAllChildren() looks like this. @@ -119,7 +137,7 @@ describe('selection watcher', () => { it('reports the same passage again after the panel dropped it', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -137,7 +155,7 @@ describe('selection watcher', () => { it('ignores an arm command older than the one already applied', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) expect(watcher.setEnabled(true, 5, 'worker-a')).toBe(true) // A slow DSH_CONTENT_READY reply computed before the panel opened. @@ -153,7 +171,7 @@ describe('selection watcher', () => { it('accepts a fresh worker even when its revision counter restarted', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) expect(watcher.setEnabled(true, 5, 'worker-a')).toBe(true) expect(watcher.setEnabled(false, 0, 'worker-b')).toBe(true) @@ -167,7 +185,7 @@ describe('selection watcher', () => { it('stays silent until a panel arms it', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) selectText('quoted text') document.dispatchEvent(new Event('selectionchange')) @@ -184,7 +202,7 @@ describe('selection watcher', () => { it('reports a highlight made before the panel opened', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) selectText('highlighted before opening') watcher.setEnabled(true) @@ -197,7 +215,7 @@ describe('selection watcher', () => { it('emits once for a drag that fires many selection changes', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) for (const partial of ['q', 'qu', 'quoted text']) { @@ -214,7 +232,7 @@ describe('selection watcher', () => { it('keeps the captured quote when the user clears the highlight', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -230,7 +248,7 @@ describe('selection watcher', () => { it('reports the same passage after the highlight was cleared', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -249,7 +267,7 @@ describe('selection watcher', () => { it('reports when the same captured prefix becomes truncated', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('x'.repeat(MAX_SELECTION_CHARS)) @@ -266,7 +284,7 @@ describe('selection watcher', () => { it('does not resend an unchanged highlight', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -281,12 +299,11 @@ describe('selection watcher', () => { it('releases the page listener when it is disarmed', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) - watcher.setEnabled(true) - watcher.dispose() - + const watcher = createWatcher(emit) + watcher.setEnabled(true, 5) selectText('quoted text') document.dispatchEvent(new Event('selectionchange')) + watcher.dispose() vi.advanceTimersByTime(50) expect(emit).not.toHaveBeenCalled()