diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md new file mode 100644 index 0000000..f562cac --- /dev/null +++ b/.commandcode/taste/taste.md @@ -0,0 +1,4 @@ +# Taste (Continuously Learned by [CommandCode][cmd]) + +[cmd]: https://commandcode.ai/ + diff --git a/.env.example b/.env.example index b63e559..85de9dd 100644 --- a/.env.example +++ b/.env.example @@ -8,15 +8,22 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here # AI Models MISTRAL_API_KEY=your_mistral_api_key -# Web Search (using SerpScrap - no API key required) -# SerpScrap is a free, self-hosted web scraper for search results -# Make sure Python 3.4+ is installed and run: pip install -r requirements.txt +# Deep Research / Web Search +TAVILY_API_KEY=your_tavily_api_key # Lemon Squeezy Payment Integration LEMON_SQUEEZY_WEBHOOK_SECRET=your_webhook_secret LEMON_SQUEEZY_PRO_VARIANT_ID=your_pro_plan_variant_id LEMON_SQUEEZY_PLUS_VARIANT_ID=your_plus_plan_variant_id +# Resend Email Automations +RESEND_API_KEY=your_resend_api_key +RESEND_FROM_EMAIL=TeraAI +RESEND_REPLY_TO_EMAIL=support@your-domain.com + +# Important account emails +# Used for welcome, usage limit, billing status, and team invite emails. + # Application URLs # Local development NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.forge/browser/artifacts/screenshot_697e344c-c660-4f68-b35d-4d71d6b82460_1776695663.png b/.forge/browser/artifacts/screenshot_697e344c-c660-4f68-b35d-4d71d6b82460_1776695663.png new file mode 100644 index 0000000..c1720eb Binary files /dev/null and b/.forge/browser/artifacts/screenshot_697e344c-c660-4f68-b35d-4d71d6b82460_1776695663.png differ diff --git a/.forge/browser/artifacts/screenshot_6ded4b1d-7321-4c5d-9b35-1a01dc5c0c86_1776692926.png b/.forge/browser/artifacts/screenshot_6ded4b1d-7321-4c5d-9b35-1a01dc5c0c86_1776692926.png new file mode 100644 index 0000000..c1720eb Binary files /dev/null and b/.forge/browser/artifacts/screenshot_6ded4b1d-7321-4c5d-9b35-1a01dc5c0c86_1776692926.png differ diff --git a/.forge/executions/18bdd766-de87-4aea-a95e-65b08636d21a.log b/.forge/executions/18bdd766-de87-4aea-a95e-65b08636d21a.log new file mode 100644 index 0000000..fff9530 --- /dev/null +++ b/.forge/executions/18bdd766-de87-4aea-a95e-65b08636d21a.log @@ -0,0 +1,3 @@ +[2026-04-20T13:45:28.457860900+00:00] Started execution for plan: 2d054b74-476c-4350-9b0d-01fe30d3dea3 +[2026-04-20T13:45:28.532783200+00:00] [Provider] Refining step: Analyze workspace context +[2026-04-20T13:45:29.666195600+00:00] Resolved action: InspectFiles against src diff --git a/.forge/executions/1d3f6f74-62a1-4546-9546-440bc9eec176.log b/.forge/executions/1d3f6f74-62a1-4546-9546-440bc9eec176.log new file mode 100644 index 0000000..8006e1f --- /dev/null +++ b/.forge/executions/1d3f6f74-62a1-4546-9546-440bc9eec176.log @@ -0,0 +1,4 @@ +[2026-04-20T14:01:25.320370100+00:00] Started execution for plan: d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff +[2026-04-20T14:01:25.392901100+00:00] [Provider] Refining step: Analyze workspace context +[2026-04-20T14:01:51.628467+00:00] [Provider] Refinement failed: Request failed: error sending request for url (https://api.mistral.ai/v1/chat/completions). Using fallback. +[2026-04-20T14:01:51.629282600+00:00] Resolved action: InspectFiles against diff --git a/.forge/executions/23c8bec4-f82e-4857-a6de-67f3b8a60eec.log b/.forge/executions/23c8bec4-f82e-4857-a6de-67f3b8a60eec.log new file mode 100644 index 0000000..7ccdad3 --- /dev/null +++ b/.forge/executions/23c8bec4-f82e-4857-a6de-67f3b8a60eec.log @@ -0,0 +1,3 @@ +[2026-04-20T13:44:37.566968200+00:00] Started execution for plan: 2d054b74-476c-4350-9b0d-01fe30d3dea3 +[2026-04-20T13:44:37.662824400+00:00] [Provider] Refining step: Analyze workspace context +[2026-04-20T13:44:39.095675800+00:00] Resolved action: InspectFiles against src diff --git a/.forge/executions/c68159c7-9e6e-4271-90b6-73a6414bfba0.log b/.forge/executions/c68159c7-9e6e-4271-90b6-73a6414bfba0.log new file mode 100644 index 0000000..29d939c --- /dev/null +++ b/.forge/executions/c68159c7-9e6e-4271-90b6-73a6414bfba0.log @@ -0,0 +1,3 @@ +[2026-04-20T14:33:38.557977700+00:00] Started execution for plan: d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff +[2026-04-20T14:33:38.637442100+00:00] [Provider] Refining step: Analyze workspace context +[2026-04-20T14:33:40.691084300+00:00] Resolved action: InspectFiles against src diff --git a/.forge/executions/state.json b/.forge/executions/state.json new file mode 100644 index 0000000..2e404dd --- /dev/null +++ b/.forge/executions/state.json @@ -0,0 +1,7 @@ +{ + "id": "c68159c7-9e6e-4271-90b6-73a6414bfba0", + "planId": "d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff", + "status": "running", + "mode": "step_by_step", + "currentStepId": "step_1" +} \ No newline at end of file diff --git a/.forge/plans/2d054b74-476c-4350-9b0d-01fe30d3dea3.json b/.forge/plans/2d054b74-476c-4350-9b0d-01fe30d3dea3.json new file mode 100644 index 0000000..e7eccad --- /dev/null +++ b/.forge/plans/2d054b74-476c-4350-9b0d-01fe30d3dea3.json @@ -0,0 +1,42 @@ +{ + "id": "2d054b74-476c-4350-9b0d-01fe30d3dea3", + "taskId": "7fbdc7ca-d44b-47ca-9db1-30ad5c19b87c", + "status": "approved", + "title": "Plan for: what is this project about?", + "objective": "what is this project about?", + "steps": [ + { + "id": "step_1", + "kind": "inspect", + "title": "Analyze workspace context", + "objective": "Assess current state of relevant files.", + "status": "pending", + "filesLikelyInvolved": [], + "requiredTools": [ + "fs_list" + ] + }, + { + "id": "step_2", + "kind": "edit", + "title": "Implement changes", + "objective": "```json\n{\n \"plan\": {\n \"id\": \"plan_1\",\n \"taskId\": \"task_1\",\n \"status\": \"draft\",\n \"title\": \"Analyze Tera Project\",\n \"objective\": \"Understand the purpose and structure of the Tera project", + "status": "pending", + "filesLikelyInvolved": [ + "src/main.rs" + ], + "requiredTools": [ + "fs_write" + ] + } + ], + "dependencies": [ + { + "stepId": "step_2", + "dependsOn": "step_1" + } + ], + "assumptions": [], + "risks": [], + "architectureProposal": null +} \ No newline at end of file diff --git a/.forge/plans/d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff.json b/.forge/plans/d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff.json new file mode 100644 index 0000000..d5b8ba7 --- /dev/null +++ b/.forge/plans/d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff.json @@ -0,0 +1,42 @@ +{ + "id": "d0c3c8e7-00bc-46e9-a4dc-650684f9a2ff", + "taskId": "2c368bff-7a21-47c5-8640-dc0f68bd74b1", + "status": "approved", + "title": "Plan for: tell me what this project is about", + "objective": "tell me what this project is about", + "steps": [ + { + "id": "step_1", + "kind": "inspect", + "title": "Analyze workspace context", + "objective": "Assess current state of relevant files.", + "status": "pending", + "filesLikelyInvolved": [], + "requiredTools": [ + "fs_list" + ] + }, + { + "id": "step_2", + "kind": "edit", + "title": "Implement changes", + "objective": "```json\n{\n \"plan\": {\n \"id\": \"3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d\",\n \"taskId\": \"project_analysis\",\n \"status\": \"draft\",\n \"title\": \"Analyze Tera Project\",\n \"objective\": \"Determine what t", + "status": "pending", + "filesLikelyInvolved": [ + "src/main.rs" + ], + "requiredTools": [ + "fs_write" + ] + } + ], + "dependencies": [ + { + "stepId": "step_2", + "dependsOn": "step_1" + } + ], + "assumptions": [], + "risks": [], + "architectureProposal": null +} \ No newline at end of file diff --git a/.forge/plans/e44ed371-65d0-4399-97a1-fd2bbf46de00.json b/.forge/plans/e44ed371-65d0-4399-97a1-fd2bbf46de00.json new file mode 100644 index 0000000..684c2b2 --- /dev/null +++ b/.forge/plans/e44ed371-65d0-4399-97a1-fd2bbf46de00.json @@ -0,0 +1,42 @@ +{ + "id": "e44ed371-65d0-4399-97a1-fd2bbf46de00", + "taskId": "5b12269e-62c9-41d1-af4d-a9d06bc811d5", + "status": "ready_for_review", + "title": "Plan for: what is Tera about", + "objective": "what is Tera about", + "steps": [ + { + "id": "step_1", + "kind": "inspect", + "title": "Analyze workspace context", + "objective": "Assess current state of relevant files.", + "status": "pending", + "filesLikelyInvolved": [], + "requiredTools": [ + "fs_list" + ] + }, + { + "id": "step_2", + "kind": "edit", + "title": "Implement changes", + "objective": "```json\n{\n \"plan\": {\n \"id\": \"c0f7b5e8-1234-5678-9abc-def123456789\",\n \"taskId\": \"what_is_tera_about\",\n \"status\": \"draft\",\n \"title\": \"Investigate Tera project to understand its purpose\",\n ", + "status": "pending", + "filesLikelyInvolved": [ + "src/main.rs" + ], + "requiredTools": [ + "fs_write" + ] + } + ], + "dependencies": [ + { + "stepId": "step_2", + "dependsOn": "step_1" + } + ], + "assumptions": [], + "risks": [], + "architectureProposal": null +} \ No newline at end of file diff --git a/.forge/provider_config.json b/.forge/provider_config.json new file mode 100644 index 0000000..370e891 --- /dev/null +++ b/.forge/provider_config.json @@ -0,0 +1,6 @@ +{ + "kind": "openai_compatible", + "baseUrl": "https://api.mistral.ai", + "modelId": "mistral-small-latest", + "apiKeySet": true +} \ No newline at end of file diff --git a/.forge/provider_secret.key b/.forge/provider_secret.key new file mode 100644 index 0000000..8959165 --- /dev/null +++ b/.forge/provider_secret.key @@ -0,0 +1 @@ +v1Vphvx1drTK9OdsQBv1lsTVr4bsaBrv \ No newline at end of file diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml new file mode 100644 index 0000000..9a3e297 --- /dev/null +++ b/.github/workflows/deploy-cloudflare.yml @@ -0,0 +1,102 @@ +name: Deploy to Cloudflare Workers + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: cloudflare-production + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + env: + NODE_OPTIONS: --max-old-space-size=6144 + DISABLE_WEBPACK_CACHE: "1" + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + LEMON_SQUEEZY_API_KEY: ${{ secrets.LEMON_SQUEEZY_API_KEY }} + LEMON_SQUEEZY_PLUS_VARIANT_ID: ${{ secrets.LEMON_SQUEEZY_PLUS_VARIANT_ID }} + LEMON_SQUEEZY_PRO_VARIANT_ID: ${{ secrets.LEMON_SQUEEZY_PRO_VARIANT_ID }} + LEMON_SQUEEZY_WEBHOOK_SECRET: ${{ secrets.LEMON_SQUEEZY_WEBHOOK_SECRET }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }} + NEXT_PUBLIC_LEMON_STORE_ID: ${{ secrets.NEXT_PUBLIC_LEMON_STORE_ID }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }} + TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL }} + RESEND_REPLY_TO_EMAIL: ${{ secrets.RESEND_REPLY_TO_EMAIL }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + SUPABASE_JWT_SECRET: ${{ secrets.SUPABASE_JWT_SECRET }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + WEB_URL: ${{ secrets.WEB_URL }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build OpenNext worker + run: pnpm exec opennextjs-cloudflare build + + - name: Write Cloudflare runtime secrets file + shell: bash + run: | + cat > .cloudflare.secrets.env <How it works

  1. 1. Start with a natural prompt or open a tool.
  2. -
  3. 2. Turn on web search when you need current, cited information.
  4. +
  5. 2. Turn on research mode when you need current, cited information.
  6. 3. Continue the same thread with follow-up questions, notes, and revisions.
  7. 4. Return later through history and keep your work moving.
