import { getAiConfig } from './aiConfig'; import { withAiQueue } from './aiQueue'; export interface ChatMsg { role: string; content: string; } // Model her istekte bellekten atılıp yeniden yüklenmesin diye (varsayılan Ollama // davranışı: 5 dakika kullanılmazsa boşalır, sıradaki istek soğuk başlangıç // gecikmesi yaşar) worker'ı bu süre boyunca bellekte tutmasını istiyoruz. // -1: Ollama'ya özel değer, "asla otomatik atma" anlamına gelir — sadece Ollama // sunucusu yeniden başlarsa model tekrar yüklenir. GPU'da bolca boş VRAM var // (bkz. 2026-08-14 log: 44GB boş / 17.5GB kullanılan), bu yüzden modeli sürekli // bellekte tutmanın maliyeti yok; 30dk'lık sınır sadece gereksiz soğuk başlangıç // gecikmelerine (~90sn, bazen backend timeout'undan uzun) sebep oluyordu. const KEEP_ALIVE = -1; // 800 pratikte çok düşük çıktı — çok bölümlü hukuki cevaplar (kira feshi, tazminat // hesabı gibi) bu sınıra çarpıp cümlenin ortasında kesiliyordu. 2048 hem çoğu cevabı // tam bitirmeye yetiyor hem de modelin sonsuz uzayıp gitmesine karşı hâlâ bir üst sınır. const DEFAULT_NUM_PREDICT = 2048; const DEFAULT_TIMEOUT_MS = 90_000; const DEFAULT_STALL_TIMEOUT_MS = 60_000; function buildBody(messages: ChatMsg[], model: string, stream: boolean, numPredict: number) { return { model, messages, think: false, stream, keep_alive: KEEP_ALIVE, // num_predict: modelin üretebileceği maksimum token sayısı — öngörülemez // uzunlukta "düşünme"/üretim sürelerine karşı üst sınır. options: { num_predict: numPredict }, }; } // Tek seferlik (streaming olmayan) çağrı — başlık üretimi, belge analizi, dilekçe // taslağı, içtihat sorgu optimizasyonu gibi kullanıcının token token izlemediği, // arka planda tamamlanması yeterli olan işler için. Ollama'nın native /api/chat // uç noktasını kullanır — think:false burada güvenilir çalışıyor (OpenAI-uyumluluk // katmanı /v1/chat/completions'ta güvenilir değil). export async function callOllama( messages: ChatMsg[], opts: { numPredict?: number; timeoutMs?: number } = {} ): Promise { // 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(); } catch (err: any) { if (err?.name === 'AbortError' || (err?.message && String(err.message).toLowerCase().includes('aborted'))) { throw new Error('Yapay zeka yanıt verme süresi aşıldı (Zaman Aşımı). Lütfen tekrar deneyin.'); } throw err; } finally { clearTimeout(timeoutId); } }); } // Akışlı (streaming) çağrı — kullanıcının yanıtı token token canlı gördüğü // etkileşimli sohbet uç noktaları için. Ollama /api/chat stream:true ile // newline-delimited JSON döner (her satır { message: { content }, done } içerir). // onToken her parça geldiğinde çağrılır; dönüş değeri tüm yanıtın birleştirilmiş hali. // Sabit bir toplam süre sınırı YOK (kullanıcı üretilmekte olan metni zaten görüyor) — // bunun yerine art arda gelen parçalar arasında stallTimeoutMs kadar sessizlik olursa // (model takıldı / bağlantı koptu) isteği iptal ediyoruz. export async function streamOllama( messages: ChatMsg[], onToken: (delta: string) => void, opts: { numPredict?: number; stallTimeoutMs?: number } = {} ): Promise { return withAiQueue(() => streamOllamaInner(messages, onToken, opts)); } async function streamOllamaInner( messages: ChatMsg[], onToken: (delta: string) => void, opts: { numPredict?: number; stallTimeoutMs?: number } = {} ): Promise { const { runpodUrl, aiModel } = getAiConfig(); const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS; const ctrl = new AbortController(); let stallTimer: ReturnType; const resetStall = () => { clearTimeout(stallTimer); stallTimer = setTimeout(() => ctrl.abort(), stallMs); }; resetStall(); try { const response = await fetch(`${runpodUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildBody(messages, aiModel, true, opts.numPredict ?? DEFAULT_NUM_PREDICT)), signal: ctrl.signal, }); if (!response.ok || !response.body) { throw new Error(`AI Model request failed: ${response.statusText}`); } let full = ''; let buffer = ''; const reader = response.body.getReader(); const decoder = new TextDecoder(); const consumeLine = (line: string) => { const trimmed = line.trim(); if (!trimmed) return; let json: any; try { json = JSON.parse(trimmed); } catch { return; } const delta = json.message?.content || ''; if (delta) { full += delta; onToken(delta); } // Ollama son satırda done_reason:"length" döndürürse yanıt num_predict // sınırına çarpıp yarım kesilmiş demektir — sessizce geçmek yerine loglayıp // ileride benzer bir "cevaplar yarım kalıyor" şikayetini hızlı teşhis edebilelim. if (json.done && json.done_reason === 'length') { console.warn('[aiClient] Yanıt num_predict sınırına çarpıp kesildi.'); } }; while (true) { const { done, value } = await reader.read(); if (done) break; resetStall(); buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; lines.forEach(consumeLine); } if (buffer.trim()) consumeLine(buffer); return full.trim(); } finally { 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 { 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 || '\nConvert the document to markdown.', 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); } }); }