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>
1106 lines
44 KiB
TypeScript
1106 lines
44 KiB
TypeScript
"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'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>
|
||
);
|
||
}
|