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>
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||
@@ -58,6 +58,14 @@ 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 };
|
||||
@@ -66,11 +74,72 @@ 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ıtlı (düz metin) mesajı useChat'in parçalı mesajına çevirir. */
|
||||
/** 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 {
|
||||
return { id: m.id, role: m.role, parts: [{ type: "text", text: m.content }] };
|
||||
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. */
|
||||
@@ -111,6 +180,20 @@ export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.
|
||||
*/
|
||||
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 = "";
|
||||
@@ -128,10 +211,11 @@ function sohbetKanaliKur(onBasladi: (krediDustu: boolean) => void) {
|
||||
let red: Red | null = null;
|
||||
const transport = new DefaultChatTransport<UIMessage>({
|
||||
api: "/api/soru",
|
||||
prepareSendMessagesRequest: ({ messages }) => {
|
||||
// `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: { message: son ? mesajMetni(son) : "", clientMessageId: son?.id, ...(body ?? {}) },
|
||||
};
|
||||
},
|
||||
fetch: async (girdiUrl, init) => {
|
||||
@@ -245,6 +329,8 @@ const ORNEK_SORULAR = [
|
||||
"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:
|
||||
@@ -263,6 +349,7 @@ export function SohbetClient({
|
||||
genis: genisProp,
|
||||
onGenisDegistir,
|
||||
genisYukseklik = GENIS_YUKSEKLIK,
|
||||
revizyonHakki = 0,
|
||||
}: {
|
||||
baslangicKredi: number;
|
||||
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
|
||||
@@ -294,6 +381,8 @@ export function SohbetClient({
|
||||
/** 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);
|
||||
@@ -406,6 +495,41 @@ export function SohbetClient({
|
||||
? [...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
|
||||
@@ -536,6 +660,30 @@ export function SohbetClient({
|
||||
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
|
||||
@@ -569,10 +717,7 @@ export function SohbetClient({
|
||||
// (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",
|
||||
// 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
|
||||
// sohbeti sıkıştırıyordu, 10-14rem denemeleri fazla yüksek geldi;
|
||||
// panel görünür ama sayfaya baskın değil.
|
||||
className ?? "h-[calc(100dvh-18rem)] min-h-[380px]",
|
||||
className ?? SOHBET_DAR_BOY,
|
||||
)}
|
||||
>
|
||||
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
|
||||
@@ -796,7 +941,7 @@ export function SohbetClient({
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{ORNEK_SORULAR.map((s) => (
|
||||
{[...(revizyonHakki > 0 ? [REVIZYON_ORNEGI] : []), ...ORNEK_SORULAR].map((s) => (
|
||||
<PromptSuggestion
|
||||
key={s}
|
||||
size="sm"
|
||||
@@ -842,6 +987,8 @@ export function SohbetClient({
|
||||
// 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)}
|
||||
@@ -868,6 +1015,13 @@ export function SohbetClient({
|
||||
) : (
|
||||
<CevapIskeleti />
|
||||
)}
|
||||
{anket ? (
|
||||
<SohbetAnketi
|
||||
anket={anket}
|
||||
durum={anketDurumu(i)}
|
||||
onGonder={(cevaplar) => anketiGonder(anket, cevaplar)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Message>
|
||||
);
|
||||
@@ -917,7 +1071,11 @@ export function SohbetClient({
|
||||
className="rounded-2xl border-slate-200"
|
||||
>
|
||||
<PromptInputTextarea
|
||||
placeholder="Sorunu yaz… (1 kredi)"
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user