|
@@ -29,10 +29,9 @@ export interface IConversation {
|
|
|
* Send a prompt into the caller scope's session.
|
|
* Send a prompt into the caller scope's session.
|
|
|
* @param text - prompt text, sent verbatim as one text block.
|
|
* @param text - prompt text, sent verbatim as one text block.
|
|
|
* @param mode - queue after the current turn, or steer into it.
|
|
* @param mode - queue after the current turn, or steer into it.
|
|
|
- * @param images - browser-owned temporary images promoted by the host during this call.
|
|
|
|
|
* @returns completion; business failures reject (and land in promptError).
|
|
* @returns completion; business failures reject (and land in promptError).
|
|
|
*/
|
|
*/
|
|
|
- send(text: string, mode: 'queue' | 'steer', images?: readonly File[]): Promise<void>
|
|
|
|
|
|
|
+ send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
|
|
/**
|
|
/**
|
|
|
* Cancel the scoped session's in-flight turn.
|
|
* Cancel the scoped session's in-flight turn.
|
|
|
* @returns completion; failures reject as in send.
|
|
* @returns completion; failures reject as in send.
|
|
@@ -43,57 +42,11 @@ export interface IConversation {
|
|
|
* @returns completion of the page pull.
|
|
* @returns completion of the page pull.
|
|
|
*/
|
|
*/
|
|
|
loadOlder(): Promise<void>
|
|
loadOlder(): Promise<void>
|
|
|
- /**
|
|
|
|
|
- * Create runtime-only draft attachments and preview URLs.
|
|
|
|
|
- * @param files - browser-owned image files.
|
|
|
|
|
- * @param current - images already present in the composer.
|
|
|
|
|
- * @returns ordered descriptors for the input state.
|
|
|
|
|
- */
|
|
|
|
|
- createDraftImages(
|
|
|
|
|
- files: readonly File[],
|
|
|
|
|
- current?: readonly ComposerAttachment[],
|
|
|
|
|
- ): readonly ComposerAttachment[]
|
|
|
|
|
- /**
|
|
|
|
|
- * Resolve ordered draft ids to runtime-owned attachments.
|
|
|
|
|
- * @param ids - ordered composer attachment ids.
|
|
|
|
|
- * @returns attachments still available in this browser runtime.
|
|
|
|
|
- */
|
|
|
|
|
- draftImages(ids: readonly string[]): readonly ComposerAttachment[]
|
|
|
|
|
- /**
|
|
|
|
|
- * Release one draft attachment and its preview URL.
|
|
|
|
|
- * @param id - draft-local attachment id.
|
|
|
|
|
- */
|
|
|
|
|
- releaseDraftImage(id: string): void
|
|
|
|
|
- /**
|
|
|
|
|
- * Resolve a session-authorized historical image to an object URL.
|
|
|
|
|
- * @param sessionId - session whose durable log grants the read.
|
|
|
|
|
- * @param attachment - durable image reference from that log.
|
|
|
|
|
- * @returns browser URL for inline and original-size rendering.
|
|
|
|
|
- */
|
|
|
|
|
- resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string>
|
|
|
|
|
- /**
|
|
|
|
|
- * Release every historical image URL owned by one rendered session.
|
|
|
|
|
- * @param sessionId - session whose rendered image scope is ending.
|
|
|
|
|
- */
|
|
|
|
|
- releaseSessionImages(sessionId: SessionId): void
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 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
|
|
|
|
|
- }
|
|
|
|
|
|
|
+/** Create one browser-only draft descriptor; only its id enters input state. */
|
|
|
|
|
+function browserDraftAttachment(file: File): ComposerAttachment {
|
|
|
|
|
+ return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface ImageUrlEntry {
|
|
interface ImageUrlEntry {
|
|
@@ -106,7 +59,7 @@ interface ImageUrlEntry {
|
|
|
export class ConversationService extends Service implements IConversation {
|
|
export class ConversationService extends Service implements IConversation {
|
|
|
/** The per-session input machine registry (InputService face, design §5.2). */
|
|
/** The per-session input machine registry (InputService face, design §5.2). */
|
|
|
readonly input: InputService
|
|
readonly input: InputService
|
|
|
- private readonly draftAttachments = new Map<string, BrowserDraftAttachment>()
|
|
|
|
|
|
|
+ private readonly draftAttachments = new Map<string, ComposerAttachment>()
|
|
|
private readonly imageUrls = new Map<string, ImageUrlEntry>()
|
|
private readonly imageUrls = new Map<string, ImageUrlEntry>()
|
|
|
private readonly imageGenerations = new Map<SessionId, number>()
|
|
private readonly imageGenerations = new Map<SessionId, number>()
|
|
|
private readonly createdImageUrls = new Set<string>()
|
|
private readonly createdImageUrls = new Set<string>()
|
|
@@ -135,11 +88,10 @@ export class ConversationService extends Service implements IConversation {
|
|
|
* exists for caller choreography (the composer restores the draft on it).
|
|
* 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 text - prompt text, sent verbatim as one text block when non-empty.
|
|
|
* @param mode - queue after the current turn, or steer into it.
|
|
* @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> {
|
|
|
|
|
|
|
+ async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
|
|
|
const session = this.scopedSession('send')
|
|
const session = this.scopedSession('send')
|
|
|
- await this.sendFiles(session, text, mode, images)
|
|
|
|
|
|
|
+ await this.sendFiles(session, text, mode, [])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -190,7 +142,7 @@ export class ConversationService extends Service implements IConversation {
|
|
|
): readonly ComposerAttachment[] {
|
|
): readonly ComposerAttachment[] {
|
|
|
this.validateImages(files, current)
|
|
this.validateImages(files, current)
|
|
|
return files.map((file) => {
|
|
return files.map((file) => {
|
|
|
- const attachment = new BrowserDraftAttachment(file)
|
|
|
|
|
|
|
+ const attachment = browserDraftAttachment(file)
|
|
|
this.draftAttachments.set(attachment.id, attachment)
|
|
this.draftAttachments.set(attachment.id, attachment)
|
|
|
this.createdImageUrls.add(attachment.previewUrl)
|
|
this.createdImageUrls.add(attachment.previewUrl)
|
|
|
return attachment
|
|
return attachment
|
|
@@ -330,19 +282,12 @@ export class ConversationService extends Service implements IConversation {
|
|
|
current: readonly ComposerAttachment[],
|
|
current: readonly ComposerAttachment[],
|
|
|
): void {
|
|
): void {
|
|
|
if (files.length === 0 && current.length === 0) return
|
|
if (files.length === 0 && current.length === 0) return
|
|
|
- // Deployment-wide limits only. Model capability is deliberately NOT
|
|
|
|
|
- // checked here: the handshake's activeModel is the host default, not the
|
|
|
|
|
- // session's current target (session.selectModel never refreshes it), so a
|
|
|
|
|
- // client-side modality gate refuses sessions the host would accept and
|
|
|
|
|
- // vice versa. The host preflight on session.prompt is the authority; its
|
|
|
|
|
- // rejection renders through the composer error strip.
|
|
|
|
|
|
|
+ // Model capability is checked only by the host against the session's
|
|
|
|
|
+ // current target; the client owns deployment limits and the one-image UI.
|
|
|
const description = this.requireSessions().hostDescription()
|
|
const description = this.requireSessions().hostDescription()
|
|
|
const limits = description?.imageLimits
|
|
const limits = description?.imageLimits
|
|
|
const all = [...current.map(attachment => attachment.file), ...files]
|
|
const all = [...current.map(attachment => attachment.file), ...files]
|
|
|
- if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
|
|
|
|
|
- throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
|
|
|
|
|
- }
|
|
|
|
|
- let totalBytes = 0
|
|
|
|
|
|
|
+ if (all.length > 1) throw new Error('每条消息最多添加 1 张图片')
|
|
|
for (const file of all) {
|
|
for (const file of all) {
|
|
|
const mediaType = imageMediaType(file.type)
|
|
const mediaType = imageMediaType(file.type)
|
|
|
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
|
|
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
|
|
@@ -351,10 +296,6 @@ export class ConversationService extends Service implements IConversation {
|
|
|
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
|
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
|
|
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
|
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
|
|
}
|
|
}
|
|
|
- totalBytes += file.size
|
|
|
|
|
- }
|
|
|
|
|
- if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
|
|
|
|
|
- throw new Error('图片总大小超过单条消息限制')
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|