forked from Tadrisino/MathApp_frontend
378 lines
17 KiB
TypeScript
378 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||
import { api, ApiError } from '@/api';
|
||
import { useAppData } from '@/state/AppData';
|
||
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||
import { useNav } from '@/nav/Navigator';
|
||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||
import { useToast } from '@/components/Toast';
|
||
import { Screen, ScreenHeader } from '@/components/ui';
|
||
import { Diagram } from '@/diagram';
|
||
import { MathText } from '@/lib/MathText';
|
||
import { friendlyError } from '@/lib/errors';
|
||
import { toFa } from '@/lib/format';
|
||
import { useGateHold } from '@/lib/useAsync';
|
||
import { iAm, useCompanion } from '@/state/companion';
|
||
|
||
const STARTERS = ['این فصل دربارهی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
|
||
|
||
/** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایهی کاربر جواب میدهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
|
||
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
|
||
const { profile, home } = useAppData();
|
||
const subjectTitle = home?.subject?.title;
|
||
const companion = useCompanion();
|
||
const chat = useAskState();
|
||
const [input, setInput] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
// «X سوالِ رایگانِ امروز + Y سوالِ خریدهشده»؛ پیش از اولین سوال از سرور، بعد از هر جواب از خودِ جواب.
|
||
const [quota, setQuota] = useState<{ free: number; credits: number } | null>(null);
|
||
const [quotaDone, setQuotaDone] = useState(false);
|
||
useEffect(() => {
|
||
api.ai
|
||
.quota()
|
||
.then((q) => setQuota({ free: q.remainingQuota, credits: q.credits }))
|
||
.catch((err: unknown) => console.warn('ai quota lookup failed', err))
|
||
.finally(() => setQuotaDone(true));
|
||
}, []);
|
||
// صفحه وقتی میآید که تاریخچه و سهمیه رسیده باشند؛ وگرنه اول خوشآمد و بعد پیامهای قبلی رویش میپرید.
|
||
useGateHold(!chat.historyLoaded || !quotaDone);
|
||
const listEnd = useRef<HTMLDivElement>(null);
|
||
const autoSent = useRef(false);
|
||
|
||
// درسِ تازه یعنی گفتگوی تازه؛ وگرنه اگر گفتگوی قبلی هست، تاریخچهاش را بیاور.
|
||
useEffect(() => {
|
||
if (lessonId !== undefined && lessonId !== chat.lessonId) {
|
||
askStore.reset(lessonId);
|
||
return;
|
||
}
|
||
if (chat.historyLoaded) return;
|
||
if (!chat.conversationId) {
|
||
askStore.markHistoryLoaded();
|
||
return;
|
||
}
|
||
api.ai
|
||
.history(chat.conversationId)
|
||
.then((r) =>
|
||
askStore.setHistory(
|
||
r.messages
|
||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
|
||
),
|
||
)
|
||
.catch(() => askStore.reset(null));
|
||
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (prompt && !autoSent.current) {
|
||
autoSent.current = true;
|
||
void send(prompt, questionId);
|
||
}
|
||
}, [prompt, questionId]);
|
||
|
||
// بعد از رندر اسکرول کن، تا پیشنهادهای ادامه که آخر از همه میآیند هم دیده شوند.
|
||
useEffect(() => {
|
||
const frame = requestAnimationFrame(() => listEnd.current?.scrollIntoView({ block: 'end' }));
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [chat.messages]);
|
||
|
||
async function send(text: string, forQuestionId?: string) {
|
||
const question = text.trim();
|
||
if (!question || busy) return;
|
||
setBusy(true);
|
||
setInput('');
|
||
askStore.add({ role: 'user', text: question });
|
||
const replyId = askStore.add({ role: 'assistant', text: '' });
|
||
let answer = '';
|
||
try {
|
||
const done = await api.ai.ask(
|
||
{
|
||
userQuestion: question,
|
||
conversationId: askStore.get().conversationId ?? undefined,
|
||
lessonId: askStore.get().lessonId ?? undefined,
|
||
// بیدرس، کتابِ انتخابیِ دانشآموز (هندسه، گسسته، …) را میفرستیم تا دستیار از همان کتاب جواب بدهد؛
|
||
// با درس، سرور کتاب را از خودِ درس درمیآورد.
|
||
subject: askStore.get().lessonId ? undefined : subjectTitle,
|
||
questionId: forQuestionId,
|
||
},
|
||
(delta) => {
|
||
answer += delta;
|
||
askStore.update(replyId, { text: answer });
|
||
},
|
||
);
|
||
askStore.update(replyId, {
|
||
text: answer,
|
||
messageId: done.messageId,
|
||
usedChunks: done.usedChunks,
|
||
followUps: done.suggestedFollowUps,
|
||
diagram: done.diagram ?? null,
|
||
});
|
||
askStore.setConversation(done.conversationId);
|
||
setQuota((q) => ({ free: done.remainingQuota, credits: done.credits ?? q?.credits ?? 0 }));
|
||
} catch (err) {
|
||
// سقفِ روزانه (نه throttleِ چندثانیهای): «دوباره» تا فردا بیفایده است.
|
||
const quotaReached = err instanceof ApiError && err.isRateLimited && err.detail.includes('سوال');
|
||
askStore.update(replyId, {
|
||
text: friendlyError(err, 'جوابی نرسید'),
|
||
...(quotaReached ? { quotaReached: true } : { failedQuestion: question }),
|
||
});
|
||
if (quotaReached) setQuota({ free: 0, credits: 0 });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function retry(message: ChatMessage) {
|
||
if (!message.failedQuestion) return;
|
||
const all = askStore.get().messages;
|
||
const index = all.findIndex((m) => m.localId === message.localId);
|
||
const question = all[index - 1];
|
||
askStore.remove(message.localId);
|
||
if (question?.role === 'user') askStore.remove(question.localId);
|
||
void send(message.failedQuestion);
|
||
}
|
||
|
||
const empty = chat.messages.length === 0;
|
||
|
||
return (
|
||
<Screen>
|
||
<ScreenHeader
|
||
title={`از ${companion.name} بپرس`}
|
||
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب میدم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
|
||
color={companion.color}
|
||
back={lessonId !== undefined || prompt !== undefined}
|
||
mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
|
||
>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
|
||
{!empty && (
|
||
<button
|
||
type="button"
|
||
onClick={() => askStore.reset(null)}
|
||
disabled={busy}
|
||
className="flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
|
||
>
|
||
<MessageSquarePlus size={14} /> گفتگوی تازه
|
||
</button>
|
||
)}
|
||
</div>
|
||
</ScreenHeader>
|
||
|
||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||
<Bubble role="assistant">
|
||
سلام {profile.name || 'دوستِ من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدمبهقدم با هم حلش میکنیم.
|
||
</Bubble>
|
||
|
||
{empty && chat.historyLoaded && (
|
||
<div className="flex flex-wrap gap-2 pr-10">
|
||
{STARTERS.map((s) => (
|
||
<button key={s} type="button" onClick={() => void send(s)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
|
||
{s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{chat.messages.map((m, i) => (
|
||
<Message key={m.localId} message={m} last={i === chat.messages.length - 1} busy={busy} onRetry={retry} onFollowUp={(q) => void send(q)} />
|
||
))}
|
||
<div ref={listEnd} />
|
||
</div>
|
||
|
||
<form
|
||
className="flex-shrink-0 border-t border-line bg-white px-4 py-3"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void send(input);
|
||
}}
|
||
>
|
||
<div className="flex items-end gap-2">
|
||
<textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value.slice(0, 1000))}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||
e.preventDefault();
|
||
void send(input);
|
||
}
|
||
}}
|
||
rows={1}
|
||
// جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپبهراست میماند. خطی که با
|
||
// فارسی شروع شود راستبهچپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابهجا دیده شود؛
|
||
// در textarea نمیشود تکهای را قفل کرد. پیامِ فرستادهشده از MathText رد میشود.
|
||
dir={input ? 'auto' : 'rtl'}
|
||
style={{ unicodeBidi: 'plaintext', textAlign: 'right' }}
|
||
placeholder="سوالت رو بنویس…"
|
||
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
aria-label="ارسال"
|
||
disabled={!input.trim() || busy}
|
||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-violet text-white transition-opacity disabled:opacity-40"
|
||
>
|
||
<Send size={16} className="-scale-x-100" />
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
|
||
const mine = role === 'user';
|
||
const companion = useCompanion().char;
|
||
return (
|
||
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
|
||
{!mine && (
|
||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||
<SeyarMascot char={companion} mood="happy" size={32} />
|
||
</span>
|
||
)}
|
||
<div
|
||
className={`max-w-[82%] whitespace-pre-line break-words rounded-2xl px-4 py-3 text-sm leading-[1.95] ${
|
||
mine ? 'rounded-tl-sm bg-coral text-white' : 'rounded-tr-sm border border-line bg-white text-ink shadow-sm'
|
||
}`}
|
||
>
|
||
{children}
|
||
{extra}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* منتظرِ جوابِ دستیار. سرویسِ مدل کلِ جواب را نگه میدارد و یکجا میفرستد؛ جوابِ
|
||
* کامل با شکل در ۲۰۲۶-۰۹-۲۹ حدودِ ۳۰ ثانیه طول کشید. پیامِ ثابت در آن مدت شبیهِ
|
||
* گیر کردن بود، پس متن با زمان جلو میرود. زمانها تقریبیاند و به مرحلهی واقعیِ
|
||
* سرور وصل نیستند.
|
||
*/
|
||
const THINKING_STEPS: [number, string][] = [
|
||
[0, '{name} داره تو کتابت میگرده'],
|
||
[4, '{name} داره حلش میکنه'],
|
||
[15, 'داره قدمها رو مرتب مینویسه'],
|
||
[28, 'جوابِ کامل یه کم طول میکشه، الان میرسه'],
|
||
];
|
||
|
||
function Thinking() {
|
||
const [seconds, setSeconds] = useState(0);
|
||
useEffect(() => {
|
||
const started = Date.now();
|
||
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
const companion = useCompanion();
|
||
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
|
||
<SeyarMascot char={companion.char} mood="idle" size={32} />
|
||
</span>
|
||
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
|
||
{text}
|
||
{[0, 1, 2].map((i) => (
|
||
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Message({
|
||
message, last, busy, onRetry, onFollowUp,
|
||
}: {
|
||
message: ChatMessage;
|
||
last: boolean;
|
||
busy: boolean;
|
||
onRetry: (m: ChatMessage) => void;
|
||
onFollowUp: (q: string) => void;
|
||
}) {
|
||
const toast = useToast();
|
||
const nav = useNav();
|
||
|
||
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
|
||
|
||
if (!message.text) return <Thinking />;
|
||
|
||
async function rate(helpful: boolean) {
|
||
if (!message.messageId || message.feedback) return;
|
||
askStore.update(message.localId, { feedback: helpful ? 'up' : 'down' });
|
||
try {
|
||
await api.ai.feedback(message.messageId, helpful);
|
||
toast(helpful ? 'ممنون! 🙂' : 'ممنون که گفتی؛ بهترش میکنیم.', 'success');
|
||
} catch {
|
||
askStore.update(message.localId, { feedback: undefined });
|
||
toast('نظرت ثبت نشد', 'error');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
|
||
<MathText text={message.text} />
|
||
</Bubble>
|
||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||
{message.quotaReached ? (
|
||
<button type="button" onClick={() => nav.switchTab('subscription')} className="rounded-full bg-violet px-3 py-1 text-xs font-bold text-white">
|
||
خریدِ بستهی سوال
|
||
</button>
|
||
) : message.failedQuestion ? (
|
||
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
||
<RotateCw size={12} /> دوباره امتحان کن
|
||
</button>
|
||
) : (
|
||
<>
|
||
{message.usedChunks && message.usedChunks.length > 0 && (
|
||
<span className="flex items-center gap-1 text-[10px] font-bold text-mint" title={message.usedChunks.map((c) => c.title).join('، ')}>
|
||
<BookOpen size={11} /> از روی کتاب
|
||
</span>
|
||
)}
|
||
{message.messageId && (
|
||
<span className="mr-auto flex gap-1">
|
||
<button type="button" aria-label="مفید بود" onClick={() => void rate(true)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'up' ? 'bg-green-100 text-green-600' : 'text-faint'}`}>
|
||
<ThumbsUp size={13} />
|
||
</button>
|
||
<button type="button" aria-label="مفید نبود" onClick={() => void rate(false)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'down' ? 'bg-red-100 text-red-500' : 'text-faint'}`}>
|
||
<ThumbsDown size={13} />
|
||
</button>
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{last && !busy && message.followUps && message.followUps.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 pr-10">
|
||
{message.followUps.map((f) => (
|
||
<button key={f} type="button" onClick={() => onFollowUp(f)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
|
||
{f}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* شمارنده در سربرگِ دستیار: سوالهای رایگانِ باقیِ امروز و سوالهای خریدهشده، هر کدام جدا و
|
||
* درشت، تا بچه پیش از پرسیدن بداند چقدر دارد. زدن روی دومی صفحهی بستهها را باز میکند.
|
||
*/
|
||
function QuotaPills({ free, credits }: { free: number; credits: number }) {
|
||
const nav = useNav();
|
||
return (
|
||
<>
|
||
<span className={`rounded-full px-3 py-1.5 text-xs font-black ${free > 0 ? 'bg-white text-[#2E7D5B]' : 'bg-white/15 text-white/80'}`}>
|
||
{free > 0 ? <>{toFa(free)} سوالِ رایگانِ امروز</> : 'رایگانِ امروز تموم شد'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => nav.switchTab('subscription')}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-black ${credits > 0 ? 'bg-white text-violet' : 'bg-white/15 text-white'}`}
|
||
>
|
||
{credits > 0 ? <>{toFa(credits)} سوالِ خریدهشده</> : '+ خریدِ سوال'}
|
||
</button>
|
||
</>
|
||
);
|
||
}
|