feat: DeepSeek-OCR endpoint + GPU request queue for AI calls

Mizan ve DeepSeek-OCR aynı self-hosted GPU'yu paylaşıyor; backend'de hiçbir
eşzamanlılık sınırı yoktu, yoğun anlarda istekler Ollama kuyruğunda 90sn'yi
aşıp sessizce "başarısız" görünüyordu. aiQueue.ts, GPU'ya giden istek sayısını
sınırlayıp fazlasını backend'de bekletiyor.

Yeni POST /documents/ocr-image, tek bir görseli DeepSeek-OCR ile metne
çeviriyor (Electron tarafındaki çok sayfalı TIFF/taranmış PDF işleme
adımının kullanacağı uç, ayrı bir PR'da bağlanacak).
This commit is contained in:
mstfyldz
2026-08-11 10:12:33 +03:00
parent 7915571130
commit 97ec7cd7f8
5 changed files with 162 additions and 20 deletions
+92 -17
View File
@@ -1,4 +1,5 @@
import { getAiConfig } from './aiConfig';
import { withAiQueue } from './aiQueue';
export interface ChatMsg { role: string; content: string; }
@@ -35,24 +36,29 @@ export async function callOllama(
messages: ChatMsg[],
opts: { numPredict?: number; timeoutMs?: number } = {}
): Promise<string> {
const { runpodUrl, aiModel } = getAiConfig();
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody(messages, aiModel, false, opts.numPredict ?? DEFAULT_NUM_PREDICT)),
signal: ctrl.signal,
});
if (!response.ok) {
throw new Error(`AI Model request failed: ${response.statusText}`);
// withAiQueue'nun İÇİNDE: zaman aşımı sayacı, istek GPU sırasında beklerken
// değil, gerçekten gönderilmeye başladığında işlemeye başlasın — yoksa kuyrukta
// uzun bekleyen bir istek, sırası gelir gelmez anında "zaman aşımı" olurdu.
return withAiQueue(async () => {
const { runpodUrl, aiModel } = getAiConfig();
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody(messages, aiModel, false, opts.numPredict ?? DEFAULT_NUM_PREDICT)),
signal: ctrl.signal,
});
if (!response.ok) {
throw new Error(`AI Model request failed: ${response.statusText}`);
}
const data = await response.json();
return (data.message?.content || '').toString().trim();
} finally {
clearTimeout(timeoutId);
}
const data = await response.json();
return (data.message?.content || '').toString().trim();
} finally {
clearTimeout(timeoutId);
}
});
}
// Akışlı (streaming) çağrı — kullanıcının yanıtı token token canlı gördüğü
@@ -66,6 +72,14 @@ export async function streamOllama(
messages: ChatMsg[],
onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> {
return withAiQueue(() => streamOllamaInner(messages, onToken, opts));
}
async function streamOllamaInner(
messages: ChatMsg[],
onToken: (delta: string) => void,
opts: { numPredict?: number; stallTimeoutMs?: number } = {}
): Promise<string> {
const { runpodUrl, aiModel } = getAiConfig();
const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
@@ -131,3 +145,64 @@ export async function streamOllama(
clearTimeout(stallTimer!);
}
}
const DEFAULT_OCR_NUM_PREDICT = 1200;
const DEFAULT_OCR_TIMEOUT_MS = 60_000;
// DeepSeek-OCR ("deepseek-ocr-fixed") ile bir görseli metne çevirir. Mizan'dan
// (sohbet/analiz) tamamen ayrı bir model — aynı Ollama sunucusunda, farklı
// model adıyla çağrılıyor, withAiQueue ikisini de aynı GPU sırasında bekletiyor.
//
// NOT — "deepseek-ocr-fixed" modeli Ollama kütüphanesindeki resmi paketten
// FARKLI: resmi paketin şablonu ("template": "{{ .Prompt }}") görsel için hiçbir
// yer tutucu içermiyordu, bu yüzden model gönderilen görseli hiç görmüyordu.
// Sunucuda elle şu şekilde düzeltilmiş bir kopya oluşturuldu:
//
// curl -X POST $RUNPOD_API_BASE_URL/api/create -d '{
// "model": "deepseek-ocr-fixed",
// "from": "deepseek-ocr",
// "template": "{{ if .Images }}{{ range .Images }}[img-0]{{ end }}\n{{ end }}{{ .Prompt }}",
// "parameters": { "temperature": 0 }
// }'
//
// Ollama'nın model verisi silinir/sıfırlanırsa (örn. volume resetlenirse) bu
// komut tekrar çalıştırılmalı — yoksa OCR istekleri sessizce boş metin döner.
export async function callOllamaVisionOcr(
imageBase64: string,
opts: { numPredict?: number; timeoutMs?: number; prompt?: string } = {}
): Promise<string> {
return withAiQueue(async () => {
const { runpodUrl, ocrModel } = getAiConfig();
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS);
try {
const response = await fetch(`${runpodUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: ocrModel,
messages: [
{
role: 'user',
content: opts.prompt || '<image>\nFree OCR.',
images: [imageBase64],
},
],
think: false,
stream: false,
keep_alive: KEEP_ALIVE,
options: { num_predict: opts.numPredict ?? DEFAULT_OCR_NUM_PREDICT },
}),
signal: ctrl.signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`OCR model request failed: ${response.statusText} ${errText}`.trim());
}
const data = await response.json();
return (data.message?.content || '').toString().trim();
} finally {
clearTimeout(timeoutId);
}
});
}