"use client"; import { startTransition, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { ChatContainerContent, ChatContainerRoot, ChatContainerScrollAnchor, } from "@/components/ui/chat-container"; import { Message, MessageContent } from "@/components/ui/message"; import { preloadMarkdown } from "@/components/ui/markdown-lazy"; import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { PromptInput, PromptInputAction, PromptInputActions, PromptInputTextarea, } from "@/components/ui/prompt-input"; import { PromptSuggestion } from "@/components/ui/prompt-suggestion"; import { ScrollButton } from "@/components/ui/scroll-button"; import { DotsLoader } from "@/components/ui/loader"; import { Skeleton } from "@/components/ui/skeleton"; import { olay } from "@/lib/analitik"; export interface Mesaj { id: string; role: "user" | "assistant"; content: string; } export interface DanismanOzeti { metin: string; uyarilar?: string[]; kilitli?: boolean; /** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */ kilitliUyariSayisi?: number; } /** Danışman mesajlarının avatarı — KolayTercih markası. */ function DanismanAvatar() { return ( ); } const ORNEK_SORULAR = [ "Listemdeki kırmızı (riskli) tercihler ne kadar riskli?", "İlk 5 tercihimin taban sıralaması trendi nasıl?", "Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?", "X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?", ]; /** * Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur: * GET geçmiş + kredi, POST 1 kredi düşerek cevabı stream eder. */ export function SohbetClient({ baslangicKredi, initialMesajlar, className, ozet, }: { baslangicKredi: number; /** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */ initialMesajlar?: Mesaj[]; /** Boyutlandırma override'ı (ör. alt popup'ta h-full) */ className?: string; /** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */ ozet?: DanismanOzeti; }) { const [mesajlar, setMesajlar] = useState(initialMesajlar ?? []); const [girdi, setGirdi] = useState(""); const [bekliyor, setBekliyor] = useState(false); const [yuklendi, setYuklendi] = useState(initialMesajlar != null); const [kredi, setKredi] = useState(baslangicKredi); const router = useRouter(); // Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı // (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın. useEffect(() => { void preloadMarkdown(); }, []); useEffect(() => { // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; // popup gibi geç mount olan yerlerde eski davranış sürer. if (initialMesajlar != null) return; // ttlMs: 0 = yalnız in-flight dedup — /sonuc popup'ı ile /listem aynı anda // mount olursa tek istek; kredi/geçmiş POST sonrası değiştiği için // TTL cache'i bilinçli kapalı. onbellekliJsonGetir<{ mesajlar?: Mesaj[]; kredi?: number }>("/api/soru", { ttlMs: 0, }) .then((data) => { if (data?.mesajlar) setMesajlar(data.mesajlar); if (typeof data?.kredi === "number") setKredi(data.kredi); }) .catch(() => {}) .finally(() => setYuklendi(true)); }, [initialMesajlar]); async function gonder(metin: string) { const mesaj = metin.trim(); if (!mesaj || bekliyor) return; if (kredi < 1) { toast.error("Kredin bitti — devam etmek için kredi yükle."); 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; } 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(); } 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 krediYok = kredi < 1; return (

Danışmanla konuş

{kredi} kredi
{ozet ? (

Danışman özeti

{ozet.metin} {ozet.uyarilar?.length ? (
    {ozet.uyarilar.map((uyari) => (
  • {uyari}
  • ))}
) : null} {ozet.kilitli ? (

{ozet.kilitliUyariSayisi ? ( <> {ozet.kilitliUyariSayisi} uyarı daha kilitli — sıralamana özel uyarıların pakette. ) : ( <> Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette. )}

) : null}
) : null} {mesajlar.length === 0 ? (
{!ozet ? ( <>

Listen hakkında her şeyi sorabilirsin

Cevaplar senin 24'lük listen, seçimlerin ve gerçek YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.

) : null} {yuklendi ? (
{ORNEK_SORULAR.map((s) => ( gonder(s)} > {s} ))}
) : (
{[112, 148, 96, 132].map((w, i) => ( ))}
)}
) : ( mesajlar.map((m) => m.role === "user" ? ( {m.content} ) : ( {m.content ? ( {m.content} ) : (
)}
), ) )}
{krediYok ? (

Kredin bitti — sohbete devam etmek için kredi yükle.

) : ( <> {kredi <= 3 ? (

Son {kredi} kredin — bitse de listen ve sohbetin kaybolmaz;{" "} kredi yükleyip {" "} kaldığın yerden devam edersin.

) : null} gonder(girdi)} isLoading={bekliyor} disabled={bekliyor} maxHeight={160} className="rounded-2xl border-slate-200" > )}
); }