add vision analysis service to dev branch?
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
const RAW_VISION_LIMIT = 12000;
|
||||
const EVIDENCE_LIMIT = 100000;
|
||||
const ANSWER_LIMIT = 30000;
|
||||
const REASONING_LIMIT = 30000;
|
||||
const MAX_RECORDS = 100;
|
||||
const MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
const bounded = (value, limit) => typeof value === "string" ? value.slice(0, limit) : value;
|
||||
const clone = value => value == null ? value : structuredClone(value);
|
||||
const exposed = record => {
|
||||
const value = clone(record);
|
||||
delete value.user;
|
||||
return value;
|
||||
};
|
||||
|
||||
export function createVisionDebugService(config) {
|
||||
const enabled = config.visionDebug === true;
|
||||
const records = new Map();
|
||||
|
||||
const prune = () => {
|
||||
const cutoff = Date.now() - MAX_AGE_MS;
|
||||
for (const [id, record] of records) {
|
||||
if (Date.parse(record.updatedAt) < cutoff) records.delete(id);
|
||||
}
|
||||
while (records.size > MAX_RECORDS) records.delete(records.keys().next().value);
|
||||
};
|
||||
const update = (requestId, operation) => {
|
||||
if (!enabled || !requestId) return null;
|
||||
const record = records.get(requestId);
|
||||
if (!record) return null;
|
||||
operation(record);
|
||||
record.updatedAt = new Date().toISOString();
|
||||
return record;
|
||||
};
|
||||
const image = (record, attachmentId) => record.images.find(item => item.attachmentId === attachmentId);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
begin({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested}) {
|
||||
if (!enabled || !requestId) return;
|
||||
prune();
|
||||
const timestamp = new Date().toISOString();
|
||||
records.delete(requestId);
|
||||
records.set(requestId, {
|
||||
requestId, user, chatId, triggeringMessageId,
|
||||
createdAt: timestamp, updatedAt: timestamp,
|
||||
request: {userQuestion, selectedModel, reasoningRequested: reasoningRequested === true, webSearchRequested: webSearchRequested === true},
|
||||
images: [],
|
||||
qwen: null,
|
||||
search: {
|
||||
requested: webSearchRequested === true,
|
||||
needed: null,
|
||||
explicitSearchIntent: null,
|
||||
state: webSearchRequested === true ? "pending" : "disabled",
|
||||
visualReference: "",
|
||||
queries: [],
|
||||
candidateCount: 0,
|
||||
selectedCount: 0,
|
||||
selectedSources: []
|
||||
},
|
||||
visibleReasoning: null,
|
||||
finalAnswer: null,
|
||||
error: null,
|
||||
experimentNote: "For controlled Web Search/Reasoning comparisons, reuse this attachment and exact question. Re-uploading identical bytes creates a new attachment ID and vision cache entry."
|
||||
});
|
||||
prune();
|
||||
},
|
||||
recordImage(requestId, value) {
|
||||
update(requestId, record => {
|
||||
const existing = image(record, value.attachmentId);
|
||||
const next = {
|
||||
requestId,
|
||||
chatId: record.chatId,
|
||||
triggeringMessageId: record.triggeringMessageId,
|
||||
attachmentId: value.attachmentId,
|
||||
sourceMessageId: value.sourceMessageId,
|
||||
position: value.position,
|
||||
scope: value.scope,
|
||||
label: value.label,
|
||||
originalFilename: value.originalFilename,
|
||||
mimeType: value.mimeType,
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
sizeBytes: value.sizeBytes,
|
||||
imageSha256: null,
|
||||
visionModel: value.visionModel,
|
||||
visionPromptVersion: value.visionPromptVersion,
|
||||
normalizedQuestion: value.normalizedQuestion,
|
||||
cacheHit: null,
|
||||
cacheIdentity: null,
|
||||
rawVisionResponse: null,
|
||||
normalizedVisionAnalysis: null
|
||||
};
|
||||
if (existing) Object.assign(existing, next); else record.images.push(next);
|
||||
});
|
||||
},
|
||||
recordCache(requestId, attachmentId, {cacheHit, cacheIdentity, normalizedVisionAnalysis}) {
|
||||
update(requestId, record => {
|
||||
const entry = image(record, attachmentId);
|
||||
if (!entry) return;
|
||||
entry.cacheHit = cacheHit === true;
|
||||
entry.cacheIdentity = clone(cacheIdentity);
|
||||
entry.rawVisionResponse = null;
|
||||
if (normalizedVisionAnalysis !== undefined) entry.normalizedVisionAnalysis = clone(normalizedVisionAnalysis);
|
||||
});
|
||||
},
|
||||
recordVisionRun(requestId, attachmentId, {imageSha256, rawVisionResponse, normalizedVisionAnalysis}) {
|
||||
update(requestId, record => {
|
||||
const entry = image(record, attachmentId);
|
||||
if (!entry) return;
|
||||
entry.cacheHit = false;
|
||||
entry.imageSha256 = imageSha256 || null;
|
||||
entry.rawVisionResponse = bounded(String(rawVisionResponse ?? ""), RAW_VISION_LIMIT);
|
||||
entry.normalizedVisionAnalysis = clone(normalizedVisionAnalysis);
|
||||
});
|
||||
},
|
||||
recordQwen(requestId, value) {
|
||||
update(requestId, record => {
|
||||
record.qwen = {
|
||||
imageEvidence: bounded(value.imageEvidence, EVIDENCE_LIMIT),
|
||||
visionGuidanceSha256: value.visionGuidanceSha256,
|
||||
visionEvidenceIncluded: value.visionEvidenceIncluded === true,
|
||||
imageCount: value.imageCount,
|
||||
currentImageLabels: clone(value.currentImageLabels),
|
||||
model: value.model,
|
||||
think: value.think === true,
|
||||
stream: value.stream === true,
|
||||
temperature: value.temperature ?? null,
|
||||
numCtx: value.numCtx ?? null,
|
||||
numPredict: value.numPredict ?? null,
|
||||
keepAlive: value.keepAlive
|
||||
};
|
||||
});
|
||||
},
|
||||
recordSearch(requestId, value) {
|
||||
update(requestId, record => {
|
||||
record.search = {
|
||||
...record.search,
|
||||
requested: value.requested === true,
|
||||
needed: value.needed ?? record.search.needed,
|
||||
explicitSearchIntent: value.explicitSearchIntent ?? record.search.explicitSearchIntent,
|
||||
state: value.state ?? record.search.state,
|
||||
visualReference: bounded(value.visualReference ?? record.search.visualReference, 1200),
|
||||
queries: Array.isArray(value.queries) ? value.queries.map(item => bounded(String(item), 500)) : record.search.queries,
|
||||
candidateCount: value.candidateCount ?? record.search.candidateCount,
|
||||
selectedCount: value.selectedCount ?? record.search.selectedCount,
|
||||
selectedSources: Array.isArray(value.selectedSources) ? value.selectedSources.slice(0, 5).map((source, index) => ({
|
||||
index: index + 1,
|
||||
title: bounded(String(source.title || ""), 300),
|
||||
url: bounded(String(source.url || ""), 2048),
|
||||
snippet: bounded(String(source.snippet || ""), 500)
|
||||
})) : record.search.selectedSources
|
||||
};
|
||||
});
|
||||
},
|
||||
complete(requestId, {finalAnswer, visibleReasoning}) {
|
||||
update(requestId, record => {
|
||||
record.finalAnswer = bounded(String(finalAnswer ?? ""), ANSWER_LIMIT);
|
||||
record.visibleReasoning = visibleReasoning ? bounded(String(visibleReasoning), REASONING_LIMIT) : null;
|
||||
});
|
||||
},
|
||||
fail(requestId, error) {
|
||||
update(requestId, record => { record.error = bounded(String(error?.message || error || "Request failed"), 1000); });
|
||||
},
|
||||
get(requestId, user, repositories) {
|
||||
if (!enabled) return null;
|
||||
prune();
|
||||
const record = records.get(requestId);
|
||||
if (!record || record.user !== user || !repositories.getChat(record.chatId, user)) return null;
|
||||
return exposed(record);
|
||||
},
|
||||
latest(user, repositories) {
|
||||
if (!enabled) return null;
|
||||
prune();
|
||||
const values = [...records.values()].reverse();
|
||||
const record = values.find(item => item.user === user && repositories.getChat(item.chatId, user));
|
||||
return record ? exposed(record) : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user