feat(assessment & chat): polish PCQ-24 mobile result view, typography hierarchy, and anchored chat composer

This commit is contained in:
ar.badiei
2026-10-18 01:08:09 +03:30
parent d2a52a2958
commit 4d734f9047
39 changed files with 3317 additions and 313 deletions
@@ -0,0 +1,327 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
DIMENSION_CONFIGS,
DIMENSION_ORDER,
type DimensionConfig,
getDescriptiveTitleForScore,
} from '@/config/resultImages';
import { DimensionCard } from './DimensionCard';
import { SmoothTextAppearance } from '@/components/motion';
import type { AssessmentSubmission } from '@/services/assessmentService';
import type { DimensionName } from '@/lib/assessment/scoring/dimensions';
import { cn } from '@/lib/utils';
import { MOTION_EASE } from '@/lib/motion';
export interface AssessmentResultViewProps {
phase: 'pre' | 'post';
currentSubmission: AssessmentSubmission;
baselineSubmission?: AssessmentSubmission | null;
onContinue?: () => void;
continueLabel?: string;
showActions?: boolean;
}
export function AssessmentResultView({
phase,
currentSubmission,
baselineSubmission,
onContinue,
continueLabel = 'مشاهده آموزشها و تمرینها',
showActions = true,
}: AssessmentResultViewProps) {
const navigate = useNavigate();
const isPre = phase === 'pre';
const scores = currentSubmission.scores;
const baselineScores = baselineSubmission?.scores;
// Active step: 0, 1, 2, 3 = dimension steps; 4 = final summary screen
const [currentStepIndex, setCurrentStepIndex] = useState<number>(0);
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
// Track revealed status for each dimension so it's preserved on back/forward
const [revealed, setRevealed] = useState<Record<DimensionName, boolean>>({
efficacy: false,
hope: false,
resilience: false,
optimism: false,
});
const totalScore = scores.total;
const maxTotalScore = 144;
const totalPercent = Math.round(
(Math.max(0, Math.min(maxTotalScore, totalScore)) / maxTotalScore) * 100
);
const hasComparison = Boolean(
!isPre && baselineScores && typeof baselineScores.total === 'number'
);
const totalDelta = hasComparison ? totalScore - (baselineScores?.total ?? 0) : 0;
const currentDimensionKey = DIMENSION_ORDER[currentStepIndex];
const isSummaryStep = currentStepIndex === 4;
const handleRevealCurrent = () => {
if (currentDimensionKey) {
setRevealed((prev) => ({ ...prev, [currentDimensionKey]: true }));
}
};
const handleNext = () => {
if (isTransitioning) return;
setIsTransitioning(true);
setCurrentStepIndex((prev) => Math.min(prev + 1, 4));
window.setTimeout(() => setIsTransitioning(false), 220);
};
const handlePrev = () => {
if (isTransitioning) return;
setIsTransitioning(true);
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
window.setTimeout(() => setIsTransitioning(false), 220);
};
const handleJumpToStep = (index: number) => {
if (isTransitioning) return;
const targetDimension = DIMENSION_ORDER[index];
if (
index < currentStepIndex ||
(index <= 3 && targetDimension && revealed[targetDimension]) ||
index === currentStepIndex
) {
setIsTransitioning(true);
setCurrentStepIndex(index);
window.setTimeout(() => setIsTransitioning(false), 220);
}
};
const handleFinalContinue = () => {
if (onContinue) {
onContinue();
} else if (isPre) {
navigate('/chat');
} else {
navigate('/course-status');
}
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-[430px] flex-1 flex-col" dir="rtl">
{/* 1. Low-Height, Minimal 4-Segment Progress Indicator */}
<div className="shrink-0 space-y-1.5 px-1 pb-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{isSummaryStep ? 'جمع‌بندی نهایی' : `مؤلفه ${currentStepIndex + 1} از ۴`}
</span>
<span>
{isSummaryStep
? 'کارنامه نهایی'
: DIMENSION_CONFIGS[currentDimensionKey]?.titleFa}
</span>
</div>
{/* Clean, low-height segment bar */}
<div className="grid grid-cols-4 gap-1.5">
{DIMENSION_ORDER.map((dimKey, idx) => {
const isCompleted =
revealed[dimKey] && (currentStepIndex > idx || isSummaryStep);
const isCurrent = currentStepIndex === idx;
return (
<button
key={dimKey}
type="button"
onClick={() => handleJumpToStep(idx)}
disabled={!revealed[dimKey] && idx > currentStepIndex}
className={cn(
'h-1.5 rounded-full transition-all duration-300 focus:outline-none',
isCurrent
? 'bg-sky-500 dark:bg-sky-400'
: isCompleted
? 'bg-sky-500/50 hover:bg-sky-500 dark:bg-sky-400/50'
: 'bg-slate-200/80 dark:bg-slate-800'
)}
aria-label={DIMENSION_CONFIGS[dimKey]?.titleFa}
/>
);
})}
</div>
</div>
{/* 2. Main Content (Dimension Card OR Final Summary) */}
<div className="flex-1 min-h-0 flex flex-col">
<AnimatePresence mode="wait" initial={false}>
{!isSummaryStep && currentDimensionKey ? (
/* Step-by-Step Dimension Card */
<DimensionCard
key={currentDimensionKey}
config={DIMENSION_CONFIGS[currentDimensionKey]}
score={scores[currentDimensionKey]}
baselineScore={
hasComparison ? baselineScores?.[currentDimensionKey] : undefined
}
stepNumber={currentStepIndex + 1}
totalSteps={4}
isRevealed={revealed[currentDimensionKey]}
onReveal={handleRevealCurrent}
onNext={handleNext}
onPrev={currentStepIndex > 0 ? handlePrev : undefined}
isLastDimension={currentStepIndex === 3}
/>
) : (
/* Final Summary Screen */
<motion.div
key="final-summary"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.22, ease: MOTION_EASE }}
style={{ willChange: 'opacity, transform' }}
className="flex h-full min-h-0 flex-1 flex-col"
>
{/* Main Summary Glass Card */}
<div className="glass-panel flex h-full min-h-0 flex-1 flex-col justify-between overflow-y-auto space-y-4 rounded-3xl border border-white/50 p-4 sm:p-5 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
{/* Header Title */}
<div className="text-center sm:text-right">
<h1 className="text-xl font-bold tracking-tight text-foreground sm:text-2xl">
{isPre
? 'کارنامه اولیه سرمایه روان‌شناختی'
: 'کارنامه نهایی دوره نویا'}
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
خلاصه وضعیت چهار مؤلفه شما
</p>
</div>
{/* Total Score Section */}
<div className="space-y-1.5 rounded-2xl bg-sky-500/10 p-4 dark:bg-sky-500/15">
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-sky-800 dark:text-sky-300">
نمره کل آزمون:
</span>
<div className="flex items-baseline gap-1 font-bold tabular-nums">
<span className="text-2xl font-black text-sky-900 dark:text-sky-100 sm:text-3xl">
{totalScore.toLocaleString('fa-IR')}
</span>
<span className="text-xs text-muted-foreground">از ۱۴۴</span>
</div>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
<div
className="h-full rounded-full bg-neon-gradient transition-all duration-600"
style={{ width: `${totalPercent}%` }}
/>
</div>
{hasComparison && baselineScores && (
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>
پیش‌آزمون: {baselineScores.total.toLocaleString('fa-IR')} ➔
پس‌آزمون: {totalScore.toLocaleString('fa-IR')}
</span>
<span
className={
totalDelta > 0
? 'font-bold text-emerald-600 dark:text-emerald-400'
: totalDelta < 0
? 'font-bold text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
}
>
{totalDelta > 0
? `+${totalDelta.toLocaleString('fa-IR')} رشد کل`
: totalDelta < 0
? `${totalDelta.toLocaleString('fa-IR')} تغییر`
: 'بدون تغییر'}
</span>
</div>
)}
</div>
{/* 4-Dimension Comparative Bars (Simple & Readable) */}
<div className="space-y-2.5 pt-1">
{DIMENSION_ORDER.map((dimKey) => {
const config: DimensionConfig = DIMENSION_CONFIGS[dimKey];
const dimScore = scores[dimKey];
const dimPercent = Math.round((dimScore / 36) * 100);
return (
<div key={dimKey} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-foreground">
{config.titleFa}
</span>
<span className="text-[11px] text-muted-foreground">
{getDescriptiveTitleForScore(dimKey, dimScore)}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${dimPercent}%`,
backgroundColor: config.accentColor,
}}
/>
</div>
</div>
);
})}
</div>
{/* Non-diagnostic notice */}
<p className="text-center text-xs text-muted-foreground">
این نتیجه بازتاب پاسخ‌های فعلی شماست و تشخیص قطعی روان‌شناختی نیست.
</p>
{/* Mandatory Closing Text */}
<SmoothTextAppearance contentKey="final-summary-closing" distance={5} duration={0.22}>
<div className="rounded-2xl bg-white/50 p-4 text-xs leading-7 text-foreground/90 dark:bg-slate-800/40 sm:text-sm sm:leading-8">
<p className="whitespace-pre-line text-body">
حالا تصویر روشن‌تری از چهار مؤلفه سرمایه روان‌شناختی خودت داری.
{'\n\n'}
این نتیجه، نقطه شروعیه برای شناخت بهتر خودت؛ نه تعریف کامل شخصیت تو.
{'\n\n'}
برای آشنایی بیشتر با این مؤلفه‌ها و تقویت اون‌ها، پیشنهاد می‌کنیم آموزش‌های نویا رو ببینی و تمرین‌های مرتبط رو انجام بدی.
</p>
</div>
</SmoothTextAppearance>
{/* Single Prominent Button */}
{showActions && (
<div className="space-y-2 pt-1">
<Button
type="button"
variant="neon"
size="lg"
onClick={handleFinalContinue}
className="w-full gap-2 font-bold shadow-md"
>
<span>{continueLabel}</span>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* Subtle Secondary Back Control */}
<div className="text-center">
<button
type="button"
onClick={() => setCurrentStepIndex(0)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
مرور دوباره مؤلفه‌ها
</button>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, Zap } from 'lucide-react';
import {
type DimensionConfig,
getFeedbackForScore,
getDescriptiveTitleForScore,
} from '@/config/resultImages';
import { Button } from '@/components/ui/button';
import { SmoothTextAppearance } from '@/components/motion';
import { MOTION_EASE } from '@/lib/motion';
export interface DimensionCardProps {
config: DimensionConfig;
score: number;
baselineScore?: number | null;
stepNumber: number; // 1 to 4
totalSteps?: number; // default 4
isRevealed: boolean;
onReveal: () => void;
onNext: () => void;
onPrev?: () => void;
isLastDimension?: boolean;
}
export function DimensionCard({
config,
score,
baselineScore,
isRevealed,
onReveal,
onNext,
onPrev,
isLastDimension = false,
}: DimensionCardProps) {
const [imgSrc, setImgSrc] = useState<string>(config.path);
const [hasImageError, setHasImageError] = useState(false);
// Sync image source when dimension changes
useEffect(() => {
setImgSrc(config.path);
setHasImageError(false);
}, [config.path]);
// Range for PCQ-24 dimension is 6 to 36
const minScore = 6;
const maxScore = 36;
const clampedScore = Math.max(minScore, Math.min(maxScore, score));
// Position percentage along the 6-36 possible span
const positionPercent = Math.round(
((clampedScore - minScore) / (maxScore - minScore)) * 100
);
const hasComparison =
typeof baselineScore === 'number' && !Number.isNaN(baselineScore);
const delta = hasComparison ? score - baselineScore : 0;
const descriptiveTitle = getDescriptiveTitleForScore(config.dimension, score);
const feedbackText = getFeedbackForScore(config.dimension, score);
const handleImageError = () => {
if (imgSrc !== config.fallbackPath) {
setImgSrc(config.fallbackPath);
} else {
setHasImageError(true);
}
};
return (
<motion.div
key={config.dimension}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.22, ease: MOTION_EASE }}
style={{ willChange: 'opacity, transform' }}
className="relative flex h-full min-h-0 flex-1 flex-col justify-between rounded-3xl border border-white/45 bg-white/70 p-4 shadow-sm backdrop-blur-md transition-all sm:p-5 dark:border-white/10 dark:bg-slate-900/65 dark:backdrop-blur-md overflow-hidden"
dir="rtl"
>
{/* 1. Dimension Header (Strong Title & Distinct Tagline with standard vertical spacing) */}
<div className="shrink-0 text-center sm:text-right">
<h2 className="text-xl font-black tracking-tight text-foreground sm:text-2xl">
{config.titleFa}
</h2>
<p className="mt-1.5 text-xs font-normal text-muted-foreground/80 sm:text-sm">
{config.tagline}
</p>
</div>
{/* 2. Focused Large Illustration (Generous size, object-cover completely fitting the frame) */}
<div className="my-auto flex w-full flex-1 min-h-0 items-center justify-center py-2 sm:py-3">
<div className="relative mx-auto flex aspect-[1122/1402] h-full max-h-[30vh] min-h-[175px] w-auto max-w-[285px] sm:max-w-[315px] items-center justify-center overflow-hidden rounded-2xl border border-white/40 bg-white/25 shadow-sm backdrop-blur-xs dark:border-white/10 dark:bg-slate-800/35">
{!hasImageError ? (
<img
src={imgSrc}
alt={config.altText}
onError={handleImageError}
className="h-full w-full object-cover object-center"
loading="eager"
decoding="async"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center">
<Zap
className="h-8 w-8 opacity-70"
style={{ color: config.accentColor }}
aria-hidden
/>
<span className="text-xs font-semibold text-foreground">
{config.titleFa}
</span>
</div>
)}
</div>
</div>
{/* 3. Action / Result Area */}
<AnimatePresence mode="wait" initial={false}>
{!isRevealed ? (
/* Initial Unrevealed State */
<motion.div
key="unrevealed"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex flex-col items-center gap-3 pt-2 text-center shrink-0"
>
<p className="max-w-xs text-xs leading-5 text-muted-foreground sm:text-sm">
{config.inviteText}
</p>
{/* Single Prominent Button */}
<Button
type="button"
variant="neon"
size="lg"
onClick={onReveal}
className="h-11 sm:h-12 w-full font-bold shadow-md sm:max-w-sm"
>
نمایش نتیجه من
</Button>
{/* Subtle Secondary Back Control */}
{onPrev && (
<button
type="button"
onClick={onPrev}
className="py-1 text-xs text-muted-foreground/75 transition-colors hover:text-foreground"
>
بازگشت به مؤلفه قبلی
</button>
)}
</motion.div>
) : (
/* Revealed Result State */
<div
key="revealed"
className="flex flex-col shrink-0"
>
{/* 6. Simple Score Status Display (Unified Card: Title + Continuum + Raw Score) */}
<SmoothTextAppearance
contentKey={`score-status-${config.dimension}-${score}`}
distance={4}
duration={0.2}
>
<div className="rounded-2xl border border-sky-400/20 bg-sky-500/10 px-3.5 py-2.5 text-right dark:border-sky-400/10 dark:bg-sky-400/10">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-muted-foreground">وضعیت:</span>
<h3 className="text-xs font-bold text-foreground sm:text-sm">
{descriptiveTitle}
</h3>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground tabular-nums">
<span>نمره:</span>
<span className="font-bold text-foreground">
{score.toLocaleString('fa-IR')} از ۳۶
</span>
</div>
</div>
{/* Continuum Visual Range Bar */}
<div className="mt-2 space-y-1">
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${positionPercent}%` }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="h-full rounded-full"
style={{
backgroundColor: config.accentColor,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground/75">
<span>نقطه شروع</span>
<span>در حال تقویت</span>
<span>نقطه اتکای پررنگ</span>
</div>
</div>
{/* Optional Baseline Delta Comparison */}
{hasComparison && (
<div className="mt-1.5 flex items-center justify-between border-t border-sky-400/15 pt-1.5 text-[10px] text-muted-foreground dark:border-sky-400/10">
<span>تغییر نسبت به پیش‌آزمون:</span>
<span
className={
delta > 0
? 'font-semibold text-emerald-600 dark:text-emerald-400'
: delta < 0
? 'font-semibold text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
}
>
{delta > 0
? `+${delta.toLocaleString('fa-IR')} (از ${baselineScore?.toLocaleString('fa-IR')} به ${score.toLocaleString('fa-IR')})`
: delta < 0
? `${delta.toLocaleString('fa-IR')} (از ${baselineScore?.toLocaleString('fa-IR')} به ${score.toLocaleString('fa-IR')})`
: 'بدون تغییر'}
</span>
</div>
)}
</div>
</SmoothTextAppearance>
{/* 7. Prepared Feedback Text with standard vertical spacing */}
<div className="mt-3.5 sm:mt-4 w-full">
<SmoothTextAppearance
contentKey={`feedback-${config.dimension}-${score}`}
distance={5}
duration={0.22}
>
<p className="text-xs leading-6 text-foreground/90 sm:text-sm sm:leading-7">
{feedbackText}
</p>
</SmoothTextAppearance>
</div>
{/* 8. Single Prominent Button with standard vertical spacing */}
<div className="mt-4 sm:mt-5">
<Button
type="button"
variant="neon"
size="lg"
onClick={onNext}
className="h-11 sm:h-12 w-full gap-2 font-bold shadow-md"
>
<span>{isLastDimension ? 'دیدن جمع‌بندی' : 'مؤلفه بعدی'}</span>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
{/* 9. Subtle Secondary Back Control */}
{onPrev && (
<div className="mt-2 text-center">
<button
type="button"
onClick={onPrev}
className="py-1 text-xs text-muted-foreground/75 transition-colors hover:text-foreground"
>
بازگشت به مؤلفه قبلی
</button>
</div>
)}
</div>
)}
</AnimatePresence>
</motion.div>
);
}
+429 -132
View File
@@ -17,27 +17,69 @@ type MessageBubbleProps = {
onStreamComplete?: (messageId: string) => void;
};
type ContentBlock =
/**
* Standardized typography scale & tokens for chat content.
* Follows Noya's design system & ChatGPT-like reading comfort.
*/
export const CHAT_TYPOGRAPHY = {
body: 'chat-prose break-words text-[15px] sm:text-base font-normal leading-[1.85] sm:leading-[1.9] text-foreground text-right',
h1: 'block w-full text-right text-[22px] sm:text-2xl font-bold leading-[1.45] text-foreground mt-6 mb-3 first:mt-1',
h2: 'block w-full text-right text-[19px] sm:text-[20px] font-bold leading-[1.5] text-foreground mt-5 mb-2.5 first:mt-1',
h3: 'block w-full text-right text-[17px] sm:text-[18px] font-bold leading-[1.5] text-foreground mt-4 mb-2 first:mt-1',
bold: 'font-bold text-foreground',
italic: 'italic text-foreground/90 font-normal',
subtext: 'text-xs sm:text-[13px] text-muted-foreground',
blockquote:
'border-r-[3px] border-sky-500/50 dark:border-sky-400/40 pr-3.5 pl-2.5 py-1.5 my-3 text-muted-foreground/95 bg-sky-500/5 dark:bg-sky-400/5 rounded-l-md text-[14.5px] sm:text-[15px] leading-[1.85] text-right',
codeInline:
'px-1.5 py-0.5 rounded-md bg-muted/80 text-foreground font-mono text-[13px] sm:text-sm border border-border/60 mx-1 dir-ltr inline-block align-baseline',
codeBlock:
'my-3 p-3.5 rounded-xl bg-slate-900 text-slate-100 dark:bg-slate-950/90 border border-white/10 font-mono text-xs sm:text-sm overflow-x-auto dir-ltr text-left leading-6',
hr: 'my-4 border-t border-border/60',
link: 'text-sky-600 dark:text-sky-400 font-medium underline underline-offset-4 hover:opacity-80 transition-opacity',
list: 'space-y-2.5 text-[15px] sm:text-base leading-[1.85] text-foreground my-2.5',
table: 'w-full border-collapse text-right text-sm leading-6 my-2',
tableHeader: 'border border-border bg-muted/60 px-3 py-2 font-bold text-foreground',
tableCell: 'border border-border px-3 py-2 text-foreground/90',
} as const;
export type ContentBlock =
| { type: 'p'; text: string }
| { type: 'heading'; text: string }
| { type: 'list'; ordered: boolean; items: string[]; markers?: string[]; delimiters?: string[] }
| { type: 'table'; headers: string[]; rows: string[][] };
| { type: 'heading'; level: 1 | 2 | 3; text: string }
| {
type: 'list';
ordered: boolean;
items: { text: string; depth: number }[];
markers?: string[];
delimiters?: string[];
}
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'blockquote'; text: string }
| { type: 'codeblock'; language?: string; code: string }
| { type: 'hr' };
const stripImageMarkdown = (text: string): string =>
text.replace(/!\[[^\]]*]\([^)]*\)/g, '').replace(/!\[[^\]]*]/g, '');
/** Hide leftover markdown/chat markers that must never appear on screen. */
const stripVisibleMarkupArtifacts = (text: string): string =>
const cleanSystemArtifacts = (text: string): string =>
text
.replace(/^#{1,6}\s+/gm, '')
.replace(/(^|\s)#{1,6}(?=\s|$)/gm, '$1')
.replace(/@[\w.\u0600-\u06FF-]*/g, '')
.replace(/```+/g, '')
.replace(/[ \t]{2,}/g, ' ');
/** Render inline markdown; during streaming hide incomplete ** and apply bold early. */
const isSafeUrl = (url: string): boolean => {
const trimmed = url.trim().toLowerCase();
return (
trimmed.startsWith('https://') ||
trimmed.startsWith('http://') ||
trimmed.startsWith('/') ||
trimmed.startsWith('#') ||
trimmed.startsWith('mailto:')
);
};
/** Render inline markdown; during streaming hide incomplete ** / ` and apply bold early. */
const renderInline = (text: string, isStreaming = false): ReactNode[] => {
const cleaned = stripVisibleMarkupArtifacts(stripImageMarkdown(text));
const cleaned = cleanSystemArtifacts(stripImageMarkdown(text));
if (!isStreaming) {
return renderInlineComplete(cleaned);
}
@@ -45,16 +87,69 @@ const renderInline = (text: string, isStreaming = false): ReactNode[] => {
};
const renderInlineComplete = (text: string): ReactNode[] => {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
// Tokenize Links, Bold, Inline code, Italics, and plain text
const TOKEN_RE =
/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`|(?:\s|^)\*[^*\s][^*]*\*(?=\s|$)|(?:\s|^)_[^_\s][^_]*_(?=\s|$))/g;
const parts = text.split(TOKEN_RE);
return parts.flatMap((part, index): ReactNode[] => {
const bold = part.match(/^\*\*([^*]+)\*\*$/);
if (bold) {
if (!part) return [];
// 1. Link: [label](url)
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (linkMatch) {
const [, label = '', url = ''] = linkMatch;
if (isSafeUrl(url)) {
return [
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className={CHAT_TYPOGRAPHY.link}
>
{renderWithNums(label, `${index}-l`)}
</a>,
];
}
return renderWithNums(label, `${index}-l`);
}
// 2. Bold: **text**
const boldMatch = part.match(/^\*\*([^*]+)\*\*$/);
if (boldMatch) {
return [
<strong key={index} className="font-semibold text-foreground">
{renderWithNums(bold[1] ?? '', `${index}-b`)}
<strong key={index} className={CHAT_TYPOGRAPHY.bold}>
{renderWithNums(boldMatch[1] ?? '', `${index}-b`)}
</strong>,
];
}
// 3. Inline code: `code`
const codeMatch = part.match(/^`([^`]+)`$/);
if (codeMatch) {
return [
<code key={index} className={CHAT_TYPOGRAPHY.codeInline}>
{codeMatch[1]}
</code>,
];
}
// 4. Italic: *text* or _text_
const italicMatch =
part.match(/^(\s?)\*([^*]+)\*(\s?)$/) || part.match(/^(\s?)_([^_]+)_(\s?)$/);
if (italicMatch) {
const [, leading = '', inner = '', trailing = ''] = italicMatch;
return [
leading,
<em key={index} className={CHAT_TYPOGRAPHY.italic}>
{renderWithNums(inner, `${index}-i`)}
</em>,
trailing,
];
}
// 5. Plain text with localized number rendering
return renderWithNums(part, String(index));
});
};
@@ -65,43 +160,75 @@ const renderInlineStreaming = (text: string): ReactNode[] => {
let key = 0;
while (i < text.length) {
const open = text.indexOf('**', i);
if (open === -1) {
nodes.push(...renderWithNums(text.slice(i), String(key++)));
break;
}
const openBold = text.indexOf('**', i);
const openCode = text.indexOf('`', i);
if (open > i) {
nodes.push(...renderWithNums(text.slice(i, open), String(key++)));
}
const close = text.indexOf('**', open + 2);
if (close === -1) {
// Opening ** without closing yet — bold weight, no visible asterisks
const pending = text.slice(open + 2);
if (pending) {
nodes.push(
<strong key={key++} className="font-semibold text-foreground">
{renderWithNums(pending, `${key}-pending`)}
</strong>
);
// Pick whichever marker appears earliest
if (openBold !== -1 && (openCode === -1 || openBold < openCode)) {
if (openBold > i) {
nodes.push(...renderWithNums(text.slice(i, openBold), String(key++)));
}
break;
const closeBold = text.indexOf('**', openBold + 2);
if (closeBold === -1) {
// Pending bold chunk
const pending = text.slice(openBold + 2);
if (pending) {
nodes.push(
<strong key={key++} className={CHAT_TYPOGRAPHY.bold}>
{renderWithNums(pending, `${key}-pending-b`)}
</strong>
);
}
break;
}
nodes.push(
<strong key={key++} className={CHAT_TYPOGRAPHY.bold}>
{renderWithNums(text.slice(openBold + 2, closeBold), `${key}-b`)}
</strong>
);
i = closeBold + 2;
continue;
}
const boldContent = text.slice(open + 2, close);
nodes.push(
<strong key={key++} className="font-semibold text-foreground">
{renderWithNums(boldContent, `${key}-b`)}
</strong>
);
i = close + 2;
if (openCode !== -1) {
if (openCode > i) {
nodes.push(...renderWithNums(text.slice(i, openCode), String(key++)));
}
const closeCode = text.indexOf('`', openCode + 1);
if (closeCode === -1) {
// Pending code chunk
const pending = text.slice(openCode + 1);
if (pending) {
nodes.push(
<code key={key++} className={CHAT_TYPOGRAPHY.codeInline}>
{pending}
</code>
);
}
break;
}
nodes.push(
<code key={key++} className={CHAT_TYPOGRAPHY.codeInline}>
{text.slice(openCode + 1, closeCode)}
</code>
);
i = closeCode + 1;
continue;
}
// Neither found, output remaining text
nodes.push(...renderWithNums(text.slice(i), String(key++)));
break;
}
return nodes;
};
/** Keep digits opaque under RTL justify — only wrap digits, leave text as plain nodes. */
/** Keep digits opaque under RTL — isolate with dir=ltr and tabular numbers. */
const renderWithNums = (text: string, keyPrefix: string): ReactNode[] => {
const chunks = text.split(/(\d+|[۰-۹]+|[٠-٩]+)/g);
return chunks.flatMap((chunk, i): ReactNode[] => {
@@ -132,37 +259,63 @@ const isTableLine = (line: string): boolean =>
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
/** Western, Persian, or Arabic-Indic digits + . or ) */
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))([.)])\s+(\S.*)$/;
const ORDERED_ITEM_RE = /^(\s*)((?:\d+|[۰-۹]+|[٠-٩]+))([.)])\s+(\S.*)$/;
const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line);
const BULLET_ITEM_RE = /^(\s*)(?:[-•*])\s+(\S.*)$/;
const parseOrderedItem = (line: string): { marker: string; delimiter: string; text: string } | null => {
const isBulletItem = (line: string): boolean => BULLET_ITEM_RE.test(line);
const parseOrderedItem = (
line: string
): { marker: string; delimiter: string; text: string; indent: number } | null => {
const match = line.match(ORDERED_ITEM_RE);
if (!match) return null;
return { marker: match[1] ?? '', delimiter: match[2] ?? '.', text: match[3] ?? '' };
const indentLen = match[1]?.length ?? 0;
return {
marker: match[2] ?? '',
delimiter: match[3] ?? '.',
text: match[4] ?? '',
indent: indentLen,
};
};
const stripListMarker = (line: string): string =>
line.replace(/^\s*(?:(?:\d+|[۰-۹]+|[٠-٩]+)[.)]|[-•*])\s+/, '');
const parseBulletItem = (line: string): { text: string; indent: number } | null => {
const match = line.match(BULLET_ITEM_RE);
if (!match) return null;
const indentLen = match[1]?.length ?? 0;
return {
text: match[2] ?? '',
indent: indentLen,
};
};
/** Markdown ## / # headings (stripped before display). */
const isHeadingLine = (line: string): boolean => /^#{1,6}\s*\S/.test(line.trim());
const isHeadingLine = (line: string): boolean => /^#{1,6}\s+\S/.test(line.trim());
const stripHeadingMarker = (line: string): string =>
stripVisibleMarkupArtifacts(line.trim().replace(/^#{1,6}\s*/, ''));
const parseHeadingLine = (line: string): { level: 1 | 2 | 3; text: string } | null => {
const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
if (!match) return null;
const rawLevel = match[1]?.length ?? 1;
const level = Math.min(rawLevel, 3) as 1 | 2 | 3;
return { level, text: match[2]?.trim() ?? '' };
};
const isBlockquoteLine = (line: string): boolean => /^\s*>\s*/.test(line);
const isCodeBlockStart = (line: string): boolean => /^\s*```/.test(line);
const isHrLine = (line: string): boolean => /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line);
const parseBlocks = (text: string): ContentBlock[] => {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const blocks: ContentBlock[] = [];
let i = 0;
// Each non-empty line becomes its own paragraph (ChatGPT-like airy breaks).
const flushParagraph = (buffer: string[]) => {
for (const line of buffer) {
const trimmed = line.trim();
if (trimmed) blocks.push({ type: 'p', text: trimmed });
const trimmed = buffer.map((l) => l.trim()).filter(Boolean).join('\n');
if (trimmed) {
blocks.push({ type: 'p', text: trimmed });
}
};
@@ -174,12 +327,53 @@ const parseBlocks = (text: string): ContentBlock[] => {
continue;
}
// 1. Code Block (```lang)
if (isCodeBlockStart(line)) {
const lang = line.replace(/^\s*```/, '').trim();
const codeLines: string[] = [];
i += 1;
while (i < lines.length) {
const current = lines[i] ?? '';
if (/^\s*```/.test(current)) {
i += 1;
break;
}
codeLines.push(current);
i += 1;
}
blocks.push({ type: 'codeblock', language: lang, code: codeLines.join('\n') });
continue;
}
// 2. Headings (#, ##, ###)
if (isHeadingLine(line)) {
blocks.push({ type: 'heading', text: stripHeadingMarker(line) });
const parsed = parseHeadingLine(line);
if (parsed) {
blocks.push({ type: 'heading', level: parsed.level, text: parsed.text });
}
i += 1;
continue;
}
// 3. Horizontal Rule (---, ***)
if (isHrLine(line)) {
blocks.push({ type: 'hr' });
i += 1;
continue;
}
// 4. Blockquotes (> quote)
if (isBlockquoteLine(line)) {
const quoteLines: string[] = [];
while (i < lines.length && isBlockquoteLine(lines[i] ?? '')) {
quoteLines.push((lines[i] ?? '').replace(/^\s*>\s*/, ''));
i += 1;
}
blocks.push({ type: 'blockquote', text: quoteLines.join('\n') });
continue;
}
// 5. Tables
if (isTableLine(line)) {
const tableLines: string[] = [];
while (i < lines.length && isTableLine(lines[i] ?? '')) {
@@ -197,27 +391,52 @@ const parseBlocks = (text: string): ContentBlock[] => {
continue;
}
// 6. Ordered & Unordered Lists (with multi-line item continuation and nesting)
if (isOrderedItem(line) || isBulletItem(line)) {
const ordered = isOrderedItem(line);
const items: string[] = [];
const items: { text: string; depth: number }[] = [];
const markers: string[] = [];
const delimiters: string[] = [];
while (i < lines.length) {
const current = lines[i] ?? '';
if (!current.trim()) break;
if (ordered ? !isOrderedItem(current) : !isBulletItem(current)) break;
if (ordered) {
const parsed = parseOrderedItem(current);
if (!parsed) break;
// Keep the model's own numbering (۱/۲/۳…) — do not renumber with CSS 1,2,3.
markers.push(parsed.marker);
delimiters.push(parsed.delimiter);
items.push(parsed.text);
const isCurrentOrdered = isOrderedItem(current);
const isCurrentBullet = isBulletItem(current);
if (ordered ? isCurrentOrdered : isCurrentBullet) {
if (ordered) {
const parsed = parseOrderedItem(current);
if (!parsed) break;
const depth = Math.min(Math.floor(parsed.indent / 2), 3);
markers.push(parsed.marker);
delimiters.push(parsed.delimiter);
items.push({ text: parsed.text, depth });
} else {
const parsed = parseBulletItem(current);
if (!parsed) break;
const depth = Math.min(Math.floor(parsed.indent / 2), 3);
items.push({ text: parsed.text, depth });
}
i += 1;
} else if (
items.length > 0 &&
!isHeadingLine(current) &&
!isTableLine(current) &&
!isBlockquoteLine(current) &&
!isCodeBlockStart(current) &&
!isHrLine(current) &&
!(ordered ? isCurrentBullet : isCurrentOrdered)
) {
// Multi-line item continuation: append with newline so it wraps on the text side
items[items.length - 1].text += '\n' + current.trim();
i += 1;
} else {
items.push(stripListMarker(current));
break;
}
i += 1;
}
blocks.push(
ordered
? { type: 'list', ordered: true, items, markers, delimiters }
@@ -226,7 +445,8 @@ const parseBlocks = (text: string): ContentBlock[] => {
continue;
}
const paragraph: string[] = [line];
// 7. Regular Paragraphs
const paragraphLines: string[] = [line];
i += 1;
while (i < lines.length) {
const current = lines[i] ?? '';
@@ -235,14 +455,17 @@ const parseBlocks = (text: string): ContentBlock[] => {
isHeadingLine(current) ||
isTableLine(current) ||
isOrderedItem(current) ||
isBulletItem(current)
isBulletItem(current) ||
isBlockquoteLine(current) ||
isCodeBlockStart(current) ||
isHrLine(current)
) {
break;
}
paragraph.push(current);
paragraphLines.push(current);
i += 1;
}
flushParagraph(paragraph);
flushParagraph(paragraphLines);
}
return blocks;
@@ -268,18 +491,18 @@ export const MessageBubble = ({
onStreamComplete?.(chatMessage.id);
}, [chatMessage.id, onStreamComplete]);
const { displayedText, isStreaming } = useStreamTypewriter({
useStreamTypewriter({
text: fullBody,
enabled: shouldStream,
onProgress: handleProgress,
onComplete: handleComplete,
});
const body = shouldStream ? displayedText : fullBody.trim();
const showCursor = shouldStream && isStreaming;
const isActivelyTyping = shouldStream && isStreaming;
const body = fullBody.trim();
const showCursor = Boolean(isAssistant && chatMessage.status === 'streaming');
const isActivelyTyping = Boolean(isAssistant && chatMessage.status === 'streaming');
// Parse structure while streaming too (ChatGPT-like spacing as text appears).
// Parse structured blocks for rich, scannable reading
const blocks = isAssistant
? body.trim()
? parseBlocks(body)
@@ -318,14 +541,14 @@ export const MessageBubble = ({
})}
</span>
</div>
<div
className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}
>
<div className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}>
{blocks.length > 0 || showCursor ? (
<div className={cn(isAssistant ? 'space-y-4' : 'space-y-2')}>
<div className={cn(isAssistant ? 'space-y-3.5' : 'space-y-2')}>
{(blocks.length > 0 ? blocks : [{ type: 'p' as const, text: '' }]).map((block, index) => {
const isLast = index === Math.max(blocks.length - 1, 0);
const cursor = showCursor && isLast ? <span className="ai-stream-cursor" aria-hidden /> : null;
const cursor =
showCursor && isLast ? <span className="ai-stream-cursor" aria-hidden /> : null;
const blockMotion = isActivelyTyping
? undefined
: {
@@ -334,64 +557,120 @@ export const MessageBubble = ({
transition: { duration: MOTION_DURATION.fast, ease: MOTION_EASE },
};
// 1. Lists (Ordered & Unordered)
if (block.type === 'list') {
const ListTag = block.ordered ? 'ol' : 'ul';
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
<ListTag
className={cn(
'space-y-1.5 text-base leading-7 text-foreground',
block.ordered ? 'list-none pe-0 ps-0' : 'list-disc'
)}
>
{block.items.map((item, itemIndex) => (
<li
key={`${chatMessage.id}-li-${index}-${itemIndex}`}
className={cn(
'chat-prose',
block.ordered && 'flex items-start gap-2'
)}
>
{block.ordered ? (
<>
<span className="chat-num shrink-0 font-semibold select-none">
<span dir="ltr">{block.markers?.[itemIndex] ?? String(itemIndex + 1)}</span>
{block.delimiters?.[itemIndex] ?? '.'}
</span>
<span className="min-w-0 flex-1">
{renderInline(item, isActivelyTyping)}
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
</span>
</>
) : (
<>
{renderInline(item, isActivelyTyping)}
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
</>
)}
</li>
))}
<ListTag className={CHAT_TYPOGRAPHY.list}>
{block.items.map((item, itemIndex) => {
const isLastItem = isLast && itemIndex === block.items.length - 1;
const depthClass =
item.depth === 1
? 'mr-4 sm:mr-5'
: item.depth === 2
? 'mr-8 sm:mr-9'
: item.depth >= 3
? 'mr-12'
: '';
return (
<li
key={`${chatMessage.id}-li-${index}-${itemIndex}`}
className={cn('chat-prose flex items-start', depthClass)}
>
{block.ordered ? (
<>
<span
className="chat-num shrink-0 font-bold select-none text-foreground/90 pl-2 leading-[1.85]"
dir="ltr"
>
<span dir="ltr">
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}
</span>
{block.delimiters?.[itemIndex] ?? '.'}
</span>
<div className="min-w-0 flex-1 whitespace-pre-line leading-[1.85] text-right">
{renderInline(item.text, isActivelyTyping)}
{cursor && isLastItem ? cursor : null}
</div>
</>
) : (
<>
<span
className="shrink-0 select-none text-sky-500/80 dark:text-sky-400/80 text-base leading-[1.85] pl-2.5 font-bold"
aria-hidden
>
•
</span>
<div className="min-w-0 flex-1 whitespace-pre-line leading-[1.85] text-right">
{renderInline(item.text, isActivelyTyping)}
{cursor && isLastItem ? cursor : null}
</div>
</>
)}
</li>
);
})}
</ListTag>
</Wrapper>
);
}
// 2. Blockquote
if (block.type === 'blockquote') {
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
<blockquote className={CHAT_TYPOGRAPHY.blockquote}>
{renderInline(block.text, isActivelyTyping)}
{cursor}
</blockquote>
</Wrapper>
);
}
// 3. Code Block
if (block.type === 'codeblock') {
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
<div className="relative my-3 overflow-hidden rounded-xl border border-white/10 bg-slate-900 shadow-sm dark:bg-slate-950">
{block.language && (
<div className="flex items-center justify-between border-b border-white/10 px-3.5 py-1.5 text-[11px] font-mono text-slate-400 dir-ltr">
<span>{block.language}</span>
</div>
)}
<pre className="p-3.5 overflow-x-auto text-xs sm:text-sm font-mono text-slate-100 dir-ltr text-left leading-6">
<code>{block.code}</code>
</pre>
</div>
</Wrapper>
);
}
// 4. Horizontal Rule
if (block.type === 'hr') {
return <hr key={`${chatMessage.id}-b-${index}`} className={CHAT_TYPOGRAPHY.hr} />;
}
// 5. Table
if (block.type === 'table') {
const Wrapper = isActivelyTyping ? 'div' : motion.div;
return (
<Wrapper
key={`${chatMessage.id}-b-${index}`}
{...blockMotion}
className={isActivelyTyping ? undefined : 'overflow-x-auto'}
className="overflow-x-auto rounded-lg border border-border/80 my-2.5"
>
<table className="w-full border-collapse text-right text-sm leading-6">
<table className={CHAT_TYPOGRAPHY.table}>
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th
key={`${chatMessage.id}-th-${index}-${headerIndex}`}
className="border border-border bg-muted/60 px-2 py-1.5 font-semibold"
className={CHAT_TYPOGRAPHY.tableHeader}
>
{renderInline(header, false)}
</th>
@@ -400,11 +679,14 @@ export const MessageBubble = ({
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={`${chatMessage.id}-tr-${index}-${rowIndex}`}>
<tr
key={`${chatMessage.id}-tr-${index}-${rowIndex}`}
className="even:bg-muted/30"
>
{row.map((cell, cellIndex) => (
<td
key={`${chatMessage.id}-td-${index}-${rowIndex}-${cellIndex}`}
className="border border-border px-2 py-1.5"
className={CHAT_TYPOGRAPHY.tableCell}
>
{renderInline(cell, false)}
{cursor &&
@@ -422,35 +704,49 @@ export const MessageBubble = ({
);
}
// 6. Headings (H1, H2, H3)
if (block.type === 'heading') {
const Tag = block.level === 1 ? 'h1' : block.level === 2 ? 'h2' : 'h3';
const headingClass =
block.level === 1
? CHAT_TYPOGRAPHY.h1
: block.level === 2
? CHAT_TYPOGRAPHY.h2
: CHAT_TYPOGRAPHY.h3;
if (isActivelyTyping) {
const RawTag = Tag;
return (
<h3
key={`${chatMessage.id}-b-${index}`}
className="block w-full text-right text-base font-bold leading-7 text-foreground"
>
<RawTag key={`${chatMessage.id}-b-${index}`} className={headingClass}>
{renderInline(block.text, true)}
{cursor}
</h3>
</RawTag>
);
}
const MotionHeading =
block.level === 1 ? motion.h1 : block.level === 2 ? motion.h2 : motion.h3;
return (
<motion.h3
<MotionHeading
key={`${chatMessage.id}-b-${index}`}
initial={index === 0 ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: MOTION_DURATION.fast, ease: MOTION_EASE }}
className="block w-full text-right text-base font-bold leading-7 text-foreground"
className={headingClass}
>
{renderInline(block.text, false)}
{cursor}
</motion.h3>
</MotionHeading>
);
}
// 7. Paragraph
if (block.type === 'p') {
const proseClass =
'chat-prose break-words text-base leading-7 text-foreground';
const proseClass = cn(
CHAT_TYPOGRAPHY.body,
isOwnMessage && 'chat-user-prose'
);
if (isActivelyTyping) {
return (
@@ -481,6 +777,7 @@ export const MessageBubble = ({
) : (
<p className="text-sm text-muted-foreground">پاسخ خالی دریافت شد.</p>
)}
{chatMessage.status === 'error' && (
<span className="mt-2 block text-[10px] text-red-500 dark:text-red-300">
ارسال ناموفق بود. دوباره تلاش کنید.
@@ -0,0 +1,94 @@
import { forwardRef, type ReactNode } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { MOTION_EASE } from '@/lib/motion';
export interface SmoothTextAppearanceProps {
children: ReactNode;
className?: string;
/** Unique key for the text content to trigger entrance animation once per actual content change */
contentKey?: string | number;
/** Duration in seconds (180ms to 280ms, default 0.22s = 220ms) */
duration?: number;
/** Vertical displacement in pixels (4px to 6px, default 5px) */
distance?: number;
/** If true, skip enter animation completely (e.g. static load or already revealed) */
skipAnimation?: boolean;
}
/**
* SmoothTextAppearance
*
* Fast, natural text entrance inspired by ChatGPT's smooth response rendering:
* - Unified block entrance (no letter-by-letter typewriter delay)
* - 220ms duration (customizable between 180-280ms)
* - Subtle vertical travel of 4-6px (default 5px) combined with opacity
* - Strictly isolated to opacity & transform to avoid layout reflow (CLS)
* - Rock-solid typography & RTL stability
* - Respects prefers-reduced-motion
*/
export const SmoothTextAppearance = forwardRef<HTMLDivElement, SmoothTextAppearanceProps>(
(
{
children,
className,
contentKey,
duration = 0.22,
distance = 5,
skipAnimation = false,
},
ref
) => {
const shouldReduceMotion = useReducedMotion();
// Respect reduced motion: no vertical displacement, minimal soft fade
const effectiveDistance = shouldReduceMotion ? 0 : Math.max(4, Math.min(6, distance));
const effectiveDuration = shouldReduceMotion ? 0.15 : Math.max(0.18, Math.min(0.28, duration));
if (skipAnimation) {
return (
<div
ref={ref}
className={cn('text-right text-foreground dir-rtl will-change-auto', className)}
dir="rtl"
>
{children}
</div>
);
}
return (
<motion.div
ref={ref}
key={contentKey}
initial={{
opacity: 0,
y: effectiveDistance,
}}
animate={{
opacity: 1,
y: 0,
}}
transition={{
duration: effectiveDuration,
ease: MOTION_EASE,
delay: 0,
}}
style={{
willChange: 'opacity, transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
}}
className={cn(
'text-right text-foreground dir-rtl',
className
)}
dir="rtl"
>
{children}
</motion.div>
);
}
);
SmoothTextAppearance.displayName = 'SmoothTextAppearance';
+2
View File
@@ -1,3 +1,5 @@
export { AnimationWrapper } from './AnimationWrapper';
export { PageTransition, AnimatedRoutesShell } from './PageTransition';
export { StaggeredList, StaggerItem } from './StaggeredList';
export { SmoothTextAppearance, type SmoothTextAppearanceProps } from './SmoothTextAppearance';
+24 -24
View File
@@ -16,30 +16,30 @@ export const PCQ_LIKERT_OPTIONS: Array<{ value: PcqLikertValue; label: string }>
];
export const PCQ24_QUESTIONS: PcqQuestion[] = [
{ id: 'pcq_01', text: 'با اعتماد، یک مسئله طولانی را بررسی می کنم تا یک راه حل بیابم.' },
{ id: 'pcq_02', text: 'من با اعتماد کامل می توانم وظایفم را در کارهای جمعی معین کنم.' },
{ id: 'pcq_03', text: 'من با اعتماد می توانم در بخش های اساسی زندگی ام مشارکت کنم.' },
{ id: 'pcq_04', text: 'من با اعتماد کامل می توانم اهداف زندگی ام را تعیین کنم.' },
{ id: 'pcq_05', text: 'من با اعتماد کامل می توانم در بیرون با مردم روبرو شوم تا درباره مسائل و مشکلات بحث کنم.' },
{ id: 'pcq_06', text: 'من با اعتماد کامل می توانم اطلاعات مفیدی به سایر دانشجویان هم رشته ام ارائه کنم.' },
{ id: 'pcq_07', text: 'اگر من در کار و موقعیتی گیر بیفتم می توانم راه های زیادی برای خلاص شدن از آن بیابم.' },
{ id: 'pcq_08', text: 'در حال حاضر من به شدت اهداف تحصیلی ام را دنبال می کنم.' },
{ id: 'pcq_09', text: 'من فکر می کنم روش های زیادی برای هر مشکل وجود دارد.' },
{ id: 'pcq_10', text: 'هم اکنون من خودم را به عنوان موفق ترین فرد در تحصیل می بینم.' },
{ id: 'pcq_11', text: 'من می توانم راه های زیادی برای رسیدن به اهداف جاری تحصیلی ام بیابم.' },
{ id: 'pcq_12', text: 'در حال حاضر من می توانم با اهداف تحصیلی که برای خودم مشخص کرده ام روبرو شوم.' },
{ id: 'pcq_13', text: 'زمانی که با مانعی در امور تحصیلی ام روبرو می شوم، با زحمت کمی آن را اصلاح می کنم و به کارم ادامه می دهم.' },
{ id: 'pcq_14', text: 'من معمولاً مشکلات متنوعی را می توانم مدیریت کنم.' },
{ id: 'pcq_15', text: 'من اگر مجبور باشم، در حین انجام تکالیف با خودم حرف می زنم.' },
{ id: 'pcq_16', text: 'من معمولاً در مواقع فشار کاری یا تحصیلی قدم می زنم.' },
{ id: 'pcq_17', text: 'من می توانم بر مشکلاتم غلبه کنم، زیرا که قبلاً تجربه چنین مشکلاتی را داشته ام.' },
{ id: 'pcq_18', text: 'من احساس می کنم که می توانم در حین انجام امور تحصیلی چند فعالیت دیگر را نیز انجام دهم.' },
{ id: 'pcq_19', text: 'من انتظار بهترین عملکرد را دارم، حتی زمانی که اهداف کاری ام در حین انجام آنها نامشخص باشند.' },
{ id: 'pcq_20', text: 'اگر چیز اشتباهی برایم پیش آید، سعی می کنم عملکرد عاقلانه و بهتری از خود نشان بدهم.' },
{ id: 'pcq_21', text: 'من همیشه جنبه های مثبت کارم را می بینم.' },
{ id: 'pcq_22', text: 'من راجع به آنچه که برایم در آینده در ارتباط با کار و تحصیلم اتفاق می افتد خوش بین هستم.' },
{ id: 'pcq_23', text: 'در امور تحصیلی کارها هیچ وقت در مسیری که من می خواستم پیش نرفته است.', reverseScored: true },
{ id: 'pcq_24', text: 'من به امور تحصیلی ام به عنوان بهترین وجه زندگی ام نگاه می کنم.' }
{ id: 'pcq_01', text: 'وقتی با مسئله‌ای روبه‌رو می‌شوم که حل کردنش زمان می‌برد، به توانایی خودم برای بررسی و پیدا کردن راه‌حل اعتماد دارم.' },
{ id: 'pcq_02', text: 'وقتی باید دربارهٔ درس‌هایم با معلم یا مسئول آموزشی صحبت کنم، احساس می‌کنم می‌توانم از پس صحبت کردن بربیایم.' },
{ id: 'pcq_03', text: 'برای شرکت در گفت‌و‌گوهای کلاسی دربارهٔ روش‌های بهتر درس خواندن، به توانایی خودم اعتماد دارم.' },
{ id: 'pcq_04', text: 'وقتی لازم است برای بهتر شدن وضعیت درسی‌ام هدف تعیین کنم، به توانایی خودم اطمینان دارم.' },
{ id: 'pcq_05', text: 'برای ارتباط گرفتن با کسی خارج از مدرسه و صحبت دربارهٔ مسائل درسی، احساس راحتی و اطمینان می‌کنم.' },
{ id: 'pcq_06', text: 'وقتی باید برای چند نفر از هم‌کلاسی‌هایم مطلبی را توضیح بدهم، به توانایی خودم اعتماد دارم.' },
{ id: 'pcq_07', text: 'اگر در درس یا یادگیری با مسئلهٔ سختی روبه‌رو شوم، می‌توانم راهی برای بیرون آمدن از آن وضعیت پیدا کنم.' },
{ id: 'pcq_08', text: 'این روزها با انرژی برای رسیدن به هدف‌های درسی‌ام تلاش می‌کنم.' },
{ id: 'pcq_09', text: 'به نظرم برای حل بیشتر مسئله‌ها می‌توان راه‌های مختلفی پیدا کرد.' },
{ id: 'pcq_10', text: 'در حال حاضر، خودم را تا حدی فرد موفقی می‌دانم.' },
{ id: 'pcq_11', text: 'می‌توانم راه‌های مختلفی برای رسیدن به هدف‌های درسی‌ای که الان دارم پیدا کنم.' },
{ id: 'pcq_12', text: 'در حال حاضر، دارم به هدف‌های درسی‌ای که برای خودم تعیین کرده‌ام نزدیک می‌شوم.' },
{ id: 'pcq_13', text: 'وقتی شکست می‌خورم، دوباره شروع کردن و ادامه دادن برایم سخت است.', reverseScored: true },
{ id: 'pcq_14', text: 'بالاخره می‌توانم راهی برای کنار آمدن با مشکلاتی که برایم پیش می‌آیند پیدا کنم.' },
{ id: 'pcq_15', text: 'اگر لازم باشد در درس‌هایم مستقل عمل کنم و به کمک دیگران متکی نباشم، می‌توانم از پسش بربیایم.' },
{ id: 'pcq_16', text: 'معمولاً وقتی در درس یا مدرسه با موقعیت پراسترس روبه‌رو می‌شوم، می‌توانم آرامش خودم را حفظ کنم.' },
{ id: 'pcq_17', text: 'می‌توانم از پس مشکلات درسی‌ام بربیایم، چون قبلاً هم سختی‌هایی را پشت سر گذاشته‌ام.' },
{ id: 'pcq_18', text: 'احساس می‌کنم در زمینهٔ درسی فعلی‌ام می‌توانم چند کار را همزمان انجام بدهم.' },
{ id: 'pcq_19', text: 'وقتی نمی‌دانم نتیجهٔ تلاش درسی‌ام چه می‌شود، معمولاً انتظار دارم نتیجهٔ خوبی بگیرم.' },
{ id: 'pcq_20', text: 'اگر احتمال داشته باشد در کاری اشتباه کنم، معمولاً انتظار دارم اشتباه پیش بیاید.', reverseScored: true },
{ id: 'pcq_21', text: 'معمولاً به جنبه‌های مثبت اتفاق‌هایی که در درس و مدرسه برایم می‌افتند توجه می‌کنم.' },
{ id: 'pcq_22', text: 'دربارهٔ اتفاق‌هایی که در آینده برای درس و تحصیلم می‌افتند، نگاه مثبتی دارم.' },
{ id: 'pcq_23', text: 'در کارهای درسی‌ام، اوضاع هیچ‌وقت آن‌طور که می‌خواهم پیش نمی‌رود.', reverseScored: true },
{ id: 'pcq_24', text: 'باور دارم بعد از سختی‌های درس و تحصیل هم ممکن است اتفاق‌های خوب و امیدوارکننده‌ای پیش بیاید.' }
];
export function scorePcqAnswer(rawValue: string, reverseScored?: boolean): number {
+192
View File
@@ -0,0 +1,192 @@
import type { DimensionName } from '@/lib/assessment/scoring/dimensions';
export type ScoreTier = 'low' | 'mid' | 'high';
export interface DimensionConfig {
/** Stable dimension key */
dimension: DimensionName;
/** Persian dimension title */
titleFa: string;
/** Short tagline */
tagline: string;
/** Accent color for charts and highlights */
accentColor: string;
/** Gradient styling */
gradient: string;
/** Primary image file name */
fileName: string;
/** Real local path */
path: string;
/** Fallback local path */
fallbackPath: string;
/** Accessible image alt text */
altText: string;
/** Short Persian invitation sentence for step initial state */
inviteText: string;
/** Short, non-diagnostic descriptive title for each score tier */
descriptiveTitleByTier: {
low: string; // 6 to 15
mid: string; // 16 to 25
high: string; // 26 to 36
};
/** Exactly 3 feedback texts for the 3 operational score categories */
feedbackByTier: {
low: string; // 6 to 15
mid: string; // 16 to 25
high: string; // 26 to 36
};
}
/**
* 4 PCQ-24 dimensions configuration with local images, descriptive titles, and 12 exact feedback texts.
* Operational Score Categories (not psychometric diagnostic labels):
* - Category 1 (low): 6 to 15
* - Category 2 (mid): 16 to 25
* - Category 3 (high): 26 to 36
*/
export const DIMENSION_CONFIGS: Record<DimensionName, DimensionConfig> = {
efficacy: {
dimension: 'efficacy',
titleFa: 'خودکارآمدی',
tagline: 'باور به توانایی‌ها و مهارت‌های فردی',
accentColor: '#00a8e8',
gradient: 'from-sky-500/20 via-cyan-500/15 to-transparent',
fileName: 'self-efficacy.webp',
path: '/images/components/self-efficacy.webp',
fallbackPath: '/images/results/self-efficacy.webp',
altText: 'تصویر اختصاصی خودکارآمدی نویا',
inviteText: 'آماده‌ای تا ببینی باورت به توانایی‌ها و روبه‌رو شدن با چالش‌ها چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر شناخت و رشد توانایی‌ها',
mid: 'باور رو‌به‌رشد به توانایی‌ها',
high: 'باور پررنگ به توانایی‌های فردی',
},
feedbackByTier: {
low: 'اعتماد به توانایی‌های خودت هنوز جای رشد داره. شاید بعضی وقت‌ها انجام کارهای تازه برات سخت به نظر برسه؛ این فقط بخشی از تصویر فعلی توئه و همه توانایی‌هایت رو مشخص نمی‌کنه.',
mid: 'به نظر می‌رسه در بعضی موقعیت‌ها به خودت اعتماد داری، اما گاهی هم درباره توانایی‌هایت تردید می‌کنی. این یعنی نگاهت به توانایی‌های خودت در موقعیت‌های مختلف می‌تونه متفاوت باشه.',
high: 'یکی از ویژگی‌های برجسته تو، اعتماد به توانایی‌هایت برای انجام کارهاست. به نظر می‌رسه در پاسخ‌هایت، باور به توانایی خودت جایگاه پررنگی داره؛ هرچند این حس ممکنه در موقعیت‌های مختلف تغییر کنه.',
},
},
hope: {
dimension: 'hope',
titleFa: 'امیدواری',
tagline: 'انگیزه و یافتن راه‌های رسیدن به هدف',
accentColor: '#0d9488',
gradient: 'from-teal-500/20 via-emerald-500/15 to-transparent',
fileName: 'hope.webp',
path: '/images/components/hope.webp',
fallbackPath: '/images/results/hope.webp',
altText: 'تصویر اختصاصی امیدواری نویا',
inviteText: 'آماده‌ای تا ببینی نگاهت به هدف‌ها و پیدا کردن مسیرهای رسیدن به اون‌ها چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر کشف هدف‌ها و راه‌ها',
mid: 'انگیزه و تلاش برای یافتن راه‌ها',
high: 'امیدواری و مسیرسازی پررنگ',
},
feedbackByTier: {
low: 'پیدا کردن مسیر رسیدن به هدف‌ها هنوز می‌تونه بخش مهمی از تجربه تو باشه. این نتیجه فقط نگاه فعلی تو به هدف‌ها و راه‌های رسیدن به اون‌ها رو نشون می‌ده، نه چیزی که در آینده برات ممکنه یا غیرممکنه.',
mid: 'به نظر می‌رسه برای بعضی هدف‌هایت مسیر روشن‌تری داری، اما درباره بعضی هدف‌های دیگه هنوز در حال پیدا کردن راهت هستی. این کاملاً تصویری از نگاه فعلی تو به هدف‌ها و مسیرهای رسیدن به اون‌هاست.',
high: 'امیدواری یکی از نقاط قوت برجسته تو در این آزمونه. به نظر می‌رسه پیدا کردن هدف‌ها و فکر کردن به راه‌های رسیدن به اون‌ها در پاسخ‌هایت جایگاه پررنگی داره.',
},
},
resilience: {
dimension: 'resilience',
titleFa: 'تاب‌آوری',
tagline: 'ایستادگی و برگشت‌پذیری پس از سختی‌ها',
accentColor: '#8b5cf6',
gradient: 'from-indigo-500/20 via-purple-500/15 to-transparent',
fileName: 'resilience.webp',
path: '/images/components/resilience.webp',
fallbackPath: '/images/results/resilience.webp',
altText: 'تصویر اختصاصی تاب‌آوری نویا',
inviteText: 'آماده‌ای تا ببینی کنار اومدن با سختی‌ها و برگشتن به مسیر در پاسخ‌هایت چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر یادگیری و برگشت به مسیر',
mid: 'ایستادگی منعطف در موقعیت‌های مختلف',
high: 'ایستادگی و برگشت‌پذیری پررنگ',
},
feedbackByTier: {
low: 'کنار اومدن با سختی‌ها و برگشتن به مسیر، از بخش‌هایی است که هنوز می‌تونه در تو رشد کنه. این نمره به‌تنهایی نمی‌گه در شرایط سخت چطور رفتار می‌کنی؛ فقط تصویری از پاسخ‌های فعلی توئه.',
mid: 'به نظر می‌رسه در بعضی شرایط می‌تونی با سختی‌ها کنار بیای، اما تجربه‌ات در موقعیت‌های مختلف یکسان نیست. این نتیجه نشون می‌ده تاب‌آوری تو در پاسخ‌های این آزمون، ترکیبی از جنبه‌های شکل‌گرفته و در حال رشده.',
high: 'تاب‌آوری یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن کنار اومدن با دشواری‌ها و ادامه دادن مسیر، در تصویر فعلی تو جایگاه پررنگی داره.',
},
},
optimism: {
dimension: 'optimism',
titleFa: 'خوش‌بینی',
tagline: 'نگاه مثبت و امیدوارکننده به آینده',
accentColor: '#10b981',
gradient: 'from-emerald-500/20 via-teal-500/15 to-transparent',
fileName: 'optimism.webp',
path: '/images/components/optimism.webp',
fallbackPath: '/images/results/optimism.webp',
altText: 'تصویر اختصاصی خوش‌بینی نویا',
inviteText: 'آماده‌ای تا ببینی نگاهت به اتفاق‌ها و دیدن جنبه‌های مثبت چطور بوده؟',
descriptiveTitleByTier: {
low: 'در مسیر کشف جنبه‌های روشن اتفاق‌ها',
mid: 'نگاه واقع‌بینانه و پویا به آینده',
high: 'دیدگاه مثبت و امیدوارانه به آینده',
},
feedbackByTier: {
low: 'نگاهت به اتفاق‌ها و آینده هنوز ظرفیت رشد داره. این نمره به معنی منفی بودن نگاهت در همه موقعیت‌ها نیست؛ فقط نشون میده پاسخ‌های فعلی تو درباره خوش‌بینی چه تصویری دارن.',
mid: 'گاهی ممکنه جنبه‌های امیدوارکننده اتفاق‌ها رو ببینی و گاهی هم نگاهت به شرایط متفاوت باشه. نتیجه تو تصویری از نحوه نگاه فعلی‌ات به اتفاق‌ها و آینده ارائه می‌ده.',
high: 'خوش‌بینی یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن نگاه امیدوارانه به اتفاق‌ها و آینده در تجربه فعلی تو جایگاه پررنگی داره.',
},
},
};
export const RESULT_IMAGES = DIMENSION_CONFIGS;
/**
* 4 dimensions ordered strictly according to the PCQ-24 specification:
* 1. Self-efficacy (خودکارآمدی)
* 2. Hope (امیدواری)
* 3. Resilience (تاب‌آوری)
* 4. Optimism (خوش‌بینی)
*/
export const DIMENSION_ORDER: DimensionName[] = [
'efficacy',
'hope',
'resilience',
'optimism',
];
/**
* Maps a dimension score (6 to 36) to one of the 3 operational categories:
* - 6 to 15: low (دسته اول)
* - 16 to 25: mid (دسته دوم)
* - 26 to 36: high (دسته سوم)
*
* NOTE: Both bounds are inclusive (e.g. 15 is low, 16 is mid, 25 is mid, 26 is high).
*/
export function getScoreTier(score: number): ScoreTier {
if (score <= 15) return 'low';
if (score <= 25) return 'mid';
return 'high';
}
/**
* Returns the short, teen-friendly descriptive title for a dimension and score.
*/
export function getDescriptiveTitleForScore(
dimension: DimensionName,
score: number
): string {
const config = DIMENSION_CONFIGS[dimension];
if (!config) return '';
const tier = getScoreTier(score);
return config.descriptiveTitleByTier[tier];
}
/**
* Returns the exact pre-written feedback text for a given dimension and score.
*/
export function getFeedbackForScore(
dimension: DimensionName,
score: number
): string {
const config = DIMENSION_CONFIGS[dimension];
if (!config) return '';
const tier = getScoreTier(score);
return config.feedbackByTier[tier];
}
+219 -44
View File
@@ -81,6 +81,7 @@ export function useChat(options: UseChatOptions = {}) {
const messagesRef = useRef<ChatMessage[]>([]);
/** After first successful bootstrap, never flash the full-page chat skeleton again. */
const hasHydratedChatRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
coachContextRef.current = coachContext;
onCoachSessionCompletedRef.current = onCoachSessionCompleted;
@@ -92,6 +93,10 @@ export function useChat(options: UseChatOptions = {}) {
clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, []);
@@ -366,6 +371,14 @@ export function useChat(options: UseChatOptions = {}) {
scheduleCompletionAfterStream(messageId);
};
const stopGenerating = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsAiThinking(false);
}, []);
const startCoachingSession = async (_userId: string) => {
if (!chatSessionId) {
throw new Error('جلسه گفتگو در دسترس نیست. لطفا صفحه را رفرش کنید.');
@@ -376,37 +389,115 @@ export function useChat(options: UseChatOptions = {}) {
const sessionOrder = coachContextRef.current?.order;
setIsAiThinking(true);
try {
const result = await api.post<{
session: { id: string };
messages: BackendChatMessage[];
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
}>(`/chat/sessions/${chatSessionId}/start`);
abortControllerRef.current = new AbortController();
const assistantLocalId = createLocalId();
let accumulatedContent = '';
let hasCreatedAssistantMessage = false;
let pendingRaf: number | null = null;
const backendMessages = result.messages.map((msg) =>
mapBackendMessage(msg, _userId, sessionOrder ?? 0)
);
const streamingAssistant = backendMessages[backendMessages.length - 1];
setMessages((prev) => {
const withoutCurrent = prev.filter((msg) => msg.sessionOrder !== sessionOrder);
const withStream = backendMessages.map((msg, index) =>
index === backendMessages.length - 1 && msg.role === 'assistant'
? { ...msg, status: 'streaming' as const }
: msg
const scheduleUpdate = () => {
if (pendingRaf !== null) return;
pendingRaf = window.requestAnimationFrame(() => {
pendingRaf = null;
const textToPaint = accumulatedContent;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, content: textToPaint } : msg
)
);
return [...withoutCurrent, ...withStream];
});
};
if (streamingAssistant?.role === 'assistant') {
queueCompletionForStream(result.completion, result.session.id, streamingAssistant.id);
} else {
emitCompletionIfValid(result.completion, result.session.id);
}
return backendMessages[backendMessages.length - 1];
try {
await api.stream(
`/chat/sessions/${chatSessionId}/start`,
{
signal: abortControllerRef.current.signal,
onEvent: (ev) => {
if (ev.event === 'chunk' && ev.data?.text) {
accumulatedContent += ev.data.text;
if (!hasCreatedAssistantMessage) {
hasCreatedAssistantMessage = true;
setIsAiThinking(false);
const placeholderAssistant: ChatMessage = {
id: assistantLocalId,
user_id: _userId,
content: accumulatedContent,
created_at: new Date().toISOString(),
role: 'assistant',
status: 'streaming',
sessionOrder,
};
setMessages((prev) => {
const withoutCurrent = prev.filter((msg) => msg.sessionOrder !== sessionOrder);
return [...withoutCurrent, placeholderAssistant];
});
} else {
scheduleUpdate();
}
} else if (ev.event === 'done' && ev.data) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
setIsAiThinking(false);
const doneData = ev.data as {
messages?: BackendChatMessage[];
assistantMessage?: BackendChatMessage;
session?: { id: string };
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
};
const backendMsg = doneData.messages?.[0] || doneData.assistantMessage;
const canonicalAssistant = backendMsg
? {
...mapBackendMessage(backendMsg, _userId, sessionOrder ?? 0),
status: 'sent' as const,
}
: null;
if (canonicalAssistant) {
setMessages((prev) => {
const exists = prev.some((m) => m.id === assistantLocalId);
if (exists) {
return prev.map((msg) =>
msg.id === assistantLocalId ? canonicalAssistant : msg
);
}
return [...prev, canonicalAssistant];
});
if (doneData.completion) {
queueCompletionForStream(
doneData.completion,
doneData.session?.id || chatSessionId,
canonicalAssistant.id
);
scheduleCompletionAfterStream(canonicalAssistant.id);
}
}
} else if (ev.event === 'error') {
throw new Error(ev.data?.message || 'خطا در شروع جلسه گفتگو');
}
},
}
);
} catch (error) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
if (abortControllerRef.current?.signal.aborted) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, status: 'sent' } : msg
)
);
return null;
}
throw resolveFriendlyError(error);
} finally {
setIsAiThinking(false);
abortControllerRef.current = null;
}
};
@@ -439,31 +530,112 @@ export function useChat(options: UseChatOptions = {}) {
}
setIsAiThinking(true);
try {
const result = await api.post<{
session: { id: string };
userMessage: BackendChatMessage & { role: 'user' };
assistantMessage: BackendChatMessage & { role: 'assistant' };
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
}>(`/chat/sessions/${chatSessionId}/messages`, { content: trimmed });
abortControllerRef.current = new AbortController();
const assistantLocalId = createLocalId();
let accumulatedContent = '';
let hasCreatedAssistantMessage = false;
let pendingRaf: number | null = null;
const persistedUser = mapBackendMessage(result.userMessage, nextUserId, sessionOrder ?? 0);
const assistant = {
...mapBackendMessage(result.assistantMessage, nextUserId, sessionOrder ?? 0),
status: 'streaming' as const,
};
setMessages((prev) => {
const withoutPending = prev.filter((msg) => msg.id !== pendingId);
if (skipUserMessage) {
return [...withoutPending, assistant];
}
return [...withoutPending, persistedUser, assistant];
const scheduleUpdate = () => {
if (pendingRaf !== null) return;
pendingRaf = window.requestAnimationFrame(() => {
pendingRaf = null;
const textToPaint = accumulatedContent;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, content: textToPaint } : msg
)
);
});
};
queueCompletionForStream(result.completion, result.session.id, assistant.id);
return assistant;
try {
await api.stream(
`/chat/sessions/${chatSessionId}/messages`,
{
body: { content: trimmed },
signal: abortControllerRef.current.signal,
onEvent: (ev) => {
if (ev.event === 'chunk' && ev.data?.text) {
accumulatedContent += ev.data.text;
if (!hasCreatedAssistantMessage) {
hasCreatedAssistantMessage = true;
setIsAiThinking(false);
const placeholderAssistant: ChatMessage = {
id: assistantLocalId,
user_id: nextUserId,
content: accumulatedContent,
created_at: new Date().toISOString(),
role: 'assistant',
status: 'streaming',
sessionOrder,
};
setMessages((prev) => [...prev, placeholderAssistant]);
} else {
scheduleUpdate();
}
} else if (ev.event === 'ready' && ev.data?.userMessage) {
const persistedUser = mapBackendMessage(ev.data.userMessage, nextUserId, sessionOrder ?? 0);
setMessages((prev) =>
prev.map((msg) => (msg.id === pendingId ? persistedUser : msg))
);
} else if (ev.event === 'done' && ev.data) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
setIsAiThinking(false);
const doneData = ev.data as {
assistantMessage?: BackendChatMessage;
session?: { id: string };
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
};
const canonicalAssistant = doneData.assistantMessage
? {
...mapBackendMessage(doneData.assistantMessage, nextUserId, sessionOrder ?? 0),
status: 'sent' as const,
}
: null;
if (canonicalAssistant) {
setMessages((prev) => {
const exists = prev.some((m) => m.id === assistantLocalId);
if (exists) {
return prev.map((msg) =>
msg.id === assistantLocalId ? canonicalAssistant : msg
);
}
return [...prev, canonicalAssistant];
});
if (doneData.completion) {
queueCompletionForStream(
doneData.completion,
doneData.session?.id || chatSessionId,
canonicalAssistant.id
);
scheduleCompletionAfterStream(canonicalAssistant.id);
}
}
} else if (ev.event === 'error') {
throw new Error(ev.data?.message || 'خطا در دریافت پاسخ');
}
},
}
);
} catch (error) {
if (pendingRaf !== null) {
window.cancelAnimationFrame(pendingRaf);
pendingRaf = null;
}
if (abortControllerRef.current?.signal.aborted) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantLocalId ? { ...msg, status: 'sent' } : msg
)
);
return null;
}
if (!skipUserMessage) {
setMessages((prev) =>
prev.map((msg) =>
@@ -474,6 +646,7 @@ export function useChat(options: UseChatOptions = {}) {
throw resolveFriendlyError(error);
} finally {
setIsAiThinking(false);
abortControllerRef.current = null;
}
};
@@ -487,5 +660,7 @@ export function useChat(options: UseChatOptions = {}) {
sendMessage,
startCoachingSession,
markAssistantStreamComplete,
stopGenerating,
};
}
+20 -9
View File
@@ -107,11 +107,15 @@ export default function AccountPage() {
<StatusRow
title="پیش‌آزمون"
state={preState}
onOpen={() => navigate('/assessment/pre')}
lockedHint={preDone ? 'انجام شده — قابل تکرار نیست' : undefined}
onOpen={preDone ? () => navigate('/assessment/pre') : undefined}
>
{preDone ? (
<StatusDone label="انجام شده" />
<div className="flex items-center justify-between">
<StatusDone label="انجام شده" />
<span className="text-xs font-medium text-sky-600 dark:text-sky-400">
مشاهده کارنامه
</span>
</div>
) : (
<p className="text-sm text-body">برای شروع مسیر، پیش‌آزمون را انجام دهید</p>
)}
@@ -154,14 +158,21 @@ export default function AccountPage() {
<StatusRow
title="پس‌آزمون"
state={postState}
onOpen={() => navigate('/assessment/post')}
onOpen={postDone ? () => navigate('/finished') : postState === 'open' ? () => navigate('/assessment/post') : undefined}
lockedHint={
postDone
? 'انجام شده — قابل تکرار نیست'
? undefined
: 'پس از رسیدن به جلسه پایانی فعال می‌شود'
}
>
{postState === 'done' && <StatusDone label="انجام شده" />}
{postState === 'done' && (
<div className="flex items-center justify-between">
<StatusDone label="انجام شده" />
<span className="text-xs font-medium text-sky-600 dark:text-sky-400">
مشاهده کارنامه
</span>
</div>
)}
{postState === 'open' && (
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
<span
@@ -195,10 +206,10 @@ function StatusRow({
title: string;
children: ReactNode;
state: StepState;
onOpen: () => void;
onOpen?: () => void;
lockedHint?: string;
}) {
const isInteractive = state === 'open';
const isInteractive = Boolean(onOpen) && (state === 'open' || state === 'done');
const content = (
<div className="flex items-start justify-between gap-3">
@@ -220,7 +231,7 @@ function StatusRow({
</div>
);
if (isInteractive) {
if (isInteractive && onOpen) {
return (
<motion.button
type="button"
+56 -13
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -7,8 +7,11 @@ import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import {
saveAssessment,
getLatestByPhase,
type AttemptPhase,
type AssessmentSubmission,
} from '@/services/assessmentService';
import { AssessmentResultView } from '@/components/assessment/AssessmentResultView';
import { BackendError } from '@/services/backend';
import { getBackendErrorMessage, isBackendNetworkError } from '@/lib/backendError';
import { useToast } from '@/components/ui/use-toast';
@@ -40,16 +43,39 @@ export default function AssessmentPage() {
const [introStep, setIntroStep] = useState(0);
const [contentVisible, setContentVisible] = useState(true);
const [isAnimating, setIsAnimating] = useState(false);
const [completedSubmission, setCompletedSubmission] = useState<AssessmentSubmission | null>(null);
const [showResultView, setShowResultView] = useState(false);
const [loadingExistingResult, setLoadingExistingResult] = useState(false);
const isPreTest = phase === 'pre';
const isPostTest = phase === 'post';
const invalidPhase = !isPreTest && !isPostTest;
useEffect(() => {
// If pre-test was already completed and user is on /assessment/pre, load the result to display
if (isPreTest && !loading && progress?.pre_test_completed_at && effectiveUserId && !completedSubmission) {
setLoadingExistingResult(true);
getLatestByPhase(effectiveUserId, 'pre')
.then((submission) => {
if (submission) {
setCompletedSubmission(submission);
setShowResultView(true);
}
})
.catch((err) => {
console.error('Failed to load existing pre-test result:', err);
})
.finally(() => {
setLoadingExistingResult(false);
});
}
}, [isPreTest, loading, progress?.pre_test_completed_at, effectiveUserId, completedSubmission]);
const canAccess = useMemo(() => {
if (isPreTest) return permissions.canTakePreTest;
if (isPreTest) return permissions.canTakePreTest || Boolean(progress?.pre_test_completed_at);
if (isPostTest) return permissions.canTakePostTest;
return false;
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest]);
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest, progress?.pre_test_completed_at]);
const totalQuestions = PCQ24_QUESTIONS.length;
const currentQuestion = PCQ24_QUESTIONS[currentIndex];
@@ -67,7 +93,23 @@ export default function AssessmentPage() {
return <Navigate to="/" replace />;
}
if (!loading && !canAccess) {
if (loading || loadingExistingResult) {
return <AssessmentSkeleton />;
}
if (showResultView && completedSubmission) {
return (
<div className="ambient-glow relative flex flex-col h-full min-h-0 flex-1 overflow-hidden px-3 pt-1.5 pb-3 sm:px-4 sm:pt-2 sm:pb-4">
<AssessmentResultView
phase={isPreTest ? 'pre' : 'post'}
currentSubmission={completedSubmission}
onContinue={() => navigate(isPreTest ? '/chat' : '/finished')}
/>
</div>
);
}
if (!canAccess) {
if (isPreTest && progress?.pre_test_completed_at) {
return <Navigate to="/" replace />;
}
@@ -79,10 +121,6 @@ export default function AssessmentPage() {
}
}
if (loading) {
return <AssessmentSkeleton />;
}
const advanceWithFade = (delta: number) => {
if (isAnimating || submitting || isRedirecting) return;
setIsAnimating(true);
@@ -151,17 +189,16 @@ export default function AssessmentPage() {
if (isPreTest) {
await markPreTestDone(submission.completedAt);
await reloadProgress();
setSuccessMessage('پیش‌آزمون با موفقیت ثبت شد. در حال انتقال به فضای چت...');
setIsRedirecting(true);
setTimeout(() => navigate('/chat'), 800);
setCompletedSubmission(submission);
setShowResultView(true);
return;
}
await markPostTestDone(submission.completedAt);
await reloadProgress();
setSuccessMessage('پس‌آزمون با موفقیت ثبت شد. در حال انتقال به صفحه پایان دوره...');
setSuccessMessage('پس‌آزمون با موفقیت ثبت شد. در حال انتقال به صفحه کارنامه پایانی...');
setIsRedirecting(true);
setTimeout(() => navigate('/finished'), 800);
setTimeout(() => navigate('/finished'), 600);
} catch (error: unknown) {
console.error('Error saving assessment:', error);
if (error instanceof BackendError) {
@@ -179,6 +216,12 @@ export default function AssessmentPage() {
if (isPreTest) {
await markPreTestDone();
await reloadProgress();
const latest = await getLatestByPhase(effectiveUserId, 'pre');
if (latest) {
setCompletedSubmission(latest);
setShowResultView(true);
return;
}
navigate('/chat', { replace: true });
return;
}
+54 -24
View File
@@ -15,7 +15,7 @@ import { exampleCompletionSignal, looksLikeFinalPostTestHandoff } from '@/lib/ai
import { useToast } from '@/components/ui/use-toast';
import { getFetchErrorMessage } from '@/lib/fetchError';
import { APP_NAME } from '@/utils/constants';
import { ArrowUp, Loader2, Sparkles } from 'lucide-react';
import { ArrowUp, Loader2, Sparkles, Square } from 'lucide-react';
import { LoadingOverlay } from '@/components/ui/loading-overlay';
import { ChatSkeleton, PageSkeleton } from '@/components/skeletons';
@@ -201,6 +201,7 @@ export default function ChatPage() {
sendMessage,
startCoachingSession,
markAssistantStreamComplete,
stopGenerating,
} = useChat({
coachContext,
userId: effectiveUserId,
@@ -296,15 +297,27 @@ export default function ChatPage() {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const scroller = messagesScrollRef.current;
const end = messagesEndRef.current;
if (end) {
end.scrollIntoView({ behavior, block: 'end' });
}
if (scroller) {
if (!scroller) return;
if (behavior === 'smooth') {
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
} else {
scroller.scrollTop = scroller.scrollHeight;
}
}, []);
// Ensure scroll position remains accurate when mobile virtual keyboard opens/resizes
useEffect(() => {
if (typeof window === 'undefined' || !window.visualViewport) return;
const handleViewportResize = () => {
scrollToBottom('auto');
};
const viewport = window.visualViewport;
viewport.addEventListener('resize', handleViewportResize);
return () => {
viewport.removeEventListener('resize', handleViewportResize);
};
}, [scrollToBottom]);
const isStreamOrThinking =
isAiThinking || messages.some((msg) => msg.status === 'streaming');
@@ -526,7 +539,7 @@ export default function ChatPage() {
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<div className="glass-panel-chat relative flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
<div className="glass-panel-chat relative flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-4">
<LoadingOverlay
open={isSessionTransitionPending}
message={
@@ -535,7 +548,7 @@ export default function ChatPage() {
: 'در حال آماده‌سازی جلسه بعد…'
}
/>
<div className="relative z-10 shrink-0">
<div className="relative z-10 shrink-0 border-b border-slate-200/50 pb-2.5 sm:pb-3 dark:border-slate-800/60">
{isProgramComplete ? (
<p className="text-center text-xs text-muted-foreground">دوره آموزشی تکمیل شده</p>
) : isWaitingForUnlock && waitingForNextSession ? (
@@ -587,7 +600,7 @@ export default function ChatPage() {
<div
ref={messagesScrollRef}
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain px-0.5 py-2.5"
>
{loading && messages.length === 0 ? (
<ChatSkeleton />
@@ -730,7 +743,7 @@ export default function ChatPage() {
)}
</div>
<div className="relative z-10 w-full shrink-0 pb-1 pt-1">
<div className="relative z-20 w-full shrink-0 pt-2 pb-0.5">
<form onSubmit={handleSend} className="w-full">
<div className="composer-glass">
<textarea
@@ -742,6 +755,9 @@ export default function ChatPage() {
setMessage(e.target.value);
resizeComposer();
}}
onFocus={() => {
window.setTimeout(() => scrollToBottom('smooth'), 120);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -754,20 +770,34 @@ export default function ChatPage() {
className="input-neon max-h-36 min-h-12 flex-1 resize-none overflow-y-auto border-0 bg-transparent py-3 text-right text-base leading-6 text-foreground placeholder:text-muted-foreground shadow-none outline-none backdrop-blur-none focus-visible:border-0 focus-visible:shadow-none focus-visible:ring-0 disabled:opacity-60"
aria-label={`پیام به ${APP_NAME}`}
/>
<Button
type="submit"
size="icon"
variant="neon"
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 disabled:opacity-50 [&_svg]:h-5 [&_svg]:w-5"
disabled={composerDisabled || !message.trim()}
aria-label="ارسال"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
)}
</Button>
{isStreamBusy ? (
<Button
type="button"
size="icon"
variant="outline"
onClick={stopGenerating}
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 border-sky-400/50 bg-sky-500/15 text-sky-400 hover:bg-sky-500/25 dark:border-sky-400/40 dark:bg-sky-400/10 dark:text-sky-300"
aria-label="توقف تولید"
title="توقف تولید"
>
<Square className="h-4 w-4 fill-current" />
</Button>
) : (
<Button
type="submit"
size="icon"
variant="neon"
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 disabled:opacity-50 [&_svg]:h-5 [&_svg]:w-5"
disabled={composerDisabled || !message.trim()}
aria-label="ارسال"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
)}
</Button>
)}
</div>
</form>
</div>
+91 -32
View File
@@ -1,14 +1,59 @@
import { useEffect, useState, useCallback } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import { Check, CheckCircle2 } from 'lucide-react';
import { FinishedSkeleton } from '@/components/skeletons';
import {
getLatestByPhase,
type AssessmentSubmission,
} from '@/services/assessmentService';
import { AssessmentResultView } from '@/components/assessment/AssessmentResultView';
import { Button } from '@/components/ui/button';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { getBackendErrorMessage } from '@/lib/backendError';
export default function FinishedPage() {
const { effectiveUserId, guestSessionHydrated } = useAuth();
const { permissions, loading } = useLearningJourney(effectiveUserId ?? undefined);
const { permissions, loading: journeyLoading } = useLearningJourney(
effectiveUserId ?? undefined
);
if (!guestSessionHydrated || loading) {
const [postAttempt, setPostAttempt] = useState<AssessmentSubmission | null>(null);
const [preAttempt, setPreAttempt] = useState<AssessmentSubmission | null>(null);
const [loadingAttempts, setLoadingAttempts] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAttempts = useCallback(async () => {
if (!effectiveUserId) return;
setLoadingAttempts(true);
setError(null);
try {
const [latestPost, latestPre] = await Promise.all([
getLatestByPhase(effectiveUserId, 'post'),
getLatestByPhase(effectiveUserId, 'pre'),
]);
setPostAttempt(latestPost);
setPreAttempt(latestPre);
} catch (err) {
console.error('Failed to load finished page assessment attempts:', err);
setError(
getBackendErrorMessage(
err,
'دریافت نتایج با مشکل مواجه شد. لطفاً دوباره تلاش کنید.'
)
);
} finally {
setLoadingAttempts(false);
}
}, [effectiveUserId]);
useEffect(() => {
if (effectiveUserId && guestSessionHydrated) {
void fetchAttempts();
}
}, [effectiveUserId, guestSessionHydrated, fetchAttempts]);
if (!guestSessionHydrated || journeyLoading || loadingAttempts) {
return <FinishedSkeleton />;
}
@@ -20,37 +65,51 @@ export default function FinishedPage() {
return <Navigate to="/" replace />;
}
return (
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-10">
<div className="mx-auto flex w-full max-w-md flex-col items-center gap-8 text-center">
<div className="avatar-glow">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm dark:bg-navy dark:text-neon-emerald dark:shadow-none">
<CheckCircle2 className="h-9 w-9" aria-hidden />
</div>
if (error) {
return (
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-8">
<div className="glass-panel mx-auto flex w-full max-w-md flex-col items-center gap-4 p-6 text-center">
<AlertCircle className="h-10 w-10 text-destructive" aria-hidden />
<h2 className="text-lg font-bold text-foreground">خطا در دریافت نتایج</h2>
<p className="text-sm text-muted-foreground">{error}</p>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => void fetchAttempts()}
>
<RefreshCw className="h-4 w-4" />
<span>تلاش مجدد</span>
</Button>
</div>
<h1 className="text-xl font-bold leading-8 text-foreground sm:text-2xl">
دوره آموزشی شما با موفقیت به پایان رسید.
</h1>
<ul className="glass-panel w-full space-y-3 px-4 py-4 text-right text-sm text-body">
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
پیش‌آزمون انجام شده
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
دوره آموزشی تکمیل شده
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
پس‌آزمون انجام شده
</li>
</ul>
<p className="text-sm text-muted-foreground">
از منوی پروفایل می‌توانید وضعیت دوره را ببینید یا خارج شوید.
</p>
</div>
);
}
if (!postAttempt) {
return (
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-8">
<div className="glass-panel mx-auto flex w-full max-w-md flex-col items-center gap-4 p-6 text-center">
<h2 className="text-lg font-bold text-foreground">نتیجه‌ای یافت نشد</h2>
<p className="text-sm text-muted-foreground">
هنوز نتیجه پس‌آزمونی برای حساب شما ثبت نشده است.
</p>
<Button type="button" variant="neon" onClick={() => window.location.assign('/')}>
بازگشت به خانه
</Button>
</div>
</div>
);
}
return (
<div className="relative flex flex-col h-full min-h-0 flex-1 overflow-hidden px-3 pt-1.5 pb-3 sm:px-4 sm:pt-2 sm:pb-4">
<AssessmentResultView
phase="post"
currentSubmission={postAttempt}
baselineSubmission={preAttempt}
/>
</div>
);
}
+119
View File
@@ -118,6 +118,123 @@ async function request<T>(
return (body as ApiSuccess<T>).data;
}
export type SseEvent<T = any> = {
event: string;
data: T;
};
export async function streamRequest(
path: string,
options: {
method?: string;
body?: unknown;
signal?: AbortSignal;
onEvent: (event: SseEvent) => void;
},
): Promise<void> {
const url = `${baseUrl}${path}`;
const token = getSessionToken();
const headers = new Headers();
headers.set('Accept', 'text/event-stream');
headers.set('Content-Type', 'application/json');
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
let response: Response;
try {
response = await fetch(url, {
method: options.method ?? 'POST',
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
} catch {
if (options.signal?.aborted) {
return;
}
throw new BackendError(
0,
'NETWORK_ERROR',
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
);
}
if (response.status === 401) {
clearSessionToken();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
}
throw new BackendError(401, 'UNAUTHORIZED', 'نشست شما منقضی شده است. لطفا دوباره وارد شوید.');
}
if (!response.ok) {
let errBody: any;
try {
errBody = await response.json();
} catch {
// ignore
}
throw new BackendError(
response.status,
errBody?.error?.code ?? 'SERVER_ERROR',
errBody?.error?.message ?? `Request failed with status ${response.status}`,
errBody?.error?.details,
);
}
if (!response.body) {
throw new BackendError(0, 'NO_BODY', 'پاسخی از سرور دریافت نشد.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.split('\n');
let eventName = 'message';
let dataStr = '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataStr = line.slice(5).trim();
}
}
if (dataStr) {
try {
const parsed = JSON.parse(dataStr);
options.onEvent({ event: eventName, data: parsed });
} catch {
options.onEvent({ event: eventName, data: dataStr });
}
}
}
}
} catch (err) {
if (options.signal?.aborted) {
return;
}
throw err;
} finally {
reader.releaseLock();
}
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
@@ -131,4 +248,6 @@ export const api = {
body: data !== undefined ? JSON.stringify(data) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
stream: streamRequest,
};
+24 -25
View File
@@ -173,6 +173,12 @@
}
}
@supports (height: 100dvh) {
html {
height: 100dvh;
}
}
html.dark {
color-scheme: dark;
}
@@ -893,7 +899,7 @@
}
.bubble-ai {
@apply rounded-2xl px-1 py-1 text-base leading-7;
@apply rounded-2xl px-1 py-1 text-[15px] sm:text-base leading-[1.85];
background: transparent;
border: none;
box-shadow: none;
@@ -903,22 +909,29 @@
.bubble-ai p,
.bubble-ai li,
.bubble-ai .chat-prose {
text-align: justify;
text-align-last: right;
text-justify: inter-word;
text-align: right;
line-height: 1.85;
}
/* ChatGPT-like breathing room between blocks */
.bubble-ai > div {
@apply space-y-4;
@apply space-y-3.5;
}
.bubble-ai p + p {
margin-top: 0.35rem;
margin-top: 0.875rem;
}
.bubble-ai h1 {
@apply mt-6 mb-3 text-[22px] sm:text-2xl font-bold leading-[1.45] text-foreground first:mt-1;
}
.bubble-ai h2 {
@apply mt-5 mb-2.5 text-[19px] sm:text-[20px] font-bold leading-[1.5] text-foreground first:mt-1;
}
.bubble-ai h3 {
@apply mt-1 mb-0.5;
@apply mt-4 mb-2 text-[17px] sm:text-[18px] font-bold leading-[1.5] text-foreground first:mt-1;
}
.bubble-user .chat-prose {
@@ -926,13 +939,14 @@
max-width: 100%;
text-align: right;
white-space: pre-wrap;
line-height: 1.75;
}
.bubble-ai strong {
font-weight: 600;
font-weight: 700;
}
/* Digits stay readable under RTL justify without breaking line layout. */
/* Digits stay readable under RTL without breaking line layout. */
.bubble-ai .chat-num,
.bubble-user .chat-num {
color: hsl(var(--foreground));
@@ -944,26 +958,11 @@
.bubble-ai ol,
.bubble-ai ul {
list-style-position: outside;
padding-inline-start: 1.35rem;
margin-inline-end: 0.15rem;
}
.bubble-ai ol:not(.list-none) {
list-style-type: decimal;
}
/* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */
.bubble-ai ol.list-none {
list-style: none;
padding-inline-start: 0;
margin-inline-end: 0;
}
.bubble-ai li::marker {
color: hsl(var(--foreground));
font-weight: 600;
}
/* App chrome — sticky top bar */
.app-header {
position: sticky;