Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
#	apps/cli/src/web.ts
#	apps/web/tests/session-title.snapshot.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/client/index.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/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.module.css
#	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/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.schema.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/host/runtime/README.md
#	packages/llm/llm-deepseek/README.md
#	scripts/translation-pairing.manifest.json
#	tsconfig.client.json
This commit is contained in:
Yichen Jiang
2026-07-26 13:13:53 +08:00
1600 changed files with 56281 additions and 31357 deletions

View File

@@ -2,7 +2,7 @@
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.
The selector primes the advisory directory when it mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. 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.
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.
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.

View File

@@ -18,6 +18,7 @@ export function ModelSelector({
}: 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)
@@ -48,8 +49,8 @@ export function ModelSelector({
const busy = selection.status === 'selecting'
useEffect(() => {
refreshModels()
}, [refreshModels])
if (intent === null) refreshModels()
}, [intent, refreshModels])
useEffect(() => {
if (!open) return
@@ -151,6 +152,11 @@ 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}

View File

@@ -22,7 +22,11 @@ export function apply(ctx: ClientContext): void {
ctx.slots.register({
name: 'conversation.composer.control',
inject: (sessionId: SessionId): ModelSelectorInjected => {
const session = sessions.manager.get(sessionId)
const binding = sessions.binding(sessionId)
if (binding === undefined) {
throw new Error(`ui-model-selector: session "${sessionId}" resolved no binding`)
}
const { session } = binding
return {
refreshModels: () => { void session.refreshModels() },
retryModelOperation: () => session.retryModelOperation(),

View File

@@ -13,7 +13,7 @@ import { apply, inject } from '../src/client/index.ts'
const SID = 'selector-session' as SessionId
async function bench() {
async function bench(hasBinding = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
@@ -31,7 +31,7 @@ async function bench() {
value: { selected: target },
})),
}
ctx.provide('sessions', { manager: { get: () => session } })
ctx.provide('sessions', { binding: () => hasBinding ? { session } : undefined })
ctx.provide('conversation', {})
return { ctx, slots, session }
}
@@ -44,7 +44,7 @@ describe('model-selector browser plugin', () => {
it('fails loud when the conversation control slot is not declared', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', { manager: { get: vi.fn() } })
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/)
@@ -72,6 +72,14 @@ describe('model-selector browser plugin', () => {
.resolves.toBe(false)
})
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]
expect(() => (entry?.inject as (sessionId: SessionId) => unknown)(SID))
.toThrow(`ui-model-selector: session "${SID}" resolved no binding`)
})
it('unregisters the occupant when its plugin fiber is disposed', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })

View File

@@ -38,8 +38,12 @@ const ready: ModelSelectionSnapshot = {
error: null,
}
function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
let current = { modelSelection: selection, removed } as unknown as ConversationSnapshot
function setup(
selection: ModelSelectionSnapshot = ready,
removed = false,
intent: ConversationSnapshot['intent'] = null,
) {
let current = { modelSelection: selection, removed, intent } as unknown as ConversationSnapshot
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
selector(current)) as ModelSelectorProps['useSession']
const refreshModels = vi.fn()
@@ -50,6 +54,8 @@ function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
useSession,
useSessions: ((selector: (snapshot: never) => unknown) =>
selector({} as never)) as ModelSelectorProps['useSessions'],
useWorkspaces: ((selector: (snapshot: never) => unknown) =>
selector({} as never)) as ModelSelectorProps['useWorkspaces'],
refreshModels,
retryModelOperation,
selectModel,
@@ -60,8 +66,16 @@ function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
refreshModels,
retryModelOperation,
selectModel,
update(next: ModelSelectionSnapshot, nextRemoved = removed) {
current = { modelSelection: next, removed: nextRemoved } as unknown as ConversationSnapshot
update(
next: ModelSelectionSnapshot,
nextRemoved = removed,
nextIntent: ConversationSnapshot['intent'] = intent,
) {
current = {
modelSelection: next,
removed: nextRemoved,
intent: nextIntent,
} as unknown as ConversationSnapshot
view.rerender(<ModelSelector {...props} />)
},
}
@@ -190,4 +204,17 @@ describe('model selector', () => {
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()
})
})