Add web multimodal image attachments

This commit is contained in:
Yichen Jiang
2026-07-23 15:20:47 +08:00
parent 3e3ea47296
commit cb4c11b869
116 changed files with 3177 additions and 151 deletions

View File

@@ -29,6 +29,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -6,7 +6,7 @@
// The ./api and ./client subpath exports are the browser-safe channels added for this.
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -6,6 +6,7 @@
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
@@ -28,6 +29,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
const FIXTURE_IMAGE_REF: ImageAttachmentRef = {
attachmentId: 'fixture:image' as AttachmentIdType,
mediaType: 'image/png',
bytes: 68,
width: 160,
height: 90,
name: 'fixture-image.png',
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / steering / context. */
function buildAlphaLog(): SessionEvent[] {
@@ -88,6 +99,12 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
push({ type: 'turn/start', data: { turn: 63, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')], source: { kind: 'user' } } })
push({ type: 'step/start', data: { turn: 63, step: 0 } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn: 63, step: 0, content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], provenance: { provider: 'fixture', model: 'fx-vision' } } })
push({ type: 'step/end', data: { turn: 63, step: 0 } })
push({ type: 'turn/end', data: { turn: 63, reason: { kind: 'completed' } } })
return events as unknown as SessionEvent[]
}
@@ -186,6 +203,18 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of host session-scoped attachment authorization. */
function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean {
const visit = (value: unknown): boolean => {
if (Array.isArray(value)) return value.some(visit)
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
if (record.attachmentId === attachmentId) return true
return Object.values(record).some(visit)
}
return log.some(event => visit(event.data))
}
interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
@@ -243,7 +272,11 @@ export function createFixtureApi(): ApiProxy {
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 64]])
const attachments = new Map<string, { attachment: ImageAttachmentRef; data: string }>([[
String(FIXTURE_IMAGE_REF.attachmentId),
{ attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA },
]])
let nextSession = 1
let nextRpc = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
@@ -394,21 +427,44 @@ export function createFixtureApi(): ApiProxy {
}
summary.updatedAt = Date.now()
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
const durable: ContentBlock[] = content.map((block) => {
if (block.type === 'text') return block
const attachment: ImageAttachmentRef = {
attachmentId: `fixture:${crypto.randomUUID()}` as AttachmentIdType,
mediaType: block.mediaType,
bytes: Math.max(1, Math.floor(block.data.length * 3 / 4) - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0)),
width: 160,
height: 90,
...block.name === undefined ? {} : { name: block.name },
}
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
return { type: 'image', attachment }
})
if (mode === 'steer' && replays.has(id)) {
// Steering: insert a steering message into the current turn; the replay continues.
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
const turn = (nextTurn.get(id) ?? 1) - 1
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content: durable, source: { kind: 'user' } } })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
nextTurn.set(id, turn + 1)
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content: durable, source: { kind: 'user' } } })
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
return ok(request, { accepted: true as const })
},
attachment: (request) => {
const stored = attachments.get(String(request.payload.attachmentId))
if (stored === undefined) {
return err(request, { code: 'attachment-error', message: 'fixture attachment missing', details: { reason: 'ATTACHMENT_NOT_FOUND' } })
}
if (!logReferencesAttachment(logs.get(request.payload.sessionId) ?? [], String(request.payload.attachmentId))) {
return err(request, { code: 'attachment-error', message: 'fixture attachment is not referenced by this session', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } })
}
return ok(request, stored)
},
cancel: (request) => {
const replay = replays.get(request.payload.sessionId)
if (replay !== undefined) {
@@ -421,7 +477,24 @@ export function createFixtureApi(): ApiProxy {
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
describe: request => ok(request, {
version: '0.0.0-fixture',
cwd: '/tmp/fixture',
provider: 'fixture',
model: 'fx-vision',
activeModel: {
provider: 'fixture', id: 'fx-vision', name: 'Fixture Vision',
inputModalities: ['text', 'image'], outputModalities: ['text', 'image'],
},
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 10,
maxMessageImageBytes: 20 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},
attachedSessions: 1,
}),
},
events: {
async *mux(_request, signal) {
@@ -512,6 +585,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.attachment': return this.api.sessions.attachment(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
}

View File

@@ -14,7 +14,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,

View File

@@ -48,6 +48,8 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -64,6 +66,7 @@ export class FakeApiClient implements IApiClient {
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -167,7 +167,7 @@ describe('createFixtureApi', () => {
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
it('steer with no replay in flight promotes image bytes to a session-scoped reference', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
@@ -175,14 +175,36 @@ describe('createFixtureApi', () => {
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
// steer while idle + a non-text content block (covers the '' arm of the text join).
// steer while idle + an image: the fixture mirrors the host's durable send boundary.
await api.sessions.prompt(req({
sessionId: created.result.value.sessionId, mode: 'steer' as const,
content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never],
content: [{ type: 'text' as const, text: '短' }, {
type: 'image' as const,
mediaType: 'image/png' as const,
data: 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==',
name: 'pixel.png',
}],
}))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
const user = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> =>
f.type === 'session/event' && f.event.type === 'user/message')
const image = ((user?.event.data as { content?: { type: string; attachment?: { attachmentId: never } }[] } | undefined)?.content)
?.find(block => block.type === 'image')
expect(image?.attachment).toBeDefined()
if (image?.attachment === undefined) throw new Error('fixture image missing')
const loaded = await api.sessions.attachment(req({
sessionId: created.result.value.sessionId,
attachmentId: image.attachment.attachmentId,
}))
expect(loaded.result).toMatchObject({ ok: true, value: { attachment: { name: 'pixel.png' } } })
const denied = await api.sessions.attachment(req({
sessionId: sid('fx-beta'), attachmentId: image.attachment.attachmentId,
}))
expect(denied.result).toMatchObject({
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
})
})
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
@@ -306,6 +328,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
const id = created.result.value.sessionId
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.attachment({ sessionId: sid('fx-alpha'), attachmentId: 'fixture:image' as never })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
})

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../llm/llm"
},

View File

@@ -35,6 +35,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -4,6 +4,7 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
/** Assistant content blocks sorted by what the UI cares about
@@ -11,6 +12,7 @@ import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'image'; attachment: ImageAttachmentRef; alt?: string }
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
@@ -32,6 +34,10 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return {
kind: 'image', attachment: block.attachment,
...block.alt === undefined ? {} : { alt: block.alt },
}
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}

View File

@@ -3,9 +3,9 @@
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import type { HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
@@ -82,11 +82,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - core content blocks verbatim.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - queue appends after the current turn; steer interrupts it.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
this.notifier.markDirty()
@@ -103,6 +103,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Resolve one image referenced by this session into browser-consumable bytes.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
async readAttachment(attachmentId: AttachmentIdType): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
try {
const result = (await this.api.sessions.attachment({ sessionId: this.sessionId, attachmentId })).result
if (!result.ok) return result
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
return { ok: true, value: { attachment: result.value.attachment, data } }
} catch (error) {
return transportError(error)
}
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* @returns the cancel result.

View File

@@ -1,22 +1,30 @@
/** Assistant block classifier (moved here with sessions/conversation.ts). */
import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => {
it('classifies the four block shapes', () => {
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 1,
height: 1,
}
const blocks: ContentBlock[] = [
{ type: 'text', text: '正文' },
{ type: 'reasoning', text: '思考' },
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
{ type: 'image', data: 'x' } as unknown as ContentBlock,
{ type: 'image', attachment },
]
expect(toAssistantBlocks(blocks)).toEqual([
{ kind: 'text', text: '正文' },
{ kind: 'reasoning', text: '思考' },
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
{ kind: 'other', block: blocks[3] },
{ kind: 'image', attachment },
])
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
})

View File

@@ -51,6 +51,8 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -67,6 +69,7 @@ export class FakeApiClient implements IApiClient {
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -239,6 +239,21 @@ describe('prompt and cancel errors', () => {
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
})
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
const { api, session } = makeSession()
const result = await session.readAttachment('attachment-1' as never)
expect(result).toEqual({
ok: true,
value: {
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: Uint8Array.of(0),
},
})
expect(api.callsOf('session.attachment')).toEqual([{
sessionId: SID, attachmentId: 'attachment-1',
}])
})
})
describe('pending interactions', () => {

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../../vendor/cordis"
},

View File

@@ -34,6 +34,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",

View File

@@ -12,7 +12,9 @@ import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { SelectionTarget } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
import type {
ComposerAttachment, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
@@ -92,15 +94,25 @@ export function apply(ctx: Context): void {
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
send: (text, mode) => {
addImages: (files) => {
const images = conversation.createDraftImages(files)
actions.addImages(images.map(image => image.id))
},
removeImage: (id) => {
conversation.releaseDraftImage(id)
actions.removeImage(id)
},
draftImages: ids => conversation.draftImages(ids),
send: (text, images: readonly ComposerAttachment[], mode) => {
const trimmed = text.trim()
if (trimmed === '') return
if (trimmed === '' && images.length === 0) return
// Optimistic clear with failure restore (choreography lives with the
// sender; the business failure also lands in snapshot.promptError).
// The store write path stays inside the declared actions set:
// restoreDraft itself no-ops once the user typed something new.
// The store write path stays inside the declared actions set.
actions.clearDraft()
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
void scoped.send(trimmed, mode, images.map(image => image.file))
.then(() => { conversation.releaseDraftImages(images) })
.catch(() => { actions.restoreDraft(trimmed, images.map(image => image.id)) })
},
stop: () => {
scoped.cancel().catch(() => {

View File

@@ -8,6 +8,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 { ToolRow } from './ToolRow.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
@@ -15,6 +16,7 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
interrupted?: boolean | undefined
loadImage?: ImageLoader
}
function firstLine(text: string): string {
@@ -36,14 +38,17 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) {
const last = blocks.length - 1
const images = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'image' }> => block.kind === 'image')
return (
<div className={css.root} data-streaming={streaming || undefined}>
<ImageGallery images={images} load={loadImage} align="start" />
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'image': return null
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
@@ -54,3 +59,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
</div>
)
})
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}

View File

@@ -11,8 +11,9 @@
// map but only rows whose own selected bit flipped.
import {
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
} from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,6 +25,7 @@ import type { ToolViewResolver } from '../contract/toolview.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { MessageItem } from './MessageItem.tsx'
import type { ImageLoader } from './MessageImage.tsx'
import { PendingCard } from './PendingCard.tsx'
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
import css from './ChatView.module.css'
@@ -32,6 +34,7 @@ import css from './ChatView.module.css'
export interface ChatViewDeps {
toolviews: ToolViewResolver
t: Translate
resolveImage?(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string>
}
const FOLLOW_THRESHOLD = 24
@@ -102,16 +105,17 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t,
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
function StreamingTail({ useSession, onGrow, loadImage }: {
useSession: UseConversation
onGrow: () => void
loadImage: ImageLoader
}) {
const partial = useSession((s) => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming />
return <AssistantMarkdown blocks={partial.blocks} streaming loadImage={loadImage} />
}
/**
@@ -120,7 +124,7 @@ function StreamingTail({ useSession, onGrow }: {
* @returns the ConvViewProps component registered as the chat view.
*/
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
const { toolviews, t, resolveImage = unavailableImage } = deps
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
@@ -132,6 +136,10 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId)
const loadImage = useCallback<ImageLoader>(
attachment => resolveImage(sessionId, attachment),
[resolveImage, sessionId],
)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
@@ -229,11 +237,11 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} loadImage={loadImage} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
return <MessageItem key={item.key} node={node} loadImage={loadImage} />
}
return (
@@ -250,7 +258,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
<StreamingTail useSession={useSession} onGrow={onGrow} loadImage={loadImage} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
@@ -291,3 +299,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
)
}
}
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}

