"use client"; import { startTransition, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { AnimasyonluMark } from "@/components/animasyonlu-mark"; 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"; export interface Mesaj { id: string; role: "user" | "assistant"; content: string; } 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şeni — header * logosuyla aynı nabız animasyonu, 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) statik görsel yerine başlıktaki nabız animasyonu * oynar: danışmanın "düşündüğü" avatardan okunur. */ export function DanismanAvatar({ canli = false }: { canli?: boolean }) { return ( {canli ? ( ) : ( )} ); } 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, ilkYorum, bildikleri, }: { 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; /** 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[]; }) { 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 [temizleAcik, setTemizleAcik] = useState(false); const [temizleniyor, setTemizleniyor] = useState(false); 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); } } /** * 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} kredi
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} {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 ? (
{ORNEK_SORULAR.map((s) => ( gonder(s)} > {s} ))}
) : (
{[112, 148, 96, 132].map((w, i) => ( ))}
)}
) : ( mesajlar.map((m, i) => m.role === "user" ? ( {m.content} ) : ( {/* Cevap beklenirken/stream sürerken (son mesaj) avatar nabız animasyonuna geçer. */} {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" > )}
); }