Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d734f9047 | ||
|
|
d2a52a2958 | ||
|
|
a3a9f2664b | ||
|
|
8c37c15697 |
@@ -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
|
||||
@@ -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=
|
||||
@@ -37,6 +37,10 @@ backend/.env
|
||||
sw.js
|
||||
workbox-*.js
|
||||
|
||||
# Temporary test files
|
||||
backend/tmp-*
|
||||
tmp-*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Set `VITE_BACKEND_URL` to the public API base URL **before** `npm run build`.
|
||||
|
||||
## 6. Checklist for the other deployer
|
||||
|
||||
- [ ] MySQL migrated + seeded (11 course sessions)
|
||||
- [ ] MySQL migrated + seeded (11 course sessions + 9 assignment definitions)
|
||||
- [ ] Backend `CORS_ORIGIN` matches the real frontend origin
|
||||
- [ ] `OPENAI_API_KEY` set on backend
|
||||
- [ ] Frontend built with correct `VITE_BACKEND_URL`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
با این کار، سرور جدید دقیقاً به وضعیت پاک و اولیه خود قبل از استقرار نویا بازمیگردد.
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
tmp-*
|
||||
.tools
|
||||
.backups
|
||||
@@ -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=
|
||||
@@ -33,7 +33,7 @@ npm run dev
|
||||
API default: `http://localhost:3001`
|
||||
PWA (CORS): `http://localhost:5173`
|
||||
|
||||
`prisma:seed` upserts the 11 course sessions. Server startup also upserts them as a safety net.
|
||||
`prisma:seed` upserts the 11 course sessions and 9 assignment definitions. Server startup also upserts both as a safety net.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -49,7 +49,7 @@ PWA (CORS): `http://localhost:5173`
|
||||
| `npm run prisma:validate` | Validate schema |
|
||||
| `npm run prisma:migrate:dev` | Dev migrations |
|
||||
| `npm run prisma:migrate:deploy` | Apply migrations (prod) |
|
||||
| `npm run prisma:seed` | Upsert 11 `course_sessions` |
|
||||
| `npm run prisma:seed` | Upsert 11 `course_sessions` + 9 assignment definitions |
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ export const errorHandler: ErrorHandler = (err, c) => {
|
||||
|
||||
if (err instanceof ZodError) {
|
||||
log.warn({ issues: err.issues }, 'validation error');
|
||||
const firstMessage = err.issues[0]?.message?.trim();
|
||||
|
||||
return errorResponse(c, {
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
message: 'Validation failed',
|
||||
message: firstMessage || 'اطلاعات واردشده نامعتبر است',
|
||||
status: HTTP_STATUS.UNPROCESSABLE_ENTITY,
|
||||
details: err.issues,
|
||||
});
|
||||
|
||||
@@ -273,3 +273,123 @@ export async function generateAiResponse(
|
||||
new ServiceUnavailableError('سرویس هوش مصنوعی موقتاً در دسترس نیست. لطفاً دوباره تلاش کنید.')
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamAiResponse(
|
||||
messages: ChatCompletionMessage[],
|
||||
onChunk: (delta: string) => Promise<void> | 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<string, string> = {
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -485,7 +485,7 @@ export async function listAssignmentsForSession(userId: bigint, sessionOrder: nu
|
||||
|
||||
/** All session assignment slots (definitions 2–10) with optional user progress. */
|
||||
export async function listAssignmentCatalog(userId: bigint) {
|
||||
await seedAssignmentDefinitions();
|
||||
const seededCount = await seedAssignmentDefinitions();
|
||||
await backfillAssignmentsForCompletedSessions(userId);
|
||||
|
||||
const completedOrders = await getCompletedSessionOrderSet(userId);
|
||||
@@ -494,6 +494,14 @@ export async function listAssignmentCatalog(userId: bigint) {
|
||||
orderBy: [{ displayOrder: 'asc' }, { sessionOrder: 'asc' }],
|
||||
});
|
||||
|
||||
if (definitions.length === 0) {
|
||||
throw new ValidationError(
|
||||
seededCount === 0
|
||||
? 'تعاریف تکالیف در پایگاهداده موجود نیست. لطفاً migrate و seed را اجرا کنید.'
|
||||
: 'بارگذاری فهرست تکالیف ناموفق بود',
|
||||
);
|
||||
}
|
||||
|
||||
const userRows = await prisma.userAssignment.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
@@ -506,8 +514,35 @@ export async function listAssignmentCatalog(userId: bigint) {
|
||||
|
||||
const byDefinitionId = new Map(userRows.map((r) => [r.definitionId.toString(), r]));
|
||||
|
||||
let activatedMissing = 0;
|
||||
for (const definition of definitions) {
|
||||
if (
|
||||
isAssignmentUnlockedByCompletedSessions(definition.sessionOrder, completedOrders) &&
|
||||
!byDefinitionId.has(definition.id.toString())
|
||||
) {
|
||||
activatedMissing += await activateAssignmentsForSession(userId, definition.sessionOrder, prisma, {
|
||||
ensureSeeded: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows =
|
||||
activatedMissing > 0
|
||||
? await prisma.userAssignment.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
definition: true,
|
||||
reports: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
dailyEntries: { orderBy: { entryDate: 'desc' } },
|
||||
_count: { select: { reports: true, dailyEntries: true } },
|
||||
},
|
||||
})
|
||||
: userRows;
|
||||
|
||||
const byId = new Map(rows.map((r) => [r.definitionId.toString(), r]));
|
||||
|
||||
return definitions.map((definition) => {
|
||||
const ua = byDefinitionId.get(definition.id.toString());
|
||||
const ua = byId.get(definition.id.toString());
|
||||
const unlocked = isAssignmentUnlockedByCompletedSessions(
|
||||
definition.sessionOrder,
|
||||
completedOrders,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> | 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> | 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> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,22 @@ import { z } from 'zod';
|
||||
|
||||
export const NAME_MAX_LENGTH = 100;
|
||||
|
||||
export const profileNameSchema = z
|
||||
.string({ required_error: 'Name is required' })
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(NAME_MAX_LENGTH, `Name must be at most ${NAME_MAX_LENGTH} characters`);
|
||||
|
||||
/** Minimum allowed profile age (years). Keep in sync with frontend ProfileCompletionPage. */
|
||||
export const PROFILE_AGE_MIN = 12;
|
||||
|
||||
export const profileNameSchema = z
|
||||
.string({ required_error: 'نام الزامی است' })
|
||||
.trim()
|
||||
.min(1, 'نام نمیتواند خالی باشد')
|
||||
.max(NAME_MAX_LENGTH, `نام حداکثر ${NAME_MAX_LENGTH} کاراکتر باشد`);
|
||||
|
||||
export const profileAgeSchema = z
|
||||
.number({
|
||||
required_error: 'Age is required',
|
||||
invalid_type_error: 'Age must be an integer',
|
||||
required_error: 'سن الزامی است',
|
||||
invalid_type_error: 'سن باید یک عدد صحیح باشد',
|
||||
})
|
||||
.int('Age must be an integer')
|
||||
.min(PROFILE_AGE_MIN, `Age must be at least ${PROFILE_AGE_MIN}`);
|
||||
.int('سن باید یک عدد صحیح باشد')
|
||||
.min(PROFILE_AGE_MIN, `حداقل سن ${PROFILE_AGE_MIN} سال است`);
|
||||
|
||||
export const patchProfileSchema = z
|
||||
.object({
|
||||
@@ -24,7 +25,7 @@ export const patchProfileSchema = z
|
||||
age: profileAgeSchema.optional(),
|
||||
})
|
||||
.refine((value) => value.name !== undefined || value.age !== undefined, {
|
||||
message: 'At least one of name or age is required',
|
||||
message: 'نام یا سن لازم است',
|
||||
});
|
||||
|
||||
export type PatchProfileInput = z.infer<typeof patchProfileSchema>;
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function updateOwnProfile(
|
||||
|
||||
if (!existing.profile) {
|
||||
if (input.name === undefined || input.age === undefined) {
|
||||
throw new ValidationError('Name and age are both required to create a profile');
|
||||
throw new ValidationError('برای تکمیل مشخصات، نام و سن هر دو لازم است');
|
||||
}
|
||||
|
||||
await prisma.userProfile.create({
|
||||
|
||||
@@ -16,8 +16,13 @@ async function startServer() {
|
||||
await connectPrisma();
|
||||
logger.info('database connection established');
|
||||
const { seedCourseSessions } = await import('./modules/sessions/sessions.service.js');
|
||||
const { seedAssignmentDefinitions } = await import(
|
||||
'./modules/assignments/assignments.service.js'
|
||||
);
|
||||
await seedCourseSessions();
|
||||
logger.info('course sessions seeded');
|
||||
const assignmentCount = await seedAssignmentDefinitions();
|
||||
logger.info({ assignmentCount }, 'assignment definitions seeded');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'database connection failed at startup; /ready will report unavailable');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const APP_NAME = 'Novya API';
|
||||
export const APP_DESCRIPTION = 'Novya learning platform backend API';
|
||||
|
||||
/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 0;
|
||||
|
||||
export const DEFAULT_PAGE = 1;
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
@@ -143,14 +143,7 @@ describe('assignments API', () => {
|
||||
});
|
||||
assert.equal((list.body.data?.assignments as unknown[]).length, 1);
|
||||
|
||||
// Backdate session 2 completion so session 3 unlocks (24h gate for order >= 3)
|
||||
await prisma.sessionProgress.update({
|
||||
where: {
|
||||
userId_sessionOrder: { userId: owner.user.id, sessionOrder: 2 },
|
||||
},
|
||||
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
// Session 3 is unlocked immediately (no 24h waiting period)
|
||||
// Incomplete homework must NOT block starting session 3
|
||||
const chat3 = await requestJson(app, '/chat/sessions', {
|
||||
method: 'POST',
|
||||
@@ -363,12 +356,8 @@ describe('assignments API', () => {
|
||||
await openChatAndComplete(token, 1);
|
||||
await openChatAndComplete(token, 2);
|
||||
|
||||
// Unlock sessions 3–10 by backdating previous completions as we go
|
||||
// Unlock sessions 3–10 sequentially without cooldown delays
|
||||
for (let order = 3; order <= 10; order += 1) {
|
||||
await prisma.sessionProgress.updateMany({
|
||||
where: { userId: user.id, sessionOrder: order - 1 },
|
||||
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
|
||||
});
|
||||
await openChatAndComplete(token, order);
|
||||
}
|
||||
|
||||
@@ -472,13 +461,13 @@ describe('assignments API', () => {
|
||||
|
||||
await openChatAndComplete(token, 2);
|
||||
|
||||
// completeSession helper only asserts 200 — fetch progress for cooldown fields
|
||||
// With waiting period removed, no cooldown is set
|
||||
const progress = await requestJson(app, '/sessions/progress', {
|
||||
headers: authHeader(token),
|
||||
});
|
||||
assert.equal(progress.status, 200);
|
||||
assert.ok(progress.body.data?.waitingForSessionOrder === 3);
|
||||
assert.ok(progress.body.data?.nextSessionUnlocksAt);
|
||||
assert.equal(progress.body.data?.waitingForSessionOrder, null);
|
||||
assert.equal(progress.body.data?.nextSessionUnlocksAt, null);
|
||||
|
||||
// Re-complete to read unlockedAssignments payload from complete endpoint
|
||||
const redo = await requestJson(app, '/sessions/2/complete', {
|
||||
@@ -508,7 +497,7 @@ describe('assignments API', () => {
|
||||
assert.equal(joy.lockReason, null);
|
||||
assert.ok(joy.userAssignment?.id);
|
||||
|
||||
// Session 3 still locked by cooldown — must NOT block assignment 1
|
||||
// Session 3 is unlocked immediately (no 24h wait)
|
||||
const sessions = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(token),
|
||||
});
|
||||
@@ -518,18 +507,19 @@ describe('assignments API', () => {
|
||||
unlocksAt?: string | null;
|
||||
}>;
|
||||
const s3 = sessionList.find((s) => s.order === 3)!;
|
||||
assert.equal(s3.unlocked, false);
|
||||
assert.ok(s3.unlocksAt);
|
||||
assert.equal(s3.unlocked, true);
|
||||
assert.equal(s3.unlocksAt, null);
|
||||
|
||||
// Opening cooldown (simulate later unlock of session 3) must not change assignment-1 state
|
||||
await prisma.sessionProgress.update({
|
||||
where: { userId_sessionOrder: { userId: user.id, sessionOrder: 2 } },
|
||||
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
|
||||
});
|
||||
const sessionsAfter = await requestJson(app, '/sessions', {
|
||||
// Prerequisites still hold: Session 4 is NOT unlocked yet
|
||||
const s4 = sessionList.find((s) => s.order === 4)!;
|
||||
assert.equal(s4.unlocked, false);
|
||||
|
||||
// Complete session 3 immediately without any time delay
|
||||
await openChatAndComplete(token, 3);
|
||||
const sessionsAfter3 = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(token),
|
||||
});
|
||||
const s3After = (sessionsAfter.body.data as typeof sessionList).find((s) => s.order === 3)!;
|
||||
const s3After = (sessionsAfter3.body.data as typeof sessionList).find((s) => s.order === 3)!;
|
||||
assert.equal(s3After.unlocked, true);
|
||||
|
||||
catalog = await requestJson(app, '/assignments/catalog', {
|
||||
@@ -539,18 +529,18 @@ describe('assignments API', () => {
|
||||
const joyAfter = items.find((i) => i.definition.key === 'joy-activities')!;
|
||||
assert.equal(joyAfter.unlocked, true);
|
||||
assert.equal(joyAfter.userAssignment?.id, joy.userAssignment?.id);
|
||||
|
||||
// Assignment 2 (meaning) is now unlocked immediately on session 3 complete
|
||||
const meaningAfter3 = items.find((i) => i.definition.key === 'meaning-purpose')!;
|
||||
assert.equal(meaningAfter3.unlocked, true);
|
||||
assert.equal(
|
||||
items.filter((i) => i.unlocked).length,
|
||||
1,
|
||||
'unlocking session 3 must not unlock later assignments',
|
||||
2,
|
||||
'unlocking session 3 must not unlock later assignments (session 4+)',
|
||||
);
|
||||
|
||||
// Mid assignment: تکلیف پنجم (displayOrder 5 / sessionOrder 6) after completing session 6
|
||||
for (let order = 3; order <= 6; order += 1) {
|
||||
await prisma.sessionProgress.updateMany({
|
||||
where: { userId: user.id, sessionOrder: order - 1 },
|
||||
data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) },
|
||||
});
|
||||
for (let order = 4; order <= 6; order += 1) {
|
||||
await openChatAndComplete(token, order);
|
||||
}
|
||||
|
||||
@@ -568,7 +558,9 @@ describe('assignments API', () => {
|
||||
headers: authHeader(token),
|
||||
});
|
||||
const s7 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 7)!;
|
||||
assert.equal(s7.unlocked, false, 'session 7 may still be on cooldown');
|
||||
assert.equal(s7.unlocked, true, 'session 7 unlocks immediately after session 6 completion');
|
||||
const s8 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 8)!;
|
||||
assert.equal(s8.unlocked, false, 'session 8 remains locked until session 7 is completed');
|
||||
assert.equal(
|
||||
items.find((i) => i.definition.sessionOrder === 7)?.unlocked,
|
||||
false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,7 @@ describe('profile API', () => {
|
||||
body: JSON.stringify({ name: 'Sara', age: 11 }),
|
||||
});
|
||||
assert.equal(underage.status, 422);
|
||||
assert.match(String(underage.body.error?.message ?? ''), /حداقل سن 12/);
|
||||
|
||||
const teenOk = await requestJson(app, '/users/me/profile', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
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 { exampleCompletionSignal } from '../src/modules/ai/sessionCompletion.js';
|
||||
import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.js';
|
||||
import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js';
|
||||
import { seedCourseSessions } from '../src/modules/sessions/seedCatalog.js';
|
||||
import { seedAssignmentDefinitions } from '../src/modules/assignments/assignments.service.js';
|
||||
import {
|
||||
getNextLockedSessionUnlockTime,
|
||||
getPreviousSessionUnlockTime,
|
||||
isSessionUnlockedForProgress,
|
||||
sessionUsesUnlockInterval,
|
||||
} from '../src/modules/sessions/unlock.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 = `session-unlock-${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 };
|
||||
}
|
||||
|
||||
import { ChatRole } from '@prisma/client';
|
||||
|
||||
async function completePreTest(token: string) {
|
||||
const res = await requestJson(app, '/assessments/attempts', {
|
||||
method: 'POST',
|
||||
headers: authHeader(token),
|
||||
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
}
|
||||
|
||||
async function openChatAndComplete(token: string, order: number) {
|
||||
const chat = await requestJson(app, '/chat/sessions', {
|
||||
method: 'POST',
|
||||
headers: authHeader(token),
|
||||
body: JSON.stringify({ sessionOrder: order }),
|
||||
});
|
||||
assert.equal(chat.status, 201);
|
||||
const chatId = (chat.body.data as { id: string }).id;
|
||||
|
||||
await prisma.chatMessage.createMany({
|
||||
data: [
|
||||
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `سلام جلسه ${order}` },
|
||||
{ chatSessionId: BigInt(chatId), role: ChatRole.user, content: `ادامه جلسه ${order}` },
|
||||
],
|
||||
});
|
||||
|
||||
const session = getSessionByOrder(order)!;
|
||||
const complete = await requestJson(app, `/sessions/${order}/complete`, {
|
||||
method: 'POST',
|
||||
headers: authHeader(token),
|
||||
body: JSON.stringify({
|
||||
...exampleCompletionSignal(buildAiCoachSessionContext(session)),
|
||||
chatSessionId: chatId,
|
||||
}),
|
||||
});
|
||||
assert.equal(complete.status, 200);
|
||||
return { chatId };
|
||||
}
|
||||
|
||||
describe('session unlock logic and waiting period removal', () => {
|
||||
let dbAvailable = false;
|
||||
|
||||
before(async () => {
|
||||
dbAvailable = await isDatabaseAvailable();
|
||||
if (dbAvailable) {
|
||||
await seedCourseSessions();
|
||||
await seedAssignmentDefinitions();
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (dbAvailable && createdUserIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
||||
}
|
||||
});
|
||||
|
||||
it('unit: unlock interval functions confirm 0 waiting period', () => {
|
||||
// 1. sessionUsesUnlockInterval returns false for all session orders
|
||||
for (let order = 1; order <= PROGRAM_SESSION_COUNT; order++) {
|
||||
assert.equal(
|
||||
sessionUsesUnlockInterval(order),
|
||||
false,
|
||||
`session ${order} should not use unlock interval`,
|
||||
);
|
||||
}
|
||||
|
||||
const completionTimes = new Map<number, Date>();
|
||||
completionTimes.set(1, new Date());
|
||||
completionTimes.set(2, new Date());
|
||||
|
||||
// 2. getPreviousSessionUnlockTime returns null (no waiting delay)
|
||||
assert.equal(getPreviousSessionUnlockTime(3, completionTimes), null);
|
||||
|
||||
// 3. isSessionUnlockedForProgress unlocks session 3 immediately when 1 and 2 are complete
|
||||
assert.equal(
|
||||
isSessionUnlockedForProgress(3, [1, 2], true, false, completionTimes),
|
||||
true,
|
||||
'session 3 should unlock immediately after sessions 1 and 2 are complete',
|
||||
);
|
||||
|
||||
// 4. Prerequisites still block: session 4 is NOT unlocked when only 1 and 2 are complete
|
||||
assert.equal(
|
||||
isSessionUnlockedForProgress(4, [1, 2], true, false, completionTimes),
|
||||
false,
|
||||
'session 4 must remain locked when session 3 is not complete',
|
||||
);
|
||||
|
||||
// 5. canOpenTraining prerequisite still blocks all sessions
|
||||
assert.equal(
|
||||
isSessionUnlockedForProgress(1, [], false, false, completionTimes),
|
||||
false,
|
||||
'session 1 must be locked if canOpenTraining is false',
|
||||
);
|
||||
|
||||
// 6. getNextLockedSessionUnlockTime returns null when next eligible session has no cooldown
|
||||
assert.equal(
|
||||
getNextLockedSessionUnlockTime([1, 2], completionTimes),
|
||||
null,
|
||||
'next eligible session should have no locked cooldown delay',
|
||||
);
|
||||
});
|
||||
|
||||
it('integration: completes sessions sequentially without delays; preserves assignments and prerequisites', async (t) => {
|
||||
if (!dbAvailable) {
|
||||
t.skip('MySQL is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const participantA = await registerUser('part-a');
|
||||
const participantB = await registerUser('part-b');
|
||||
|
||||
// Participant A takes pre-test
|
||||
await completePreTest(participantA.token);
|
||||
|
||||
// Participant B has NOT taken pre-test — training is locked
|
||||
const bSessions = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(participantB.token),
|
||||
});
|
||||
const bList = bSessions.body.data as Array<{ order: number; unlocked: boolean }>;
|
||||
assert.ok(bList.every((s) => !s.unlocked), 'all sessions locked for participant B without pre-test');
|
||||
|
||||
// Participant A: session 1 is unlocked initially, session 2-11 are locked
|
||||
let aSessions = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
let aList = aSessions.body.data as Array<{ order: number; unlocked: boolean; completed: boolean; unlocksAt: string | null }>;
|
||||
assert.equal(aList[0]?.unlocked, true);
|
||||
assert.equal(aList[1]?.unlocked, false);
|
||||
assert.equal(aList[2]?.unlocked, false);
|
||||
|
||||
// Complete session 1
|
||||
await openChatAndComplete(participantA.token, 1);
|
||||
|
||||
// Session 2 is now unlocked immediately
|
||||
aSessions = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
aList = aSessions.body.data as typeof aList;
|
||||
assert.equal(aList[0]?.completed, true);
|
||||
assert.equal(aList[1]?.unlocked, true);
|
||||
assert.equal(aList[2]?.unlocked, false);
|
||||
|
||||
// Complete session 2
|
||||
await openChatAndComplete(participantA.token, 2);
|
||||
|
||||
// Progress endpoint shows NO cooldown waiting
|
||||
const aProgress = await requestJson(app, '/sessions/progress', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
assert.equal(aProgress.status, 200);
|
||||
assert.equal(aProgress.body.data?.waitingForSessionOrder, null);
|
||||
assert.equal(aProgress.body.data?.nextSessionUnlocksAt, null);
|
||||
|
||||
// Session 3 is unlocked IMMEDIATELY (no 24h waiting period!)
|
||||
aSessions = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
aList = aSessions.body.data as typeof aList;
|
||||
const s3 = aList.find((s) => s.order === 3)!;
|
||||
assert.equal(s3.unlocked, true, 'session 3 must be unlocked immediately');
|
||||
assert.equal(s3.unlocksAt, null, 'session 3 unlocksAt must be null');
|
||||
|
||||
// Completing session 2 does NOT unlock session 4 or the rest of the program
|
||||
const s4 = aList.find((s) => s.order === 4)!;
|
||||
assert.equal(s4.unlocked, false, 'session 4 must remain locked');
|
||||
const s11 = aList.find((s) => s.order === 11)!;
|
||||
assert.equal(s11.unlocked, false, 'session 11 must remain locked');
|
||||
|
||||
// Associated assignment for session 2 (joy-activities) is unlocked immediately
|
||||
const aCatalog = await requestJson(app, '/assignments/catalog', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
const catalogItems = aCatalog.body.data?.catalog as Array<{
|
||||
unlocked: boolean;
|
||||
definition: { key: string; sessionOrder: number };
|
||||
}>;
|
||||
const joyAssignment = catalogItems.find((i) => i.definition.key === 'joy-activities')!;
|
||||
assert.equal(joyAssignment.unlocked, true, 'joy assignment unlocked after session 2 complete');
|
||||
|
||||
// Assignment for session 3 (meaning-purpose) is STILL locked because session 3 is not yet completed
|
||||
const meaningAssignment = catalogItems.find((i) => i.definition.key === 'meaning-purpose')!;
|
||||
assert.equal(meaningAssignment.unlocked, false, 'meaning assignment remains locked until session 3 is completed');
|
||||
|
||||
// Participant B cannot access participant A's assignments
|
||||
const bAssignments = await requestJson(app, '/assignments?filter=all', {
|
||||
headers: authHeader(participantB.token),
|
||||
});
|
||||
assert.equal(bAssignments.status, 200);
|
||||
assert.equal((bAssignments.body.data?.assignments as unknown[]).length, 0, 'participant B sees no assignments');
|
||||
|
||||
// Participant A immediately accesses and completes session 3 without any delay
|
||||
await openChatAndComplete(participantA.token, 3);
|
||||
|
||||
// Associated assignment for session 3 is now unlocked immediately
|
||||
const aCatalogAfter3 = await requestJson(app, '/assignments/catalog', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
const itemsAfter3 = aCatalogAfter3.body.data?.catalog as typeof catalogItems;
|
||||
const meaningAfter3 = itemsAfter3.find((i) => i.definition.key === 'meaning-purpose')!;
|
||||
assert.equal(meaningAfter3.unlocked, true, 'meaning assignment unlocks immediately after session 3 completion');
|
||||
|
||||
// Session 4 is now unlocked immediately
|
||||
aSessions = await requestJson(app, '/sessions', {
|
||||
headers: authHeader(participantA.token),
|
||||
});
|
||||
aList = aSessions.body.data as typeof aList;
|
||||
const s4After3 = aList.find((s) => s.order === 4)!;
|
||||
assert.equal(s4After3.unlocked, true, 'session 4 unlocks immediately after session 3 completion');
|
||||
|
||||
// Completed sessions 1, 2, 3 remain completed
|
||||
assert.equal(aList.find((s) => s.order === 1)!.completed, true);
|
||||
assert.equal(aList.find((s) => s.order === 2)!.completed, true);
|
||||
assert.equal(aList.find((s) => s.order === 3)!.completed, true);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#e6eef6" />
|
||||
<meta name="description" content="پلتفرم روانشناسی و سلامت روان نویا" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -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/<filename>.webp`
|
||||
> در صورت نبود موقت فایلها، سیستم به صورت خودکار آیکون و پسزمینه گرادیان هماهنگ با طراحی نویا را بدون خطا نمایش میدهد.
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DIMENSION_CONFIGS,
|
||||
DIMENSION_ORDER,
|
||||
type DimensionConfig,
|
||||
getDescriptiveTitleForScore,
|
||||
} from '@/config/resultImages';
|
||||
import { DimensionCard } from './DimensionCard';
|
||||
import { SmoothTextAppearance } from '@/components/motion';
|
||||
import type { AssessmentSubmission } from '@/services/assessmentService';
|
||||
import type { DimensionName } from '@/lib/assessment/scoring/dimensions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MOTION_EASE } from '@/lib/motion';
|
||||
|
||||
export interface AssessmentResultViewProps {
|
||||
phase: 'pre' | 'post';
|
||||
currentSubmission: AssessmentSubmission;
|
||||
baselineSubmission?: AssessmentSubmission | null;
|
||||
onContinue?: () => void;
|
||||
continueLabel?: string;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export function AssessmentResultView({
|
||||
phase,
|
||||
currentSubmission,
|
||||
baselineSubmission,
|
||||
onContinue,
|
||||
continueLabel = 'مشاهده آموزشها و تمرینها',
|
||||
showActions = true,
|
||||
}: AssessmentResultViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const isPre = phase === 'pre';
|
||||
const scores = currentSubmission.scores;
|
||||
const baselineScores = baselineSubmission?.scores;
|
||||
|
||||
// Active step: 0, 1, 2, 3 = dimension steps; 4 = final summary screen
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState<number>(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
||||
|
||||
// Track revealed status for each dimension so it's preserved on back/forward
|
||||
const [revealed, setRevealed] = useState<Record<DimensionName, boolean>>({
|
||||
efficacy: false,
|
||||
hope: false,
|
||||
resilience: false,
|
||||
optimism: false,
|
||||
});
|
||||
|
||||
const totalScore = scores.total;
|
||||
const maxTotalScore = 144;
|
||||
const totalPercent = Math.round(
|
||||
(Math.max(0, Math.min(maxTotalScore, totalScore)) / maxTotalScore) * 100
|
||||
);
|
||||
|
||||
const hasComparison = Boolean(
|
||||
!isPre && baselineScores && typeof baselineScores.total === 'number'
|
||||
);
|
||||
const totalDelta = hasComparison ? totalScore - (baselineScores?.total ?? 0) : 0;
|
||||
|
||||
const currentDimensionKey = DIMENSION_ORDER[currentStepIndex];
|
||||
const isSummaryStep = currentStepIndex === 4;
|
||||
|
||||
const handleRevealCurrent = () => {
|
||||
if (currentDimensionKey) {
|
||||
setRevealed((prev) => ({ ...prev, [currentDimensionKey]: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (isTransitioning) return;
|
||||
setIsTransitioning(true);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, 4));
|
||||
window.setTimeout(() => setIsTransitioning(false), 220);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (isTransitioning) return;
|
||||
setIsTransitioning(true);
|
||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
||||
window.setTimeout(() => setIsTransitioning(false), 220);
|
||||
};
|
||||
|
||||
const handleJumpToStep = (index: number) => {
|
||||
if (isTransitioning) return;
|
||||
const targetDimension = DIMENSION_ORDER[index];
|
||||
if (
|
||||
index < currentStepIndex ||
|
||||
(index <= 3 && targetDimension && revealed[targetDimension]) ||
|
||||
index === currentStepIndex
|
||||
) {
|
||||
setIsTransitioning(true);
|
||||
setCurrentStepIndex(index);
|
||||
window.setTimeout(() => setIsTransitioning(false), 220);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalContinue = () => {
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
} else if (isPre) {
|
||||
navigate('/chat');
|
||||
} else {
|
||||
navigate('/course-status');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[430px] flex-1 flex-col" dir="rtl">
|
||||
{/* 1. Low-Height, Minimal 4-Segment Progress Indicator */}
|
||||
<div className="shrink-0 space-y-1.5 px-1 pb-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{isSummaryStep ? 'جمعبندی نهایی' : `مؤلفه ${currentStepIndex + 1} از ۴`}
|
||||
</span>
|
||||
<span>
|
||||
{isSummaryStep
|
||||
? 'کارنامه نهایی'
|
||||
: DIMENSION_CONFIGS[currentDimensionKey]?.titleFa}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Clean, low-height segment bar */}
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{DIMENSION_ORDER.map((dimKey, idx) => {
|
||||
const isCompleted =
|
||||
revealed[dimKey] && (currentStepIndex > idx || isSummaryStep);
|
||||
const isCurrent = currentStepIndex === idx;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dimKey}
|
||||
type="button"
|
||||
onClick={() => handleJumpToStep(idx)}
|
||||
disabled={!revealed[dimKey] && idx > currentStepIndex}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-300 focus:outline-none',
|
||||
isCurrent
|
||||
? 'bg-sky-500 dark:bg-sky-400'
|
||||
: isCompleted
|
||||
? 'bg-sky-500/50 hover:bg-sky-500 dark:bg-sky-400/50'
|
||||
: 'bg-slate-200/80 dark:bg-slate-800'
|
||||
)}
|
||||
aria-label={DIMENSION_CONFIGS[dimKey]?.titleFa}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Main Content (Dimension Card OR Final Summary) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!isSummaryStep && currentDimensionKey ? (
|
||||
/* Step-by-Step Dimension Card */
|
||||
<DimensionCard
|
||||
key={currentDimensionKey}
|
||||
config={DIMENSION_CONFIGS[currentDimensionKey]}
|
||||
score={scores[currentDimensionKey]}
|
||||
baselineScore={
|
||||
hasComparison ? baselineScores?.[currentDimensionKey] : undefined
|
||||
}
|
||||
stepNumber={currentStepIndex + 1}
|
||||
totalSteps={4}
|
||||
isRevealed={revealed[currentDimensionKey]}
|
||||
onReveal={handleRevealCurrent}
|
||||
onNext={handleNext}
|
||||
onPrev={currentStepIndex > 0 ? handlePrev : undefined}
|
||||
isLastDimension={currentStepIndex === 3}
|
||||
/>
|
||||
) : (
|
||||
/* Final Summary Screen */
|
||||
<motion.div
|
||||
key="final-summary"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.22, ease: MOTION_EASE }}
|
||||
style={{ willChange: 'opacity, transform' }}
|
||||
className="flex h-full min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{/* Main Summary Glass Card */}
|
||||
<div className="glass-panel flex h-full min-h-0 flex-1 flex-col justify-between overflow-y-auto space-y-4 rounded-3xl border border-white/50 p-4 sm:p-5 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
|
||||
{/* Header Title */}
|
||||
<div className="text-center sm:text-right">
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground sm:text-2xl">
|
||||
{isPre
|
||||
? 'کارنامه اولیه سرمایه روانشناختی'
|
||||
: 'کارنامه نهایی دوره نویا'}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
خلاصه وضعیت چهار مؤلفه شما
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Score Section */}
|
||||
<div className="space-y-1.5 rounded-2xl bg-sky-500/10 p-4 dark:bg-sky-500/15">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-semibold text-sky-800 dark:text-sky-300">
|
||||
نمره کل آزمون:
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1 font-bold tabular-nums">
|
||||
<span className="text-2xl font-black text-sky-900 dark:text-sky-100 sm:text-3xl">
|
||||
{totalScore.toLocaleString('fa-IR')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">از ۱۴۴</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-neon-gradient transition-all duration-600"
|
||||
style={{ width: `${totalPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasComparison && baselineScores && (
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
پیشآزمون: {baselineScores.total.toLocaleString('fa-IR')} ➔
|
||||
پسآزمون: {totalScore.toLocaleString('fa-IR')}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
totalDelta > 0
|
||||
? 'font-bold text-emerald-600 dark:text-emerald-400'
|
||||
: totalDelta < 0
|
||||
? 'font-bold text-amber-600 dark:text-amber-400'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{totalDelta > 0
|
||||
? `+${totalDelta.toLocaleString('fa-IR')} رشد کل`
|
||||
: totalDelta < 0
|
||||
? `${totalDelta.toLocaleString('fa-IR')} تغییر`
|
||||
: 'بدون تغییر'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4-Dimension Comparative Bars (Simple & Readable) */}
|
||||
<div className="space-y-2.5 pt-1">
|
||||
{DIMENSION_ORDER.map((dimKey) => {
|
||||
const config: DimensionConfig = DIMENSION_CONFIGS[dimKey];
|
||||
const dimScore = scores[dimKey];
|
||||
const dimPercent = Math.round((dimScore / 36) * 100);
|
||||
|
||||
return (
|
||||
<div key={dimKey} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-foreground">
|
||||
{config.titleFa}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{getDescriptiveTitleForScore(dimKey, dimScore)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${dimPercent}%`,
|
||||
backgroundColor: config.accentColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Non-diagnostic notice */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
این نتیجه بازتاب پاسخهای فعلی شماست و تشخیص قطعی روانشناختی نیست.
|
||||
</p>
|
||||
|
||||
{/* Mandatory Closing Text */}
|
||||
<SmoothTextAppearance contentKey="final-summary-closing" distance={5} duration={0.22}>
|
||||
<div className="rounded-2xl bg-white/50 p-4 text-xs leading-7 text-foreground/90 dark:bg-slate-800/40 sm:text-sm sm:leading-8">
|
||||
<p className="whitespace-pre-line text-body">
|
||||
حالا تصویر روشنتری از چهار مؤلفه سرمایه روانشناختی خودت داری.
|
||||
{'\n\n'}
|
||||
این نتیجه، نقطه شروعیه برای شناخت بهتر خودت؛ نه تعریف کامل شخصیت تو.
|
||||
{'\n\n'}
|
||||
برای آشنایی بیشتر با این مؤلفهها و تقویت اونها، پیشنهاد میکنیم آموزشهای نویا رو ببینی و تمرینهای مرتبط رو انجام بدی.
|
||||
</p>
|
||||
</div>
|
||||
</SmoothTextAppearance>
|
||||
|
||||
{/* Single Prominent Button */}
|
||||
{showActions && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="neon"
|
||||
size="lg"
|
||||
onClick={handleFinalContinue}
|
||||
className="w-full gap-2 font-bold shadow-md"
|
||||
>
|
||||
<span>{continueLabel}</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Subtle Secondary Back Control */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStepIndex(0)}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
مرور دوباره مؤلفهها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, Zap } from 'lucide-react';
|
||||
import {
|
||||
type DimensionConfig,
|
||||
getFeedbackForScore,
|
||||
getDescriptiveTitleForScore,
|
||||
} from '@/config/resultImages';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SmoothTextAppearance } from '@/components/motion';
|
||||
import { MOTION_EASE } from '@/lib/motion';
|
||||
|
||||
export interface DimensionCardProps {
|
||||
config: DimensionConfig;
|
||||
score: number;
|
||||
baselineScore?: number | null;
|
||||
stepNumber: number; // 1 to 4
|
||||
totalSteps?: number; // default 4
|
||||
isRevealed: boolean;
|
||||
onReveal: () => void;
|
||||
onNext: () => void;
|
||||
onPrev?: () => void;
|
||||
isLastDimension?: boolean;
|
||||
}
|
||||
|
||||
export function DimensionCard({
|
||||
config,
|
||||
score,
|
||||
baselineScore,
|
||||
isRevealed,
|
||||
onReveal,
|
||||
onNext,
|
||||
onPrev,
|
||||
isLastDimension = false,
|
||||
}: DimensionCardProps) {
|
||||
const [imgSrc, setImgSrc] = useState<string>(config.path);
|
||||
const [hasImageError, setHasImageError] = useState(false);
|
||||
|
||||
// Sync image source when dimension changes
|
||||
useEffect(() => {
|
||||
setImgSrc(config.path);
|
||||
setHasImageError(false);
|
||||
}, [config.path]);
|
||||
|
||||
// Range for PCQ-24 dimension is 6 to 36
|
||||
const minScore = 6;
|
||||
const maxScore = 36;
|
||||
const clampedScore = Math.max(minScore, Math.min(maxScore, score));
|
||||
// Position percentage along the 6-36 possible span
|
||||
const positionPercent = Math.round(
|
||||
((clampedScore - minScore) / (maxScore - minScore)) * 100
|
||||
);
|
||||
|
||||
const hasComparison =
|
||||
typeof baselineScore === 'number' && !Number.isNaN(baselineScore);
|
||||
const delta = hasComparison ? score - baselineScore : 0;
|
||||
|
||||
const descriptiveTitle = getDescriptiveTitleForScore(config.dimension, score);
|
||||
const feedbackText = getFeedbackForScore(config.dimension, score);
|
||||
|
||||
const handleImageError = () => {
|
||||
if (imgSrc !== config.fallbackPath) {
|
||||
setImgSrc(config.fallbackPath);
|
||||
} else {
|
||||
setHasImageError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={config.dimension}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.22, ease: MOTION_EASE }}
|
||||
style={{ willChange: 'opacity, transform' }}
|
||||
className="relative flex h-full min-h-0 flex-1 flex-col justify-between rounded-3xl border border-white/45 bg-white/70 p-4 shadow-sm backdrop-blur-md transition-all sm:p-5 dark:border-white/10 dark:bg-slate-900/65 dark:backdrop-blur-md overflow-hidden"
|
||||
dir="rtl"
|
||||
>
|
||||
{/* 1. Dimension Header (Strong Title & Distinct Tagline with standard vertical spacing) */}
|
||||
<div className="shrink-0 text-center sm:text-right">
|
||||
<h2 className="text-xl font-black tracking-tight text-foreground sm:text-2xl">
|
||||
{config.titleFa}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-xs font-normal text-muted-foreground/80 sm:text-sm">
|
||||
{config.tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2. Focused Large Illustration (Generous size, object-cover completely fitting the frame) */}
|
||||
<div className="my-auto flex w-full flex-1 min-h-0 items-center justify-center py-2 sm:py-3">
|
||||
<div className="relative mx-auto flex aspect-[1122/1402] h-full max-h-[30vh] min-h-[175px] w-auto max-w-[285px] sm:max-w-[315px] items-center justify-center overflow-hidden rounded-2xl border border-white/40 bg-white/25 shadow-sm backdrop-blur-xs dark:border-white/10 dark:bg-slate-800/35">
|
||||
{!hasImageError ? (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={config.altText}
|
||||
onError={handleImageError}
|
||||
className="h-full w-full object-cover object-center"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<Zap
|
||||
className="h-8 w-8 opacity-70"
|
||||
style={{ color: config.accentColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-xs font-semibold text-foreground">
|
||||
{config.titleFa}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Action / Result Area */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!isRevealed ? (
|
||||
/* Initial Unrevealed State */
|
||||
<motion.div
|
||||
key="unrevealed"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex flex-col items-center gap-3 pt-2 text-center shrink-0"
|
||||
>
|
||||
<p className="max-w-xs text-xs leading-5 text-muted-foreground sm:text-sm">
|
||||
{config.inviteText}
|
||||
</p>
|
||||
|
||||
{/* Single Prominent Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="neon"
|
||||
size="lg"
|
||||
onClick={onReveal}
|
||||
className="h-11 sm:h-12 w-full font-bold shadow-md sm:max-w-sm"
|
||||
>
|
||||
نمایش نتیجه من
|
||||
</Button>
|
||||
|
||||
{/* Subtle Secondary Back Control */}
|
||||
{onPrev && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
className="py-1 text-xs text-muted-foreground/75 transition-colors hover:text-foreground"
|
||||
>
|
||||
بازگشت به مؤلفه قبلی
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
/* Revealed Result State */
|
||||
<div
|
||||
key="revealed"
|
||||
className="flex flex-col shrink-0"
|
||||
>
|
||||
{/* 6. Simple Score Status Display (Unified Card: Title + Continuum + Raw Score) */}
|
||||
<SmoothTextAppearance
|
||||
contentKey={`score-status-${config.dimension}-${score}`}
|
||||
distance={4}
|
||||
duration={0.2}
|
||||
>
|
||||
<div className="rounded-2xl border border-sky-400/20 bg-sky-500/10 px-3.5 py-2.5 text-right dark:border-sky-400/10 dark:bg-sky-400/10">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">وضعیت:</span>
|
||||
<h3 className="text-xs font-bold text-foreground sm:text-sm">
|
||||
{descriptiveTitle}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground tabular-nums">
|
||||
<span>نمره:</span>
|
||||
<span className="font-bold text-foreground">
|
||||
{score.toLocaleString('fa-IR')} از ۳۶
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Continuum Visual Range Bar */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-800">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${positionPercent}%` }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
backgroundColor: config.accentColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground/75">
|
||||
<span>نقطه شروع</span>
|
||||
<span>در حال تقویت</span>
|
||||
<span>نقطه اتکای پررنگ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Baseline Delta Comparison */}
|
||||
{hasComparison && (
|
||||
<div className="mt-1.5 flex items-center justify-between border-t border-sky-400/15 pt-1.5 text-[10px] text-muted-foreground dark:border-sky-400/10">
|
||||
<span>تغییر نسبت به پیشآزمون:</span>
|
||||
<span
|
||||
className={
|
||||
delta > 0
|
||||
? 'font-semibold text-emerald-600 dark:text-emerald-400'
|
||||
: delta < 0
|
||||
? 'font-semibold text-amber-600 dark:text-amber-400'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{delta > 0
|
||||
? `+${delta.toLocaleString('fa-IR')} (از ${baselineScore?.toLocaleString('fa-IR')} به ${score.toLocaleString('fa-IR')})`
|
||||
: delta < 0
|
||||
? `${delta.toLocaleString('fa-IR')} (از ${baselineScore?.toLocaleString('fa-IR')} به ${score.toLocaleString('fa-IR')})`
|
||||
: 'بدون تغییر'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SmoothTextAppearance>
|
||||
|
||||
{/* 7. Prepared Feedback Text with standard vertical spacing */}
|
||||
<div className="mt-3.5 sm:mt-4 w-full">
|
||||
<SmoothTextAppearance
|
||||
contentKey={`feedback-${config.dimension}-${score}`}
|
||||
distance={5}
|
||||
duration={0.22}
|
||||
>
|
||||
<p className="text-xs leading-6 text-foreground/90 sm:text-sm sm:leading-7">
|
||||
{feedbackText}
|
||||
</p>
|
||||
</SmoothTextAppearance>
|
||||
</div>
|
||||
|
||||
{/* 8. Single Prominent Button with standard vertical spacing */}
|
||||
<div className="mt-4 sm:mt-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="neon"
|
||||
size="lg"
|
||||
onClick={onNext}
|
||||
className="h-11 sm:h-12 w-full gap-2 font-bold shadow-md"
|
||||
>
|
||||
<span>{isLastDimension ? 'دیدن جمعبندی' : 'مؤلفه بعدی'}</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 9. Subtle Secondary Back Control */}
|
||||
{onPrev && (
|
||||
<div className="mt-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
className="py-1 text-xs text-muted-foreground/75 transition-colors hover:text-foreground"
|
||||
>
|
||||
بازگشت به مؤلفه قبلی
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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[] }
|
||||
| { type: 'table'; headers: string[]; rows: string[][] };
|
||||
| { type: 'heading'; level: 1 | 2 | 3; text: string }
|
||||
| {
|
||||
type: 'list';
|
||||
ordered: boolean;
|
||||
items: { text: string; depth: number }[];
|
||||
markers?: string[];
|
||||
delimiters?: string[];
|
||||
}
|
||||
| { type: 'table'; headers: string[]; rows: string[][] }
|
||||
| { type: 'blockquote'; text: string }
|
||||
| { type: 'codeblock'; language?: string; code: string }
|
||||
| { type: 'hr' };
|
||||
|
||||
const stripImageMarkdown = (text: string): string =>
|
||||
text.replace(/!\[[^\]]*]\([^)]*\)/g, '').replace(/!\[[^\]]*]/g, '');
|
||||
|
||||
/** Hide leftover markdown/chat markers that must never appear on screen. */
|
||||
const stripVisibleMarkupArtifacts = (text: string): string =>
|
||||
const cleanSystemArtifacts = (text: string): string =>
|
||||
text
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/(^|\s)#{1,6}(?=\s|$)/gm, '$1')
|
||||
.replace(/@[\w.\u0600-\u06FF-]*/g, '')
|
||||
.replace(/```+/g, '')
|
||||
.replace(/[ \t]{2,}/g, ' ');
|
||||
|
||||
/** Render inline markdown; during streaming hide incomplete ** and apply bold early. */
|
||||
const isSafeUrl = (url: string): boolean => {
|
||||
const trimmed = url.trim().toLowerCase();
|
||||
return (
|
||||
trimmed.startsWith('https://') ||
|
||||
trimmed.startsWith('http://') ||
|
||||
trimmed.startsWith('/') ||
|
||||
trimmed.startsWith('#') ||
|
||||
trimmed.startsWith('mailto:')
|
||||
);
|
||||
};
|
||||
|
||||
/** Render inline markdown; during streaming hide incomplete ** / ` and apply bold early. */
|
||||
const renderInline = (text: string, isStreaming = false): ReactNode[] => {
|
||||
const cleaned = stripVisibleMarkupArtifacts(stripImageMarkdown(text));
|
||||
const cleaned = cleanSystemArtifacts(stripImageMarkdown(text));
|
||||
if (!isStreaming) {
|
||||
return renderInlineComplete(cleaned);
|
||||
}
|
||||
@@ -45,16 +87,69 @@ const renderInline = (text: string, isStreaming = false): ReactNode[] => {
|
||||
};
|
||||
|
||||
const renderInlineComplete = (text: string): ReactNode[] => {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/g);
|
||||
// Tokenize Links, Bold, Inline code, Italics, and plain text
|
||||
const TOKEN_RE =
|
||||
/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`|(?:\s|^)\*[^*\s][^*]*\*(?=\s|$)|(?:\s|^)_[^_\s][^_]*_(?=\s|$))/g;
|
||||
const parts = text.split(TOKEN_RE);
|
||||
|
||||
return parts.flatMap((part, index): ReactNode[] => {
|
||||
const bold = part.match(/^\*\*([^*]+)\*\*$/);
|
||||
if (bold) {
|
||||
if (!part) return [];
|
||||
|
||||
// 1. Link: [label](url)
|
||||
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (linkMatch) {
|
||||
const [, label = '', url = ''] = linkMatch;
|
||||
if (isSafeUrl(url)) {
|
||||
return [
|
||||
<a
|
||||
key={index}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={CHAT_TYPOGRAPHY.link}
|
||||
>
|
||||
{renderWithNums(label, `${index}-l`)}
|
||||
</a>,
|
||||
];
|
||||
}
|
||||
return renderWithNums(label, `${index}-l`);
|
||||
}
|
||||
|
||||
// 2. Bold: **text**
|
||||
const boldMatch = part.match(/^\*\*([^*]+)\*\*$/);
|
||||
if (boldMatch) {
|
||||
return [
|
||||
<strong key={index} className="font-semibold text-foreground">
|
||||
{renderWithNums(bold[1] ?? '', `${index}-b`)}
|
||||
<strong key={index} className={CHAT_TYPOGRAPHY.bold}>
|
||||
{renderWithNums(boldMatch[1] ?? '', `${index}-b`)}
|
||||
</strong>,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Inline code: `code`
|
||||
const codeMatch = part.match(/^`([^`]+)`$/);
|
||||
if (codeMatch) {
|
||||
return [
|
||||
<code key={index} className={CHAT_TYPOGRAPHY.codeInline}>
|
||||
{codeMatch[1]}
|
||||
</code>,
|
||||
];
|
||||
}
|
||||
|
||||
// 4. Italic: *text* or _text_
|
||||
const italicMatch =
|
||||
part.match(/^(\s?)\*([^*]+)\*(\s?)$/) || part.match(/^(\s?)_([^_]+)_(\s?)$/);
|
||||
if (italicMatch) {
|
||||
const [, leading = '', inner = '', trailing = ''] = italicMatch;
|
||||
return [
|
||||
leading,
|
||||
<em key={index} className={CHAT_TYPOGRAPHY.italic}>
|
||||
{renderWithNums(inner, `${index}-i`)}
|
||||
</em>,
|
||||
trailing,
|
||||
];
|
||||
}
|
||||
|
||||
// 5. Plain text with localized number rendering
|
||||
return renderWithNums(part, String(index));
|
||||
});
|
||||
};
|
||||
@@ -65,43 +160,75 @@ const renderInlineStreaming = (text: string): ReactNode[] => {
|
||||
let key = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const open = text.indexOf('**', i);
|
||||
if (open === -1) {
|
||||
nodes.push(...renderWithNums(text.slice(i), String(key++)));
|
||||
break;
|
||||
}
|
||||
const openBold = text.indexOf('**', i);
|
||||
const openCode = text.indexOf('`', i);
|
||||
|
||||
if (open > i) {
|
||||
nodes.push(...renderWithNums(text.slice(i, open), String(key++)));
|
||||
}
|
||||
|
||||
const close = text.indexOf('**', open + 2);
|
||||
if (close === -1) {
|
||||
// Opening ** without closing yet — bold weight, no visible asterisks
|
||||
const pending = text.slice(open + 2);
|
||||
if (pending) {
|
||||
nodes.push(
|
||||
<strong key={key++} className="font-semibold text-foreground">
|
||||
{renderWithNums(pending, `${key}-pending`)}
|
||||
</strong>
|
||||
);
|
||||
// Pick whichever marker appears earliest
|
||||
if (openBold !== -1 && (openCode === -1 || openBold < openCode)) {
|
||||
if (openBold > i) {
|
||||
nodes.push(...renderWithNums(text.slice(i, openBold), String(key++)));
|
||||
}
|
||||
break;
|
||||
|
||||
const closeBold = text.indexOf('**', openBold + 2);
|
||||
if (closeBold === -1) {
|
||||
// Pending bold chunk
|
||||
const pending = text.slice(openBold + 2);
|
||||
if (pending) {
|
||||
nodes.push(
|
||||
<strong key={key++} className={CHAT_TYPOGRAPHY.bold}>
|
||||
{renderWithNums(pending, `${key}-pending-b`)}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<strong key={key++} className={CHAT_TYPOGRAPHY.bold}>
|
||||
{renderWithNums(text.slice(openBold + 2, closeBold), `${key}-b`)}
|
||||
</strong>
|
||||
);
|
||||
i = closeBold + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const boldContent = text.slice(open + 2, close);
|
||||
nodes.push(
|
||||
<strong key={key++} className="font-semibold text-foreground">
|
||||
{renderWithNums(boldContent, `${key}-b`)}
|
||||
</strong>
|
||||
);
|
||||
i = close + 2;
|
||||
if (openCode !== -1) {
|
||||
if (openCode > i) {
|
||||
nodes.push(...renderWithNums(text.slice(i, openCode), String(key++)));
|
||||
}
|
||||
|
||||
const closeCode = text.indexOf('`', openCode + 1);
|
||||
if (closeCode === -1) {
|
||||
// Pending code chunk
|
||||
const pending = text.slice(openCode + 1);
|
||||
if (pending) {
|
||||
nodes.push(
|
||||
<code key={key++} className={CHAT_TYPOGRAPHY.codeInline}>
|
||||
{pending}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<code key={key++} className={CHAT_TYPOGRAPHY.codeInline}>
|
||||
{text.slice(openCode + 1, closeCode)}
|
||||
</code>
|
||||
);
|
||||
i = closeCode + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Neither found, output remaining text
|
||||
nodes.push(...renderWithNums(text.slice(i), String(key++)));
|
||||
break;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
/** Keep digits opaque under RTL justify — only wrap digits, leave text as plain nodes. */
|
||||
/** Keep digits opaque under RTL — isolate with dir=ltr and tabular numbers. */
|
||||
const renderWithNums = (text: string, keyPrefix: string): ReactNode[] => {
|
||||
const chunks = text.split(/(\d+|[۰-۹]+|[٠-٩]+)/g);
|
||||
return chunks.flatMap((chunk, i): ReactNode[] => {
|
||||
@@ -132,37 +259,63 @@ const isTableLine = (line: string): boolean =>
|
||||
line.includes('|') && line.trim().split('|').filter(Boolean).length >= 2;
|
||||
|
||||
/** Western, Persian, or Arabic-Indic digits + . or ) */
|
||||
const ORDERED_ITEM_RE = /^\s*((?:\d+|[۰-۹]+|[٠-٩]+))[.)]\s+(\S.*)$/;
|
||||
const ORDERED_ITEM_RE = /^(\s*)((?:\d+|[۰-۹]+|[٠-٩]+))([.)])\s+(\S.*)$/;
|
||||
|
||||
const isOrderedItem = (line: string): boolean => ORDERED_ITEM_RE.test(line);
|
||||
|
||||
const isBulletItem = (line: string): boolean => /^\s*(?:[-•]|[*])\s+\S/.test(line);
|
||||
const BULLET_ITEM_RE = /^(\s*)(?:[-•*])\s+(\S.*)$/;
|
||||
|
||||
const parseOrderedItem = (line: string): { marker: string; 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] ?? '', text: match[2] ?? '' };
|
||||
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,32 +391,62 @@ 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);
|
||||
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 } : { type: 'list', ordered: false, items }
|
||||
ordered
|
||||
? { type: 'list', ordered: true, items, markers, delimiters }
|
||||
: { type: 'list', ordered: false, items }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph: string[] = [line];
|
||||
// 7. Regular Paragraphs
|
||||
const paragraphLines: string[] = [line];
|
||||
i += 1;
|
||||
while (i < lines.length) {
|
||||
const current = lines[i] ?? '';
|
||||
@@ -231,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;
|
||||
@@ -264,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)
|
||||
@@ -314,14 +541,14 @@ export const MessageBubble = ({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}
|
||||
>
|
||||
|
||||
<div className={cn(isOwnMessage ? 'bubble-user' : 'bubble-ai')}>
|
||||
{blocks.length > 0 || showCursor ? (
|
||||
<div className={cn(isAssistant ? 'space-y-4' : 'space-y-2')}>
|
||||
<div className={cn(isAssistant ? 'space-y-3.5' : 'space-y-2')}>
|
||||
{(blocks.length > 0 ? blocks : [{ type: 'p' as const, text: '' }]).map((block, index) => {
|
||||
const isLast = index === Math.max(blocks.length - 1, 0);
|
||||
const cursor = showCursor && isLast ? <span className="ai-stream-cursor" aria-hidden /> : null;
|
||||
const cursor =
|
||||
showCursor && isLast ? <span className="ai-stream-cursor" aria-hidden /> : null;
|
||||
const blockMotion = isActivelyTyping
|
||||
? undefined
|
||||
: {
|
||||
@@ -330,63 +557,120 @@ export const MessageBubble = ({
|
||||
transition: { duration: MOTION_DURATION.fast, ease: MOTION_EASE },
|
||||
};
|
||||
|
||||
// 1. Lists (Ordered & Unordered)
|
||||
if (block.type === 'list') {
|
||||
const ListTag = block.ordered ? 'ol' : 'ul';
|
||||
const Wrapper = isActivelyTyping ? 'div' : motion.div;
|
||||
return (
|
||||
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
|
||||
<ListTag
|
||||
className={cn(
|
||||
'space-y-1.5 text-base leading-7 text-foreground',
|
||||
block.ordered ? 'list-none pe-0 ps-0' : 'list-disc'
|
||||
)}
|
||||
>
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${chatMessage.id}-li-${index}-${itemIndex}`}
|
||||
className={cn(
|
||||
'chat-prose',
|
||||
block.ordered && 'flex items-start gap-2'
|
||||
)}
|
||||
>
|
||||
{block.ordered ? (
|
||||
<>
|
||||
<span className="chat-num shrink-0 font-semibold" dir="ltr">
|
||||
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}.
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{renderInline(item, isActivelyTyping)}
|
||||
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderInline(item, isActivelyTyping)}
|
||||
{cursor && itemIndex === block.items.length - 1 ? cursor : null}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<ListTag className={CHAT_TYPOGRAPHY.list}>
|
||||
{block.items.map((item, itemIndex) => {
|
||||
const isLastItem = isLast && itemIndex === block.items.length - 1;
|
||||
const depthClass =
|
||||
item.depth === 1
|
||||
? 'mr-4 sm:mr-5'
|
||||
: item.depth === 2
|
||||
? 'mr-8 sm:mr-9'
|
||||
: item.depth >= 3
|
||||
? 'mr-12'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${chatMessage.id}-li-${index}-${itemIndex}`}
|
||||
className={cn('chat-prose flex items-start', depthClass)}
|
||||
>
|
||||
{block.ordered ? (
|
||||
<>
|
||||
<span
|
||||
className="chat-num shrink-0 font-bold select-none text-foreground/90 pl-2 leading-[1.85]"
|
||||
dir="ltr"
|
||||
>
|
||||
<span dir="ltr">
|
||||
{block.markers?.[itemIndex] ?? String(itemIndex + 1)}
|
||||
</span>
|
||||
{block.delimiters?.[itemIndex] ?? '.'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 whitespace-pre-line leading-[1.85] text-right">
|
||||
{renderInline(item.text, isActivelyTyping)}
|
||||
{cursor && isLastItem ? cursor : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="shrink-0 select-none text-sky-500/80 dark:text-sky-400/80 text-base leading-[1.85] pl-2.5 font-bold"
|
||||
aria-hidden
|
||||
>
|
||||
•
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 whitespace-pre-line leading-[1.85] text-right">
|
||||
{renderInline(item.text, isActivelyTyping)}
|
||||
{cursor && isLastItem ? cursor : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ListTag>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Blockquote
|
||||
if (block.type === 'blockquote') {
|
||||
const Wrapper = isActivelyTyping ? 'div' : motion.div;
|
||||
return (
|
||||
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
|
||||
<blockquote className={CHAT_TYPOGRAPHY.blockquote}>
|
||||
{renderInline(block.text, isActivelyTyping)}
|
||||
{cursor}
|
||||
</blockquote>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Code Block
|
||||
if (block.type === 'codeblock') {
|
||||
const Wrapper = isActivelyTyping ? 'div' : motion.div;
|
||||
return (
|
||||
<Wrapper key={`${chatMessage.id}-b-${index}`} {...blockMotion}>
|
||||
<div className="relative my-3 overflow-hidden rounded-xl border border-white/10 bg-slate-900 shadow-sm dark:bg-slate-950">
|
||||
{block.language && (
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-3.5 py-1.5 text-[11px] font-mono text-slate-400 dir-ltr">
|
||||
<span>{block.language}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="p-3.5 overflow-x-auto text-xs sm:text-sm font-mono text-slate-100 dir-ltr text-left leading-6">
|
||||
<code>{block.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Horizontal Rule
|
||||
if (block.type === 'hr') {
|
||||
return <hr key={`${chatMessage.id}-b-${index}`} className={CHAT_TYPOGRAPHY.hr} />;
|
||||
}
|
||||
|
||||
// 5. Table
|
||||
if (block.type === 'table') {
|
||||
const Wrapper = isActivelyTyping ? 'div' : motion.div;
|
||||
return (
|
||||
<Wrapper
|
||||
key={`${chatMessage.id}-b-${index}`}
|
||||
{...blockMotion}
|
||||
className={isActivelyTyping ? undefined : 'overflow-x-auto'}
|
||||
className="overflow-x-auto rounded-lg border border-border/80 my-2.5"
|
||||
>
|
||||
<table className="w-full border-collapse text-right text-sm leading-6">
|
||||
<table className={CHAT_TYPOGRAPHY.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{block.headers.map((header, headerIndex) => (
|
||||
<th
|
||||
key={`${chatMessage.id}-th-${index}-${headerIndex}`}
|
||||
className="border border-border bg-muted/60 px-2 py-1.5 font-semibold"
|
||||
className={CHAT_TYPOGRAPHY.tableHeader}
|
||||
>
|
||||
{renderInline(header, false)}
|
||||
</th>
|
||||
@@ -395,11 +679,14 @@ export const MessageBubble = ({
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, rowIndex) => (
|
||||
<tr key={`${chatMessage.id}-tr-${index}-${rowIndex}`}>
|
||||
<tr
|
||||
key={`${chatMessage.id}-tr-${index}-${rowIndex}`}
|
||||
className="even:bg-muted/30"
|
||||
>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={`${chatMessage.id}-td-${index}-${rowIndex}-${cellIndex}`}
|
||||
className="border border-border px-2 py-1.5"
|
||||
className={CHAT_TYPOGRAPHY.tableCell}
|
||||
>
|
||||
{renderInline(cell, false)}
|
||||
{cursor &&
|
||||
@@ -417,35 +704,49 @@ export const MessageBubble = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Headings (H1, H2, H3)
|
||||
if (block.type === 'heading') {
|
||||
const Tag = block.level === 1 ? 'h1' : block.level === 2 ? 'h2' : 'h3';
|
||||
const headingClass =
|
||||
block.level === 1
|
||||
? CHAT_TYPOGRAPHY.h1
|
||||
: block.level === 2
|
||||
? CHAT_TYPOGRAPHY.h2
|
||||
: CHAT_TYPOGRAPHY.h3;
|
||||
|
||||
if (isActivelyTyping) {
|
||||
const RawTag = Tag;
|
||||
return (
|
||||
<h3
|
||||
key={`${chatMessage.id}-b-${index}`}
|
||||
className="block w-full text-right text-base font-bold leading-7 text-foreground"
|
||||
>
|
||||
<RawTag key={`${chatMessage.id}-b-${index}`} className={headingClass}>
|
||||
{renderInline(block.text, true)}
|
||||
{cursor}
|
||||
</h3>
|
||||
</RawTag>
|
||||
);
|
||||
}
|
||||
|
||||
const MotionHeading =
|
||||
block.level === 1 ? motion.h1 : block.level === 2 ? motion.h2 : motion.h3;
|
||||
|
||||
return (
|
||||
<motion.h3
|
||||
<MotionHeading
|
||||
key={`${chatMessage.id}-b-${index}`}
|
||||
initial={index === 0 ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: MOTION_DURATION.fast, ease: MOTION_EASE }}
|
||||
className="block w-full text-right text-base font-bold leading-7 text-foreground"
|
||||
className={headingClass}
|
||||
>
|
||||
{renderInline(block.text, false)}
|
||||
{cursor}
|
||||
</motion.h3>
|
||||
</MotionHeading>
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Paragraph
|
||||
if (block.type === 'p') {
|
||||
const proseClass =
|
||||
'chat-prose break-words text-base leading-7 text-foreground';
|
||||
const proseClass = cn(
|
||||
CHAT_TYPOGRAPHY.body,
|
||||
isOwnMessage && 'chat-user-prose'
|
||||
);
|
||||
|
||||
if (isActivelyTyping) {
|
||||
return (
|
||||
@@ -476,6 +777,7 @@ export const MessageBubble = ({
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">پاسخ خالی دریافت شد.</p>
|
||||
)}
|
||||
|
||||
{chatMessage.status === 'error' && (
|
||||
<span className="mt-2 block text-[10px] text-red-500 dark:text-red-300">
|
||||
ارسال ناموفق بود. دوباره تلاش کنید.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { forwardRef, type ReactNode } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MOTION_EASE } from '@/lib/motion';
|
||||
|
||||
export interface SmoothTextAppearanceProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Unique key for the text content to trigger entrance animation once per actual content change */
|
||||
contentKey?: string | number;
|
||||
/** Duration in seconds (180ms to 280ms, default 0.22s = 220ms) */
|
||||
duration?: number;
|
||||
/** Vertical displacement in pixels (4px to 6px, default 5px) */
|
||||
distance?: number;
|
||||
/** If true, skip enter animation completely (e.g. static load or already revealed) */
|
||||
skipAnimation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SmoothTextAppearance
|
||||
*
|
||||
* Fast, natural text entrance inspired by ChatGPT's smooth response rendering:
|
||||
* - Unified block entrance (no letter-by-letter typewriter delay)
|
||||
* - 220ms duration (customizable between 180-280ms)
|
||||
* - Subtle vertical travel of 4-6px (default 5px) combined with opacity
|
||||
* - Strictly isolated to opacity & transform to avoid layout reflow (CLS)
|
||||
* - Rock-solid typography & RTL stability
|
||||
* - Respects prefers-reduced-motion
|
||||
*/
|
||||
export const SmoothTextAppearance = forwardRef<HTMLDivElement, SmoothTextAppearanceProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
contentKey,
|
||||
duration = 0.22,
|
||||
distance = 5,
|
||||
skipAnimation = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
// Respect reduced motion: no vertical displacement, minimal soft fade
|
||||
const effectiveDistance = shouldReduceMotion ? 0 : Math.max(4, Math.min(6, distance));
|
||||
const effectiveDuration = shouldReduceMotion ? 0.15 : Math.max(0.18, Math.min(0.28, duration));
|
||||
|
||||
if (skipAnimation) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-right text-foreground dir-rtl will-change-auto', className)}
|
||||
dir="rtl"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
key={contentKey}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: effectiveDistance,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: effectiveDuration,
|
||||
ease: MOTION_EASE,
|
||||
delay: 0,
|
||||
}}
|
||||
style={{
|
||||
willChange: 'opacity, transform',
|
||||
transform: 'translateZ(0)',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
className={cn(
|
||||
'text-right text-foreground dir-rtl',
|
||||
className
|
||||
)}
|
||||
dir="rtl"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SmoothTextAppearance.displayName = 'SmoothTextAppearance';
|
||||
@@ -1,3 +1,5 @@
|
||||
export { AnimationWrapper } from './AnimationWrapper';
|
||||
export { PageTransition, AnimatedRoutesShell } from './PageTransition';
|
||||
export { StaggeredList, StaggerItem } from './StaggeredList';
|
||||
export { SmoothTextAppearance, type SmoothTextAppearanceProps } from './SmoothTextAppearance';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { DimensionName } from '@/lib/assessment/scoring/dimensions';
|
||||
|
||||
export type ScoreTier = 'low' | 'mid' | 'high';
|
||||
|
||||
export interface DimensionConfig {
|
||||
/** Stable dimension key */
|
||||
dimension: DimensionName;
|
||||
/** Persian dimension title */
|
||||
titleFa: string;
|
||||
/** Short tagline */
|
||||
tagline: string;
|
||||
/** Accent color for charts and highlights */
|
||||
accentColor: string;
|
||||
/** Gradient styling */
|
||||
gradient: string;
|
||||
/** Primary image file name */
|
||||
fileName: string;
|
||||
/** Real local path */
|
||||
path: string;
|
||||
/** Fallback local path */
|
||||
fallbackPath: string;
|
||||
/** Accessible image alt text */
|
||||
altText: string;
|
||||
/** Short Persian invitation sentence for step initial state */
|
||||
inviteText: string;
|
||||
/** Short, non-diagnostic descriptive title for each score tier */
|
||||
descriptiveTitleByTier: {
|
||||
low: string; // 6 to 15
|
||||
mid: string; // 16 to 25
|
||||
high: string; // 26 to 36
|
||||
};
|
||||
/** Exactly 3 feedback texts for the 3 operational score categories */
|
||||
feedbackByTier: {
|
||||
low: string; // 6 to 15
|
||||
mid: string; // 16 to 25
|
||||
high: string; // 26 to 36
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 4 PCQ-24 dimensions configuration with local images, descriptive titles, and 12 exact feedback texts.
|
||||
* Operational Score Categories (not psychometric diagnostic labels):
|
||||
* - Category 1 (low): 6 to 15
|
||||
* - Category 2 (mid): 16 to 25
|
||||
* - Category 3 (high): 26 to 36
|
||||
*/
|
||||
export const DIMENSION_CONFIGS: Record<DimensionName, DimensionConfig> = {
|
||||
efficacy: {
|
||||
dimension: 'efficacy',
|
||||
titleFa: 'خودکارآمدی',
|
||||
tagline: 'باور به تواناییها و مهارتهای فردی',
|
||||
accentColor: '#00a8e8',
|
||||
gradient: 'from-sky-500/20 via-cyan-500/15 to-transparent',
|
||||
fileName: 'self-efficacy.webp',
|
||||
path: '/images/components/self-efficacy.webp',
|
||||
fallbackPath: '/images/results/self-efficacy.webp',
|
||||
altText: 'تصویر اختصاصی خودکارآمدی نویا',
|
||||
inviteText: 'آمادهای تا ببینی باورت به تواناییها و روبهرو شدن با چالشها چطور بوده؟',
|
||||
descriptiveTitleByTier: {
|
||||
low: 'در مسیر شناخت و رشد تواناییها',
|
||||
mid: 'باور روبهرشد به تواناییها',
|
||||
high: 'باور پررنگ به تواناییهای فردی',
|
||||
},
|
||||
feedbackByTier: {
|
||||
low: 'اعتماد به تواناییهای خودت هنوز جای رشد داره. شاید بعضی وقتها انجام کارهای تازه برات سخت به نظر برسه؛ این فقط بخشی از تصویر فعلی توئه و همه تواناییهایت رو مشخص نمیکنه.',
|
||||
mid: 'به نظر میرسه در بعضی موقعیتها به خودت اعتماد داری، اما گاهی هم درباره تواناییهایت تردید میکنی. این یعنی نگاهت به تواناییهای خودت در موقعیتهای مختلف میتونه متفاوت باشه.',
|
||||
high: 'یکی از ویژگیهای برجسته تو، اعتماد به تواناییهایت برای انجام کارهاست. به نظر میرسه در پاسخهایت، باور به توانایی خودت جایگاه پررنگی داره؛ هرچند این حس ممکنه در موقعیتهای مختلف تغییر کنه.',
|
||||
},
|
||||
},
|
||||
hope: {
|
||||
dimension: 'hope',
|
||||
titleFa: 'امیدواری',
|
||||
tagline: 'انگیزه و یافتن راههای رسیدن به هدف',
|
||||
accentColor: '#0d9488',
|
||||
gradient: 'from-teal-500/20 via-emerald-500/15 to-transparent',
|
||||
fileName: 'hope.webp',
|
||||
path: '/images/components/hope.webp',
|
||||
fallbackPath: '/images/results/hope.webp',
|
||||
altText: 'تصویر اختصاصی امیدواری نویا',
|
||||
inviteText: 'آمادهای تا ببینی نگاهت به هدفها و پیدا کردن مسیرهای رسیدن به اونها چطور بوده؟',
|
||||
descriptiveTitleByTier: {
|
||||
low: 'در مسیر کشف هدفها و راهها',
|
||||
mid: 'انگیزه و تلاش برای یافتن راهها',
|
||||
high: 'امیدواری و مسیرسازی پررنگ',
|
||||
},
|
||||
feedbackByTier: {
|
||||
low: 'پیدا کردن مسیر رسیدن به هدفها هنوز میتونه بخش مهمی از تجربه تو باشه. این نتیجه فقط نگاه فعلی تو به هدفها و راههای رسیدن به اونها رو نشون میده، نه چیزی که در آینده برات ممکنه یا غیرممکنه.',
|
||||
mid: 'به نظر میرسه برای بعضی هدفهایت مسیر روشنتری داری، اما درباره بعضی هدفهای دیگه هنوز در حال پیدا کردن راهت هستی. این کاملاً تصویری از نگاه فعلی تو به هدفها و مسیرهای رسیدن به اونهاست.',
|
||||
high: 'امیدواری یکی از نقاط قوت برجسته تو در این آزمونه. به نظر میرسه پیدا کردن هدفها و فکر کردن به راههای رسیدن به اونها در پاسخهایت جایگاه پررنگی داره.',
|
||||
},
|
||||
},
|
||||
resilience: {
|
||||
dimension: 'resilience',
|
||||
titleFa: 'تابآوری',
|
||||
tagline: 'ایستادگی و برگشتپذیری پس از سختیها',
|
||||
accentColor: '#8b5cf6',
|
||||
gradient: 'from-indigo-500/20 via-purple-500/15 to-transparent',
|
||||
fileName: 'resilience.webp',
|
||||
path: '/images/components/resilience.webp',
|
||||
fallbackPath: '/images/results/resilience.webp',
|
||||
altText: 'تصویر اختصاصی تابآوری نویا',
|
||||
inviteText: 'آمادهای تا ببینی کنار اومدن با سختیها و برگشتن به مسیر در پاسخهایت چطور بوده؟',
|
||||
descriptiveTitleByTier: {
|
||||
low: 'در مسیر یادگیری و برگشت به مسیر',
|
||||
mid: 'ایستادگی منعطف در موقعیتهای مختلف',
|
||||
high: 'ایستادگی و برگشتپذیری پررنگ',
|
||||
},
|
||||
feedbackByTier: {
|
||||
low: 'کنار اومدن با سختیها و برگشتن به مسیر، از بخشهایی است که هنوز میتونه در تو رشد کنه. این نمره بهتنهایی نمیگه در شرایط سخت چطور رفتار میکنی؛ فقط تصویری از پاسخهای فعلی توئه.',
|
||||
mid: 'به نظر میرسه در بعضی شرایط میتونی با سختیها کنار بیای، اما تجربهات در موقعیتهای مختلف یکسان نیست. این نتیجه نشون میده تابآوری تو در پاسخهای این آزمون، ترکیبی از جنبههای شکلگرفته و در حال رشده.',
|
||||
high: 'تابآوری یکی از نقاط قوت برجسته تو در این آزمونه. پاسخهایت نشون میدن کنار اومدن با دشواریها و ادامه دادن مسیر، در تصویر فعلی تو جایگاه پررنگی داره.',
|
||||
},
|
||||
},
|
||||
optimism: {
|
||||
dimension: 'optimism',
|
||||
titleFa: 'خوشبینی',
|
||||
tagline: 'نگاه مثبت و امیدوارکننده به آینده',
|
||||
accentColor: '#10b981',
|
||||
gradient: 'from-emerald-500/20 via-teal-500/15 to-transparent',
|
||||
fileName: 'optimism.webp',
|
||||
path: '/images/components/optimism.webp',
|
||||
fallbackPath: '/images/results/optimism.webp',
|
||||
altText: 'تصویر اختصاصی خوشبینی نویا',
|
||||
inviteText: 'آمادهای تا ببینی نگاهت به اتفاقها و دیدن جنبههای مثبت چطور بوده؟',
|
||||
descriptiveTitleByTier: {
|
||||
low: 'در مسیر کشف جنبههای روشن اتفاقها',
|
||||
mid: 'نگاه واقعبینانه و پویا به آینده',
|
||||
high: 'دیدگاه مثبت و امیدوارانه به آینده',
|
||||
},
|
||||
feedbackByTier: {
|
||||
low: 'نگاهت به اتفاقها و آینده هنوز ظرفیت رشد داره. این نمره به معنی منفی بودن نگاهت در همه موقعیتها نیست؛ فقط نشون میده پاسخهای فعلی تو درباره خوشبینی چه تصویری دارن.',
|
||||
mid: 'گاهی ممکنه جنبههای امیدوارکننده اتفاقها رو ببینی و گاهی هم نگاهت به شرایط متفاوت باشه. نتیجه تو تصویری از نحوه نگاه فعلیات به اتفاقها و آینده ارائه میده.',
|
||||
high: 'خوشبینی یکی از نقاط قوت برجسته تو در این آزمونه. پاسخهایت نشون میدن نگاه امیدوارانه به اتفاقها و آینده در تجربه فعلی تو جایگاه پررنگی داره.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RESULT_IMAGES = DIMENSION_CONFIGS;
|
||||
|
||||
/**
|
||||
* 4 dimensions ordered strictly according to the PCQ-24 specification:
|
||||
* 1. Self-efficacy (خودکارآمدی)
|
||||
* 2. Hope (امیدواری)
|
||||
* 3. Resilience (تابآوری)
|
||||
* 4. Optimism (خوشبینی)
|
||||
*/
|
||||
export const DIMENSION_ORDER: DimensionName[] = [
|
||||
'efficacy',
|
||||
'hope',
|
||||
'resilience',
|
||||
'optimism',
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a dimension score (6 to 36) to one of the 3 operational categories:
|
||||
* - 6 to 15: low (دسته اول)
|
||||
* - 16 to 25: mid (دسته دوم)
|
||||
* - 26 to 36: high (دسته سوم)
|
||||
*
|
||||
* NOTE: Both bounds are inclusive (e.g. 15 is low, 16 is mid, 25 is mid, 26 is high).
|
||||
*/
|
||||
export function getScoreTier(score: number): ScoreTier {
|
||||
if (score <= 15) return 'low';
|
||||
if (score <= 25) return 'mid';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the short, teen-friendly descriptive title for a dimension and score.
|
||||
*/
|
||||
export function getDescriptiveTitleForScore(
|
||||
dimension: DimensionName,
|
||||
score: number
|
||||
): string {
|
||||
const config = DIMENSION_CONFIGS[dimension];
|
||||
if (!config) return '';
|
||||
const tier = getScoreTier(score);
|
||||
return config.descriptiveTitleByTier[tier];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exact pre-written feedback text for a given dimension and score.
|
||||
*/
|
||||
export function getFeedbackForScore(
|
||||
dimension: DimensionName,
|
||||
score: number
|
||||
): string {
|
||||
const config = DIMENSION_CONFIGS[dimension];
|
||||
if (!config) return '';
|
||||
const tier = getScoreTier(score);
|
||||
return config.feedbackByTier[tier];
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
/** After first successful bootstrap, never flash the full-page chat skeleton again. */
|
||||
const hasHydratedChatRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
coachContextRef.current = coachContext;
|
||||
onCoachSessionCompletedRef.current = onCoachSessionCompleted;
|
||||
@@ -92,6 +93,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -366,6 +371,14 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
scheduleCompletionAfterStream(messageId);
|
||||
};
|
||||
|
||||
const stopGenerating = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setIsAiThinking(false);
|
||||
}, []);
|
||||
|
||||
const startCoachingSession = async (_userId: string) => {
|
||||
if (!chatSessionId) {
|
||||
throw new Error('جلسه گفتگو در دسترس نیست. لطفا صفحه را رفرش کنید.');
|
||||
@@ -376,37 +389,115 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
|
||||
const sessionOrder = coachContextRef.current?.order;
|
||||
setIsAiThinking(true);
|
||||
try {
|
||||
const result = await api.post<{
|
||||
session: { id: string };
|
||||
messages: BackendChatMessage[];
|
||||
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
|
||||
}>(`/chat/sessions/${chatSessionId}/start`);
|
||||
abortControllerRef.current = new AbortController();
|
||||
const assistantLocalId = createLocalId();
|
||||
let accumulatedContent = '';
|
||||
let hasCreatedAssistantMessage = false;
|
||||
let pendingRaf: number | null = null;
|
||||
|
||||
const backendMessages = result.messages.map((msg) =>
|
||||
mapBackendMessage(msg, _userId, sessionOrder ?? 0)
|
||||
);
|
||||
const streamingAssistant = backendMessages[backendMessages.length - 1];
|
||||
setMessages((prev) => {
|
||||
const withoutCurrent = prev.filter((msg) => msg.sessionOrder !== sessionOrder);
|
||||
const withStream = backendMessages.map((msg, index) =>
|
||||
index === backendMessages.length - 1 && msg.role === 'assistant'
|
||||
? { ...msg, status: 'streaming' as const }
|
||||
: msg
|
||||
const scheduleUpdate = () => {
|
||||
if (pendingRaf !== null) return;
|
||||
pendingRaf = window.requestAnimationFrame(() => {
|
||||
pendingRaf = null;
|
||||
const textToPaint = accumulatedContent;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantLocalId ? { ...msg, content: textToPaint } : msg
|
||||
)
|
||||
);
|
||||
return [...withoutCurrent, ...withStream];
|
||||
});
|
||||
};
|
||||
|
||||
if (streamingAssistant?.role === 'assistant') {
|
||||
queueCompletionForStream(result.completion, result.session.id, streamingAssistant.id);
|
||||
} else {
|
||||
emitCompletionIfValid(result.completion, result.session.id);
|
||||
}
|
||||
return backendMessages[backendMessages.length - 1];
|
||||
try {
|
||||
await api.stream(
|
||||
`/chat/sessions/${chatSessionId}/start`,
|
||||
{
|
||||
signal: abortControllerRef.current.signal,
|
||||
onEvent: (ev) => {
|
||||
if (ev.event === 'chunk' && ev.data?.text) {
|
||||
accumulatedContent += ev.data.text;
|
||||
if (!hasCreatedAssistantMessage) {
|
||||
hasCreatedAssistantMessage = true;
|
||||
setIsAiThinking(false);
|
||||
const placeholderAssistant: ChatMessage = {
|
||||
id: assistantLocalId,
|
||||
user_id: _userId,
|
||||
content: accumulatedContent,
|
||||
created_at: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
sessionOrder,
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const withoutCurrent = prev.filter((msg) => msg.sessionOrder !== sessionOrder);
|
||||
return [...withoutCurrent, placeholderAssistant];
|
||||
});
|
||||
} else {
|
||||
scheduleUpdate();
|
||||
}
|
||||
} else if (ev.event === 'done' && ev.data) {
|
||||
if (pendingRaf !== null) {
|
||||
window.cancelAnimationFrame(pendingRaf);
|
||||
pendingRaf = null;
|
||||
}
|
||||
setIsAiThinking(false);
|
||||
const doneData = ev.data as {
|
||||
messages?: BackendChatMessage[];
|
||||
assistantMessage?: BackendChatMessage;
|
||||
session?: { id: string };
|
||||
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
|
||||
};
|
||||
const backendMsg = doneData.messages?.[0] || doneData.assistantMessage;
|
||||
const canonicalAssistant = backendMsg
|
||||
? {
|
||||
...mapBackendMessage(backendMsg, _userId, sessionOrder ?? 0),
|
||||
status: 'sent' as const,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (canonicalAssistant) {
|
||||
setMessages((prev) => {
|
||||
const exists = prev.some((m) => m.id === assistantLocalId);
|
||||
if (exists) {
|
||||
return prev.map((msg) =>
|
||||
msg.id === assistantLocalId ? canonicalAssistant : msg
|
||||
);
|
||||
}
|
||||
return [...prev, canonicalAssistant];
|
||||
});
|
||||
|
||||
if (doneData.completion) {
|
||||
queueCompletionForStream(
|
||||
doneData.completion,
|
||||
doneData.session?.id || chatSessionId,
|
||||
canonicalAssistant.id
|
||||
);
|
||||
scheduleCompletionAfterStream(canonicalAssistant.id);
|
||||
}
|
||||
}
|
||||
} else if (ev.event === 'error') {
|
||||
throw new Error(ev.data?.message || 'خطا در شروع جلسه گفتگو');
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (pendingRaf !== null) {
|
||||
window.cancelAnimationFrame(pendingRaf);
|
||||
pendingRaf = null;
|
||||
}
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantLocalId ? { ...msg, status: 'sent' } : msg
|
||||
)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw resolveFriendlyError(error);
|
||||
} finally {
|
||||
setIsAiThinking(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -439,31 +530,112 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
setIsAiThinking(true);
|
||||
try {
|
||||
const result = await api.post<{
|
||||
session: { id: string };
|
||||
userMessage: BackendChatMessage & { role: 'user' };
|
||||
assistantMessage: BackendChatMessage & { role: 'assistant' };
|
||||
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
|
||||
}>(`/chat/sessions/${chatSessionId}/messages`, { content: trimmed });
|
||||
abortControllerRef.current = new AbortController();
|
||||
const assistantLocalId = createLocalId();
|
||||
let accumulatedContent = '';
|
||||
let hasCreatedAssistantMessage = false;
|
||||
let pendingRaf: number | null = null;
|
||||
|
||||
const persistedUser = mapBackendMessage(result.userMessage, nextUserId, sessionOrder ?? 0);
|
||||
const assistant = {
|
||||
...mapBackendMessage(result.assistantMessage, nextUserId, sessionOrder ?? 0),
|
||||
status: 'streaming' as const,
|
||||
};
|
||||
|
||||
setMessages((prev) => {
|
||||
const withoutPending = prev.filter((msg) => msg.id !== pendingId);
|
||||
if (skipUserMessage) {
|
||||
return [...withoutPending, assistant];
|
||||
}
|
||||
return [...withoutPending, persistedUser, assistant];
|
||||
const scheduleUpdate = () => {
|
||||
if (pendingRaf !== null) return;
|
||||
pendingRaf = window.requestAnimationFrame(() => {
|
||||
pendingRaf = null;
|
||||
const textToPaint = accumulatedContent;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantLocalId ? { ...msg, content: textToPaint } : msg
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
queueCompletionForStream(result.completion, result.session.id, assistant.id);
|
||||
return assistant;
|
||||
try {
|
||||
await api.stream(
|
||||
`/chat/sessions/${chatSessionId}/messages`,
|
||||
{
|
||||
body: { content: trimmed },
|
||||
signal: abortControllerRef.current.signal,
|
||||
onEvent: (ev) => {
|
||||
if (ev.event === 'chunk' && ev.data?.text) {
|
||||
accumulatedContent += ev.data.text;
|
||||
if (!hasCreatedAssistantMessage) {
|
||||
hasCreatedAssistantMessage = true;
|
||||
setIsAiThinking(false);
|
||||
const placeholderAssistant: ChatMessage = {
|
||||
id: assistantLocalId,
|
||||
user_id: nextUserId,
|
||||
content: accumulatedContent,
|
||||
created_at: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
sessionOrder,
|
||||
};
|
||||
setMessages((prev) => [...prev, placeholderAssistant]);
|
||||
} else {
|
||||
scheduleUpdate();
|
||||
}
|
||||
} else if (ev.event === 'ready' && ev.data?.userMessage) {
|
||||
const persistedUser = mapBackendMessage(ev.data.userMessage, nextUserId, sessionOrder ?? 0);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => (msg.id === pendingId ? persistedUser : msg))
|
||||
);
|
||||
} else if (ev.event === 'done' && ev.data) {
|
||||
if (pendingRaf !== null) {
|
||||
window.cancelAnimationFrame(pendingRaf);
|
||||
pendingRaf = null;
|
||||
}
|
||||
setIsAiThinking(false);
|
||||
const doneData = ev.data as {
|
||||
assistantMessage?: BackendChatMessage;
|
||||
session?: { id: string };
|
||||
completion?: { valid: boolean; signal?: CoachSessionCompletionSignal; errors?: string[] } | null;
|
||||
};
|
||||
const canonicalAssistant = doneData.assistantMessage
|
||||
? {
|
||||
...mapBackendMessage(doneData.assistantMessage, nextUserId, sessionOrder ?? 0),
|
||||
status: 'sent' as const,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (canonicalAssistant) {
|
||||
setMessages((prev) => {
|
||||
const exists = prev.some((m) => m.id === assistantLocalId);
|
||||
if (exists) {
|
||||
return prev.map((msg) =>
|
||||
msg.id === assistantLocalId ? canonicalAssistant : msg
|
||||
);
|
||||
}
|
||||
return [...prev, canonicalAssistant];
|
||||
});
|
||||
|
||||
if (doneData.completion) {
|
||||
queueCompletionForStream(
|
||||
doneData.completion,
|
||||
doneData.session?.id || chatSessionId,
|
||||
canonicalAssistant.id
|
||||
);
|
||||
scheduleCompletionAfterStream(canonicalAssistant.id);
|
||||
}
|
||||
}
|
||||
} else if (ev.event === 'error') {
|
||||
throw new Error(ev.data?.message || 'خطا در دریافت پاسخ');
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (pendingRaf !== null) {
|
||||
window.cancelAnimationFrame(pendingRaf);
|
||||
pendingRaf = null;
|
||||
}
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantLocalId ? { ...msg, status: 'sent' } : msg
|
||||
)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!skipUserMessage) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
@@ -474,6 +646,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
throw resolveFriendlyError(error);
|
||||
} finally {
|
||||
setIsAiThinking(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -487,5 +660,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
sendMessage,
|
||||
startCoachingSession,
|
||||
markAssistantStreamComplete,
|
||||
stopGenerating,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,23 +8,10 @@ type UseStreamTypewriterOptions = {
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
/** ~20% faster than the original pacing (delays × 0.8). */
|
||||
const SPEED = 0.8;
|
||||
|
||||
const randomDelayMs = (char: string, nextChar = ''): number => {
|
||||
if (char === '\n') {
|
||||
if (nextChar === '\n') return (340 + Math.random() * 140) * SPEED;
|
||||
return (200 + Math.random() * 90) * SPEED;
|
||||
}
|
||||
if (/[.!?…۔؟]/.test(char)) return (80 + Math.random() * 90) * SPEED;
|
||||
if (/[,،;:]/.test(char)) return (40 + Math.random() * 40) * SPEED;
|
||||
if (/\s/.test(char)) return (12 + Math.random() * 10) * SPEED;
|
||||
return (15 + Math.random() * 15) * SPEED;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulated streaming typewriter with variable per-character delay.
|
||||
* Stable across parent re-renders (onComplete/onProgress via refs).
|
||||
* Direct, continuous message stream hook.
|
||||
* Avoids slow character-by-character timer replay on complete responses
|
||||
* while cleanly maintaining progress and completion callbacks.
|
||||
*/
|
||||
export function useStreamTypewriter({
|
||||
text,
|
||||
@@ -32,13 +19,8 @@ export function useStreamTypewriter({
|
||||
onProgress,
|
||||
onComplete,
|
||||
}: UseStreamTypewriterOptions) {
|
||||
const [displayedText, setDisplayedText] = useState(() => (enabled ? '' : text));
|
||||
const [isStreaming, setIsStreaming] = useState(Boolean(enabled && text));
|
||||
const indexRef = useRef(0);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const textRef = useRef(text);
|
||||
const enabledRef = useRef(enabled);
|
||||
const completedForTextRef = useRef<string | null>(enabled ? null : text);
|
||||
const [displayedText, setDisplayedText] = useState(text);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const onProgressRef = useRef(onProgress);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
|
||||
@@ -51,79 +33,18 @@ export function useStreamTypewriter({
|
||||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
setDisplayedText('');
|
||||
setIsStreaming(false);
|
||||
indexRef.current = 0;
|
||||
textRef.current = '';
|
||||
enabledRef.current = enabled;
|
||||
completedForTextRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
setDisplayedText(text);
|
||||
setIsStreaming(false);
|
||||
textRef.current = text;
|
||||
enabledRef.current = false;
|
||||
completedForTextRef.current = text;
|
||||
return;
|
||||
}
|
||||
|
||||
const textChanged = textRef.current !== text;
|
||||
const enabledChanged = enabledRef.current !== enabled;
|
||||
textRef.current = text;
|
||||
enabledRef.current = enabled;
|
||||
|
||||
if (!textChanged && !enabledChanged && completedForTextRef.current === text) {
|
||||
setDisplayedText(text);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRestart =
|
||||
textChanged || enabledChanged || completedForTextRef.current !== text;
|
||||
|
||||
if (shouldRestart) {
|
||||
indexRef.current = 0;
|
||||
completedForTextRef.current = null;
|
||||
setDisplayedText('');
|
||||
setIsStreaming(true);
|
||||
|
||||
const tick = () => {
|
||||
const full = textRef.current;
|
||||
if (indexRef.current >= full.length) {
|
||||
setIsStreaming(false);
|
||||
completedForTextRef.current = full;
|
||||
onCompleteRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentChar = full[indexRef.current] ?? '';
|
||||
const nextChar = full[indexRef.current + 1] ?? '';
|
||||
const nextIndex = indexRef.current + 1;
|
||||
const nextText = full.slice(0, nextIndex);
|
||||
indexRef.current = nextIndex;
|
||||
setDisplayedText(nextText);
|
||||
onProgressRef.current?.(nextText);
|
||||
timeoutRef.current = setTimeout(tick, randomDelayMs(currentChar, nextChar));
|
||||
};
|
||||
|
||||
timeoutRef.current = setTimeout(tick, randomDelayMs(text[0] ?? ' ', text[1] ?? ''));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
setDisplayedText(text);
|
||||
setIsStreaming(false);
|
||||
onProgressRef.current?.(text);
|
||||
onCompleteRef.current?.();
|
||||
}, [text, enabled]);
|
||||
|
||||
return { displayedText, isStreaming };
|
||||
}
|
||||
|
||||
|
||||
@@ -91,12 +91,25 @@ export function getLoginErrorMessage(error: unknown): string {
|
||||
function localizeValidationMessage(message: string | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const lower = message.toLowerCase();
|
||||
if (/حداقل سن|سن باید|سن الزامی/.test(message)) {
|
||||
return message;
|
||||
}
|
||||
if (/age must be at least\s*(\d+)/i.test(message)) {
|
||||
const min = message.match(/age must be at least\s*(\d+)/i)?.[1] ?? '12';
|
||||
return `حداقل سن ${min} سال است`;
|
||||
}
|
||||
if (/age must be an integer|age is required/i.test(message)) {
|
||||
return 'سن باید یک عدد صحیح باشد';
|
||||
}
|
||||
if (lower.includes('email')) {
|
||||
return 'فرمت ایمیل نامعتبر است.';
|
||||
}
|
||||
if (lower.includes('password')) {
|
||||
return 'رمز عبور نامعتبر است.';
|
||||
}
|
||||
if (/^validation failed$/i.test(message.trim())) {
|
||||
return 'اطلاعات واردشده نامعتبر است.';
|
||||
}
|
||||
return localizeKnownEnglishMessage(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
||||
|
||||
/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */
|
||||
export const SESSION_UNLOCK_INTERVAL_MS = 0;
|
||||
|
||||
export type SessionCompletionTimes = Record<number, string>;
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
||||
import { getProgramProgress } from '@/services/programService';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronLeft, Lock } from 'lucide-react';
|
||||
import { Check, ChevronLeft, Lock, NotebookPen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StaggeredList, StaggerItem, AnimationWrapper } from '@/components/motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import { fastTransition } from '@/lib/motion';
|
||||
import { CourseStatusSkeleton } from '@/components/skeletons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type StepState = 'open' | 'locked' | 'done';
|
||||
|
||||
@@ -85,6 +86,20 @@ export default function AccountPage() {
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{!isGuest ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 w-full justify-between"
|
||||
onClick={() => navigate('/assignments')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<NotebookPen className="h-4 w-4 text-sky-600 dark:text-sky-400" />
|
||||
تکالیف من
|
||||
</span>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null}
|
||||
</AnimationWrapper>
|
||||
|
||||
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
|
||||
@@ -92,11 +107,15 @@ export default function AccountPage() {
|
||||
<StatusRow
|
||||
title="پیشآزمون"
|
||||
state={preState}
|
||||
onOpen={() => navigate('/assessment/pre')}
|
||||
lockedHint={preDone ? 'انجام شده — قابل تکرار نیست' : undefined}
|
||||
onOpen={preDone ? () => navigate('/assessment/pre') : undefined}
|
||||
>
|
||||
{preDone ? (
|
||||
<StatusDone label="انجام شده" />
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusDone label="انجام شده" />
|
||||
<span className="text-xs font-medium text-sky-600 dark:text-sky-400">
|
||||
مشاهده کارنامه
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-body">برای شروع مسیر، پیشآزمون را انجام دهید</p>
|
||||
)}
|
||||
@@ -139,14 +158,21 @@ export default function AccountPage() {
|
||||
<StatusRow
|
||||
title="پسآزمون"
|
||||
state={postState}
|
||||
onOpen={() => navigate('/assessment/post')}
|
||||
onOpen={postDone ? () => navigate('/finished') : postState === 'open' ? () => navigate('/assessment/post') : undefined}
|
||||
lockedHint={
|
||||
postDone
|
||||
? 'انجام شده — قابل تکرار نیست'
|
||||
? undefined
|
||||
: 'پس از رسیدن به جلسه پایانی فعال میشود'
|
||||
}
|
||||
>
|
||||
{postState === 'done' && <StatusDone label="انجام شده" />}
|
||||
{postState === 'done' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusDone label="انجام شده" />
|
||||
<span className="text-xs font-medium text-sky-600 dark:text-sky-400">
|
||||
مشاهده کارنامه
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{postState === 'open' && (
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span
|
||||
@@ -180,10 +206,10 @@ function StatusRow({
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
state: StepState;
|
||||
onOpen: () => void;
|
||||
onOpen?: () => void;
|
||||
lockedHint?: string;
|
||||
}) {
|
||||
const isInteractive = state === 'open';
|
||||
const isInteractive = Boolean(onOpen) && (state === 'open' || state === 'done');
|
||||
|
||||
const content = (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -205,7 +231,7 @@ function StatusRow({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isInteractive) {
|
||||
if (isInteractive && onOpen) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -7,8 +7,11 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useLearningJourney } from '@/hooks/useLearningJourney';
|
||||
import {
|
||||
saveAssessment,
|
||||
getLatestByPhase,
|
||||
type AttemptPhase,
|
||||
type AssessmentSubmission,
|
||||
} from '@/services/assessmentService';
|
||||
import { AssessmentResultView } from '@/components/assessment/AssessmentResultView';
|
||||
import { BackendError } from '@/services/backend';
|
||||
import { getBackendErrorMessage, isBackendNetworkError } from '@/lib/backendError';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
@@ -40,16 +43,39 @@ export default function AssessmentPage() {
|
||||
const [introStep, setIntroStep] = useState(0);
|
||||
const [contentVisible, setContentVisible] = useState(true);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [completedSubmission, setCompletedSubmission] = useState<AssessmentSubmission | null>(null);
|
||||
const [showResultView, setShowResultView] = useState(false);
|
||||
const [loadingExistingResult, setLoadingExistingResult] = useState(false);
|
||||
|
||||
const isPreTest = phase === 'pre';
|
||||
const isPostTest = phase === 'post';
|
||||
const invalidPhase = !isPreTest && !isPostTest;
|
||||
|
||||
useEffect(() => {
|
||||
// If pre-test was already completed and user is on /assessment/pre, load the result to display
|
||||
if (isPreTest && !loading && progress?.pre_test_completed_at && effectiveUserId && !completedSubmission) {
|
||||
setLoadingExistingResult(true);
|
||||
getLatestByPhase(effectiveUserId, 'pre')
|
||||
.then((submission) => {
|
||||
if (submission) {
|
||||
setCompletedSubmission(submission);
|
||||
setShowResultView(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load existing pre-test result:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingExistingResult(false);
|
||||
});
|
||||
}
|
||||
}, [isPreTest, loading, progress?.pre_test_completed_at, effectiveUserId, completedSubmission]);
|
||||
|
||||
const canAccess = useMemo(() => {
|
||||
if (isPreTest) return permissions.canTakePreTest;
|
||||
if (isPreTest) return permissions.canTakePreTest || Boolean(progress?.pre_test_completed_at);
|
||||
if (isPostTest) return permissions.canTakePostTest;
|
||||
return false;
|
||||
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest]);
|
||||
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest, progress?.pre_test_completed_at]);
|
||||
|
||||
const totalQuestions = PCQ24_QUESTIONS.length;
|
||||
const currentQuestion = PCQ24_QUESTIONS[currentIndex];
|
||||
@@ -67,7 +93,23 @@ export default function AssessmentPage() {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
if (!loading && !canAccess) {
|
||||
if (loading || loadingExistingResult) {
|
||||
return <AssessmentSkeleton />;
|
||||
}
|
||||
|
||||
if (showResultView && completedSubmission) {
|
||||
return (
|
||||
<div className="ambient-glow relative flex flex-col h-full min-h-0 flex-1 overflow-hidden px-3 pt-1.5 pb-3 sm:px-4 sm:pt-2 sm:pb-4">
|
||||
<AssessmentResultView
|
||||
phase={isPreTest ? 'pre' : 'post'}
|
||||
currentSubmission={completedSubmission}
|
||||
onContinue={() => navigate(isPreTest ? '/chat' : '/finished')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
if (isPreTest && progress?.pre_test_completed_at) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
@@ -79,10 +121,6 @@ export default function AssessmentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <AssessmentSkeleton />;
|
||||
}
|
||||
|
||||
const advanceWithFade = (delta: number) => {
|
||||
if (isAnimating || submitting || isRedirecting) return;
|
||||
setIsAnimating(true);
|
||||
@@ -151,17 +189,16 @@ export default function AssessmentPage() {
|
||||
if (isPreTest) {
|
||||
await markPreTestDone(submission.completedAt);
|
||||
await reloadProgress();
|
||||
setSuccessMessage('پیشآزمون با موفقیت ثبت شد. در حال انتقال به فضای چت...');
|
||||
setIsRedirecting(true);
|
||||
setTimeout(() => navigate('/chat'), 800);
|
||||
setCompletedSubmission(submission);
|
||||
setShowResultView(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await markPostTestDone(submission.completedAt);
|
||||
await reloadProgress();
|
||||
setSuccessMessage('پسآزمون با موفقیت ثبت شد. در حال انتقال به صفحه پایان دوره...');
|
||||
setSuccessMessage('پسآزمون با موفقیت ثبت شد. در حال انتقال به صفحه کارنامه پایانی...');
|
||||
setIsRedirecting(true);
|
||||
setTimeout(() => navigate('/finished'), 800);
|
||||
setTimeout(() => navigate('/finished'), 600);
|
||||
} catch (error: unknown) {
|
||||
console.error('Error saving assessment:', error);
|
||||
if (error instanceof BackendError) {
|
||||
@@ -179,6 +216,12 @@ export default function AssessmentPage() {
|
||||
if (isPreTest) {
|
||||
await markPreTestDone();
|
||||
await reloadProgress();
|
||||
const latest = await getLatestByPhase(effectiveUserId, 'pre');
|
||||
if (latest) {
|
||||
setCompletedSubmission(latest);
|
||||
setShowResultView(true);
|
||||
return;
|
||||
}
|
||||
navigate('/chat', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchAssignmentCatalog,
|
||||
type AssignmentCatalogItem,
|
||||
} from '@/services/assignmentService';
|
||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
||||
import { AnimationWrapper, StaggerItem, StaggeredList } from '@/components/motion';
|
||||
import { PageSkeleton } from '@/components/skeletons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -40,7 +41,7 @@ export default function AssignmentsPage() {
|
||||
const rows = await fetchAssignmentCatalog();
|
||||
setItems(rows);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'بارگذاری تکالیف ناموفق بود');
|
||||
setError(getBackendErrorMessage(err, 'بارگذاری تکالیف ناموفق بود'));
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -109,14 +110,20 @@ export default function AssignmentsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground dark:border-slate-600">
|
||||
هنوز تکلیفی تعریف نشده است.
|
||||
</p>
|
||||
<div className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground dark:border-slate-600">
|
||||
<p>فهرست تکالیف هنوز از سرور بارگذاری نشده است.</p>
|
||||
<p className="mt-2 text-xs">
|
||||
پس از تکمیل هر یک از جلسات ۲ تا ۱۰، تکلیف همان جلسه اینجا باز میشود.
|
||||
</p>
|
||||
<Button variant="link" className="mt-2 px-0" onClick={() => void load()}>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<StaggeredList itemCount={items.length} className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const ua = item.userAssignment;
|
||||
const unlocked = item.unlocked && ua;
|
||||
const isOpen = item.unlocked && Boolean(ua);
|
||||
const completed = ua?.status === 'COMPLETED';
|
||||
|
||||
const content = (
|
||||
@@ -130,7 +137,7 @@ export default function AssignmentsPage() {
|
||||
{item.definition.title}
|
||||
</h2>
|
||||
</div>
|
||||
{unlocked ? (
|
||||
{isOpen ? (
|
||||
<ChevronLeft className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Lock className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -143,7 +150,7 @@ export default function AssignmentsPage() {
|
||||
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
|
||||
{assignmentTypeLabel(item.definition.assignmentType)}
|
||||
</span>
|
||||
{unlocked && ua ? (
|
||||
{isOpen && ua ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -169,7 +176,7 @@ export default function AssignmentsPage() {
|
||||
|
||||
return (
|
||||
<StaggerItem key={item.definition.id}>
|
||||
{unlocked ? (
|
||||
{isOpen && ua ? (
|
||||
<Link
|
||||
to={`/assignments/${ua.id}`}
|
||||
className="block rounded-xl border border-slate-200/80 bg-white p-4 shadow-sm transition hover:border-sky-300 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-600"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { exampleCompletionSignal, looksLikeFinalPostTestHandoff } from '@/lib/ai
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getFetchErrorMessage } from '@/lib/fetchError';
|
||||
import { APP_NAME } from '@/utils/constants';
|
||||
import { ArrowUp, Loader2, Sparkles } from 'lucide-react';
|
||||
import { ArrowUp, Loader2, Sparkles, Square } from 'lucide-react';
|
||||
import { LoadingOverlay } from '@/components/ui/loading-overlay';
|
||||
import { ChatSkeleton, PageSkeleton } from '@/components/skeletons';
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function ChatPage() {
|
||||
sendMessage,
|
||||
startCoachingSession,
|
||||
markAssistantStreamComplete,
|
||||
stopGenerating,
|
||||
} = useChat({
|
||||
coachContext,
|
||||
userId: effectiveUserId,
|
||||
@@ -296,15 +297,27 @@ export default function ChatPage() {
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
|
||||
const scroller = messagesScrollRef.current;
|
||||
const end = messagesEndRef.current;
|
||||
if (end) {
|
||||
end.scrollIntoView({ behavior, block: 'end' });
|
||||
}
|
||||
if (scroller) {
|
||||
if (!scroller) return;
|
||||
if (behavior === 'smooth') {
|
||||
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ensure scroll position remains accurate when mobile virtual keyboard opens/resizes
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.visualViewport) return;
|
||||
const handleViewportResize = () => {
|
||||
scrollToBottom('auto');
|
||||
};
|
||||
const viewport = window.visualViewport;
|
||||
viewport.addEventListener('resize', handleViewportResize);
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', handleViewportResize);
|
||||
};
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const isStreamOrThinking =
|
||||
isAiThinking || messages.some((msg) => msg.status === 'streaming');
|
||||
|
||||
@@ -526,7 +539,7 @@ export default function ChatPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="glass-panel-chat relative flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
|
||||
<div className="glass-panel-chat relative flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-4">
|
||||
<LoadingOverlay
|
||||
open={isSessionTransitionPending}
|
||||
message={
|
||||
@@ -535,7 +548,7 @@ export default function ChatPage() {
|
||||
: 'در حال آمادهسازی جلسه بعد…'
|
||||
}
|
||||
/>
|
||||
<div className="relative z-10 shrink-0">
|
||||
<div className="relative z-10 shrink-0 border-b border-slate-200/50 pb-2.5 sm:pb-3 dark:border-slate-800/60">
|
||||
{isProgramComplete ? (
|
||||
<p className="text-center text-xs text-muted-foreground">دوره آموزشی تکمیل شده</p>
|
||||
) : isWaitingForUnlock && waitingForNextSession ? (
|
||||
@@ -587,7 +600,7 @@ export default function ChatPage() {
|
||||
|
||||
<div
|
||||
ref={messagesScrollRef}
|
||||
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain px-0.5 py-2.5"
|
||||
>
|
||||
{loading && messages.length === 0 ? (
|
||||
<ChatSkeleton />
|
||||
@@ -730,7 +743,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full shrink-0 pb-1 pt-1">
|
||||
<div className="relative z-20 w-full shrink-0 pt-2 pb-0.5">
|
||||
<form onSubmit={handleSend} className="w-full">
|
||||
<div className="composer-glass">
|
||||
<textarea
|
||||
@@ -742,6 +755,9 @@ export default function ChatPage() {
|
||||
setMessage(e.target.value);
|
||||
resizeComposer();
|
||||
}}
|
||||
onFocus={() => {
|
||||
window.setTimeout(() => scrollToBottom('smooth'), 120);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
@@ -754,20 +770,34 @@ export default function ChatPage() {
|
||||
className="input-neon max-h-36 min-h-12 flex-1 resize-none overflow-y-auto border-0 bg-transparent py-3 text-right text-base leading-6 text-foreground placeholder:text-muted-foreground shadow-none outline-none backdrop-blur-none focus-visible:border-0 focus-visible:shadow-none focus-visible:ring-0 disabled:opacity-60"
|
||||
aria-label={`پیام به ${APP_NAME}`}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="neon"
|
||||
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 disabled:opacity-50 [&_svg]:h-5 [&_svg]:w-5"
|
||||
disabled={composerDisabled || !message.trim()}
|
||||
aria-label="ارسال"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
|
||||
)}
|
||||
</Button>
|
||||
{isStreamBusy ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={stopGenerating}
|
||||
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 border-sky-400/50 bg-sky-500/15 text-sky-400 hover:bg-sky-500/25 dark:border-sky-400/40 dark:bg-sky-400/10 dark:text-sky-300"
|
||||
aria-label="توقف تولید"
|
||||
title="توقف تولید"
|
||||
>
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="neon"
|
||||
className="mb-0.5 h-10 w-10 shrink-0 rounded-full p-0 disabled:opacity-50 [&_svg]:h-5 [&_svg]:w-5"
|
||||
disabled={composerDisabled || !message.trim()}
|
||||
aria-label="ارسال"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-5 w-5" strokeWidth={2.75} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,59 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useLearningJourney } from '@/hooks/useLearningJourney';
|
||||
import { Check, CheckCircle2 } from 'lucide-react';
|
||||
import { FinishedSkeleton } from '@/components/skeletons';
|
||||
import {
|
||||
getLatestByPhase,
|
||||
type AssessmentSubmission,
|
||||
} from '@/services/assessmentService';
|
||||
import { AssessmentResultView } from '@/components/assessment/AssessmentResultView';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { getBackendErrorMessage } from '@/lib/backendError';
|
||||
|
||||
export default function FinishedPage() {
|
||||
const { effectiveUserId, guestSessionHydrated } = useAuth();
|
||||
const { permissions, loading } = useLearningJourney(effectiveUserId ?? undefined);
|
||||
const { permissions, loading: journeyLoading } = useLearningJourney(
|
||||
effectiveUserId ?? undefined
|
||||
);
|
||||
|
||||
if (!guestSessionHydrated || loading) {
|
||||
const [postAttempt, setPostAttempt] = useState<AssessmentSubmission | null>(null);
|
||||
const [preAttempt, setPreAttempt] = useState<AssessmentSubmission | null>(null);
|
||||
const [loadingAttempts, setLoadingAttempts] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAttempts = useCallback(async () => {
|
||||
if (!effectiveUserId) return;
|
||||
setLoadingAttempts(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [latestPost, latestPre] = await Promise.all([
|
||||
getLatestByPhase(effectiveUserId, 'post'),
|
||||
getLatestByPhase(effectiveUserId, 'pre'),
|
||||
]);
|
||||
setPostAttempt(latestPost);
|
||||
setPreAttempt(latestPre);
|
||||
} catch (err) {
|
||||
console.error('Failed to load finished page assessment attempts:', err);
|
||||
setError(
|
||||
getBackendErrorMessage(
|
||||
err,
|
||||
'دریافت نتایج با مشکل مواجه شد. لطفاً دوباره تلاش کنید.'
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setLoadingAttempts(false);
|
||||
}
|
||||
}, [effectiveUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveUserId && guestSessionHydrated) {
|
||||
void fetchAttempts();
|
||||
}
|
||||
}, [effectiveUserId, guestSessionHydrated, fetchAttempts]);
|
||||
|
||||
if (!guestSessionHydrated || journeyLoading || loadingAttempts) {
|
||||
return <FinishedSkeleton />;
|
||||
}
|
||||
|
||||
@@ -20,37 +65,51 @@ export default function FinishedPage() {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-10">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col items-center gap-8 text-center">
|
||||
<div className="avatar-glow">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm dark:bg-navy dark:text-neon-emerald dark:shadow-none">
|
||||
<CheckCircle2 className="h-9 w-9" aria-hidden />
|
||||
</div>
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-8">
|
||||
<div className="glass-panel mx-auto flex w-full max-w-md flex-col items-center gap-4 p-6 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" aria-hidden />
|
||||
<h2 className="text-lg font-bold text-foreground">خطا در دریافت نتایج</h2>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => void fetchAttempts()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>تلاش مجدد</span>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold leading-8 text-foreground sm:text-2xl">
|
||||
دوره آموزشی شما با موفقیت به پایان رسید.
|
||||
</h1>
|
||||
|
||||
<ul className="glass-panel w-full space-y-3 px-4 py-4 text-right text-sm text-body">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
|
||||
پیشآزمون انجام شده
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
|
||||
دوره آموزشی تکمیل شده
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 shrink-0 text-neon-emerald" aria-hidden />
|
||||
پسآزمون انجام شده
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
از منوی پروفایل میتوانید وضعیت دوره را ببینید یا خارج شوید.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!postAttempt) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-8">
|
||||
<div className="glass-panel mx-auto flex w-full max-w-md flex-col items-center gap-4 p-6 text-center">
|
||||
<h2 className="text-lg font-bold text-foreground">نتیجهای یافت نشد</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
هنوز نتیجه پسآزمونی برای حساب شما ثبت نشده است.
|
||||
</p>
|
||||
<Button type="button" variant="neon" onClick={() => window.location.assign('/')}>
|
||||
بازگشت به خانه
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full min-h-0 flex-1 overflow-hidden px-3 pt-1.5 pb-3 sm:px-4 sm:pt-2 sm:pb-4">
|
||||
<AssessmentResultView
|
||||
phase="post"
|
||||
currentSubmission={postAttempt}
|
||||
baselineSubmission={preAttempt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function ProfileCompletionPage() {
|
||||
id="profile-age"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="سن"
|
||||
placeholder={`حداقل ${PROFILE_AGE_MIN} سال`}
|
||||
value={age}
|
||||
onChange={(event) => {
|
||||
setAge(event.target.value);
|
||||
@@ -152,9 +152,17 @@ export default function ProfileCompletionPage() {
|
||||
}}
|
||||
disabled={isLoading}
|
||||
autoComplete="off"
|
||||
min={PROFILE_AGE_MIN}
|
||||
aria-invalid={Boolean(fieldError.age)}
|
||||
aria-describedby="profile-age-hint"
|
||||
/>
|
||||
{fieldError.age ? <p className="text-xs text-red-400">{fieldError.age}</p> : null}
|
||||
{fieldError.age ? (
|
||||
<p className="text-xs text-red-400">{fieldError.age}</p>
|
||||
) : (
|
||||
<p id="profile-age-hint" className="text-xs text-muted-foreground">
|
||||
حداقل سن ورود {PROFILE_AGE_MIN} سال است
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="neon" className="w-full" disabled={isLoading}>
|
||||
|
||||
@@ -118,6 +118,123 @@ async function request<T>(
|
||||
return (body as ApiSuccess<T>).data;
|
||||
}
|
||||
|
||||
export type SseEvent<T = any> = {
|
||||
event: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export async function streamRequest(
|
||||
path: string,
|
||||
options: {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
onEvent: (event: SseEvent) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const token = getSessionToken();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Accept', 'text/event-stream');
|
||||
headers.set('Content-Type', 'application/json');
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: options.method ?? 'POST',
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch {
|
||||
if (options.signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
throw new BackendError(
|
||||
0,
|
||||
'NETWORK_ERROR',
|
||||
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearSessionToken();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
|
||||
}
|
||||
throw new BackendError(401, 'UNAUTHORIZED', 'نشست شما منقضی شده است. لطفا دوباره وارد شوید.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errBody: any;
|
||||
try {
|
||||
errBody = await response.json();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new BackendError(
|
||||
response.status,
|
||||
errBody?.error?.code ?? 'SERVER_ERROR',
|
||||
errBody?.error?.message ?? `Request failed with status ${response.status}`,
|
||||
errBody?.error?.details,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new BackendError(0, 'NO_BODY', 'پاسخی از سرور دریافت نشد.');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue;
|
||||
const lines = block.split('\n');
|
||||
let eventName = 'message';
|
||||
let dataStr = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataStr = line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (dataStr) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataStr);
|
||||
options.onEvent({ event: eventName, data: parsed });
|
||||
} catch {
|
||||
options.onEvent({ event: eventName, data: dataStr });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (options.signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data?: unknown) =>
|
||||
@@ -131,4 +248,6 @@ export const api = {
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
}),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
stream: streamRequest,
|
||||
};
|
||||
|
||||
|
||||
@@ -334,8 +334,7 @@ export async function submitHomeworkReport(
|
||||
|
||||
/**
|
||||
* Check if a specific session is unlocked for a user.
|
||||
* Session N unlocks when sessions 1…N-1 are complete and at least 1 hour has passed
|
||||
* since session N-1 was completed.
|
||||
* Session N unlocks immediately when previous sessions 1…N-1 are complete.
|
||||
*/
|
||||
export function isSessionUnlocked(userId: string, sessionOrder: number): boolean {
|
||||
const cachedSessions = getSessions(userId);
|
||||
|
||||
@@ -173,6 +173,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
html {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -893,7 +899,7 @@
|
||||
}
|
||||
|
||||
.bubble-ai {
|
||||
@apply rounded-2xl px-1 py-1 text-base leading-7;
|
||||
@apply rounded-2xl px-1 py-1 text-[15px] sm:text-base leading-[1.85];
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
@@ -903,22 +909,29 @@
|
||||
.bubble-ai p,
|
||||
.bubble-ai li,
|
||||
.bubble-ai .chat-prose {
|
||||
text-align: justify;
|
||||
text-align-last: right;
|
||||
text-justify: inter-word;
|
||||
text-align: right;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
/* ChatGPT-like breathing room between blocks */
|
||||
.bubble-ai > div {
|
||||
@apply space-y-4;
|
||||
@apply space-y-3.5;
|
||||
}
|
||||
|
||||
.bubble-ai p + p {
|
||||
margin-top: 0.35rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.bubble-ai h1 {
|
||||
@apply mt-6 mb-3 text-[22px] sm:text-2xl font-bold leading-[1.45] text-foreground first:mt-1;
|
||||
}
|
||||
|
||||
.bubble-ai h2 {
|
||||
@apply mt-5 mb-2.5 text-[19px] sm:text-[20px] font-bold leading-[1.5] text-foreground first:mt-1;
|
||||
}
|
||||
|
||||
.bubble-ai h3 {
|
||||
@apply mt-1 mb-0.5;
|
||||
@apply mt-4 mb-2 text-[17px] sm:text-[18px] font-bold leading-[1.5] text-foreground first:mt-1;
|
||||
}
|
||||
|
||||
.bubble-user .chat-prose {
|
||||
@@ -926,13 +939,14 @@
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.bubble-ai strong {
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Digits stay readable under RTL justify without breaking line layout. */
|
||||
/* Digits stay readable under RTL without breaking line layout. */
|
||||
.bubble-ai .chat-num,
|
||||
.bubble-user .chat-num {
|
||||
color: hsl(var(--foreground));
|
||||
@@ -944,22 +958,11 @@
|
||||
|
||||
.bubble-ai ol,
|
||||
.bubble-ai ul {
|
||||
list-style-position: outside;
|
||||
padding-inline-start: 1.35rem;
|
||||
margin-inline-end: 0.15rem;
|
||||
}
|
||||
|
||||
/* Ordered lists keep source numbers (۱. ۲. ۳.) — no CSS auto-renumber padding */
|
||||
.bubble-ai ol.list-none {
|
||||
list-style: none;
|
||||
padding-inline-start: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.bubble-ai li::marker {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* App chrome — sticky top bar */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
|
||||