View File

@@ -0,0 +1,53 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: min(240px, 100%);
}
.gallery[data-align='end'] {
justify-content: flex-end;
align-self: flex-end;
}
.gallery[data-align='start'] {
justify-content: flex-start;
align-self: flex-start;
}
.frame {
display: grid;
flex: 0 0 auto;
place-items: center;
min-width: 44px;
min-height: 44px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.frame img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.loading,
.error {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.error {
max-width: 240px;
padding: 10px 12px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 10px;
background: var(--dsw-alias-interactive-bg-hover-danger);
cursor: pointer;
}

View File

@@ -0,0 +1,70 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Compact history renderer with retryable loading and double-click original preview. */
export function MessageImage({ attachment, alt, load }: {
attachment: ImageAttachmentRef
alt?: string
load: ImageLoader
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const request = useCallback(() => {
setError(false)
setSrc(null)
void load(attachment).then(setSrc).catch(() => { setError(true) })
}, [attachment, load])
useEffect(() => {
let live = true
setError(false)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load])
const label = alt ?? attachment.name ?? '图片'
if (error) return <button type="button" className={css.error} onClick={request}></button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title="双击查看原图"
aria-label={`${label},双击查看原图`}
onDoubleClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}></span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align }: {
images: readonly { attachment: ImageAttachmentRef; alt?: string }[]
load: ImageLoader
align: 'start' | 'end'
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} />
))}
</div>
)
}

