/** * Browser-side API client for the /api/dsh-imagegen route family. The only * data access path the panel uses — plain fetch, same origin. */ import { CANVAS_API, CANVAS_SKILL_API, CONVERSATION_IMAGE_API, DATA_FOLDER_API, DIAGNOSTICS_API, GALLERY_API, GENERATE_API, HISTORY_API, PROMPT_ENHANCE_API, STORAGE_API, TASK_API, TEMPLATE_FAVORITES_API, TEMPLATES_API, UPDATE_API, type PptRunRequest, type PptRuntimeStatus, type CanvasAssetRef, type CanvasDocument, type CanvasFilePreview, type CanvasLayerPlan, type CanvasSkillCatalog, type CanvasSkillConfigApplyRequest, type CanvasSkillConfigApplyResult, type CanvasSkillConfigSaveRequest, type CanvasSkillConfigSaveResult, type CanvasSkillInstallRequest, type CanvasSkillInstallResult, type CanvasSkillLibrary, type CanvasSkillRemoveResult, type CanvasSkillRunRequest, type CanvasSkillTask, type CanvasSummary, type DiagnosticEntry, type GenerateRequest, type GenerateResult, type GenerationTask, type GenerationTaskSummary, type HistoryEntry, type HistoryEntryInput, type TemplateCase, type TemplateFavorite, type TemplateListResult, type TemplateRefreshResult, type TemplateSample, type UpdateInfo } from '../protocol.ts' import { activeImageGenLanguage } from './helpers.ts' /** Error carrying the route's JSON error message. */ export class ImageGenApiError extends Error { /** Stable wire code from the host. */ readonly code: string constructor(message: string, code = 'generate-failed') { super(message) this.name = 'ImageGenApiError' this.code = code } } /** Parse the { ok, ... } envelope or throw an ImageGenApiError. */ async function readEnvelope(response: Response): Promise { let body: unknown try { body = await response.json() } catch { throw new ImageGenApiError(`HTTP ${response.status}: invalid JSON response`) } if (body === null || typeof body !== 'object') { throw new ImageGenApiError(`HTTP ${response.status}: malformed response`) } const record = body as { ok?: unknown; message?: unknown; code?: unknown } if (record.ok !== true) { throw new ImageGenApiError( typeof record.message === 'string' ? record.message : `HTTP ${response.status}`, typeof record.code === 'string' ? record.code : 'generate-failed', ) } return body as T } /** The browser half's data entry point. */ export class ImageGenApi { async pptRun(request: Omit): Promise { const response = await fetch(CANVAS_SKILL_API.pptRun, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...request, language: activeImageGenLanguage() }) }) return (await readEnvelope<{ ok: true; task: CanvasSkillTask }>(response)).task } async pptTasks(): Promise { return (await readEnvelope<{ ok: true; tasks: CanvasSkillTask[] }>(await fetch(CANVAS_SKILL_API.pptTasks, { method: 'POST' }))).tasks } async pptRuntime(install = false): Promise { const response = await fetch(CANVAS_SKILL_API.pptRuntime, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ install }) }) return (await readEnvelope<{ ok: true; runtime: PptRuntimeStatus }>(response)).runtime } /** Ask the host to check the latest stable GitHub Release. */ async updateCheck(): Promise { const response = await fetch(UPDATE_API.check, { method: 'POST' }) const body = await readEnvelope<{ ok: true; update: UpdateInfo }>(response) return body.update } /** Ask the host to install a previously discovered Release. */ async updateApply(version: string): Promise<{ updatedVersion: string; restartRequired: boolean }> { const response = await fetch(UPDATE_API.apply, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ version }), }) const body = await readEnvelope<{ ok: true; updatedVersion: string; restartRequired: boolean }>(response) return { updatedVersion: body.updatedVersion, restartRequired: body.restartRequired } } /** Forward one generate request to the host proxy. */ async generate(request: GenerateRequest): Promise { const response = await fetch(GENERATE_API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), }) const body = await readEnvelope<{ ok: true; images: GenerateResult['images']; history?: HistoryEntry[]; historyError?: string }>(response) return { images: body.images, ...body.history === undefined ? {} : { history: body.history }, ...body.historyError === undefined ? {} : { historyError: body.historyError }, } } /** Ask the configured chat model to expand a concise image prompt. */ async enhancePrompt(prompt: string): Promise { const response = await fetch(PROMPT_ENHANCE_API.enhance, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompt }), }) const body = await readEnvelope<{ ok: true; prompt: string }>(response) return body.prompt } /** Polish one existing generation prompt through the configured chat model. */ async polishPrompt(prompt: string): Promise { const response = await fetch(PROMPT_ENHANCE_API.enhance, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompt, mode: 'polish' }), }) const body = await readEnvelope<{ ok: true; prompt: string }>(response) return body.prompt } /** Stage a generated image as a durable reference for `/edit_image`. */ async attachConversationImage(sessionId: string, dataUrl: string, name: string): Promise { const response = await fetch(CONVERSATION_IMAGE_API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, dataUrl, name }), }) await readEnvelope<{ ok: true }>(response) } async taskSubmit(request: GenerateRequest): Promise { const response = await fetch(TASK_API.submit, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request) }) return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task } async taskList(): Promise { const response = await fetch(TASK_API.list, { method: 'POST' }) return (await readEnvelope<{ ok: true; tasks: GenerationTaskSummary[] }>(response)).tasks } /** Fetch one task with its reference image and completed result payload. */ async taskGet(id: string): Promise { const response = await fetch(TASK_API.get, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task } async taskCancel(id: string): Promise { const response = await fetch(TASK_API.cancel, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task } async taskRetry(id: string): Promise { const response = await fetch(TASK_API.retry, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task } /** * The retained record of upstream attempts (#38). Read after a failure to * see which address was called, what came back, and how long it took. */ async diagnosticsList(): Promise { const response = await fetch(DIAGNOSTICS_API.list, { method: 'POST' }) return (await readEnvelope<{ ok: true; entries: DiagnosticEntry[] }>(response)).entries } /** Drop the retained attempts. */ async diagnosticsClear(): Promise { const response = await fetch(DIAGNOSTICS_API.clear, { method: 'POST' }) await readEnvelope<{ ok: true }>(response) } /** List the host-persisted history (newest first). */ async historyList(): Promise { const response = await fetch(HISTORY_API.list, { method: 'POST' }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response) return body.entries } /** Remove one history entry by id. */ async historyRemove(id: string): Promise { const response = await fetch(HISTORY_API.remove, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }), }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response) return body.entries } /** Clear the entire history. */ async historyClear(): Promise { const response = await fetch(HISTORY_API.clear, { method: 'POST' }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response) return body.entries } /** List the host-persisted gallery (newest first). */ async galleryList(): Promise { const response = await fetch(GALLERY_API.list, { method: 'POST' }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response) return body.entries } /** Append one image to the gallery. The host assigns the id and skips the * append when a content-identical image is already in the gallery. */ async galleryAppend(entry: HistoryEntryInput): Promise<{ entries: HistoryEntry[]; added: boolean }> { const response = await fetch(GALLERY_API.append, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ entry }), }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[]; added: boolean }>(response) return { entries: body.entries, added: body.added } } /** Remove one gallery entry by id. */ async galleryRemove(id: string): Promise { const response = await fetch(GALLERY_API.remove, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }), }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response) return body.entries } /** Clear the entire gallery. */ async galleryClear(): Promise { const response = await fetch(GALLERY_API.clear, { method: 'POST' }) const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response) return body.entries } async gallerySetTags(id: string, tags: string[]): Promise { const response = await fetch(GALLERY_API.tags, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, tags }) }) return (await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)).entries } async canvasList(): Promise { const response = await fetch(CANVAS_API.list, { method: 'POST' }) return (await readEnvelope<{ ok: true; projects: CanvasSummary[] }>(response)).projects } async canvasCreate(title?: string): Promise { const response = await fetch(CANVAS_API.create, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }) }) return (await readEnvelope<{ ok: true; document: CanvasDocument }>(response)).document } async canvasRead(id: string): Promise { const response = await fetch(CANVAS_API.read, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) return (await readEnvelope<{ ok: true; document: CanvasDocument }>(response)).document } async canvasSave(document: CanvasDocument, expectedRevision: number): Promise { const response = await fetch(CANVAS_API.save, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ document, expectedRevision }) }) return (await readEnvelope<{ ok: true; document: CanvasDocument }>(response)).document } async canvasRemove(id: string): Promise { const response = await fetch(CANVAS_API.remove, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) return (await readEnvelope<{ ok: true; projects: CanvasSummary[] }>(response)).projects } async canvasFavorite(id: string, favorite: boolean): Promise { const response = await fetch(CANVAS_API.favorite, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, favorite }) }) return (await readEnvelope<{ ok: true; projects: CanvasSummary[] }>(response)).projects } async canvasUpload(dataUrl: string, width: number, height: number, meta: { origin?: string; originId?: string; entryId?: string; imageIndex?: number } = {}): Promise { const response = await fetch(CANVAS_API.assetUpload, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dataUrl, width, height, ...meta }) }) return (await readEnvelope<{ ok: true; asset: CanvasAssetRef }>(response)).asset } async canvasImport(source: 'history' | 'gallery', entryId: string, imageIndex: number, width: number, height: number): Promise { const response = await fetch(CANVAS_API.assetImport, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ source, entryId, imageIndex, width, height }) }) return (await readEnvelope<{ ok: true; asset: CanvasAssetRef }>(response)).asset } /** Ask the host's chat model to decompose one image into editable layers. */ async canvasLayers(image: string): Promise { const response = await fetch(CANVAS_API.layers, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ image }) }) return (await readEnvelope<{ ok: true; plan: CanvasLayerPlan }>(response)).plan } /** * Upload one arbitrary file for a canvas file node. The File object travels * as the raw request body (no base64 inflation) with its name in the query * and its type in the content-type header. */ async canvasFileUpload(file: File): Promise { const response = await fetch(`${CANVAS_API.fileUpload}?name=${encodeURIComponent(file.name)}`, { method: 'POST', headers: { 'content-type': file.type === '' ? 'application/octet-stream' : file.type }, body: file, }) return (await readEnvelope<{ ok: true; asset: CanvasAssetRef }>(response)).asset } /** * Ask the host for one canvas file node's readable content. Media types come * back as an inline URL; text, spreadsheets, office documents and archives * come back decoded and bounded. */ async canvasFilePreview(asset: { assetId: string; name?: string; mime?: string }): Promise { const response = await fetch(CANVAS_API.filePreview, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(asset), }) return (await readEnvelope<{ ok: true; preview: CanvasFilePreview }>(response)).preview } /** Read the skill catalog offered to canvas nodes. */ async canvasSkillsList(): Promise { const response = await fetch(CANVAS_SKILL_API.list, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ language: activeImageGenLanguage() }), }) const body = await readEnvelope<{ ok: true } & CanvasSkillCatalog>(response) return { skills: body.skills, agentAvailable: body.agentAvailable, registryAvailable: body.registryAvailable, installed: body.installed ?? [], ...body.reason === undefined ? {} : { reason: body.reason }, } } /** Inspect the local skill library (`~/.dsh/skills`). */ async canvasSkillLibrary(): Promise { const response = await fetch(CANVAS_SKILL_API.library, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ language: activeImageGenLanguage() }), }) return (await readEnvelope<{ ok: true; library: CanvasSkillLibrary }>(response)).library } /** Install skills from URLs and/or an archive asset uploaded to the canvas. */ async canvasSkillInstall(request: CanvasSkillInstallRequest): Promise { const response = await fetch(CANVAS_SKILL_API.install, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...request, language: activeImageGenLanguage() }), }) return await readEnvelope(response) } /** Remove one installed skill. */ async canvasSkillRemove(name: string): Promise { const response = await fetch(CANVAS_SKILL_API.remove, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, language: activeImageGenLanguage() }), }) return await readEnvelope(response) } /** Save the values one skill's own declaration asked for. */ async canvasSkillConfigSave(request: CanvasSkillConfigSaveRequest): Promise { const response = await fetch(CANVAS_SKILL_API.configSave, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...request, language: activeImageGenLanguage() }), }) return await readEnvelope(response) } /** Run a skill declaration's `apply` steps (its CLI or a config file). */ async canvasSkillConfigApply(request: CanvasSkillConfigApplyRequest): Promise { const response = await fetch(CANVAS_SKILL_API.configApply, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...request, language: activeImageGenLanguage() }), }) return await readEnvelope(response) } /** Queue one skill run; the task is polled through {@link canvasSkillTask}. */ async canvasSkillRun(request: CanvasSkillRunRequest): Promise { const response = await fetch(CANVAS_SKILL_API.run, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...request, language: activeImageGenLanguage() }), }) return (await readEnvelope<{ ok: true; task: CanvasSkillTask }>(response)).task } /** One skill-run snapshot. */ async canvasSkillTask(taskId: string): Promise { const response = await fetch(CANVAS_SKILL_API.task, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ taskId, language: activeImageGenLanguage() }), }) return (await readEnvelope<{ ok: true; task: CanvasSkillTask }>(response)).task } /** Unfinished skill runs of one canvas (rebuilds run cards after a reload). */ async canvasSkillTasks(canvasId: string): Promise { const response = await fetch(CANVAS_SKILL_API.task, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ canvasId, language: activeImageGenLanguage() }), }) return (await readEnvelope<{ ok: true; tasks: CanvasSkillTask[] }>(response)).tasks } /** Ask the host to cancel a queued or running skill run. */ async canvasSkillCancel(taskId: string): Promise { const response = await fetch(CANVAS_SKILL_API.cancel, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ taskId, language: activeImageGenLanguage() }), }) return (await readEnvelope<{ ok: true; cancelled: boolean }>(response)).cancelled } /** Fetch one template source's list (bundled snapshot or refreshed copy). */ async templatesList(sourceId: string): Promise { const response = await fetch(TEMPLATES_API.list, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ source: sourceId }) }) const body = await readEnvelope(response) return { sourceId: body.sourceId, cases: body.cases, total: body.total, origin: body.origin, repository: body.repository, fetchedAt: body.fetchedAt, } } /** Re-download one template source's list from its upstream mirror (host-side). */ async templatesRefresh(sourceId: string): Promise { const response = await fetch(TEMPLATES_API.refresh, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ source: sourceId }) }) const body = await readEnvelope(response) return { sourceId: body.sourceId, total: body.total, fetchedAt: body.fetchedAt } } /** Draw random cases across every source (studio inspiration wall). */ async templatesSample(count: number): Promise { const response = await fetch(TEMPLATES_API.sample, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ count }) }) return (await readEnvelope<{ ok: true; samples: TemplateSample[] }>(response)).samples } /** List the host-persisted template favorites. */ async favoritesList(): Promise { const response = await fetch(TEMPLATE_FAVORITES_API.list, { method: 'POST' }) return (await readEnvelope<{ ok: true; favorites: TemplateFavorite[] }>(response)).favorites } /** Reveal the host data directory (saved images) in the OS file manager. */ async openDataFolder(): Promise<{ ok: boolean; path?: string; message?: string }> { const response = await fetch(DATA_FOLDER_API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}) }) return await readEnvelope<{ ok: true; path: string }>(response) } /** Probe the configured S3-compatible object storage with a small upload. */ async storageTest(): Promise<{ ok: boolean; ms?: number; key?: string; message?: string }> { const response = await fetch(STORAGE_API.test, { method: 'POST' }) return await readEnvelope<{ ok: true; ms: number; key: string }>(response) } /** Star one template (the host keeps a full case snapshot). */ async favoritesAdd(sourceId: string, item: TemplateCase): Promise { const response = await fetch(TEMPLATE_FAVORITES_API.add, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ source: sourceId, case: item }), }) return (await readEnvelope<{ ok: true; favorites: TemplateFavorite[] }>(response)).favorites } /** Unstar one template by its favorites key. */ async favoritesRemove(key: string): Promise { const response = await fetch(TEMPLATE_FAVORITES_API.remove, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }), }) return (await readEnvelope<{ ok: true; favorites: TemplateFavorite[] }>(response)).favorites } }