Danışman sohbeti AI SDK'ya taşındı; dört yeni araç
- 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>
This commit is contained in:
115
src/lib/ai/sohbet-akisi.ts
Normal file
115
src/lib/ai/sohbet-akisi.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user