View File

@@ -7,9 +7,18 @@
justify-content: flex-end;
}
.userStack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
min-width: 0;
max-width: min(525px, 82%);
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);
max-width: 100%;
background: var(--dsw-specific-bubble);
border-radius: 22px;
/* 44px single-line bubble: 24 line + 10 vertical padding each side. */

View File

@@ -9,33 +9,49 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
loadImage?: ImageLoader
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
function contentParts(content: readonly unknown[]): {
text: string
images: { attachment: UserImage['attachment']; alt?: string }[]
rest: unknown[]
} {
const texts: string[] = []
const images: { attachment: UserImage['attachment']; alt?: string }[] = []
const rest: unknown[] = []
for (const block of content) {
const b = block as { type?: string; text?: string }
const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string }
if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
else if (b.type === 'image' && b.attachment !== undefined) {
const image = b as UserImage
images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } })
}
else rest.push(block)
}
return { text: texts.join(''), rest }
return { text: texts.join(''), images, rest }
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({ node, loadImage = unavailableImage }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'steering': {
const { text, rest } = contentText(node.content)
const { text, images, rest } = contentParts(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}></span>}
<MessageText text={text} />
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
<div className={css.userStack}>
<ImageGallery images={images} load={loadImage} align="end" />
{(text !== '' || rest.length > 0 || node.kind === 'steering') && <div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}></span>}
<MessageText text={text} />
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>}
</div>
</div>
)
@@ -54,3 +70,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
)
}
})
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}