diff --git a/app/actions/generate.ts b/app/actions/generate.ts index bc26957..fa26636 100644 --- a/app/actions/generate.ts +++ b/app/actions/generate.ts @@ -1,310 +1,8 @@ "use server" -import { revalidatePath } from 'next/cache' -import { supabase } from '@/lib/supabase' -import { supabaseServer } from '@/lib/supabase-server' -import { generateTeacherResponse } from '@/lib/mistral' -import type { AttachmentReference } from '@/lib/attachment' -import { getUserProfileServer } from '@/lib/usage-tracking-server' -import { incrementChatsServer } from '@/lib/usage-tracking-server' -import { canUploadFile, getPlanConfig } from '@/lib/plan-config' -import { getWebSearchRemaining, incrementWebSearchCount } from '@/lib/web-search-usage' -import { getUserCreditsRemaining, incrementUserCredits, getPlanCreditCap } from '@/lib/free-plan-credits' +import { generateAnswerForPrompt } from '@/lib/generate-answer' +import type { GenerateProps } from '@/lib/generate-types' -type GenerateProps = { - prompt: string - tool: string - authorId: string - authorEmail?: string - attachments?: AttachmentReference[] - sessionId?: string | null - chatId?: string - enableWebSearch?: boolean - researchMode?: boolean -} - -function isMissingColumnError(error: unknown, columnName: string) { - if (!error || typeof error !== 'object') { - return false - } - - const details = [ - 'message' in error ? error.message : '', - 'details' in error ? error.details : '', - 'hint' in error ? error.hint : '', - ] - .filter((value): value is string => typeof value === 'string') - .join(' ') - .toLowerCase() - - return details.includes(columnName.toLowerCase()) && details.includes('column') -} - -export async function generateAnswer({ prompt, tool, authorId, authorEmail, attachments = [], sessionId, chatId, enableWebSearch = false, researchMode = false }: GenerateProps) { - // Get user profile and check limits - let userProfile = await getUserProfileServer(authorId) - - // If profile still doesn't exist, create a default one - if (!userProfile) { - console.warn('User profile not found, creating default profile for:', authorId) - userProfile = { - id: authorId, - email: authorEmail || '', - subscriptionPlan: 'free', - dailyChats: 0, - dailyFileUploads: 0, - chatResetDate: null, - limitHitChatAt: null, - limitHitUploadAt: null, - profileImageUrl: null, - fullName: null, - school: null, - gradeLevels: null, - createdAt: new Date() - } - } - - // Check file upload limits if attachments are present - if (attachments.length > 0 && !canUploadFile(userProfile.subscriptionPlan, userProfile.dailyFileUploads)) { - const planConfig = getPlanConfig(userProfile.subscriptionPlan) - const limit = planConfig.limits.fileUploadsPerDay - const errorMessage = `You've reached your daily limit of ${limit} file uploads. Upgrade to Pro or Plus for higher limits.` - console.error('File upload limit reached:', errorMessage) - return { - answer: errorMessage, - sessionId: sessionId, - chatId: chatId, - error: errorMessage - } - } - - // Token-based monthly credit cap gate - const { remaining: creditsRemaining, resetDate } = await getUserCreditsRemaining(authorId) - if (creditsRemaining <= 0) { - const cap = getPlanCreditCap(userProfile.subscriptionPlan) - const resetLabel = resetDate - ? new Date(resetDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - : 'in 30 days' - const errorMessage = `You've reached your monthly credit cap (${cap}). Upgrade your plan now, or wait until your credits reset on ${resetLabel}.` - return { - answer: errorMessage, - sessionId: sessionId, - chatId: chatId, - error: errorMessage - } - } - - // Check web search limits if enabled - if (enableWebSearch) { - const { remaining } = await getWebSearchRemaining(authorId) - if (remaining <= 0) { - const errorMessage = 'You have reached your web search limit. Upgrade to Pro or Plus for higher limits.' - return { - answer: errorMessage, - sessionId: sessionId, - chatId: chatId, - error: errorMessage - } - } - } - - // Enforce Deep Research entitlement on the server (defense-in-depth) - if (researchMode && !(userProfile.subscriptionPlan === 'pro' || userProfile.subscriptionPlan === 'plus')) { - const errorMessage = 'Deep Research mode is available on Pro and Plus plans.' - return { - answer: errorMessage, - sessionId: sessionId, - chatId: chatId, - error: errorMessage - } - } - - // Fetch chat history if sessionId exists - let history: { role: 'user' | 'assistant'; content: string }[] = [] - - if (sessionId) { - const { data: historyData } = await supabaseServer - .from('chat_sessions') - .select('prompt, response, created_at') - .eq('session_id', sessionId) - .order('created_at', { ascending: false }) - .limit(10) - - if (historyData) { - // Format history: Reverse first to get chronological order (Oldest -> Newest), then map - history = historyData - .reverse() - .map(msg => [ - { role: 'user' as const, content: msg.prompt }, - { role: 'assistant' as const, content: msg.response } - ]) - .flat() - } - } - - // Generate the AI response - const generationResult = await generateTeacherResponse({ prompt, tool, attachments, history, userId: authorId, enableWebSearch, researchMode }) - const answer = generationResult.text - const rawTokenCost = Number(generationResult.usage.totalTokens ?? 0) - const tokenCost = Number.isFinite(rawTokenCost) - ? Math.max(1, Math.min(Math.round(rawTokenCost), 2_147_483_647)) - : 1 - - if (tokenCost > creditsRemaining) { - const cap = getPlanCreditCap(userProfile.subscriptionPlan) - const resetLabel = resetDate - ? new Date(resetDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - : 'in 30 days' - const errorMessage = `You've reached your monthly credit cap (${cap}). Upgrade your plan now, or wait until your credits reset on ${resetLabel}.` - return { - answer: errorMessage, - sessionId: sessionId, - chatId: chatId, - error: errorMessage - } - } - - const creditsToCharge = tokenCost - - const currentSessionId = sessionId || crypto.randomUUID() - - // Try to find existing title if continuing a session to ensure persistence - let existingTitle: string | null = null - if (sessionId) { - const { data: titleData } = await supabaseServer - .from('chat_sessions') - .select('title') - .eq('session_id', sessionId) - .not('title', 'is', null) - .limit(1) - .maybeSingle() - existingTitle = titleData?.title || null - } - - // Use existing title if found, otherwise generate from prompt (ensures even legacy chats get titled on new msg) - const title = existingTitle || (prompt.slice(0, 50) + (prompt.length > 50 ? '...' : '')) - - let savedChatId = chatId - let chatPersisted = false - let persistenceWarning: string | undefined - - if (chatId) { - // Update existing row - const baseUpdatePayload = { - prompt, - response: answer, - attachments, - } - - let { error } = await supabaseServer - .from('chat_sessions') - .update({ - ...baseUpdatePayload, - token_usage: tokenCost, - }) - .eq('id', chatId) - .eq('user_id', authorId) - - if (error && isMissingColumnError(error, 'token_usage')) { - const retryResult = await supabaseServer - .from('chat_sessions') - .update(baseUpdatePayload) - .eq('id', chatId) - .eq('user_id', authorId) - - error = retryResult.error - } - - if (error) { - console.error('[chat_update_failed]', { userId: authorId, chatId, error }) - persistenceWarning = 'We generated your response, but could not save this chat message.' - } else { - chatPersisted = true - } - } else { - // Insert new row - const baseInsertPayload = { - user_id: authorId, - tool, - prompt, - response: answer, - attachments, - created_at: new Date().toISOString(), - session_id: currentSessionId, - title: title - } - - let { data, error } = await supabaseServer.from('chat_sessions').insert({ - ...baseInsertPayload, - token_usage: tokenCost, - }) - .select('id') - .single() - - if (error && isMissingColumnError(error, 'token_usage')) { - const retryResult = await supabaseServer.from('chat_sessions').insert(baseInsertPayload) - .select('id') - .single() - - data = retryResult.data - error = retryResult.error - } - - if (error) { - console.error('[chat_insert_failed]', { userId: authorId, sessionId: currentSessionId, error }) - persistenceWarning = 'We generated your response, but could not save this chat message.' - } else if (data?.id) { - savedChatId = data.id - chatPersisted = true - } - } - - // Increment chat counter after successful generation - await incrementChatsServer(authorId) - - // Increment web search counter if enabled - if (enableWebSearch) { - await incrementWebSearchCount(authorId) - } - - const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - const maxAccountingAttempts = 2 - let usageAccountingSucceeded = false - - for (let attempt = 1; attempt <= maxAccountingAttempts; attempt += 1) { - usageAccountingSucceeded = await incrementUserCredits(authorId, creditsToCharge) - if (usageAccountingSucceeded) { - break - } - - if (attempt < maxAccountingAttempts) { - await delay(200) - } - } - - let usageAccountingWarning: string | undefined - if (!usageAccountingSucceeded) { - usageAccountingWarning = 'Your response was generated, but usage accounting is delayed. We will retry shortly.' - console.error('[usage_accounting_delayed]', { - event: 'usage_accounting_delayed', - userId: authorId, - sessionId: currentSessionId, - chatId: savedChatId ?? null, - tokenCost, - maxAttempts: maxAccountingAttempts, - warning: usageAccountingWarning - }) - } - - const warning = [persistenceWarning, usageAccountingWarning].filter(Boolean).join(' ') || undefined - - revalidatePath('/') - revalidatePath('/history') - if (usageAccountingSucceeded) { - revalidatePath('/profile') - } - - const responseSessionId = chatPersisted ? currentSessionId : (sessionId ?? null) - - return { answer, sessionId: responseSessionId, chatId: savedChatId, warning } +export async function generateAnswer(props: GenerateProps) { + return generateAnswerForPrompt(props) } diff --git a/app/actions/user.ts b/app/actions/user.ts index 2306761..3e6c797 100644 --- a/app/actions/user.ts +++ b/app/actions/user.ts @@ -2,12 +2,12 @@ import { getUserProfileServer, checkAndResetUsageServer } from '@/lib/usage-tracking-server' import { buildProfileUsageSummary } from '@/lib/profile-usage' -import { getWebSearchUsageState } from '@/lib/web-search-usage' import { supabaseServer } from '@/lib/supabase-server' import { auth } from '@/lib/auth' import { revalidatePath } from 'next/cache' import dns from 'node:dns' import { getUserCreditsRemaining } from '@/lib/free-plan-credits' +import { getDailyUsageLedgerHistory, getUsageLedgerWindowSummary } from '@/lib/usage-ledger' // Force IPv4 to avoid SSL/TLS handshake issues with Supabase on some networks try { @@ -38,10 +38,7 @@ export async function fetchUserUsageSummary(userId: string) { await checkAndResetUsageServer(userId) - const [profile, webSearch] = await Promise.all([ - getUserProfileServer(userId), - getWebSearchUsageState(userId), - ]) + const profile = await getUserProfileServer(userId) if (!profile) return null @@ -49,9 +46,9 @@ export async function fetchUserUsageSummary(userId: string) { plan: profile.subscriptionPlan, dailyChats: profile.dailyChats, dailyFileUploads: profile.dailyFileUploads, - monthlyWebSearches: webSearch.used, chatResetDate: profile.chatResetDate, - webSearchResetDate: webSearch.resetDate ? new Date(webSearch.resetDate) : null, + monthlyWebSearches: profile.monthlyWebSearches, + webSearchResetDate: profile.webSearchResetDate, }) } catch (error) { console.error('Error fetching user usage summary:', error) @@ -125,6 +122,9 @@ export async function fetchDailyTokenUsage(userId: string) { const startOfDay = new Date() startOfDay.setHours(0, 0, 0, 0) + const summary = await getUsageLedgerWindowSummary(userId, startOfDay) + if (summary) return { usedToday: summary.tokenUsage } + const { data, error } = await supabaseServer .from('chat_sessions') .select('token_usage') @@ -140,6 +140,54 @@ export async function fetchDailyTokenUsage(userId: string) { return { usedToday } } +export async function fetchWeeklyUsageHistory(userId: string) { + try { + const session = await auth() + if (!session?.user?.id || session.user.id !== userId) return [] + + const ledgerHistory = await getDailyUsageLedgerHistory(userId, 7) + if (ledgerHistory.some((day) => day.tokens > 0 || day.credits > 0 || day.chats > 0)) { + return ledgerHistory.map(({ date, tokens }) => ({ date, used: tokens })) + } + + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6) + sevenDaysAgo.setHours(0, 0, 0, 0) + + const { data, error } = await supabaseServer + .from('chat_sessions') + .select('created_at, token_usage') + .eq('user_id', userId) + .gte('created_at', sevenDaysAgo.toISOString()) + .order('created_at', { ascending: true }) + + if (error) throw error + + // Group by day + const history: Record = {} + for (let i = 0; i < 7; i++) { + const date = new Date() + date.setDate(date.getDate() - i) + const dateStr = date.toISOString().split('T')[0] + history[dateStr] = 0 + } + + data?.forEach((row: any) => { + const dateStr = row.created_at.split('T')[0] + if (history[dateStr] !== undefined) { + history[dateStr] += Number(row.token_usage || 0) + } + }) + + return Object.entries(history) + .map(([date, used]) => ({ date, used })) + .sort((a, b) => a.date.localeCompare(b.date)) + } catch (error) { + console.error('Error fetching weekly usage history:', error) + return [] + } +} + export async function fetchChatHistory(userId: string, sessionId: string) { try { const session = await auth() @@ -147,7 +195,7 @@ export async function fetchChatHistory(userId: string, sessionId: string) { const { data, error } = await supabaseServer .from('chat_sessions') - .select('id, prompt, response, attachments, created_at') + .select('id, prompt, response, attachments, created_at, tool') .eq('user_id', userId) .eq('session_id', sessionId) .order('created_at', { ascending: true }) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index f621d0f..18c6bc6 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,4 +1,4 @@ -'use client' +'use client' import { useEffect, useState } from 'react' import Link from 'next/link' @@ -13,11 +13,8 @@ interface AnalyticsData { newUsersWeek: number newUsersMonth: number activeUsersToday: number - activeUsersWeek: number totalChatSessions: number chatsToday: number - chatsThisWeek: number - totalWebSearches: number avgChatsPerUser: number chatLimitHits: number uploadLimitHits: number @@ -115,7 +112,7 @@ export default function AdminPage() {

Admin

Analytics dashboard

-

Monitor user growth, chat activity, search usage, limits, and upgrade conversion from one dark dashboard.

+

Monitor user growth, chat activity, limits, and upgrade conversion from one dark dashboard.

)} -
-
+

