Forked from
Project Files
src / promotionBase64.ts
import fs from "fs";
import path from "path";
import { type Chat } from "@lmstudio/sdk";
import {
type BuildVisionPromotionPartsParams,
recoverProVariantsFromHistory,
harvestToolGeneratedVariantsFromLatestToolMessage,
buildPromotionItems,
toGeminiInlineDataParts,
shouldPromoteImages,
markAsPromoted,
} from "./promotion";
import { readChatMediaState, writeChatMediaStateAtomic } from "./chat-media-state";
import { addMultipleActiveGenerated } from "./files-api";
import {
findAllAttachmentsFromConversation,
importAttachmentBatch,
} from "./attachments";
import { resizeMaxDimJpegFromFile } from "./image";
import { getMaxPromotedAttachments } from "./capabilities";
import { checkAndMarkInjected, makeDedupKey } from "./promotionDedup";
import { parseAttachmentWrappers } from "./generator-utils";
/**
* Build promotion parts for Base64 mode (Mode B).
*
* REFACTORED (2025-12) to use:
* - importAttachmentBatch (stable n-numbering, no copies)
* - buildPromotionItems (stable labels with n-field)
* - toGeminiInlineDataParts (unified base64 encoding)
*/
export async function buildPromotionPartsB(params: BuildVisionPromotionPartsParams): Promise<{ promoParts: any[]; promotedFiles: string[] }> {
const { ctl, history, chatWd, debugChunks, shouldUseFilesApi, showOnlyLastImageVariant, visionPromotionPersistent } = params as any;
const promoParts: any[] = [];
const promotedFiles: string[] = [];
try {
// Step 1: Deterministic variants pipeline.
// - External tools: harvest from history (tool/assistant) WITHOUT copies or preview generation.
// - Gemini-native (gemini-3-pro-image-preview): use legacy recover path.
if (params.model === "gemini-3-pro-image-preview") {
try {
await recoverProVariantsFromHistory(history as Chat, chatWd, debugChunks, false);
} catch (e) {
if (debugChunks) console.warn('[Variants] Gemini-native recover failed:', (e as Error).message);
}
} else {
try {
const res = await harvestToolGeneratedVariantsFromLatestToolMessage(ctl, history as Chat, chatWd, debugChunks);
if (debugChunks) {
console.info(`[Variants] Harvest result: source=${res.source} found=${res.foundVariants} recorded=${res.recordedVariants} reason=${res.reason}`);
}
} catch (e) {
if (debugChunks) console.warn('[Variants] Harvest failed:', (e as Error).message);
}
}
// Step 2: Load current state
let state = await readChatMediaState(chatWd).catch(() => ({ attachments: [], variants: [], counters: {} } as any));
// Step 3: Import attachments — SDK-first (race-free) ∪ SSOT (historical turns)
const maxPromotedAttachments = getMaxPromotedAttachments(params.model || "");
if (debugChunks) console.info(`[Promotion B] Model=${params.model} maxPromotedAttachments=${maxPromotedAttachments}`);
// History-first: parse [[LMSTUDIO_ATTACHMENT:...]] wrappers from in-memory history messages.
// Preprocessor calls consumeFiles() BEFORE the generator runs, so getAllFiles() returns empty.
// File paths are embedded as [[LMSTUDIO_ATTACHMENT:{"kind":"image","url":"file://..."}]] wrappers.
const historyPaths: string[] = [];
try {
for (const msg of history as any) {
if (typeof msg.getRole === 'function' && msg.getRole() === 'user') {
const text = typeof msg.getText === 'function' ? (msg.getText() || '') : '';
const { parts } = parseAttachmentWrappers(text);
for (const p of parts) {
if (p.kind === 'image' && typeof p.url === 'string') {
const fp = p.url.startsWith('file://') ? p.url.slice(7) : (path.isAbsolute(p.url) ? p.url : null);
if (fp) historyPaths.push(fp);
}
}
}
}
if (debugChunks && historyPaths.length > 0) console.info(`[Promotion B] History wrappers: ${historyPaths.length} image(s)`);
} catch (e) {
if (debugChunks) console.warn('[Promotion B] History parse failed, SSOT-only:', (e as Error).message);
}
const ssotPaths = await findAllAttachmentsFromConversation(chatWd, debugChunks);
const seen = new Set<string>();
const sourcePaths: string[] = [];
for (const p of [...historyPaths, ...ssotPaths]) {
const r = path.resolve(p);
if (!seen.has(r)) { seen.add(r); sourcePaths.push(r); }
}
if (sourcePaths.length > 0) {
const importResult = await importAttachmentBatch(
chatWd,
state,
sourcePaths,
{ maxDim: 1024, quality: 85 },
maxPromotedAttachments,
debugChunks
);
if (importResult.changed) {
state = await readChatMediaState(chatWd);
// No manual idempotency reset: shouldPromoteImages compares window sets automatically
}
}
// Step 4: Idempotency check - skip if nothing new (unless persistent mode)
const persistentMode = visionPromotionPersistent === true;
const { shouldPromoteAttachment, shouldPromoteVariants, promotableNs, newAttachmentNs } = shouldPromoteImages(state, persistentMode, maxPromotedAttachments);
if (!shouldPromoteAttachment && !shouldPromoteVariants) {
if (debugChunks) console.info('[Promotion B] Idempotent mode: no new attachments/variants, skipping promotion');
return { promoParts: [], promotedFiles: [] };
}
// Dedup: prevent title-gen and main-response from both injecting the same attachments
// within the same logical user turn (10s TTL). Only applies in non-persistent mode.
// Source pattern: draw-things-chat/src/orchestrator.ts (recentlyInjected Map)
if (!persistentMode && shouldPromoteAttachment) {
const attachmentNs = (state.attachments || []).map((a: any) => a.n ?? 0);
const dedupKey = makeDedupKey(chatWd, attachmentNs);
if (!checkAndMarkInjected(dedupKey)) {
if (debugChunks) console.info('[Promotion B] Dedup: skipping (already injected this turn)');
return { promoParts: [], promotedFiles: [] };
}
}
// Step 5: Build promotion items using new function
// This uses stable n-field for labels
const hasAttachments = Array.isArray(state.attachments) && state.attachments.length > 0;
const hasVariants = Array.isArray(state.variants) && state.variants.length > 0;
if (!hasAttachments && !hasVariants) {
if (debugChunks) console.info('[Promotion B] No attachments or variants to promote');
return { promoParts: [], promotedFiles: [] };
}
// Determine variant limit
let maxVariantItems = 3;
if (showOnlyLastImageVariant && state.variants?.length > 0) {
maxVariantItems = 1;
if (debugChunks) console.info('[Promotion B] Limiting to only last variant due to showOnlyLastImageVariant');
}
const items = buildPromotionItems(chatWd, state, {
labels: true,
maxAttachmentItems: maxPromotedAttachments,
onlyAttachmentNs: persistentMode ? undefined : newAttachmentNs,
maxVariantItems,
});
// NOTE: Attachments outside the Rolling Window are NOT promoted at all (no text-only labels).
// The model only sees the last N attachments. If user references an older one, the agent
// should communicate that it's outside the visual context.
if (items.length === 0) {
if (debugChunks) console.info('[Promotion B] buildPromotionItems returned empty');
return { promoParts: [], promotedFiles: [] };
}
// Step 6: Convert to Gemini inlineData parts
const parts = await toGeminiInlineDataParts(items);
promoParts.push(...parts);
// Track promoted files
for (const it of items) {
promotedFiles.push(path.basename(it.previewAbs));
}
// Step 7: Write canary for debugging/audit
try {
const generatedForCanary = items.map((it) => ({
localPath: it.previewAbs,
mimeType: /\.png$/i.test(it.previewAbs) ? "image/png" : "image/jpeg",
uploadResult: { fileName: path.basename(it.previewAbs), fileUri: it.previewAbs },
}));
if (generatedForCanary.length) {
await addMultipleActiveGenerated(chatWd, generatedForCanary);
}
} catch { /* best-effort canary */ }
if (debugChunks) {
console.info(`[Promotion B] Promoted ${items.length} item(s): ${promotedFiles.join(', ')}`);
}
// Step 8: Mark as promoted for idempotency tracking
await markAsPromoted(chatWd, state, shouldPromoteAttachment, shouldPromoteVariants, promotableNs);
} catch (e) {
const err = e as any;
const msg = (err && (err.stack || err.message)) ? (err.stack || err.message) : String(err);
console.error("Promotion parts error (Base64):", msg);
}
return { promoParts, promotedFiles };
}