View File

@@ -46,7 +46,11 @@ export function registerChat(deps: RegisterChatDeps): () => void {
id: 'chat',
label: 'Chat',
order: 0,
component: createChatView({ toolviews, t }),
component: createChatView({
toolviews,
t,
resolveImage: (sessionId, attachment) => conversation.resolveImage(sessionId, attachment),
}),
chrome: { footer: StatsLine },
})
}

View File

@@ -12,6 +12,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { SelectionTarget, ViewEntry } from './views.ts'
/** Browser-owned image that has not crossed the durable host boundary. */
export interface ComposerAttachment {
id: string
file: File
previewUrl: string
}
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
export type ChatStore = ReturnType<typeof createChatStore>
@@ -29,8 +36,14 @@ export interface ConversationInjected {
subscribe(fn: () => void): () => void
version(): number
}
/** Create browser previews and append their ids through the declared store action. */
addImages(files: readonly File[]): void
/** 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. */
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
send(text: string, mode: 'queue' | 'steer'): void
send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
@@ -60,7 +73,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
startSession(opts: {
cwd?: string
text: string
images?: readonly File[]
mode: 'queue' | 'steer'
}): Promise<void>
}
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */

View File

@@ -69,6 +69,12 @@ export interface ChatStoreState {
selection: SelectionTarget | null
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
/**
* Ordered browser-draft attachment ids. The matching File/object-URL
* objects stay in ConversationService because they are runtime-only; stale
* persisted ids are pruned by ConversationRoot after a page reload.
*/
imageIds: string[]
/** Active conversation view id; null falls back to the first registered view. */
view: ViewId | null
}

View File

