"use client"; import { useEffect, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type ChatTransport, type UIMessage } from "ai"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { animate, useReducedMotion } from "motion/react"; import { ArrowUp, Coins, Info, Lock, Maximize2, Minimize2, Sparkles, Trash2, X, } from "lucide-react"; import { toast } from "@/components/ui/bildirim"; import { cn } from "@/lib/utils"; import { SayiRulo } from "@/components/ui/sayi-rulo"; import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { AracPilleri, aracPilleri } from "./arac-pilleri"; import { SOHBET_MARKDOWN_BILESENLERI, baglantilariIsaretle } from "./sohbet-baglanti"; import { Button } from "@/components/ui/button"; import { ChatContainerContent, ChatContainerRoot, ChatContainerScrollAnchor, } from "@/components/ui/chat-container"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { DotsLoader } from "@/components/ui/loader"; import { Skeleton } from "@/components/ui/skeleton"; import { olay } from "@/lib/analitik"; import type { ProfilAlani } from "@/lib/danisman-profil"; import { anketCevabiMetni, type Anket, type AnketCevaplari, type RevizyonOzeti, type SohbetMeta, } from "@/lib/ai/sohbet-meta"; import { SohbetAnketi, type AnketDurumu } from "./sohbet-anketi"; /** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */ export type ProfilSatiri = { alan: ProfilAlani; metin: string }; export interface Mesaj { id: string; role: "user" | "assistant"; content: string; /** Yapısal ek: soru formu / form cevabı / revizyon sonucu (lib/ai/sohbet-meta.ts) */ meta?: SohbetMeta | null; } /** Kayıttan gelen araç parçalarının kimlik öneki: canlı akışın parçalarından ayırt edilir. */ const KAYIT_PARCA_ONEKI = "kayit-"; /** * Kayıtlı mesajı useChat'in parçalı mesajına çevirir. Kayıt düz metindir; * soru formu ve revizyon sonucu `meta`dan araç parçası olarak geri kurulur ki * canlı akışla aynı yoldan çizilsin (form, "Listeni yeniden kurdum" hapı). */ function uiMesaj(m: Mesaj): UIMessage { const parts: UIMessage["parts"] = [{ type: "text", text: m.content }]; if (m.role === "assistant" && m.meta?.anket) { parts.push({ type: "tool-soru_sor", toolCallId: `${KAYIT_PARCA_ONEKI}anket-${m.meta.anket.id}`, state: "output-available", input: {}, output: { soruldu: true, anket: m.meta.anket }, } as UIMessage["parts"][number]); } if (m.role === "assistant" && m.meta?.revizyon?.ok) { parts.push({ type: "tool-liste_revize", toolCallId: `${KAYIT_PARCA_ONEKI}revizyon-${m.id}`, state: "output-available", input: {}, output: m.meta.revizyon, } as UIMessage["parts"][number]); } return { id: m.id, role: m.role, parts }; } /** Mesajdaki soru formu (soru_sor aracının çıktısı); yoksa null. */ function mesajinAnketi(m: UIMessage): Anket | null { for (const p of m.parts) { if (p.type !== "tool-soru_sor" || !("state" in p) || p.state !== "output-available") continue; const cikti = p.output as { anket?: Anket } | undefined; if (cikti?.anket?.sorular?.length) return cikti.anket; } return null; } /** Mesajdaki başarılı liste revizyonları: parça kimliği → özet. */ function mesajinRevizyonlari(m: UIMessage): { id: string; ozet: RevizyonOzeti }[] { const sonuc: { id: string; ozet: RevizyonOzeti }[] = []; for (const p of m.parts) { if (p.type !== "tool-liste_revize" || !("state" in p) || p.state !== "output-available") continue; const cikti = p.output as RevizyonOzeti | undefined; if (cikti?.ok) sonuc.push({ id: p.toolCallId, ozet: cikti }); } return sonuc; } /** Formun cevabı olan kullanıcı mesajı mı (anketCevabiMetni ile aynı başlık). */ const FORM_CEVABI_BASI = "Form cevaplarım:"; /** * Sayfadaki "Revize et" düğmesi (listem-govde.tsx) sohbeti bu olayla odaklar: * kutuya başlangıç metni yazılır, panel görünür yere kaydırılır, imleç kutuya gelir. */ export const SOHBET_ODAK_OLAYI = "kolaytercih:sohbet-odak"; export function sohbetiOdakla(metin?: string) { window.dispatchEvent(new CustomEvent(SOHBET_ODAK_OLAYI, { detail: { metin } })); } /** 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"); } /** Soru gitti, cevabın ilk parçası henüz gelmedi: listede cevabın yerini * tutan boş danışman mesajı. */ const BEKLENEN_CEVAP: UIMessage = { id: "beklenen-cevap", role: "assistant", parts: [], }; /** Danışman cevabının liste anahtarı: kendi kimliği değil, cevapladığı sorunun * kimliği. Böylece bekleme satırı (BEKLENEN_CEVAP) ile sonradan gelen gerçek * cevap React için aynı satırdır — avatarın animasyonu ve iskelet, ilk parça * geldiğinde baştan başlamaz. */ function cevapAnahtari(mesajlar: UIMessage[], i: number): string { const soru = mesajlar[i - 1]; return soru?.role === "user" ? `cevap-${soru.id}` : mesajlar[i].id; } // Araç adı → öğrenci dili eşlemesi ve "…bakıyorum" hapları arac-pilleri.tsx'te. /** Panelin boyut geçişi: sekmesiz yay (DESIGN.md §6); listem-govde ızgarası aynı yayı kullanır. */ export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.35 } as const; /** * Genişletilmiş panel üstten `top-28` (7rem; listem sütununun sticky ofseti) * hizasına kaydırılır ve altı site geneli alt progressive blur'un * (`.kt-sayfa-blur-alt`, 7rem; globals.css) üstünde biter — Bilal, 6 Eki 2026: * "aşağı kısmı progressive blura takılıyor". Sayfa o kadar kaymıyorsa kalan * fark boydan düşülür; panel hiçbir durumda bulanık şeride girmez. */ const USTE_UZAKLIK_REM = 7; const ALT_BLUR_REM = 7; /** * Dar hâlin boyu (Bilal, 7 Eki 2026: "yüksekliğini alttan kısalım, progressive * blur üzerine geliyor, sığmıyor"). Panelin altı, sayfa en üstteyken bile alt * bulanık şeridin yaklaşık 1rem üstünde biter. Ekrandan düşülen pay üç * parçadır: panelin sayfa en üstteykenki üst boşluğu + 7rem şerit + 1rem nefes. * Üst boşluk ölçüldü (7 Eki 2026, canlı site): sezon bandı + site başlığının * altı telefonda 171, 768 px'te 187, 1280 px'te 137 px; üstüne `main` py-12 ve * ızgara mt-8 (80 px) gelir → 251 / 267 / 217 px. Eski tek değer (18rem) sezon * bandını hesaba katmıyordu: giriş kutusu masaüstünde ~40 px, telefonda ~90 px * şeridin altında kalıyordu. Sezon bandının metni uzayıp satır atlarsa bu * sayılar yeniden ölçülür. En az boy çok kısa ekran içindir. */ export const SOHBET_DAR_BOY = "h-[calc(100dvh-23.75rem)] min-h-[340px] sm:h-[calc(100dvh-24.75rem)] lg:h-[calc(100dvh-21.5rem)]"; const GENIS_EN_AZ_PX = 380; /** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */ const GENIS_YUKSEKLIK = ""; 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({ api: "/api/soru", // `body`: sendMessage'a verilen ek gövde (soru formunun yapısal cevabı) prepareSendMessagesRequest: ({ messages, body }) => { const son = messages[messages.length - 1]; return { body: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id, ...(body ?? {}) }, }; }, 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; } /** Liste kurulunca sohbetin açılışında gösterilen 1. tercih yorumu. * Metin ACIK_SATIR maskesinin her zaman açık bıraktığı ilk satırdan gelir, * yani paketsiz kullanıcıda da gerçek Yapay Zeka yorumudur. */ export interface IlkTercihYorumu { program: string; universite: string; metin: string; } /** Danışman panellerinin ortak başlığı: animasyonlu logo + site header'daki * KolayTercih wordmark deseninde "DanışmanlaKonuş" (ilk kelime ince, ikinci * kalın, boşluksuz). İşaret ortak AnimasyonluMark bileşeninin yerinde * nabız varyantı, burada sürekli atar. */ export function DanismanBaslik() { return ( // gap yok denecek kadar az (0.5): SVG viewBox'ının kendi iç boşluğu // zaten nefes payı bırakıyor.

