Files
MathApp_frontend/src/screens/AskScreen.tsx
AliReza 8a61cb25b8 The selected companion everywhere, each with a small trait; next-APK notes
- state/companion.ts: one store for the chosen companion (also in localStorage so splash/login show it).
  BrandMark (the orange «π» tile) now shows the companion's symbol (Σ Δ π ω) in its colour; screen-header
  mascots, loaders and empty/error states default to it; the assistant is «از {name} بپرس» with its face,
  colour and wording; the shop, lesson and quiz «ask» buttons name it; the active tab takes its tint.
- Traits, all client-side and light: Σ sigma keeps a running tally of correct answers in the quiz header;
  Δ delta shows a flame «×N» chip and an extra buzz for answers in a row; π pi gives a short solving tip
  after a wrong answer; ω omega throws gold confetti at the end of every island whatever the score.
  Each companion's «correct» sound is pitched differently; confetti uses its colours. Characters screen
  lists each trait.
- android/README.md: what MainActivity.kt has that the installed APK lacks (vibrate bridge, media gesture,
  in-app gateway + paysInApp), what to check on a phone after building, signing owner, Bazaar policy note.

Checked in headless Chrome with Sigma, Delta and Omega selected (home, assistant, shop, characters, quiz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:51:19 +03:30

374 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
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 } = useAppData();
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,
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>
</>
);
}