diff --git a/index.js b/index.js index c23874c..9019172 100644 --- a/index.js +++ b/index.js @@ -67,6 +67,7 @@ export class MyDurableObject { this.controller = null; this.oaStream = null; this.pending = ''; + this.pendingImages = []; this.flushTimer = null; this.lastSavedAt = 0; this.lastFlushedAt = 0; @@ -85,7 +86,7 @@ export class MyDurableObject { getConversationText() { const prompt = (this.messages || []).map(m => `## ${m.role}\n\n${this.extractTextFromMessage(m)}`).join('\n\n---\n\n'); - const response = this.buffer.map(it => it.text).join(''); + const response = this.buffer.map(it => it.text || '').join(''); if (!prompt && !response) return ''; return `${prompt}\n\n---\n\n## assistant\n\n${response}`; } @@ -125,6 +126,7 @@ export class MyDurableObject { this.error = snap.error || null; this.messages = Array.isArray(snap.messages) ? snap.messages : []; this.pending = ''; + this.pendingImages = []; if (this.phase === 'running') { this.phase = 'evicted'; @@ -142,17 +144,21 @@ export class MyDurableObject { } replay(ws, after) { - this.buffer.forEach(it => { if (it.seq > after) this.send(ws, { type: 'delta', seq: it.seq, text: it.text }); }); + this.buffer.forEach(it => { if (it.seq > after) this.send(ws, { type: 'delta', seq: it.seq, text: it.text, images: it.images }); }); if (this.phase === 'done') this.send(ws, { type: 'done' }); else if (['error', 'evicted'].includes(this.phase)) this.send(ws, { type: 'err', message: this.error || 'The run was terminated unexpectedly.' }); } flush(force = false) { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } - if (this.pending) { - this.buffer.push({ seq: ++this.seq, text: this.pending }); - this.bcast({ type: 'delta', seq: this.seq, text: this.pending }); + if (this.pending || this.pendingImages.length > 0) { + const payload = { type: 'delta', seq: ++this.seq }; + if (this.pending) payload.text = this.pending; + if (this.pendingImages.length > 0) payload.images = this.pendingImages; + this.buffer.push(payload); + this.bcast(payload); this.pending = ''; + this.pendingImages = []; this.lastFlushedAt = Date.now(); } if (force) this.saveSnapshot(); @@ -165,6 +171,12 @@ export class MyDurableObject { else if (!this.flushTimer) this.flushTimer = setTimeout(() => this.flush(false), BATCH_MS); } + queueImages(images) { + if (!Array.isArray(images) || images.length === 0) return; + this.pendingImages.push(...images); + if (!this.flushTimer) this.flushTimer = setTimeout(() => this.flush(false), BATCH_MS); + } + async fetch(req) { if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS_HEADERS }); @@ -179,10 +191,12 @@ export class MyDurableObject { if (req.method === 'GET') { await this.autopsy(); - const text = this.buffer.map(it => it.text).join('') + this.pending; + const text = this.buffer.map(it => it.text || '').join('') + this.pending; + const images = this.buffer.flatMap(it => it.images || []); + if (this.pendingImages.length > 0) images.push(...this.pendingImages); const isTerminal = ['done', 'error', 'evicted'].includes(this.phase); const isError = ['error', 'evicted'].includes(this.phase); - const payload = { rid: this.rid, seq: this.seq, phase: this.phase, done: isTerminal, error: isError ? (this.error || 'The run was terminated unexpectedly.') : null, text }; + const payload = { rid: this.rid, seq: this.seq, phase: this.phase, done: isTerminal, error: isError ? (this.error || 'The run was terminated unexpectedly.') : null, text, images }; return this.corsJSON(payload); } return this.corsJSON({ error: 'not allowed' }, 405); @@ -342,6 +356,9 @@ export class MyDurableObject { this.queueDelta(delta.content); hasContent = true; } + if (delta?.images) { + this.queueImages(delta.images); + } } } @@ -407,7 +424,7 @@ export class MyDurableObject { mapContentPartToResponses(part) { const type = part?.type || 'text'; if (['image_url', 'input_image'].includes(type)) return (part?.image_url?.url || part?.image_url) ? { type: 'input_image', image_url: String(part?.image_url?.url || part?.image_url) } : null; - if (['text', 'input_text'].includes(type)) return { type: 'input_text', text: String(type === 'text' ? (part.text ?? part.content ?? '') : (part.text ?? '')) }; + if (['text', 'input_text'].includes(type)) return { type: 'input_text', text: String(type === 'text' ? (p.text ?? p.content ?? '') : (p.text ?? '')) }; return { type: 'input_text', text: `[${type}:${part?.file?.filename || 'file'}]` }; }