Danışmanla Konuş

); } /** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap * beklenirken/yazılırken) panel başlığındaki DanışmanlaKonuş işareti gibi * yerinde nabız atar: danışmanın "düşündüğü" avatardan okunur. `aracta` iken * (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler sırayla * birbirinin yerine yürür; araç bitince nabza döner. Her geçişte ve cevap * bitince kareler yumuşakça yerine oturur. * `renk="turuncu"`: panel başlığındaki DanışmanlaKonuş işaretinin rengi — * yalnız "Danışman uyarısı" balonunda (durağan; hareket "düşünüyor" demek). */ export function DanismanAvatar({ canli = false, aracta = false, renk = "marka", }: { canli?: boolean; aracta?: boolean; renk?: "marka" | "turuncu"; }) { return ( ); } /** Cevap metni gelene kadar balonun yerinde duran iskelet. */ function CevapIskeleti() { 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?", ]; /** Revizyon hakkı olan (paketli) adaya listeyi sohbetten değiştirme örneği önde gelir. */ const REVIZYON_ORNEGI = "Listemde değişiklik istiyorum: ilk 5 tercihim İstanbul'dan olsun"; /** * 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, uyarilar, ilkYorum, bildikleri, initialProfil, denemeTransport, genis: genisProp, onGenisDegistir, genisYukseklik = GENIS_YUKSEKLIK, revizyonHakki = 0, }: { baslangicKredi: number; /** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */ initialProfil?: ProfilSatiri[]; /** 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; /** Listenin kendisinden çıkan uyarılar (liste-uyarilari.ts: dağılım havuza * uyarlandı, tüm tercihler tek şehirde). Özetin altında turuncu işaretli * "Danışman uyarısı" balonu olarak durur; sohbet ilerlese de kalır. */ uyarilar?: string[]; /** 1. tercihin Yapay Zeka yorumu; sohbet boşken (taze kurulmuş liste) * özetin altında açılış balonu olarak durur — üretim panelindeki "ilk * tercihinin yorumunu burada okuyacaksın" sözünün karşılığı. */ ilkYorum?: IlkTercihYorumu; /** Danışmanın prompt'unda gerçekten yer alan bağlam maddeleri; başlıktaki * "Danışmanın bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */ bildikleri?: string[]; /** Yalnız /dev/sohbet: /api/soru yerine önceden yazılmış senaryoyu oynatır * (model, oturum ve kredi yok). Üretim yüzeyleri bunu vermez. */ denemeTransport?: ChatTransport; /** Genişletilmiş hâl dışarıdan yönetiliyorsa (listem ızgarası sütunu da * büyütür); verilmezse panel kendi durumunu tutar ve yalnız boyu uzar. */ genis?: boolean; onGenisDegistir?: (genis: boolean) => void; /** Genişletilmiş panelin yüksekliği; verilmezse ekrana göre hesaplanır * (üst 7rem sticky hizası, alt 7rem progressive blur payı). */ genisYukseklik?: string; /** Kalan liste revizyonu hakkı (paketli); 0 ise revizyon örneği gösterilmez. */ revizyonHakki?: number; }) { const [girdi, setGirdi] = useState(""); const [yuklendi, setYuklendi] = useState(initialMesajlar != null); const [kredi, setKredi] = useState(baslangicKredi); const [temizleAcik, setTemizleAcik] = useState(false); const [temizleniyor, setTemizleniyor] = useState(false); // Danışmanın sohbetten çıkarıp kaydettiği tercihler (kapalı listeli profil). // Başlığın altında çip olarak durur; çarpı tek alanı siler. Cevap sonrası // yeniden çekilir: model profil_guncelle çağırdıysa yeni çip burada belirir. const [profil, setProfil] = useState(initialProfil ?? []); const router = useRouter(); // Genişletilmiş sohbet (Bilal, 6 Eki 2026: "div'in smooth genişlemesi, diğer // alanların kendini yeniden boyutlandırması"): panel YERİNDE büyür — perde, // tam ekran ya da ayrı pencere yok. Boy burada yaylı geçişle uzar; listem // ızgarası (listem-govde.tsx) aynı anda sohbet sütununu genişletir, liste ve // harita daralan sütunda kendini yeniden akıtır. useChat durumu ve kaydırma // korunur. Hareketi azalt: geçişsiz. const [genisIc, setGenisIc] = useState(false); const genis = genisProp ?? genisIc; const kapRef = useRef(null); const hareketAz = useReducedMotion(); function genisligiDegistir() { const yeni = !genis; if (genisProp === undefined) setGenisIc(yeni); onGenisDegistir?.(yeni); const el = kapRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const eski = rect.height; let hedefYukseklik = ""; if (yeni) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const usteUzaklik = USTE_UZAKLIK_REM * rem; // Panelin üstünü sticky noktasına getir; sayfa o kadar kaymıyorsa kalan // fark panelin üstünde kalır ve boydan düşülür const kayabilir = Math.max( 0, document.documentElement.scrollHeight - window.innerHeight - window.scrollY, ); const kaydir = Math.min(Math.max(0, rect.top - usteUzaklik), kayabilir); const kalanUst = rect.top - kaydir; const hedefPx = Math.max( GENIS_EN_AZ_PX, Math.round(window.innerHeight - kalanUst - ALT_BLUR_REM * rem), ); hedefYukseklik = genisYukseklik || `${hedefPx}px`; if (kaydir > 0) { const basla = window.scrollY; if (hareketAz) window.scrollTo(0, basla + kaydir); else void animate(basla, basla + kaydir, { ...SOHBET_GENISLEME_GECISI, onUpdate: (v) => window.scrollTo(0, v), }); } } // Boy geçişi: hedefi ölçüp eski değerden hedefe aynı yayla git; dar hâle // dönünce satır içi değer kalkar, sınıftaki yükseklik yeniden geçerli olur el.style.height = hedefYukseklik; if (hareketAz) return; const hedef = el.getBoundingClientRect().height; el.style.height = `${eski}px`; void animate(el, { height: hedef }, SOHBET_GENISLEME_GECISI).then(() => { el.style.height = hedefYukseklik; }); } // 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 = denemeTransport ?? kanal.transport; 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"; const gorunenMesajlar = status === "submitted" && mesajlar.at(-1)?.role === "user" ? [...mesajlar, BEKLENEN_CEVAP] : mesajlar; // Canlı akışta liste_revize başarıyla bitince liste sayfada yenilenir // (sunucu bileşeni raporu yeniden okur). Kayıttan gelen parçalar (önek) // zaten ekrandaki listeye ait; onlar için yenileme yok. const islenenRevizyonlar = useRef(new Set()); useEffect(() => { for (const m of mesajlar) { if (m.role !== "assistant") continue; for (const r of mesajinRevizyonlari(m)) { if (r.id.startsWith(KAYIT_PARCA_ONEKI) || islenenRevizyonlar.current.has(r.id)) continue; islenenRevizyonlar.current.add(r.id); router.refresh(); toast.success("Listen yeniden kuruldu."); olay("liste_revize", { sayfa: "listem", yol: "sohbet" }); } } }, [mesajlar, router]); // Sayfadaki "Revize et" düğmesi sohbeti odaklar (sohbetiOdakla) useEffect(() => { function odakla(e: Event) { const metin = (e as CustomEvent<{ metin?: string }>).detail?.metin; if (typeof metin === "string") setGirdi(metin); const el = kapRef.current; el?.scrollIntoView({ behavior: hareketAz ? "auto" : "smooth", block: "nearest" }); // Kaydırma başlasın, sonra odak (odak kaydırmayı yarıda keser) window.setTimeout(() => { const alan = el?.querySelector("textarea"); alan?.focus(); alan?.setSelectionRange(alan.value.length, alan.value.length); }, 50); } window.addEventListener(SOHBET_ODAK_OLAYI, odakla); return () => window.removeEventListener(SOHBET_ODAK_OLAYI, odakla); }, [hareketAz]); /** * İ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. Yalnız bağlantı koptuysa sunucu cevabı üretmeyi sürdürür * (kredi harcanır, cevap kaydedilir; kopunca iade etmek bedava cevap kapısı * olurdu) ve o an henüz kaydetmemiş olabilir: "iade edildi" deme. */ 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 tamamlanırsa sohbetine kaydedilir, tamamlanamazsa kredin iade edilir — yeniden göndermeden önce sayfayı yenile.", ); } async function profiliCek(oncekiAlanlar: Set) { try { const res = await fetch("/api/soru/profil"); if (!res.ok) return; const data = (await res.json()) as { satirlar?: ProfilSatiri[] }; const satirlar = data.satirlar ?? []; setProfil(satirlar); for (const s of satirlar) { // Analitiğe yalnız alan adı gider, değer gitmez (KVKK) if (!oncekiAlanlar.has(s.alan)) olay("profil_alani_kaydedildi", { alan: s.alan }); } } catch {} } useEffect(() => { // Listem sayfası profili sunucudan indirir; /sonuc popup'ı gibi geç mount // olan yerler çeker (ttlMs 0: yalnız in-flight dedup, DELETE sonrası // bayat kalmasın). if (initialProfil != null) return; onbellekliJsonGetir<{ satirlar?: ProfilSatiri[] }>("/api/soru/profil", { ttlMs: 0, }) .then((data) => setProfil(data?.satirlar ?? [])) .catch(() => {}); }, [initialProfil]); async function profilAlaniSil(alan: ProfilAlani) { const onceki = profil; setProfil((p) => p.filter((s) => s.alan !== alan)); try { const res = await fetch("/api/soru/profil", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ alan }), }); if (!res.ok) throw new Error("silinemedi"); const data = (await res.json()) as { satirlar?: ProfilSatiri[] }; setProfil(data.satirlar ?? []); olay("profil_alani_silindi", { alan }); } catch { setProfil(onceki); toast.error("Silinemedi, tekrar dener misin?"); } } // 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.map(uiMesaj)); if (typeof data?.kredi === "number") setKredi(data.kredi); }) .catch(() => {}) .finally(() => setYuklendi(true)); }, [initialMesajlar, setMesajlar]); 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(""); 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 }] }); } /** * Soru formunun cevabı: balonda okunur metin, gövdede yapısal cevap. Kredi * harcamaz (sunucu formu son danışman mesajıyla eşleştirip bedelsiz sayar), * bu yüzden kredi kapısından geçmez. */ function anketiGonder(anket: Anket, cevaplar: AnketCevaplari) { if (bekliyor) return; const metin = anketCevabiMetni(anket, cevaplar); const id = crypto.randomUUID(); sonSoruRef.current = null; void sendMessage( { id, role: "user", parts: [{ type: "text", text: metin }] }, { body: { anketCevabi: { anketId: anket.id, cevaplar } } }, ); } /** Formun durumu: yalnız son danışman mesajındaki form açıktır. */ function anketDurumu(i: number): AnketDurumu { const sonraki = gorunenMesajlar[i + 1]; if (!sonraki) return bekliyor ? "gonderiliyor" : "acik"; if (sonraki.role === "user" && mesajMetni(sonraki).startsWith(FORM_CEVABI_BASI)) return "cevaplandi"; return "kapandi"; } /** * Sohbet geçmişini hem sunucudan hem ekrandan siler. Krediler iade * edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti * ve liste bağlamı prompt'tan geldiği için korunur). */ async function sohbetiTemizle() { if (temizleniyor) return; setTemizleniyor(true); try { const res = await fetch("/api/soru", { method: "DELETE" }); if (!res.ok) throw new Error("silinemedi"); setMesajlar([]); setTemizleAcik(false); // Sunucudan gelen geçmiş (listem sayfasının initialMesajlar'ı) da boşalsın router.refresh(); toast.success("Sohbet temizlendi."); } catch { toast.error("Sohbet temizlenemedi, tekrar dener misin?"); } finally { setTemizleniyor(false); } } const krediYok = kredi < 1; return (
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar. Temizle butonu ikon-only — metinli hâli başlıkla yarışıp taşıyordu. */}
{bildikleri?.length ? (

Danışmanın bildikleri — cevaplar bu bağlamla üretilir:

    {bildikleri.map((b) => (
  • {b}
  • ))}

Adın, e-postan ve netlerin danışmanla paylaşılmaz.

) : null} {mesajlar.length > 0 ? ( ) : null} kredi
{profil.length > 0 ? (
Kaydettiklerim {profil.map((s) => ( // Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil. {s.metin} ))}
) : null} Sohbeti temizle Danışmanla olan tüm yazışman silinir ve geri getirilemez. Listen ve kredilerin etkilenmez; yeni bir soruyla sıfırdan devam edebilirsin.
{ozet ? (

Danışman özeti

{ozet.metin} {ozet.kilitli ? (

Değerlendirmenin tamamı pakette.

) : null}
) : null} {uyarilar?.length ? (

{uyarilar.length > 1 ? "Danışman uyarıları" : "Danışman uyarısı"}

{uyarilar.map((u) => ( // Zemin başlıktaki DanışmanlaKonuş işaretinin turuncusu // (orange-500); yazı beyaz ve font-medium (§4.20). {u} ))}
) : null} {ilkYorum && mesajlar.length === 0 ? (

1. tercihin: {ilkYorum.program} — {ilkYorum.universite}

{ilkYorum.metin}
) : 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 ? (
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => ( gonder(s)} > {s} ))}
) : (
{[112, 148, 96, 132].map((w, i) => ( ))}
)}
) : ( <> {gorunenMesajlar.map((m, i) => { const metin = mesajMetni(m); if (m.role === "user") { return ( {metin} ); } const canli = bekliyor && i === gorunenMesajlar.length - 1; // Danışman veriye bakarken ne yaptığını hap olarak söyler // (arac-pilleri.tsx); haplar cevap bitince de kalır. const piller = aracPilleri(m); // Soru formu balonun altında (sohbet-anketi.tsx) const anket = mesajinAnketi(m); return ( {/* Cevap beklenirken/stream sürerken (son mesaj) avatar nabız atar; bir araç çalışırken kareler dolaşır. */} p.durum === "calisiyor")} />
{metin ? ( // Site içi adresler bağlantı hapı olur (sohbet-baglanti.tsx): // markdown bağlantısı, backtick içi ve çıplak adres aynı hap {baglantilariIsaretle(metin)} ) : ( )} {anket ? ( anketiGonder(anket, cevaplar)} /> ) : null}
); })} )}
{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" > 0 ? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)" : "Sorunu yaz… (1 kredi)" } maxLength={2000} /> {/* KVKK: sohbete kişisel bilgi yazılmasın (hukuk-ve-rakipler.md C.5/22) */}

Ad, telefon, kimlik numarası ya da sağlık bilgisi yazma; danışmanın bunlara ihtiyacı yok.

)}
); }