- dsh web gains --provider/--model: a non-deepseek provider mounts the matching pi-ai catalog route (ambient credentials), making image input reachable from the shipped Web assembly; requires an explicit --model - attachment-local syncs the publication directories after the hard-link publish so a reported durable reference survives a crash (POSIX; Windows relies on filesystem metadata journaling) - the attachment seam gains storage-free validateImage; the host validates a complete multi-image prompt before persisting any member, so one malformed image cannot strand valid members as unreferenced objects - startSession sends before navigating: a rejected first send keeps the empty state, its error strip, and the complete draft mounted - the webserver rejects an undeclared-length body the moment it crosses the configured limit instead of draining a potentially endless stream to EOF
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
/**
|
|
* ConversationService implementation: scope-addressed send/cancel and the
|
|
* empty-state startSession chain. Contract: api-contracts v3 section 7.
|
|
* Selection/draft state moved to the declared chat store (slot terminal
|
|
* design §4); the view registry moved to the 'conversation.view' slot (slot
|
|
* ledger owns registration, ordering, and disposal) — what remains is the
|
|
* send/stop orchestration face.
|
|
*
|
|
* Scope addressing rides the cordis Service tracker: property access through
|
|
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
|
* read the session tag with scopeOf (same mechanism as the host tool
|
|
* registry). Mutable state lives in plain objects reached by one property
|
|
* read — field assignment through the tracker's shadow proxy is off-limits,
|
|
* as are `#` hard-private fields.
|
|
*/
|
|
import { Service } from 'cordis'
|
|
import type { Context } from 'cordis'
|
|
// 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'
|
|
|
|
/** Opaque wrapper keeps browser `File` internals outside persisted store state. */
|
|
class BrowserDraftAttachment implements ComposerAttachment {
|
|
readonly kind = 'image' as const
|
|
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
|
|
}
|
|
}
|
|
|
|
interface ImageUrlEntry {
|
|
readonly sessionId: SessionId
|
|
readonly generation: number
|
|
readonly pending: Promise<string>
|
|
}
|
|
|
|
/** 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, ImageUrlEntry>()
|
|
private readonly imageGenerations = new Map<SessionId, number>()
|
|
private readonly createdImageUrls = new Set<string>()
|
|
|
|
/**
|
|
* @param ctx - owning root context (the plugin apply context; the service
|
|
* registers itself and follows that fiber's lifetime).
|
|
*/
|
|
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()
|
|
this.imageGenerations.clear()
|
|
}, 'conversation attachment URL cache')
|
|
}
|
|
|
|
/**
|
|
* Send a prompt into the scoped session. Business failures also land in the
|
|
* session snapshot's promptError (object-layer surface); the rejection here
|
|
* exists for caller choreography (the composer restores the draft on it).
|
|
* @param text - prompt text, sent verbatim as one text block when non-empty.
|
|
* @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', images: readonly File[] = []): Promise<void> {
|
|
const session = this.scopedSession('send')
|
|
this.validateImages(images, [])
|
|
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.
|
|
* @param current - images already present in the same composer.
|
|
* @param checkDefaultModel - whether to apply the host default-model capability before a session exists.
|
|
* @returns ordered attachment descriptors whose ids may enter the chat store.
|
|
*/
|
|
createDraftImages(
|
|
files: readonly File[],
|
|
current: readonly ComposerAttachment[] = [],
|
|
checkDefaultModel = false,
|
|
): readonly ComposerAttachment[] {
|
|
this.validateImages(files, current, checkDefaultModel)
|
|
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.pending
|
|
const generation = this.imageGenerations.get(sessionId) ?? 0
|
|
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,
|
|
}))
|
|
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
|
|
revokePreview(url)
|
|
throw new Error('historical image scope was released before loading completed')
|
|
}
|
|
this.createdImageUrls.add(url)
|
|
return url
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key)
|
|
throw error
|
|
})
|
|
this.imageUrls.set(key, { sessionId, generation, pending })
|
|
return pending
|
|
}
|
|
|
|
/**
|
|
* Release every historical image URL owned by one rendered session.
|
|
* @param sessionId - session whose rendered image scope is ending.
|
|
*/
|
|
releaseSessionImages(sessionId: SessionId): void {
|
|
this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1)
|
|
for (const [key, entry] of this.imageUrls) {
|
|
if (entry.sessionId !== sessionId) continue
|
|
this.imageUrls.delete(key)
|
|
void entry.pending.then((url) => {
|
|
if (!this.createdImageUrls.delete(url)) return
|
|
revokePreview(url)
|
|
}, () => {
|
|
// A failed or generation-invalidated load owns no cached object URL.
|
|
})
|
|
}
|
|
}
|
|
|
|
/** 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')
|
|
const result = await session.cancel()
|
|
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
|
}
|
|
|
|
/**
|
|
* Empty-state first-send chain (root-context method; does not read scope):
|
|
* create the session, send through the new scope, and navigate only after
|
|
* the send is accepted. Navigation is the publication point — opening
|
|
* earlier would unmount the empty state (releasing its draft previews)
|
|
* while the send can still fail, leaving the failure with no surface and
|
|
* the user with a lost draft; on rejection here the still-mounted empty
|
|
* state keeps the draft and shows the error locally.
|
|
* @param opts - project directory, prompt text, images, and send mode.
|
|
*/
|
|
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
|
|
// list-store projection landed before sessions.open validates against it
|
|
// (the manager merges the new summary synchronously before create()
|
|
// resolves; batching is microtask-based).
|
|
await Promise.resolve()
|
|
const scoped = sessions.scope(id)
|
|
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
|
|
// ctx.get, not scoped.conversation: property access walks the fiber
|
|
// topology (a scope fiber never injects services), while get reads the
|
|
// 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, opts.images ?? [])
|
|
sessions.open(id)
|
|
}
|
|
|
|
/** Resolve the caller scope's Session or throw on root contexts. */
|
|
private scopedSession(op: string): Session {
|
|
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 id
|
|
}
|
|
|
|
private requireSessions(): SessionsService {
|
|
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
|
|
// while the client/host `sessions` declaration collision awaits
|
|
// arbitration (see the runtime package's Context merge note).
|
|
const sessions = this.ctx.get('sessions')
|
|
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
|
return sessions
|
|
}
|
|
|
|
/** Apply host-advertised fast-path checks before any object URL or base64 allocation. */
|
|
private validateImages(
|
|
files: readonly File[],
|
|
current: readonly ComposerAttachment[],
|
|
checkDefaultModel = false,
|
|
): void {
|
|
const description = this.requireSessions().hostDescription()
|
|
const modalities = description?.activeModel?.inputModalities
|
|
if (checkDefaultModel && modalities !== undefined && !modalities.includes('image')) {
|
|
throw new Error('当前模型不支持图片输入')
|
|
}
|
|
const limits = description?.imageLimits
|
|
const all = [...current.map(attachment => attachment.file), ...files]
|
|
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
|
|
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
|
|
}
|
|
let totalBytes = 0
|
|
for (const file of all) {
|
|
const mediaType = imageMediaType(file.type)
|
|
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
|
|
throw new Error(`当前部署不支持 ${mediaType} 图片`)
|
|
}
|
|
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
|
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
|
}
|
|
totalBytes += file.size
|
|
}
|
|
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
|
|
throw new Error('图片总大小超过单条消息限制')
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|