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:
bilalgursen
2026-10-05 00:36:27 +03:00
parent 6564ec8948
commit b935158d3c
8 changed files with 802 additions and 368 deletions

View File

@@ -1,6 +1,8 @@
"use client";
import { startTransition, useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Image from "next/image";
@@ -53,6 +55,92 @@ export interface Mesaj {
content: string;
}
/** Kayıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
function uiMesaj(m: Mesaj): UIMessage {
return { id: m.id, role: m.role, parts: [{ type: "text", text: m.content }] };
}
/** Mesajın metni: araç turları arasındaki metin parçaları boş satırla birleşir. */
function mesajMetni(m: UIMessage): string {
return m.parts
.flatMap((p) => (p.type === "text" && p.text.trim() ? [p.text.trim()] : []))
.join("\n\n");
}
/** Danışman veriye bakarken görünen satır (araç adı → öğrenci dili). */
const ARAC_ETIKETI: Record<string, string> = {
program_ara: "Programlara bakıyorum",
program_detay: "Programın ayrıntısına bakıyorum",
program_netleri: "Netlere bakıyorum",
program_karsilastir: "Programları karşılaştırıyorum",
bolum_istihdam: "Mezunların iş durumuna bakıyorum",
bolum_ozeti: "Bölüme bakıyorum",
il_ozeti: "Şehirdeki üniversitelere bakıyorum",
rehber_ara: "Rehber yazılarına bakıyorum",
tercih_takvimi: "Takvime bakıyorum",
universite_profili: "Üniversiteye bakıyorum",
profil_guncelle: "Tercihini kaydediyorum",
};
/** Şu an çalışan (sonucu henüz gelmemiş) araçların etiketleri. */
function calisanAraclar(m: UIMessage): string[] {
const etiketler = m.parts.flatMap((p) => {
if (!p.type.startsWith("tool-") || !("state" in p)) return [];
if (p.state === "output-available" || p.state === "output-error") return [];
return [ARAC_ETIKETI[p.type.slice("tool-".length)] ?? "Veriye bakıyorum"];
});
return [...new Set(etiketler)];
}
type Red = { status: number; error?: string };
/**
* /api/soru ile konuşan useChat taşıyıcısı. Sunucu geçmişi kendi kaydından
* okur; istemci yalnız yeni soruyu ve kimliğini yollar (kimlik = kredi
* düşümünün tekrarsızlık anahtarı). Akış başlamadan reddedilen istek
* (402/409/503) `redAl` ile bir kez okunur; useChat hatayı yalnız metin
* olarak verdiği için durum kodu burada tutulur.
*/
function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
let red: Red | null = null;
const transport = new DefaultChatTransport<UIMessage>({
api: "/api/soru",
prepareSendMessagesRequest: ({ messages }) => {
const son = messages[messages.length - 1];
return {
body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id },
};
},
fetch: async (girdiUrl, init) => {
red = null;
const res = await fetch(girdiUrl, init);
if (!res.ok) {
const data = (await res
.clone()
.json()
.catch(() => ({}))) as { error?: string };
red = { status: res.status, error: data.error };
return res;
}
// Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir
onBasladi(res.headers.get("X-Kredi-Dusuldu") !== "0");
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
olay("danisman_sorusu", {
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
});
return res;
},
});
return {
transport,
redAl(): Red | null {
const r = red;
red = null;
return r;
},
};
}
export interface DanismanOzeti {
metin: string;
kilitli?: boolean;
@@ -109,6 +197,17 @@ export function DanismanAvatar({ canli = false }: { canli?: boolean }) {
);
}
/** Cevap metni gelene kadar balonun yerinde duran iskelet. */
function CevapIskeleti() {
return (
<div className="space-y-2 rounded-lg bg-slate-100 px-4 py-3">
<Skeleton className="h-3.5 w-full bg-slate-200" />
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
</div>
);
}
const ORNEK_SORULAR = [
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
@@ -146,9 +245,7 @@ export function SohbetClient({
* "Danışmanın bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */
bildikleri?: string[];
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
const [kredi, setKredi] = useState(baslangicKredi);
const [temizleAcik, setTemizleAcik] = useState(false);
@@ -159,6 +256,91 @@ export function SohbetClient({
const [profil, setProfil] = useState<ProfilSatiri[]>(initialProfil ?? []);
const router = useRouter();
// useChat geri çağrıları ilk render'da yakalanır; güncel değerler ref'ten okunur.
const profilRef = useRef(profil);
useEffect(() => {
profilRef.current = profil;
}, [profil]);
/** Son gönderilen soru; istek reddedilirse giriş kutusuna geri konur. */
const sonSoruRef = useRef<{ id: string; metin: string } | null>(null);
// Kanal bir kez kurulur; akış başlayınca bakiye hemen düşer.
const [kanal] = useState(() =>
sohbetKanaliKur((krediDustu) => {
if (krediDustu) setKredi((k) => k - 1);
}),
);
const { transport } = kanal;
const {
messages: mesajlar,
sendMessage,
setMessages: setMesajlar,
status,
} = useChat({
messages: (initialMesajlar ?? []).map(uiMesaj),
transport,
// Kullanıcı mesajının kimliği sunucuya clientMessageId olarak gider
generateId: () => crypto.randomUUID(),
onFinish: ({ isError, isAbort, isDisconnect }) => {
if (isError || isAbort || isDisconnect) return;
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profilRef.current.map((s) => s.alan)));
},
onError: () => {
void hatadanSonraToparla();
},
});
const bekliyor = status === "submitted" || status === "streaming";
/**
* İstek reddedildiyse (akış hiç başlamadı) iyimser eklenen soruyu geri al
* ve giriş kutusuna koy. Akış yarıda koptuysa tahmin etme: sunucu krediyi
* iade edip yarım cevabı kaydetmiş olabilir — kayıtlı geçmişi ve bakiyeyi
* sunucudan çek.
*/
async function hatadanSonraToparla() {
const reddedilen = kanal.redAl();
const sonSoru = sonSoruRef.current;
if (reddedilen) {
if (sonSoru) {
setMesajlar((m) => m.filter((x) => x.id !== sonSoru.id));
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(sonSoru.metin);
}
if (reddedilen.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(reddedilen.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
let cevapKaydedildi = false;
try {
const res = await fetch("/api/soru");
const data = res.ok
? ((await res.json()) as { mesajlar?: Mesaj[]; kredi?: number })
: null;
if (data?.mesajlar) {
setMesajlar(data.mesajlar.map(uiMesaj));
cevapKaydedildi = data.mesajlar.at(-1)?.role === "assistant";
}
if (typeof data?.kredi === "number") setKredi(data.kredi);
} catch {}
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
if (!cevapKaydedildi && sonSoru) setGirdi(sonSoru.metin);
router.refresh();
toast.error(
cevapKaydedildi
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
);
}
async function profiliCek(oncekiAlanlar: Set<ProfilAlani>) {
try {
const res = await fetch("/api/soru/profil");
@@ -221,14 +403,14 @@ export function SohbetClient({
ttlMs: 0,
})
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (data?.mesajlar) setMesajlar(data.mesajlar.map(uiMesaj));
if (typeof data?.kredi === "number") setKredi(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
}, [initialMesajlar]);
}, [initialMesajlar, setMesajlar]);
async function gonder(metin: string) {
function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
@@ -236,89 +418,10 @@ export function SohbetClient({
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
let birikmis = "";
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
// Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir
if (res.headers.get("X-Kredi-Dusuldu") !== "0") setKredi((k) => k - 1);
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
olay("danisman_sorusu", {
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
// Chunk başına gelen güncelleme acil değil: transition'a alınca
// kullanıcı stream sürerken input'a takılmadan yazabilir
startTransition(() =>
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
),
);
}
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profil.map((s) => s.alan)));
} catch {
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
try {
const res = await fetch("/api/soru");
const data = res.ok ? await res.json() : null;
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
} catch {}
if (!birikmis) {
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
}
router.refresh();
toast.error(
birikmis
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
);
} finally {
setBekliyor(false);
}
const id = crypto.randomUUID();
sonSoruRef.current = { id, metin: mesaj };
// Hata ve bitiş useChat'in onError / onFinish geri çağrılarında ele alınır
void sendMessage({ id, role: "user", parts: [{ type: "text", text: mesaj }] });
}
/**
@@ -572,37 +675,61 @@ export function SohbetClient({
)}
</div>
) : (
mesajlar.map((m, i) =>
m.role === "user" ? (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{m.content}
</MessageContent>
</Message>
) : (
<Message key={m.id} className="justify-start">
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar
canli={bekliyor && i === mesajlar.length - 1}
/>
{m.content ? (
<MessageContent
markdown
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
>
{m.content}
</MessageContent>
) : (
<div className="w-full max-w-[85%] space-y-2 rounded-lg bg-slate-100 px-4 py-3">
<Skeleton className="h-3.5 w-full bg-slate-200" />
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
<>
{mesajlar.map((m, i) => {
const metin = mesajMetni(m);
if (m.role === "user") {
return (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{metin}
</MessageContent>
</Message>
);
}
const canli = bekliyor && i === mesajlar.length - 1;
// Danışman veriye bakarken ne yaptığını söyler; sonuç
// gelince satır kalkar, cevap metni akar.
const araclar = canli ? calisanAraclar(m) : [];
return (
<Message key={m.id} className="justify-start">
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar canli={canli} />
<div className="flex w-full max-w-[85%] flex-col gap-2">
{metin ? (
<MessageContent
markdown
className="bg-slate-100 text-slate-800 prose-sm"
>
{metin}
</MessageContent>
) : (
<CevapIskeleti />
)}
{araclar.length > 0 ? (
<p
role="status"
className="px-1 text-xs font-medium text-slate-500"
>
{araclar.join(" · ")}…
</p>
) : null}
</div>
)}
</Message>
);
})}
{/* Soru gitti, cevabın ilk parçası henüz gelmedi */}
{status === "submitted" &&
mesajlar[mesajlar.length - 1]?.role === "user" ? (
<Message className="justify-start">
<DanismanAvatar canli />
<div className="w-full max-w-[85%]">
<CevapIskeleti />
</div>
</Message>
),
)
) : null}
</>
)}
<ChatContainerScrollAnchor />
</ChatContainerContent>