From a5c2bcbc4dbd289452c88f64313251aef2187bdc Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 3 Aug 2026 14:12:06 +0300 Subject: [PATCH] Enhance error handling and user experience in AI-related features - Introduced a mechanism to save partially generated responses in case of errors, ensuring users do not lose their input. - Updated the RevizyonKutusu component to utilize a persistent request ID for retrying failed requests without double charging credits. - Improved the Sayfalama component to support client-side pagination without changing the URL, enhancing user navigation. - Added a timeout for AI generation requests to ensure credits are refunded in case of prolonged processing. - Refactored various components for better clarity and responsiveness, including updates to the BolumProgramListesi and ListePaneli components. --- src/app/api/soru/route.ts | 17 +- src/app/bolum/[slug]/bolum-icerik.tsx | 16 +- src/app/listem/page.tsx | 5 + src/app/listem/revizyon-kutusu.tsx | 10 +- src/app/listem/sohbet-client.tsx | 20 +- src/app/paket/page.tsx | 11 +- src/app/sonuc/actions.ts | 55 ++++- src/app/sonuc/liste-paneli.tsx | 14 +- src/app/sonuc/page.tsx | 5 + src/app/sonuc/sihirbaz-cagri-karti.tsx | 18 +- src/components/bolum-program-listesi.tsx | 264 +++++++++++++---------- src/components/sayfalama.tsx | 96 ++++++--- src/lib/ai/cagri.ts | 33 ++- 13 files changed, 384 insertions(+), 180 deletions(-) diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index 68dcc3d..754779c 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -247,9 +247,9 @@ export async function POST(request: NextRequest) { const readable = new ReadableStream({ async start(controller) { let tamamlandi = false; + let metin = ""; try { // Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır - let metin = ""; for await (const parca of sohbetAkisi({ system, mesajlar, @@ -269,7 +269,20 @@ export async function POST(request: NextRequest) { controller.close(); } catch (err) { if (!tamamlandi) { - // Hiç çıktı üretilmeden hata olduysa krediyi iade et + // Cevap tamamlanmadı: kredi iade edilir. Yarıda kalan metin varsa + // yine de kaydedilir ki kullanıcı ekranda gördüğü kısmı sayfa + // yenilenince kaybetmesin. + if (metin) { + try { + await appDb.insert(schema.chatMessages).values({ + id: crypto.randomUUID(), + userId, + role: "assistant", + content: metin, + createdAt: new Date(), + }); + } catch {} + } await grantCredits({ userId, delta: 1, diff --git a/src/app/bolum/[slug]/bolum-icerik.tsx b/src/app/bolum/[slug]/bolum-icerik.tsx index b2e2aa1..84d0ca4 100644 --- a/src/app/bolum/[slug]/bolum-icerik.tsx +++ b/src/app/bolum/[slug]/bolum-icerik.tsx @@ -4,7 +4,7 @@ import { notFound } from "next/navigation"; import { ChevronRight } from "lucide-react"; import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog"; import { JsonLd, breadcrumbJsonLd } from "@/lib/seo"; -import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama"; +import { SAYFA_BOYU } from "@/components/sayfalama"; import { BolumProgramListesi } from "@/components/bolum-program-listesi"; import { SiteFooter } from "@/components/site-footer"; @@ -46,10 +46,6 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { ]; const toplamSayfa = bolumToplamSayfa(tumProgramlar.length); if (sayfa < 1 || sayfa > toplamSayfa) notFound(); - const dilim = tumProgramlar.slice( - (sayfa - 1) * SAYFA_BOYU, - sayfa * SAYFA_BOYU, - ); const ilkSayfa = sayfa === 1; const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`; @@ -113,19 +109,15 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { ({ + programlar={tumProgramlar.map((program) => ({ ...program, universiteSlug: uniSlugFromAd(program.universite), }))} bazAd={bolum.ad} + tabanYol={`/bolum/${slug}`} + sayfa={sayfa} /> - - diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index 2e1c504..08375f1 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -29,6 +29,11 @@ import { SohbetClient, type Mesaj } from "./sohbet-client"; import { RevizyonKutusu } from "./revizyon-kutusu"; import { ListemBosCta } from "./listem-bos-cta"; +// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort +// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA +// dolmalı ki kredi iadesi her zaman çalışabilsin. +export const maxDuration = 120; + export const metadata: Metadata = { title: "Listem", robots: { index: false, follow: false }, diff --git a/src/app/listem/revizyon-kutusu.tsx b/src/app/listem/revizyon-kutusu.tsx index 2d50e80..6fef1c1 100644 --- a/src/app/listem/revizyon-kutusu.tsx +++ b/src/app/listem/revizyon-kutusu.tsx @@ -4,7 +4,7 @@ // listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki // sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın. -import { useState } from "react"; +import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { RefreshCcw } from "lucide-react"; import { toast } from "sonner"; @@ -16,6 +16,10 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) { const [feedback, setFeedback] = useState(""); const [pending, setPending] = useState(false); const router = useRouter(); + // Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry + // çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı + // ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts). + const requestIdRef = useRef(null); if (kalanHak <= 0) { return ( @@ -34,10 +38,12 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) { async function revizeEt() { setPending(true); try { + if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); const sonuc = await listeRevize({ feedback: feedback.trim(), - requestId: crypto.randomUUID(), + requestId: requestIdRef.current, }); + requestIdRef.current = null; if (sonuc.ok) { olay("liste_revize", { sayfa: "listem" }); toast.success("Listen yeniden kuruldu."); diff --git a/src/app/listem/sohbet-client.tsx b/src/app/listem/sohbet-client.tsx index db50151..7feec19 100644 --- a/src/app/listem/sohbet-client.tsx +++ b/src/app/listem/sohbet-client.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -66,6 +67,7 @@ export function SohbetClient({ const [bekliyor, setBekliyor] = useState(false); const [yuklendi, setYuklendi] = useState(initialMesajlar != null); const [kredi, setKredi] = useState(baslangicKredi); + const router = useRouter(); useEffect(() => { // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; @@ -110,6 +112,8 @@ export function SohbetClient({ setMesajlar((m) => m.filter((x) => x.id !== asistanId && x.id !== clientMessageId), ); + // Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın + setGirdi(mesaj); if (res.status === 402) { toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin."); setKredi(0); @@ -136,6 +140,9 @@ export function SohbetClient({ m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)), ); } + // Header'daki kredi pill'i layout'ta server-render edilir ve client + // harcamasını görmez; bakiye orada da güncellensin. + router.refresh(); } catch { toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin."); } finally { @@ -315,7 +322,17 @@ export function SohbetClient({ ) : ( - + {kredi <= 3 ? ( +

+ Son {kredi} kredin — bitse de listen ve sohbetin kaybolmaz;{" "} + + kredi yükleyip + {" "} + kaldığın yerden devam edersin. +

+ ) : null} + gonder(girdi)} @@ -342,6 +359,7 @@ export function SohbetClient({ + )} diff --git a/src/app/paket/page.tsx b/src/app/paket/page.tsx index e6158ff..d7b5e9a 100644 --- a/src/app/paket/page.tsx +++ b/src/app/paket/page.tsx @@ -164,11 +164,20 @@ export default async function PaketPage({ {tl(URUNLER.topup.amountKurus)} TL.

- {u ? ( + {/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip + yine maskeli liste görür. Satın alma yalnızca paketliye + açık; paketsize durum dürüstçe açıklanır. */} + {u?.hasPaket ? ( + ) : u ? ( +

+ Ek kredi, kilitli analizleri açmaz — o yüzden önce paket + gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir; + bitince buradan yükleyebilirsin. +

) : ( ); diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index c1b1299..bca5d15 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -18,6 +18,11 @@ import { ManuelHarita } from "@/components/manuel-liste/manuel-harita"; import { SiteFooter } from "@/components/site-footer"; import { SiraGerekli } from "./sira-gerekli"; +// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort +// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA +// dolmalı ki kredi iadesi her zaman çalışabilsin. +export const maxDuration = 120; + export const metadata: Metadata = { title: "Tercih Listeni Kur", // Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama diff --git a/src/app/sonuc/sihirbaz-cagri-karti.tsx b/src/app/sonuc/sihirbaz-cagri-karti.tsx index dd28acb..c662270 100644 --- a/src/app/sonuc/sihirbaz-cagri-karti.tsx +++ b/src/app/sonuc/sihirbaz-cagri-karti.tsx @@ -105,6 +105,8 @@ export function SihirbazBolumu({ // Aynı üretim için sabit requestId (retry çifte harcama yapmaz) const requestIdRef = useRef(null); + // Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx) + const revizyonIdRef = useRef(null); const otomatikBasladi = useRef(false); const bolumRef = useRef(null); // Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi @@ -228,9 +230,17 @@ export function SihirbazBolumu({ // Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır, // liste (paketsizse paywall'lı hâliyle) /listem'de karşılar. router.push("/listem"); + // Header'daki kredi pill'i layout'ta server-render edilir; push layout'u + // yeniden çizmediği için düşen bakiye refresh ile güncellenir. + router.refresh(); return; } - // Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın + // Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi; + // sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e + // takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap + // alınamayan network hatasında (catch) korunur — orada çifte harcama + // riski gerçek. + requestIdRef.current = null; if (sonuc.code === "KREDI" || sonuc.code === "PAKET") { setDurum("kredi-yetersiz"); } else if (sonuc.code === "AUTH") { @@ -389,10 +399,14 @@ export function SihirbazBolumu({ sira={sira} acilis={acilis} onRevize={async (feedback) => { + if (!revizyonIdRef.current) { + revizyonIdRef.current = crypto.randomUUID(); + } const sonuc = await listeRevize({ feedback, - requestId: crypto.randomUUID(), + requestId: revizyonIdRef.current, }); + revizyonIdRef.current = null; if (sonuc.ok) { olay("liste_revize", { sayfa: "sonuc" }); setRapor({ diff --git a/src/components/bolum-program-listesi.tsx b/src/components/bolum-program-listesi.tsx index 8ac6355..3c92148 100644 --- a/src/components/bolum-program-listesi.tsx +++ b/src/components/bolum-program-listesi.tsx @@ -1,10 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import Link from "next/link"; import { ChevronDown, ChevronUp } from "lucide-react"; import type { Program } from "@/lib/db"; import { trBaslikDuzeni } from "@/lib/slug"; +import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama"; import { ProgramSiraGecmisi, ProgramTabanTrendi, @@ -82,12 +83,22 @@ function SiralamaDugmesi({ export function BolumProgramListesi({ programlar, bazAd, + tabanYol, + sayfa, }: { + /** Bölümün TÜM programları — sıralama liste geneline uygulanır. */ programlar: BolumProgrami[]; bazAd: string; + tabanYol: string; + sayfa: number; }) { const [aktifAlan, setAktifAlan] = useState(null); const [yon, setYon] = useState("artan"); + // Sıralama aktifken URL değişmeden istemci tarafında gezilen sayfa + const [istemciSayfa, setIstemciSayfa] = useState(1); + const listeRef = useRef(null); + + const toplamSayfa = Math.max(1, Math.ceil(programlar.length / SAYFA_BOYU)); const siraliProgramlar = useMemo(() => { if (!aktifAlan) return programlar; @@ -107,7 +118,18 @@ export function BolumProgramListesi({ .map(({ program }) => program); }, [aktifAlan, programlar, yon]); + const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa; + const gosterilenProgramlar = useMemo( + () => + siraliProgramlar.slice( + (gosterilenSayfa - 1) * SAYFA_BOYU, + gosterilenSayfa * SAYFA_BOYU, + ), + [siraliProgramlar, gosterilenSayfa], + ); + function sirala(alan: SiralamaAlani) { + setIstemciSayfa(1); if (aktifAlan === alan) { setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan")); return; @@ -116,124 +138,140 @@ export function BolumProgramListesi({ setYon("artan"); } - return ( -
- - - - Üniversite - - Son 5 yıl - - - - - - - - - - - - Listeye ekle - - - - - {siraliProgramlar.map((p) => { - const uniAd = trBaslikDuzeni(p.universite); - const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null; - const devlet = p.unitur === "DEVLET"; + function istemciSayfaDegistir(n: number) { + setIstemciSayfa(n); + listeRef.current?.scrollIntoView({ block: "start" }); + } - return ( - - - - {p.universiteSlug ? ( - - ) : null} - + return ( + <> +
+
+ + + Üniversite + + Son 5 yıl + + + + + + + + + + + + Listeye ekle + + + + + {gosterilenProgramlar.map((p) => { + const uniAd = trBaslikDuzeni(p.universite); + const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null; + const devlet = p.unitur === "DEVLET"; + + return ( + + + {p.universiteSlug ? ( - - {uniAd} - - ) : ( - - {uniAd} - - )} - - {p.unitur ? ( - + ) : null} + + {p.universiteSlug ? ( + - {devlet ? "Devlet" : trBaslikDuzeni(p.unitur)} + {uniAd} + + ) : ( + + {uniAd} - ) : null} - - {p.il ? trBaslikDuzeni(p.il) : null} - {varyant ? `${p.il ? " · " : ""}${varyant}` : ""} + )} + + {p.unitur ? ( + + {devlet ? "Devlet" : trBaslikDuzeni(p.unitur)} + + ) : null} + + {p.il ? trBaslikDuzeni(p.il) : null} + {varyant ? `${p.il ? " · " : ""}${varyant}` : ""} + + {p.sure ? ( + + · {p.sure} yıl + + ) : null} - {p.sure ? ( - - · {p.sure} yıl - - ) : null} - - - - - - - {p.puan2025 != null - ? p.puan2025.toFixed(2).replace(".", ",") - : "—"} - - - {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"} - - - - - {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"} - - - - - - - ); - })} - -
-
+ + + + + + {p.puan2025 != null + ? p.puan2025.toFixed(2).replace(".", ",") + : "—"} + + + {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"} + + + + + {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"} + + + + + + + ); + })} + + +
+ + ); } diff --git a/src/components/sayfalama.tsx b/src/components/sayfalama.tsx index 0b82e45..f950ab4 100644 --- a/src/components/sayfalama.tsx +++ b/src/components/sayfalama.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import Link from "next/link"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -8,23 +9,56 @@ export const SAYFA_BOYU = 50; * Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in * kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek * , Google her sayfayı ayrı statik URL olarak tarar. + * + * `onSayfa` verilirse Link yerine aynı görünümde butonlar render edilir — + * istemci tarafında sıralanmış listelerin sayfalaması URL değiştirmeden + * bu geri çağrıyla ilerler. */ export function Sayfalama({ tabanYol, sayfa, toplamSayfa, sorgu, + onSayfa, }: { tabanYol: string; sayfa: number; toplamSayfa: number; sorgu?: string; + onSayfa?: (n: number) => void; }) { if (toplamSayfa <= 1) return null; const yol = (n: number) => { const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`; return sorgu ? `${path}?${sorgu}` : path; }; + const gecis = ( + n: number, + className: string, + icerik: ReactNode, + ozellikler?: { rel?: string; aktif?: boolean }, + ) => + onSayfa ? ( + + ) : ( + + {icerik} + + ); // 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son const numaralar: (number | "...")[] = []; @@ -49,16 +83,17 @@ export function Sayfalama({ aria-label="Sayfalama" className="mt-6 flex flex-wrap items-center justify-center gap-1.5" > - {sayfa > 1 ? ( - - - Önceki - - ) : null} + {sayfa > 1 + ? gecis( + sayfa - 1, + "inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary", + <> + + Önceki + , + { rel: "prev" }, + ) + : null} {numaralar.map((n, i) => n === "..." ? ( ) : ( - - {n} - + gecis( + n, + n === sayfa + ? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground" + : "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary", + n, + { aktif: n === sayfa }, + ) ), )} - {sayfa < toplamSayfa ? ( - - Sonraki - - - ) : null} + {sayfa < toplamSayfa + ? gecis( + sayfa + 1, + "inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary", + <> + Sonraki + + , + { rel: "next" }, + ) + : null} ); } diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index 381a424..1187ab5 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -61,9 +61,17 @@ async function geciciHatadaTekrarla( throw sonHata; } +/** + * Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın + * (server action) platform limitinden önce dolmalı ki hata iade kodunun + * çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse + * harcanan kredi iade edilemiyor. + */ +const URETIM_ZAMAN_ASIMI_MS = 50_000; + /** * Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner - * (çağıran taraf yeniden dener). + * (çağıran taraf yeniden dener). Zaman aşımında abort hatası fırlar. */ export async function yapilandirilmisUret(opts: { system: string; @@ -71,12 +79,16 @@ export async function yapilandirilmisUret(opts: { sema: T; maxTokens: number; }): Promise | null> { + // Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da + // anında düşer, toplam süre sınırın üstüne çıkamaz. + const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS); if (saglayici() === "gemini") { const yanit = await geciciHatadaTekrarla(() => getGemini().models.generateContent({ model: raporModeli(), contents: opts.kullanici, config: { + abortSignal: sinyal, systemInstruction: opts.system, maxOutputTokens: opts.maxTokens, responseMimeType: "application/json", @@ -95,14 +107,17 @@ export async function yapilandirilmisUret(opts: { } } - const yanit = await getAnthropic().messages.parse({ - model: raporModeli(), - max_tokens: opts.maxTokens, - thinking: { type: "adaptive" }, - system: opts.system, - messages: [{ role: "user", content: opts.kullanici }], - output_config: { format: zodOutputFormat(opts.sema) }, - }); + const yanit = await getAnthropic().messages.parse( + { + model: raporModeli(), + max_tokens: opts.maxTokens, + thinking: { type: "adaptive" }, + system: opts.system, + messages: [{ role: "user", content: opts.kullanici }], + output_config: { format: zodOutputFormat(opts.sema) }, + }, + { signal: sinyal }, + ); return yanit.parsed_output ?? null; }