Project Files
src / chat-media-state.ts
import fs from "fs";
import path from "path";
// ============================================================================
// State Types (based on standalone_generator_guide/src/media-promotion-core/state.ts)
// ============================================================================
/**
* BREAKING CHANGE (2025-12):
* - filename is now optional (we no longer copy originals to chat WD)
* - originAbs is the SSOT for i2i resolution
* - preview naming: preview-<origin> instead of analysis-<stem>.jpg
*/
export type AttachmentItem = {
filename?: string; // optional - we may not keep 1:1 copies
origin?: string; // LM Studio fileIdentifier (e.g., "1766017090130 - 411.jpg")
originAbs?: string; // absolute path in user-files (SSOT for i2i!)
originalName?: string; // real original filename from LM Studio metadata (for labels)
preview?: string; // relative JPEG preview (only for promoted/base64-injected attachments)
createdAt: string; // ISO timestamp
n: number; // 1-based stable attachment counter - NEVER CHANGES!
};
export type VariantItem = {
filename: string; // relative original (PNG/JPG/WEBP)
preview: string; // relative JPEG preview
originAbs?: string; // absolute path to original (for Files API promotion)
createdAt: string; // ISO timestamp
v: number; // 1-based variant index within generation batch
};
export type ChatMediaState = {
attachments: AttachmentItem[]; // max 2
variants: VariantItem[]; // max 3 (Pro) or max 1 (Flash)
lastEvent?: { type: "attachment" | "variants"; at: string };
counters: { nextAttachmentN?: number; nextVariantV?: number };
// Idempotency tracking for vision promotion
injectedMarkdown?: string[]; // Track injected image basenames to prevent duplicates
lastVariantsTs?: string; // Latest generated-image <ts> group observed
lastPromotedTs?: string; // Latest generated-image <ts> group already promoted
lastPromotedAttachmentN?: number; // Latest attachment n already promoted
};
const FILE_NAME = "chat_media_state.json";
// ============================================================================
// Timestamp helper
// ============================================================================
export function tsForFilename(d = new Date()): string {
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
const hours = String(d.getUTCHours()).padStart(2, "0");
const minutes = String(d.getUTCMinutes()).padStart(2, "0");
const seconds = String(d.getUTCSeconds()).padStart(2, "0");
const millis = String(d.getUTCMilliseconds()).padStart(3, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}${millis}Z`;
}
// ============================================================================
// Read/Write with atomic writes
// ============================================================================
export async function readChatMediaState(chatWd: string): Promise<ChatMediaState> {
const p = path.join(chatWd, FILE_NAME);
try {
const raw = await fs.promises.readFile(p, "utf8");
const parsed = JSON.parse(raw);
return normalizeState(parsed);
} catch {
return { attachments: [], variants: [], counters: {} };
}
}
function normalizeState(s: any): ChatMediaState {
return {
attachments: Array.isArray(s?.attachments) ? s.attachments : [],
variants: Array.isArray(s?.variants) ? s.variants : [],
lastEvent: s?.lastEvent,
counters: typeof s?.counters === "object" && s?.counters ? s.counters : {},
injectedMarkdown: Array.isArray(s?.injectedMarkdown) ? s.injectedMarkdown : undefined,
lastVariantsTs: typeof s?.lastVariantsTs === "string" ? s.lastVariantsTs : undefined,
lastPromotedTs: typeof s?.lastPromotedTs === "string" ? s.lastPromotedTs : undefined,
lastPromotedAttachmentN: typeof s?.lastPromotedAttachmentN === "number" ? s.lastPromotedAttachmentN : undefined,
};
}
/**
* Atomic write: write to tmp file then rename to prevent corruption
*/
export async function writeChatMediaStateAtomic(chatWd: string, state: ChatMediaState): Promise<void> {
await fs.promises.mkdir(chatWd, { recursive: true });
const tmp = path.join(chatWd, `${FILE_NAME}.tmp`);
const dst = path.join(chatWd, FILE_NAME);
const json = JSON.stringify(state, null, 2);
await fs.promises.writeFile(tmp, json, "utf8");
await fs.promises.rename(tmp, dst);
}
/** @deprecated Use writeChatMediaStateAtomic instead */
export async function writeChatMediaState(chatWd: string, state: ChatMediaState): Promise<void> {
return writeChatMediaStateAtomic(chatWd, state);
}
// ============================================================================
// Legacy record functions (kept for backwards compatibility)
// For new code, use importAttachmentBatch from attachments.ts instead
// ============================================================================
/** @deprecated Use importAttachmentBatch instead */
export async function recordAttachmentsProvision(chatWd: string, attachments: Array<{ filename?: string; origin?: string; originAbs?: string; originalName?: string; preview?: string; createdAt?: string }>): Promise<ChatMediaState> {
const state = await readChatMediaState(chatWd);
const list: AttachmentItem[] = attachments.map((a, idx) => ({
filename: a.filename,
origin: a.origin,
originAbs: a.originAbs,
originalName: a.originalName,
preview: a.preview,
createdAt: a.createdAt ?? new Date().toISOString(),
n: idx + 1, // WARNING: This is wrong! Use importAttachmentBatch for stable n
}));
const nextState: ChatMediaState = {
...state,
attachments: list,
lastEvent: { type: "attachment", at: new Date().toISOString() },
counters: { ...(state.counters ?? {}), nextAttachmentN: list.length + 1, nextVariantV: 1 },
};
await writeChatMediaStateAtomic(chatWd, nextState);
return nextState;
}
/** @deprecated Use importAttachmentBatch instead */
export async function recordAttachmentProvision(chatWd: string, params: { filename?: string; origin?: string; originAbs?: string; originalName?: string; preview?: string; createdAt?: string }): Promise<ChatMediaState> {
return recordAttachmentsProvision(chatWd, [params]);
}
export async function recordVariantsProvision(chatWd: string, variants: Array<{ filename: string; preview: string; createdAt?: string }>): Promise<ChatMediaState> {
const state = await readChatMediaState(chatWd);
const list: VariantItem[] = variants.map((v, idx) => ({
filename: v.filename,
preview: v.preview,
originAbs: (v as any).originAbs,
createdAt: v.createdAt ?? new Date().toISOString(),
v: (v as any).v ?? (idx + 1),
}));
const nextState: ChatMediaState = {
...state,
variants: list,
lastEvent: { type: "variants", at: new Date().toISOString() },
counters: { ...(state.counters ?? {}), nextVariantV: list.length + 1 },
};
await writeChatMediaStateAtomic(chatWd, nextState);
return nextState;
}