@@ -22,8 +22,27 @@ import type { Context } from 'cordis'
// 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'
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ViewEntry, ViewId } from './index.ts'
import type { ComposerAttachment } from './contract/slots.ts'
/** Opaque wrapper keeps browser `File` internals outside persisted store state. */
class BrowserDraftAttachment implements ComposerAttachment {
readonly id: string
readonly previewUrl: string
readonly #file: File
constructor(file: File) {
this.id = crypto.randomUUID()
this.previewUrl = URL.createObjectURL(file)
this.#file = file
}
get file(): File {
return this.#file
}
}
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
@@ -36,6 +55,9 @@ interface ViewsState {
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly draftAttachments = new Map<string, BrowserDraftAttachment>()
private readonly imageUrls = new Map<string, Promise<string>>()
private readonly createdImageUrls = new Set<string>()
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
}
@@ -46,6 +68,12 @@ export class ConversationService extends Service {
*/
constructor(ctx: Context) {
super(ctx, 'conversation')
ctx.effect(() => () => {
for (const url of this.createdImageUrls) URL.revokeObjectURL(url)
this.createdImageUrls.clear()
this.draftAttachments.clear()
this.imageUrls.clear()
}, 'conversation attachment URL cache')
}
/**
@@ -54,13 +82,98 @@ export class ConversationService extends Service {
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @param images - browser-owned temporary images promoted by the host during this call.
*/
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise<void> {
const session = this.scopedSession('send')
const result = await session.prompt([{ type: 'text', text }], mode)
const uploaded = await Promise.all(images.map(async file => ({
type: 'image' as const,
mediaType: imageMediaType(file.type),
data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
...(file.name === '' ? {} : { name: file.name }),
})))
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode)
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Create runtime-only draft attachments and their object URLs.
* @param files - browser-owned image files.
* @returns ordered attachment descriptors whose ids may enter the chat store.
*/
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
return files.map((file) => {
const attachment = new BrowserDraftAttachment(file)
this.draftAttachments.set(attachment.id, attachment)
this.createdImageUrls.add(attachment.previewUrl)
return attachment
})
}
/**
* Resolve ordered store ids to runtime-owned draft attachments.
* @param ids - ordered ids from the chat store.
* @returns attachments still available in this browser runtime.
*/
draftImages(ids: readonly string[]): readonly ComposerAttachment[] {
const attachments: ComposerAttachment[] = []
for (const id of ids) {
const attachment = this.draftAttachments.get(id)
if (attachment !== undefined) attachments.push(attachment)
}
return attachments
}
/**
* Release one draft attachment preview.
* @param id - draft-local attachment id.
*/
releaseDraftImage(id: string): void {
const attachment = this.draftAttachments.get(id)
if (attachment === undefined) return
this.draftAttachments.delete(id)
this.createdImageUrls.delete(attachment.previewUrl)
revokePreview(attachment.previewUrl)
}
/**
* Release sent draft attachment previews.
* @param attachments - successfully submitted attachments.
*/
releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
for (const attachment of attachments) this.releaseDraftImage(attachment.id)
}
/**
* Resolve and cache one session-authorized historical image as an object URL.
* @param sessionId - session whose durable log grants the read.
* @param attachment - immutable reference from that log.
* @returns a browser URL for inline and original-size display.
*/
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
const key = `${sessionId}:${attachment.attachmentId}`
const cached = this.imageUrls.get(key)
if (cached !== undefined) return cached
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 }))
this.createdImageUrls.add(url)
return url
})
.catch((error: unknown) => {
this.imageUrls.delete(key)
throw error
})
this.imageUrls.set(key, pending)
return pending
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')
@@ -132,7 +245,12 @@ export class ConversationService extends Service {
* awaited through the RPC round trip).
* @param opts - project directory, prompt text, and send mode.
*/
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
async startSession(opts: {
cwd?: string
text: string
images?: readonly File[]
mode: 'queue' | 'steer'
}): Promise<void> {
const sessions = this.requireSessions()
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
// The manager notifier flushes per microtask; one await guarantees the
@@ -146,7 +264,7 @@ export class ConversationService extends Service {
// global store and still binds this service to the scoped ctx.
const scopedConversation = scoped.get('conversation')
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
await scopedConversation.send(opts.text, opts.mode)
await scopedConversation.send(opts.text, opts.mode, opts.images ?? [])
}
/** Resolve the caller scope's Session or throw on root contexts. */
@@ -173,3 +291,28 @@ function bumpViews(state: ViewsState): void {
state.tick += 1
for (const fn of [...state.listeners]) fn()
}
function imageMediaType(value: string): ImageMediaType {
switch (value) {
case 'image/png':
case 'image/jpeg':
case 'image/webp':
case 'image/gif':
return value
default:
throw new Error(`不支持的图片格式:${value || '未知格式'}`)
}
}
function bytesToBase64(data: Uint8Array): string {
let binary = ''
const chunk = 0x8000
for (let offset = 0; offset < data.length; offset += chunk) {
binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
}
return btoa(binary)
}
function revokePreview(url: string): void {
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
}

View File

@@ -5,7 +5,7 @@
// 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).
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
import { useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -36,7 +36,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions,
views, send, stop, openDetails, loadOlder, open,
views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
@@ -47,11 +47,22 @@ export function ConversationRoot({
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const imageIds = useStore(s => s.imageIds)
const attachments = useMemo(() => draftImages(imageIds), [draftImages, imageIds])
const running = useSession(s => s.running)
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
// Browser File/object-URL values are intentionally runtime-only. A reload
// may rehydrate ids whose objects no longer exist; prune those ids through
// the declared store action after the first render.
useEffect(() => {
if (attachments.length !== imageIds.length) {
actions.pruneImages(attachments.map(attachment => attachment.id))
}
}, [actions, attachments, imageIds])
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
@@ -128,12 +139,15 @@ export function ConversationRoot({
<InputBar
draft={draft}
attachments={attachments}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onAddImages={addImages}
onRemoveAttachment={removeImage}
onSend={(mode) => { send(draft, attachments, mode) }}
onStop={stop}
/>
</div>

View File

@@ -6,10 +6,10 @@
// §6) plus a free-form new-directory input; submit runs the startSession
// chain (create → open → send) in one service call.
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import type { ComposerAttachment, EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
@@ -36,6 +36,9 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
const [attachments, setAttachments] = useState<readonly ComposerAttachment[]>([])
const attachmentsRef = useRef(attachments)
attachmentsRef.current = attachments
const [cwd, setCwd] = useState<string>('')
const [custom, setCustom] = useState(false)
const [sending, setSending] = useState(false)
@@ -44,11 +47,16 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
const submit = (mode: 'queue' | 'steer'): void => {
const text = draft.trim()
/* v8 ignore next -- defensive: InputBar disables send while empty. */
if (text === '' || sending) return
if ((text === '' && attachments.length === 0) || sending) return
setSending(true)
setError(null)
const chosen = cwd.trim()
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
startSession({
text,
...(attachments.length === 0 ? {} : { images: attachments.map(item => item.file) }),
mode,
...(chosen === '' ? {} : { cwd: chosen }),
})
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
@@ -58,6 +66,24 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
useEffect(() => () => {
for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl)
}, [])
const addImages = (files: readonly File[]): void => {
setAttachments(current => [...current, ...files.map(file => ({
id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file),
}))])
}
const removeImage = (id: string): void => {
setAttachments((current) => {
const removed = current.find(item => item.id === id)
if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl)
return current.filter(item => item.id !== id)
})
}
const picker = (
<div className={css.picker}>
{custom
@@ -102,6 +128,7 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
</div>
<InputBar
draft={draft}
attachments={attachments}
running={false}
disabled={sending}
error={error}
@@ -109,6 +136,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
placeholder="Message to run task, plan and build"
accessory={picker}
onDraftChange={setDraft}
onAddImages={addImages}
onRemoveAttachment={removeImage}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}

View File

@@ -0,0 +1,34 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 40px;
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
}
.image {
max-width: min(100%, 1600px);
max-height: calc(100vh - 80px);
object-fit: contain;
border-radius: 12px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
}
.close {
position: fixed;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
font-size: 24px;
cursor: pointer;
}

View File

@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react'
import css from './ImageLightbox.module.css'
/** Document-level original-image preview opened by an explicit double-click. */
export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose(): void }) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return (
<div
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label="原图预览"
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label="关闭原图预览" onClick={onClose}>×</button>
</div>
)
}

View File

@@ -32,6 +32,7 @@
}
.card {
position: relative;
display: flex;
flex-direction: column;
/* figma Input 34:11458: 12px between the text area and the button row. */
@@ -49,6 +50,25 @@
line-height: 24px;
}
.dragActive {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
}
.dropHint {
position: absolute;
z-index: 2;
inset: 4px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
color: var(--dsw-alias-state-business-primary);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
/* New-session state rounds up (figma: r24 and a taller box). */
.hero .card {
border-radius: 24px;
@@ -61,6 +81,57 @@
padding: 10px 12px 0;
}
.attachments {
display: flex;
gap: 8px;
min-width: 0;
padding: 12px 12px 0;
overflow-x: auto;
overflow-y: hidden;
}
.attachment {
position: relative;
flex: 0 0 72px;
width: 72px;
height: 72px;
}
.thumbnail {
width: 72px;
height: 72px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove {
position: absolute;
top: -6px;
right: -6px;
display: grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--dsw-specific-input-major);
border-radius: 999px;
background: var(--dsw-alias-label-primary);
color: var(--dsw-specific-input-major);
font-size: 16px;
line-height: 1;
cursor: pointer;
}
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
MUST share font, line-height, padding and wrapping rules or heights diverge. */

View File

@@ -5,11 +5,19 @@
// LOCKS the input: textarea disabled with the draft visible, stop is the only
// action; the turn ending re-enables and refocuses.
import { useEffect, useRef } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import type { ComposerAttachment } from '../contract/slots.ts'
import { ImageLightbox } from './ImageLightbox.tsx'
import css from './InputBar.module.css'
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
function supportedImages(files: Iterable<File>): File[] {
return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type))
}
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
export interface InputBarError {
op: 'send' | 'stop'
@@ -18,6 +26,7 @@ export interface InputBarError {
export interface InputBarProps {
draft: string
attachments?: readonly ComposerAttachment[]
running: boolean
disabled: boolean
error: InputBarError | null
@@ -27,15 +36,22 @@ export interface InputBarProps {
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
onDraftChange: (text: string) => void
onAddImages?: (files: readonly File[]) => void
onRemoveAttachment?: (id: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, attachments = [], running, disabled, error, variant, placeholder, accessory,
onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
const [dropError, setDropError] = useState<string | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const dragDepthRef = useRef(0)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -72,6 +88,56 @@ export function InputBar({
if (!empty && !locked) onSend('queue')
}
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => {
const files = [...event.clipboardData.items]
.filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type))
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length === 0) return
event.preventDefault()
setDropError(null)
onAddImages(files)
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked) return
dragDepthRef.current += 1
setDropError(null)
setDragActive(true)
}
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
dragDepthRef.current = 0
setDragActive(false)
if (locked) return
const dropped = [...event.dataTransfer.files]
const images = supportedImages(dropped)
if (images.length === 0) {
setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片')
return
}
setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件')
onAddImages(images)
}
const closePreview = useCallback(() => { setPreview(null) }, [])
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
@@ -95,8 +161,38 @@ export function InputBar({
{error.op === 'stop' ? '停止失败' : '发送失败'}{error.message}
</div>
)}
<div className={css.card}>
{dropError !== null && <div className={css.error}>{dropError}</div>}
<div
className={clsx(css.card, dragActive && css.dragActive)}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status"></div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} aria-label="待发送图片">
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title="双击查看原图"
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || '待发送图片'} />
</button>
<button
type="button"
className={css.remove}
aria-label={`移除图片 ${attachment.file.name || ''}`}
onClick={() => { onRemoveAttachment(attachment.id) }}
>×</button>
</div>
))}
</div>
)}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
@@ -110,6 +206,7 @@ export function InputBar({
rows={2}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
@@ -137,6 +234,7 @@ export function InputBar({
</button>
</div>
</div>
{preview !== null && <ImageLightbox src={preview.previewUrl} alt={preview.file.name || '原图'} onClose={closePreview} />}
</div>
)
}

View File

@@ -19,8 +19,11 @@ import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.t
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
addImages: (draft: ChatStoreState, ids: readonly string[]) => void
removeImage: (draft: ChatStoreState, id: string) => void
pruneImages: (draft: ChatStoreState, available: readonly string[]) => void
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
restoreDraft: (draft: ChatStoreState, text: string, imageIds: readonly string[]) => void
setView: (draft: ChatStoreState, view: ViewId) => void
}
@@ -37,15 +40,30 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
// Anchored to the contract shape: views consume the store through
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
// contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
init: (): ChatStoreState => ({ selection: null, draft: '', imageIds: [], view: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
// Optimistic-send failure restore: only when the user typed nothing new
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
addImages: (d, ids: readonly string[]) => { d.imageIds.push(...ids) },
removeImage: (d, id: string) => {
d.imageIds = d.imageIds.filter(candidate => candidate !== id)
},
pruneImages: (d, available: readonly string[]) => {
const keep = new Set(available)
d.imageIds = d.imageIds.filter(id => keep.has(id))
},
clearDraft: (d) => {
d.draft = ''
d.imageIds = []
},
// Optimistic-send failure restore keeps any newer typing/images while
// restoring the submitted draft material that disappeared on clear.
restoreDraft: (d, text: string, imageIds: readonly string[]) => {
if (d.draft === '') d.draft = text
const current = new Set(d.imageIds)
d.imageIds = [...imageIds.filter(id => !current.has(id)), ...d.imageIds]
},
setView: (d, view: ViewId) => { d.view = view },
},
})

