perf: switch all AI calls to native Ollama endpoint, add streaming + keep_alive

Tüm AI çağrıları artık ortak lib/aiClient.ts üzerinden gidiyor:
- /v1/chat/completions yerine native /api/chat (think:false burada
  güvenilir çalışıyor)
- keep_alive:30m ile model her istekte bellekten atılıp soğuk
  başlamıyor
- num_predict ile öngörülemez uzunlukta üretime üst sınır
- İnteraktif sohbet uç noktaları (chatMessage, sendThreadMessage)
  artık SSE ile token token akıtıyor
- Dosya sohbetinde varsayılan olarak belgenin tam metni değil analiz
  özeti gönderiliyor; tam metin sadece kullanıcı açıkça isterse
  eklenir

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mstfyldz
2026-08-10 00:21:49 +03:00
co-authored by Claude Sonnet 5
parent 0318f2ac77
commit bcc78ce304
5 changed files with 239 additions and 150 deletions
+124
View File
@@ -0,0 +1,124 @@
import { getAiConfig } from './aiConfig';
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.
const KEEP_ALIVE = '30m';
const DEFAULT_NUM_PREDICT = 800;
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<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}`);
}
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üğü
// 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<string> {
const { runpodUrl, aiModel } = getAiConfig();
const stallMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
const ctrl = new AbortController();
let stallTimer: ReturnType<typeof setTimeout>;
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);
}
};
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!);
}
}