diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md new file mode 100644 index 0000000..b37c56d --- /dev/null +++ b/.commandcode/taste/taste.md @@ -0,0 +1,5 @@ +# Taste (Continuously Learned by [CommandCode][cmd]) + +[cmd]: https://commandcode.ai/ + + diff --git a/.env.example b/.env.example index b63e559..f728aa5 100644 --- a/.env.example +++ b/.env.example @@ -8,14 +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 +LEMON_SQUEEZY_CREDIT_TOPUP_VARIANT_ID=your_credit_topup_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 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 <

About Tera

- Your AI learning companion for anything. + TeraAI — your AI learning companion for deep learning and research

- Tera is built to make learning feel direct, personal, and useful. Whether you are studying a subject, planning lessons, exploring an idea, or building a new skill, the product keeps research, writing, tools, notes, and history in one dark workspace. + TeraAI helps you learn difficult topics deeply, research with clearer context, and turn what you study into + real projects. Research, tools, notes, and conversation history stay in one workspace.

@@ -88,13 +90,28 @@ export default function AboutPage() { ))} +
+

Learn more

+
+ + AI learning companion + + + AI study assistant + + + AI research assistant + +
+
+

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.
@@ -116,6 +133,8 @@ export default function AboutPage() {
+ +
) 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..e40c833 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, metadata') .eq('user_id', userId) .eq('session_id', sessionId) .order('created_at', { ascending: true }) @@ -161,6 +209,87 @@ export async function fetchChatHistory(userId: string, sessionId: string) { } } +export interface UserMemory { + id: string + memory_text: string + created_at: string +} + +export async function fetchUserMemories(userId: string) { + try { + const session = await auth() + if (!session?.user?.id || session.user.id !== userId) return [] + + const { data, error } = await supabaseServer + .from('user_memories') + .select('id, memory_text, created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(20) + + if (error) { + console.error('Error fetching memories:', error) + return [] + } + + return data || [] + } catch (error) { + console.error('Error fetching memories:', error) + return [] + } +} + +export async function addUserMemory(userId: string, memoryText: string) { + try { + const session = await auth() + if (!session?.user?.id || session.user.id !== userId) return null + + const cleanedMemory = memoryText.trim() + if (!cleanedMemory) return null + + const { data, error } = await supabaseServer + .from('user_memories') + .insert([{ user_id: userId, memory_text: cleanedMemory }]) + .select('id, memory_text, created_at') + .single() + + if (error) { + console.error('Error adding memory:', error) + return null + } + + revalidatePath('/profile') + return data + } catch (error) { + console.error('Error adding memory:', error) + return null + } +} + +export async function deleteUserMemory(userId: string, memoryId: string) { + try { + const session = await auth() + if (!session?.user?.id || session.user.id !== userId) return false + + const { error } = await supabaseServer + .from('user_memories') + .delete() + .eq('id', memoryId) + .eq('user_id', userId) + + if (error) { + console.error('Error deleting memory:', error) + return false + } + + revalidatePath('/profile') + return true + } catch (error) { + console.error('Error deleting memory:', error) + return false + } +} + export async function fetchHistoryPageData(userId: string, page: number = 1, pageSize: number = 20, searchQuery: string = '') { try { const session = await auth() 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.

+ + ))} -
+
-
+

Notebook

Thoughts and references

@@ -79,7 +79,7 @@ export default function NotesPage() {
{isAdding && ( -
+