View File

@@ -132,25 +132,25 @@ describe('conversation slot inject surface', () => {
const { instance, injected } = b.conversationSurface(ROOT)
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
instance.actions.setDraft(' ')
injected.send(' ', 'queue')
injected.send(' ', [], 'queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(instance.store.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
instance.actions.setDraft('hello')
injected.send('hello', 'queue')
injected.send('hello', [], 'queue')
expect(instance.store.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
instance.actions.setDraft('retry me')
injected.send('retry me', 'queue')
injected.send('retry me', [], 'queue')
await vi.waitFor(() => {
expect(instance.store.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.send('retry me', 'queue')
injected.send('retry me', [], 'queue')
instance.actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(instance.store.getSnapshot().draft).toBe('typed during flight')

View File

@@ -15,9 +15,9 @@ beforeEach(() => {
})
describe('createChatStore', () => {
it('init shape: empty selection/draft/view', () => {
it('init shape: empty selection/draft/images/view', () => {
const store = createChatStore().create()
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null })
})
it('actions cover the declared write set', () => {
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
store.actions.setDraft('hello')
expect(store.store.getSnapshot().draft).toBe('hello')
store.actions.addImages(['a', 'b'])
store.actions.removeImage('a')
expect(store.store.getSnapshot().imageIds).toEqual(['b'])
store.actions.pruneImages([])
expect(store.store.getSnapshot().imageIds).toEqual([])
store.actions.clearDraft()
expect(store.store.getSnapshot().draft).toBe('')
@@ -40,12 +45,15 @@ describe('createChatStore', () => {
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
const store = createChatStore().create()
// Rollback path: draft was cleared by send, nothing typed since.
store.actions.restoreDraft('failed text')
store.actions.restoreDraft('failed text', ['old-image'])
expect(store.store.getSnapshot().draft).toBe('failed text')
expect(store.store.getSnapshot().imageIds).toEqual(['old-image'])
// The user typed something new before the failure landed: keep theirs.
store.actions.setDraft('newer input')
store.actions.restoreDraft('stale text')
store.actions.addImages(['new-image'])
store.actions.restoreDraft('stale text', ['old-image'])
expect(store.store.getSnapshot().draft).toBe('newer input')
expect(store.store.getSnapshot().imageIds).toEqual(['old-image', 'new-image'])
})
it('persists per scope key and rehydrates a fresh instance', () => {

View File

@@ -129,3 +129,89 @@ describe('error strip and variants', () => {
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
})
describe('image draft rail', () => {
it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => {
const onAddImages = vi.fn()
const { textarea } = setup({ draft: '', onAddImages })
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
const prevented = fireEvent.paste(textarea, {
clipboardData: {
items: [
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
{ kind: 'file', type: 'image/png', getAsFile: () => image },
],
},
})
expect(prevented).toBe(false)
expect(onAddImages).toHaveBeenCalledWith([image])
fireEvent.paste(textarea, {
clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] },
})
expect(onAddImages).toHaveBeenCalledTimes(1)
})
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
const onAddImages = vi.fn()
const { view } = setup({ draft: '', onAddImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' })
const dataTransfer = {
types: ['Files'],
files: [image],
dropEffect: 'none',
}
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(view.queryByRole('status')).toBeNull()
expect(onAddImages).toHaveBeenCalledWith([image])
})
it('ignores unsupported dropped files and refuses drops while locked', () => {
const onAddImages = vi.fn()
const { view } = setup({ draft: '', onAddImages })
const card = view.container.querySelector('[class*="card"]')!
const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' })
fireEvent.drop(card, {
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
})
expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy()
expect(onAddImages).not.toHaveBeenCalled()
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
const locked = setup({ draft: '', disabled: true, onAddImages })
const lockedCard = locked.view.container.querySelector('[class*="card"]')!
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
fireEvent.dragEnter(lockedCard, { dataTransfer })
expect(locked.view.queryByRole('status')).toBeNull()
fireEvent.dragOver(lockedCard, { dataTransfer })
expect(dataTransfer.dropEffect).toBe('none')
fireEvent.drop(lockedCard, { dataTransfer })
expect(onAddImages).not.toHaveBeenCalled()
})
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' }
const onRemoveAttachment = vi.fn()
const { view, textarea, props } = setup({
draft: '', attachments: [attachment], onRemoveAttachment,
})
const send = view.getByRole('button', { name: '发送' }) as HTMLButtonElement
expect(send.disabled).toBe(false)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledWith('queue')
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(onRemoveAttachment).toHaveBeenCalledWith('draft-1')
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
expect(view.getAllByAltText('pixel.png').every(node => (node as HTMLImageElement).src.includes('blob:draft-1'))).toBe(true)
fireEvent.keyDown(window, { key: 'Escape' })
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
})

View File

@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
afterEach(cleanup)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} />)
const frame = view.getByRole('button', { name: 'history.png双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('surfaces a retry control when durable bytes cannot be read', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
})

View File

@@ -175,6 +175,6 @@ describe('selection survives on the store seat', () => {
await flush()
const reborn = storeFor(b, 'conversation', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null })
})
})

View File

@@ -93,6 +93,29 @@ describe('send / cancel', () => {
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
})
it('uploads temporary browser files as base64 image parts at the send boundary', async () => {
const b = await bench()
const file = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
Object.defineProperty(file, 'arrayBuffer', {
value: () => Promise.resolve(Uint8Array.of(1, 2, 3).buffer),
})
await b.scopedSvc(sid('s1')).send('describe', 'queue', [file])
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith([
{ type: 'image', mediaType: 'image/png', data: 'AQID', name: 'pixel.png' },
{ type: 'text', text: 'describe' },
], 'queue')
})
it('rejects unsupported browser media before prompting the session', async () => {
const b = await bench()
const file = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
Object.defineProperty(file, 'arrayBuffer', {
value: () => Promise.resolve(Uint8Array.of(1).buffer),
})
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file])).rejects.toThrow(/不支持的图片格式/)
expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled()
})
it('cancel resolves on ok and throws the folded business error', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))

View File

@@ -71,6 +71,9 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn()}
removeImage={vi.fn()}
draftImages={() => []}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
@@ -129,6 +132,9 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn()}
removeImage={vi.fn()}
draftImages={() => []}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}

View File

@@ -121,6 +121,9 @@ describe('ConversationRoot', () => {
subscribe: () => () => {},
version: () => 1,
}}
addImages={vi.fn()}
removeImage={vi.fn()}
draftImages={() => []}
send={send}
stop={stop}
openDetails={openDetails}
@@ -183,7 +186,7 @@ describe('ConversationRoot', () => {
// Typing goes through actions.setDraft into the shared store.
expect(chat.store.getSnapshot().draft).toBe('hi')
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('hi', 'queue')
expect(send).toHaveBeenCalledWith('hi', [], 'queue')
})
})

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../../vendor/cordis"
},

View File

@@ -96,6 +96,9 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
}}
addImages={vi.fn()}
removeImage={vi.fn()}
draftImages={() => []}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}