diff --git a/adapters/common/attachment/__tests__/image-block-watcher.test.ts b/adapters/common/attachment/__tests__/image-block-watcher.test.ts new file mode 100644 index 00000000..01d0775b --- /dev/null +++ b/adapters/common/attachment/__tests__/image-block-watcher.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'bun:test' +import { ImageBlockWatcher } from '../image-block-watcher.js' + +describe('ImageBlockWatcher', () => { + it('extracts a markdown image with http URL', () => { + const w = new ImageBlockWatcher() + const out = w.feed('Here is ![alt](https://example.com/foo.png) an image.') + expect(out.length).toBe(1) + const source = out[0]!.source + expect(source.kind).toBe('url') + if (source.kind === 'url') { + expect(source.url).toBe('https://example.com/foo.png') + } + expect(out[0]!.alt).toBe('alt') + }) + + it('extracts a markdown image with absolute local path', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![cat](/tmp/cat.jpg)') + expect(out.length).toBe(1) + const source = out[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') { + expect(source.path).toBe('/tmp/cat.jpg') + } + }) + + it('extracts a markdown image with file:// URL as path', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![x](file:///var/img/x.png)') + const source = out[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png') + }) + + it('extracts a data URI as base64', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![inline](data:image/png;base64,AAAA)') + const source = out[0]!.source + expect(source.kind).toBe('base64') + if (source.kind === 'base64') { + expect(source.mime).toBe('image/png') + expect(source.data).toBe('AAAA') + } + }) + + it('deduplicates the same image across multiple feeds', () => { + const w = new ImageBlockWatcher() + const a = w.feed('![](https://x/y.png)') + const b = w.feed(' repeated ![](https://x/y.png) again') + expect(a.length).toBe(1) + expect(b.length).toBe(0) + }) + + it('handles images split across feed boundaries', () => { + const w = new ImageBlockWatcher() + const a = w.feed('a ![al') + const b = w.feed('t](/tmp/x.png) b') + expect(a.length).toBe(0) + expect(b.length).toBe(1) + const source = b[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png') + }) + + it('skips non-image markdown links', () => { + const w = new ImageBlockWatcher() + const out = w.feed('See [docs](https://example.com).') + expect(out.length).toBe(0) + }) + + it('drain() returns all accumulated uploads', () => { + const w = new ImageBlockWatcher() + w.feed('![a](/tmp/a.png)') + w.feed(' and ![b](/tmp/b.png)') + const all = w.drain() + expect(all.length).toBe(2) + }) +}) diff --git a/adapters/common/attachment/image-block-watcher.ts b/adapters/common/attachment/image-block-watcher.ts new file mode 100644 index 00000000..cce5c826 --- /dev/null +++ b/adapters/common/attachment/image-block-watcher.ts @@ -0,0 +1,94 @@ +/** + * Pure, stateful extractor that watches a stream of assistant text for + * markdown image references (`![alt](source)`) and emits PendingUpload + * records. Used by IM adapters to know which images to upload to IM. + * + * - Buffers input so an image marker split across multiple feed() calls + * still gets detected. + * - Dedups by fingerprint of the source so the same image is only emitted + * once per watcher lifetime. + */ + +import type { PendingUpload } from './attachment-types.js' + +// Matches a complete markdown image: ![alt](target) +// `alt` may be empty; `target` stops at the first closing paren. +const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g + +function fingerprint(raw: string): string { + let h = 5381 + for (let i = 0; i < raw.length; i++) { + h = ((h << 5) + h) ^ raw.charCodeAt(i) + } + return (h >>> 0).toString(16) +} + +function classify(target: string): PendingUpload['source'] | null { + if (target.startsWith('data:')) { + const m = /^data:([^;,]+);base64,(.+)$/.exec(target) + if (!m) return null + return { kind: 'base64', mime: m[1]!, data: m[2]! } + } + if (target.startsWith('file://')) { + return { kind: 'path', path: target.slice('file://'.length) } + } + if (target.startsWith('http://') || target.startsWith('https://')) { + return { kind: 'url', url: target } + } + if (target.startsWith('/')) { + return { kind: 'path', path: target } + } + return null // relative paths — skip, we can't resolve them safely +} + +export class ImageBlockWatcher { + private buffer = '' + private seen = new Set() + private accumulated: PendingUpload[] = [] + + /** Feed a new chunk of streaming text; returns any NEW PendingUploads. */ + feed(chunk: string): PendingUpload[] { + this.buffer += chunk + const out: PendingUpload[] = [] + + IMAGE_RE.lastIndex = 0 + let lastConsumedEnd = 0 + let m: RegExpExecArray | null + while ((m = IMAGE_RE.exec(this.buffer)) !== null) { + const [, alt, target] = m + const source = classify(target!) + if (source) { + const id = fingerprint(`${source.kind}:${target}`) + if (!this.seen.has(id)) { + this.seen.add(id) + const pending: PendingUpload = { id, source, alt: alt || undefined } + out.push(pending) + this.accumulated.push(pending) + } + } + lastConsumedEnd = m.index + m[0].length + } + + // Preserve tail that might contain a partially-received marker. + if (lastConsumedEnd > 0) { + this.buffer = this.buffer.slice(lastConsumedEnd) + } + if (this.buffer.length > 4096) { + this.buffer = this.buffer.slice(-2048) + } + + return out + } + + /** Return everything seen so far (for end-of-stream reconciliation). */ + drain(): PendingUpload[] { + return [...this.accumulated] + } + + /** Reset watcher state (use at /clear or new session). */ + reset(): void { + this.buffer = '' + this.seen.clear() + this.accumulated = [] + } +}