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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user