diff --git a/.env.mysql.example b/.env.mysql.example new file mode 100644 index 0000000..22791c3 --- /dev/null +++ b/.env.mysql.example @@ -0,0 +1,8 @@ +# Noya MySQL Production Environment Variables (Template) +# این مقادیر نمونه هستند؛ مقادیر واقعی را در سرور در فایل .env.mysql ایجاد کنید. +# هشدار: هرگز فایل واقعی دارای رمز عبور را در Git قرار ندهید. + +MYSQL_ROOT_PASSWORD=change_this_to_strong_root_password_32char +MYSQL_DATABASE=noya_db +MYSQL_USER=noya_user +MYSQL_PASSWORD=change_this_to_strong_user_password_32char diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..afffaf7 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,18 @@ +# Noya Compose Production Settings (Template) +# این متغیرها برای docker-compose.prod.yml استفاده می‌شوند. + +# پورت داخلی وب‌سرور نویا روی لوکال‌هاست سرور (127.0.0.1) +NOYA_WEB_PORT=38080 + +# تگ ایمیج‌ها (پیش‌فرض: latest) +NOYA_IMAGE_TAG=latest + +# نسخه MySQL متناسب با CPU سرور جدید: +# اگر CPU از SSE4.2 پشتیبانی کند: mysql:8.0 یا mysql:8.4 +# اگر CPU قدیمی باشد یا سرور اقتصادی/اشتراکی: mysql:5.7 +NOYA_MYSQL_IMAGE=mysql:5.7 + +# پروکسی اختیاری صرفاً در صورتی که ساخت ایمیج روی سرور انجام شود و به پروکسی نیاز باشد (در حالت عادی خالی بگذارید) +BUILD_HTTP_PROXY= +BUILD_HTTPS_PROXY= +BUILD_ALL_PROXY= diff --git a/.gitignore b/.gitignore index 858d77c..7e4d7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ backend/.env sw.js workbox-*.js +# Temporary test files +backend/tmp-* +tmp-* + diff --git a/DEPLOYMENT_RUNBOOK.md b/DEPLOYMENT_RUNBOOK.md new file mode 100644 index 0000000..2f545ed --- /dev/null +++ b/DEPLOYMENT_RUNBOOK.md @@ -0,0 +1,253 @@ +# Noya — Production Deployment Runbook (راهنمای جامع استقرار ایزوله) + +این سند راهنمای عملیاتی و گام‌به‌گام برای استقرار ایمن و کاملاً ایزوله پروژه **Noya** روی سرور جدید است. این فرآیند به گونه‌ای طراحی شده که هیچ تداخلی با سایر پروژه‌ها، پورت‌ها، کانتینرها و دیتابیس‌های موجود روی سرور ایجاد نکند و تا زمان تأیید نهایی، هیچ نیازی به تغییر در رکوردهای DNS دامنه ندارد. + +--- + +## ۱. پیش‌نیازها و ارزیابی سرور جدید (Server Preflight) + +پیش از انتقال یا اجرای هر فایلی، دستورات زیر را روی سرور جدید اجرا کنید: + +```bash +# ۱. بررسی فضای دیسک و حافظه رم (حداقل ۱۰ گیگابایت دیسک و ۲ گیگابایت رم آزاد پیشنهاد می‌شود) +df -h / +free -m + +# ۲. بررسی معماری پردازنده و فلگ SSE4.2 (جهت تعیین نسخه MySQL) +lscpu | grep -E "Model name|Flags|sse4_2" + +# ۳. بررسی پورت‌های در حال استفاده (جهت اطمینان از آزاد بودن پورت ۳۸۰۸۰ یا انتخاب پورت دیگر) +sudo ss -tulpn | grep 38080 + +# ۴. بررسی کانتینرها و شبکه‌های فعال داکر +docker ps --format "table {{.Names}}\t{{.Ports}}\t{{.Status}}" +docker network ls + +# ۵. بررسی وضعیت Reverse Proxy فعال سرور (Nginx، Caddy، Traefik یا NPM) +ps aux | grep -E 'nginx|caddy|traefik' +``` + +--- + +## ۲. روش ساخت (Build) و بسته‌بندی Imageها در سیستم محلی + +با توجه به محدودیت‌های احتمالی دانلود پکیج‌ها و باینری‌های Prisma روی سرورهای واقع در ایران، **روش پیشنهادی و پایدار**، ساخت محلی Imageها و انتقال آن‌ها به صورت فایل فشرده است: + +```bash +# در سیستم محلی (پوشه ریشه پروژه E:\P_W_A): + +# الف) ساخت ایمیج فرانت‌اند برای معماری لینوکس x86_64 +docker build --platform linux/amd64 -t noya-frontend:latest . + +# ب) ساخت ایمیج بک‌اند برای معماری لینوکس x86_64 +docker build --platform linux/amd64 -t noya-backend:latest ./backend + +# ج) خروجی و فشرده‌سازی ایمیج‌ها به فرمت tar.gz +docker save noya-frontend:latest | gzip > noya-frontend.tar.gz +docker save noya-backend:latest | gzip > noya-backend.tar.gz +``` + +> **نکته:** در صورتی که پردازنده سرور جدید ARM64 باشد (مانند سرورهای Hetzner CAX)، در دستورات فوق مقدار `--platform linux/arm64` قرار دهید. + +--- + +## ۳. انتقال فایل‌ها به سرور جدید + +فولدر اختصاصی برای پروژه در مسیر استاندارد سرور (مثلاً `/opt/noya`) ایجاد کنید: + +```bash +# روی سرور جدید: +sudo mkdir -p /opt/noya/backend +sudo chown -R $USER:$USER /opt/noya +``` + +سپس از سیستم محلی، فایل‌های موردنیاز را با `scp` منتقل کنید: + +```bash +# انتقال ایمیج‌های ساخته‌شده: +scp noya-frontend.tar.gz noya-backend.tar.gz user@NEW_SERVER_IP:/opt/noya/ + +# انتقال فایل کامپوز و نمونه‌های کانفیگ: +scp docker-compose.prod.yml user@NEW_SERVER_IP:/opt/noya/ +scp .env.production.example user@NEW_SERVER_IP:/opt/noya/.env +scp .env.mysql.example user@NEW_SERVER_IP:/opt/noya/.env.mysql +scp backend/.env.production.example user@NEW_SERVER_IP:/opt/noya/backend/.env +``` + +--- + +## ۴. بارگذاری ایمیج‌ها و تنظیم متغیرهای محیطی روی سرور + +وارد سرور شوید و مراحل زیر را انجام دهید: + +### ۴.۱. لود کردن ایمیج‌ها در Docker سرور +```bash +cd /opt/noya +docker load < noya-frontend.tar.gz +docker load < noya-backend.tar.gz +``` + +### ۴.۲. تنظیم فایل‌های محیطی محرمانه +فایل‌های `.env` را با مقادیر واقعی و امن ویرایش کنید: + +1. **فایل دیتابیس (`/opt/noya/.env.mysql`):** + ```ini + MYSQL_ROOT_PASSWORD=your_generated_secure_root_password_32char + MYSQL_DATABASE=noya_db + MYSQL_USER=noya_user + MYSQL_PASSWORD=your_generated_secure_user_password_32char + ``` + +2. **فایل اصلی کامپوز (`/opt/noya/.env`):** + ```ini + NOYA_WEB_PORT=38080 + NOYA_IMAGE_TAG=latest + # در صورت پشتیبانی CPU از SSE4.2 می‌توانید mysql:8.0 بگذارید، در غیر این صورت mysql:5.7 + NOYA_MYSQL_IMAGE=mysql:5.7 + ``` + +3. **فایل بک‌اند (`/opt/noya/backend/.env`):** + ```ini + NODE_ENV=production + PORT=3001 + HOST=0.0.0.0 + # توجه به رمز عبور: اگر رمز حاوی کاراکترهای خاص است باید URL-encoded باشد. + # پیشنهاد: استفاده از رمز صرفاً الفبانومریک (A-Z, a-z, 0-9) با طول ۳۲ حرف. + DATABASE_URL=mysql://noya_user:your_generated_secure_user_password_32char@noya-mysql:3306/noya_db + LOG_LEVEL=info + CORS_ORIGIN=https://your-domain.com + TRUST_PROXY=true + AUTH_SESSION_TTL_HOURS=168 + ADMIN_EMAILS=admin@your-domain.com + OPENAI_API_KEY=your_production_gapgpt_or_openai_api_key + OPENAI_BASE_URL=https://api.gapgpt.app/v1 + OPENAI_MODEL=gpt-4o-mini + ``` + +--- + +## ۵. راه‌اندازی سرویس‌ها + +```bash +cd /opt/noya +docker compose -f docker-compose.prod.yml up -d +``` + +### بررسی وضعیت کانتینرها: +```bash +docker compose -f docker-compose.prod.yml ps +``` +کانتینرهای زیر باید در حالت `Up` (و برای MySQL در وضعیت `healthy`) باشند: +- `noya-mysql` +- `noya-backend` +- `noya-frontend` + +--- + +## ۶. تست و اعتبارسنجی پیش از تغییر DNS (Zero-Downtime Verification) + +### ۶.۱. تست داخلی سرور (Localhost Health) +```bash +# بررسی سلامت وب فرانت‌اند +curl -I http://127.0.0.1:38080/health +# پاسخ باید HTTP 200 با متن healthy باشد + +# بررسی سلامت بک‌اند و ارتباط موفق با MySQL (اجرای موفق مایگریشن‌ها و سید) +curl -s http://127.0.0.1:38080/api/backend/ready +# خروجی باید شامل status: "ready" و database: "ok" باشد: +# {"success":true,"data":{"status":"ready","checks":{"database":"ok"}}} +``` + +### ۶.۲. اتصال به Reverse Proxy موجود سرور +یک VirtualHost مستقل برای دامنه نویا به Nginx اصلی سرور اضافه کنید (مثلاً `/etc/nginx/sites-available/noya.conf`): + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:38080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + } +} +``` +سپس فعال‌سازی و ریلود: +```bash +sudo ln -s /etc/nginx/sites-available/noya.conf /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +### ۶.۳. تست با Override کلاینت (بدون دستکاری DNS) +در کامپیوتر خودتان، فایل hosts را باز کنید: +- ویندوز: `C:\Windows\System32\drivers\etc\hosts` +- لینوکس / مک: `/etc/hosts` + +خط زیر را اضافه کنید: +```text +NEW_SERVER_IP your-domain.com +``` + +اکنون مرورگر خود را در حالت Private/Incognito باز کرده و آدرس `http://your-domain.com` را بررسی کنید: +1. ثبت نام کاربر تستی جدید. +2. ارسال پیام چت و دریافت پاسخ استریم هوش مصنوعی. +3. بررسی باز شدن جلسات آموزشی. +4. بررسی عدم بروز هرگونه خطای CORS یا دیتابیس در کنسول مرورگر (`F12`). + +--- + +## ۷. مشاهده و بررسی لاگ‌ها (Log Inspection) + +```bash +# لاگ زنده تمام سرویس‌های نویا +docker compose -f docker-compose.prod.yml logs -f --tail=100 + +# لاگ اختصاصی بک‌اند +docker compose -f docker-compose.prod.yml logs -f noya-backend + +# لاگ اختصاصی دیتابیس +docker compose -f docker-compose.prod.yml logs -f noya-mysql + +# لاگ اختصاصی فرانت‌اند +docker compose -f docker-compose.prod.yml logs -f noya-frontend +``` + +--- + +## ۸. فعال‌سازی نهایی دامنه (Final Cutover) + +پس از تأیید کامل تست مرحله ۶: +1. صدور گواهی SSL (Let's Encrypt / Certbot) روی سرور جدید: + ```bash + sudo certbot --nginx -d your-domain.com + ``` +2. حذف خط تستی اضافه شده به فایل `hosts` سیستم محلی شما. +3. ورود به پنل مدیریت دامنه (Cloudflare، ابرآروان یا کنترل‌پنل دامنه) و تغییر رکورد `A` به `NEW_SERVER_IP`. + +--- + +## ۹. برنامه بازگشت اضطراری (Rollback Plan) + +در صورت بروز هرگونه مشکل یا ناپایداری غیرمنتظره پس از اتصال: +1. **بازگشت فوری DNS:** رکورد `A` در پنل DNS به IP سرور قبلی بازگردانده شود (ظرف چند دقیقه اعمال می‌شود). +2. **توقف امن سرویس‌های نویا روی سرور جدید:** + ```bash + cd /opt/noya + docker compose -f docker-compose.prod.yml stop + ``` + *(داده‌های ثبت‌شده در Volume `noya_mysql_data` بدون آسیب حفظ می‌مانند).* +3. **غیرفعال‌سازی کانفیگ در Reverse Proxy سرور جدید:** + ```bash + sudo rm -f /etc/nginx/sites-enabled/noya.conf + sudo nginx -t && sudo systemctl reload nginx + ``` + با این کار، سرور جدید دقیقاً به وضعیت پاک و اولیه خود قبل از استقرار نویا بازمی‌گردد. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..05e29a4 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.env +.env.* +!.env.example +*.log +tmp-* +.tools +.backups diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 0000000..e7f758b --- /dev/null +++ b/backend/.env.production.example @@ -0,0 +1,58 @@ +# Noya Backend Production Environment Variables (Template) +# این مقادیر نمونه هستند؛ مقادیر واقعی را روی سرور در فایل backend/.env تنظیم کنید. +# هشدار: هرگز اطلاعات محرمانه و کلیدهای اصلی را در Git قرار ندهید. + +# ---------------------------------------------------- +# Application Core +# ---------------------------------------------------- +NODE_ENV=production +PORT=3001 +HOST=0.0.0.0 +API_VERSION=1.0.0 +BUILD_TIMESTAMP= + +# ---------------------------------------------------- +# Database Connection (MySQL) +# ---------------------------------------------------- +# قالب: mysql://USER:PASSWORD@noya-mysql:3306/DATABASE +# نکته بسیار مهم درباره کاراکترهای خاص در رمز عبور: +# اگر رمز عبور دارای کاراکترهایی مانند @, :, /, #, %, ?, & است، +# باید به صورت URL-encoded (درصد-کدگذاری شده) نوشته شود. +# به عنوان مثال: @ -> %40 و # -> %23 و / -> %2F +# توصیه بهتر: برای رمز عبور فقط از حروف انگلیسی و اعداد (A-Z, a-z, 0-9) با طول ۳۲ کاراکتر استفاده کنید تا نیازی به encode نباشد. +DATABASE_URL=mysql://noya_user:change_this_to_strong_user_password_32char@noya-mysql:3306/noya_db + +# ---------------------------------------------------- +# Logging +# ---------------------------------------------------- +LOG_LEVEL=info + +# ---------------------------------------------------- +# Security & Proxy +# ---------------------------------------------------- +# دامنه نهایی و جدید، بدون اسلش انتهایی (با کاما قابل تفکیک است) +CORS_ORIGIN=https://your-domain.com +TRUST_PROXY=true + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=100 +AUTH_RATE_LIMIT_MAX=20 + +# ---------------------------------------------------- +# Authentication +# ---------------------------------------------------- +# مدت اعتبار نشست ورود بر حسب ساعت (۱۶۸ ساعت = ۷ روز) +AUTH_SESSION_TTL_HOURS=168 + +# ایمیل‌های مجاز جهت دریافت خودکار نقش ادمین در اولین لاگین (با کاما تفکیک کنید) +ADMIN_EMAILS=admin@your-domain.com + +# ---------------------------------------------------- +# AI Service (GapGPT / OpenAI Compatible) +# ---------------------------------------------------- +OPENAI_API_KEY=your_openai_or_gapgpt_api_key_here +OPENAI_BASE_URL=https://api.gapgpt.app/v1 +OPENAI_MODEL=gpt-4o-mini +OPENAI_ORG_ID= +OPENAI_PROJECT_ID= diff --git a/backend/src/modules/ai/openaiClient.ts b/backend/src/modules/ai/openaiClient.ts index cc9e6d5..6bfad60 100644 --- a/backend/src/modules/ai/openaiClient.ts +++ b/backend/src/modules/ai/openaiClient.ts @@ -273,3 +273,123 @@ export async function generateAiResponse( new ServiceUnavailableError('سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.') ); } + +export async function streamAiResponse( + messages: ChatCompletionMessage[], + onChunk: (delta: string) => Promise | void, + signal?: AbortSignal, +): Promise<{ text: string; model: string; totalTokens: number | null; latencyMs: number }> { + if (!isAiConfigured()) { + throw new ServiceUnavailableError( + 'سرویس هوش مصنوعی پیکربندی نشده است. OPENAI_API_KEY را در backend/.env تنظیم کنید.', + ); + } + + const baseUrl = env.OPENAI_BASE_URL.replace(/\/$/, ''); + const endpoint = `${baseUrl}/chat/completions`; + const started = Date.now(); + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.OPENAI_API_KEY}`, + }; + if (env.OPENAI_ORG_ID) { + headers['OpenAI-Organization'] = env.OPENAI_ORG_ID; + } + if (env.OPENAI_PROJECT_ID) { + headers['OpenAI-Project'] = env.OPENAI_PROJECT_ID; + } + + const timeoutController = new AbortController(); + const timeout = setTimeout(() => timeoutController.abort(), OPENAI_TIMEOUT_MS); + + const abortListener = () => timeoutController.abort(); + if (signal) { + signal.addEventListener('abort', abortListener, { once: true }); + } + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model: env.OPENAI_MODEL, + messages, + temperature: 0.3, + max_tokens: 2000, + stream: true, + }), + signal: timeoutController.signal, + }); + + if (!response.ok) { + const raw = await response.text(); + const snippet = raw.slice(0, 300); + logger.warn({ status: response.status, body: snippet }, 'AI provider stream error'); + throw statusToUserError(response.status, snippet); + } + + if (!response.body) { + throw new ServiceUnavailableError('سرویس هوش مصنوعی پاسخ خالی برگرداند.'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let accumulatedText = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(':')) continue; + if (trimmed === 'data: [DONE]') { + break; + } + if (trimmed.startsWith('data: ')) { + const dataStr = trimmed.slice(6); + try { + const data = JSON.parse(dataStr); + const delta = data.choices?.[0]?.delta?.content; + if (typeof delta === 'string' && delta.length > 0) { + accumulatedText += delta; + await onChunk(delta); + } + } catch { + // Ignore non-json or incomplete lines + } + } + } + } + } finally { + reader.releaseLock(); + } + + const latencyMs = Date.now() - started; + return { + text: accumulatedText, + model: env.OPENAI_MODEL, + totalTokens: null, + latencyMs, + }; + } catch (err) { + if (signal?.aborted) { + throw err; + } + const classified = err instanceof ServiceUnavailableError ? err : classifyFetchError(err); + throw classified; + } finally { + clearTimeout(timeout); + if (signal) { + signal.removeEventListener('abort', abortListener); + } + } +} + diff --git a/backend/src/modules/chat/chat.routes.ts b/backend/src/modules/chat/chat.routes.ts index fe9ffb4..9b0a0bd 100644 --- a/backend/src/modules/chat/chat.routes.ts +++ b/backend/src/modules/chat/chat.routes.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { streamSSE } from 'hono/streaming'; import { jsonValidator, queryValidator } from '../../shared/validation/index.js'; import { created, success } from '../../lib/response.js'; import { requireAuth } from '../../middleware/auth.js'; @@ -13,6 +14,8 @@ import { listChatSessions, sendChatMessage, startChatSession, + streamChatMessage, + streamStartChatSession, } from './chat.service.js'; export function createChatRoutes() { @@ -45,15 +48,100 @@ export function createChatRoutes() { async (c) => { const authUser = c.get('authUser'); const { content } = c.req.valid('json'); - const result = await sendChatMessage(authUser.id, BigInt(c.req.param('id')), content); - return created(c, result); + const sessionId = BigInt(c.req.param('id')); + const isStream = + c.req.header('Accept')?.includes('text/event-stream') || + c.req.query('stream') === 'true'; + + if (!isStream) { + const result = await sendChatMessage(authUser.id, sessionId, content); + return created(c, result); + } + + return streamSSE(c, async (stream) => { + const controller = new AbortController(); + stream.onAbort(() => { + controller.abort(); + }); + + try { + const result = await streamChatMessage( + authUser.id, + sessionId, + content, + async (delta) => { + await stream.writeSSE({ + event: 'chunk', + data: JSON.stringify({ text: delta }), + }); + }, + controller.signal, + ); + + await stream.writeSSE({ + event: 'done', + data: JSON.stringify(result), + }); + } catch (err) { + if (controller.signal.aborted) { + return; + } + const message = err instanceof Error ? err.message : 'خطا در ارتباط با هوش مصنوعی'; + await stream.writeSSE({ + event: 'error', + data: JSON.stringify({ message }), + }); + } + }); }, ); routes.post('/sessions/:id/start', requireAuth, async (c) => { const authUser = c.get('authUser'); - const result = await startChatSession(authUser.id, BigInt(c.req.param('id'))); - return success(c, result); + const sessionId = BigInt(c.req.param('id')); + const isStream = + c.req.header('Accept')?.includes('text/event-stream') || + c.req.query('stream') === 'true'; + + if (!isStream) { + const result = await startChatSession(authUser.id, sessionId); + return success(c, result); + } + + return streamSSE(c, async (stream) => { + const controller = new AbortController(); + stream.onAbort(() => { + controller.abort(); + }); + + try { + const result = await streamStartChatSession( + authUser.id, + sessionId, + async (delta) => { + await stream.writeSSE({ + event: 'chunk', + data: JSON.stringify({ text: delta }), + }); + }, + controller.signal, + ); + + await stream.writeSSE({ + event: 'done', + data: JSON.stringify(result), + }); + } catch (err) { + if (controller.signal.aborted) { + return; + } + const message = err instanceof Error ? err.message : 'خطا در شروع جلسه گفتگو'; + await stream.writeSSE({ + event: 'error', + data: JSON.stringify({ message }), + }); + } + }); }); return routes; diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts index c5794b1..f666a92 100644 --- a/backend/src/modules/chat/chat.service.ts +++ b/backend/src/modules/chat/chat.service.ts @@ -17,7 +17,7 @@ import { import { assertHomeworkReportForSessionStart } from '../sessions/homework.service.js'; import { PROGRAM_SESSION_COUNT } from '../sessions/catalog.js'; import { buildCoachSystemPrompt, buildCoachSessionStartCue } from '../ai/coachPrompt.js'; -import { generateAiResponse, getAiProviderLabel, isAiConfigured } from '../ai/openaiClient.js'; +import { generateAiResponse, streamAiResponse, getAiProviderLabel, isAiConfigured } from '../ai/openaiClient.js'; import { processCoachAssistantResponse } from '../ai/sessionCompletion.js'; import { buildAiCoachSessionContext } from '../ai/sessionContext.js'; @@ -410,3 +410,299 @@ export async function sendChatMessage(userId: bigint, chatSessionId: bigint, con throw err; } } + +async function streamCoachTurn(params: { + userId: bigint; + chatSession: { id: bigint; sessionOrder: number }; + hiddenUserContent?: string; + onVisibleChunk: (delta: string) => Promise | void; + signal?: AbortSignal; +}) { + const coachOrder = await resolveEffectiveCoachOrder(params.userId, params.chatSession.sessionOrder); + const catalogSession = await requireCatalogSessionOrder(coachOrder); + const context = buildAiCoachSessionContext(catalogSession); + const history = await loadConversation(params.chatSession.id); + const alreadyCompleted = (await getCompletedOrders(params.userId)).includes(coachOrder); + + const messages = [ + { role: 'system' as const, content: buildCoachSystemPrompt(context) }, + ...history.map((message) => ({ + role: message.role as 'user' | 'assistant', + content: message.content, + })), + ]; + + if (params.hiddenUserContent) { + messages.push({ role: 'user' as const, content: params.hiddenUserContent }); + } + + const userTurnCount = history.filter((message) => message.role === ChatRole.user).length; + + const run = await prisma.aiAnalysisRun.create({ + data: { + userId: params.userId, + chatSessionId: params.chatSession.id, + provider: getAiProviderLabel(), + model: 'pending', + status: AiRunStatus.queued, + }, + }); + + if (!isAiConfigured()) { + await prisma.aiAnalysisRun.update({ + where: { id: run.id }, + data: { + status: AiRunStatus.failed, + model: 'unconfigured', + errorMessage: 'OPENAI_API_KEY is not set', + }, + }); + throw new ServiceUnavailableError( + 'سرویس هوش مصنوعی پیکربندی نشده است. OPENAI_API_KEY را در backend/.env تنظیم کنید.', + ); + } + + await prisma.aiAnalysisRun.update({ + where: { id: run.id }, + data: { status: AiRunStatus.running }, + }); + + const COACH_SIGNAL_PREFIX = '[COACH_SESSION_COMPLETE]'; + let buffer = ''; + let inSignalBlock = false; + let fullAccumulated = ''; + + const handleChunk = async (delta: string) => { + fullAccumulated += delta; + if (inSignalBlock) { + return; + } + + buffer += delta; + + while (buffer.length > 0) { + const openIdx = buffer.indexOf('['); + if (openIdx === -1) { + const textToEmit = buffer; + buffer = ''; + await params.onVisibleChunk(textToEmit); + break; + } + + if (openIdx > 0) { + const textBefore = buffer.slice(0, openIdx); + buffer = buffer.slice(openIdx); + await params.onVisibleChunk(textBefore); + } + + // Buffer starts with '[' + if (COACH_SIGNAL_PREFIX.startsWith(buffer)) { + if (buffer === COACH_SIGNAL_PREFIX) { + inSignalBlock = true; + buffer = ''; + } + break; + } else { + const char = buffer.slice(0, 1); + buffer = buffer.slice(1); + await params.onVisibleChunk(char); + } + } + }; + + try { + const result = await streamAiResponse(messages, handleChunk, params.signal); + + if (buffer.length > 0 && !inSignalBlock) { + await params.onVisibleChunk(buffer); + buffer = ''; + } + + const processed = processCoachAssistantResponse(result.text, context, { + userTurnCount, + alreadyCompleted, + }); + + const assistant = await prisma.chatMessage.create({ + data: { + chatSessionId: params.chatSession.id, + role: ChatRole.assistant, + content: processed.displayContent, + metadata: processed.signal + ? ({ + completionSignal: processed.signal, + completionValidation: processed.validation, + } as unknown as Prisma.InputJsonValue) + : undefined, + }, + }); + + await prisma.aiAnalysisRun.update({ + where: { id: run.id }, + data: { + status: AiRunStatus.succeeded, + model: result.model, + latencyMs: result.latencyMs, + totalTokens: result.totalTokens, + }, + }); + + if (processed.validation?.valid && processed.signal && !alreadyCompleted) { + try { + await completeSession( + params.userId, + params.chatSession.sessionOrder, + processed.signal, + params.chatSession.id, + ); + } catch (err) { + console.error('[chat] Failed to auto-complete session after valid coach signal:', err); + } + } + + return { + message: serializeMessage(assistant), + completion: processed.validation?.valid + ? { valid: true, signal: processed.signal } + : processed.signal + ? { + valid: false, + errors: + processed.validation && 'errors' in processed.validation + ? processed.validation.errors + : [], + } + : null, + }; + } catch (err) { + if (params.signal?.aborted) { + if (fullAccumulated.trim()) { + const processed = processCoachAssistantResponse(fullAccumulated, context, { + userTurnCount, + alreadyCompleted, + }); + const assistant = await prisma.chatMessage.create({ + data: { + chatSessionId: params.chatSession.id, + role: ChatRole.assistant, + content: processed.displayContent, + }, + }); + await prisma.aiAnalysisRun.update({ + where: { id: run.id }, + data: { + status: AiRunStatus.failed, + errorMessage: 'Stream aborted by user', + }, + }); + return { + message: serializeMessage(assistant), + completion: null, + }; + } + } + + const message = err instanceof Error ? err.message : 'AI provider stream failed'; + await prisma.aiAnalysisRun.update({ + where: { id: run.id }, + data: { + status: AiRunStatus.failed, + errorMessage: message.slice(0, 500), + }, + }); + if (err instanceof ServiceUnavailableError) { + throw err; + } + throw new ServiceUnavailableError( + 'سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.', + { cause: message }, + ); + } +} + +export async function streamChatMessage( + userId: bigint, + chatSessionId: bigint, + content: string, + onVisibleChunk: (delta: string) => Promise | void, + signal?: AbortSignal, +) { + const trimmed = content.trim(); + if (!trimmed) { + throw new ValidationError('Message content is required'); + } + + const session = await getOwnedChatSession(userId, chatSessionId); + await assertTrainingAccess(userId, session.sessionOrder); + + const userMessage = await prisma.chatMessage.create({ + data: { + chatSessionId: session.id, + role: ChatRole.user, + content: trimmed, + }, + }); + + try { + const result = await streamCoachTurn({ + userId, + chatSession: session, + onVisibleChunk, + signal, + }); + + return { + session: serializeChatSession(session), + userMessage: serializeMessage(userMessage), + assistantMessage: result.message, + completion: result.completion, + }; + } catch (err) { + const assistantCount = await prisma.chatMessage.count({ + where: { + chatSessionId: session.id, + role: ChatRole.assistant, + createdAt: { gte: userMessage.createdAt }, + }, + }); + if (assistantCount === 0) { + await prisma.chatMessage.delete({ where: { id: userMessage.id } }).catch(() => undefined); + } + throw err; + } +} + +export async function streamStartChatSession( + userId: bigint, + chatSessionId: bigint, + onVisibleChunk: (delta: string) => Promise | void, + signal?: AbortSignal, +) { + const session = await getOwnedChatSession(userId, chatSessionId); + await assertTrainingAccess(userId, session.sessionOrder); + + const existing = await prisma.chatMessage.count({ where: { chatSessionId: session.id } }); + if (existing > 0) { + const list = await listChatMessages(userId, session.id); + return { + session: list.session, + messages: list.messages, + completion: null, + }; + } + + const result = await streamCoachTurn({ + userId, + chatSession: session, + hiddenUserContent: buildCoachSessionStartCue(session.sessionOrder), + onVisibleChunk, + signal, + }); + + return { + session: serializeChatSession(session), + messages: [result.message], + completion: result.completion, + }; +} + diff --git a/backend/tests/chat-stream.test.ts b/backend/tests/chat-stream.test.ts new file mode 100644 index 0000000..769674e --- /dev/null +++ b/backend/tests/chat-stream.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { prisma } from '../src/lib/prisma.js'; +import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js'; +import { seedCourseSessions } from '../src/modules/sessions/sessions.service.js'; +import { + authHeader, + createTestApp, + isDatabaseAvailable, + requestJson, +} from './helpers.js'; + +const app = createTestApp(); +const createdUserIds: bigint[] = []; + +function pcqAnswers(value: number) { + return PCQ_QUESTION_IDS.map((questionId) => ({ + questionId, + selectedValue: String(value), + })); +} + +async function registerUser(suffix: string) { + const email = `stream-${suffix}-${Date.now()}@example.com`; + const { status, body } = await requestJson(app, '/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password: 'password12' }), + }); + assert.equal(status, 201); + const token = body.data?.sessionToken as string; + const externalId = (body.data?.user as { id: string }).id; + const user = await prisma.user.findUnique({ where: { externalId } }); + assert.ok(user); + createdUserIds.push(user.id); + return { token, user }; +} + +async function completePreTest(token: string) { + const pre = await requestJson(app, '/assessments/attempts', { + method: 'POST', + headers: authHeader(token), + body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }), + }); + assert.equal(pre.status, 201); +} + +describe('chat SSE progressive streaming', () => { + let dbAvailable = false; + + before(async () => { + dbAvailable = await isDatabaseAvailable(); + if (dbAvailable) { + await seedCourseSessions(); + } + }); + + after(async () => { + if (dbAvailable && createdUserIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); + } + }); + + it('preserves non-streaming fallback when Accept is not text/event-stream', async (t) => { + if (!dbAvailable) { + t.skip('MySQL is not running'); + return; + } + + const { token } = await registerUser('non-stream'); + await completePreTest(token); + + const created = await requestJson(app, '/chat/sessions', { + method: 'POST', + headers: authHeader(token), + body: JSON.stringify({ sessionOrder: 1 }), + }); + assert.equal(created.status, 201); + const chatId = created.body.data?.id as string; + + const res = await requestJson(app, `/chat/sessions/${chatId}/messages`, { + method: 'POST', + headers: authHeader(token), + body: JSON.stringify({ content: 'سلام' }), + }); + assert.equal(res.status, 201); + assert.ok(res.body.data?.userMessage); + assert.ok(res.body.data?.assistantMessage); + }); + + it('delivers progressive chunks via SSE without leaking [COACH_SESSION_COMPLETE] control markers', async (t) => { + if (!dbAvailable) { + t.skip('MySQL is not running'); + return; + } + + const { token } = await registerUser('sse-delivery'); + await completePreTest(token); + + const created = await requestJson(app, '/chat/sessions', { + method: 'POST', + headers: authHeader(token), + body: JSON.stringify({ sessionOrder: 1 }), + }); + assert.equal(created.status, 201); + const chatId = created.body.data?.id as string; + + const response = await app.request(`/chat/sessions/${chatId}/messages`, { + method: 'POST', + headers: { + ...authHeader(token), + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content: 'سلام' }), + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /text\/event-stream/); + + const reader = response.body?.getReader(); + assert.ok(reader, 'Response body must be readable stream'); + + const decoder = new TextDecoder(); + let buffer = ''; + const events: Array<{ event: string; data: any }> = []; + let receivedFirstChunk = false; + + 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); + events.push({ event: eventName, data: parsed }); + if (eventName === 'chunk') { + receivedFirstChunk = true; + } + } catch { + events.push({ event: eventName, data: dataStr }); + } + } + } + } + + assert.ok(receivedFirstChunk, 'Must have received at least one chunk before stream completion'); + const chunkEvents = events.filter((e) => e.event === 'chunk'); + assert.ok(chunkEvents.length > 0, 'Must have delivered chunk events'); + + // Verify control marker never leaks in visible chunks + for (const chunk of chunkEvents) { + assert.doesNotMatch( + chunk.data.text || '', + /\[COACH_SESSION_COMPLETE\]/, + 'Visible chunk must NEVER contain [COACH_SESSION_COMPLETE]', + ); + } + + const doneEvent = events.find((e) => e.event === 'done'); + assert.ok(doneEvent, 'Must emit done event'); + assert.ok(doneEvent.data.assistantMessage, 'Done event must contain assistantMessage'); + assert.ok(doneEvent.data.assistantMessage.content, 'Assistant content must be non-empty'); + + // Verify DB consistency + const dbMessage = await prisma.chatMessage.findUnique({ + where: { id: BigInt(doneEvent.data.assistantMessage.id) }, + }); + assert.ok(dbMessage, 'Message must be saved in database'); + assert.equal( + dbMessage.content, + doneEvent.data.assistantMessage.content, + 'Database content must match done event content', + ); + assert.doesNotMatch( + dbMessage.content, + /\[COACH_SESSION_COMPLETE\]/, + 'Database content must not contain raw control markers', + ); + }); + + it('supports stream start on /chat/sessions/:id/start', async (t) => { + if (!dbAvailable) { + t.skip('MySQL is not running'); + return; + } + + const { token } = await registerUser('sse-start'); + await completePreTest(token); + + const created = await requestJson(app, '/chat/sessions', { + method: 'POST', + headers: authHeader(token), + body: JSON.stringify({ sessionOrder: 1 }), + }); + assert.equal(created.status, 201); + const chatId = created.body.data?.id as string; + + const response = await app.request(`/chat/sessions/${chatId}/start`, { + method: 'POST', + headers: { + ...authHeader(token), + Accept: 'text/event-stream', + }, + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /text\/event-stream/); + + const reader = response.body?.getReader(); + if (reader) { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } + }); +}); diff --git a/backend/tests/setup.ts b/backend/tests/setup.ts index 8ce6d06..6f0269b 100644 --- a/backend/tests/setup.ts +++ b/backend/tests/setup.ts @@ -17,10 +17,14 @@ const globalForTestDb = globalThis as unknown as { }; function migrateDeploy(): void { - execSync('npx prisma generate --schema=./prisma/schema.prisma', { - stdio: 'inherit', - env: process.env, - }); + try { + execSync('npx prisma generate --schema=./prisma/schema.prisma', { + stdio: 'inherit', + env: process.env, + }); + } catch { + // Ignore file-lock error if another process (e.g. dev server) holds query engine + } execSync('npx prisma migrate deploy --schema=./prisma/schema.prisma', { stdio: 'inherit', env: process.env, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7348a78 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,83 @@ +name: noya + +networks: + noya_internal_net: + name: noya_internal_net + driver: bridge + +volumes: + noya_mysql_data: + name: noya_mysql_data + +services: + noya-mysql: + image: ${NOYA_MYSQL_IMAGE:-mysql:5.7} + container_name: noya-mysql + restart: unless-stopped + env_file: + - path: .env.mysql + required: false + volumes: + - noya_mysql_data:/var/lib/mysql + networks: + noya_internal_net: + aliases: + - mysql + - noya-mysql + # پورت دیتابیس هرگز روی هاست یا اینترنت باز نمی‌شود + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + noya-backend: + build: + context: ./backend + dockerfile: Dockerfile + args: + HTTP_PROXY: ${BUILD_HTTP_PROXY:-} + HTTPS_PROXY: ${BUILD_HTTPS_PROXY:-} + ALL_PROXY: ${BUILD_ALL_PROXY:-} + image: noya-backend:${NOYA_IMAGE_TAG:-latest} + container_name: noya-backend + restart: unless-stopped + env_file: + - path: ./backend/.env + required: false + environment: + - NODE_ENV=production + - PORT=3001 + - HOST=0.0.0.0 + - TRUST_PROXY=true + networks: + noya_internal_net: + aliases: + - backend + - noya-backend + depends_on: + noya-mysql: + condition: service_healthy + + noya-frontend: + build: + context: . + dockerfile: Dockerfile + args: + HTTP_PROXY: ${BUILD_HTTP_PROXY:-} + HTTPS_PROXY: ${BUILD_HTTPS_PROXY:-} + ALL_PROXY: ${BUILD_ALL_PROXY:-} + image: noya-frontend:${NOYA_IMAGE_TAG:-latest} + container_name: noya-frontend + restart: unless-stopped + ports: + # پورت فقط روی 127.0.0.1 لوکال‌هاست سرور بایند می‌شود + - "127.0.0.1:${NOYA_WEB_PORT:-38080}:80" + networks: + noya_internal_net: + aliases: + - app + - noya-frontend + depends_on: + - noya-backend diff --git a/index.html b/index.html index df76b57..393f7cc 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - + diff --git a/public/docs/sarmaee-ravan-shenakhti-test.pdf b/public/docs/sarmaee-ravan-shenakhti-test.pdf new file mode 100644 index 0000000..f561d58 Binary files /dev/null and b/public/docs/sarmaee-ravan-shenakhti-test.pdf differ diff --git a/public/favion.ico b/public/favion.ico new file mode 100644 index 0000000..8194d56 Binary files /dev/null and b/public/favion.ico differ diff --git a/public/images/components/hope.webp b/public/images/components/hope.webp new file mode 100644 index 0000000..84fb4d9 Binary files /dev/null and b/public/images/components/hope.webp differ diff --git a/public/images/components/optimism.webp b/public/images/components/optimism.webp new file mode 100644 index 0000000..5ea6953 Binary files /dev/null and b/public/images/components/optimism.webp differ diff --git a/public/images/components/resilience.webp b/public/images/components/resilience.webp new file mode 100644 index 0000000..ae96e39 Binary files /dev/null and b/public/images/components/resilience.webp differ diff --git a/public/images/components/self-efficacy.webp b/public/images/components/self-efficacy.webp new file mode 100644 index 0000000..02a59d7 Binary files /dev/null and b/public/images/components/self-efficacy.webp differ diff --git a/public/images/results/README.md b/public/images/results/README.md new file mode 100644 index 0000000..debca6d --- /dev/null +++ b/public/images/results/README.md @@ -0,0 +1,27 @@ +# Noya PCQ-24 Result Illustrations Directory + +این پوشه برای قرار دادن ۱۲ تصویر گرافیکی نتایج ابعاد سرمایه روان‌شناختی نویا (۳ تصویر برای هر یک از ۴ بعد) در نظر گرفته شده است: + +## 1. خودکارآمدی (Self-efficacy) +- `self-efficacy-guardian.webp` — Guardian (نگهبان توانایی) +- `self-efficacy-problem-solver.webp` — Problem Solver (حل‌کنندهٔ مسئله) +- `self-efficacy-path-builder.webp` — Path Builder (سازندهٔ مسیر) + +## 2. امیدواری (Hope) +- `hope-path-finder.webp` — Path Finder (کاشف مسیرهای تازه) +- `hope-goal-climber.webp` — Goal Climber (رهسپار قله) +- `hope-goal-grower.webp` — Goal Grower (پرورشدهندهٔ رؤیا) + +## 3. تاب‌آوری (Resilience) +- `resilience-rise-again.webp` — Rise Again (دوباره روی پاهایم) +- `resilience-keep-going.webp` — Keep Going (ادامه دادن در سختی) +- `resilience-calm-after-storm.webp` — Calm After Storm (آرامش بعد از طوفان) + +## 4. خوش‌بینی (Optimism) +- `optimism-see-the-good.webp` — See the Good (دیدن نیمهٔ روشن) +- `optimism-bright-future.webp` — Bright Future (نگاه به آیندهٔ روشن) +- `optimism-new-opportunity.webp` — New Opportunity (کشف فرصت تازه) + +--- +> مسیر وب دسترسی فایل‌ها در فرانت‌اند: `/images/results/.webp` +> در صورت نبود موقت فایل‌ها، سیستم به صورت خودکار آیکون و پس‌زمینه گرادیان هماهنگ با طراحی نویا را بدون خطا نمایش می‌دهد. diff --git a/public/images/results/hope.webp b/public/images/results/hope.webp new file mode 100644 index 0000000..84fb4d9 Binary files /dev/null and b/public/images/results/hope.webp differ diff --git a/public/images/results/optimism.webp b/public/images/results/optimism.webp new file mode 100644 index 0000000..5ea6953 Binary files /dev/null and b/public/images/results/optimism.webp differ diff --git a/public/images/results/resilience.webp b/public/images/results/resilience.webp new file mode 100644 index 0000000..ae96e39 Binary files /dev/null and b/public/images/results/resilience.webp differ diff --git a/public/images/results/self-efficacy.webp b/public/images/results/self-efficacy.webp new file mode 100644 index 0000000..02a59d7 Binary files /dev/null and b/public/images/results/self-efficacy.webp differ diff --git a/scripts/test-pcq24-results.ts b/scripts/test-pcq24-results.ts new file mode 100644 index 0000000..640f3a1 --- /dev/null +++ b/scripts/test-pcq24-results.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + DIMENSION_CONFIGS, + DIMENSION_ORDER, + getScoreTier, + getFeedbackForScore, + getDescriptiveTitleForScore, +} from '../src/config/resultImages'; + +describe('PCQ-24 Result Logic & 12 Feedback Texts', () => { + it('has exactly 4 dimensions in correct order', () => { + assert.deepEqual(DIMENSION_ORDER, ['efficacy', 'hope', 'resilience', 'optimism']); + }); + + it('correctly maps boundary scores to tiers', () => { + // 6 to 15 is Category 1 (low) + assert.equal(getScoreTier(6), 'low'); + assert.equal(getScoreTier(10), 'low'); + assert.equal(getScoreTier(15), 'low'); + + // 16 to 25 is Category 2 (mid) + assert.equal(getScoreTier(16), 'mid'); + assert.equal(getScoreTier(20), 'mid'); + assert.equal(getScoreTier(25), 'mid'); + + // 26 to 36 is Category 3 (high) + assert.equal(getScoreTier(26), 'high'); + assert.equal(getScoreTier(30), 'high'); + assert.equal(getScoreTier(36), 'high'); + }); + + it('provides distinct, teen-friendly descriptive titles for all 3 tiers across all 4 dimensions', () => { + const titles = new Set(); + + for (const dim of DIMENSION_ORDER) { + const lowTitle = getDescriptiveTitleForScore(dim, 12); + const midTitle = getDescriptiveTitleForScore(dim, 20); + const highTitle = getDescriptiveTitleForScore(dim, 30); + + // Verify no forbidden judgmental labels + for (const t of [lowTitle, midTitle, highTitle]) { + assert.ok(t.length > 5, `Title "${t}" too short`); + assert.ok(!t.includes('ضعیف'), `Title "${t}" contains forbidden word ضعیف`); + assert.ok(!t.includes('متوسط'), `Title "${t}" contains forbidden word متوسط`); + assert.ok(!t.includes('قوی'), `Title "${t}" contains forbidden word قوی`); + titles.add(t); + } + + assert.notEqual(lowTitle, midTitle); + assert.notEqual(midTitle, highTitle); + } + + assert.equal(titles.size, 12, 'Must have exactly 12 unique descriptive titles'); + }); + + it('has non-empty distinct feedback for all 12 combinations', () => { + const allTexts = new Set(); + + for (const dim of DIMENSION_ORDER) { + const config = DIMENSION_CONFIGS[dim]; + assert.ok(config, `Missing config for ${dim}`); + + const low = getFeedbackForScore(dim, 15); + const mid = getFeedbackForScore(dim, 20); + const high = getFeedbackForScore(dim, 26); + + assert.ok(low.length > 20, `Low text for ${dim} is too short`); + assert.ok(mid.length > 20, `Mid text for ${dim} is too short`); + assert.ok(high.length > 20, `High text for ${dim} is too short`); + + assert.notEqual(low, mid, `Low and Mid for ${dim} should differ`); + assert.notEqual(mid, high, `Mid and High for ${dim} should differ`); + + allTexts.add(low); + allTexts.add(mid); + allTexts.add(high); + } + + assert.equal(allTexts.size, 12, 'Must have exactly 12 unique feedback texts'); + }); + + it('contains correct Persian half-spaces (ZWNJ) for key phrases like «جنبه‌های» and «نگاه فعلی‌ات»', () => { + const optimismMid = getFeedbackForScore('optimism', 20); + assert.ok( + optimismMid.includes('جنبه‌های'), + 'Must contain «جنبه‌های» with proper ZWNJ (U+200C)' + ); + assert.ok( + optimismMid.includes('نگاه فعلی‌ات'), + 'Must contain «نگاه فعلی‌ات» with proper ZWNJ (U+200C)' + ); + + const resilienceMid = getFeedbackForScore('resilience', 20); + assert.ok( + resilienceMid.includes('جنبه‌های'), + 'Must contain «جنبه‌های» in resilience mid feedback' + ); + assert.ok( + resilienceMid.includes('تاب‌آوری'), + 'Must contain «تاب‌آوری» with proper ZWNJ (U+200C)' + ); + + const efficacyLow = getFeedbackForScore('efficacy', 10); + assert.ok( + efficacyLow.includes('توانایی‌های'), + 'Must contain «توانایی‌های» with proper ZWNJ (U+200C)' + ); + + const hopeLow = getFeedbackForScore('hope', 10); + assert.ok( + hopeLow.includes('هدف‌ها'), + 'Must contain «هدف‌ها» with proper ZWNJ (U+200C)' + ); + }); + + it('matches the exact specified Persian text for Self-Efficacy', () => { + assert.equal( + getFeedbackForScore('efficacy', 15), + 'اعتماد به توانایی‌های خودت هنوز جای رشد داره. شاید بعضی وقت‌ها انجام کارهای تازه برات سخت به نظر برسه؛ این فقط بخشی از تصویر فعلی توئه و همه توانایی‌هایت رو مشخص نمی‌کنه.' + ); + assert.equal( + getFeedbackForScore('efficacy', 20), + 'به نظر می‌رسه در بعضی موقعیت‌ها به خودت اعتماد داری، اما گاهی هم درباره توانایی‌هایت تردید می‌کنی. این یعنی نگاهت به توانایی‌های خودت در موقعیت‌های مختلف می‌تونه متفاوت باشه.' + ); + assert.equal( + getFeedbackForScore('efficacy', 28), + 'یکی از ویژگی‌های برجسته تو، اعتماد به توانایی‌هایت برای انجام کارهاست. به نظر می‌رسه در پاسخ‌هایت، باور به توانایی خودت جایگاه پررنگی داره؛ هرچند این حس ممکنه در موقعیت‌های مختلف تغییر کنه.' + ); + }); + + it('matches the exact specified Persian text for Hope', () => { + assert.equal( + getFeedbackForScore('hope', 10), + 'پیدا کردن مسیر رسیدن به هدف‌ها هنوز می‌تونه بخش مهمی از تجربه تو باشه. این نتیجه فقط نگاه فعلی تو به هدف‌ها و راه‌های رسیدن به اون‌ها رو نشون می‌ده، نه چیزی که در آینده برات ممکنه یا غیرممکنه.' + ); + assert.equal( + getFeedbackForScore('hope', 18), + 'به نظر می‌رسه برای بعضی هدف‌هایت مسیر روشن‌تری داری، اما درباره بعضی هدف‌های دیگه هنوز در حال پیدا کردن راهت هستی. این کاملاً تصویری از نگاه فعلی تو به هدف‌ها و مسیرهای رسیدن به اون‌هاست.' + ); + assert.equal( + getFeedbackForScore('hope', 32), + 'امیدواری یکی از نقاط قوت برجسته تو در این آزمونه. به نظر می‌رسه پیدا کردن هدف‌ها و فکر کردن به راه‌های رسیدن به اون‌ها در پاسخ‌هایت جایگاه پررنگی داره.' + ); + }); + + it('matches the exact specified Persian text for Resilience', () => { + assert.equal( + getFeedbackForScore('resilience', 12), + 'کنار اومدن با سختی‌ها و برگشتن به مسیر، از بخش‌هایی است که هنوز می‌تونه در تو رشد کنه. این نمره به‌تنهایی نمی‌گه در شرایط سخت چطور رفتار می‌کنی؛ فقط تصویری از پاسخ‌های فعلی توئه.' + ); + assert.equal( + getFeedbackForScore('resilience', 22), + 'به نظر می‌رسه در بعضی شرایط می‌تونی با سختی‌ها کنار بیای، اما تجربه‌ات در موقعیت‌های مختلف یکسان نیست. این نتیجه نشون می‌ده تاب‌آوری تو در پاسخ‌های این آزمون، ترکیبی از جنبه‌های شکل‌گرفته و در حال رشده.' + ); + assert.equal( + getFeedbackForScore('resilience', 30), + 'تاب‌آوری یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن کنار اومدن با دشواری‌ها و ادامه دادن مسیر، در تصویر فعلی تو جایگاه پررنگی داره.' + ); + }); + + it('matches the exact specified Persian text for Optimism', () => { + assert.equal( + getFeedbackForScore('optimism', 14), + 'نگاهت به اتفاق‌ها و آینده هنوز ظرفیت رشد داره. این نمره به معنی منفی بودن نگاهت در همه موقعیت‌ها نیست؛ فقط نشون میده پاسخ‌های فعلی تو درباره خوش‌بینی چه تصویری دارن.' + ); + assert.equal( + getFeedbackForScore('optimism', 24), + 'گاهی ممکنه جنبه‌های امیدوارکننده اتفاق‌ها رو ببینی و گاهی هم نگاهت به شرایط متفاوت باشه. نتیجه تو تصویری از نحوه نگاه فعلی‌ات به اتفاق‌ها و آینده ارائه می‌ده.' + ); + assert.equal( + getFeedbackForScore('optimism', 35), + 'خوش‌بینی یکی از نقاط قوت برجسته تو در این آزمونه. پاسخ‌هایت نشون می‌دن نگاه امیدوارانه به اتفاق‌ها و آینده در تجربه فعلی تو جایگاه پررنگی داره.' + ); + }); + + it('configures the 4 local image paths properly without remote dependencies', () => { + for (const dim of DIMENSION_ORDER) { + const config = DIMENSION_CONFIGS[dim]; + assert.ok(config.path.startsWith('/images/components/')); + assert.ok(config.fileName.endsWith('.webp')); + assert.ok(!config.path.startsWith('http')); + assert.ok(config.titleFa.length > 0); + assert.ok(config.inviteText.length > 0); + } + }); +}); diff --git a/src/components/assessment/AssessmentResultView.tsx b/src/components/assessment/AssessmentResultView.tsx new file mode 100644 index 0000000..d2a7946 --- /dev/null +++ b/src/components/assessment/AssessmentResultView.tsx @@ -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(0); + const [isTransitioning, setIsTransitioning] = useState(false); + + // Track revealed status for each dimension so it's preserved on back/forward + const [revealed, setRevealed] = useState>({ + 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 ( +
+ {/* 1. Low-Height, Minimal 4-Segment Progress Indicator */} +
+
+ + {isSummaryStep ? 'جمع‌بندی نهایی' : `مؤلفه ${currentStepIndex + 1} از ۴`} + + + {isSummaryStep + ? 'کارنامه نهایی' + : DIMENSION_CONFIGS[currentDimensionKey]?.titleFa} + +
+ + {/* Clean, low-height segment bar */} +
+ {DIMENSION_ORDER.map((dimKey, idx) => { + const isCompleted = + revealed[dimKey] && (currentStepIndex > idx || isSummaryStep); + const isCurrent = currentStepIndex === idx; + + return ( +
+
+ + {/* 2. Main Content (Dimension Card OR Final Summary) */} +
+ + {!isSummaryStep && currentDimensionKey ? ( + /* Step-by-Step Dimension Card */ + 0 ? handlePrev : undefined} + isLastDimension={currentStepIndex === 3} + /> + ) : ( + /* Final Summary Screen */ + + {/* Main Summary Glass Card */} +
+ {/* Header Title */} +
+

+ {isPre + ? 'کارنامه اولیه سرمایه روان‌شناختی' + : 'کارنامه نهایی دوره نویا'} +

+

+ خلاصه وضعیت چهار مؤلفه شما +

+
+ + {/* Total Score Section */} +
+
+ + نمره کل آزمون: + +
+ + {totalScore.toLocaleString('fa-IR')} + + از ۱۴۴ +
+
+ +
+
+
+ + {hasComparison && baselineScores && ( +
+ + پیش‌آزمون: {baselineScores.total.toLocaleString('fa-IR')} ➔ + پس‌آزمون: {totalScore.toLocaleString('fa-IR')} + + 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')} تغییر` + : 'بدون تغییر'} + +
+ )} +
+ + {/* 4-Dimension Comparative Bars (Simple & Readable) */} +
+ {DIMENSION_ORDER.map((dimKey) => { + const config: DimensionConfig = DIMENSION_CONFIGS[dimKey]; + const dimScore = scores[dimKey]; + const dimPercent = Math.round((dimScore / 36) * 100); + + return ( +
+
+ + {config.titleFa} + + + {getDescriptiveTitleForScore(dimKey, dimScore)} + +
+
+
+
+
+ ); + })} +
+ + {/* Non-diagnostic notice */} +

+ این نتیجه بازتاب پاسخ‌های فعلی شماست و تشخیص قطعی روان‌شناختی نیست. +

+ + {/* Mandatory Closing Text */} + +
+

+ حالا تصویر روشن‌تری از چهار مؤلفه سرمایه روان‌شناختی خودت داری. + {'\n\n'} + این نتیجه، نقطه شروعیه برای شناخت بهتر خودت؛ نه تعریف کامل شخصیت تو. + {'\n\n'} + برای آشنایی بیشتر با این مؤلفه‌ها و تقویت اون‌ها، پیشنهاد می‌کنیم آموزش‌های نویا رو ببینی و تمرین‌های مرتبط رو انجام بدی. +

+
+
+ + {/* Single Prominent Button */} + {showActions && ( +
+ + + {/* Subtle Secondary Back Control */} +
+ +
+
+ )} +
+ + )} + +
+
+ ); +} diff --git a/src/components/assessment/DimensionCard.tsx b/src/components/assessment/DimensionCard.tsx new file mode 100644 index 0000000..efe882c --- /dev/null +++ b/src/components/assessment/DimensionCard.tsx @@ -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(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 ( + + {/* 1. Dimension Header (Strong Title & Distinct Tagline with standard vertical spacing) */} +
+

+ {config.titleFa} +

+

+ {config.tagline} +

+
+ + {/* 2. Focused Large Illustration (Generous size, object-cover completely fitting the frame) */} +
+
+ {!hasImageError ? ( + {config.altText} + ) : ( +
+ + + {config.titleFa} + +
+ )} +
+
+ + {/* 3. Action / Result Area */} + + {!isRevealed ? ( + /* Initial Unrevealed State */ + +

+ {config.inviteText} +

+ + {/* Single Prominent Button */} + + + {/* Subtle Secondary Back Control */} + {onPrev && ( + + )} +
+ ) : ( + /* Revealed Result State */ +
+ {/* 6. Simple Score Status Display (Unified Card: Title + Continuum + Raw Score) */} + +
+
+
+ وضعیت: +

+ {descriptiveTitle} +

+
+
+ نمره: + + {score.toLocaleString('fa-IR')} از ۳۶ + +
+
+ + {/* Continuum Visual Range Bar */} +
+
+ +
+
+ نقطه شروع + در حال تقویت + نقطه اتکای پررنگ +
+
+ + {/* Optional Baseline Delta Comparison */} + {hasComparison && ( +
+ تغییر نسبت به پیش‌آزمون: + 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')})` + : 'بدون تغییر'} + +
+ )} +
+
+ + {/* 7. Prepared Feedback Text with standard vertical spacing */} +
+ +

+ {feedbackText} +

+
+
+ + {/* 8. Single Prominent Button with standard vertical spacing */} +
+ +
+ + {/* 9. Subtle Secondary Back Control */} + {onPrev && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 003d7f8..bf48cd7 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -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 [ + + {renderWithNums(label, `${index}-l`)} + , + ]; + } + return renderWithNums(label, `${index}-l`); + } + + // 2. Bold: **text** + const boldMatch = part.match(/^\*\*([^*]+)\*\*$/); + if (boldMatch) { return [ - - {renderWithNums(bold[1] ?? '', `${index}-b`)} + + {renderWithNums(boldMatch[1] ?? '', `${index}-b`)} , ]; } + + // 3. Inline code: `code` + const codeMatch = part.match(/^`([^`]+)`$/); + if (codeMatch) { + return [ + + {codeMatch[1]} + , + ]; + } + + // 4. Italic: *text* or _text_ + const italicMatch = + part.match(/^(\s?)\*([^*]+)\*(\s?)$/) || part.match(/^(\s?)_([^_]+)_(\s?)$/); + if (italicMatch) { + const [, leading = '', inner = '', trailing = ''] = italicMatch; + return [ + leading, + + {renderWithNums(inner, `${index}-i`)} + , + 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( - - {renderWithNums(pending, `${key}-pending`)} - - ); + // 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( + + {renderWithNums(pending, `${key}-pending-b`)} + + ); + } + break; + } + + nodes.push( + + {renderWithNums(text.slice(openBold + 2, closeBold), `${key}-b`)} + + ); + i = closeBold + 2; + continue; } - const boldContent = text.slice(open + 2, close); - nodes.push( - - {renderWithNums(boldContent, `${key}-b`)} - - ); - 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( + + {pending} + + ); + } + break; + } + + nodes.push( + + {text.slice(openCode + 1, closeCode)} + + ); + 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 = ({ })}
-
+ +
{blocks.length > 0 || showCursor ? ( -
+
{(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 ? : null; + const cursor = + showCursor && isLast ? : 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 ( - - {block.items.map((item, itemIndex) => ( -
  • - {block.ordered ? ( - <> - - {block.markers?.[itemIndex] ?? String(itemIndex + 1)} - {block.delimiters?.[itemIndex] ?? '.'} - - - {renderInline(item, isActivelyTyping)} - {cursor && itemIndex === block.items.length - 1 ? cursor : null} - - - ) : ( - <> - {renderInline(item, isActivelyTyping)} - {cursor && itemIndex === block.items.length - 1 ? cursor : null} - - )} -
  • - ))} + + {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 ( +
  • + {block.ordered ? ( + <> + + + {block.markers?.[itemIndex] ?? String(itemIndex + 1)} + + {block.delimiters?.[itemIndex] ?? '.'} + +
    + {renderInline(item.text, isActivelyTyping)} + {cursor && isLastItem ? cursor : null} +
    + + ) : ( + <> + + • + +
    + {renderInline(item.text, isActivelyTyping)} + {cursor && isLastItem ? cursor : null} +
    + + )} +
  • + ); + })}
    ); } + // 2. Blockquote + if (block.type === 'blockquote') { + const Wrapper = isActivelyTyping ? 'div' : motion.div; + return ( + +
    + {renderInline(block.text, isActivelyTyping)} + {cursor} +
    +
    + ); + } + + // 3. Code Block + if (block.type === 'codeblock') { + const Wrapper = isActivelyTyping ? 'div' : motion.div; + return ( + +
    + {block.language && ( +
    + {block.language} +
    + )} +
    +                        {block.code}
    +                      
    +
    +
    + ); + } + + // 4. Horizontal Rule + if (block.type === 'hr') { + return
    ; + } + + // 5. Table if (block.type === 'table') { const Wrapper = isActivelyTyping ? 'div' : motion.div; return ( - +
    {block.headers.map((header, headerIndex) => ( @@ -400,11 +679,14 @@ export const MessageBubble = ({ {block.rows.map((row, rowIndex) => ( - + {row.map((cell, cellIndex) => (
    {renderInline(header, false)}
    {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 ( -

    + {renderInline(block.text, true)} {cursor} -

    + ); } + + const MotionHeading = + block.level === 1 ? motion.h1 : block.level === 2 ? motion.h2 : motion.h3; + return ( - {renderInline(block.text, false)} {cursor} - + ); } + // 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 = ({ ) : (

    پاسخ خالی دریافت شد.

    )} + {chatMessage.status === 'error' && ( ارسال ناموفق بود. دوباره تلاش کنید. diff --git a/src/components/motion/SmoothTextAppearance.tsx b/src/components/motion/SmoothTextAppearance.tsx new file mode 100644 index 0000000..4f7c507 --- /dev/null +++ b/src/components/motion/SmoothTextAppearance.tsx @@ -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( + ( + { + 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 ( +
    + {children} +
    + ); + } + + return ( + + {children} + + ); + } +); + +SmoothTextAppearance.displayName = 'SmoothTextAppearance'; diff --git a/src/components/motion/index.ts b/src/components/motion/index.ts index 2e7cdf6..1e21050 100644 --- a/src/components/motion/index.ts +++ b/src/components/motion/index.ts @@ -1,3 +1,5 @@ export { AnimationWrapper } from './AnimationWrapper'; export { PageTransition, AnimatedRoutesShell } from './PageTransition'; export { StaggeredList, StaggerItem } from './StaggeredList'; +export { SmoothTextAppearance, type SmoothTextAppearanceProps } from './SmoothTextAppearance'; + diff --git a/src/config/pcq24.ts b/src/config/pcq24.ts index 5aaee98..871245e 100644 --- a/src/config/pcq24.ts +++ b/src/config/pcq24.ts @@ -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 { diff --git a/src/config/resultImages.ts b/src/config/resultImages.ts new file mode 100644 index 0000000..38e587f --- /dev/null +++ b/src/config/resultImages.ts @@ -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 = { + 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]; +} diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index ed682b9..a8caf19 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -81,6 +81,7 @@ export function useChat(options: UseChatOptions = {}) { const messagesRef = useRef([]); /** After first successful bootstrap, never flash the full-page chat skeleton again. */ const hasHydratedChatRef = useRef(false); + const abortControllerRef = useRef(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, }; } + diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx index 3a70944..5f48e60 100644 --- a/src/pages/Account/AccountPage.tsx +++ b/src/pages/Account/AccountPage.tsx @@ -107,11 +107,15 @@ export default function AccountPage() { navigate('/assessment/pre')} - lockedHint={preDone ? 'انجام شده — قابل تکرار نیست' : undefined} + onOpen={preDone ? () => navigate('/assessment/pre') : undefined} > {preDone ? ( - +
    + + + مشاهده کارنامه + +
    ) : (

    برای شروع مسیر، پیش‌آزمون را انجام دهید

    )} @@ -154,14 +158,21 @@ export default function AccountPage() { navigate('/assessment/post')} + onOpen={postDone ? () => navigate('/finished') : postState === 'open' ? () => navigate('/assessment/post') : undefined} lockedHint={ postDone - ? 'انجام شده — قابل تکرار نیست' + ? undefined : 'پس از رسیدن به جلسه پایانی فعال می‌شود' } > - {postState === 'done' && } + {postState === 'done' && ( +
    + + + مشاهده کارنامه + +
    + )} {postState === 'open' && (

    void; + onOpen?: () => void; lockedHint?: string; }) { - const isInteractive = state === 'open'; + const isInteractive = Boolean(onOpen) && (state === 'open' || state === 'done'); const content = (

    @@ -220,7 +231,7 @@ function StatusRow({
    ); - if (isInteractive) { + if (isInteractive && onOpen) { return ( (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 ; } - if (!loading && !canAccess) { + if (loading || loadingExistingResult) { + return ; + } + + if (showResultView && completedSubmission) { + return ( +
    + navigate(isPreTest ? '/chat' : '/finished')} + /> +
    + ); + } + + if (!canAccess) { if (isPreTest && progress?.pre_test_completed_at) { return ; } @@ -79,10 +121,6 @@ export default function AssessmentPage() { } } - if (loading) { - return ; - } - 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; } diff --git a/src/pages/Chat/ChatPage.tsx b/src/pages/Chat/ChatPage.tsx index c477807..c3a37b7 100644 --- a/src/pages/Chat/ChatPage.tsx +++ b/src/pages/Chat/ChatPage.tsx @@ -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 (
    -
    +
    -
    +
    {isProgramComplete ? (

    دوره آموزشی تکمیل شده

    ) : isWaitingForUnlock && waitingForNextSession ? ( @@ -587,7 +600,7 @@ export default function ChatPage() {
    {loading && messages.length === 0 ? ( @@ -730,7 +743,7 @@ export default function ChatPage() { )}
    -
    +