Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/host/runtime/src/api-proxy.ts
#	packages/host/runtime/src/boot.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-24 16:29:03 +08:00
503 changed files with 19038 additions and 3597 deletions

View File

@@ -69,7 +69,12 @@ export function apply(ctx: Context): void {
// ConversationRoot is the only component authorized to render the ring.
slots.register({
name: 'conversation',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
// The composer chain rides the same declaration table: takeover plugins
// register selector-routed replacements of the InputBar.
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
// History pull is NOT triggered here: the runtime sessions service opens

View File

@@ -7,7 +7,7 @@
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ToolRow } from './ToolRow.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import css from './AssistantMarkdown.module.css'
@@ -46,7 +46,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
// Tool-call heads render as tool rows in the chat view's grouping pass.

View File

@@ -256,7 +256,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />

View File

@@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>等待回答({item.questions.length} 题)</div>
<JsonBlock label="问题内容" payload={item.questions} />
<div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>

View File

@@ -11,7 +11,7 @@
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -42,6 +42,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
* entry; the owner dispatches the {@link ComposerChainProps} currency and
* routing lives in entry selectors — new takeover kinds register with
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
}
}
@@ -112,7 +120,7 @@ export interface ConversationInjected {
addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null
/** Release one browser preview and remove its id through the declared store action. */
removeImage(id: string): void
/** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */
/** Resolve ordered store ids to browser-owned draft attachments still available this runtime. */
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
/** Release historical image URLs when this rendered session scope unmounts. */
releaseSessionImages(sessionId: SessionId): void
@@ -124,9 +132,22 @@ export interface ConversationInjected {
open(id: SessionId): void
}
/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
/**
* Composer chain currency: what ConversationRoot dispatches at its
* renderSlotChain site. The owner declares the currency only — never a
* per-entry contract; takeover packages narrow it in their own selectors
* (`interactions.find(i => i.kind === ...)`), so new takeover kinds register
* with zero owner changes.
*/
export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
& PropsStore<ChatStore> & ConversationInjected
/**
* Injected share of the chat view entry: the two callbacks whose targets live

View File

@@ -18,9 +18,10 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerAttachment, ConversationInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerAttachment, ComposerChainProps,
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps,
DetailsInjected, DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps,
ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -15,13 +15,9 @@
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
// Value import MUST use the /client subpath: only that specifier is in the
// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime
// module at load time. A bare-specifier value import gets INLINED as a second
// module instance whose private scope-tag Symbol never matches the one
// SessionsService tags contexts with — scopeOf then always returns undefined
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ComposerAttachment } from './contract/slots.ts'
@@ -160,14 +156,17 @@ export class ConversationService extends Service {
const cached = this.imageUrls.get(key)
if (cached !== undefined) return cached.pending
const generation = this.imageGenerations.get(sessionId) ?? 0
const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId)
const pending = this.requireSessions().manager.get(sessionId)
.readAttachment(attachment.attachmentId)
.then((result) => {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
if (typeof URL.createObjectURL !== 'function') {
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
}
const bytes = Uint8Array.from(result.value.data)
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
const url = URL.createObjectURL(new Blob([bytes.buffer], {
type: result.value.attachment.mediaType,
}))
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
revokePreview(url)
throw new Error('historical image scope was released before loading completed')
@@ -242,11 +241,17 @@ export class ConversationService extends Service {
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = scopeOf(this.ctx)
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
}
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
private scopeId(op: string): SessionId {
const id = this.requireSessions().scopeOf(this.ctx)
if (id === undefined) {
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
}
return this.requireSessions().manager.get(id)
return id
}
private requireSessions(): SessionsService {

View File

@@ -3,7 +3,8 @@
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter).
// slot entries; the active one renders via the list `only` filter) plus the
// renderSlotChain share for the 'conversation.composer' takeover chain.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
@@ -36,7 +37,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
}
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions, renderSlot,
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, addImages, removeImage, draftImages, releaseSessionImages,
send, stop, open,
}: ConversationRootProps) {
@@ -55,6 +56,7 @@ export function ConversationRoot({
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const pending = useSession(s => s.pending)
// Browser File/object-URL values are runtime-only. A reload may rehydrate
// ids whose objects no longer exist; prune those ids after the first render.
@@ -72,6 +74,24 @@ export function ConversationRoot({
? null
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
const composerBar = (
<InputBar
draft={draft}
attachments={attachments}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onAddImages={files => addImages(files, attachments)}
onRemoveAttachment={removeImage}
onSend={(mode) => { send(draft, attachments, mode) }}
onStop={stop}
/>
)
return (
<div className={css.root}>
<header className={css.header}>
@@ -88,7 +108,7 @@ export function ConversationRoot({
disabled={last}
onClick={() => { open(s.id) }}
>
{s.title}
{s.displayTitle}
</button>
</span>
)
@@ -121,19 +141,7 @@ export function ConversationRoot({
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
<InputBar
draft={draft}
attachments={attachments}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onAddImages={files => addImages(files, attachments)}
onRemoveAttachment={removeImage}
onSend={(mode) => { send(draft, attachments, mode) }}
onStop={stop}
/>
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
</div>
)
}