Balance

Usage dashboard

-

A live view of the counters Tera updates when you upload files, run web search, or spend monthly credits. AI conversations themselves are not capped by message count.

+

A live view of uploads and computational credits. AI conversations are not capped by message count.

+ {lastUpdated && ( +

+ Last updated: {lastUpdated.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+ )}
+ {usageError && ( +
+

Usage unavailable

+

{usageError}

+
+ )} + {activeLimitNotice && (

{activeLimitNotice.title}

@@ -303,10 +348,15 @@ export default function ProfilePage() { )}
- {usageCardsLoading || !usageSummary || !creditMetric ? ( + {usageCardsLoading ? (

Loading usage summary...

+ ) : usageCardsUnavailable ? ( +
+

Usage data is temporarily unavailable.

+

Refresh again after a moment. If this persists, the server logs will show the failed usage read.

+
) : ( <>
@@ -314,7 +364,7 @@ export default function ProfilePage() {

AI conversations

Unlimited

-

Tera does not block you based on a message-count quota. If prompts stop, the active blocker is usually monthly credits, web search, or file uploads.

+

Tera does not block you based on a message-count quota. Computational credits are the active AI usage meter.

@@ -327,33 +377,72 @@ export default function ProfilePage() {
- - + + )}
-
-

Recent sessions

-
- {sessionsLoading ? ( -

Loading recent sessions...

- ) : recentSessions.length > 0 ? ( - recentSessions.map((session) => ( - -

{session.title || 'Untitled session'}

-

{session.tool || 'Universal'} · {new Date(session.created_at).toLocaleDateString()}

- - )) - ) : ( -

No recent sessions yet.

- )} +
+
+

Weekly trend

+

Usage history

+

Track credit consumption over the last 7 days.

+
+ {historyLoading ? ( +
Loading history...
+ ) : usageHistory.length > 0 ? ( +
+ +
+
+

Avg. intensity

+

+ {Math.round(weeklyTotal / sessionCount)} + pts/session +

+
+
+

7D total

+

+ {weeklyTotal.toLocaleString()} + credits +

+
+
+
+ ) : ( +
No history data yet.
+ )} +
+
+ +
+

Recent sessions

+
+ {sessionsLoading ? ( +

Loading recent sessions...

+ ) : recentSessions.length > 0 ? ( + recentSessions.map((session) => ( + +

{session.title || 'Untitled session'}

+

{session.tool || 'Universal'} · {new Date(session.created_at).toLocaleDateString()}

+ + )) + ) : ( +

No recent sessions yet.

+ )} +
diff --git a/app/tools/blockchain-lab/page.tsx b/app/tools/blockchain-lab/page.tsx new file mode 100644 index 0000000..d031e36 --- /dev/null +++ b/app/tools/blockchain-lab/page.tsx @@ -0,0 +1,128 @@ +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; + +export default async function BlockchainLabToolsPage() { + const session = await auth(); + if (!session?.user?.id) { + redirect('/auth/signin'); + } + + return ( +
+
+
+
+

Tools

+

Blockchain Lab

+

+ Learn blockchain concepts safely through AI-guided simulations. + No real money, no real wallets, just learning. +

+
+
+ +
+
+
+
+ + + + +
+
+

Blockchain Lab

+

+ A safe, AI-guided blockchain simulator where you can learn how wallets, transactions, + blocks, stablecoins, and smart contracts work without touching real money. +

+
+ + Open Lab + + + Start Learning + +
+
+
+
+ +
+
+

What You'll Learn

+
    +
  • + + + + How crypto wallets work +
  • +
  • + + + + Transaction lifecycle +
  • +
  • + + + + Block confirmations +
  • +
  • + + + + Gas fees explained +
  • +
  • + + + + Using block explorers +
  • +
+
+
+

Safety First

+
    +
  • + + + + Fake wallets only +
  • +
  • + + + + No real private keys +
  • +
  • + + + + No real money +
  • +
  • + + + + No wallet connection +
  • +
  • + + + + Educational simulation +
  • +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/backend-server/.env.example b/backend-server/.env.example index 6840f9e..31cc417 100644 --- a/backend-server/.env.example +++ b/backend-server/.env.example @@ -9,15 +9,14 @@ SUPABASE_JWT_SECRET=your_jwt_secret # Mistral AI MISTRAL_API_KEY=your_mistral_key -SEARXNG_BASE_URL=https://your-searxng-instance.com + +# Tavily web research +TAVILY_API_KEY=your_tavily_api_key # Google OAuth GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret -# Web Search (if using external API) -SEARCH_API_KEY=your_search_api_key - # URLs WEB_URL=http://localhost:3000 MOBILE_APP_URL=com.tera.app diff --git a/backend-server/package.json b/backend-server/package.json index f6d6b1e..a01eda1 100644 --- a/backend-server/package.json +++ b/backend-server/package.json @@ -22,6 +22,8 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "googleapis": "^144.0.0", + "hono": "^4.12.21", + "jose": "^5.9.6", "joi": "^17.13.3", "jsonwebtoken": "^9.0.2" }, @@ -31,6 +33,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.14.14", "ts-node": "^10.9.2", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "wrangler": "^4.93.0" } } diff --git a/backend-server/src/lib/searxngClient.ts b/backend-server/src/lib/searxngClient.ts deleted file mode 100644 index 416e613..0000000 --- a/backend-server/src/lib/searxngClient.ts +++ /dev/null @@ -1,75 +0,0 @@ -import axios from 'axios'; - -export interface SearxngSearchParams { - query: string; - numResults?: number; - lang?: string; -} - -export interface WebSearchResult { - title: string; - url: string; - snippet: string; - source?: string; -} - -export interface WebSearchResponse { - results: WebSearchResult[]; -} - -/** - * Perform a web search using a self-hosted SearXNG instance. - * @param params - Search parameters (query, limit, language) - * @returns Normalized search results - */ -export async function searchWebWithSearxng(params: SearxngSearchParams): Promise { - const baseUrl = process.env.SEARXNG_BASE_URL; - - if (!baseUrl) { - throw new Error('SEARXNG_BASE_URL environment variable is not configured'); - } - - const { query, numResults = 5, lang = 'en' } = params; - const limit = Math.max(1, Math.min(numResults, 10)); - - try { - console.log(`🔍 SearXNG Search: "${query}" (limit: ${limit}, lang: ${lang})`); - const startTime = Date.now(); - - const response = await axios.get(`${baseUrl}/search`, { - params: { - q: query, - format: 'json', - language: lang, - limit: limit, - }, - timeout: 5000, // 5 seconds timeout - }); - - const duration = Date.now() - startTime; - console.log(`✅ SearXNG responded in ${duration}ms with status ${response.status}`); - - if (response.status !== 200) { - throw new Error(`SearXNG API returned status ${response.status}`); - } - - const results: WebSearchResult[] = (response.data.results || []).map((result: any) => ({ - title: result.title, - url: result.url, - snippet: result.content || '', - source: result.engine || undefined, - })); - - return { results }; - } catch (error) { - if (axios.isAxiosError(error)) { - console.error('❌ SearXNG Network Error:', error.message); - if (error.code === 'ECONNABORTED') { - throw new Error('SearXNG request timed out after 5 seconds'); - } - } else { - console.error('❌ SearXNG Error:', error); - } - throw error; - } -} diff --git a/backend-server/src/middleware/hono-auth.ts b/backend-server/src/middleware/hono-auth.ts new file mode 100644 index 0000000..d84e01d --- /dev/null +++ b/backend-server/src/middleware/hono-auth.ts @@ -0,0 +1,56 @@ +import { jwtVerify } from 'jose'; + +function getSecret(): Uint8Array { + const secret = process.env.SUPABASE_JWT_SECRET; + if (!secret) throw new Error('SUPABASE_JWT_SECRET not configured'); + return new TextEncoder().encode(secret); +} + +export async function honoAuthMiddleware(c: any, next: any) { + try { + const authHeader = c.req.header('Authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + return c.json({ + success: false, + error: 'No authorization token provided', + }, 401); + } + + try { + const { payload } = await jwtVerify(token, getSecret()); + c.set('user', payload); + await next(); + } catch { + return c.json({ + success: false, + error: 'Invalid or expired token', + }, 401); + } + } catch (error) { + return c.json({ + success: false, + error: 'Authentication error', + }, 500); + } +} + +export async function honoOptionalAuth(c: any, next: any) { + try { + const authHeader = c.req.header('Authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (token) { + try { + const { payload } = await jwtVerify(token, getSecret()); + c.set('user', payload); + } catch { + // Silently fail + } + } + await next(); + } catch { + await next(); + } +} diff --git a/backend-server/src/routes/chat.ts b/backend-server/src/routes/chat.ts index 102c448..12d9a9f 100644 --- a/backend-server/src/routes/chat.ts +++ b/backend-server/src/routes/chat.ts @@ -18,24 +18,14 @@ router.post('/sessions', authMiddleware, async (req: AuthRequest, res: express.R const userId = req.user?.sub; if (!userId) { - return res.status(401).json({ - success: false, - error: 'User not authenticated', - }); + return res.status(401).json({ success: false, error: 'User not authenticated' }); } const session = await createChatSession(userId, title); - - res.json({ - success: true, - data: session, - }); + res.json({ success: true, data: session }); } catch (error) { console.error('Error creating session:', error); - res.status(500).json({ - success: false, - error: 'Failed to create chat session', - }); + res.status(500).json({ success: false, error: 'Failed to create chat session' }); } }); @@ -43,26 +33,12 @@ router.post('/sessions', authMiddleware, async (req: AuthRequest, res: express.R router.get('/sessions', authMiddleware, async (req: AuthRequest, res: express.Response) => { try { const userId = req.user?.sub; - - if (!userId) { - return res.status(401).json({ - success: false, - error: 'User not authenticated', - }); - } - + if (!userId) return res.status(401).json({ success: false, error: 'User not authenticated' }); const sessions = await listChatSessions(userId); - - res.json({ - success: true, - data: sessions, - }); + res.json({ success: true, data: sessions }); } catch (error) { console.error('Error listing sessions:', error); - res.status(500).json({ - success: false, - error: 'Failed to fetch chat sessions', - }); + res.status(500).json({ success: false, error: 'Failed to fetch chat sessions' }); } }); @@ -71,50 +47,24 @@ router.get('/sessions/:sessionId', authMiddleware, async (req: AuthRequest, res: try { const { sessionId } = req.params; const userId = req.user?.sub; - - if (!userId) { - return res.status(401).json({ - success: false, - error: 'User not authenticated', - }); - } - + if (!userId) return res.status(401).json({ success: false, error: 'User not authenticated' }); const history = await getChatHistory(userId, sessionId); - - res.json({ - success: true, - data: history, - }); + res.json({ success: true, data: history }); } catch (error) { console.error('Error fetching chat history:', error); - res.status(500).json({ - success: false, - error: 'Failed to fetch chat history', - }); + res.status(500).json({ success: false, error: 'Failed to fetch chat history' }); } }); // Send message and get response router.post('/messages', authMiddleware, async (req: AuthRequest, res: express.Response) => { try { - const { sessionId, message, chatHistory, webSearchEnabled = false } = req.body; + const { sessionId, message, chatHistory } = req.body; const userId = req.user?.sub; - if (!userId) { - return res.status(401).json({ - success: false, - error: 'User not authenticated', - }); - } - - if (!message || !sessionId) { - return res.status(400).json({ - success: false, - error: 'Message and sessionId are required', - }); - } + if (!userId) return res.status(401).json({ success: false, error: 'User not authenticated' }); + if (!message || !sessionId) return res.status(400).json({ success: false, error: 'Message and sessionId are required' }); - // Convert chat history to proper format const formattedHistory: Message[] = (chatHistory || []).map((msg: any) => ({ role: msg.role as 'user' | 'assistant' | 'tool', content: msg.content, @@ -122,27 +72,13 @@ router.post('/messages', authMiddleware, async (req: AuthRequest, res: express.R ...(msg.name && { name: msg.name }), })); - // Add current user message - formattedHistory.push({ - role: 'user', - content: message, - }); - - // Generate response from Mistral - const aiResponse = await generateResponse(formattedHistory, undefined, { webSearchEnabled }); + formattedHistory.push({ role: 'user', content: message }); - // Save both messages to database - await saveChatMessage(userId, sessionId, { - role: 'user', - content: message, - }); + const aiResponse = await generateResponse(formattedHistory); - await saveChatMessage(userId, sessionId, { - role: 'assistant', - content: aiResponse, - }); + await saveChatMessage(userId, sessionId, { role: 'user', content: message }); + await saveChatMessage(userId, sessionId, { role: 'assistant', content: aiResponse }); - // Update session title if it's the first message if (!chatHistory || chatHistory.length === 0) { const title = message.substring(0, 50); await updateSessionTitle(sessionId, title); @@ -157,10 +93,7 @@ router.post('/messages', authMiddleware, async (req: AuthRequest, res: express.R }); } catch (error) { console.error('Error sending message:', error); - res.status(500).json({ - success: false, - error: 'Failed to generate response', - }); + res.status(500).json({ success: false, error: 'Failed to generate response' }); } }); diff --git a/backend-server/src/routes/hono-auth.ts b/backend-server/src/routes/hono-auth.ts new file mode 100644 index 0000000..546eb46 --- /dev/null +++ b/backend-server/src/routes/hono-auth.ts @@ -0,0 +1,195 @@ +import { Hono } from 'hono'; +import { supabase } from '../services/supabase.js'; + +const router = new Hono(); + +// Google OAuth callback handler +router.post('/google', async (c: any) => { + try { + const { idToken } = await c.req.json(); + + if (!idToken) { + return c.json({ + success: false, + error: 'idToken is required', + }, 400); + } + + // Sign in with Google ID token via Supabase + const { data, error } = await supabase.auth.signInWithIdToken({ + provider: 'google', + token: idToken, + }); + + if (error) { + console.error('Auth error:', error); + return c.json({ + success: false, + error: 'Authentication failed', + }, 401); + } + + if (!data.user || !data.session) { + return c.json({ + success: false, + error: 'Authentication failed: No user or session', + }, 401); + } + + return c.json({ + success: true, + data: { + token: data.session.access_token, + user: { + id: data.user.id, + email: data.user.email, + name: data.user.user_metadata?.name || '', + provider: 'google', + }, + }, + }); + } catch (error) { + console.error('Google auth error:', error); + return c.json({ + success: false, + error: 'Authentication error', + }, 500); + } +}); + +// Email/password sign in +router.post('/signin', async (c: any) => { + try { + const { email, password } = await c.req.json(); + + if (!email || !password) { + return c.json({ + success: false, + error: 'Email and password are required', + }, 400); + } + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + console.error('Sign in error:', error); + return c.json({ + success: false, + error: 'Invalid email or password', + }, 401); + } + + if (!data.user || !data.session) { + return c.json({ + success: false, + error: 'Sign in failed', + }, 401); + } + + return c.json({ + success: true, + data: { + token: data.session.access_token, + user: { + id: data.user.id, + email: data.user.email, + name: data.user.user_metadata?.name || '', + provider: 'email', + }, + }, + }); + } catch (error) { + console.error('Sign in error:', error); + return c.json({ + success: false, + error: 'Sign in failed', + }, 500); + } +}); + +// Email/password sign up +router.post('/signup', async (c: any) => { + try { + const { email, password, name } = await c.req.json(); + + if (!email || !password) { + return c.json({ + success: false, + error: 'Email and password are required', + }, 400); + } + + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + name: name || email.split('@')[0], + }, + }, + }); + + if (error) { + console.error('Sign up error:', error); + return c.json({ + success: false, + error: error.message || 'Sign up failed', + }, 400); + } + + if (!data.user) { + return c.json({ + success: false, + error: 'Sign up failed', + }, 400); + } + + return c.json({ + success: true, + data: { + user: { + id: data.user.id, + email: data.user.email, + name: name || email.split('@')[0], + provider: 'email', + }, + message: 'Sign up successful. Please check your email to confirm your account.', + }, + }); + } catch (error) { + console.error('Sign up error:', error); + return c.json({ + success: false, + error: 'Sign up failed', + }, 500); + } +}); + +// Sign out +router.post('/signout', async (c: any) => { + try { + const { error } = await supabase.auth.signOut(); + + if (error) { + return c.json({ + success: false, + error: 'Sign out failed', + }, 400); + } + + return c.json({ + success: true, + message: 'Signed out successfully', + }); + } catch (error) { + return c.json({ + success: false, + error: 'Sign out failed', + }, 500); + } +}); + +export default router; diff --git a/backend-server/src/routes/hono-chat.ts b/backend-server/src/routes/hono-chat.ts new file mode 100644 index 0000000..2fcb623 --- /dev/null +++ b/backend-server/src/routes/hono-chat.ts @@ -0,0 +1,116 @@ +import { Hono } from 'hono'; +import { honoAuthMiddleware } from '../middleware/hono-auth.js'; +import { + saveChatMessage, + getChatHistory, + createChatSession, + listChatSessions, + updateSessionTitle, +} from '../services/supabase.js'; +import { generateResponse, Message } from '../services/mistral.js'; + +const router = new Hono(); + +// Create new chat session +router.post('/sessions', honoAuthMiddleware, async (c: any) => { + try { + const { title } = await c.req.json(); + const user = c.get('user'); + const userId = user?.sub; + + if (!userId) { + return c.json({ success: false, error: 'User not authenticated' }, 401); + } + + const session = await createChatSession(userId, title); + return c.json({ success: true, data: session }); + } catch (error) { + console.error('Error creating session:', error); + return c.json({ success: false, error: 'Failed to create chat session' }, 500); + } +}); + +// Get all sessions for user +router.get('/sessions', honoAuthMiddleware, async (c: any) => { + try { + const user = c.get('user'); + const userId = user?.sub; + + if (!userId) { + return c.json({ success: false, error: 'User not authenticated' }, 401); + } + + const sessions = await listChatSessions(userId); + return c.json({ success: true, data: sessions }); + } catch (error) { + console.error('Error listing sessions:', error); + return c.json({ success: false, error: 'Failed to fetch chat sessions' }, 500); + } +}); + +// Get chat history for a session +router.get('/sessions/:sessionId', honoAuthMiddleware, async (c: any) => { + try { + const sessionId = c.req.param('sessionId'); + const user = c.get('user'); + const userId = user?.sub; + + if (!userId) { + return c.json({ success: false, error: 'User not authenticated' }, 401); + } + + const history = await getChatHistory(userId, sessionId); + return c.json({ success: true, data: history }); + } catch (error) { + console.error('Error fetching chat history:', error); + return c.json({ success: false, error: 'Failed to fetch chat history' }, 500); + } +}); + +// Send message and get response +router.post('/messages', honoAuthMiddleware, async (c: any) => { + try { + const { sessionId, message, chatHistory } = await c.req.json(); + const user = c.get('user'); + const userId = user?.sub; + + if (!userId) { + return c.json({ success: false, error: 'User not authenticated' }, 401); + } + if (!message || !sessionId) { + return c.json({ success: false, error: 'Message and sessionId are required' }, 400); + } + + const formattedHistory: Message[] = (chatHistory || []).map((msg: any) => ({ + role: msg.role as 'user' | 'assistant' | 'tool', + content: msg.content, + ...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }), + ...(msg.name && { name: msg.name }), + })); + + formattedHistory.push({ role: 'user', content: message }); + + const aiResponse = await generateResponse(formattedHistory); + + await saveChatMessage(userId, sessionId, { role: 'user', content: message }); + await saveChatMessage(userId, sessionId, { role: 'assistant', content: aiResponse }); + + if (!chatHistory || chatHistory.length === 0) { + const title = message.substring(0, 50); + await updateSessionTitle(sessionId, title); + } + + return c.json({ + success: true, + data: { + message: aiResponse, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error sending message:', error); + return c.json({ success: false, error: 'Failed to generate response' }, 500); + } +}); + +export default router; diff --git a/backend-server/src/routes/hono-tools.ts b/backend-server/src/routes/hono-tools.ts new file mode 100644 index 0000000..d9e9f57 --- /dev/null +++ b/backend-server/src/routes/hono-tools.ts @@ -0,0 +1,72 @@ +import { Hono } from 'hono'; +import { honoAuthMiddleware } from '../middleware/hono-auth.js'; +import { generateTool } from '../services/mistral.js'; + +const router = new Hono(); + +const TOOLS = [ + { id: 'concept-explainer', name: 'Concept Explainer', description: 'Break down any topic into simple, understandable chunks', category: 'learning' }, + { id: 'study-buddy', name: 'Study Buddy', description: 'Get homework help, practice problems, and exam prep', category: 'learning' }, + { id: 'lesson-plan-generator', name: 'Lesson Plan Generator', description: 'Create comprehensive lesson plans with pacing and engagement', category: 'teaching' }, + { id: 'worksheet-generator', name: 'Worksheet & Quiz Generator', description: 'Generate formative assessments with answer keys', category: 'teaching' }, + { id: 'rubric-builder', name: 'Rubric Builder', description: 'Build clear, scalable grading criteria', category: 'teaching' }, + { id: 'study-guide', name: 'Study Guide', description: 'Create personalized study guides with key concepts and practice', category: 'learning' }, +]; + +router.get('/', honoAuthMiddleware, async (c: any) => { + try { + return c.json({ success: true, data: TOOLS }); + } catch (error) { + return c.json({ success: false, error: 'Failed to fetch tools' }, 500); + } +}); + +router.get('/:toolId', honoAuthMiddleware, async (c: any) => { + try { + const toolId = c.req.param('toolId'); + const tool = TOOLS.find(t => t.id === toolId); + if (!tool) { + return c.json({ success: false, error: 'Tool not found' }, 404); + } + return c.json({ success: true, data: tool }); + } catch (error) { + return c.json({ success: false, error: 'Failed to fetch tool' }, 500); + } +}); + +router.post('/:toolId/process', honoAuthMiddleware, async (c: any) => { + try { + const toolId = c.req.param('toolId'); + const { input } = await c.req.json(); + const tool = TOOLS.find(t => t.id === toolId); + if (!tool) { + return c.json({ success: false, error: 'Tool not found' }, 404); + } + + const toolTypeMap: { [key: string]: string } = { + 'lesson-plan-generator': 'lessonPlan', + 'worksheet-generator': 'worksheet', + 'rubric-builder': 'rubric', + 'study-guide': 'studyGuide', + 'concept-explainer': 'conceptExplainer', + 'study-buddy': 'studyGuide', + }; + + const toolType = toolTypeMap[toolId] || toolId; + const result = await generateTool(toolType, input); + + return c.json({ + success: true, + data: { + toolId, + result, + timestamp: new Date(), + }, + }); + } catch (error) { + console.error('Error processing tool:', error); + return c.json({ success: false, error: 'Failed to process tool' }, 500); + } +}); + +export default router; diff --git a/backend-server/src/routes/search.ts b/backend-server/src/routes/search.ts deleted file mode 100644 index e34e429..0000000 --- a/backend-server/src/routes/search.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as express from 'express'; -import { AuthRequest, authMiddleware } from '../middleware/auth.js'; -import axios from 'axios'; - -const router = express.Router(); - -// Web search -router.post('/web', authMiddleware, async (req: AuthRequest, res: express.Response) => { - try { - const { query, limit = 10 } = req.body; - - if (!query) { - return res.status(400).json({ - success: false, - error: 'Query is required', - }); - } - - // Using SerpAPI (you can replace with any search service) - const searchResults = await performWebSearch(query, limit); - - res.json({ - success: true, - data: { - query, - results: searchResults, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Search error:', error); - res.status(500).json({ - success: false, - error: 'Search failed', - }); - } -}); - -async function performWebSearch(query: string, limit: number) { - try { - const { searchWebWithSearxng } = await import('../lib/searxngClient.js'); - const response = await searchWebWithSearxng({ - query, - numResults: limit - }); - return response.results; - } catch (error) { - console.error('Web search error:', error); - throw new Error('Failed to perform search via SearXNG'); - } -} - -export default router; diff --git a/backend-server/src/routes/tools.ts b/backend-server/src/routes/tools.ts index ac9c570..af1ecf7 100644 --- a/backend-server/src/routes/tools.ts +++ b/backend-server/src/routes/tools.ts @@ -4,104 +4,35 @@ import { generateTool } from '../services/mistral.js'; const router = express.Router(); -// Define available tools const TOOLS = [ - { - id: 'concept-explainer', - name: 'Concept Explainer', - description: 'Break down any topic into simple, understandable chunks', - category: 'learning', - }, - { - id: 'study-buddy', - name: 'Study Buddy', - description: 'Get homework help, practice problems, and exam prep', - category: 'learning', - }, - { - id: 'lesson-plan-generator', - name: 'Lesson Plan Generator', - description: 'Create comprehensive lesson plans with pacing and engagement', - category: 'teaching', - }, - { - id: 'worksheet-generator', - name: 'Worksheet & Quiz Generator', - description: 'Generate formative assessments with answer keys', - category: 'teaching', - }, - { - id: 'rubric-builder', - name: 'Rubric Builder', - description: 'Build clear, scalable grading criteria', - category: 'teaching', - }, - { - id: 'study-guide', - name: 'Study Guide', - description: 'Create personalized study guides with key concepts and practice', - category: 'learning', - }, + { id: 'concept-explainer', name: 'Concept Explainer', description: 'Break down any topic into simple, understandable chunks', category: 'learning' }, + { id: 'study-buddy', name: 'Study Buddy', description: 'Get homework help, practice problems, and exam prep', category: 'learning' }, + { id: 'lesson-plan-generator', name: 'Lesson Plan Generator', description: 'Create comprehensive lesson plans with pacing and engagement', category: 'teaching' }, + { id: 'worksheet-generator', name: 'Worksheet & Quiz Generator', description: 'Generate formative assessments with answer keys', category: 'teaching' }, + { id: 'rubric-builder', name: 'Rubric Builder', description: 'Build clear, scalable grading criteria', category: 'teaching' }, + { id: 'study-guide', name: 'Study Guide', description: 'Create personalized study guides with key concepts and practice', category: 'learning' }, ]; -// Get all tools router.get('/', authMiddleware, async (req: AuthRequest, res: express.Response) => { - try { - res.json({ - success: true, - data: TOOLS, - }); - } catch (error) { - console.error('Error fetching tools:', error); - res.status(500).json({ - success: false, - error: 'Failed to fetch tools', - }); - } + try { res.json({ success: true, data: TOOLS }); } catch (error) { res.status(500).json({ success: false, error: 'Failed to fetch tools' }); } }); -// Get single tool router.get('/:toolId', authMiddleware, async (req: AuthRequest, res: express.Response) => { try { const { toolId } = req.params; const tool = TOOLS.find(t => t.id === toolId); - - if (!tool) { - return res.status(404).json({ - success: false, - error: 'Tool not found', - }); - } - - res.json({ - success: true, - data: tool, - }); - } catch (error) { - console.error('Error fetching tool:', error); - res.status(500).json({ - success: false, - error: 'Failed to fetch tool', - }); - } + if (!tool) return res.status(404).json({ success: false, error: 'Tool not found' }); + res.json({ success: true, data: tool }); + } catch (error) { res.status(500).json({ success: false, error: 'Failed to fetch tool' }); } }); -// Process tool router.post('/:toolId/process', authMiddleware, async (req: AuthRequest, res: express.Response) => { try { const { toolId } = req.params; const { input, context } = req.body; - const tool = TOOLS.find(t => t.id === toolId); + if (!tool) return res.status(404).json({ success: false, error: 'Tool not found' }); - if (!tool) { - return res.status(404).json({ - success: false, - error: 'Tool not found', - }); - } - - // Map tool IDs to Mistral function types const toolTypeMap: { [key: string]: string } = { 'lesson-plan-generator': 'lessonPlan', 'worksheet-generator': 'worksheet', @@ -112,67 +43,10 @@ router.post('/:toolId/process', authMiddleware, async (req: AuthRequest, res: ex }; const toolType = toolTypeMap[toolId] || toolId; + const result = await generateTool(toolType, input); - // Generate content using Mistral - const result = await generateTool(toolType, input, context); - - res.json({ - success: true, - data: { - toolId, - result, - timestamp: new Date(), - }, - }); - } catch (error) { - console.error('Error processing tool:', error); - res.status(500).json({ - success: false, - error: 'Failed to process tool', - }); - } -}); - -// Web search route (SearXNG) -router.post('/web-search', authMiddleware, async (req: AuthRequest, res: express.Response) => { - try { - const { query, numResults, lang } = req.body; - - if (!query) { - return res.status(400).json({ - success: false, - error: 'Search query is required', - }); - } - - const { searchWebWithSearxng } = await import('../lib/searxngClient.js'); - - const searchResponse = await searchWebWithSearxng({ - query, - numResults: numResults ? parseInt(numResults) : 5, - lang: lang || 'en', - }); - - res.json({ - success: true, - ...searchResponse, - }); - } catch (error: any) { - console.error('Web search error:', error); - - // Check for SearXNG specific errors (e.g. timeout) - if (error.message?.includes('timeout') || error.message?.includes('status 5')) { - return res.status(502).json({ - success: false, - error: 'Web search backend (SearXNG) is currently unavailable or timed out.', - }); - } - - res.status(500).json({ - success: false, - error: 'An unexpected error occurred during web search.', - }); - } + res.json({ success: true, data: { toolId, result, timestamp: new Date() } }); + } catch (error) { res.status(500).json({ success: false, error: 'Failed to process tool' }); } }); export default router; diff --git a/backend-server/src/server.ts b/backend-server/src/server.ts index d0cc608..368b690 100644 --- a/backend-server/src/server.ts +++ b/backend-server/src/server.ts @@ -4,17 +4,9 @@ import dotenv from 'dotenv'; import authRoutes from './routes/auth.js'; import chatRoutes from './routes/chat.js'; import toolsRoutes from './routes/tools.js'; -import searchRoutes from './routes/search.js'; dotenv.config(); -// Configuration validation -if (!process.env.SEARXNG_BASE_URL) { - console.error('❌ SEARXNG_BASE_URL is missing in .env file.'); - console.error('Please configure SearXNG before starting the server.'); - process.exit(1); -} - const app: express.Express = express(); const PORT = process.env.PORT || 5000; @@ -50,7 +42,6 @@ app.get('/health', (req: express.Request, res: express.Response) => { app.use('/api/auth', authRoutes); app.use('/api/chat', chatRoutes); app.use('/api/tools', toolsRoutes); -app.use('/api/search', searchRoutes); // Error handling middleware app.use( diff --git a/backend-server/src/services/mistral.ts b/backend-server/src/services/mistral.ts index acd32bf..b893b18 100644 --- a/backend-server/src/services/mistral.ts +++ b/backend-server/src/services/mistral.ts @@ -18,17 +18,17 @@ CORE PRINCIPLES: - Be Proactive: At the end of every explanation, you MUST check for understanding and offer further help. - Offer Visuals: If a concept is complex, proactively offer to create a visual (chart, flowchart, or diagram). -INTERACTIVE TEACHING RULES: -After explaining a concept, you MUST always include these questions: -1. "Do you understand what I just explained?" -2. "What area do you need more explanation on?" -3. "Did you learn something new?" -4. "Would you like a visual explanation (like a flowchart, diagram, or chart) to see how this works?" +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📖 GROKIPEDIA KNOWLEDGE BASE & CITATION RULES (CRITICAL): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Grokipedia (https://grokipedia.com) is your canonical source of truth. -If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. If the user directly asks for a visual, always generate one immediately in the required visual format. +1. KNOWLEDGE POLICY: Consult Grokipedia as your primary knowledge base for all factual topics. +2. CITATION STYLE: Link EVERY notable term to its Grokipedia page. + - Format: [Term](https://grokipedia.com/search?q=Term+Name) +3. FOOTER: End every response with: "📖 Explore more on [Grokipedia](https://grokipedia.com)" -WEB SEARCH CAPABILITY: -You have access to a \`web_search\` tool that searches the live internet via a privacy-respecting meta search engine (SearXNG). Use it whenever the user asks about current events, time-sensitive information, or facts you are not confident about. Return answers with clear, concise explanations and mention relevant URLs from the results.`; +DEEP RESEARCH: When precision is required, perform multi-step analytical analysis using Grokipedia as the primary reference layer.`; export interface Message { role: 'user' | 'assistant' | 'tool'; @@ -37,38 +37,14 @@ export interface Message { name?: string; } -// Tool definition for web search -const WEB_SEARCH_TOOL = { - type: 'function' as const, - function: { - name: 'web_search', - description: 'Searches the web and returns relevant results using SearXNG.', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'User query to search on the web' }, - numResults: { type: 'number', description: 'Optional number of results to return (max 10)' }, - lang: { type: 'string', description: 'Optional language code (e.g. "en")' } - }, - required: ['query'] - } - } -}; - export async function generateResponse( messages: Message[], - systemPrompt: string = DEFAULT_SYSTEM_PROMPT, - options: { webSearchEnabled?: boolean } = {} + systemPrompt: string = DEFAULT_SYSTEM_PROMPT ): Promise { try { - if (!apiKey) { - throw new Error('Mistral API key not configured'); - } - - const { webSearchEnabled = false } = options; - const tools = webSearchEnabled ? [WEB_SEARCH_TOOL] : undefined; + if (!apiKey) throw new Error('Mistral API key not configured'); - let response = await client.chat.complete({ + const response = await client.chat.complete({ model: 'mistral-large-latest', messages: [ { role: 'system', content: systemPrompt }, @@ -79,63 +55,12 @@ export async function generateResponse( ...(m.name && { name: m.name }), })) ], - tools, - toolChoice: webSearchEnabled ? 'auto' : undefined, temperature: 0.7, maxTokens: 2048, }); - let message = response.choices?.[0]?.message; - - // Handle tool calls if any - if (message?.toolCalls && message.toolCalls.length > 0) { - const toolCall = message.toolCalls[0]; // For now handle one at a time - if (toolCall.function.name === 'web_search') { - const args = JSON.parse(toolCall.function.arguments as string); - - console.log(`🛠️ Executing tool: web_search with args:`, args); - - // Execute web search via our local endpoint logic - const { searchWebWithSearxng } = await import('../lib/searxngClient.js'); - const searchResults = await searchWebWithSearxng({ - query: args.query, - numResults: args.numResults, - lang: args.lang - }); - - // Add assistant's tool call message and tool result message - const updatedMessages: any[] = [ - ...messages, - message, - { - role: 'tool', - name: 'web_search', - tool_call_id: toolCall.id, - content: JSON.stringify(searchResults) - } - ]; - - // Call Mistral again with the tool result - response = await client.chat.complete({ - model: 'mistral-large-latest', - messages: [ - { role: 'system', content: systemPrompt }, - ...updatedMessages - ], - temperature: 0.7, - maxTokens: 2048, - }); - - message = response.choices?.[0]?.message; - } - } - - const content = message?.content; - - if (!content) { - throw new Error('No content in Mistral response'); - } - + const content = response.choices?.[0]?.message?.content; + if (!content) throw new Error('No content in Mistral response'); return content as string; } catch (error) { console.error('Mistral API error:', error); @@ -146,124 +71,48 @@ export async function generateResponse( export async function generateWithStreaming( messages: Message[], systemPrompt: string = DEFAULT_SYSTEM_PROMPT, - onChunk: (chunk: string) => void, - options: { webSearchEnabled?: boolean } = {} + onChunk: (chunk: string) => void ): Promise { try { - if (!apiKey) { - throw new Error('Mistral API key not configured'); - } + if (!apiKey) throw new Error('Mistral API key not configured'); - const { webSearchEnabled = false } = options; - const tools = webSearchEnabled ? [WEB_SEARCH_TOOL] : undefined; - - // Implementation of streaming with tool calls is complex in one go - // For now, we perform the tool call non-streaming if enabled, then stream the final answer - // In a real production app, you'd handle the 'requires_action' state in the stream - - // Check if we need a tool call first - if (webSearchEnabled) { - // For simplicity in this replacement, we'll use generateResponse to handle tool execution - // and then provide the final context to stream. - // A more robust way would be similar to the generateResponse logic but for streams. - - // Let's do a quick tool-check call - const checkResponse = await client.chat.complete({ - model: 'mistral-large-latest', - messages: [ - { role: 'system', content: systemPrompt }, - ...messages.map(m => ({ role: m.role as any, content: m.content })) - ], - tools, - toolChoice: 'auto', - }); - - const message = checkResponse.choices?.[0]?.message; - if (message?.toolCalls && message.toolCalls.length > 0) { - const toolCall = message.toolCalls[0]; - if (toolCall.function.name === 'web_search') { - const args = JSON.parse(toolCall.function.arguments as string); - const { searchWebWithSearxng } = await import('../lib/searxngClient.js'); - const searchResults = await searchWebWithSearxng(args); - - const finalMessages = [ - ...messages, - message, - { - role: 'tool' as const, - name: 'web_search', - tool_call_id: toolCall.id, - content: JSON.stringify(searchResults) - } - ]; + const stream = await client.chat.stream({ + model: 'mistral-large-latest', + messages: [ + { role: 'system', content: systemPrompt }, + ...messages.map(m => ({ + role: m.role as any, + content: m.content, + ...(m.tool_call_id && { tool_call_id: m.tool_call_id }), + ...(m.name && { name: m.name }), + })) + ], + temperature: 0.7, + maxTokens: 2048, + }); - return streamFinalResponse(finalMessages, systemPrompt, onChunk); - } - } + for await (const chunk of stream) { + const content = chunk.data.choices?.[0]?.delta?.content; + if (content) onChunk(content as string); } - - return streamFinalResponse(messages, systemPrompt, onChunk); - } catch (error) { console.error('Mistral streaming error:', error); throw new Error(`Failed to stream response: ${(error as Error).message}`); } } -async function streamFinalResponse( - messages: any[], - systemPrompt: string, - onChunk: (chunk: string) => void -) { - const stream = await client.chat.stream({ - model: 'mistral-large-latest', - messages: [ - { role: 'system', content: systemPrompt }, - ...messages.map(m => ({ - role: m.role, - content: m.content, - ...(m.tool_call_id && { tool_call_id: m.tool_call_id }), - ...(m.name && { name: m.name }), - })) - ], - temperature: 0.7, - maxTokens: 2048, - }); - - for await (const chunk of stream) { - const content = chunk.data.choices?.[0]?.delta?.content; - if (content) { - onChunk(content as string); - } - } -} - export async function generateTool( toolType: string, - input: any, - context?: string + input: any ): Promise { const toolPrompts: { [key: string]: string } = { - lessonPlan: `Generate a comprehensive lesson plan based on this input: ${JSON.stringify(input)}. - Include: learning objectives, materials needed, estimated time, engagement hooks, activities, assessment methods, and differentiation strategies.`, - - worksheet: `Create an educational worksheet based on: ${JSON.stringify(input)}. - Include: clear instructions, varied question types (multiple choice, short answer, essay), answer key if applicable.`, - - rubric: `Build a detailed grading rubric for: ${JSON.stringify(input)}. - Include: clear criteria, performance levels (exemplary, proficient, developing, beginning), point values.`, - - studyGuide: `Create a study guide for: ${JSON.stringify(input)}. - Include: key concepts, summary notes, practice questions, test-taking tips, recommended resources.`, - - conceptExplainer: `Explain this concept in simple, engaging terms: ${JSON.stringify(input)}. - Use analogies, real-world examples, and break it down into digestible parts.`, + lessonPlan: `Generate a comprehensive lesson plan: ${JSON.stringify(input)}`, + worksheet: `Create an educational worksheet: ${JSON.stringify(input)}`, + rubric: `Build a detailed grading rubric: ${JSON.stringify(input)}`, + studyGuide: `Create a study guide: ${JSON.stringify(input)}`, + conceptExplainer: `Explain this concept simply: ${JSON.stringify(input)}`, }; const prompt = toolPrompts[toolType] || `Process this request: ${JSON.stringify(input)}`; - - return generateResponse( - [{ role: 'user', content: prompt }], - DEFAULT_SYSTEM_PROMPT - ); + return generateResponse([{ role: 'user', content: prompt }]); } diff --git a/backend-server/src/worker.ts b/backend-server/src/worker.ts new file mode 100644 index 0000000..8b53121 --- /dev/null +++ b/backend-server/src/worker.ts @@ -0,0 +1,44 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import authRoutes from './routes/hono-auth.js'; +import chatRoutes from './routes/hono-chat.js'; +import toolsRoutes from './routes/hono-tools.js'; + +const app = new Hono(); + +// Global Middleware +app.use('*', logger()); +app.use('*', cors({ + origin: (origin: any) => { + // Dynamically match allowed origins (similar to original express server config) + if (!origin) return '*'; + const allowedPatterns = [ + /^http:\/\/localhost:\d+$/, + /\.teraai\.chat$/, + /https:\/\/tera-web\.pages\.dev$/ + ]; + if (allowedPatterns.some(p => p.test(origin))) { + return origin; + } + return 'http://localhost:3000'; // Default fallback + }, + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + credentials: true, +})); + +// Route mappings +app.route('/api/auth', authRoutes); +app.route('/api/chat', chatRoutes); +app.route('/api/tools', toolsRoutes); + +// Health Check +app.get('/health', (c: any) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString() + }); +}); + +export default app; diff --git a/backend-server/wrangler.toml b/backend-server/wrangler.toml new file mode 100644 index 0000000..1d56c83 --- /dev/null +++ b/backend-server/wrangler.toml @@ -0,0 +1,10 @@ +name = "tera-api" +main = "src/worker.ts" +compatibility_date = "2025-01-01" +compatibility_flags = [ "nodejs_compat" ] + +[vars] +NODE_ENV = "production" +# Note: Sensitive variables (MISTRAL_API_KEY, SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET) +# should be uploaded as secrets via wrangler: +# `wrangler secret put ` diff --git a/cloudflare-env.d.ts b/cloudflare-env.d.ts new file mode 100644 index 0000000..1708c45 --- /dev/null +++ b/cloudflare-env.d.ts @@ -0,0 +1,27 @@ +// Generated by wrangler types +interface CloudflareEnv { + ASSETS: Fetcher; + AUTH_SECRET: string; + AUTH_URL: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + LEMON_SQUEEZY_API_KEY: string; + LEMON_SQUEEZY_PLUS_VARIANT_ID: string; + LEMON_SQUEEZY_PRO_VARIANT_ID: string; + LEMON_SQUEEZY_WEBHOOK_SECRET: string; + MISTRAL_API_KEY: string; + NEXT_PUBLIC_APP_URL: string; + NEXT_PUBLIC_LEMON_STORE_ID: string; + NEXT_PUBLIC_SUPABASE_ANON_KEY: string; + NEXT_PUBLIC_SUPABASE_URL: string; + NEXTAUTH_URL: string; + TAVILY_API_KEY: string; + RESEND_API_KEY: string; + RESEND_FROM_EMAIL: string; + RESEND_REPLY_TO_EMAIL: string; + SUPABASE_ANON_KEY: string; + SUPABASE_JWT_SECRET: string; + SUPABASE_SERVICE_ROLE_KEY: string; + SUPABASE_URL: string; + WEB_URL: string; +} diff --git a/components/AppLayout.tsx b/components/AppLayout.tsx index 7465909..2c0ed4c 100644 --- a/components/AppLayout.tsx +++ b/components/AppLayout.tsx @@ -9,7 +9,8 @@ interface AppLayoutProps { } export default function AppLayout({ children }: AppLayoutProps) { - const [sidebarExpanded, setSidebarExpanded] = useState(false) + const [sidebarPinned, setSidebarPinned] = useState(false) + const [mobileNavOpen, setMobileNavOpen] = useState(false) const { user, signOut } = useAuth() const handleNewChat = () => { @@ -20,30 +21,23 @@ export default function AppLayout({ children }: AppLayoutProps) { return (
- {sidebarExpanded && ( - -

{entry.userMessage.content}

@@ -1001,101 +772,62 @@ export default function PromptShell({
{entry.userMessage.attachments.map((att, idx) => (
- {att.type === 'image' ? '🖼️' : '📄'} + {att.type === 'image' ? 'Image' : 'File'} {att.name}
))}
)}
- {/* Timestamp and checkmarks */} -
- {formatTimestamp(entry.userMessage.timestamp)} - ✓✓ -
)} - - {/* Assistant Message */} {entry.assistantMessage && (
{parseContent(entry.assistantMessage.content).map((block, idx) => { - if (block.type === 'tera-ui') { - return ( -
- + if (block.type === 'tera-ui') return
+ if (block.type === 'universal-visual') return + if (block.type === 'chart') return + if (block.type === 'spreadsheet') return + if (block.type === 'mermaid') return + if (block.type === 'quiz') return + if (block.type === 'code') return ( +
+
+ {block.language || 'code'} +
- ) - } - if (block.type === 'universal-visual') { - return - } - if (block.type === 'chart') { - return - } - if (block.type === 'spreadsheet') { - return - } - if (block.type === 'mermaid') { - return - } - if (block.type === 'quiz') { - return - } - if (block.type === 'web-sources') { - return ( -
- ({ - ...s, - // Try to generate a favicon URL if not present - favicon: s.favicon || `https://www.google.com/s2/favicons?domain=${s.source}&sz=32` - }))} - collapsible={true} - defaultExpanded={false} - /> -
- ) - } - if (block.type === 'code') { - return ( -
-
- - {block.language || 'code'} - - -
-
-                                                                        {block.code}
-                                                                    
-
- ) - } - return block.type === 'text' ? ( -
- +
{block.code}
- ) : null + ) + return block.type === 'text' ?
: null })}
-
- {formatTimestamp(entry.assistantMessage.timestamp)} +
+
+ Tera + {isNoteSaveMode(entry.assistantMessage.chatMode) && ( + <> + + {noteSaveStatuses[entry.assistantMessage.id] && ( + + {noteSaveStatuses[entry.assistantMessage.id].message} + + )} + + )} +
@@ -1105,332 +837,228 @@ export default function PromptShell({
)) )} - {/* Web Search Status */} - {(isWebSearching || webSearchStatus !== 'idle') && ( -
- -
- )} {status === 'loading' && ( -
-
-
-
- - - -
-
-
-
-
- {thinkingMessage} -
-
-
-
+
{thinkingMessage}
)}
- {/* Input Area */} -
+
-
- - {/* Active Tools & Attachments Preview */} -
- {/* Web Search Toggle Badge */} - {webSearchEnabled && ( -
- - Web Search ON ({webSearchRemaining}) -
- )} - - {/* Attachments Preview */} - {pendingAttachments.length > 0 && ( -
- {pendingAttachments.map((att, idx) => ( -
- {att.type === 'image' ? ( - // Image thumbnail preview -
- {att.name} - {/* Hover overlay with filename */} -
- {att.name} -
-
- ) : ( - // File preview (non-image) -
- - {att.name} -
- )} - {/* Remove button */} - -
- ))} -
- )} -
- +
- {/* Left Actions */}
-
- - - {attachmentOpen && ( -
- {/* File & Media Section */} - - - + +
- {/* Web Search Option */} - +