Project Files
src / history-state.ts
import path from "path";
import fs from "fs";
import { type Chat, type GeneratorController } from "@lmstudio/sdk";
import { writeChatMediaState, readChatMediaState, type ChatMediaState } from "./chat-media-state";
import { detectCapabilities } from "./capabilities";
function parseAttachmentWrappers(text: string): Array<{ kind: "image" | "text" | "text_link"; url?: string; text?: string }> {
const parts: Array<{ kind: "image" | "text" | "text_link"; url?: string; text?: string }> = [];
if (!text) return parts;
const re = /\[\[LMSTUDIO_ATTACHMENT:\s*(\{[\s\S]*?\})\s*\]\]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
try {
const obj = JSON.parse(m[1]);
if (obj && obj.kind === "image" && typeof obj.url === "string") {
parts.push({ kind: "image", url: obj.url });
} else if (obj && obj.kind === "text" && typeof obj.text === "string") {
parts.push({ kind: "text", text: obj.text });
} else if (obj && obj.kind === "text_link" && typeof obj.url === "string") {
parts.push({ kind: "text_link", url: obj.url });
}
} catch { /* ignore */ }
}
// Also detect plain file:// URIs and absolute image paths in the text
try {
const seen = new Set<string>();
const pushOnce = (u: string) => { if (!seen.has(u)) { parts.push({ kind: 'image', url: u }); seen.add(u); } };
// file:// URIs
const reFile = /file:\/\/[^\s)]+/gi;
while ((m = reFile.exec(text)) !== null) {
const u = m[0];
pushOnce(u);
}
// Absolute paths with common image extensions
const reAbs = /(^|\s)(\/[\w@#$%&+.,:\-\/]+?\.(?:png|jpe?g|webp|gif|bmp|tiff?|heic))(\s|$|[)])/gi;
let m2: RegExpExecArray | null;
while ((m2 = reAbs.exec(text)) !== null) {
const u = m2[2];
pushOnce(u);
}
} catch { /* ignore */ }
return parts;
}
function extractMarkdownLocalImageLinks(text: string): string[] {
const out: string[] = [];
if (!text) return out;
const mdRel = /!\[[^\]]*\]\(((?:\.?\.?\/)[^\s)]+\.(?:png|jpe?g|webp))\)/gi;
let m: RegExpExecArray | null;
while ((m = mdRel.exec(text)) !== null) out.push(m[1]);
return out;
}
export async function snapshotHistoryMediaState(ctl: GeneratorController, history: Chat, chatWd: string, model: string): Promise<ChatMediaState> {
// IMPORTANT: chat_media_state.json is now maintained by dedicated import/record pipelines.
// This snapshot helper is only allowed to initialize state when it does not exist.
// Otherwise it would clobber fields like originAbs, stable n-numbering, and variant provenance.
try {
const statePath = path.join(chatWd, "chat_media_state.json");
if (fs.existsSync(statePath)) {
return await readChatMediaState(chatWd);
}
} catch {
// best-effort only
}
const caps = detectCapabilities(model);
const maxVariants = caps.imageGeneration?.numberOfImages || 3;
let attachment: { filename: string; origin?: string; preview: string } | null = null;
let variants: Array<{ filename: string; preview: string }> = [];
// Scan history backwards
const arr = Array.from(history);
for (let i = arr.length - 1; i >= 0 && (!attachment || variants.length === 0); i--) {
const msg: any = arr[i];
const role = typeof msg.getRole === 'function' ? msg.getRole() : undefined;
const text = typeof msg.getText === 'function' ? (msg.getText() || "") : "";
if (!role) continue;
if (!attachment && role === 'user') {
const parts = parseAttachmentWrappers(text);
for (const p of parts) {
if (p.kind === 'image' && typeof p.url === 'string') {
// Map to relative filename if possible (./file) else keep basename
const base = path.basename(p.url.replace(/^file:\/\//, ""));
attachment = { filename: base, origin: base, preview: base };
break;
}
}
}
if (variants.length === 0 && role === 'assistant') {
const links = extractMarkdownLocalImageLinks(text);
if (links.length) {
const rel = links.filter(l => l.startsWith('./') || l.startsWith('../'));
const picked = rel.slice(0, maxVariants).map(r => path.basename(r));
if (picked.length) variants = picked.map(fn => ({ filename: fn, preview: fn }));
}
}
}
const nowIso = new Date().toISOString();
const state: ChatMediaState = {
attachments: attachment ? [{ filename: attachment.filename, origin: attachment.origin, preview: attachment.preview, createdAt: nowIso, n: 1 }] : [],
variants: variants.map((v, idx) => ({ filename: v.filename, preview: v.preview, createdAt: nowIso, v: idx + 1 })),
// Prefer variants over attachment when both are present, since variants denote a generation event.
lastEvent: (attachment || variants.length)
? { type: (variants.length ? 'variants' : 'attachment'), at: nowIso }
: { type: 'none', at: nowIso } as any,
counters: { nextAttachmentN: (attachment ? 2 : 1), nextVariantV: Math.min(maxVariants, variants.length) + 1 },
};
await writeChatMediaState(chatWd, state);
try { console.info("[MediaState] Pre-turn snapshot written:", path.join(chatWd, "chat_media_state.json")); } catch { }
return state;
}