Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/client/ui-conversation/package.json
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 15:58:52 +08:00
193 changed files with 5815 additions and 507 deletions

View File

@@ -33,6 +33,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",

View File

@@ -7,9 +7,9 @@
* must stub); runtime-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
MessageId, QueueAction, RpcResult, SessionId,
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -33,11 +33,19 @@ export interface ISession {
readonly projections: ProjectionsFace
/**
* Send a prompt into the session.
* @param content - model-facing content blocks.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.

View File

@@ -8,6 +8,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
@@ -43,6 +44,7 @@ export interface AssistantProvenanceView {
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'image'; attachment: ImageAttachmentRef }
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
@@ -64,6 +66,7 @@ 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 }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}

View File

@@ -1,10 +1,10 @@
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { Context } from 'cordis'
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, MessageId, MuxFrame, QueueAction, RpcError,
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
@@ -178,11 +178,11 @@ export class Session implements SessionFace {
/**
* 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
// Synchronous, before the first await: the blank → engaging edge must be
@@ -205,8 +205,24 @@ export class Session implements SessionFace {
},
}
} else {
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
if (content.some(part => part.type === 'image')) {
result = {
ok: false,
error: {
code: 'attachment-error',
message: 'Image input is unavailable for subagent continuations.',
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
},
}
} else {
const routed = (await this.api.subagents.prompt({
...this.address,
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
}
} catch (error) {
result = transportError(error)
@@ -232,6 +248,28 @@ export class Session implements SessionFace {
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)
}
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
try {

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

@@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient {
Promise<RpcResponse<{ selected: ModelSelection }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
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==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -531,6 +531,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('rename', () => {

View File

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