Files
kolaytercih/src/features/rapor/components/sohbet-client.tsx
bilalgursen 4d30dd1305 feat(sohbet): liste revizyonu sohbete taşındı — soru formu ve yapısal kural
Eski revizyon kutusu kaldırıldı: serbest metin modele gidiyor, sıra ve havuz
koddan belirlendiği için "ilk 5 X ilinden olsun" gibi isteklerin karşılığı
yoktu. Danışman artık belirsiz isteği soru formuyla (soru_sor → shadcn
Questionnaire, proje diline çevrildi) netleştirir, sonra liste_revize ile
yapısal kural verir: il/alan/tip farkı, "ilk N" ölçütü, çıkar/ekle,
iskelet (lib/revizyon-kurali.ts). Kurallar havuz, seçim ve sırada koddan
uygulanır; "ilk N" ölçütünün ili/alanı süzgece kendiliğinden eklenir.

- chat_messages.meta sütunu (göç 0006): form, form cevabı ve revizyon sonucu
  kayıttan geri kurulur; form cevabı kredi harcamaz.
- Akış korumaları: form açılınca başka araç yok, revizyonda zaman aşımı uzar,
  "yeniden kurdum" beyanı araçsızsa düzeltme notu.
- /listem "Revize et" sohbeti odaklar; başarılı revizyonda liste yenilenir.
- /dev/sohbet senaryosu, DESIGN.md §4.20 notu ve §4.33.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00

