Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector
# Conflicts: # apps/cli/package.json # apps/web/tests/smoke-real.e2e.ts # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # apps/web/tests/snapshots/seeded-history/ui.expected.md # docs/config-catalog.md # packages/client/connection/tests/fake-api.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/host/apiproxy/README.md # packages/host/apiproxy/src/api-proxy.ts # pnpm-lock.yaml # scripts/verify-package-readme-model-experience.ts # tsconfig.base.json
This commit is contained in:
6
packages/client/ui-model-selector/README.i18n.yaml
Normal file
6
packages/client/ui-model-selector/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model-selector/README.md
|
||||
README.md: 24d42b7493b7528abe414efeec561cc507afc344
|
||||
README.zh.md: 91a16f4ac3a02b1657d2d11e1797588d9cb5663f
|
||||
@@ -1,8 +1,10 @@
|
||||
# @deepseek-ai/dsh-client-ui-model-selector
|
||||
|
||||
Session-scoped Web model selector. Its browser half occupies `conversation.composer.control`, shows the current catalog name beside the send button, and opens an upward provider-grouped menu. Provider names appear once as group headings; model rows and the trigger show catalog names without repeating the provider route, with the model id as the fallback for an unlisted current target.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The selector primes the advisory directory when an existing Host session mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. A frontend Session Intent has no Host model route yet, so the selector stays absent and issues no directory RPC until publication clears the intent. The Session object layer owns loading, selection, partial-provider-failure, and stale-response state. A selection updates only that live session and takes effect at the next prompt-assembly boundary, including while the current step is running. The latest consumed target remains durable through the existing `request/header`; an unused choice is process-local.
|
||||
Session-scoped Web model selector. Its browser half occupies `conversation.input.model`, shows the current catalog name beside the send button, and opens an upward provider-grouped menu. Provider names appear once as group headings; model rows and the trigger show catalog names without repeating the provider route, with the model id as the fallback for an unlisted current target.
|
||||
|
||||
The selector primes the advisory directory when a Host session's composer mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. The resident no-session shell uses a disabled input bar and has no session-scoped model seat. The Session object layer owns loading, selection, partial-provider-failure, and stale-response state. A selection updates only that live session and takes effect at the next prompt-assembly boundary, including while the current step is running. The latest consumed target remains durable through the existing `request/header`; an unused choice is process-local.
|
||||
|
||||
Catalog membership is not request validation. The current target is included as an unlisted row when its registered provider omits it, while a target whose provider is unavailable remains visible on the trigger with a warning in the menu.
|
||||
|
||||
@@ -16,5 +18,5 @@ Switching routes may invalidate provider-side cache reuse according to the selec
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The new-session composer has no selector** — a session starts with the host default and exposes the selector after creation.
|
||||
- **The no-session shell has no selector** — the control appears after Workspace selection connects or reuses a Host session, including a blank session.
|
||||
- **Unused selections are not durable** — reload restores the last route consumed by a request, not a choice made without sending.
|
||||
|
||||
22
packages/client/ui-model-selector/README.zh.md
Normal file
22
packages/client/ui-model-selector/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-ui-model-selector
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
会话作用域的 Web 模型选择器。其浏览器侧占用 `conversation.input.model`,在发送按钮旁显示当前目录名称,并打开按提供方分组、向上展开的菜单。提供方名称仅作为分组标题出现一次;模型行与触发器显示目录名称,不重复提供方路由;当前目标未列出时则回退到模型 ID。
|
||||
|
||||
Host 会话的输入区挂载时,选择器会预加载仅供参考的目录,使触发器能够解析目录名称;此后每次打开菜单都会刷新目录。常驻无会话壳使用禁用输入栏,不提供会话作用域的模型 seat。`Session` 对象层拥有加载、选择、部分提供方失败和陈旧响应状态。一次选择只更新该实时会话,并从下一提示词组装边界起生效,包括当前步骤正在运行时。最近一次被请求采用的目标通过现有 `request/header` 保持持久;未使用的选择仅保存在进程内。
|
||||
|
||||
目录成员关系不构成请求校验。若已注册提供方未列出当前目标,系统仍将该目标作为未列出行纳入目录;若目标的提供方不可用,触发器仍会显示该目标,并在菜单中给出警告。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为浏览器选择器会改变后续请求路由,但不添加任何模型可见内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
切换路由可能会根据所选适配器使提供方侧的缓存复用失效。选择器本身不添加提示词内容。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无会话壳没有选择器**:Workspace 选择连接或复用 Host 会话后,控件才会出现,空白会话也会显示该控件。
|
||||
- **未使用的选择不会持久化**:重新加载会恢复请求最近一次实际采用的路由,而不是尚未发送时作出的选择。
|
||||
@@ -14,11 +14,9 @@ type FocusPreference = 'current' | 'first' | 'last'
|
||||
|
||||
/** Session-scoped provider-grouped model selector for the composer action row. */
|
||||
export function ModelSelector({
|
||||
useSession, refreshModels, retryModelOperation, selectModel,
|
||||
useSession, locked, refreshModels, retryModelOperation, selectModel,
|
||||
}: ModelSelectorProps) {
|
||||
const selection = useSession(snapshot => snapshot.modelSelection)
|
||||
const removed = useSession(snapshot => snapshot.removed)
|
||||
const intent = useSession(snapshot => snapshot.intent)
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
@@ -49,8 +47,8 @@ export function ModelSelector({
|
||||
const busy = selection.status === 'selecting'
|
||||
|
||||
useEffect(() => {
|
||||
if (intent === null) refreshModels()
|
||||
}, [intent, refreshModels])
|
||||
refreshModels()
|
||||
}, [refreshModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -152,11 +150,6 @@ export function ModelSelector({
|
||||
|| selection.failures.some(failure => failure.id === selection.current?.provider)
|
||||
const label = choices[selectedIndex]?.model.name ?? selection.current?.model ?? '选择模型'
|
||||
|
||||
// A frontend Session Intent has no Host session/model route yet. It shares
|
||||
// the resident composer during attachment, so suppress both the control and
|
||||
// its directory RPC until publication clears the intent.
|
||||
if (intent !== null) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -173,7 +166,7 @@ export function ModelSelector({
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
title={label}
|
||||
disabled={removed}
|
||||
disabled={locked}
|
||||
onClick={() => { open ? close() : show() }}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,6 @@ export interface ModelSelectorInjected {
|
||||
selectModel(target: ModelTarget): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Full props of the conversation composer-control occupant. */
|
||||
/** Full props of the conversation model-control occupant. */
|
||||
export type ModelSelectorProps =
|
||||
PropsRuntime<'conversation.composer.control'> & ModelSelectorInjected
|
||||
PropsRuntime<'conversation.input.model'> & ModelSelectorInjected
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Browser model-selector plugin: registers one session-scoped occupant in the
|
||||
* conversation composer-control slot. The Session object owns all catalog and
|
||||
* conversation model-control slot. The Session object owns all catalog and
|
||||
* selection state; the component receives only the standard snapshot hook and
|
||||
* injected callbacks.
|
||||
*/
|
||||
@@ -20,7 +20,7 @@ export const inject = ['slots', 'sessions', 'conversation']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.sessions
|
||||
ctx.slots.register({
|
||||
name: 'conversation.composer.control',
|
||||
name: 'conversation.input.model',
|
||||
inject: (sessionId: SessionId): ModelSelectorInjected => {
|
||||
const binding = sessions.binding(sessionId)
|
||||
if (binding === undefined) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Browser-plugin assembly: the selector occupies the conversation-declared
|
||||
* composer-control slot, injects only Session object actions, fails loud when
|
||||
* model-control slot, injects only Session object actions, fails loud when
|
||||
* the slot is undeclared, and unregisters with its plugin fiber.
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ async function bench(hasBinding = true) {
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.composer.control': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
} as never, (_props: { renderSlot?: unknown }) => null)
|
||||
const session = {
|
||||
@@ -47,13 +47,13 @@ describe('model-selector browser plugin', () => {
|
||||
ctx.provide('sessions', { binding: vi.fn() })
|
||||
ctx.provide('conversation', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation\.composer\.control" is not declared/)
|
||||
.rejects.toThrow(/slot "conversation\.input\.model" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the singleton and injects Session-owned refresh/select actions', async () => {
|
||||
const { ctx, slots, session } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entries = slots.entries('conversation.composer.control')
|
||||
const entries = slots.entries('conversation.input.model')
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]?.component).toBe(ModelSelector)
|
||||
const injected = (entries[0]?.inject as (sessionId: SessionId) => {
|
||||
@@ -75,7 +75,7 @@ describe('model-selector browser plugin', () => {
|
||||
it('fails loud when slot injection resolves no Session binding', async () => {
|
||||
const { ctx, slots } = await bench(false)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = slots.entries('conversation.composer.control')[0]
|
||||
const entry = slots.entries('conversation.input.model')[0]
|
||||
expect(() => (entry?.inject as (sessionId: SessionId) => unknown)(SID))
|
||||
.toThrow(`ui-model-selector: session "${SID}" resolved no binding`)
|
||||
})
|
||||
@@ -84,8 +84,8 @@ describe('model-selector browser plugin', () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.composer.control')).toHaveLength(1)
|
||||
expect(slots.entries('conversation.input.model')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.composer.control')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.input.model')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ConversationSnapshot, ModelSelectionSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ModelSelectionSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelectorProps } from '../src/client/contract.ts'
|
||||
import { ModelSelector } from '../src/client/ModelSelector.tsx'
|
||||
@@ -40,10 +38,9 @@ const ready: ModelSelectionSnapshot = {
|
||||
|
||||
function setup(
|
||||
selection: ModelSelectionSnapshot = ready,
|
||||
removed = false,
|
||||
intent: ConversationSnapshot['intent'] = null,
|
||||
locked = false,
|
||||
) {
|
||||
let current = { modelSelection: selection, removed, intent } as unknown as ConversationSnapshot
|
||||
let current = { modelSelection: selection } as unknown as ConversationSnapshot
|
||||
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
|
||||
selector(current)) as ModelSelectorProps['useSession']
|
||||
const refreshModels = vi.fn()
|
||||
@@ -51,11 +48,15 @@ function setup(
|
||||
const selectModel = vi.fn((_target: ModelTarget) => Promise.resolve(true))
|
||||
const props: ModelSelectorProps = {
|
||||
sessionId: 'selector-session' as never,
|
||||
locked,
|
||||
useSession,
|
||||
useSessions: ((selector: (snapshot: never) => unknown) =>
|
||||
selector({} as never)) as ModelSelectorProps['useSessions'],
|
||||
useWorkspaces: ((selector: (snapshot: never) => unknown) =>
|
||||
selector({} as never)) as ModelSelectorProps['useWorkspaces'],
|
||||
useInput: ((selector: (snapshot: never) => unknown) =>
|
||||
selector({} as never)) as ModelSelectorProps['useInput'],
|
||||
inputActions: {} as ModelSelectorProps['inputActions'],
|
||||
refreshModels,
|
||||
retryModelOperation,
|
||||
selectModel,
|
||||
@@ -68,14 +69,8 @@ function setup(
|
||||
selectModel,
|
||||
update(
|
||||
next: ModelSelectionSnapshot,
|
||||
nextRemoved = removed,
|
||||
nextIntent: ConversationSnapshot['intent'] = intent,
|
||||
) {
|
||||
current = {
|
||||
modelSelection: next,
|
||||
removed: nextRemoved,
|
||||
intent: nextIntent,
|
||||
} as unknown as ConversationSnapshot
|
||||
current = { modelSelection: next } as unknown as ConversationSnapshot
|
||||
view.rerender(<ModelSelector {...props} />)
|
||||
},
|
||||
}
|
||||
@@ -200,21 +195,8 @@ describe('model selector', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the trigger only when the session is removed', () => {
|
||||
it('disables the trigger when the model-control seat is locked', () => {
|
||||
setup(ready, true)
|
||||
expect(trigger().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for a frontend Session Intent to publish before rendering or loading models', () => {
|
||||
const b = setup(ready, false, {
|
||||
target: { kind: 'workspace-intent' },
|
||||
phase: 'connecting',
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: /选择模型,当前/ })).toBeNull()
|
||||
expect(b.refreshModels).not.toHaveBeenCalled()
|
||||
|
||||
b.update(ready, false, null)
|
||||
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
|
||||
expect(b.refreshModels).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user