Ship Novya PWA with backend for deployable production use.

Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-09-03 21:01:52 +03:30
co-authored by Cursor
parent aa9236fb92
commit 370ac15471
193 changed files with 25997 additions and 1991 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* Assessment Service Abstraction
*
* This module provides a clean interface for assessment operations.
* Currently implemented with localStorage for offline/guest mode.
*
* To migrate to Node.js + Prisma backend:
* 1. Create a new implementation (e.g., assessmentServiceApi.ts)
* 2. Replace the import below to point to the API implementation
* 3. No changes needed in any UI component or hook
*/
import type { AttemptPhase } from '@/hooks/useAssessmentAttempts';
import { type RawAnswer, type DimensionName } from '@/lib/assessment/scoring';
import { api, hasSessionToken } from '@/services/backend';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export type { AttemptPhase };
export interface AssessmentAnswer {
questionId: string;
selectedValue: number;
dimension: DimensionName;
isReverseScored: boolean;
score: number;
}
export interface AssessmentScores {
total: number;
efficacy: number;
hope: number;
resilience: number;
optimism: number;
}
export interface AssessmentSubmission {
id: string;
userId: string;
phase: AttemptPhase;
scores: AssessmentScores;
answers: AssessmentAnswer[];
completedAt: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Input accepted from AssessmentPage (raw answers before scoring)
// ─────────────────────────────────────────────────────────────────────────────
export interface RawAssessmentInput {
userId: string;
phase: AttemptPhase;
answers: RawAnswer[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Storage Key
// ─────────────────────────────────────────────────────────────────────────────
const ASSESSMENT_STORAGE_KEY = 'assessments';
const getKey = (userId: string) => `${ASSESSMENT_STORAGE_KEY}:${userId}`;
// ─────────────────────────────────────────────────────────────────────────────
// Local Storage Implementation
// ─────────────────────────────────────────────────────────────────────────────
const getAll = (userId: string): AssessmentSubmission[] => {
try {
const raw = localStorage.getItem(getKey(userId));
return raw ? (JSON.parse(raw) as AssessmentSubmission[]) : [];
} catch {
return [];
}
};
const saveAll = (userId: string, submissions: AssessmentSubmission[]) => {
localStorage.setItem(getKey(userId), JSON.stringify(submissions));
};
// ─────────────────────────────────────────────────────────────────────────────
// Backend Mapping
// ─────────────────────────────────────────────────────────────────────────────
type BackendAttempt = {
id: string;
phase: AttemptPhase;
submittedAt: string;
scores: AssessmentScores;
answers: AssessmentAnswer[];
};
const isOfflineGuest = (userId: string): boolean => userId.startsWith('guest-');
const shouldUseBackend = (userId: string): boolean => hasSessionToken() && !isOfflineGuest(userId);
function mapBackendAttempt(userId: string, attempt: BackendAttempt): AssessmentSubmission {
return {
id: attempt.id,
userId,
phase: attempt.phase,
scores: attempt.scores,
answers: attempt.answers,
completedAt: attempt.submittedAt,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
/**
* Score and save a completed assessment.
*
* Receives raw answers → runs PCQ-24 scoring engine → saves result.
*
* @throws ValidationError — if answers are missing, duplicated, or out of range
*/
export async function saveAssessment(input: RawAssessmentInput): Promise<AssessmentSubmission> {
if (shouldUseBackend(input.userId)) {
const attempt = await api.post<BackendAttempt>('/assessments/attempts', {
phase: input.phase,
answers: input.answers,
});
const mapped = mapBackendAttempt(input.userId, attempt);
const existing = getAll(input.userId).filter((entry) => entry.phase !== input.phase);
saveAll(input.userId, [mapped, ...existing]);
return mapped;
}
throw new Error('Assessment submission requires backend session');
}
async function getLatestByPhaseFromBackend(
userId: string,
phase: AttemptPhase
): Promise<AssessmentSubmission | null> {
if (!shouldUseBackend(userId)) {
return getAll(userId).find((s) => s.phase === phase) ?? null;
}
const attempt = await api.get<BackendAttempt | null>(`/assessments/attempts/latest?phase=${phase}`);
if (!attempt) return null;
const mapped = mapBackendAttempt(userId, attempt);
const existing = getAll(userId);
const next = [mapped, ...existing.filter((entry) => entry.phase !== phase)];
saveAll(userId, next);
return mapped;
}
/**
* Get all assessment submissions for a user.
*/
export async function getAssessmentHistory(userId: string): Promise<AssessmentSubmission[]> {
if (!shouldUseBackend(userId)) return getAll(userId);
const attempts = await api.get<BackendAttempt[]>('/assessments/attempts');
const mapped = attempts.map((attempt) => mapBackendAttempt(userId, attempt));
saveAll(userId, mapped);
return mapped;
}
/**
* Get the latest submission for a specific phase (pre or post).
*/
export async function getLatestByPhase(
userId: string,
phase: AttemptPhase
): Promise<AssessmentSubmission | null> {
return getLatestByPhaseFromBackend(userId, phase);
}
/**
* Check if a phase has already been completed.
*/
export async function isPhaseCompleted(userId: string, phase: AttemptPhase): Promise<boolean> {
return (await getLatestByPhaseFromBackend(userId, phase)) !== null;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Backend API client for the Novya PWA.
*
* Handles:
* - Base URL resolution (VITE_BACKEND_URL or /api proxy)
* - Bearer token injection from localStorage
* - 401 handling (clear session, redirect to login)
* - Typed JSON responses matching backend ApiSuccess / ApiError
*/
const SESSION_TOKEN_KEY = 'novya-session-token';
/** Prefer Vite dev proxy (`/api/backend`) to avoid cross-origin issues on localhost. */
const baseUrl = (import.meta.env.VITE_BACKEND_URL || '/api/backend').replace(/\/$/, '');
export type ApiSuccess<T = unknown> = {
success: true;
data: T;
meta: { requestId: string; timestamp: string };
};
export type ApiErrorBody = {
success: false;
error: { code: string; message: string; details?: unknown };
meta: { requestId: string; timestamp: string };
};
export type ApiResponse<T = unknown> = ApiSuccess<T> | ApiErrorBody;
export class BackendError extends Error {
code: string;
status: number;
details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.name = 'BackendError';
this.status = status;
this.code = code;
this.details = details;
}
}
export function getSessionToken(): string | null {
return localStorage.getItem(SESSION_TOKEN_KEY);
}
export function setSessionToken(token: string): void {
localStorage.setItem(SESSION_TOKEN_KEY, token);
}
export function clearSessionToken(): void {
localStorage.removeItem(SESSION_TOKEN_KEY);
}
export function hasSessionToken(): boolean {
return Boolean(localStorage.getItem(SESSION_TOKEN_KEY));
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${baseUrl}${path}`;
const token = getSessionToken();
const headers = new Headers(options.headers);
if (!headers.has('Content-Type') && options.body) {
headers.set('Content-Type', 'application/json');
}
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
let response: Response;
try {
response = await fetch(url, { ...options, headers });
} catch {
throw new BackendError(
0,
'NETWORK_ERROR',
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
);
}
if (response.status === 204) {
return undefined as unknown as T;
}
let body: ApiResponse<T>;
try {
body = (await response.json()) as ApiResponse<T>;
} catch {
throw new BackendError(
response.status || 0,
'NETWORK_ERROR',
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
);
}
if (!response.ok || !body.success) {
const err = body as ApiErrorBody;
const isCredentialAttempt = path === '/auth/login' || path === '/auth/register';
if (response.status === 401 && !isCredentialAttempt) {
clearSessionToken();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
}
}
throw new BackendError(
response.status,
err.error?.code ?? 'UNKNOWN',
err.error?.message ?? `Request failed with status ${response.status}`,
err.error?.details,
);
}
return (body as ApiSuccess<T>).data;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: 'POST',
body: data !== undefined ? JSON.stringify(data) : undefined,
}),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: data !== undefined ? JSON.stringify(data) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
-297
View File
@@ -1,297 +0,0 @@
// URL پیش‌فرض n8n chat flow
const DEFAULT_N8N_CHAT_URL = 'https://nn.feep.ir/webhook/ad03737e-f857-4b5c-87d0-601327b2081c/chat';
const n8nWebhookUrl = import.meta.env.VITE_N8N_WEBHOOK_URL || DEFAULT_N8N_CHAT_URL;
const n8nApiKey = import.meta.env.VITE_N8N_API_KEY || '';
export type ChatMessage = {
role: 'user' | 'assistant';
content: string;
timestamp?: string;
};
export const isN8nConfigured = Boolean(n8nWebhookUrl);
// ذخیره sessionId برای هر کاربر (برای حفظ context در Simple Memory)
const sessionIdMap = new Map<string, string>();
// تولید یا دریافت sessionId برای یک کاربر
const getOrCreateSessionId = (userId: string): string => {
if (!sessionIdMap.has(userId)) {
// تولید sessionId جدید (UUID-like)
const sessionId = `${userId}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
sessionIdMap.set(userId, sessionId);
console.log('🔑 ایجاد sessionId جدید:', sessionId, 'برای کاربر:', userId);
}
return sessionIdMap.get(userId)!;
};
// در development از proxy استفاده می‌کنیم، در production از URL مستقیم
const getN8nUrl = () => {
// اگر در development هستیم و URL شامل nn.feep.ir است، از proxy استفاده کن
const useProxy = import.meta.env.DEV && n8nWebhookUrl.includes('nn.feep.ir');
if (useProxy) {
console.log('🔀 استفاده از proxy برای دور زدن CORS');
// استخراج مسیر webhook از URL کامل
try {
const url = new URL(n8nWebhookUrl);
const urlPath = url.pathname;
const proxyUrl = `/api/n8n${urlPath}`;
console.log('📍 مسیر proxy:', proxyUrl, 'از URL اصلی:', n8nWebhookUrl);
return proxyUrl;
} catch (error) {
console.error('❌ خطا در parse کردن URL:', error);
// اگر URL معتبر نیست، از URL مستقیم استفاده کن
return n8nWebhookUrl;
}
}
console.log('🌐 استفاده از URL مستقیم:', n8nWebhookUrl);
return n8nWebhookUrl;
};
/**
* ارسال پیام به n8n و دریافت پاسخ
* @param message - پیام کاربر
* @param userId - شناسه کاربر
* @param conversationHistory - تاریخچه مکالمه (اختیاری)
* @returns پاسخ از n8n
*/
export async function sendMessageToN8n(
message: string,
userId: string,
_conversationHistory?: ChatMessage[] // n8n Chat با Simple Memory خودش session را مدیریت می‌کند، این پارامتر برای backward compatibility است
): Promise<string> {
if (!isN8nConfigured) {
throw new Error(
'n8n تنظیم نشده است. مقدار VITE_N8N_WEBHOOK_URL را در فایل .env تنظیم کنید.'
);
}
// فرمت n8n Chat Trigger - این workflow از "When chat message received" استفاده می‌کند
// فرمت مورد نیاز: { sessionId, action: "sendMessage", chatInput }
const sessionId = getOrCreateSessionId(userId);
const payload = {
sessionId: sessionId,
action: 'sendMessage',
chatInput: message
};
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// اگر API Key تنظیم شده باشد، اضافه می‌کنیم
if (n8nApiKey) {
headers['Authorization'] = `Bearer ${n8nApiKey}`;
// یا می‌توانید از header دیگری استفاده کنید
// headers['X-API-Key'] = n8nApiKey;
}
try {
const actualUrl = getN8nUrl();
console.log('📤 ارسال به n8n:', { url: actualUrl, originalUrl: n8nWebhookUrl, payload });
const response = await fetch(actualUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
// اضافه کردن mode برای جلوگیری از CORS issues
mode: 'cors',
credentials: 'omit'
});
console.log('📥 پاسخ از n8n:', { status: response.status, ok: response.ok });
if (!response.ok) {
let errorText = '';
try {
errorText = await response.text();
// اگر پاسخ HTML است (مثل خطای 500)، پیام بهتری نمایش بده
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.includes('<html')) {
console.error('❌ خطا از n8n (HTML Error Page):', { status: response.status });
throw new Error(
`خطا در n8n workflow (${response.status}): خطای داخلی سرور. لطفا workflow را در n8n بررسی کنید:\n` +
`1. آیا workflow فعال است؟\n` +
`2. آیا Message a model node درست تنظیم شده؟\n` +
`3. آیا API Key OpenAI درست است؟\n` +
`4. آیا Respond to Webhook node وجود دارد؟`
);
}
} catch (e) {
if (e instanceof Error && e.message.includes('خطا در n8n workflow')) {
throw e;
}
errorText = response.statusText;
}
console.error('❌ خطا از n8n:', { status: response.status, errorText });
throw new Error(
`خطا در ارتباط با n8n (${response.status}): ${errorText.substring(0, 200) || response.statusText}`
);
}
let data;
try {
// بررسی Content-Type
const contentType = response.headers.get('content-type') || '';
console.log('📋 Content-Type:', contentType);
const responseText = await response.text();
console.log('📄 پاسخ خام از n8n (اولین 500 کاراکتر):', responseText.substring(0, 500));
if (!responseText || responseText.trim() === '') {
throw new Error('پاسخ خالی از n8n دریافت شد.');
}
// اگر پاسخ HTML است (خطای 500)، خطا بده
if (responseText.trim().startsWith('<!DOCTYPE') || responseText.includes('<html')) {
console.error('❌ پاسخ HTML دریافت شد (خطای سرور)');
throw new Error(
'n8n خطای سرور برمی‌گرداند. لطفا بررسی کنید:\n' +
'1. آیا workflow فعال است؟\n' +
'2. آیا همه node ها درست تنظیم شده‌اند؟\n' +
'3. آیا Respond to Webhook node وجود دارد و درست تنظیم شده؟'
);
}
// تلاش برای parse کردن JSON
try {
data = JSON.parse(responseText);
} catch (jsonError) {
// اگر JSON نیست، ممکنه string ساده باشه
console.warn('⚠️ پاسخ JSON نیست، به عنوان string استفاده می‌شود');
return responseText.trim();
}
} catch (parseError) {
if (parseError instanceof Error && parseError.message.includes('n8n خطای سرور')) {
throw parseError;
}
console.error('❌ خطا در parse کردن پاسخ:', parseError);
throw new Error('فرمت پاسخ n8n نامعتبر است (JSON parse failed): ' + String(parseError));
}
console.log('📦 داده parse شده از n8n:', data);
// n8n Chat Trigger + AI Agent معمولاً پاسخ را در فرمت‌های مختلف برمی‌گرداند
// این فرمت‌های رایج را پشتیبانی می‌کنیم:
// 0. فرمت AI Agent output (اولویت اول برای n8n chat)
if (data.output && typeof data.output === 'string' && data.output.trim()) {
console.log('✅ پاسخ از AI Agent (output):', data.output);
return data.output.trim();
}
// 0.1. فرمت AI Agent با object output
if (data.output && typeof data.output === 'object') {
// بررسی فیلدهای مختلف در output
if (data.output.output && typeof data.output.output === 'string' && data.output.output.trim()) {
console.log('✅ پاسخ از AI Agent (output.output):', data.output.output);
return data.output.output.trim();
}
if (data.output.text && typeof data.output.text === 'string' && data.output.text.trim()) {
console.log('✅ پاسخ از AI Agent (output.text):', data.output.text);
return data.output.text.trim();
}
if (data.output.response && typeof data.output.response === 'string' && data.output.response.trim()) {
console.log('✅ پاسخ از AI Agent (output.response):', data.output.response);
return data.output.response.trim();
}
}
// 1. اگر پاسخ از Message a model (GPT) با Simplify Output خاموش است
if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) {
const content = data.choices[0]?.message?.content;
if (content && content.trim()) {
console.log('✅ پاسخ از GPT (choices):', content);
return content.trim();
}
}
// 2. اگر Simplify Output فعال است - پاسخ مستقیم از GPT
if (data.response && typeof data.response === 'string' && data.response.trim()) {
console.log('✅ پاسخ از n8n (response):', data.response);
return data.response.trim();
}
// 3. اگر response خالی است، ببین آیا در جای دیگری هست
if (data.response === '' || data.response === null) {
// شاید پاسخ در جای دیگری باشد
if (data.choices?.[0]?.message?.content) {
const content = data.choices[0].message.content;
if (content && content.trim()) {
console.log('✅ پاسخ از GPT (در choices):', content);
return content.trim();
}
}
}
// 4. فرمت‌های دیگر
if (typeof data === 'string' && data.trim()) {
return data.trim();
}
if (data.message && typeof data.message === 'string' && data.message.trim()) {
return data.message.trim();
}
if (data.text && typeof data.text === 'string' && data.text.trim()) {
return data.text.trim();
}
if (data.data?.response && typeof data.data.response === 'string' && data.data.response.trim()) {
return data.data.response.trim();
}
if (data.data?.message && typeof data.data.message === 'string' && data.data.message.trim()) {
return data.data.message.trim();
}
// 5. اگر پاسخ یک object است و هیچ فیلد متنی نداره، خطا بده
if (typeof data === 'object') {
// بررسی عمیق‌تر برای پیدا کردن متن
const findTextInObject = (obj: any): string | null => {
if (typeof obj === 'string' && obj.trim()) {
return obj.trim();
}
if (Array.isArray(obj)) {
for (const item of obj) {
const text = findTextInObject(item);
if (text) return text;
}
}
if (obj && typeof obj === 'object') {
for (const key in obj) {
if (key === 'content' || key === 'text' || key === 'message' || key === 'response') {
const text = findTextInObject(obj[key]);
if (text) return text;
}
}
}
return null;
};
const foundText = findTextInObject(data);
if (foundText) {
console.log('✅ متن پیدا شد در object:', foundText);
return foundText;
}
// اگر هیچ متنی پیدا نشد، خطا بده
console.error('❌ هیچ متن معتبری در پاسخ پیدا نشد:', JSON.stringify(data, null, 2));
throw new Error('پاسخ از n8n خالی است یا فرمت نامعتبر دارد. لطفا Respond to Webhook را بررسی کنید.');
}
throw new Error('فرمت پاسخ n8n نامعتبر است. ساختار: ' + JSON.stringify(data));
} catch (error) {
console.error('❌ خطا در sendMessageToN8n:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('خطا در اتصال به n8n. لطفا بررسی کنید:\n1. URL صحیح است؟\n2. n8n در دسترس است؟\n3. مشکل CORS وجود دارد؟');
}
if (error instanceof Error) {
throw error;
}
throw new Error('خطا در ارتباط با n8n: ' + String(error));
}
}
+260 -31
View File
@@ -1,8 +1,21 @@
const openAiApiKey = import.meta.env.VITE_OPENAI_API_KEY || '';
const openAiBaseUrl = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1';
const openAiModel = import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini';
const openAiOrg = import.meta.env.VITE_OPENAI_ORG_ID || '';
const openAiProject = import.meta.env.VITE_OPENAI_PROJECT_ID || '';
/**
* @deprecated Direct provider calls are no longer used by the PWA chat path.
* Chat and AI runtime now go through the backend (`/chat/*` + backend AI client).
* This file is retained only as a historical adapter and must not be imported
* by runtime UI code.
*/
import { getFetchErrorMessage, isAbortError } from '@/lib/fetchError';
const openAiApiKey = (import.meta.env.VITE_OPENAI_API_KEY || '').replace(/^\uFEFF/, '').trim();
const openAiBaseUrl = (import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.gapgpt.app/v1')
.trim()
.replace(/\/$/, '');
const openAiModel = (import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini').trim();
const openAiOrg = (import.meta.env.VITE_OPENAI_ORG_ID || '').trim();
const openAiProject = (import.meta.env.VITE_OPENAI_PROJECT_ID || '').trim();
const OPENAI_TIMEOUT_MS = 120_000;
export type ChatCompletionRole = 'system' | 'user' | 'assistant';
@@ -14,31 +27,106 @@ export type ChatCompletionMessage = {
export const isOpenAIConfigured = Boolean(openAiApiKey);
const defaultSystemPrompt =
import.meta.env.VITE_OPENAI_SYSTEM_PROMPT ||
'You are an AI assistant that responds in Persian (Farsi) with concise, helpful answers for users of a progressive web app.';
(import.meta.env.VITE_OPENAI_SYSTEM_PROMPT || '').trim() ||
'You are Novya (نویا), a Persian-speaking educational psychology coach. Reply in concise, helpful Farsi.';
export const systemMessage: ChatCompletionMessage = {
role: 'system',
content: defaultSystemPrompt,
};
export async function generateAIResponse(messages: ChatCompletionMessage[]): Promise<string> {
if (!isOpenAIConfigured) {
throw new Error('کلید OpenAI تعریف نشده است. مقدار VITE_OPENAI_API_KEY را تنظیم کنید.');
const isOpenRouter = /openrouter\.ai/i.test(openAiBaseUrl);
const isGapGpt = /gapgpt\.app/i.test(openAiBaseUrl);
const OPENROUTER_BLOCKED_FA =
'ارتباط با OpenRouter برقرار نشد. VPN را روشن نگه دارید، صفحه را رفرش کنید و دوباره پیام بفرستید.';
const asTrimmedString = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const joinContentParts = (content: unknown): string | undefined => {
if (typeof content === 'string') return asTrimmedString(content);
if (!Array.isArray(content)) return undefined;
const joined = content
.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object') {
const record = part as Record<string, unknown>;
if (typeof record.text === 'string') return record.text;
if (typeof record.content === 'string') return record.content;
}
return '';
})
.join('')
.trim();
return joined || undefined;
};
const extractAssistantText = (data: unknown): string | undefined => {
if (!data || typeof data !== 'object') return undefined;
const root = data as Record<string, unknown>;
const choices = root.choices;
if (!Array.isArray(choices) || choices.length === 0) return undefined;
const first = choices[0] as Record<string, unknown>;
const message = (first?.message ?? first?.delta) as Record<string, unknown> | undefined;
if (!message) return undefined;
const fromContent = joinContentParts(message.content);
if (fromContent) return fromContent;
const fromOutputText = asTrimmedString(message.output_text);
if (fromOutputText) return fromOutputText;
const fromAnswer = asTrimmedString(message.answer);
if (fromAnswer) return fromAnswer;
const fromReasoning = asTrimmedString(message.reasoning);
if (fromReasoning) return fromReasoning;
const details = message.reasoning_details;
if (Array.isArray(details)) {
const joined = details
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
return String(record.text ?? record.content ?? '');
}
return '';
})
.join('\n')
.trim();
if (joined) return joined;
}
const payload = {
model: openAiModel,
messages: messages.length ? messages : [systemMessage],
temperature: 0.3,
max_tokens: 800,
};
if (import.meta.env.DEV) {
console.debug('[AI] Unrecognized message shape:', {
finishReason: first.finish_reason,
messageKeys: Object.keys(message),
contentType: typeof message.content,
});
}
return undefined;
};
const buildHeaders = (viaProxy: boolean): Record<string, string> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${openAiApiKey}`,
};
if (isOpenRouter && viaProxy) {
headers['HTTP-Referer'] = 'https://novya.app';
headers['X-Title'] = 'Novya';
}
if (openAiOrg) {
headers['OpenAI-Organization'] = openAiOrg;
}
@@ -47,24 +135,165 @@ export async function generateAIResponse(messages: ChatCompletionMessage[]): Pro
headers['OpenAI-Project'] = openAiProject;
}
const response = await fetch(`${openAiBaseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
return headers;
};
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`خطا در دریافت پاسخ از GPT: ${errorBody}`);
/**
* DEV endpoints:
* GapGPT — same-origin proxy first (avoids browser CORS surprises).
* OpenRouter — browser first (Cloudflare often blocks Node's TLS fingerprint).
*/
const getCompletionEndpoints = (): { url: string; viaProxy: boolean }[] => {
const direct = { url: `${openAiBaseUrl}/chat/completions`, viaProxy: false };
const proxy = { url: '/api/ai/chat/completions', viaProxy: true };
if (!import.meta.env.DEV) {
return [direct];
}
if (isGapGpt) {
return [proxy, direct];
}
return [direct, proxy];
};
const isCloudflareSecurityBlock = (status: number, body: string): boolean =>
status === 403 && /access denied by security policy/i.test(body);
const isRetryableFailure = (error: unknown, status?: number): boolean => {
if (status === 502 || status === 503 || status === 504) return true;
if (isAbortError(error)) return false;
const message = error instanceof Error ? error.message : String(error ?? '');
return /failed to fetch|networkerror|load failed|socket hang up|ECONNRESET|proxy/i.test(message);
};
const toUserError = (error: unknown): Error => {
const msg = error instanceof Error ? error.message : String(error ?? '');
if (
msg.includes('ارتباط با OpenRouter') ||
msg.includes('ارتباط با GapGPT') ||
msg.includes('کلید API') ||
msg.includes('مدل')
) {
return error instanceof Error ? error : new Error(msg);
}
if (isOpenRouter && /failed to fetch|networkerror|load failed|403|security policy/i.test(msg)) {
return new Error(OPENROUTER_BLOCKED_FA);
}
if (isGapGpt && /failed to fetch|networkerror|load failed|fetch failed/i.test(msg)) {
return new Error('ارتباط با GapGPT برقرار نشد. صفحه را یک‌بار کامل رفرش کنید و دوباره پیام بفرستید.');
}
return new Error(getFetchErrorMessage(error, 'خطا در ارتباط با سرویس هوش مصنوعی.'));
};
export async function generateAIResponse(messages: ChatCompletionMessage[]): Promise<string> {
if (!isOpenAIConfigured) {
throw new Error(
'کلید هوش مصنوعی تعریف نشده است. مقدار VITE_OPENAI_API_KEY را در فایل .env تنظیم کنید و سرور را ری‌استارت کنید.'
);
}
const data = await response.json();
const text: string | undefined = data?.choices?.[0]?.message?.content?.trim();
const payload: Record<string, unknown> = {
model: openAiModel,
messages: messages.length ? messages : [systemMessage],
temperature: 0.3,
max_tokens: 2000,
stream: false,
};
if (!text) {
throw new Error('پاسخی از هوش مصنوعی دریافت نشد.');
const endpoints = getCompletionEndpoints();
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
let lastError: unknown;
try {
for (let i = 0; i < endpoints.length; i += 1) {
const { url: endpoint, viaProxy } = endpoints[i];
const isLast = i === endpoints.length - 1;
const headers = buildHeaders(viaProxy);
try {
if (import.meta.env.DEV) {
console.debug('[AI] Request →', endpoint, {
model: openAiModel,
provider: isOpenRouter ? 'openrouter' : isGapGpt ? 'gapgpt' : 'openai-compatible',
viaProxy,
});
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.text();
if (!isLast && isRetryableFailure(null, response.status)) {
lastError = new Error(
`خطا در دریافت پاسخ از هوش مصنوعی (${response.status}): ${errorBody.slice(0, 200)}`
);
continue;
}
if (isCloudflareSecurityBlock(response.status, errorBody) || (isOpenRouter && response.status === 403)) {
throw new Error(OPENROUTER_BLOCKED_FA);
}
if (response.status === 400) {
throw new Error(
`درخواست نامعتبر است (400). مدل «${openAiModel}» را در پنل GapGPT/OpenRouter بررسی کنید. ${errorBody.slice(0, 180)}`
);
}
if (response.status === 401) {
throw new Error(
'کلید API نامعتبر است (401). مقدار VITE_OPENAI_API_KEY را در .env با کلید همان سرویس (OpenRouter یا GapGPT) هم‌خوان کنید.'
);
}
if (response.status === 403) {
throw new Error(
`دسترسی به سرویس هوش مصنوعی رد شد (403). کلید، مدل، و محدودیت حساب را بررسی کنید.`
);
}
if (response.status === 502 || response.status === 503 || response.status === 504) {
throw new Error(
`سرویس هوش مصنوعی موقتاً در دسترس نیست (${response.status}). چند لحظه بعد دوباره تلاش کنید.`
);
}
throw new Error(
`خطا در دریافت پاسخ از هوش مصنوعی (${response.status}): ${errorBody.slice(0, 280)}`
);
}
const data = await response.json();
const text = extractAssistantText(data);
if (!text) {
throw new Error(
'پاسخی از هوش مصنوعی دریافت نشد. مدل ممکن است فقط thinking برگرداند؛ مدل را در .env بررسی کنید.'
);
}
return text;
} catch (error) {
lastError = error;
if (isAbortError(error)) {
throw new Error('پاسخ هوش مصنوعی بیش از حد طول کشید. لطفاً دوباره تلاش کنید.');
}
const msg = error instanceof Error ? error.message : '';
if (msg.includes('پاسخی از هوش مصنوعی دریافت نشد') || msg.includes('کلید API') || msg.includes('درخواست نامعتبر')) {
throw error instanceof Error ? error : new Error(msg);
}
if (!isLast && isRetryableFailure(error)) {
if (import.meta.env.DEV) {
console.warn('[AI] Endpoint failed, trying fallback…', endpoint, error);
}
continue;
}
throw toUserError(error);
}
}
throw toUserError(lastError);
} finally {
window.clearTimeout(timeoutId);
}
return text;
}
+375
View File
@@ -0,0 +1,375 @@
import {
PROGRAM_SESSION_COUNT,
getSessionByOrder,
getLastSession,
type ProgramSession,
} from '@/data/program/sessions';
import { api, hasSessionToken } from '@/services/backend';
import {
getNextLockedSessionUnlockTime,
isSessionUnlockedForProgress,
type SessionCompletionTimes,
} from '@/lib/sessionUnlock';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface ProgramProgress {
userId: string;
currentSessionOrder: number; // 0 = not started; 1-based order
completedSessions: number[]; // sorted array of completed session orders
sessionCompletedAt: SessionCompletionTimes;
startedAt: string | null;
completedAt: string | null;
nextSessionUnlocksAt: string | null;
waitingForSessionOrder: number | null;
}
export interface SessionCompletionSignal {
sessionComplete: boolean;
sessionId: string;
sessionOrder: number;
reason: string;
coveredTopics: string[];
completedExercises: string[];
completedReflections?: string[];
userConfirmed: boolean;
readyForNextSession: boolean;
chatSessionId?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Storage
// ─────────────────────────────────────────────────────────────────────────────
const STORAGE_KEY_PREFIX = 'program-progress';
const SESSIONS_CACHE_KEY_PREFIX = 'program-sessions';
const getKey = (userId: string) => `${STORAGE_KEY_PREFIX}:${userId}`;
const getSessionsKey = (userId: string) => `${SESSIONS_CACHE_KEY_PREFIX}:${userId}`;
const getProgress = (userId: string): ProgramProgress | null => {
try {
const raw = localStorage.getItem(getKey(userId));
return raw ? (JSON.parse(raw) as ProgramProgress) : null;
} catch {
return null;
}
};
const saveProgress = (userId: string, progress: ProgramProgress) => {
localStorage.setItem(getKey(userId), JSON.stringify(progress));
};
const saveSessions = (userId: string, sessions: ProgramSession[]) => {
localStorage.setItem(getSessionsKey(userId), JSON.stringify(sessions));
};
const getSessions = (userId: string): ProgramSession[] => {
try {
const raw = localStorage.getItem(getSessionsKey(userId));
return raw ? (JSON.parse(raw) as ProgramSession[]) : [];
} catch {
return [];
}
};
// ─────────────────────────────────────────────────────────────────────────────
// Derived State
// ─────────────────────────────────────────────────────────────────────────────
const defaultProgress = (userId: string): ProgramProgress => ({
userId,
currentSessionOrder: 0,
completedSessions: [],
sessionCompletedAt: {},
startedAt: null,
completedAt: null,
nextSessionUnlocksAt: null,
waitingForSessionOrder: null,
});
type BackendSessionProgress = {
currentSessionOrder: number;
completedSessions: number[];
startedAt: string | null;
completedAt: string | null;
nextSessionUnlocksAt?: string | null;
waitingForSessionOrder?: number | null;
sessions?: {
sessionOrder: number;
completedAt: string | null;
}[];
};
type BackendSession = ProgramSession & {
unlocked?: boolean;
completed?: boolean;
unlocksAt?: string | null;
};
const buildSessionCompletedAt = (
completedSessions: number[],
sessions?: BackendSessionProgress['sessions'],
existing: SessionCompletionTimes = {},
): SessionCompletionTimes => {
const next: SessionCompletionTimes = { ...existing };
for (const session of sessions ?? []) {
if (session.completedAt) {
next[session.sessionOrder] = session.completedAt;
}
}
for (const order of completedSessions) {
if (!next[order]) {
next[order] = new Date().toISOString();
}
}
return next;
};
const toProgramProgress = (userId: string, data: BackendSessionProgress): ProgramProgress => {
const sessionCompletedAt = buildSessionCompletedAt(data.completedSessions, data.sessions);
const waiting = getNextLockedSessionUnlockTime(data.completedSessions, sessionCompletedAt);
return {
userId,
currentSessionOrder: data.currentSessionOrder,
completedSessions: data.completedSessions,
sessionCompletedAt,
startedAt: data.startedAt,
completedAt: data.completedAt,
nextSessionUnlocksAt: data.nextSessionUnlocksAt ?? waiting?.unlocksAt.toISOString() ?? null,
waitingForSessionOrder: data.waitingForSessionOrder ?? waiting?.sessionOrder ?? null,
};
};
const isOfflineGuest = (userId: string): boolean => userId.startsWith('guest-');
const shouldUseBackend = (userId: string): boolean => hasSessionToken() && !isOfflineGuest(userId);
// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get the program progress for a user.
* Returns a default progress if none exists.
*/
export function getProgramProgress(userId: string): ProgramProgress {
const raw = getProgress(userId);
if (!raw) {
return defaultProgress(userId);
}
const merged: ProgramProgress = {
...defaultProgress(userId),
...raw,
sessionCompletedAt: raw.sessionCompletedAt ?? {},
nextSessionUnlocksAt: raw.nextSessionUnlocksAt ?? null,
waitingForSessionOrder: raw.waitingForSessionOrder ?? null,
};
const waiting = getNextLockedSessionUnlockTime(merged.completedSessions, merged.sessionCompletedAt);
return {
...merged,
nextSessionUnlocksAt: merged.nextSessionUnlocksAt ?? waiting?.unlocksAt.toISOString() ?? null,
waitingForSessionOrder: merged.waitingForSessionOrder ?? waiting?.sessionOrder ?? null,
};
}
export async function syncProgramProgress(userId: string): Promise<ProgramProgress> {
if (!shouldUseBackend(userId)) {
return getProgramProgress(userId);
}
const data = await api.get<BackendSessionProgress>('/sessions/progress');
const mapped = toProgramProgress(userId, data);
saveProgress(userId, mapped);
return mapped;
}
export async function syncProgramSessions(userId: string): Promise<ProgramSession[]> {
if (!shouldUseBackend(userId)) {
return getSessions(userId);
}
const sessions = await api.get<BackendSession[]>('/sessions');
saveSessions(userId, sessions);
return sessions;
}
/**
* Mark a session as completed.
* Updates currentSessionOrder to the next incomplete session.
* If all sessions are completed, marks the program as completed.
*/
export function completeSession(userId: string, sessionOrder: number): ProgramProgress {
if (shouldUseBackend(userId)) {
return getProgramProgress(userId);
}
const current = getProgramProgress(userId);
if (current.completedAt !== null) {
// Program already completed — return as-is
return current;
}
const alreadyCompleted = current.completedSessions.includes(sessionOrder);
const completedSessions = alreadyCompleted
? current.completedSessions
: [...current.completedSessions, sessionOrder].sort((a, b) => a - b);
const startedAt = current.startedAt ?? new Date().toISOString();
const completedAtIso = new Date().toISOString();
const sessionCompletedAt = {
...current.sessionCompletedAt,
...(alreadyCompleted ? {} : { [sessionOrder]: completedAtIso }),
};
// Determine next available session (sequential unlock)
const nextOrder = sessionOrder + 1;
const allDone = completedSessions.length >= PROGRAM_SESSION_COUNT;
const nextSessionOrder = allDone ? sessionOrder : nextOrder;
const completedAt = allDone ? completedAtIso : null;
const waiting = getNextLockedSessionUnlockTime(completedSessions, sessionCompletedAt);
const updated: ProgramProgress = {
userId,
currentSessionOrder: nextSessionOrder,
completedSessions,
sessionCompletedAt,
startedAt,
completedAt,
nextSessionUnlocksAt: waiting?.unlocksAt.toISOString() ?? null,
waitingForSessionOrder: waiting?.sessionOrder ?? null,
};
saveProgress(userId, updated);
return updated;
}
export async function completeSessionWithBackend(
userId: string,
signal: SessionCompletionSignal,
): Promise<ProgramProgress> {
if (!shouldUseBackend(userId)) {
return completeSession(userId, signal.sessionOrder);
}
const data = await api.post<BackendSessionProgress>(`/sessions/${signal.sessionOrder}/complete`, signal);
const mapped = toProgramProgress(userId, data);
saveProgress(userId, mapped);
return mapped;
}
/**
* 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.
*/
export function isSessionUnlocked(userId: string, sessionOrder: number): boolean {
const cachedSessions = getSessions(userId);
if (cachedSessions.length > 0) {
const session = cachedSessions.find((item) => item.order === sessionOrder);
if (session && typeof (session as BackendSession).unlocked === 'boolean') {
return Boolean((session as BackendSession).unlocked);
}
}
const progress = getProgramProgress(userId);
if (progress.completedAt !== null) {
return true;
}
return isSessionUnlockedForProgress(
sessionOrder,
progress.completedSessions,
true,
false,
progress.sessionCompletedAt ?? {},
);
}
export function isWaitingForNextSession(userId: string): boolean {
const progress = getProgramProgress(userId);
if (!progress.nextSessionUnlocksAt || !progress.waitingForSessionOrder) {
return false;
}
return new Date(progress.nextSessionUnlocksAt).getTime() > Date.now();
}
/**
* Check if a session is completed.
*/
export function isSessionCompleted(userId: string, sessionOrder: number): boolean {
return getProgramProgress(userId).completedSessions.includes(sessionOrder);
}
/**
* Check if the entire program is completed.
*/
export function isProgramCompleted(userId: string): boolean {
return getProgramProgress(userId).completedAt !== null;
}
/**
* Check if the program has been started (at least one session completed).
*/
export function isProgramStarted(userId: string): boolean {
const progress = getProgramProgress(userId);
return progress.completedSessions.length > 0;
}
/**
* Get the current session order (next session to complete).
* Returns 0 if the program has not started.
*/
export function getCurrentSessionOrder(userId: string): number {
return getProgramProgress(userId).currentSessionOrder;
}
/**
* Get the next available session for a user.
* Returns null if the program is completed.
*/
export function getNextSession(userId: string): { id: string; order: number } | null {
const cachedSessions = getSessions(userId);
if (cachedSessions.length > 0) {
const session = cachedSessions.find((item) => (item as BackendSession).unlocked && !(item as BackendSession).completed);
if (session) {
return { id: session.id, order: session.order };
}
}
const progress = getProgramProgress(userId);
if (progress.completedAt !== null) {
return null; // All done
}
if (progress.completedSessions.length === 0) {
// Not started
return { id: 'session-01', order: 1 };
}
const nextOrder = progress.currentSessionOrder + 1;
const lastSession = getLastSession();
if (nextOrder > (lastSession?.order ?? PROGRAM_SESSION_COUNT)) {
return null;
}
const session = getSessionByOrder(nextOrder);
return session ? { id: session.id, order: session.order } : null;
}
/**
* Reset all program progress for a user.
*/
export function resetProgramProgress(userId: string): void {
localStorage.removeItem(getKey(userId));
localStorage.removeItem(getSessionsKey(userId));
}
+49 -43
View File
@@ -1,13 +1,34 @@
import { createClient } from '@supabase/supabase-js';
import type { SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '';
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
const supabaseUrl = (import.meta.env.VITE_SUPABASE_URL || '').trim();
const supabaseAnonKey = (import.meta.env.VITE_SUPABASE_ANON_KEY || '').trim();
// Check if Supabase is configured
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
export const supabaseHost = (() => {
try {
return new URL(supabaseUrl).host;
} catch {
return 'سرور';
}
})();
const isValidUrl = (url: string): boolean => {
if (!url) return false;
try {
const urlObj = new URL(url);
return urlObj.protocol === 'https:' || urlObj.protocol === 'http:';
} catch {
return false;
}
};
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey && isValidUrl(supabaseUrl));
if (supabaseUrl && !isValidUrl(supabaseUrl)) {
console.error('❌ فرمت URL Supabase نامعتبر است:', supabaseUrl);
console.error('💡 URL باید با https:// یا http:// شروع شود');
}
// Create a mock Supabase client for offline mode
const createMockClient = (): SupabaseClient => {
const mockClient = {
auth: {
@@ -39,7 +60,6 @@ const createMockClient = (): SupabaseClient => {
return mockClient;
};
// Create real or mock client based on configuration
export const supabase = isSupabaseConfigured
? createClient(supabaseUrl, supabaseAnonKey, {
auth: {
@@ -49,7 +69,7 @@ export const supabase = isSupabaseConfigured
},
global: {
headers: {
'apikey': supabaseAnonKey
apikey: supabaseAnonKey
}
}
})
@@ -57,47 +77,34 @@ export const supabase = isSupabaseConfigured
if (!isSupabaseConfigured) {
console.warn('⚠️ Supabase environment variables are not set. Running in offline/mock mode.');
} else {
// Test connection to Supabase (runs once on module load, but doesn't block)
// Use setTimeout to avoid blocking module initialization
setTimeout(() => {
testSupabaseConnection();
}, 100);
}
// Test Supabase connection without needing tables
async function testSupabaseConnection() {
if (!isSupabaseConfigured) return;
/** آیا REST API سرور Supabase پاسخ می‌دهد؟ (401/404 = سالم؛ 5xx = قطع) */
export async function checkSupabaseReachable(): Promise<boolean> {
if (!isSupabaseConfigured) return false;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try {
// Simple health check: try to get current session
// This doesn't require any tables, just checks if we can communicate with Supabase
const { data, error } = await supabase.auth.getSession();
if (error) {
console.error('❌ خطا در اتصال به Supabase:', error.message);
console.log('📋 اطلاعات اتصال:');
console.log(' URL:', supabaseUrl);
console.log(' Key:', supabaseAnonKey ? `${supabaseAnonKey.substring(0, 20)}...` : 'not set');
console.log('💡 نکته: اگر URL شما custom domain است، مطمئن شوید که به درستی پیکربندی شده است.');
} else {
console.log('✅ اتصال به Supabase با موفقیت برقرار شد!');
console.log('📋 اطلاعات اتصال:');
console.log(' URL:', supabaseUrl);
console.log(' Session:', data.session ? 'فعال' : 'غیرفعال (کاربر لاگین نشده)');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error('❌ خطا در تست اتصال Supabase:', errorMessage);
console.log('📋 لطفاً بررسی کنید:');
console.log(' 1. فایل .env درست تنظیم شده است؟');
console.log(' 2. URL و Key صحیح هستند؟');
console.log(' 3. اینترنت متصل است؟');
console.log(' 4. اگر از custom domain استفاده می‌کنید، مطمئن شوید که CORS و تنظیمات درست است.');
const response = await fetch(`${supabaseUrl}/rest/v1/`, {
method: 'GET',
headers: {
apikey: supabaseAnonKey,
Accept: 'application/json'
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (response.status >= 500) return false;
return response.ok || response.status === 401 || response.status === 404;
} catch {
clearTimeout(timeoutId);
return false;
}
}
// Database types (extend as needed)
export type Profile = {
id: string;
email?: string;
@@ -129,4 +136,3 @@ export type Message = {
avatar_url?: string;
};
};