1106 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<UIMessage>({
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.
<h2 className="flex min-w-0 flex-1 items-center gap-0.5 font-bricolage text-base tracking-tighter">
<AnimasyonluMark varyant="nabiz" renk="turuncu" className="size-5" />
<span className="truncate">
<span className="font-light">Danışmanla</span>
<span className="font-semibold">Konuş</span>
</span>
</h2>
);
}
/** 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 (
<span
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-card"
aria-hidden
>
<AnimasyonluMark
oynat={canli}
varyant={canli && aracta ? "zincir" : "nabiz"}
renk={renk}
className="size-5"
/>
</span>
);
}
/** 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?",
"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<UIMessage>;
/** 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<ProfilSatiri[]>(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<HTMLElement>(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<string>());
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<ProfilAlani>) {
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 (
<section
ref={kapRef}
aria-label="Danışman sohbeti"
// Yükseklik: sınıftan (dar hâl); genişletilince satır içi stil
// (genisligiDegistir) sınıfı ezer, daraltılınca kaldırılır.
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-card",
className ?? SOHBET_DAR_BOY,
)}
>
{/* 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. */}
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
<DanismanBaslik />
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={genisligiDegistir}
aria-pressed={genis}
aria-label={genis ? "Sohbeti daralt" : "Sohbeti genişlet"}
title={genis ? "Daralt" : "Genişlet"}
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
{genis ? (
<Minimize2 className="size-3.5" aria-hidden />
) : (
<Maximize2 className="size-3.5" aria-hidden />
)}
</button>
{bildikleri?.length ? (
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Danışmanın bildikleri"
className="inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-800"
>
<Info className="size-3.5" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-xs flex-col items-start gap-1.5 p-3 text-left leading-relaxed"
>
<p className="font-semibold">
Danışmanın bildikleri — cevaplar bu bağlamla üretilir:
</p>
<ul className="flex list-disc flex-col gap-1 pl-4">
{bildikleri.map((b) => (
<li key={b}>{b}</li>
))}
</ul>
<p className="text-background/70">
Adın, e-postan ve netlerin danışmanla paylaşılmaz.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{mesajlar.length > 0 ? (
<button
type="button"
onClick={() => setTemizleAcik(true)}
disabled={bekliyor || temizleniyor}
aria-label="Sohbeti temizle"
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-50"
title="Sohbet geçmişini sil"
>
<Trash2 className="size-3.5" aria-hidden />
</button>
) : null}
<Link
href="/paket?kaynak=danisman_rozet"
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
title="Kredilerin — yüklemek için tıkla"
>
<Coins className="size-3.5" aria-hidden />
<SayiRulo deger={kredi} /> kredi
</Link>
</div>
</div>
{profil.length > 0 ? (
<div
role="group"
aria-label="Danışmanın kaydettiği tercihlerin"
className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5"
>
<span className="text-xs font-semibold text-slate-500">
Kaydettiklerim
</span>
{profil.map((s) => (
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
<span
key={s.alan}
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-card pr-1 pl-3 text-xs font-semibold text-slate-700"
>
{s.metin}
<button
type="button"
onClick={() => profilAlaniSil(s.alan)}
aria-label={`Sil: ${s.metin}`}
className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96]"
>
<X className="size-3.5" aria-hidden />
</button>
</span>
))}
</div>
) : null}
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sohbeti temizle</DialogTitle>
<DialogDescription>
Danışmanla olan tüm yazışman silinir ve geri getirilemez. Listen
ve kredilerin etkilenmez; yeni bir soruyla sıfırdan devam
edebilirsin.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="cursor-pointer"
onClick={() => setTemizleAcik(false)}
disabled={temizleniyor}
>
Vazgeç
</Button>
<Button
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
onClick={sohbetiTemizle}
disabled={temizleniyor}
>
{temizleniyor ? <DotsLoader size="sm" /> : "Sohbeti temizle"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="relative flex-1 overflow-hidden">
<ChatContainerRoot className="h-full">
<ChatContainerContent className="gap-4 p-4">
{ozet ? (
<Message className="justify-start">
<DanismanAvatar />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="text-xs font-semibold text-slate-500">
Danışman özeti
</p>
<MessageContent className="bg-slate-100 text-slate-800">
{ozet.metin}
</MessageContent>
{ozet.kilitli ? (
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı pakette.
</p>
) : null}
</div>
</Message>
) : null}
{uyarilar?.length ? (
<Message className="justify-start">
<DanismanAvatar renk="turuncu" />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="text-xs font-semibold text-slate-500">
{uyarilar.length > 1
? "Danışman uyarıları"
: "Danışman uyarısı"}
</p>
{uyarilar.map((u) => (
// Zemin başlıktaki DanışmanlaKonuş işaretinin turuncusu
// (orange-500); yazı beyaz ve font-medium (§4.20).
<MessageContent
key={u}
className="bg-orange-500 font-medium text-white"
>
{u}
</MessageContent>
))}
</div>
</Message>
) : null}
{ilkYorum && mesajlar.length === 0 ? (
<Message className="justify-start">
<DanismanAvatar />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="text-xs font-semibold text-slate-500">
1. tercihin: {ilkYorum.program} — {ilkYorum.universite}
</p>
<MessageContent className="bg-slate-100 text-slate-800">
{ilkYorum.metin}
</MessageContent>
</div>
</Message>
) : null}
{mesajlar.length === 0 ? (
<div
className={cn(
"flex flex-col gap-4",
!ozet && "items-center py-10 text-center",
)}
>
{!ozet ? (
<>
<Sparkles
className="mx-auto size-7 text-primary"
aria-hidden
/>
<div>
<p className="font-heading font-bold">
Listen hakkında her şeyi sorabilirsin
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
Cevaplar senin 24&apos;lük listen, seçimlerin ve gerçek
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
</p>
</div>
</>
) : null}
{yuklendi ? (
<div
className={cn(
"flex max-w-lg flex-wrap gap-2",
!ozet && "justify-center",
)}
>
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => (
<PromptSuggestion
key={s}
size="sm"
className="cursor-pointer text-xs"
disabled={krediYok}
onClick={() => gonder(s)}
>
{s}
</PromptSuggestion>
))}
</div>
) : (
<div
className={cn(
"flex max-w-lg flex-wrap gap-2",
!ozet && "justify-center",
)}
>
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
)}
</div>
) : (
<>
{gorunenMesajlar.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 === 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 (
<Message
key={cevapAnahtari(gorunenMesajlar, i)}
className="justify-start"
>
{/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız atar; bir araç çalışırken kareler dolaşır. */}
<DanismanAvatar
canli={canli}
aracta={piller.some((p) => p.durum === "calisiyor")}
/>
<div className="flex w-full max-w-[85%] flex-col gap-2">
<AracPilleri piller={piller} />
{metin ? (
// Site içi adresler bağlantı hapı olur (sohbet-baglanti.tsx):
// markdown bağlantısı, backtick içi ve çıplak adres aynı hap
<MessageContent
markdown
components={SOHBET_MARKDOWN_BILESENLERI}
className="bg-slate-100 text-slate-800 prose-sm"
>
{baglantilariIsaretle(metin)}
</MessageContent>
) : (
<CevapIskeleti />
)}
{anket ? (
<SohbetAnketi
anket={anket}
durum={anketDurumu(i)}
onGonder={(cevaplar) => anketiGonder(anket, cevaplar)}
/>
) : null}
</div>
</Message>
);
})}
</>
)}
<ChatContainerScrollAnchor />
</ChatContainerContent>
<div className="absolute bottom-4 right-4">
<ScrollButton className="cursor-pointer bg-card shadow-md" />
</div>
</ChatContainerRoot>
</div>
<div className="border-t border-slate-100 p-3">
{krediYok ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-medium text-amber-800">
Kredin bitti — sohbete devam etmek için kredi yükle.
</p>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href="/paket?kaynak=danisman_kredi_bitti">Kredi yükle</Link>
</Button>
</div>
) : (
<>
{kredi <= 3 ? (
<p className="mb-2 px-1 text-xs text-amber-700">
Son {kredi} kredin — bitse de listen ve sohbetin kaybolmaz;{" "}
<Link href="/paket?kaynak=danisman_kredi_az" className="underline hover:text-amber-800">
kredi yükleyip
</Link>{" "}
kaldığın yerden devam edersin.
</p>
) : null}
<PromptInput
value={girdi}
onValueChange={setGirdi}
onSubmit={() => gonder(girdi)}
isLoading={bekliyor}
disabled={bekliyor}
maxHeight={160}
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder={
revizyonHakki > 0
? "Sorunu yaz ya da listende ne değişsin söyle… (1 kredi)"
: "Sorunu yaz… (1 kredi)"
}
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">
<PromptInputAction tooltip="Gönder (Enter)">
<Button
size="icon"
onClick={() => gonder(girdi)}
disabled={bekliyor || !girdi.trim()}
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
</Button>
</PromptInputAction>
</PromptInputActions>
</PromptInput>
{/* KVKK: sohbete kişisel bilgi yazılmasın (hukuk-ve-rakipler.md C.5/22) */}
<p className="mt-2 px-1 text-xs leading-5 text-slate-500">
Ad, telefon, kimlik numarası ya da sağlık bilgisi yazma; danışmanın
bunlara ihtiyacı yok.
</p>
</>
)}
</div>
</section>
);
}