- Sunucu: elle yazılmış araç döngüsü yerine streamText + UI mesaj akışı (src/lib/ai/sohbet-akisi.ts). Kredi düşme/iade, bedelsiz profil onayı, mesaj kaydı ve Jev çıktı denetimi aynı davranışla korunur. - İstemci: sohbet-client useChat kullanır; araç çalışırken balonun altında durum satırı görünür. Kayıtlı geçmiş düz metin kalır. - Sağlayıcı aynı tek kapı (AI_BASE_URL / AI_API_KEY), openai-compatible. - Yeni araçlar: bolum_ozeti, il_ozeti, rehber_ara, tercih_takvimi. - Kalkan davranış: sağlayıcı araç çağrısını reddedince düz sohbete düşme. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
116 lines
4.9 KiB
TypeScript
116 lines
4.9 KiB
TypeScript
/**
|
||
* Danışman cevabının UI mesaj akışı (AI SDK). api/soru/route.ts bunu
|
||
* createUIMessageStreamResponse ile döner; istemci useChat ile okur.
|
||
*
|
||
* Araç çağrıları akışta ayrı parça olarak görünür ("Programlara
|
||
* bakıyorum…"); kayda yalnız METİN gider (chat_messages.content düz metin).
|
||
* Kayıt ve kredi iadesi çağıranın işidir: cevap tamamlanınca `onTamam`,
|
||
* yarıda kalınca `onYarim` çağrılır (ikisi de biriken metinle).
|
||
*
|
||
* Saf modül: DB'ye ve oturuma dokunmaz, Next dışından da çalışır.
|
||
*/
|
||
import {
|
||
createUIMessageStream,
|
||
isStepCount,
|
||
streamText,
|
||
toUIMessageStream,
|
||
type ModelMessage,
|
||
type UIMessage,
|
||
type UIMessageChunk,
|
||
} from "ai";
|
||
import { aracSeti, type AracBaglami } from "./araclar";
|
||
import { sohbetDilModeli } from "./client";
|
||
import { cevapIhlalOlasiligi, JEV_DUZELTME_NOTU, JEV_IHLAL_ESIGI } from "./jev";
|
||
|
||
/** Bir cevapta en fazla model adımı (araç turları + son cevap). */
|
||
const ARAC_TUR_SINIRI = 4;
|
||
const HATA_METNI = "Danışman şu anda yanıt veremiyor.";
|
||
|
||
export function danismanAkisi(opts: {
|
||
system: string;
|
||
mesajlar: ModelMessage[];
|
||
aracBaglami: AracBaglami;
|
||
/** Cevap tamamlandı (çıktı denetimi notu dahil). Fırlatırsa akış hata verir. */
|
||
onTamam: (metin: string) => Promise<void>;
|
||
/** Cevap tamamlanamadı; `metin` o ana kadar üretilen kısım (boş olabilir). */
|
||
onYarim: (metin: string) => Promise<void>;
|
||
}): ReadableStream<UIMessageChunk> {
|
||
return createUIMessageStream<UIMessage>({
|
||
execute: async ({ writer }) => {
|
||
let tamamlandi = false;
|
||
let metin = "";
|
||
// Model/sağlayıcı hatası akışa "error" parçası olarak düşer
|
||
let akisHatasi: string | null = null;
|
||
try {
|
||
const result = streamText({
|
||
model: sohbetDilModeli(),
|
||
instructions: opts.system,
|
||
messages: opts.mesajlar,
|
||
tools: aracSeti(opts.aracBaglami, (ad) => console.info("[soru] araç:", ad)),
|
||
// En fazla ARAC_TUR_SINIRI adım; son adımda araç kapalı (cevap
|
||
// vermek zorunda).
|
||
stopWhen: isStepCount(ARAC_TUR_SINIRI),
|
||
prepareStep: ({ stepNumber }) =>
|
||
stepNumber >= ARAC_TUR_SINIRI - 1 ? { toolChoice: "none" } : undefined,
|
||
maxOutputTokens: 1024,
|
||
abortSignal: AbortSignal.timeout(120_000),
|
||
providerOptions: { openaiCompatible: { reasoningEffort: "none" } },
|
||
onError: ({ error }) => {
|
||
// Hata nesnesinin tamamı istek gövdesini ve cevap başlıklarını
|
||
// taşır; loga yalnız durum kodu ve mesaj gider.
|
||
const e = error as { statusCode?: number; message?: string };
|
||
console.error("[soru] model hatası:", e?.statusCode ?? "", e?.message ?? String(error));
|
||
},
|
||
});
|
||
// Bitiş parçasını biz yazarız: araya çıktı denetimi notu girebilir.
|
||
const okuyucu = toUIMessageStream({
|
||
stream: result.stream,
|
||
sendFinish: false,
|
||
onError: () => HATA_METNI,
|
||
}).getReader();
|
||
for (;;) {
|
||
const { done, value: parca } = await okuyucu.read();
|
||
if (done) break;
|
||
if (parca.type === "text-start" && metin && !metin.endsWith("\n")) {
|
||
// Araç turundan sonra yeni metin parçası: kayıtta yapışmasın
|
||
metin += "\n\n";
|
||
} else if (parca.type === "text-delta") {
|
||
metin += parca.delta;
|
||
} else if (parca.type === "error") {
|
||
akisHatasi = parca.errorText;
|
||
}
|
||
writer.write(parca);
|
||
}
|
||
if (akisHatasi != null) throw new Error(akisHatasi);
|
||
if (!metin.trim()) throw new Error("Boş cevap");
|
||
|
||
// Çıktı denetimi (jev.ts): cevap garanti/olasılık dili içeriyorsa
|
||
// sonuna düzeltme notu eklenir. Akış zaten gittiği için cevap geri
|
||
// alınamaz; not hem ekrana hem kayda yazılır. Denetim yapılamazsa
|
||
// (anahtar yok, zaman aşımı) cevap olduğu gibi kalır.
|
||
const ihlalP = await cevapIhlalOlasiligi(metin);
|
||
if (ihlalP != null) {
|
||
console.info("[soru] jev ihlal olasılığı:", ihlalP.toFixed(2));
|
||
if (ihlalP >= JEV_IHLAL_ESIGI) {
|
||
const id = crypto.randomUUID();
|
||
metin += `\n\n${JEV_DUZELTME_NOTU}`;
|
||
writer.write({ type: "text-start", id });
|
||
writer.write({ type: "text-delta", id, delta: JEV_DUZELTME_NOTU });
|
||
writer.write({ type: "text-end", id });
|
||
}
|
||
}
|
||
await opts.onTamam(metin);
|
||
tamamlandi = true;
|
||
writer.write({ type: "finish" });
|
||
} catch (err) {
|
||
if (!tamamlandi) await opts.onYarim(metin).catch(() => {});
|
||
console.error("[soru] stream hatası:", err);
|
||
// Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa
|
||
// (boş cevap, kayıt hatası) istemci hatayı buradan görür.
|
||
if (akisHatasi == null) throw err;
|
||
}
|
||
},
|
||
onError: () => HATA_METNI,
|
||
});
|
||
}
|