Repository: zaidmukaddam/scira Branch: main Commit: 7215d5302303 Files: 390 Total size: 4.9 MB Directory structure: gitextract_hejsnn8z/ ├── .dockerignore ├── .eslintrc.json ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── .vercelignore ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── ai/ │ ├── models.ts │ └── providers.ts ├── app/ │ ├── (auth)/ │ │ ├── layout.tsx │ │ ├── sign-in/ │ │ │ └── page.tsx │ │ └── sign-up/ │ │ └── page.tsx │ ├── (content)/ │ │ ├── about/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── privacy-policy/ │ │ │ └── page.tsx │ │ ├── terms/ │ │ │ └── page.tsx │ │ └── x-wrapped/ │ │ ├── [username]/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── (search)/ │ │ └── page.tsx │ ├── actions.ts │ ├── api/ │ │ ├── auth/ │ │ │ └── [...all]/ │ │ │ └── route.ts │ │ ├── clean_images/ │ │ │ └── route.ts │ │ ├── export/ │ │ │ ├── docx/ │ │ │ │ └── route.ts │ │ │ └── pdf/ │ │ │ └── route.ts │ │ ├── lookout/ │ │ │ └── route.ts │ │ ├── mcp/ │ │ │ ├── apps/ │ │ │ │ ├── bridge/ │ │ │ │ │ └── route.ts │ │ │ │ └── resource/ │ │ │ │ └── read/ │ │ │ │ └── route.ts │ │ │ ├── elicitation/ │ │ │ │ └── respond/ │ │ │ │ └── route.ts │ │ │ ├── oauth/ │ │ │ │ ├── callback/ │ │ │ │ │ └── route.ts │ │ │ │ └── client-metadata/ │ │ │ │ └── [serverId]/ │ │ │ │ └── route.ts │ │ │ └── servers/ │ │ │ ├── [id]/ │ │ │ │ ├── oauth/ │ │ │ │ │ ├── callback/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── disconnect/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── start/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ └── tools/ │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── test/ │ │ │ └── route.ts │ │ ├── og/ │ │ │ ├── chat/ │ │ │ │ └── [id]/ │ │ │ │ └── route.tsx │ │ │ └── x-wrapped/ │ │ │ └── route.tsx │ │ ├── preferences/ │ │ │ └── route.ts │ │ ├── proxy-image/ │ │ │ └── route.ts │ │ ├── raycast/ │ │ │ └── route.ts │ │ ├── search/ │ │ │ ├── [id]/ │ │ │ │ ├── stop/ │ │ │ │ │ └── route.ts │ │ │ │ └── stream/ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── suggest/ │ │ │ └── route.ts │ │ ├── transcribe/ │ │ │ └── route.ts │ │ ├── upload/ │ │ │ └── route.ts │ │ ├── x-wrapped/ │ │ │ └── route.ts │ │ └── xql/ │ │ └── route.ts │ ├── apps/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── connectors/ │ │ └── [provider]/ │ │ └── callback/ │ │ └── page.tsx │ ├── error.tsx │ ├── global-error.tsx │ ├── globals.css │ ├── layout.tsx │ ├── lookout/ │ │ ├── components/ │ │ │ ├── action-buttons.tsx │ │ │ ├── empty-state.tsx │ │ │ ├── index.ts │ │ │ ├── loading-skeleton.tsx │ │ │ ├── lookout-card.tsx │ │ │ ├── lookout-details-sidebar.tsx │ │ │ ├── lookout-form.tsx │ │ │ ├── pro-upgrade-screen.tsx │ │ │ ├── run-status-badge.tsx │ │ │ ├── status-badge.tsx │ │ │ ├── time-picker.tsx │ │ │ ├── timezone-selector.tsx │ │ │ └── warning-card.tsx │ │ ├── constants.ts │ │ ├── hooks/ │ │ │ └── use-lookout-form.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── utils/ │ │ └── time-utils.ts │ ├── manifest.ts │ ├── new/ │ │ └── page.tsx │ ├── not-found.tsx │ ├── pricing/ │ │ ├── _component/ │ │ │ └── pricing-table.tsx │ │ └── page.tsx │ ├── providers.tsx │ ├── robots.txt │ ├── search/ │ │ └── [id]/ │ │ ├── loading-old.tsx │ │ └── page.tsx │ ├── searches/ │ │ └── page.tsx │ ├── settings/ │ │ └── page.tsx │ ├── share/ │ │ └── [id]/ │ │ └── page.tsx │ ├── success/ │ │ └── page.tsx │ ├── voice/ │ │ ├── components/ │ │ │ └── pro-upgrade-screen.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ └── xql/ │ └── page.tsx ├── components/ │ ├── InstallPrompt.tsx │ ├── academic-papers.tsx │ ├── ai-elements/ │ │ └── web-preview.tsx │ ├── app-sidebar.tsx │ ├── auth-card.tsx │ ├── build-search.tsx │ ├── canvas-renderer.tsx │ ├── charts/ │ │ ├── area-chart.tsx │ │ ├── area.tsx │ │ ├── bar-chart.tsx │ │ ├── bar-x-axis.tsx │ │ ├── bar-y-axis.tsx │ │ ├── bar.tsx │ │ ├── chart-context.tsx │ │ ├── grid.tsx │ │ ├── line-chart.tsx │ │ ├── line.tsx │ │ ├── tooltip/ │ │ │ ├── chart-tooltip.tsx │ │ │ ├── date-ticker.tsx │ │ │ ├── index.ts │ │ │ ├── tooltip-box.tsx │ │ │ ├── tooltip-content.tsx │ │ │ ├── tooltip-dot.tsx │ │ │ └── tooltip-indicator.tsx │ │ └── x-axis.tsx │ ├── chat-dialogs.tsx │ ├── chat-history-dialog.tsx │ ├── chat-interface.tsx │ ├── chat-state.ts │ ├── chat-text-highlighter.tsx │ ├── client-analytics.tsx │ ├── connectors-search-results.tsx │ ├── core/ │ │ ├── border-trail.tsx │ │ ├── sliding-number.tsx │ │ ├── text-loop.tsx │ │ └── text-shimmer.tsx │ ├── crypto-charts.tsx │ ├── crypto-coin-data.tsx │ ├── currency_conv.tsx │ ├── data-stream-provider.tsx │ ├── dialogs/ │ │ ├── share-dialog.tsx │ │ └── use-share-dialog.tsx │ ├── emails/ │ │ └── lookout-completed.tsx │ ├── example-categories.tsx │ ├── extreme-search.tsx │ ├── file-query-search.tsx │ ├── flight-tracker.tsx │ ├── github-search.tsx │ ├── haptics-provider.tsx │ ├── icons/ │ │ ├── agent-network-icon.tsx │ │ ├── apps-icon.tsx │ │ └── mcp-logo.tsx │ ├── interactive-charts.tsx │ ├── interactive-maps.tsx │ ├── interactive-stock-chart.tsx │ ├── keyboard-shortcuts-dialog.tsx │ ├── kibo-ui/ │ │ └── table/ │ │ └── index.tsx │ ├── list-view.tsx │ ├── logos/ │ │ ├── elevenlabs-logo.tsx │ │ ├── exa-logo.tsx │ │ ├── sarvam-logo.tsx │ │ ├── scira-logo.tsx │ │ └── vercel-logo.tsx │ ├── map-components.tsx │ ├── markdown.tsx │ ├── mcp-elicitation-modal.tsx │ ├── mcp-server-list.tsx │ ├── memory-dialog.tsx │ ├── message-parts/ │ │ └── index.tsx │ ├── message.tsx │ ├── messages.tsx │ ├── movie-info.tsx │ ├── multi-search.tsx │ ├── nearby-search-map-view.tsx │ ├── new-chat-hotkey.tsx │ ├── onchain-crypto-components.tsx │ ├── place-card.tsx │ ├── placeholder-image.tsx │ ├── prediction-search.tsx │ ├── reasoning-part.tsx │ ├── reddit-search.tsx │ ├── retrieve-results.tsx │ ├── reui/ │ │ ├── stepper.tsx │ │ └── timeline.tsx │ ├── scira-logo-header.tsx │ ├── searches-page.tsx │ ├── settings/ │ │ └── theme-previews.tsx │ ├── settings-dialog.tsx │ ├── share/ │ │ ├── index.tsx │ │ ├── share-attachments-badge.tsx │ │ ├── share-button.tsx │ │ └── share-dialog.tsx │ ├── share-viewer.tsx │ ├── sidebar-layout.tsx │ ├── sign-in-prompt-dialog.tsx │ ├── spotify-search-results.tsx │ ├── student-domain-request-button.tsx │ ├── supported-domains-list.tsx │ ├── text-translate.tsx │ ├── theme-switcher.tsx │ ├── tool-invocation-list-view.tsx │ ├── trending-tv-movies-results.tsx │ ├── ui/ │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── animated-beam.tsx │ │ ├── audio-lines.tsx │ │ ├── audio-player.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button-group.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── empty.tsx │ │ ├── form-component.tsx │ │ ├── form.tsx │ │ ├── grip.tsx │ │ ├── hover-card.tsx │ │ ├── hugeicons.tsx │ │ ├── input-group.tsx │ │ ├── input.tsx │ │ ├── kbd.tsx │ │ ├── kibo-ui/ │ │ │ └── contribution-graph/ │ │ │ └── index.tsx │ │ ├── label.tsx │ │ ├── live-waveform.tsx │ │ ├── loading.tsx │ │ ├── magic-edit-icon.tsx │ │ ├── magic-wand-icon.tsx │ │ ├── matrix.tsx │ │ ├── model-selector.tsx │ │ ├── navigation-menu.tsx │ │ ├── orb.tsx │ │ ├── popover.tsx │ │ ├── pro-accordion.tsx │ │ ├── processor-icon.tsx │ │ ├── progress-ring.tsx │ │ ├── progress.tsx │ │ ├── scroll-area.tsx │ │ ├── scrub-bar.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── settings.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── sileo-toaster.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── spinner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── text-rotate.tsx │ │ ├── textarea.tsx │ │ ├── tooltip.tsx │ │ ├── transcript-viewer.tsx │ │ ├── voice-button.tsx │ │ └── voice-picker.tsx │ ├── user-cache-status.tsx │ ├── weather-chart.tsx │ ├── x-search.tsx │ ├── xql-pro-upgrade-screen.tsx │ └── youtube-search-results.tsx ├── components.json ├── contexts/ │ └── user-context.tsx ├── docker-compose.yml ├── drizzle/ │ └── migrations/ │ └── meta/ │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ ├── 0009_snapshot.json │ ├── 0010_snapshot.json │ ├── 0011_snapshot.json │ └── _journal.json ├── drizzle.config.ts ├── env/ │ ├── client.ts │ └── server.ts ├── hooks/ │ ├── use-auto-resume.ts │ ├── use-cached-user-data.tsx │ ├── use-chat-prefetch.ts │ ├── use-github-stars.ts │ ├── use-local-storage.tsx │ ├── use-location.ts │ ├── use-lookouts.ts │ ├── use-media-query.tsx │ ├── use-mobile.ts │ ├── use-optimized-scroll.ts │ ├── use-synced-preferences.tsx │ ├── use-transcript-viewer.ts │ ├── use-usage-data.ts │ ├── use-user-data.ts │ ├── use-voice-client.ts │ └── use-window-size.tsx ├── instrumentation.ts ├── lib/ │ ├── auth-client.ts │ ├── auth-utils.ts │ ├── auth.ts │ ├── better-all.ts │ ├── canvas/ │ │ ├── catalog.ts │ │ ├── registry.tsx │ │ └── renderer.tsx │ ├── chat-messages.ts │ ├── connectors.tsx │ ├── constants.ts │ ├── db/ │ │ ├── chat-queries.ts │ │ ├── index.ts │ │ ├── queries.ts │ │ └── schema.ts │ ├── discount.ts │ ├── email.ts │ ├── errors.ts │ ├── mcp/ │ │ ├── auth-headers.ts │ │ ├── catalog-icons.ts │ │ ├── crypto.ts │ │ ├── managed-credentials.ts │ │ ├── oauth.ts │ │ └── server-config.ts │ ├── memory-actions.ts │ ├── notte.ts │ ├── parser.ts │ ├── performance-cache.ts │ ├── r2.ts │ ├── rate-limit.ts │ ├── redis.ts │ ├── search/ │ │ ├── auto-router.ts │ │ ├── chat-title.ts │ │ ├── group-config.ts │ │ ├── server-helpers.ts │ │ └── tool-loader.ts │ ├── search-utils.ts │ ├── subscription.ts │ ├── tools/ │ │ ├── academic-search.ts │ │ ├── build-tools.ts │ │ ├── code-context.ts │ │ ├── code-interpreter.ts │ │ ├── connectors-search.ts │ │ ├── crypto-tools.ts │ │ ├── currency-converter.ts │ │ ├── datetime.ts │ │ ├── extreme-search.ts │ │ ├── file-query-search.ts │ │ ├── flight-tracker.ts │ │ ├── github-search.ts │ │ ├── greeting.ts │ │ ├── index.ts │ │ ├── map-tools.ts │ │ ├── mcp-client.ts │ │ ├── mcp-search.ts │ │ ├── movie-tv-search.ts │ │ ├── prediction-search.ts │ │ ├── reddit-search.ts │ │ ├── retrieve.ts │ │ ├── spotify-search.ts │ │ ├── stock-chart.ts │ │ ├── supermemory.ts │ │ ├── text-translate.ts │ │ ├── trending-movies.ts │ │ ├── trending-tv.ts │ │ ├── weather.ts │ │ ├── web-search.ts │ │ ├── x-search.ts │ │ └── youtube-search.ts │ ├── types.ts │ ├── user-data-server.ts │ ├── user-data.ts │ └── utils.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── proxy.ts ├── public/ │ ├── .well-known/ │ │ └── microsoft-identity-association.json │ ├── audio-capture-processor.js │ ├── pcm-processor-worklet.js │ └── privacy-policy.html ├── sandbox.py ├── tsconfig.json └── vercel.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ Dockerfile .dockerignore node_modules npm-debug.log README.md .next .git ================================================ FILE: .eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: .github/FUNDING.yml ================================================ github: zaidmukaddam ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug Report about: Report a bug or unexpected behavior title: '[BUG] ' labels: bug assignees: '' --- ## Bug Description A clear and concise description of what the bug is. ## Steps to Reproduce 1. Go to '...' 2. Click on '...' 3. Scroll down to '...' 4. See error ## Expected Behavior A clear and concise description of what you expected to happen. ## Actual Behavior A clear and concise description of what actually happened. ## Screenshots If applicable, add screenshots to help explain your problem. ## Environment - **OS**: [e.g., macOS, Windows, Linux] - **Browser**: [e.g., Chrome, Safari, Firefox] - **Version**: [e.g., 22] - **Device**: [e.g., Desktop, Mobile] ## Additional Context Add any other context about the problem here (error messages, logs, etc.). ## Possible Solution If you have suggestions on how to fix the bug, please describe them here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Question or Discussion url: https://github.com/zaidmukaddam/scira/discussions about: Ask questions or discuss ideas with the community - name: Documentation url: https://github.com/zaidmukaddam/scira/blob/main/README.md about: Check out the project documentation ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature Request about: Suggest an idea or new feature for this project title: '[FEATURE] ' labels: enhancement assignees: '' --- ## Feature Description A clear and concise description of the feature you'd like to see. ## Problem Statement Is your feature request related to a problem? Please describe. Example: I'm always frustrated when [...] ## Proposed Solution A clear and concise description of what you want to happen. ## Alternatives Considered A clear and concise description of any alternative solutions or features you've considered. ## Use Cases Describe specific scenarios where this feature would be useful. ## Additional Context Add any other context, mockups, or screenshots about the feature request here. ## Implementation Ideas If you have technical suggestions on how this could be implemented, please share them here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Dependency update ## Related Issues Closes # ## Changes Made - - - ## Testing - [ ] Tested locally - [ ] Added/updated unit tests - [ ] Added/updated integration tests - [ ] All tests pass ### Test Environment - **OS**: - **Browser**: - **Node Version**: ## Screenshots ## Checklist - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings or errors - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published ## Additional Notes ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem __pycache__/ *.pyc # debug npm-debug.log* yarn-debug.log* yarn-error.log* # env files .env*.local .env # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts certificates creds.ts # local SQL scripts *.sql # IDE / AI tools .cursor/ .codex/ .claude/ .opencode/ .agents/ video/node_modules ================================================ FILE: .prettierrc ================================================ { "semi": true, "singleQuote": true, "trailingComma": "all", "printWidth": 120, "tabWidth": 2, "useTabs": false } ================================================ FILE: .vercelignore ================================================ .codex .codex .cursor .opencode .claude .vscode add_dodo_indexes.sql reindex_tables.sql create_indexes.sql sandbox.py vercel_old.json creds.ts ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: Dockerfile ================================================ # syntax=docker.io/docker/dockerfile:1 # Base image: Using Node.js 22 with Alpine Linux for a minimal footprint FROM node:22-alpine AS base # Stage 1: Dependencies # This stage is responsible for installing all npm dependencies FROM base AS deps # Installing libc6-compat for Alpine Linux compatibility with certain Node.js packages # Required for some npm packages that have native dependencies RUN apk add --no-cache libc6-compat WORKDIR /app # Copy package files and install dependencies using pnpm # pnpm is used for faster and more efficient package management COPY package.json pnpm-lock.yaml* ./ RUN corepack enable pnpm && pnpm i; # Stage 2: Building the application # This stage builds the Next.js application FROM base AS builder WORKDIR /app # Copy node_modules from deps stage COPY --from=deps /app/node_modules ./node_modules # Copy all source files COPY . . # Copy environment variables for build configuration COPY .env .env # Build the Next.js application RUN npm run build # Stage 3: Production runtime # Final stage that runs the application FROM base AS runner LABEL org.opencontainers.image.name="scira.app" WORKDIR /app # Set production environment ENV NODE_ENV=production # Create a non-root user for security RUN addgroup -g 1001 -S nodejs RUN adduser -S nextjs -u 1001 # Copy only the necessary files for running the application # Static files for serving COPY --from=builder /app/public ./public # Copy the standalone build output and static files # Using Next.js output tracing to minimize the final image size COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Switch to non-root user for security USER nextjs # Expose the port the app runs on EXPOSE 3000 # Configure the server ENV PORT=3000 ENV HOSTNAME="0.0.0.0" # Start the Next.js application CMD ["node", "server.js"] ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Scira Copyright (C) 2024-present Zaid Mukaddam This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ # Scira Research at the speed of thought. The agentic research platform that plans, retrieves, and cites — so you can think faster. Vercel OSS Program
![Scira](/app/opengraph-image.png)
🔗 **[Try Scira at scira.ai](https://scira.ai)** [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zaidmukaddam/scira) ## Powered By
| [Vercel AI SDK](https://sdk.vercel.ai/docs) | [Exa AI](https://exa.ai) | [Upstash](https://upstash.com) | | :-----------------------------------------------------------: | :----------------------------------------------------: | :-----------------------------------------------------: | | Vercel AI SDK | Exa AI | Upstash | | For AI model integration and streaming | For web search and content retrieval | For serverless Redis and rate limiting |
## Special Thanks
[![Warp](https://github.com/user-attachments/assets/2bda420d-4211-4900-a37e-e3c7056d799c)](https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=scira)
### **[Warp, the intelligent terminal](https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=scira)**
[Available for MacOS, Linux, & Windows](https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=scira)
[Visit warp.dev to learn more](https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=scira)
## How It Works 1. **Ask anything** — Type a question, upload a PDF, or paste a URL. Pick a mode or let Scira decide for you. 2. **Scira plans & retrieves** — The agent breaks your question into sub-tasks, searches live sources, and cross-checks the evidence. 3. **Get cited answers** — Receive a grounded answer with inline citations. Click any source to verify it yourself. ## Features ### Core Capabilities - **Agentic Planning** — Breaks complex questions into steps, selects the right models and tools, then executes multi-step workflows end to end - **Grounded Retrieval** — Every answer comes with inline citations you can click to audit the evidence yourself - **Extensible & Open** — AGPL-3.0 licensed. Self-host, bring your own models, connect custom tools, and tailor everything to your workflow - **Lookouts** — Schedule recurring research agents that monitor topics, track changes, and email you updates ### Search Modes (17 modes) | Mode | Description | |---|---| | **Web** | Search the entire web with AI-powered analysis | | **Chat** | Talk to the model directly, no search | | **X** | Real-time posts, trends, and conversations | | **Stocks** | Market data, charts, and financial analysis | | **Code** | Get context about languages and frameworks | | **Academic** | Research papers, citations, and scholarly sources | | **Extreme** | Deep research with multiple sources and analysis | | **Reddit** | Discussions, opinions, and community insights | | **GitHub** | Repositories, code, and developer discussions | | **Crypto** | Cryptocurrency research powered by CoinGecko | | **Prediction** | Prediction markets from Polymarket and Kalshi | | **YouTube** | Video summaries, transcripts, and analysis | | **Spotify** | Search songs, artists, and albums | | **Connectors** | Search Google Drive, Notion & OneDrive *(Pro)* | | **Memory** | Your personal memory companion *(Pro)* | | **Voice** | Conversational AI with real-time voice *(Pro)* | | **XQL** | Advanced X query language for tweet analysis *(Pro)* | ### Tools (28 tools) #### Search & Retrieval - **Web search** — Multi-query parallel web search with deduplication using Exa, Firecrawl, Parallel, and Tavily - **Extreme search** — LLM-driven deep research agent with multi-step planning, code execution, and R2 artifact storage - **Academic search** — Search academic papers and research using Exa and Firecrawl - **Reddit search** — Search Reddit with configurable time ranges using Parallel - **X search** — Search X posts with date range filtering and handle inclusion/exclusion using xAI Grok - **YouTube search** — Search videos, channels, playlists with transcript extraction using Supadata - **GitHub search** — Search repositories with structured metadata extraction using Firecrawl - **Spotify search** — Search tracks, artists, albums, and playlists via Spotify Web API - **URL content retrieval** — Extract content from any URL including tweets, YouTube, TikTok, and Instagram #### Financial & Market Data - **Stock charts** — Interactive stock charts with OHLC data, earnings, and news using Valyu, Tavily, and Exa - **Currency converter** — Forex and crypto conversion with real-time rates using Valyu - **Crypto tools** — Cryptocurrency data, contract lookups, and OHLC charts using CoinGecko - **Prediction markets** — Query Polymarket and Kalshi data with Cohere reranking using Valyu #### Location & Travel - **Weather** — Current weather, 5-day forecast, air quality, and 16-day extended forecast using OpenWeatherMap and Open-Meteo - **Maps & geocoding** — Forward/reverse geocoding and nearby place discovery using Google Maps API - **Flight tracking** — Real-time flight status with departure/arrival details #### Media & Entertainment - **Movie/TV search** — Search movies and TV shows with detailed cast, ratings, and metadata using TMDB - **Trending movies** — Today's trending movies from TMDB - **Trending TV shows** — Today's trending TV shows from TMDB #### Productivity & Utilities - **Code interpreter** — Write and execute Python code in a sandboxed Daytona environment with chart generation - **Code context** — Get contextual information about programming topics using Exa Context API - **Text translation** — Translate text (and text within images) between languages using AI models - **File query search** — Semantic search over uploaded files (PDF, CSV, DOCX, Excel) with Cohere embeddings and reranking - **Connectors search** — Search connected Google Drive, Notion, and OneDrive using Supermemory - **Memory tools** — Save and search personal memories using Supermemory - **Date & time** — Current date/time in multiple formats with timezone support - **Greeting** — Personalized time-of-day-aware greetings ## LLM Models Supported - **xAI**: Grok 3, Grok 3 Mini, Grok 4, Grok 4 Fast, Grok 4.1 Fast, Grok Code - **OpenAI**: GPT 4.1 (Nano/Mini/Standard), GPT 5 (Nano/Mini/Medium/Standard), GPT 5.1 (Instant/Thinking/Codex), GPT 5.2 (Instant/Thinking/Codex), o3, o4 mini, GPT OSS 20B/120B - **Anthropic**: Claude Haiku 4.5, Claude Sonnet 4.5, Claude 4.5 Opus, Claude 4.6 Opus - **Google**: Gemini 2.5 Flash (Lite/Standard), Gemini 2.5 Pro, Gemini 3 Flash, Gemini 3 Pro - **Alibaba (Qwen)**: Qwen 3 (4B/32B/235B), Qwen 3 VL, Qwen 3 Max, Qwen 3 Coder (Small/Standard/Plus/Next), Qwen 3 Next 80B - **Mistral**: Ministral 3 (3B/8B/14B), Mistral Large 3, Mistral Medium, Magistral (Small/Medium), Devstral 2 (Small/Standard) - **DeepSeek**: DeepSeek v3, v3.1 Terminus, v3.2, R1, R1 0528 - **Zhipu (GLM)**: GLM 4.5, GLM 4.5 Air, GLM 4.6, GLM 4.6V, GLM 4.7, GLM 4.7 Flash - **Cohere**: Command A, Command A Thinking - **MoonShot**: Kimi K2, Kimi K2.5 - **Minimax**: M1 80K, M2, M2.1, M2.1 Lightning - **ByteDance**: Seed 1.6, Seed 1.6 Flash, Seed 1.8 - **Arcee**: Trinity Mini, Trinity Large - **Others**: Vercel v0 (1.0/1.5), Amazon Nova 2 Lite, Xiaomi Mimo V2 Flash, StepFun Step 3.5 Flash, Kwaipilot KAT-Coder-Pro V1 ## Built with - [Next.js](https://nextjs.org/) - React framework - [Tailwind CSS](https://tailwindcss.com/) - Styling - [Vercel AI SDK](https://sdk.vercel.ai/docs) - AI model integration and streaming - [Shadcn/UI](https://ui.shadcn.com/) - UI components - [Exa.AI](https://exa.ai/) - Web search, academic search, and content retrieval - [Firecrawl](https://firecrawl.dev/) - Web scraping with structured extraction - [Parallel](https://parallel.ai/) - Web and Reddit search - [Tavily](https://tavily.com/) - Web search and financial news - [Valyu](https://valyu.network/) - Financial data, forex, and prediction markets - [Supadata](https://supadata.ai/) - YouTube search, transcripts, and social media - [CoinGecko](https://www.coingecko.com/) - Cryptocurrency market data - [Spotify](https://developer.spotify.com/) - Music search - [OpenWeatherMap](https://openweathermap.org/) - Weather data - [Open-Meteo](https://open-meteo.com/) - Extended forecasts and geocoding - [Daytona](https://daytona.io/) - Code execution sandbox - [Google Maps](https://developers.google.com/maps) - Geocoding and places - [TMDB](https://www.themoviedb.org/) - Movie and TV data - [Cohere](https://cohere.com/) - Embeddings and reranking - [Supermemory](https://supermemory.ai/) - Memory management and connector search - [Upstash](https://upstash.com/) - Serverless Redis and rate limiting - [Cloudflare R2](https://www.cloudflare.com/r2/) - Object storage for artifacts - [ElevenLabs](https://elevenlabs.io/) - Voice synthesis - [Better Auth](https://github.com/better-auth/better-auth) - Authentication - [Drizzle ORM](https://orm.drizzle.team/) - Database management - [Novita AI](https://novita.ai) - AI Inference ### Deploy your own [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzaidmukaddam%2Fscira&env=XAI_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEY,GROQ_API_KEY,GOOGLE_GENERATIVE_AI_API_KEY,DAYTONA_API_KEY,DATABASE_URL,BETTER_AUTH_SECRET,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,TWITTER_CLIENT_ID,TWITTER_CLIENT_SECRET,REDIS_URL,ELEVENLABS_API_KEY,TAVILY_API_KEY,EXA_API_KEY,SUPADATA_API_KEY,TMDB_API_KEY,YT_ENDPOINT,FIRECRAWL_API_KEY,OPENWEATHER_API_KEY,GOOGLE_MAPS_API_KEY,MAPBOX_ACCESS_TOKEN,AVIATION_STACK_API_KEY,CRON_SECRET,BLOB_READ_WRITE_TOKEN,MEM0_API_KEY,MEM0_ORG_ID,MEM0_PROJECT_ID,SMITHERY_API_KEY,NEXT_PUBLIC_MAPBOX_TOKEN,NEXT_PUBLIC_POSTHOG_KEY,NEXT_PUBLIC_POSTHOG_HOST,NEXT_PUBLIC_SCIRA_PUBLIC_API_KEY,SCIRA_API_KEY&envDescription=API%20keys%20and%20configuration%20required%20for%20Scira%20to%20function) ## Set Scira as your default search engine 1. **Open the Chrome browser settings**: - Click on the three vertical dots in the upper right corner of the browser. - Select "Settings" from the dropdown menu. 2. **Go to the search engine settings**: - In the left sidebar, click on "Search engine." - Then select "Manage search engines and site search." 3. **Add a new search engine**: - Click on "Add" next to "Site search." 4. **Set the search engine name**: - Enter `Scira` in the "Search engine" field. 5. **Set the search engine URL**: - Enter `https://scira.ai?q=%s` in the "URL with %s in place of query" field. 6. **Set the search engine shortcut**: - Enter `sh` in the "Shortcut" field. 7. **Set Default**: - Click on the three dots next to the search engine you just added. - Select "Make default" from the dropdown menu. After completing these steps, you should be able to use Scira as your default search engine in Chrome. ### Local development #### Run via Docker The application can be run using Docker in two ways: ##### Using Docker Compose (Recommended) 1. Make sure you have Docker and Docker Compose installed on your system 2. Create a `.env` file based on `.env.example` with your API keys 3. Run the following command in the project root: ```bash docker compose up ``` 4. The application will be available at `http://localhost:3000` ##### Using Docker Directly 1. Create a `.env` file based on `.env.example` with your API keys 2. Build the Docker image: ```bash docker build -t scira.app . ``` 3. Run the container: ```bash docker run --env-file .env -p 3000:3000 scira.app ``` The application uses a multi-stage build process to minimize the final image size and implements security best practices. The production image runs on Node.js LTS with Alpine Linux for a minimal footprint. #### Run with Node.js To run the application locally without Docker: 1. Sign up for accounts with the required AI providers: - OpenAI (required) - Anthropic (required) - Exa (required for web search feature) 2. Copy `.env.example` to `.env.local` and fill in your API keys 3. Install dependencies: ```bash pnpm install ``` 4. Start the development server: ```bash pnpm dev ``` 5. Open `http://localhost:3000` in your browser # License This project is licensed under the AGPLv3 License - see the [LICENSE](LICENSE) file for details. ================================================ FILE: ai/models.ts ================================================ // Pure model data and helper functions — no server SDK imports interface ModelParameters { temperature?: number; topP?: number; topK?: number; minP?: number; frequencyPenalty?: number; presencePenalty?: number; maxOutputTokens?: number; } // Provider definitions for model categorization export type ModelProvider = | 'scira' | 'xai' | 'openai' | 'anthropic' | 'google' | 'alibaba' | 'mistral' | 'deepseek' | 'zhipu' | 'cohere' | 'moonshot' | 'minimax' | 'bytedance' | 'arcee' | 'vercel' | 'amazon' | 'xiaomi' | 'kwaipilot' | 'stepfun' | 'sarvam' | 'inception' | 'nvidia'; export interface ProviderInfo { id: ModelProvider; name: string; icon: string; // SVG path or icon identifier hasNew?: boolean; } export const PROVIDERS: Record = { scira: { id: 'scira', name: 'Scira', icon: 'scira' }, xai: { id: 'xai', name: 'xAI', icon: 'xai', hasNew: true }, openai: { id: 'openai', name: 'OpenAI', icon: 'openai', hasNew: true }, anthropic: { id: 'anthropic', name: 'Anthropic', icon: 'anthropic', hasNew: true }, google: { id: 'google', name: 'Google', icon: 'google', hasNew: true }, alibaba: { id: 'alibaba', name: 'Alibaba', icon: 'alibaba', hasNew: true }, zhipu: { id: 'zhipu', name: 'Zhipu AI', icon: 'zhipu' }, minimax: { id: 'minimax', name: 'Minimax', icon: 'minimax', hasNew: true }, deepseek: { id: 'deepseek', name: 'DeepSeek', icon: 'deepseek' }, moonshot: { id: 'moonshot', name: 'MoonShot', icon: 'moonshot' }, cohere: { id: 'cohere', name: 'Cohere', icon: 'cohere' }, bytedance: { id: 'bytedance', name: 'ByteDance', icon: 'bytedance', hasNew: true }, mistral: { id: 'mistral', name: 'Mistral', icon: 'mistral', hasNew: true }, arcee: { id: 'arcee', name: 'Arcee', icon: 'arcee' }, vercel: { id: 'vercel', name: 'Vercel', icon: 'vercel' }, amazon: { id: 'amazon', name: 'Amazon', icon: 'amazon' }, xiaomi: { id: 'xiaomi', name: 'Xiaomi', icon: 'xiaomi' }, kwaipilot: { id: 'kwaipilot', name: 'Kwaipilot', icon: 'kwaipilot' }, stepfun: { id: 'stepfun', name: 'StepFun', icon: 'stepfun' }, sarvam: { id: 'sarvam', name: 'Sarvam', icon: 'sarvam', hasNew: true }, inception: { id: 'inception', name: 'Inception', icon: 'inception', hasNew: true }, nvidia: { id: 'nvidia', name: 'NVIDIA', icon: 'nvidia', hasNew: true }, }; export interface Model { value: string; label: string; description: string; vision: boolean; reasoning: boolean; experimental: boolean; category: string; pdf: boolean; pro: boolean; max?: boolean; // Requires Max plan (superset of Pro) requiresAuth: boolean; freeUnlimited: boolean; maxOutputTokens: number; extreme?: boolean; fast?: boolean; isNew?: boolean; parameters?: ModelParameters; provider?: ModelProvider; // Optional - will be derived if not specified } export const models: Model[] = [ { value: 'scira-auto', label: 'Auto', description: 'Automatically routes your query to the best model', vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'scira', }, // Models (xAI) { value: 'scira-grok-3-mini', label: 'Grok 3 Mini', description: "xAI's recent smallest LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, provider: 'xai', }, { value: 'scira-grok-3', label: 'Grok 3', description: "xAI's recent smartest LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, provider: 'xai', }, { // grok-4.20-multi-agent-beta-latest value: 'grok-4.20-multi-agent-beta-latest', label: 'Grok 4.20 Multi Agent Beta', description: "xAI's experimental beta multi-agent model", vision: true, reasoning: true, experimental: true, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 30000, isNew: true, provider: 'xai', }, { value: 'scira-grok-4', label: 'Grok 4', description: "xAI's most intelligent LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, provider: 'xai', }, { value: 'scira-grok-4.20-experimental-beta-0304', label: 'Grok 4.20 Beta', description: "xAI's experimental beta chat model", vision: true, reasoning: false, experimental: true, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 30000, isNew: true, provider: 'xai', }, { value: 'scira-grok-4.20-experimental-beta-0304-thinking', label: 'Grok 4.20 Beta Thinking', description: "xAI's experimental beta reasoning model", vision: true, reasoning: true, experimental: true, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 30000, isNew: true, provider: 'xai', }, { value: 'scira-default', label: 'Grok 4.1 Fast', description: "xAI's greatest and fastest multimodel LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, freeUnlimited: false, maxOutputTokens: 30000, extreme: true, fast: true, isNew: true, provider: 'xai', }, { value: 'scira-sarvam-105b', label: 'Sarvam 105B', description: "Sarvam's flagship model for chat and reasoning", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'sarvam', parameters: { temperature: 0.5, }, }, { value: 'scira-grok4.1-fast-thinking', label: 'Grok 4.1 Fast Thinking', description: "xAI's greatest and fastest multimodel reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 30000, extreme: true, fast: true, isNew: true, provider: 'xai', }, { value: 'scira-grok-4-fast', label: 'Grok 4 Fast', description: "xAI's previous fastest multimodel LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 30000, extreme: true, fast: true, isNew: false, provider: 'xai', }, { value: 'scira-grok-4-fast-think', label: 'Grok 4 Fast Thinking', description: "xAI's previous fastest multimodel reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 30000, extreme: true, fast: true, isNew: false, parameters: { maxOutputTokens: 30000, }, provider: 'xai', }, { value: 'scira-code', label: 'Grok Code', description: "xAI's advanced coding LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: true, provider: 'xai', }, { value: 'scira-seed-2.0-mini', label: 'Seed 2.0 Mini', description: "ByteDance's compact and efficient reasoning model", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, fast: true, provider: 'bytedance', }, { value: 'scira-seed-2.0-lite', label: 'Seed 2.0 Lite', description: "ByteDance's lightweight vision model", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, fast: true, provider: 'bytedance', }, { value: 'scira-seed-1.6', label: 'Seed 1.6', description: "ByteDance's recent reasoning model", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'bytedance', }, { value: 'scira-seed-1.8', label: 'Seed 1.8', description: "ByteDance's latest reasoning model", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'bytedance', }, { value: 'scira-seed-1.6-flash', label: 'Seed 1.6 Flash', description: "ByteDance's fast vision reasoning model", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, fast: true, provider: 'bytedance', }, { value: 'scira-qwen-32b', label: 'Qwen 3 32B', description: "Alibaba's base LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, freeUnlimited: false, maxOutputTokens: 40960, fast: true, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-32b-thinking', label: 'Qwen 3 32B Thinking', description: "Alibaba's base reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, freeUnlimited: false, maxOutputTokens: 40960, fast: true, parameters: { temperature: 0.6, topP: 0.95, topK: 20, minP: 0, }, provider: 'alibaba', }, { value: 'scira-nemotron-3-super', label: 'Nemotron 3 Super', description: "NVIDIA's powerful Nemotron 3 model", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, freeUnlimited: false, maxOutputTokens: 16000, provider: 'nvidia', }, { value: 'scira-qwen-4b', label: 'Qwen 3 4B', description: "Alibaba's small base LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, maxOutputTokens: 16000, freeUnlimited: false, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-4b-thinking', label: 'Qwen 3 4B Thinking', description: "Alibaba's small base LLM", vision: false, reasoning: true, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, maxOutputTokens: 16000, freeUnlimited: false, parameters: { temperature: 0.6, topP: 0.95, topK: 20, minP: 0, }, provider: 'alibaba', }, { value: 'scira-gpt-oss-20', label: 'GPT OSS 20B', description: "OpenAI's small OSS LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: true, provider: 'openai', }, { value: 'scira-gpt5-nano', label: 'GPT 5 Nano', description: "OpenAI's smallest flagship LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: true, provider: 'openai', }, { value: 'scira-google-lite', label: 'Gemini 2.5 Flash Lite', description: "Google's advanced small LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, extreme: true, isNew: false, provider: 'google', }, { value: 'scira-ministral-3b', label: 'Ministral 3 3B', description: "Mistral's mini-model 3B multi-modal LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-ministral-8b', label: 'Ministral 3 8B', description: "Mistral's mini-model 8B multi-modal LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-devstral', label: 'Devstral 2', description: "Mistral's coding-focused LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-devstral-small', label: 'Devstral Small 2', description: "Mistral's small coding-focused LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-ministral-14b', label: 'Ministral 3 14B', description: "Mistral's mini-model 14B multi-modal LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-mistral-large', label: 'Mistral Large 3', description: "Mistral's latest and greatest large multi-modal LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-mistral-medium', label: 'Mistral Medium', description: "Mistral's medium multi-modal LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'mistral', }, { value: 'scira-magistral-small', label: 'Magistral Small', description: "Mistral's small reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'mistral', }, { value: 'scira-magistral-medium', label: 'Magistral Medium', description: "Mistral's medium reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'mistral', }, { value: 'scira-mistral-small', label: 'Mistral Small 4', description: "Mistral's small efficient model", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-mistral-small-think', label: 'Mistral Small 4 Thinking', description: "Mistral's small model with reasoning mode enabled", vision: false, reasoning: true, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-leanstral', label: 'Leanstral', description: "Mistral's lean and efficient small model", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'mistral', }, { value: 'scira-trinity-mini', label: 'Trinity Mini', description: "Arcee's small reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, parameters: { temperature: 0.15, topK: 50, topP: 0.75, minP: 0.06, }, provider: 'arcee', }, { value: 'scira-trinity-large', label: 'Trinity Large', description: "Arcee's large reasoning LLM via OpenRouter", vision: true, reasoning: true, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, parameters: { temperature: 0.15, topK: 50, topP: 0.75, minP: 0.06, }, provider: 'arcee', }, { value: 'scira-gpt-oss-120', label: 'GPT OSS 120B', description: "OpenAI's advanced OSS LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: true, provider: 'openai', }, { value: 'scira-gpt-4.1-nano', label: 'GPT 4.1 Nano', description: "OpenAI's smallest LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: true, provider: 'openai', }, { value: 'scira-gpt-4.1-mini', label: 'GPT 4.1 Mini', description: "OpenAI's small LLM", vision: true, reasoning: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: true, extreme: true, experimental: false, provider: 'openai', }, { value: 'scira-gpt-4.1', label: 'GPT 4.1', description: "OpenAI's LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.1', label: 'GPT 5.1 Instant', description: "OpenAI's fast and smart LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.1-thinking', label: 'GPT 5.1 Thinking', description: "OpenAI's recent and smart reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.2', label: 'GPT 5.2 Instant', description: "OpenAI's latest and greatest LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.3-chat-latest', label: 'GPT 5.3 Instant', description: "OpenAI's latest chat-optimized LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: true, provider: 'openai', }, { value: 'scira-gpt-5.4', label: 'GPT 5.4 Instant', description: "OpenAI's latest and greatest LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: true, provider: 'openai', }, { value: 'scira-gpt-5.4-mini', label: 'GPT 5.4 Mini', description: "OpenAI's small GPT 5.4 model", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: true, isNew: true, provider: 'openai', }, { value: 'scira-gpt-5.4-nano', label: 'GPT 5.4 Nano', description: "OpenAI's smallest GPT 5.4 model", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: true, isNew: true, provider: 'openai', }, { value: 'scira-gpt-5.4-thinking', label: 'GPT 5.4 Thinking', description: "OpenAI's latest and greatest reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: true, provider: 'openai', }, { value: 'scira-gpt-5.4-thinking-xhigh', label: 'GPT 5.4 Thinking XHigh', description: "OpenAI's latest and greatest reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: true, provider: 'openai', }, { value: 'scira-gpt-5.2-thinking', label: 'GPT 5.2 Thinking', description: "OpenAI's latest and greatest reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.2-thinking-xhigh', label: 'GPT 5.2 Thinking XHigh', description: "OpenAI's latest and greatest reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt5-mini', label: 'GPT 5 Mini', description: "OpenAI's small flagship LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt5', label: 'GPT 5', description: "OpenAI's flagship LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-o4-mini', label: 'o4 mini', description: "OpenAI's recent mini reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-o3', label: 'o3', description: "OpenAI's advanced LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt5-medium', label: 'GPT 5 Medium', description: "OpenAI's latest flagship reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.1-codex', label: 'GPT 5.1 Codex', description: "OpenAI's advanced coding LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.1-codex-mini', label: 'GPT 5.1 Codex Mini', description: "OpenAI's advanced coding LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.1-codex-max', label: 'GPT 5.1 Codex Max', description: "OpenAI's advanced coding LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.2-codex', label: 'GPT 5.2 Codex', description: "OpenAI's latest advanced coding LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-gpt-5.3-codex', label: 'GPT 5.3 Codex', description: "OpenAI's latest advanced coding LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: true, provider: 'openai', }, { value: 'scira-gpt5-codex', label: 'GPT 5 Codex', description: "OpenAI's advanced coding LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, extreme: true, fast: false, isNew: false, provider: 'openai', }, { value: 'scira-cmd-a', label: 'Command A', description: "Cohere's advanced command LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'cohere', }, { value: 'scira-cmd-a-think', label: 'Command A Thinking', description: "Cohere's advanced command LLM with thinking", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'cohere', }, { value: 'scira-kat-coder', label: 'KAT-Coder-Pro V1', description: "Kwaipilot's advanced coding LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'kwaipilot', }, { value: 'scira-deepseek-v3', label: 'DeepSeek v3', description: "DeepSeek's previous advanced chat LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, provider: 'deepseek', }, { value: 'scira-deepseek-v3.1-terminus', label: 'DeepSeek v3.1 Terminus', description: "DeepSeek's advanced chat LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, provider: 'deepseek', }, { value: 'scira-deepseek-chat', label: 'DeepSeek v3.2', description: "DeepSeek's advanced chat LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, parameters: { temperature: 1.0, topP: 0.95, }, provider: 'deepseek', }, { value: 'scira-deepseek-chat-think', label: 'DeepSeek v3.2 Thinking', description: "DeepSeek's advanced chat LLM with thinking", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'deepseek', }, { value: 'scira-deepseek-chat-exp', label: 'DeepSeek v3.2 Exp', description: "DeepSeek's advanced chat LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'deepseek', }, { value: 'scira-deepseek-chat-think-exp', label: 'DeepSeek v3.2 Exp Thinking', description: "DeepSeek's advanced chat LLM with thinking", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'deepseek', }, { value: 'scira-deepseek-r1', label: 'DeepSeek R1', description: "DeepSeek's advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'deepseek', }, { value: 'scira-deepseek-r1-0528', label: 'DeepSeek R1 0528', description: "DeepSeek's advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: false, provider: 'deepseek', }, { value: 'scira-qwen-coder-small', label: 'Qwen 3 Coder 30B A3B Instruct', description: "Alibaba's advanced coding LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, fast: false, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, isNew: false, provider: 'alibaba', }, { value: 'scira-qwen-coder', label: 'Qwen 3 Coder', description: "Alibaba's advanced coding LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: false, provider: 'alibaba', }, { value: 'scira-qwen-coder-plus', label: 'Qwen 3 Coder Plus', description: "Alibaba's extremely advanced coding LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: false, provider: 'alibaba', }, { value: 'scira-qwen-3.5', label: 'Qwen 3.5 397B A17B', description: "Alibaba's latest flagship LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: false, isNew: true, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, presencePenalty: 1.5, }, provider: 'alibaba', }, { value: 'scira-qwen-3.5-plus', label: 'Qwen 3.5 Plus', description: "Alibaba's latest flagship LLM with vision and reasoning", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: false, isNew: true, provider: 'alibaba', }, { value: 'scira-qwen-3.5-27b', label: 'Qwen 3.5 27B', description: "Alibaba's Qwen 3.5 27B vision reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: true, isNew: true, provider: 'alibaba', }, { value: 'scira-qwen-3.5-35b', label: 'Qwen 3.5 35B A3B', description: "Alibaba's Qwen 3.5 35B A3B vision reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: true, isNew: true, provider: 'alibaba', }, { value: 'scira-qwen-3.5-122b', label: 'Qwen 3.5 122B A10B', description: "Alibaba's Qwen 3.5 122B A10B vision reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, fast: false, isNew: true, provider: 'alibaba', }, { value: 'scira-qwen-3.5-flash', label: 'Qwen 3.5 Flash', description: "Alibaba's fast vision reasoning LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, fast: true, isNew: true, provider: 'alibaba', parameters: { temperature: 1, topP: 0.95, topK: 20, minP: 0, presencePenalty: 1.5, }, }, { value: 'scira-qwen-coder-next', label: 'Qwen 3 Coder Next', description: "Alibaba's next-gen coding LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: false, isNew: true, provider: 'alibaba', }, { value: 'scira-qwen-3-vl-30b', label: 'Qwen 3 VL 30B', description: "Alibaba's advanced vision LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: true, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-3-vl-30b-thinking', label: 'Qwen 3 VL 30B Thinking', description: "Alibaba's advanced vision LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, fast: true, parameters: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0, }, isNew: false, provider: 'alibaba', }, { value: 'scira-qwen-3-next', label: 'Qwen 3 Next 80B A3B Instruct', description: "Qwen's advanced instruct LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 100000, fast: true, isNew: false, parameters: { temperature: 0.7, topP: 0.8, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-3-next-think', label: 'Qwen 3 Next 80B A3B Thinking', description: "Qwen's advanced thinking LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 100000, isNew: false, parameters: { temperature: 0.6, topP: 0.95, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-3-max', label: 'Qwen 3 Max', description: "Qwen's advanced instruct LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, provider: 'alibaba', }, { value: 'scira-qwen-3-max-preview', label: 'Qwen 3 Max Preview', description: "Qwen's advanced instruct LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, provider: 'alibaba', }, { value: 'scira-qwen-3-max-preview-thinking', label: 'Qwen 3 Max Thinking', description: "Qwen's most advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: true, provider: 'alibaba', }, { value: 'scira-qwen-235', label: 'Qwen 3 235B A22B', description: "Qwen's advanced instruct LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 100000, parameters: { temperature: 0.7, topP: 0.8, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-235-think', label: 'Qwen 3 235B A22B Thinking', description: "Qwen's advanced thinking LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 100000, parameters: { temperature: 0.6, topP: 0.95, minP: 0, }, provider: 'alibaba', }, { value: 'scira-qwen-3-vl', label: 'Qwen 3 VL', description: "Qwen's advanced vision LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, parameters: { temperature: 0.6, topP: 0.95, minP: 0, }, isNew: false, provider: 'alibaba', }, { value: 'scira-qwen-3-vl-thinking', label: 'Qwen 3 VL Thinking', description: "Qwen's advanced vision LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, parameters: { temperature: 0.6, topP: 0.95, minP: 0, }, isNew: false, provider: 'alibaba', }, { value: 'scira-kimi-k2.5', label: 'Kimi K2.5', description: "MoonShot AI's latest vision-enabled LLM", vision: true, reasoning: true, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: true, provider: 'moonshot', }, { value: 'scira-kimi-k2.5-thinking', label: 'Kimi K2.5 Thinking', description: "MoonShot AI's latest multi-modal LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: true, provider: 'moonshot', }, { value: 'scira-kimi-k2-v2', label: 'Kimi K2 0905', description: "MoonShot AI's advanced base LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, fast: true, parameters: { temperature: 0.6, }, provider: 'moonshot', }, { value: 'scira-kimi-k2-v2-thinking', label: 'Kimi K2 Thinking', description: "MoonShot AI's advanced base LLM with thinking", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, fast: true, parameters: { temperature: 1, }, isNew: false, provider: 'moonshot', }, { value: 'scira-minimax', label: 'Minimax M1 80K', description: "Minimax's advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, parameters: { temperature: 0.6, }, provider: 'minimax', }, { value: 'scira-minimax-m2', label: 'Minimax M2', description: "Minimax's advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, parameters: { temperature: 1.0, topP: 0.95, topK: 40, }, provider: 'minimax', }, { value: 'scira-minimax-m2.1', label: 'Minimax M2.1', description: "Minimax's latest advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, parameters: { temperature: 1.0, topP: 0.95, topK: 40, }, provider: 'minimax', }, { value: 'scira-minimax-m2.1-lightning', label: 'Minimax M2.1 Lightning', description: "Minimax's fast advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, fast: true, parameters: { temperature: 1.0, topP: 0.95, topK: 40, }, provider: 'minimax', }, { value: 'scira-minimax-m2.7', label: 'MiniMax M2.7', description: "MiniMax's latest high-speed reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, fast: true, isNew: true, parameters: { temperature: 1.0, topP: 0.95, topK: 40, }, provider: 'minimax', }, { value: 'scira-minimax-m2.5', label: 'Minimax M2.5', description: "Minimax's most capable reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 10000, isNew: false, parameters: { temperature: 1.0, topP: 0.95, topK: 40, }, provider: 'minimax', }, { value: 'scira-glm-4.6', label: 'GLM 4.6', description: "Zhipu AI's advanced reasoning LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: false, fast: true, parameters: { temperature: 0.6, topP: 0.95, }, provider: 'zhipu', }, { value: 'scira-glm-4.6v-flash', label: 'GLM 4.6V Flash', description: "Zhipu AI's fast vision reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: false, fast: true, parameters: { temperature: 0.8, topP: 0.6, topK: 2, frequencyPenalty: 1.1, }, provider: 'zhipu', }, { value: 'scira-glm-4.6v', label: 'GLM 4.6V', description: "Zhipu AI's advanced vision reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: false, parameters: { temperature: 0.8, topP: 0.6, topK: 2, frequencyPenalty: 1.1, }, provider: 'zhipu', }, { value: 'scira-glm-4.7', label: 'GLM 4.7', description: "Zhipu AI's latest advanced reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: false, fast: true, parameters: { temperature: 1, topP: 0.95, }, }, { value: 'scira-glm-4.7-flash', label: 'GLM 4.7 Flash', description: "Zhipu AI's latest fast vision reasoning LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: false, fast: true, provider: 'zhipu', }, { value: 'scira-glm-5', label: 'GLM 5', description: "Zhipu AI's most powerful LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: true, parameters: { temperature: 1, topP: 0.95, }, provider: 'zhipu', }, { value: 'scira-glm-5-thinking', label: 'GLM 5 Thinking', description: "Zhipu AI's most powerful reasoning LLM", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 20000, isNew: true, parameters: { temperature: 1, topP: 0.95, }, provider: 'zhipu', }, { value: 'scira-glm-air', label: 'GLM 4.5 Air', description: "Zhipu AI's efficient base LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 130000, parameters: { temperature: 0.6, topP: 0.95, }, provider: 'zhipu', }, { value: 'scira-glm', label: 'GLM 4.5', description: "Zhipu AI's previous advanced LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 13000, parameters: { temperature: 0.6, topP: 0.95, }, provider: 'zhipu', }, { value: 'scira-google', label: 'Gemini 2.5 Flash', description: "Google's advanced small LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: false, provider: 'google', }, { value: 'scira-google-think', label: 'Gemini 2.5 Flash Thinking', description: "Google's advanced small LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: false, provider: 'google', }, { value: 'scira-google-pro', label: 'Gemini 2.5 Pro', description: "Google's advanced LLM", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: false, provider: 'google', }, { value: 'scira-google-pro-think', label: 'Gemini 2.5 Pro Thinking', description: "Google's advanced LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: false, provider: 'google', }, { value: 'scira-gemini-3-flash', label: 'Gemini 3 Flash', description: "Google's latest small SOTA LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: true, provider: 'google', }, { value: 'scira-gemini-3-flash-think', label: 'Gemini 3 Flash Thinking', description: "Google's latest small SOTA LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: true, provider: 'google', }, { value: 'scira-gemini-3.1-flash-lite', label: 'Gemini 3.1 Flash Lite', description: "Google's newest lightweight flash LLM", vision: true, reasoning: false, experimental: false, category: 'Free', pdf: true, pro: false, requiresAuth: false, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, fast: true, isNew: true, provider: 'google', }, { value: 'scira-gemini-3.1-flash-lite-think', label: 'Gemini 3.1 Flash Lite Thinking', description: "Google's newest lightweight flash LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Pro', pdf: true, pro: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, fast: true, isNew: true, provider: 'google', }, { value: 'scira-gemini-3.1-pro', label: 'Gemini 3.1 Pro', description: "Google's newest SOTA LLM", vision: true, reasoning: false, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, extreme: true, maxOutputTokens: 10000, isNew: true, provider: 'google', }, { value: 'scira-anthropic-small', label: 'Claude Haiku 4.5', description: "Anthropic's fast and efficient LLM", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: true, pro: true, max: false, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: false, provider: 'anthropic', }, { value: 'scira-anthropic', label: 'Claude Sonnet 4.5', description: "Anthropic's latest and greatest LLM", vision: true, reasoning: false, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: false, provider: 'anthropic', }, { value: 'scira-anthropic-think', label: 'Claude Sonnet 4.5 Thinking', description: "Anthropic's latest and greatest LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: false, provider: 'anthropic', }, { value: 'scira-anthropic-sonnet-4.6', label: 'Claude Sonnet 4.6', description: "Anthropic's latest Sonnet LLM", vision: true, reasoning: false, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: true, provider: 'anthropic', }, { value: 'scira-anthropic-sonnet-4.6-think', label: 'Claude Sonnet 4.6 Thinking', description: "Anthropic's latest Sonnet LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: true, provider: 'anthropic', }, { value: 'scira-anthropic-opus', label: 'Claude 4.5 Opus', description: "Anthropic's previous advanced LLM", vision: true, reasoning: false, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: false, provider: 'anthropic', }, { value: 'scira-anthropic-opus-think', label: 'Claude 4.5 Opus Thinking', description: "Anthropic's previous advanced LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: false, provider: 'anthropic', }, { value: 'scira-anthropic-opus-4.6', label: 'Claude 4.6 Opus', description: "Anthropic's most advanced LLM", vision: true, reasoning: false, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: true, provider: 'anthropic', }, { value: 'scira-anthropic-opus-4.6-think', label: 'Claude 4.6 Opus Thinking', description: "Anthropic's most advanced LLM with thinking", vision: true, reasoning: true, experimental: false, category: 'Max', pdf: true, pro: true, max: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 8000, isNew: true, provider: 'anthropic', }, { value: 'scira-mimo-v2-flash', label: 'Mimo V2 Flash', description: "Xiaomi's fast Mimo V2 Flash model via OpenRouter (thinking disabled)", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'xiaomi', }, { value: 'scira-mimo-v2-pro', label: 'Mimo V2 Pro', description: "Xiaomi's advanced Mimo V2 Pro model", vision: false, reasoning: true, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'xiaomi', }, { value: 'scira-nova-2-lite', label: 'Nova 2 Lite', description: "Amazon's latest and smallest LLM", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'amazon', }, { value: 'scira-v0-10', label: 'Vercel v0 1.0', description: "Vercel's v0 1.0 model", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'vercel', }, { value: 'scira-v0-15', label: 'Vercel v0 1.5', description: "Vercel's v0 1.5 model", vision: true, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 16000, isNew: true, provider: 'vercel', }, { value: 'scira-step-3.5-flash', label: 'Step 3.5 Flash', description: "StepFun's fast and efficient LLM", vision: false, reasoning: false, experimental: false, category: 'Free', pdf: false, pro: false, requiresAuth: false, freeUnlimited: false, maxOutputTokens: 8000, fast: true, isNew: true, provider: 'stepfun', }, { value: 'scira-mercury-2', label: 'Mercury 2', description: "Inception's diffusion-based language model", vision: false, reasoning: false, experimental: false, category: 'Pro', pdf: false, pro: true, requiresAuth: true, freeUnlimited: false, maxOutputTokens: 1000, fast: true, isNew: true, provider: 'inception', }, ]; // Helper functions for model access checks export function getModelConfig(modelValue: string) { return models.find((model) => model.value === modelValue); } export function requiresAuthentication(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.requiresAuth || false; } export function requiresProSubscription(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.pro || false; } export function requiresMaxSubscription(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.max || false; } export function isFreeUnlimited(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.freeUnlimited || false; } export function hasVisionSupport(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.vision || false; } export function hasPdfSupport(modelValue: string): boolean { const model = getModelConfig(modelValue); // Models with vision support can also handle PDFs return model?.pdf || false; } export function hasReasoningSupport(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.reasoning || false; } export function isExperimentalModel(modelValue: string): boolean { const model = getModelConfig(modelValue); return model?.experimental || false; } export function getMaxOutputTokens(modelValue: string): number { const model = getModelConfig(modelValue); return model?.maxOutputTokens || 8000; } export function getModelParameters(modelValue: string): ModelParameters { const model = getModelConfig(modelValue); return model?.parameters || {}; } // Access control helper export function canUseModel( modelValue: string, user: any, isProUser: boolean, isMaxUser: boolean = false, ): { canUse: boolean; reason?: string } { const model = getModelConfig(modelValue); if (!model) { return { canUse: false, reason: 'Model not found' }; } // Check if model requires authentication if (model.requiresAuth && !user) { return { canUse: false, reason: 'authentication_required' }; } // Check if model requires Max subscription if (model.max && !isMaxUser) { return { canUse: false, reason: 'max_subscription_required' }; } // Check if model requires Pro subscription (Max is a superset of Pro) if (model.pro && !isProUser && !isMaxUser) { return { canUse: false, reason: 'pro_subscription_required' }; } return { canUse: true }; } // Helper to check if user should bypass rate limits export function shouldBypassRateLimits(modelValue: string, user: any): boolean { const model = getModelConfig(modelValue); return Boolean(user && model?.freeUnlimited); } // Get acceptable file types for a model export function getAcceptedFileTypes(modelValue: string, isProUser: boolean): string { const model = getModelConfig(modelValue); // Document file types for file_query_search tool - available for ALL models const documentTypes = '.csv,.xlsx,.xls,.docx'; // Vision models get images + documents, PDF models also get PDFs if (model?.vision) { if (model?.pdf) { return `image/*,.pdf,${documentTypes}`; } return `image/*,${documentTypes}`; } // Non-vision models only get document types for file_query_search return documentTypes; } // Check if a model supports extreme mode export function supportsExtremeMode(modelValue: string): boolean { // Extreme mode restrictions removed: allow all models in extreme mode return true; } // Get models that support extreme mode export function getExtremeModels(): Model[] { // With restrictions removed, all models are considered extreme-capable return models; } // Models that support canvas mode (spec generation) const CANVAS_MODELS = new Set([ 'scira-code', 'scira-gpt-5.2', 'scira-gpt-5.3-chat-latest', 'scira-gpt-5.4', 'scira-gpt-5.4-mini', 'scira-gpt-5.4-nano', 'scira-gpt-5.4-thinking', 'scira-gpt-5.4-thinking-xhigh', 'scira-gpt-5.2-thinking', 'scira-gpt-5.2-thinking-xhigh', 'scira-anthropic', 'scira-anthropic-sonnet-4.6', 'scira-anthropic-sonnet-4.6-think', 'scira-anthropic-opus-4.6', 'scira-anthropic-opus-4.6-think', 'scira-glm-5', 'scira-glm-4.7', 'scira-kimi-k2.5', 'scira-qwen-3.5', 'scira-qwen-3.5-plus', 'scira-seed-1.8', 'scira-deepseek-chat', 'scira-gpt-5.2-codex', 'scira-gpt-5.3-codex', 'scira-gemini-3.1-pro', ]); export function supportsCanvasMode(modelValue: string): boolean { return CANVAS_MODELS.has(modelValue); } // Restricted regions for OpenAI and Anthropic models const RESTRICTED_REGIONS = ['CN', 'KP', 'RU']; // China, North Korea, Russia // Models that should be filtered in restricted regions const OPENAI_MODELS = [ 'scira-gpt-4.1', 'scira-gpt-4.1-mini', 'scira-gpt-4.1-nano', 'scira-gpt5', 'scira-gpt5-mini', 'scira-gpt5-nano', 'scira-gpt5-medium', 'scira-gpt5-codex', 'scira-gpt-5.1', 'scira-gpt-5.1-codex', 'scira-gpt-5.1-codex-mini', 'scira-gpt-5.1-codex-max', 'scira-gpt-5.1-thinking', 'scira-gpt-5.2', 'scira-gpt-5.4', 'scira-gpt-5.4-mini', 'scira-gpt-5.4-nano', 'scira-gpt-5.4-thinking', 'scira-gpt-5.4-thinking-xhigh', 'scira-gpt-5.2-thinking', 'scira-gpt-5.2-thinking-xhigh', 'scira-gpt-5.2-codex', 'scira-gpt-5.3-codex', 'scira-o3', 'scira-o4-mini', ]; const ANTHROPIC_MODELS = [ 'scira-haiku', 'scira-anthropic-small', 'scira-anthropic', 'scira-anthropic-think', 'scira-anthropic-opus', 'scira-anthropic-opus-think', 'scira-anthropic-sonnet-4.6', 'scira-anthropic-sonnet-4.6-think', 'scira-anthropic-opus-4.6', 'scira-anthropic-opus-4.6-think', ]; // Check if a model should be filtered based on region export function isModelRestrictedInRegion(modelValue: string, countryCode?: string): boolean { if (!countryCode) return false; const isRestricted = RESTRICTED_REGIONS.includes(countryCode.toUpperCase()); if (!isRestricted) return false; const isOpenAI = OPENAI_MODELS.includes(modelValue); const isAnthropic = ANTHROPIC_MODELS.includes(modelValue); return isOpenAI || isAnthropic; } // Filter models based on user's region export function getFilteredModels(countryCode?: string): Model[] { if (!countryCode || !RESTRICTED_REGIONS.includes(countryCode.toUpperCase())) { return models; } return models.filter((model) => !isModelRestrictedInRegion(model.value, countryCode)); } // Legacy arrays for backward compatibility (deprecated - use helper functions instead) export const authRequiredModels = models.filter((m) => m.requiresAuth).map((m) => m.value); export const proRequiredModels = models.filter((m) => m.pro).map((m) => m.value); export const freeUnlimitedModels = models.filter((m) => m.freeUnlimited).map((m) => m.value); // Helper function to derive provider from model value/label patterns export function getModelProvider(modelValue: string, label?: string): ModelProvider { const value = modelValue.toLowerCase(); const modelLabel = (label || '').toLowerCase(); // xAI (Grok) if ( value.includes('grok') || value.includes('scira-default') || (value.includes('scira-code') && !value.includes('codex')) ) { return 'xai'; } // OpenAI (GPT, o3, o4) if (value.includes('gpt') || value.includes('scira-o3') || value.includes('scira-o4')) { return 'openai'; } // Anthropic (Claude) if (value.includes('anthropic') || value.includes('haiku') || modelLabel.includes('claude')) { return 'anthropic'; } // Google (Gemini) if (value.includes('google') || value.includes('gemini')) { return 'google'; } // Alibaba (Qwen) if (value.includes('qwen')) { return 'alibaba'; } // Mistral (Mistral, Ministral, Magistral, Devstral, Leanstral) if ( value.includes('mistral') || value.includes('ministral') || value.includes('magistral') || value.includes('devstral') || value.includes('leanstral') ) { return 'mistral'; } // DeepSeek if (value.includes('deepseek')) { return 'deepseek'; } // Zhipu (GLM) if (value.includes('glm')) { return 'zhipu'; } // Cohere (Command) if (value.includes('cmd') || modelLabel.includes('command')) { return 'cohere'; } // MoonShot (Kimi) if (value.includes('kimi')) { return 'moonshot'; } // Minimax if (value.includes('minimax')) { return 'minimax'; } // ByteDance (Seed) if (value.includes('seed')) { return 'bytedance'; } // Arcee (Trinity) if (value.includes('trinity')) { return 'arcee'; } // Vercel (v0) if (value.includes('v0')) { return 'vercel'; } // Amazon (Nova) if (value.includes('nova')) { return 'amazon'; } // Xiaomi (Mimo) if (value.includes('mimo')) { return 'xiaomi'; } // Kwaipilot (KAT) if (value.includes('kat')) { return 'kwaipilot'; } // StepFun (Step) if (value.includes('step')) { return 'stepfun'; } // Sarvam if (value.includes('sarvam')) { return 'sarvam'; } // Inception (Mercury) if (value.includes('mercury')) { return 'inception'; } // Default fallback return 'openai'; } // Get provider info for a model export function getModelProviderInfo(modelValue: string): ProviderInfo { const model = getModelConfig(modelValue); const provider = model?.provider || getModelProvider(modelValue, model?.label); return PROVIDERS[provider]; } // Get all unique providers that have models export function getActiveProviders(): ProviderInfo[] { const providerSet = new Set(); for (const model of models) { const provider = model.provider || getModelProvider(model.value, model.label); providerSet.add(provider); } return Array.from(providerSet).map((p) => PROVIDERS[p]); } // Get models by provider export function getModelsByProvider(provider: ModelProvider): Model[] { return models.filter((m) => { const modelProvider = m.provider || getModelProvider(m.value, m.label); return modelProvider === provider; }); } ================================================ FILE: ai/providers.ts ================================================ import 'server-only'; import { wrapLanguageModel, customProvider, extractReasoningMiddleware, gateway } from 'ai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { createOpenAI } from '@ai-sdk/openai'; import { createWebSocketFetch } from 'ai-sdk-openai-websocket-fetch'; import { xai } from '@ai-sdk/xai'; import { groq } from '@ai-sdk/groq'; import { mistral } from '@ai-sdk/mistral'; import { google } from '@ai-sdk/google'; import { baseten } from '@ai-sdk/baseten'; import { anthropic } from '@ai-sdk/anthropic'; import { cohere } from '@ai-sdk/cohere'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createRetryable } from 'ai-retry'; import { createWorkersAI } from 'workers-ai-provider'; const ark = createOpenAICompatible({ name: 'ark', baseURL: 'https://ark.ap-southeast.bytepluses.com/api/v3', apiKey: process.env.ARK_API_KEY, }); const sarvam = createOpenAICompatible({ name: 'sarvam', baseURL: 'https://api.sarvam.ai/v1', apiKey: process.env.SARVAM_API_KEY, }); const zai = createOpenAICompatible({ name: 'zai', baseURL: 'https://api.z.ai/api/paas/v4', apiKey: process.env.ZAI_API_KEY, }); const middleware = extractReasoningMiddleware({ tagName: 'think', }); const middlewareWithStartWithReasoning = extractReasoningMiddleware({ tagName: 'think', startWithReasoning: true, }); const huggingface = createOpenAICompatible({ name: 'huggingface', baseURL: 'https://router.huggingface.co/v1', apiKey: process.env.HF_TOKEN, }); const novita = createOpenAICompatible({ name: 'novita', baseURL: 'https://api.novita.ai/openai', apiKey: process.env.NOVITA_API_KEY, }); const workersai = createWorkersAI({ accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, apiKey: process.env.CLOUDFLARE_API_TOKEN!, }); const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY, headers: { 'HTTP-Referer': 'https://sciraai.in', 'X-Title': 'Scira AI', 'Content-Type': 'application/json', }, }); const minimax = createOpenAICompatible({ name: 'minimax', baseURL: 'https://api.minimax.io/v1', apiKey: process.env.MINIMAX_API_KEY, }); const wsFetch = createWebSocketFetch(); const openai = createOpenAI({ fetch: wsFetch, }); const openai_2 = createOpenAI({ apiKey: process.env.OPENAI_API_KEY_2, fetch: wsFetch, }); export const scira = customProvider({ languageModels: { 'scira-arch-router': huggingface.chatModel('katanemo/Arch-Router-1.5B:hf-inference'), 'scira-default': xai('grok-4-1-fast-non-reasoning'), 'scira-auto': xai('grok-4-1-fast-non-reasoning'), 'scira-sarvam-105b': sarvam.chatModel('sarvam-105b'), 'scira-grok4.1-fast-thinking': xai('grok-4-1-fast-reasoning'), 'scira-ext-1': createRetryable({ model: xai('grok-4-1-fast-reasoning'), retries: [gateway('xai/grok-4.1-fast-reasoning')], }), 'scira-ext-2': createRetryable({ model: openai('gpt-5.4'), retries: [openai_2('gpt-5.4')], }), // 'scira-ext-3': gateway('anthropic/claude-sonnet-4.6'), 'scira-ext-4': createRetryable({ model: workersai('@cf/zai-org/glm-4.7-flash'), retries: [novita.chatModel('zai-org/glm-4.7-flash')], }), 'scira-ext-5': gateway('moonshotai/kimi-k2.5'), 'scira-ext-6': createRetryable({ model: google('gemini-3.1-pro-preview'), retries: [gateway('google/gemini-3.1-pro-preview')], }), 'scira-ext-7': gateway('alibaba/qwen3.5-flash'), 'scira-ext-8': xai('grok-4.20-experimental-beta-0304-non-reasoning'), 'scira-nano': groq('llama-3.3-70b-versatile'), 'scira-name': createRetryable({ model: gateway('google/gemini-2.5-flash-lite-preview-09-2025'), retries: [google('gemini-2.5-flash-lite-preview-09-2025'), google('gemini-2.5-flash-lite')], }), 'scira-grok-3-mini': xai('grok-3-mini'), 'scira-grok-3': xai('grok-3'), 'scira-grok-4': xai('grok-4'), 'scira-grok-4.20-experimental-beta-0304': xai('grok-4.20-non-reasoning-latest'), 'scira-grok-4.20-experimental-beta-0304-thinking': xai('grok-4.20-reasoning-latest'), 'scira-grok-4-fast': xai('grok-4-fast-non-reasoning'), 'scira-grok-4-fast-think': xai('grok-4-fast-reasoning'), 'scira-code': xai('grok-code-fast-1'), 'scira-enhance': groq('moonshotai/kimi-k2-instruct-0905'), 'scira-follow-up': createRetryable({ model: google('gemini-2.5-flash-lite-preview-09-2025'), retries: [ google('gemini-2.5-flash-lite'), gateway('google/gemini-2.5-flash-lite'), gateway('google/gemini-2.5-flash-lite-preview-09-2025'), ], }), 'scira-qwen-4b': huggingface.chatModel('Qwen/Qwen3-4B-Instruct-2507:nscale'), 'scira-qwen-4b-thinking': wrapLanguageModel({ model: huggingface.chatModel('Qwen/Qwen3-4B-Thinking-2507:nscale'), middleware: [middlewareWithStartWithReasoning], }), 'scira-gpt-4.1-nano': createRetryable({ model: openai('gpt-4.1-nano'), retries: [openai_2('gpt-4.1-nano')], }), 'scira-gpt-4.1-mini': createRetryable({ model: openai('gpt-4.1-mini'), retries: [openai_2('gpt-4.1-mini')], }), 'scira-gpt-4.1': createRetryable({ model: openai('gpt-4.1'), retries: [openai_2('gpt-4.1')], }), 'scira-gpt-5.1': createRetryable({ model: openai('gpt-5.1'), retries: [openai_2('gpt-5.1')], }), 'scira-gpt-5.1-thinking': createRetryable({ model: openai('gpt-5.1'), retries: [openai_2('gpt-5.1')], }), 'scira-gpt-5.2': createRetryable({ model: openai('gpt-5.2'), retries: [openai_2('gpt-5.2')], }), 'scira-gpt-5.3-chat-latest': createRetryable({ model: openai('gpt-5.3-chat-latest'), retries: [openai_2('gpt-5.3-chat-latest')], }), 'scira-gpt-5.4': createRetryable({ model: openai('gpt-5.4'), retries: [openai_2('gpt-5.4')], }), 'scira-gpt-5.4-mini': createRetryable({ model: openai('gpt-5.4-mini'), retries: [openai_2('gpt-5.4-mini')], }), 'scira-gpt-5.4-nano': createRetryable({ model: openai('gpt-5.4-nano'), retries: [openai_2('gpt-5.4-nano')], }), 'scira-gpt-5.4-thinking': createRetryable({ model: openai('gpt-5.4'), retries: [openai_2('gpt-5.4')], }), 'scira-gpt-5.4-thinking-xhigh': createRetryable({ model: openai('gpt-5.4'), retries: [openai_2('gpt-5.4')], }), 'scira-gpt-5.2-thinking': createRetryable({ model: openai('gpt-5.2'), retries: [openai_2('gpt-5.2')], }), 'scira-gpt-5.2-thinking-xhigh': createRetryable({ model: openai('gpt-5.2'), retries: [openai_2('gpt-5.2')], }), 'scira-gpt-5.1-codex': createRetryable({ model: openai('gpt-5.1-codex'), retries: [openai_2('gpt-5.1-codex')], }), 'scira-gpt-5.1-codex-mini': createRetryable({ model: openai('gpt-5.1-codex-mini'), retries: [openai_2('gpt-5.1-codex-mini')], }), 'scira-gpt-5.1-codex-max': createRetryable({ model: openai('gpt-5.1-codex-max'), retries: [openai_2('gpt-5.1-codex-max')], }), 'scira-gpt-5.2-codex': createRetryable({ model: openai('gpt-5.2-codex'), retries: [openai_2('gpt-5.2-codex')], }), 'scira-gpt-5.3-codex': createRetryable({ model: openai('gpt-5.3-codex'), retries: [openai_2('gpt-5.3-codex')], }), 'scira-gpt5': createRetryable({ model: openai('gpt-5'), retries: [openai_2('gpt-5')], }), 'scira-gpt5-medium': createRetryable({ model: openai('gpt-5'), retries: [openai_2('gpt-5')], }), 'scira-gpt5-mini': createRetryable({ model: openai('gpt-5-mini'), retries: [openai_2('gpt-5-mini')], }), 'scira-gpt5-nano': createRetryable({ model: openai('gpt-5-nano'), retries: [openai_2('gpt-5-nano')], }), 'scira-o3': createRetryable({ model: openai('o3'), retries: [openai_2('o3')], }), 'scira-o4-mini': createRetryable({ model: openai('o4-mini'), retries: [openai_2('o4-mini')], }), 'scira-gpt5-codex': createRetryable({ model: openai('gpt-5-codex'), retries: [openai_2('gpt-5-codex')], }), 'scira-qwen-32b': wrapLanguageModel({ model: groq('qwen/qwen3-32b'), middleware, }), 'scira-qwen-32b-thinking': wrapLanguageModel({ model: groq('qwen/qwen3-32b'), middleware, }), 'scira-gpt-oss-20': wrapLanguageModel({ model: groq('openai/gpt-oss-20b'), middleware, }), 'scira-nemotron-3-super': workersai('@cf/nvidia/nemotron-3-120b-a12b'), 'scira-gpt-oss-120': wrapLanguageModel({ model: baseten('openai/gpt-oss-120b'), middleware, }), 'scira-trinity-mini': wrapLanguageModel({ model: gateway('arcee-ai/trinity-mini'), middleware, }), 'scira-trinity-large': wrapLanguageModel({ model: openrouter('arcee-ai/trinity-large-preview:free'), middleware, }), 'scira-step-3.5-flash': openrouter('stepfun/step-3.5-flash:free'), 'scira-kat-coder': gateway('kwaipilot/kat-coder-pro-v1'), 'scira-deepseek-v3': baseten('deepseek-ai/DeepSeek-V3-0324'), 'scira-deepseek-v3.1-terminus': gateway('deepseek/deepseek-v3.1-terminus'), 'scira-deepseek-chat': gateway('deepseek/deepseek-v3.2'), 'scira-deepseek-chat-think': gateway('deepseek/deepseek-v3.2-thinking'), 'scira-deepseek-chat-exp': gateway('deepseek/deepseek-v3.2-exp'), 'scira-deepseek-chat-think-exp': wrapLanguageModel({ model: novita.chatModel('deepseek/deepseek-v3.2-exp'), middleware, }), 'scira-v0-10': gateway('vercel/v0-1.0-md'), 'scira-v0-15': gateway('vercel/v0-1.5-md'), 'scira-deepseek-r1': wrapLanguageModel({ model: novita.chatModel('deepseek/deepseek-r1-turbo'), middleware, }), 'scira-deepseek-r1-0528': wrapLanguageModel({ model: novita.chatModel('deepseek/deepseek-r1-0528'), middleware, }), 'scira-qwen-coder-small': gateway('alibaba/qwen3-coder-30b-a3b'), 'scira-qwen-coder': baseten('Qwen/Qwen3-Coder-480B-A35B-Instruct'), 'scira-qwen-coder-plus': gateway('alibaba/qwen3-coder-plus'), 'scira-qwen-coder-next': novita.chatModel('qwen/qwen3-coder-next'), 'scira-qwen-30': huggingface.chatModel('Qwen/Qwen3-30B-A3B-Instruct-2507:nebius'), 'scira-qwen-30-think': wrapLanguageModel({ model: huggingface.chatModel('Qwen/Qwen3-30B-A3B-Thinking-2507:nebius'), middleware, }), 'scira-qwen-3-vl-30b': novita.chatModel('qwen/qwen3-vl-30b-a3b-instruct'), 'scira-qwen-3-vl-30b-thinking': wrapLanguageModel({ model: novita.chatModel('qwen/qwen3-vl-30b-a3b-thinking'), middleware, }), 'scira-qwen-3-next': huggingface.chatModel('Qwen/Qwen3-Next-80B-A3B-Instruct:hyperbolic'), 'scira-qwen-3-next-think': wrapLanguageModel({ model: huggingface.chatModel('Qwen/Qwen3-Next-80B-A3B-Thinking:hyperbolic'), middleware: [middlewareWithStartWithReasoning], }), 'scira-qwen-3-max': gateway('alibaba/qwen3-max'), 'scira-qwen-3-max-preview': gateway('alibaba/qwen3-max-preview'), 'scira-qwen-3-max-preview-thinking': gateway('alibaba/qwen3-max-thinking'), 'scira-qwen-235': gateway('alibaba/qwen-3-235b'), 'scira-qwen-235-think': wrapLanguageModel({ model: huggingface.chatModel('Qwen/Qwen3-235B-A22B-Thinking-2507:fireworks-ai'), middleware: [middlewareWithStartWithReasoning], }), 'scira-qwen-3.5-27b': novita.chatModel('qwen/qwen3.5-27b'), 'scira-qwen-3.5-35b': novita.chatModel('qwen/qwen3.5-35b-a3b'), 'scira-qwen-3.5-122b': novita.chatModel('qwen/qwen3.5-122b-a10b'), 'scira-qwen-3.5': novita.chatModel('qwen/qwen3.5-397b-a17b'), 'scira-qwen-3.5-plus': gateway('alibaba/qwen3.5-plus'), 'scira-qwen-3.5-flash': gateway('alibaba/qwen3.5-flash'), 'scira-qwen-3-vl': gateway('alibaba/qwen3-vl-instruct'), 'scira-qwen-3-vl-thinking': wrapLanguageModel({ model: gateway('alibaba/qwen3-vl-thinking'), middleware, }), 'scira-glm-air': gateway('zai/glm-4.5-air'), 'scira-glm': wrapLanguageModel({ model: gateway('zai/glm-4.5'), middleware, }), 'scira-glm-4.6': wrapLanguageModel({ model: huggingface.chatModel('zai-org/GLM-4.6:zai-org'), middleware, }), 'scira-glm-4.6v-flash': wrapLanguageModel({ model: huggingface.chatModel('zai-org/GLM-4.6V-Flash:zai-org'), middleware, }), 'scira-glm-4.6v': wrapLanguageModel({ model: huggingface.chatModel('zai-org/GLM-4.6V:zai-org'), middleware, }), 'scira-glm-4.7': wrapLanguageModel({ model: huggingface.chatModel('zai-org/GLM-4.7:novita'), middleware, }), 'scira-glm-4.7-flash': createRetryable({ model: novita.chatModel('zai-org/glm-4.7-flash'), retries: [gateway('zai/glm-4.7-flashx')], }), 'scira-glm-5': wrapLanguageModel({ model: zai('glm-5-turbo'), middleware, }), 'scira-glm-5-thinking': wrapLanguageModel({ model: zai('glm-5-turbo'), middleware, }), 'scira-minimax': wrapLanguageModel({ model: novita.chatModel('minimaxai/minimax-m1-80k'), middleware, }), 'scira-minimax-m2': wrapLanguageModel({ model: gateway('minimax/minimax-m2'), middleware, }), 'scira-minimax-m2.1': wrapLanguageModel({ model: gateway('minimax/minimax-m2.1'), middleware, }), 'scira-minimax-m2.1-lightning': wrapLanguageModel({ model: gateway('minimax/minimax-m2.1-lightning'), middleware, }), 'scira-minimax-m2.7': wrapLanguageModel({ model: minimax.chatModel('MiniMax-M2.7-highspeed'), middleware, }), 'scira-minimax-m2.5': createRetryable({ model: baseten.chatModel('MiniMaxAI/MiniMax-M2.5'), retries: [ minimax.chatModel('MiniMax-M2.5-highspeed'), novita.chatModel('minimax/minimax-m2.5'), gateway('minimax/minimax-m2.5'), ], }), 'scira-cmd-a': cohere('command-a-03-2025'), 'scira-cmd-a-think': cohere('command-a-reasoning-08-2025'), 'scira-kimi-k2-v2': groq('moonshotai/kimi-k2-instruct-0905'), 'scira-kimi-k2-v2-thinking': wrapLanguageModel({ model: gateway('moonshotai/kimi-k2-thinking-turbo'), middleware, }), 'scira-kimi-k2.5': createRetryable({ model: baseten.chatModel('moonshotai/Kimi-K2.5'), retries: [gateway('moonshotai/kimi-k2.5')], }), 'scira-kimi-k2.5-thinking': gateway('moonshotai/kimi-k2.5'), 'scira-ministral-3b': mistral('ministral-3b-2512'), 'scira-ministral-8b': mistral('ministral-8b-2512'), 'scira-ministral-14b': mistral('ministral-14b-2512'), 'scira-mistral-large': mistral('mistral-large-2512'), 'scira-mistral-medium': mistral('mistral-medium-2508'), 'scira-magistral-small': mistral('magistral-small-2509'), 'scira-magistral-medium': mistral('magistral-medium-2509'), 'scira-mistral-small': mistral('mistral-small-2603'), 'scira-mistral-small-think': mistral('mistral-small-2603'), 'scira-leanstral': mistral('labs-leanstral-2603'), 'scira-devstral': mistral('devstral-2512'), 'scira-devstral-small': mistral('labs-devstral-small-2512'), 'scira-google-lite': google('gemini-flash-lite-latest'), 'scira-google': google('gemini-flash-latest'), 'scira-google-think': google('gemini-flash-latest'), 'scira-google-pro': createRetryable({ model: google('gemini-2.5-pro'), retries: [gateway('google/gemini-2.5-pro')], }), 'scira-google-pro-think': createRetryable({ model: google('gemini-2.5-pro'), retries: [gateway('google/gemini-2.5-pro')], }), 'scira-gemini-3-flash': createRetryable({ model: google('gemini-3-flash-preview'), retries: [gateway('google/gemini-3-flash')], }), 'scira-gemini-3-flash-think': google('gemini-3-flash-preview'), 'scira-gemini-3.1-flash-lite': createRetryable({ model: gateway('google/gemini-3.1-flash-lite-preview'), retries: [google('gemini-3.1-flash-lite-preview')], }), 'scira-gemini-3.1-flash-lite-think': createRetryable({ model: gateway('google/gemini-3.1-flash-lite-preview'), retries: [google('gemini-3.1-flash-lite-preview')], }), 'scira-gemini-3.1-pro': createRetryable({ model: google('gemini-3.1-pro-preview'), retries: [gateway('google/gemini-3.1-pro-preview')], }), 'scira-anthropic-small': anthropic('claude-haiku-4-5'), 'scira-anthropic': anthropic('claude-sonnet-4-5'), 'scira-anthropic-think': anthropic('claude-sonnet-4-5'), 'scira-anthropic-sonnet-4.6': anthropic('claude-sonnet-4-6'), 'scira-anthropic-sonnet-4.6-think': anthropic('claude-sonnet-4-6'), 'scira-mimo-v2-flash': wrapLanguageModel({ model: gateway('xiaomi/mimo-v2-flash'), middleware, }), 'scira-mimo-v2-pro': wrapLanguageModel({ model: gateway('xiaomi/mimo-v2-pro'), middleware, }), 'scira-anthropic-opus': anthropic('claude-opus-4-5'), 'scira-anthropic-opus-think': anthropic('claude-opus-4-5'), 'scira-anthropic-opus-4.6': anthropic('claude-opus-4-6'), 'scira-anthropic-opus-4.6-think': anthropic('claude-opus-4-6'), 'scira-nova-2-lite': gateway('amazon/nova-2-lite'), 'scira-seed-1.6': wrapLanguageModel({ model: ark('seed-1-6-250915'), middleware, }), 'scira-seed-1.8': wrapLanguageModel({ model: ark('seed-1-8-251228'), middleware, }), 'scira-seed-2.0-mini': wrapLanguageModel({ model: ark('seed-2-0-mini-260215'), middleware, }), 'scira-seed-2.0-lite': ark('seed-2-0-lite-260228'), 'scira-seed-1.6-flash': wrapLanguageModel({ model: ark('seed-1-6-flash-250715'), middleware, }), 'scira-mercury-2': gateway('inception/mercury-2'), }, }); // Re-export all model data and pure helpers from the client-safe models module export type { ModelProvider, ProviderInfo, Model } from './models'; export { PROVIDERS, models, getModelConfig, requiresAuthentication, requiresProSubscription, requiresMaxSubscription, isFreeUnlimited, hasVisionSupport, hasPdfSupport, hasReasoningSupport, isExperimentalModel, getMaxOutputTokens, getModelParameters, canUseModel, shouldBypassRateLimits, getAcceptedFileTypes, supportsExtremeMode, getExtremeModels, supportsCanvasMode, isModelRestrictedInRegion, getFilteredModels, authRequiredModels, proRequiredModels, freeUnlimitedModels, getModelProvider, getModelProviderInfo, getActiveProviders, getModelsByProvider, } from './models'; ================================================ FILE: app/(auth)/layout.tsx ================================================ 'use client'; import Link from 'next/link'; import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '@/components/ui/carousel'; import { useState, useEffect } from 'react'; import Autoplay from 'embla-carousel-autoplay'; import { SciraLogo } from '@/components/logos/scira-logo'; import { Brain, Search, Eye, Mic, Blocks } from 'lucide-react'; const testimonials = [ { content: 'Scira is better than Grok at digging up information from X, its own platform! I asked it 3 different queries to help scrape and find some data points I was interested in about my own account and Scira did much much better with insanely accurate answers!', author: 'Chris Universe', handle: '@chrisuniverseb', link: 'https://x.com/chrisuniverseb/status/1943025911043100835', }, { content: 'Scira does a really good job scraping through the reddit mines.', author: 'nyaaier', handle: '@nyaaier', link: 'https://x.com/nyaaier/status/1932810453107065284', }, { content: "I searched for myself using Gemini 2.5 Pro in extreme mode to see what results it could generate. It is not just the best, it is wild. And the best part is it's 100% accurate.", author: 'Aniruddha Dak', handle: '@aniruddhadak', link: 'https://x.com/aniruddhadak/status/1917140602107445545', }, { content: 'Read nothing the whole sem and here I am with Scira to top my mid sems! Literally so good to get all the related diagrams, points and topics from the website my professor uses.', author: 'Rajnandinit', handle: '@itsRajnandinit', link: 'https://x.com/itsRajnandinit/status/1897896134837682288', }, ]; const features = [ { icon: Brain, label: 'Agentic Planning', description: 'Multi-step research, automated' }, { icon: Search, label: 'Cited Answers', description: 'Every claim linked to a source' }, { icon: Eye, label: 'Lookouts', description: 'Scheduled research, auto-delivered' }, { icon: Mic, label: 'Voice Mode', description: 'Conversational AI research' }, { icon: Blocks, label: 'Apps', description: '100+ connected tools via MCP' }, ]; export default function AuthLayout({ children }: { children: React.ReactNode }) { const [api, setApi] = useState(); const [current, setCurrent] = useState(0); useEffect(() => { if (!api) return; setCurrent(api.selectedScrollSnap()); api.on('select', () => { setCurrent(api.selectedScrollSnap()); }); }, [api]); return (
{/* Left Panel */}
{/* Background */}
{/* Content */}
{/* Logo */} scira {/* Tagline */}

Research anything.
Do anything.

Deep web research, cited answers, and 100+ connected apps. One assistant for everything you need.

{/* Feature Pills */}
{features.map((f) => (

{f.label}

{f.description}

))}
{/* Testimonial Carousel */}
{testimonials.map((testimonial, index) => (
“{testimonial.content}”
{testimonial.author} {testimonial.handle}
))}
{/* Indicators */}
{testimonials.map((_, index) => (
{/* Bottom Stats */}
{[ { num: '5M+', label: 'searches' }, { num: '100K+', label: 'users' }, { num: '11K+', label: 'stars' }, ].map((s, i) => (
{i > 0 && } {s.num} {s.label}
))}
{/* Right Panel - Auth Form */}
{/* Mobile Header */}
scira
5M+ searches 100K+ users
{/* Form Container */}
{children}
{/* Footer */}
Trusted by researchers worldwide About Terms
); } ================================================ FILE: app/(auth)/sign-in/page.tsx ================================================ import { Suspense } from 'react'; import AuthCard from '@/components/auth-card'; function SignInContent() { return ( ); } export default function SignInPage() { return ( ); } ================================================ FILE: app/(auth)/sign-up/page.tsx ================================================ import { Suspense } from 'react'; import AuthCard from '@/components/auth-card'; function SignUpContent() { return ( ); } export default function SignUpPage() { return ( ); } ================================================ FILE: app/(content)/about/page.tsx ================================================ 'use client'; import { Brain, Search, ArrowUpRight, ArrowRight, Bot, GraduationCap, Eye, Filter, X, Sparkles, Check, Quote, Globe, FileText, Mic, Code, BarChart3, Newspaper, BookOpen, Music, TrendingUp, MessageSquare, Bitcoin, Plug, Database, Headphones, ChartNoAxesCombined, } from 'lucide-react'; import { AnimatedBeam } from '@/components/ui/animated-beam'; import Link from 'next/link'; import Image from 'next/image'; import React, { useState, useMemo, useEffect, useRef } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { useRouter } from 'next/navigation'; import { GithubLogoIcon, XLogoIcon } from '@phosphor-icons/react'; import { ProAccordion, ProAccordionItem, ProAccordionTrigger, ProAccordionContent, } from '@/components/ui/pro-accordion'; import { useGitHubStars } from '@/hooks/use-github-stars'; import { models } from '@/ai/models'; import { VercelLogo } from '@/components/logos/vercel-logo'; import { ExaLogo } from '@/components/logos/exa-logo'; import { ElevenLabsLogo } from '@/components/logos/elevenlabs-logo'; import { PRICING, SEARCH_LIMITS } from '@/lib/constants'; import { ThemeSwitcher } from '@/components/theme-switcher'; import { SciraLogo } from '@/components/logos/scira-logo'; import { getSearchGroups } from '@/lib/utils'; const testimonials = [ { content: 'Scira is better than Grok at digging up information from X, its own platform! Scira did much much better with insanely accurate answers!', author: 'Chris Universe', handle: '@chrisuniverseb', }, { content: 'Scira does a really good job scraping through the reddit mines.', author: 'nyaaier', handle: '@nyaaier', }, { content: "I searched for myself using Gemini 2.5 Pro in extreme mode. It is not just the best, it is wild. And the best part is it's 100% accurate.", author: 'Aniruddha Dak', handle: '@aniruddhadak', }, { content: 'Read nothing the whole sem and here I am with Scira to top my mid sems! Literally so good to get all the related diagrams, points and topics.', author: 'Rajnandinit', handle: '@itsRajnandinit', }, ]; function AnimatedCounter({ target, suffix = '' }: { target: string; suffix?: string }) { const ref = useRef(null); const [visible, setVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) setVisible(true); }, { threshold: 0.5 }, ); if (ref.current) observer.observe(ref.current); return () => observer.disconnect(); }, []); return ( {target} {suffix} ); } const AppCircle = React.forwardRef( ({ favicon, label, className }, ref) => (
{/* eslint-disable-next-line @next/next/no-img-element */} {label}
), ); AppCircle.displayName = 'AppCircle'; function AppsBeamSection() { const containerRef = useRef(null); const centerRef = useRef(null); const l0 = useRef(null); const l1 = useRef(null); const l2 = useRef(null); const l3 = useRef(null); const l4 = useRef(null); const r0 = useRef(null); const r1 = useRef(null); const r2 = useRef(null); const r3 = useRef(null); const r4 = useRef(null); return (
Apps

Your tools, connected.

Connect 100+ apps via MCP and let Scira take action inside them. Research and act, without leaving the conversation.

{/* Row 1 — top */}
{/* Row 2 */}
{/* Row 3 — center */}
{/* Row 4 */}
{/* Row 5 — bottom */}
{/* Left beams */} {/* Right beams */}
Browse all apps
); } export default function AboutPage() { const router = useRouter(); const [selectedCategory, setSelectedCategory] = useState('all'); const [selectedCapabilities, setSelectedCapabilities] = useState([]); const [openCategory, setOpenCategory] = useState(false); const [openCapabilities, setOpenCapabilities] = useState(false); const [showAllModels, setShowAllModels] = useState(false); const { data: githubStars, isLoading: isLoadingStars } = useGitHubStars(); const visibleGroups = useMemo( () => getSearchGroups('parallel').filter( (g) => g.show && !['extreme', 'connectors', 'memory'].includes(g.id as string), ), [], ); const [selectedGroup, setSelectedGroup] = useState(visibleGroups[0]?.id || 'web'); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); const query = formData.get('query')?.toString(); if (query) { const params = new URLSearchParams({ q: query, group: String(selectedGroup) }); router.push(`/?${params.toString()}`); } }; const searchModes = [ { icon: Globe, name: 'Web', description: 'Search the entire web with AI-powered analysis' }, { icon: MessageSquare, name: 'Chat', description: 'Talk to the model directly, no search' }, { icon: XLogoIcon, name: 'X', description: 'Real-time posts, trends, and conversations' }, { icon: TrendingUp, name: 'Stocks', description: 'Market data, charts, and financial analysis' }, { icon: Code, name: 'Code', description: 'Get context about languages and frameworks' }, { icon: BookOpen, name: 'Academic', description: 'Research papers, citations, and scholarly sources' }, { icon: BarChart3, name: 'Extreme', description: 'Deep research with multiple sources and analysis' }, { icon: Newspaper, name: 'Reddit', description: 'Discussions, opinions, and community insights' }, { icon: Search, name: 'GitHub', description: 'Repositories, code, and developer discussions' }, { icon: Bitcoin, name: 'Crypto', description: 'Cryptocurrency research powered by CoinGecko' }, { icon: ChartNoAxesCombined, name: 'Prediction', description: 'Prediction markets from Polymarket and Kalshi' }, { icon: Music, name: 'YouTube', description: 'Video summaries, transcripts, and analysis' }, { icon: Headphones, name: 'Spotify', description: 'Search songs, artists, and albums' }, { icon: Plug, name: 'Connectors', description: 'Search Google Drive, Notion & OneDrive', pro: true }, { icon: Database, name: 'Memory', description: 'Your personal memory companion', pro: true }, { icon: Mic, name: 'Voice', description: 'Conversational AI with real-time voice', pro: true }, { icon: FileText, name: 'XQL', description: 'Advanced X query language for tweet analysis', pro: true }, ]; return (
{/* Navigation */}
scira
{/* Hero Section */}
{/* Eyebrow */}
Open Source AGPL-3.0
{/* Title */}

Research anything.

Do anything.

{/* Description */}

The AI assistant that searches the web in depth, cites its sources, and connects to 100+ apps so you can act on what you find.

{/* Search Form */}
No mode found. {visibleGroups.map((g) => ( setSelectedGroup(g.id)} className="text-sm" >
{g.name} {g.description}
))}
{/* Quick Links */}
Star on GitHub {!isLoadingStars && githubStars && ( {githubStars > 1000 ? `${(githubStars / 1000).toFixed(1)}k` : githubStars} )} Try now
{/* Stats */}
{[ { value: '5M', suffix: '+', label: 'Searches' }, { value: '100K', suffix: '+', label: 'Users' }, { value: isLoadingStars ? '...' : `${githubStars && githubStars > 1000 ? `${(githubStars / 1000).toFixed(1)}k` : githubStars || '11k'}`, suffix: '+', label: 'Stars', }, ].map((stat) => (
{stat.label}
))}
{/* How It Works */}
How it works

Three steps. Zero friction.

{[ { step: '01', title: 'Ask anything', description: 'Type a question, upload a PDF, or paste a URL. Pick a mode or let Scira decide for you.', }, { step: '02', title: 'Scira plans & retrieves', description: 'The agent breaks your question into sub-tasks, searches live sources, and cross-checks the evidence.', }, { step: '03', title: 'Get cited answers', description: 'Receive a grounded answer with inline citations. Click any source to verify it yourself.', }, ].map((item) => (
{item.step}

{item.title}

{item.description}

))}
{/* CTA */}
{/* Features - Bento Grid */}
Capabilities

Built for the way you think

Research, plan, connect, and act. Everything in one place.

{/* Bento Grid */}
{/* Large card - Agentic Planning */}
01

Agentic Planning

Breaks complex questions into steps, selects the right models and tools, then executes multi-step workflows end to end. Ask one question, get a research report.

{['Multi-step reasoning', 'Tool orchestration', 'Auto-planning'].map((tag) => ( {tag} ))}
{/* Small card - Grounded Retrieval */}
02

Grounded Retrieval

Every answer comes with inline citations. Click any source to audit the evidence yourself.

{/* Small card - Extensible & Open */}
03

Extensible & Open

AGPL-3.0. Self-host, bring your own models, connect custom tools, and tailor everything to your workflow.

{/* Large card - Lookouts */}
04

Lookouts

Schedule recurring research agents that monitor topics, track changes, and email you updates. Set it once, stay informed forever.

{['Scheduled runs', 'Email alerts', 'Change detection'].map((tag) => ( {tag} ))}
{/* Search Modes Showcase */}
{searchModes.length} Modes

One box, every source

Each mode is fine-tuned for a specific type of research. Pick one, or let Scira choose.

{searchModes.filter((m) => !('pro' in m && m.pro)).length} Free {searchModes.filter((m) => 'pro' in m && m.pro).length} Pro
{searchModes.map((mode, i) => (
{'pro' in mode && mode.pro ? ( Pro ) : ( {String(i + 1).padStart(2, '0')} )}

{mode.name}

{mode.description}

))}
{/* Apps Section */} {/* Testimonials */}
Wall of Love

Don't take our word for it

{testimonials.map((t) => (

{t.content}

{t.author} {t.handle}
))}
{/* Social Proof Marquee */}
Tiny Startups

#1 Product

Tiny Startups

Peerlist

#1 Project

Peerlist

Scira badge
{/* Built With */}
Built with
{[ { logo: VercelLogo, name: 'Vercel AI SDK' }, { logo: ExaLogo, name: 'Exa Search' }, { logo: ElevenLabsLogo, name: 'ElevenLabs' }, ].map((partner) => (
{partner.name}
))}
{/* Featured on Vercel */}
{/* Decorative number */} V
Press

Featured on

Vercel Blog

Recognized for innovative use of AI technology and pushing the boundaries of what's possible with the Vercel AI SDK.

Read the feature
Featured on Vercel Blog
{/* Inline CTA */}

Ready to think faster?

Join 100K+ users who research smarter and get things done with Scira.

{/* Models Section */}
AI Providers

Every model, one place

Switch between models on the fly. Use the best tool for each question.

{models.length}
{/* Filter Controls */}
No category found. {[ { value: 'all', label: 'All Categories' }, { value: 'Free', label: 'Free' }, { value: 'Pro', label: 'Pro' }, { value: 'Experimental', label: 'Experimental' }, ].map((category) => ( { setSelectedCategory(v); setOpenCategory(false); }} > {category.label} ))} No capability found. {[ { value: 'vision', label: 'Vision' }, { value: 'reasoning', label: 'Reasoning' }, { value: 'pdf', label: 'PDF' }, ].map((capability) => ( { setSelectedCapabilities((prev) => prev.includes(v) ? prev.filter((item) => item !== v) : [...prev, v], ); }} >
{capability.label}
))} {(selectedCategory !== 'all' || selectedCapabilities.length > 0) && ( )}
{/* Models Grid */}
{(() => { const filteredModels = models.filter((model) => { const categoryMatch = selectedCategory === 'all' || model.category === selectedCategory; const capabilityMatch = selectedCapabilities.length === 0 || selectedCapabilities.some((c) => { if (c === 'vision') return model.vision; if (c === 'reasoning') return model.reasoning; if (c === 'pdf') return model.pdf; return false; }); return categoryMatch && capabilityMatch; }); const groupedModels = filteredModels.reduce( (acc, model) => { const category = model.category; if (!acc[category]) acc[category] = []; acc[category].push(model); return acc; }, {} as Record, ); const sortedModels = ['Free', 'Experimental', 'Pro'] .filter((c) => groupedModels[c]?.length > 0) .flatMap((c) => groupedModels[c]); if (sortedModels.length === 0) { return (

No models match your filters

); } const modelsToShow = showAllModels ? sortedModels : sortedModels.slice(0, 9); return ( <> {modelsToShow.map((model: any) => (

{model.label}

{model.category}

{model.description}

{model.vision && ( Vision )} {model.reasoning && ( Reasoning )} {model.pdf && ( PDF )} {model.fast && ( Fast )} {model.isNew && ( New )}
))} {sortedModels.length > 9 && (
)} ); })()}
{/* Pricing Section */}
Plans

Simple, honest pricing

Start free. Upgrade when you need unlimited power.

Free

Get started with the essentials

$0 /month
    {[ `${SEARCH_LIMITS.DAILY_SEARCH_LIMIT} research runs per day`, 'Basic AI models', 'Research history', ].map((item) => (
  • {item}
  • ))}

Pro

Popular

Everything for serious research

${PRICING.PRO_MONTHLY} /month

Less than a coffee a day

    {[ 'Unlimited research', 'All standard AI models', 'Scira Apps (100+ integrations)', 'PDF analysis', 'Voice mode', 'XQL (X Query Language)', 'Scira Lookout', 'Priority support', ].map((item) => (
  • {item}
  • ))}

Max

All paid features + Anthropic Claude models

$60 /month

For Anthropic Sonnet, Opus, and Thinking models

    {[ 'All paid features', 'Claude Sonnet models', 'Claude Opus models', 'Thinking variants', 'Canvas support for Max models', 'Priority support', ].map((item) => (
  • {item}
  • ))}

Student discount

Get Pro for $5/month with a university email. Student pricing applies to Pro only.

{/* FAQ Section */}
Support

Questions?
Answers.

Can't find what you need? Reach out at{' '} zaid@scira.ai

What is Scira? Scira is an open-source AI assistant built for research and action. It searches the web in depth, cites its sources, and connects to 100+ apps via MCP so you can act on what you find without leaving the conversation. What's the difference between Free, Pro, and Max? Free includes limited daily research runs with essential models. Pro ($15/month) unlocks unlimited research, standard paid models, PDF analysis, Lookout automations, and priority support. Max ($60/month) includes all paid features plus Anthropic Claude Sonnet, Opus, and Thinking models. Is there a student discount? Yes! Students with university emails (.edu, .ac.in, .ac.uk, etc.) automatically get Pro for just $5/month — that's $120 saved per year. Applied automatically at checkout. Can I cancel anytime? Yes, cancel any time. Your benefits continue until the end of your billing period. What AI models does Scira use? Scira uses a range of advanced models including Grok, Claude, GPT, Gemini, and more. Switch between them for each query based on what works best. How does Scira ensure accuracy? Scira grounds outputs in retrieved sources (RAG + search grounding) and includes inline citations so you can audit the evidence. Agents cross-check multiple sources before synthesizing an answer.
{/* Footer */}
© {new Date().getFullYear()} Scira
Terms Privacy
); } ================================================ FILE: app/(content)/layout.tsx ================================================ import React from 'react'; export default function ContentLayout({ children }: { children: React.ReactNode }) { return
{children}
; } ================================================ FILE: app/(content)/privacy-policy/page.tsx ================================================ import type { Metadata } from 'next'; import Link from 'next/link'; import { ArrowLeft, Clock, Shield, ArrowUpRight } from 'lucide-react'; import { SciraLogo } from '@/components/logos/scira-logo'; export const metadata: Metadata = { title: 'Privacy Policy', description: 'Scira AI Privacy Policy — how we collect, use, and protect your personal data.', alternates: { canonical: 'https://scira.ai/privacy-policy', }, robots: { index: true, follow: true, }, }; const sections = [ { id: 'info-collect', label: 'Information Collected' }, { id: 'how-use', label: 'How We Use It' }, { id: 'sharing', label: 'Data Sharing' }, { id: 'security', label: 'Data Security' }, { id: 'rights', label: 'Your Rights' }, { id: 'children', label: "Children's Privacy" }, { id: 'retention', label: 'Data Retention' }, { id: 'changes', label: 'Changes' }, { id: 'contact', label: 'Contact' }, ]; export default function PrivacyPage() { return (
{/* Header */}
scira
Terms of Service Back
{/* Title */}
Legal

Privacy Policy

Last updated: July 24, 2025 5 min read
{/* TLDR */}
Quick Summary

We collect search queries, usage data, and account info to run the service. We never store payment card details — those go directly to our payment processors. We don't sell your data. You can request deletion of your data anytime by emailing us.

{/* Content */}

At Scira AI, we respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, and safeguard your information when you use our AI-powered research and app integration platform.

01Information We Collect

We may collect the following types of information:

  • Search Queries: The questions and searches you submit to our platform.
  • Usage Data: Information about how you interact with our service, including features used and time spent.
  • Device Information: Information about your device, browser type, IP address, and operating system.
  • Account Information: Email address and profile information when you create an account.
  • Subscription Data: Information about your subscription status and payment history (but not payment details).
  • Cookies and Similar Technologies: We use cookies and similar tracking technologies to enhance your experience.

Important Note on Payment Data: Scira AI does not collect, store, or process any payment card details, bank information, UPI details, or other sensitive payment data. All payment information is handled directly by our payment processors (Polar and DodoPayments).

02How We Use Your Information

We use your information for the following purposes:

  • To provide and improve our search service
  • To understand how users interact with our platform
  • To personalize and enhance your experience
  • To monitor and analyze usage patterns and trends
  • To detect, prevent, and address technical issues

03Data Sharing and Disclosure

We may share your information in the following circumstances:

  • Service Providers: With third-party service providers who help us operate and improve our service, including:
    • Vercel: Our hosting and infrastructure provider
    • AI Processing Partners: OpenAI, Anthropic, xAI, and others for processing search queries
    • Payment Processors: Polar and DodoPayments for billing and subscription management
  • Compliance with Laws: When required by applicable law, regulation, or legal process.
  • Business Transfers: In connection with a merger, acquisition, or sale of assets.

04Data Security

We implement appropriate technical and organizational measures to protect your personal information. However, no method of transmission over the Internet or electronic storage is 100% secure, and we cannot guarantee absolute security.

05Your Rights

Depending on your location, you may have the right to:

  • Access the personal information we hold about you
  • Request correction or deletion of your personal information
  • Object to or restrict certain processing activities
  • Data portability
  • Withdraw consent where applicable

06Children's Privacy

Our service is not directed to children under the age of 13. We do not knowingly collect personal information from children under 13. If you are a parent or guardian and believe your child has provided us with personal information, please contact us.

07Data Retention & Deletion

We retain your personal information for as long as necessary to provide our services and fulfil the purposes outlined in this Privacy Policy, unless a longer retention period is required or permitted by law. When the applicable retention period expires, we will securely delete or anonymize your data.

You may request deletion of your personal data at any time by emailing{' '} zaid@scira.ai. We will action deletion requests within 30 days, except where we are required to retain data for legal or compliance reasons.

08Changes to This Privacy Policy

We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.

09Contact Us

If you have any questions about this Privacy Policy, please contact us at:

zaid@scira.ai

{/* Agreement Note */}

By using Scira AI, you agree to our Privacy Policy and our{' '} Terms of Service.

Read Terms
{/* Sidebar - Table of Contents */}
{/* Footer */}
© {new Date().getFullYear()} Scira
Home About Terms Privacy
); } ================================================ FILE: app/(content)/terms/page.tsx ================================================ import Link from 'next/link'; import { ArrowLeft, Clock, FileText, ArrowUpRight } from 'lucide-react'; import { SciraLogo } from '@/components/logos/scira-logo'; const sections = [ { id: 'acceptance', label: 'Acceptance' }, { id: 'service', label: 'Service' }, { id: 'conduct', label: 'User Conduct' }, { id: 'content', label: 'Content' }, { id: 'ip', label: 'IP' }, { id: 'third-party', label: 'Third-Party' }, { id: 'pricing', label: 'Pricing' }, { id: 'cancellation', label: 'Cancellation' }, { id: 'privacy', label: 'Privacy' }, { id: 'liability', label: 'Liability' }, ]; export default function TermsPage() { return (
{/* Header */}
scira
Privacy Policy Back
{/* Title */}
Legal

Terms of Service

Last updated: March 16, 2026 8 min read
{/* TLDR */}
Quick Summary

Scira AI is free to use with optional paid plans including Pro at $15/mo and Max at $60/mo. Max includes access to Anthropic Claude models with a 60 requests per week usage cap. We don't store payment data. You own your queries. Be respectful, don't scrape, and verify important answers independently. Cancel anytime; no refunds on subscriptions.

{/* Content */}

Welcome to Scira AI. These Terms of Service govern your use of our website and services. By using Scira AI, you agree to these terms in full. If you disagree with any part of these terms, please do not use our service.

01Acceptance of Terms

By accessing or using Scira AI, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service. We reserve the right to modify these terms at any time, and such modifications shall be effective immediately upon posting. Your continued use of Scira AI after any modifications indicates your acceptance of the modified terms.

02Description of Service

Scira AI is an AI assistant that helps users research information on the internet and take action through connected third-party apps. Our service utilizes artificial intelligence to process search queries, provide relevant results, and interact with external services via the Model Context Protocol (MCP).

Our service is hosted on Vercel and integrates with various AI technology providers, including OpenAI, Anthropic, xAI, and others, to deliver search results and content generation capabilities. Pro users may also connect third-party apps (such as GitHub, Notion, Slack, and others) via MCP to extend functionality.

03User Conduct

You agree not to use Scira AI to:

  • Engage in any activity that violates applicable laws or regulations
  • Infringe upon the rights of others, including intellectual property rights
  • Distribute malware, viruses, or other harmful computer code
  • Attempt to gain unauthorized access to our systems or networks
  • Conduct automated queries or scrape our service
  • Generate or distribute illegal, harmful, or offensive content
  • Interfere with the proper functioning of the service

04Content and Results

While we strive to provide accurate and reliable information, Scira AI:

  • Does not guarantee the accuracy, completeness, or reliability of any results
  • Is not responsible for content generated based on your search queries
  • May provide links to third-party websites over which we have no control

You should exercise judgment and critical thinking when evaluating search results and generated content. Scira AI should not be used as the sole source for making important decisions, especially in professional, medical, legal, or financial contexts.

05Intellectual Property

All content, features, and functionality of Scira AI, including but not limited to text, graphics, logos, icons, images, audio clips, and software, are the property of Scira AI or its licensors and are protected by copyright, trademark, and other intellectual property laws.

You may not copy, modify, distribute, sell, or lease any part of our service or included software without explicit permission.

06Third-Party Services

Scira AI relies on third-party services to provide its functionality:

  • Our service is hosted on Vercel's infrastructure
  • We integrate with AI technology providers including OpenAI, Anthropic, xAI, and others
  • Pro users may connect third-party apps (GitHub, Notion, Slack, etc.) via MCP, which may transmit data to those services
  • We use payment processors including Polar and DodoPayments for billing and subscription management
  • These third-party services have their own terms of service and privacy policies
  • We are not responsible for the practices or policies of these third-party services

07Pricing and Billing

Scira AI offers both free and paid subscription plans. For detailed pricing information, visit our{' '} Pricing page.

We may, without prior notice, change the availability, pricing category, or subscription tier classification of specific AI models at any time, including moving models between Free, Pro, and Max tiers, if usage patterns, suspected misuse, abuse-prevention needs, provider cost changes, reliability concerns, security considerations, or other operational factors make such changes necessary.

  • Free Plan: Includes limited daily searches with access to basic AI models
  • Scira Pro: $15/month subscription with unlimited searches and access to standard paid features and non-Max AI models
  • Scira Max: $60/month subscription with all paid features plus Anthropic Claude models, subject to a 60 requests per week usage cap

Important: Scira AI does not store any payment card details, bank information, or other sensitive payment data. All payment information is processed directly by our payment providers.

08Cancellation and Refunds

You may cancel your subscription at any time. Upon cancellation:

  • Your subscription will remain active until the end of your current billing period
  • You will retain access to paid features until the subscription expires
  • Your account will automatically revert to the free plan
  • No partial refunds will be provided for unused portions of your subscription

No Refund Policy: All subscription fees are final and non-refundable. Please consider this policy carefully before subscribing to our paid plans.

09Privacy

Your use of Scira AI is also governed by our Privacy Policy, which is incorporated into these Terms of Service by reference.

10Limitation of Liability

To the maximum extent permitted by law, Scira AI shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including loss of profits, data, or goodwill, arising out of or in connection with your use of or inability to use the service.

11Disclaimers

Scira AI is provided "as is" and "as available" without any warranties of any kind, either express or implied.

12Termination

We reserve the right to suspend or terminate your access to Scira AI, with or without notice, for conduct that we believe violates these Terms of Service or is harmful to other users, us, or third parties.

13Governing Law

These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which Scira AI operates.

14Contact Us

If you have any questions about these Terms of Service, please contact us at:

zaid@scira.ai

{/* Agreement Note */}

By using Scira AI, you agree to these Terms and our{' '} Privacy Policy .

Read Privacy Policy{' '}
{/* Sidebar - Table of Contents */}
{/* Footer */}
© {new Date().getFullYear()} Scira
Home About Terms Privacy
); } ================================================ FILE: app/(content)/x-wrapped/[username]/page.tsx ================================================ 'use client'; import { useEffect, useState } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { motion } from 'framer-motion'; import { XLogoIcon } from '@phosphor-icons/react'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { ArrowUpRight, RotateCcw } from 'lucide-react'; import Image from 'next/image'; import { ColorPanels } from '@paper-design/shaders-react'; import { TextShimmer } from '@/components/core/text-shimmer'; import { TextLoop } from '@/components/core/text-loop'; import { Badge } from '@/components/ui/badge'; interface XWrappedData { username: string; displayName?: string; avatarUrl?: string; followersCount?: number; verified?: boolean; totalPosts: number; topTopics: string[]; sentiment: { positive: number; neutral: number; negative: number; }; mostActiveMonth: string; engagementScore: number; writingStyle: string; yearSummary: string; topPosts: Array<{ text: string; url: string; date: string; }>; } function StatCard({ label, value, subtext, delay = 0, className, }: { label: string; value: string | number; subtext?: string; delay?: number; className?: string; }) { return (

{label}

{value}

{subtext &&

{subtext}

}
); } function SentimentBar({ positive, neutral, negative, delay = 0 }: { positive: number; neutral: number; negative: number; delay?: number }) { return (
); } export default function XWrappedUsernamePage() { const params = useParams(); const router = useRouter(); const username = (params?.username as string) || ''; const [loading, setLoading] = useState(true); const [wrappedData, setWrappedData] = useState(null); const [error, setError] = useState(''); useEffect(() => { if (!username) { router.push('/x-wrapped'); return; } const fetchData = async () => { setLoading(true); setError(''); try { const response = await fetch('/api/x-wrapped', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, year: 2025 }), }); if (!response.ok) { throw new Error('Failed to generate X Wrapped'); } const data: XWrappedData = await response.json(); setWrappedData(data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate X Wrapped'); } finally { setLoading(false); } }; fetchData(); }, [username, router]); const handleShare = () => { if (!wrappedData) return; const shareUrl = `${window.location.origin}/x-wrapped/${encodeURIComponent(wrappedData.username)}`; const text = `My X Wrapped 2025 ✨\n\n@${wrappedData.username}\n${wrappedData.mostActiveMonth} was my month\n\nTop topics: ${wrappedData.topTopics.slice(0, 3).join(', ')}\n\n${shareUrl}`; // Open X (Twitter) compose with pre-filled text const twitterUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`; window.open(twitterUrl, '_blank', 'noopener,noreferrer'); }; const reset = () => { router.push('/x-wrapped'); }; if (loading) { return (
{/* Shader Background */}
{`Analyzing @${username}...`} Searching through posts... Calculating insights... Almost there...
); } if (error || !wrappedData) { return (
{/* Shader Background */}

{error || 'Failed to load data'}

); } const d = wrappedData; return (
{/* Shader Background */}
{/* Header */} {d.avatarUrl ? ( {d.displayName ) : (
)}
{d.displayName &&

{d.displayName}

} {d.verified && ( )}

@{d.username}

{d.followersCount !== undefined && (
{d.followersCount.toLocaleString()} Followers
)}

2025 Year in Review

{/* Bento Grid */}
{/* Sentiment */}

Sentiment

Positive {d.sentiment.positive}% Neutral {d.sentiment.neutral}% Negative {d.sentiment.negative}%
{/* Topics */} {d.topTopics.length > 0 && (

Top Topics

{d.topTopics.map((t) => ( {t} ))}
)} {/* Writing Style */}

Writing Style

{d.writingStyle}

{/* Summary */}

Year Summary

{d.yearSummary}

{/* Interesting Posts */} {d.topPosts.length > 0 && (

Interesting Posts

)} {/* Actions */}

Powered by{' '} Grok {' '} · Built with Scira
Results are cached for 5 minutes

); } ================================================ FILE: app/(content)/x-wrapped/layout.tsx ================================================ import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'X Wrapped 2025 - Your Year on X', description: 'Discover your 2025 on X! Get personalized insights about your posting activity, top topics, sentiment analysis, and more with X Wrapped powered by Scira AI.', openGraph: { title: 'X Wrapped 2025 - Your Year on X', description: 'Discover your personalized year-in-review on X with AI-powered insights and beautiful visualizations.', type: 'website', url: 'https://scira.ai/x-wrapped', images: [ { url: 'https://scira.ai/api/og/x-wrapped', width: 1200, height: 630, alt: 'X Wrapped 2025', }, ], }, twitter: { card: 'summary_large_image', title: 'X Wrapped 2025', description: 'Get your personalized year-in-review on X', images: ['https://scira.ai/api/og/x-wrapped'], }, }; export default function XWrappedLayout({ children }: { children: React.ReactNode }) { return children; } ================================================ FILE: app/(content)/x-wrapped/page.tsx ================================================ 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; import { XLogoIcon } from '@phosphor-icons/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { ChevronRight } from 'lucide-react'; import { ColorPanels } from '@paper-design/shaders-react'; export default function XWrappedPage() { const router = useRouter(); const [username, setUsername] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const handleGenerate = () => { const cleanUsername = username.trim().replace(/^@+/, ''); if (!cleanUsername) { setError('Please enter a username'); return; } setError(''); setLoading(true); // Navigate to the username route router.push(`/x-wrapped/${encodeURIComponent(cleanUsername)}`); }; return (
{/* Shader Background */}
{/* Overlay to improve text readability */}
{/* Logo + Title */}

Wrapped

Your 2025 on X, analyzed by AI

{/* Form */}
@ { const value = e.target.value.replace(/\s+/g, ''); setUsername(value); }} onKeyDown={(e) => e.key === 'Enter' && handleGenerate()} disabled={loading} className="pl-8" autoComplete="off" spellCheck={false} />
{error &&

{error}

}

Analysis takes ~2 minutes · Profiles must be public
Powered by{' '} Grok {' '} · Results cached for 5 minutes

); } ================================================ FILE: app/(search)/page.tsx ================================================ import dynamic from 'next/dynamic'; import React from 'react'; const ChatInterface = dynamic(() => import('@/components/chat-interface').then((m) => m.ChatInterface), { ssr: true, loading: () =>
, }); import { InstallPrompt } from '@/components/InstallPrompt'; const Home = () => { return ( ); }; export default Home; ================================================ FILE: app/actions.ts ================================================ // app/actions.ts 'use server'; import { geolocation } from '@vercel/functions'; import { serverEnv } from '@/env/server'; import { UIMessage, generateText, Output } from 'ai'; import type { ModelMessage } from 'ai'; import { z } from 'zod'; import { getUser } from '@/lib/auth-utils'; import { hasVisionSupport, scira } from '@/ai/providers'; import { getChatsByUserId, getRecentChatsByUserId, deleteChatById, updateChatVisibilityById, getChatById, getMessageById, deleteMessagesByChatIdAfterTimestamp, updateChatTitleById, updateChatPinnedById, getExtremeSearchCount, getMessageCountAndExtremeSearchByUserId, incrementMessageUsage, incrementAnthropicUsage, incrementGoogleUsage, getMessageCount, getAnthropicUsageCount, getGoogleUsageCount, getAgentModeRequestCountForCurrentMonth, getHistoricalUsageData, getCustomInstructionsByUserId, createCustomInstructions, updateCustomInstructions, deleteCustomInstructions, upsertUserPreferences, getDodoSubscriptionsByUserId, createLookout, getLookoutsByUserId, getLookoutById, updateLookout, updateLookoutStatus, deleteLookout, getChatWithUserById, } from '@/lib/db/queries'; import { extractChatPreview } from '@/lib/search-utils'; import { db, maindb } from '@/lib/db'; import { chat, message, buildSession, dodosubscription, type User } from '@/lib/db/schema'; import { eq, desc, ilike, and, asc, inArray, notExists } from 'drizzle-orm'; import { getDiscountConfig } from '@/lib/discount'; import { get } from '@vercel/edge-config'; import { GroqProviderOptions, groq } from '@ai-sdk/groq'; import { Client } from '@upstash/qstash'; import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; import type { CharacterAlignmentResponseModel } from '@elevenlabs/elevenlabs-js/api/types/CharacterAlignmentResponseModel'; import { usageCountCache, createMessageCountKey, createExtremeCountKey, createAnthropicCountKey, createGoogleCountKey, createAgentModeCountKey, } from '@/lib/performance-cache'; import { CronExpressionParser } from 'cron-parser'; import { getComprehensiveUserData, getLightweightUserAuth, getCachedUserPreferencesByUserId, clearUserPreferencesCache, } from '@/lib/user-data-server'; import { createConnection, listUserConnections, deleteConnection, manualSync, getSyncStatus, type ConnectorProvider, } from '@/lib/connectors'; import { jsonrepair } from 'jsonrepair'; import { headers } from 'next/headers'; import { v7 as uuidv7 } from 'uuid'; import { saveChat, saveMessages } from '@/lib/db/queries'; import { all, allSettled } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; import { getGroupConfig as getSearchGroupConfig } from '@/lib/search/group-config'; import { GoogleGenerativeAIProviderOptions, GoogleLanguageModelOptions } from '@ai-sdk/google'; import { GatewayProviderOptions } from '@ai-sdk/gateway'; import { OpenAIResponsesProviderOptions } from '@ai-sdk/openai'; // Server action to get the current user with Pro status - UNIFIED VERSION export async function getCurrentUser() { 'use server'; return await getComprehensiveUserData(); } // Lightweight auth check for fast authentication validation export async function getLightweightUser() { 'use server'; return await getLightweightUserAuth(); } // Fetch chat meta with user details (server action for client use via React Query) export async function getChatMeta(chatId: string, viewerUserId?: string) { 'use server'; if (!chatId) return null; try { const chat = await getChatWithUserById({ id: chatId }); if (!chat) return null; const isOwner = viewerUserId ? chat.userId === viewerUserId : false; return { id: chat.id, title: chat.title, visibility: chat.visibility as 'public' | 'private', createdAt: chat.createdAt, updatedAt: chat.updatedAt, user: { id: chat.userId, name: chat.userName, email: chat.userEmail, image: chat.userImage, }, isOwner, } as const; } catch (error) { console.error('Error in getChatMeta:', error); return null; } } // Get user's country code from geolocation export async function getUserCountryCode() { 'use server'; try { const headersList = await headers(); const request = { headers: headersList, }; const locationData = geolocation(request); return locationData.country || null; } catch (error) { console.error('Error getting geolocation:', error); return null; } } export async function suggestQuestions(history: any[]) { 'use server'; console.log(history); const { output } = await generateText({ model: scira.languageModel('scira-follow-up'), providerOptions: { google: { structuredOutputs: true, } satisfies GoogleGenerativeAIProviderOptions, }, system: `You are a search engine follow up query/questions generator. You MUST create between 3 and 5 questions for the search engine based on the conversation history. ### Question Generation Guidelines: - Create 3-5 questions that are open-ended and encourage further discussion - Questions must be concise (5-10 words each) but specific and contextually relevant - Each question must contain specific nouns, entities, or clear context markers - NEVER use pronouns (he, she, him, his, her, etc.) - always use proper nouns from the context - Questions must be related to tools available in the system - Questions should flow naturally from previous conversation - You are here to generate questions for the search engine not to use tools or run tools!! ### Tool-Specific Question Types: - Web search: Focus on factual information, current events, or general knowledge - Academic: Focus on scholarly topics, research questions, or educational content - YouTube: Focus on tutorials, how-to questions, or content discovery - Social media (X/Twitter): Focus on trends, opinions, or social conversations - Code/Analysis: Focus on programming, data analysis, or technical problem-solving - Weather: Redirect to news, sports, or other non-weather topics - Location: Focus on culture, history, landmarks, or local information - Finance: Focus on market analysis, investment strategies, or economic topics ### Context Transformation Rules: - For weather conversations → Generate questions about news, sports, or other non-weather topics - For programming conversations → Generate questions about algorithms, data structures, or code optimization - For location-based conversations → Generate questions about culture, history, or local attractions - For mathematical queries → Generate questions about related applications or theoretical concepts - For current events → Generate questions that explore implications, background, or related topics ### Formatting Requirements: - No bullet points, numbering, or prefixes - No quotation marks around questions - Each question must be grammatically complete - Each question must end with a question mark - Questions must be diverse and not redundant - Do not include instructions or meta-commentary in the questions JSON Output Schema: { "questions": [ "question1 (string)", "question2 (string)", "question3 (string)" ] } `, messages: history, output: Output.object({ schema: z.object({ questions: z .array(z.string().max(150)) .describe('The generated questions based on the message history.') .min(3) .max(5), }), }), }); return { questions: output.questions, }; } export async function checkImageModeration(images: string[]) { const messages: ModelMessage[] = images.map((image) => ({ role: 'user', content: [{ type: 'image', image: image }], })); const { text } = await generateText({ model: groq('meta-llama/llama-guard-4-12b'), messages, providerOptions: { groq: { service_tier: 'flex', }, }, }); return text; } export async function generateTitleFromUserMessage({ message }: { message: UIMessage }) { const startTime = Date.now(); const firstTextPart = message.parts.find((part) => part.type === 'text'); const prompt = JSON.stringify(firstTextPart && firstTextPart.type === 'text' ? firstTextPart.text : ''); console.log('Prompt: ', prompt); const { text: title } = await generateText({ model: scira.languageModel('scira-name'), system: `You are an expert title generator. You are given a message and you need to generate a short title based on it. - you will generate a short 3-4 words title based on the first message a user begins a conversation with - the title should creative and unique - do not write anything other than the title - do not use quotes or colons - no markdown formatting allowed - keep plain text only - not more than 4 words in the title - do not use any other text other than the title`, messages: [ { role: 'user', content: prompt, }, ], providerOptions: { openai: { reasoningEffort: 'minimal', reasoningSummary: null, textVerbosity: 'low', store: false, include: ['reasoning.encrypted_content'], } satisfies OpenAIResponsesProviderOptions, gateway: { only: ['vertex', 'google'], order: ['vertex', 'google'], } satisfies GatewayProviderOptions, google: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false, }, } satisfies GoogleGenerativeAIProviderOptions, vertex: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false, }, } satisfies GoogleLanguageModelOptions, }, onFinish: (output) => { console.log('Title generated: ', output.text); console.log('Model Used: ', output.model.modelId); const durationMs = Date.now() - startTime; console.log(`⏱️ [USAGE] generateTitleFromUserMessage: Model took ${durationMs}ms`); }, }); console.log('Title: ', title); const durationMs = Date.now() - startTime; console.log(`⏱️ [USAGE] generateTitleFromUserMessage: Model took ${durationMs}ms`); return title; } export async function enhancePrompt(raw: string) { try { const auth = await getLightweightUserAuth(); if (!auth?.isProUser) { return { success: false, error: 'Pro subscription required' }; } const system = `You are an expert prompt engineer. Rewrite and enhance the user's prompt. Today's date: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' })}. Treat this as the authoritative current date/time. Temporal awareness: - Interpret relative time expressions (e.g., "today", "last week", "current", "up-to-date") relative to the date stated above. - Do not include meta-references like "date above", "current date", or similar in the output. - Only include an explicit calendar date when the user's prompt requests or clearly implies a time boundary; otherwise, keep timing implicit and avoid adding extra date text. - Do not speculate about future events beyond the date stated above. Guidelines (MANDATORY): - Preserve the user's original intent, constraints, and point of view and voice. - Make the prompt specific, unambiguous, and actionable. - Add missing context when implied: entities, timeframe, location, and output format/constraints. - Remove fluff and vague language; prefer proper nouns over pronouns. - Keep it concise (add at most 1–2 sentences of necessary context) but information-dense. - Do NOT ask follow-up questions. - Do NOT answer the user's request; your job is only to improve the prompt. - Do NOT introduce new facts not implied by the user. Output requirements: - Return ONLY the improved prompt text, in plain text. - No quotes, no commentary, no markdown, and no preface.`; const { text } = await generateText({ model: scira.languageModel('scira-enhance'), temperature: 0.6, topP: 0.95, maxOutputTokens: 1024, system, prompt: raw, }); console.log('Enhanced text: ', text); return { success: true, enhanced: text.trim() }; } catch (error) { console.error('Error enhancing prompt:', error); return { success: false, error: 'Failed to enhance prompt' }; } } export interface GenerateSpeechResult { audio: string; alignment: CharacterAlignmentResponseModel | null; normalizedAlignment: CharacterAlignmentResponseModel | null; } export async function generateSpeech(text: string): Promise { const client = new ElevenLabsClient({ apiKey: serverEnv.ELEVENLABS_API_KEY, }); const result = await client.textToSpeech.convertWithTimestamps('90ipbRoKi4CpHXvKVtl0', { text, modelId: 'eleven_v3', }); return { audio: `data:audio/mp3;base64,${result.audioBase64}`, alignment: result.alignment ?? null, normalizedAlignment: result.normalizedAlignment ?? null, }; } export async function getGroupConfig(...args: Parameters) { 'use server'; return getSearchGroupConfig(...args); } // Lightweight function for sidebar recent chats - minimal payload, no cursor pagination export async function getRecentChats( userId: string, limit: number = 8, ): Promise<{ chats: Array<{ id: string; title: string; createdAt: Date; updatedAt: Date; isPinned: boolean; visibility: 'public' | 'private'; }>; hasMore: boolean; }> { 'use server'; if (!userId) return { chats: [], hasMore: false }; try { return await getRecentChatsByUserId({ userId, limit }); } catch (error) { console.error('Error fetching recent chats:', error); return { chats: [], hasMore: false }; } } // Add functions to fetch user chats export async function getUserChats( userId: string, limit: number = 20, startingAfter?: string, endingBefore?: string, ): Promise<{ chats: any[]; hasMore: boolean }> { 'use server'; if (!userId) return { chats: [], hasMore: false }; try { return await getChatsByUserId({ id: userId, limit, startingAfter: startingAfter || null, endingBefore: endingBefore || null, }); } catch (error) { console.error('Error fetching user chats:', error); return { chats: [], hasMore: false }; } } // Add function to load more chats for infinite scroll // Accepts optional cursorDate to skip the extra DB lookup for the cursor chat's updatedAt export async function loadMoreChats( userId: string, lastChatId: string, limit: number = 20, cursorDate?: string, cursorIsPinned?: boolean, ): Promise<{ chats: any[]; hasMore: boolean }> { 'use server'; if (!userId || !lastChatId) return { chats: [], hasMore: false }; try { return await getChatsByUserId({ id: userId, limit, startingAfter: null, endingBefore: lastChatId, cursorDate: cursorDate || null, cursorIsPinned: cursorIsPinned ?? null, }); } catch (error) { console.error('Error loading more chats:', error); return { chats: [], hasMore: false }; } } // Add function to delete a chat export async function deleteChat(chatId: string) { 'use server'; if (!chatId) return null; try { return await deleteChatById({ id: chatId }); } catch (error) { console.error('Error deleting chat:', error); return null; } } // Add function to bulk delete chats export async function bulkDeleteChats(chatIds: string[]) { 'use server'; if (!chatIds || chatIds.length === 0) { return { success: true, deletedCount: 0 }; } try { const taskEntries = chatIds.map((id) => [`chat:${id}`, async () => deleteChatById({ id })] as const); const settled = await allSettled(Object.fromEntries(taskEntries), getBetterAllOptions()); const settledValues = Object.values(settled); const anyRejected = settledValues.some((r) => r.status === 'rejected'); if (anyRejected) { // Preserve previous behavior: bubble up failure throw new Error('Failed to delete chats'); } const deletedCount = settledValues.filter((r) => r.status === 'fulfilled' && r.value !== null).length; return { success: true, deletedCount }; } catch (error) { console.error('Error bulk deleting chats:', error); throw new Error('Failed to delete chats'); } } // Add function to update chat visibility export async function updateChatVisibility(chatId: string, visibility: 'private' | 'public') { 'use server'; console.log('🔄 updateChatVisibility called with:', { chatId, visibility }); if (!chatId) { console.error('❌ updateChatVisibility: No chatId provided'); throw new Error('Chat ID is required'); } try { console.log('📡 Calling updateChatVisibilityById with:', { chatId, visibility }); const result = await updateChatVisibilityById({ chatId, visibility }); console.log('✅ updateChatVisibilityById successful, result:', result); // Return a serializable plain object instead of raw database result return { success: true, chatId, visibility, rowCount: result?.rowCount || 0, }; } catch (error) { console.error('❌ Error in updateChatVisibility:', { chatId, visibility, error: error instanceof Error ? error.message : error, stack: error instanceof Error ? error.stack : undefined, }); throw error; } } export async function updateChatPinned(chatId: string, isPinned: boolean) { 'use server'; if (!chatId) return null; try { return await updateChatPinnedById({ chatId, isPinned }); } catch (error) { console.error('Error updating chat pinned state:', error); return null; } } // Add function to get chat info export async function getChatInfo(chatId: string) { 'use server'; if (!chatId) return null; try { return await getChatById({ id: chatId }); } catch (error) { console.error('Error getting chat info:', error); return null; } } export async function deleteTrailingMessages({ id }: { id: string }) { 'use server'; try { const [message] = await getMessageById({ id }); console.log('Message: ', message); if (!message) { console.error(`No message found with id: ${id}`); return; } await deleteMessagesByChatIdAfterTimestamp({ chatId: message.chatId, timestamp: message.createdAt, }); console.log(`Successfully deleted trailing messages after message ID: ${id}`); } catch (error) { console.error(`Error deleting trailing messages: ${error}`); throw error; // Re-throw to allow caller to handle } } // Add function to update chat title export async function updateChatTitle(chatId: string, title: string) { 'use server'; if (!chatId || !title.trim()) return null; try { return await updateChatTitleById({ chatId, title: title.trim() }); } catch (error) { console.error('Error updating chat title:', error); return null; } } export async function forkChat( originalChatId: string, ): Promise<{ success: boolean; newChatId?: string; error?: string }> { 'use server'; if (!originalChatId) { return { success: false, error: 'Chat ID is required' }; } try { const currentUser = await getCurrentUser(); if (!currentUser) { return { success: false, error: 'User not authenticated' }; } const originalChat = await getChatById({ id: originalChatId }); if (!originalChat || originalChat.visibility !== 'public') { return { success: false, error: 'Chat is not available for forking' }; } const messages = await db.query.message.findMany({ where: eq(message.chatId, originalChatId), orderBy: (fields, { asc }) => [asc(fields.createdAt), asc(fields.id)], }); const newChatId = uuidv7(); const newChatTitle = originalChat.title ? `Fork of ${originalChat.title}` : 'Forked Chat'; const messagesToSave = messages.map((messageItem) => ({ chatId: newChatId, id: uuidv7(), role: messageItem.role, parts: messageItem.parts, attachments: messageItem.attachments ?? [], createdAt: messageItem.createdAt, model: messageItem.model ?? null, inputTokens: messageItem.inputTokens ?? null, outputTokens: messageItem.outputTokens ?? null, totalTokens: messageItem.totalTokens ?? null, completionTime: messageItem.completionTime ?? null, })); await all( { async saveMessages() { if (messagesToSave.length > 0) { await saveMessages({ messages: messagesToSave }); } return true; }, async saveChat() { await saveChat({ id: newChatId, userId: currentUser.id, title: newChatTitle, visibility: 'private', }); return true; }, }, getBetterAllOptions(), ); return { success: true, newChatId }; } catch (error) { console.error('Error forking chat:', error); return { success: false, error: 'Failed to fork chat' }; } } // Branch out a chat - create a new chat with the current user and assistant message pair export async function branchOutChat({ userMessage, assistantMessage, }: { userMessage: UIMessage; assistantMessage: UIMessage; }) { 'use server'; try { const currentUser = await getCurrentUser(); if (!currentUser) { return { success: false, error: 'User not authenticated' }; } // Generate new chat ID and message IDs const newChatId = uuidv7(); const newUserMessageId = uuidv7(); const newAssistantMessageId = uuidv7(); // Start title generation early (can run while we prepare messages) const chatTitlePromise = generateTitleFromUserMessage({ message: userMessage }); // Prepare messages for saving const messagesToSave = [ { chatId: newChatId, id: newUserMessageId, role: 'user' as const, parts: userMessage.parts, attachments: (userMessage as any).experimental_attachments ?? [], createdAt: new Date(), model: (userMessage as any).metadata?.model || null, inputTokens: (userMessage as any).metadata?.inputTokens ?? null, outputTokens: null, totalTokens: null, completionTime: null, }, { chatId: newChatId, id: newAssistantMessageId, role: 'assistant' as const, parts: assistantMessage.parts, attachments: [], createdAt: new Date(), model: (assistantMessage as any).metadata?.model || null, inputTokens: (assistantMessage as any).metadata?.inputTokens ?? null, outputTokens: (assistantMessage as any).metadata?.outputTokens ?? null, totalTokens: (assistantMessage as any).metadata?.totalTokens ?? null, completionTime: (assistantMessage as any).metadata?.completionTime ?? null, }, ]; // Create chat first (messages have foreign key to chat), then save messages await all( { chatTitle: async function () { return chatTitlePromise; }, saveChat: async function () { const chatTitle = await this.$.chatTitle; await saveChat({ id: newChatId, userId: currentUser.id, title: chatTitle, visibility: 'private', }); return true; }, saveMessages: async function () { await this.$.saveChat; // Wait for chat to be created first (foreign key constraint) await saveMessages({ messages: messagesToSave }); return true; }, }, getBetterAllOptions(), ); return { success: true, chatId: newChatId }; } catch (error) { console.error('Error branching out chat:', error); return { success: false, error: 'Failed to branch out chat' }; } } export async function getSubDetails() { 'use server'; // Import here to avoid issues with SSR const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const userData = await getComprehensiveUserData(); if (!userData) return { hasSubscription: false }; return userData.polarSubscription ? { hasSubscription: true, subscription: userData.polarSubscription, } : { hasSubscription: false }; } export async function previewMaxUpgrade() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const { dodoPayments } = await import('@/lib/auth'); const userData = await getComprehensiveUserData(); if (!userData) { return { success: false, error: 'User data not found' }; } if (userData.isMaxUser) { return { success: false, error: 'Already on Max plan' }; } const maxProductId = process.env.NEXT_PUBLIC_MAX_TIER; if (!maxProductId) { return { success: false, error: 'NEXT_PUBLIC_MAX_TIER environment variable is required' }; } if (userData.proSource !== 'dodo') { return { success: false, error: 'Preview is only available for active Dodo subscriptions' }; } const dodoProProductId = process.env.NEXT_PUBLIC_PREMIUM_TIER; if (!dodoProProductId) { return { success: false, error: 'NEXT_PUBLIC_PREMIUM_TIER environment variable is required' }; } const activeDodoProSub = await maindb.query.dodosubscription.findFirst({ where: and( eq(dodosubscription.userId, user.id), eq(dodosubscription.productId, dodoProProductId), eq(dodosubscription.status, 'active'), ), orderBy: (table, { desc }) => [desc(table.updatedAt), desc(table.createdAt)], }); if (!activeDodoProSub?.id) { return { success: false, error: 'Active Dodo Pro subscription not found' }; } console.log('ℹ️ [UPGRADE] previewMaxUpgrade selected subscription:', { userId: user.id, subscriptionId: activeDodoProSub.id, productId: activeDodoProSub.productId, status: activeDodoProSub.status, amount: activeDodoProSub.amount, currency: activeDodoProSub.currency, interval: activeDodoProSub.interval, currentPeriodStart: activeDodoProSub.currentPeriodStart, currentPeriodEnd: activeDodoProSub.currentPeriodEnd, targetProductId: maxProductId, }); const preview = await dodoPayments.subscriptions.previewChangePlan(activeDodoProSub.id, { product_id: maxProductId, quantity: 1, proration_billing_mode: 'prorated_immediately', }); console.log('ℹ️ [UPGRADE] previewMaxUpgrade Dodo preview summary:', { subscriptionId: activeDodoProSub.id, totalAmount: preview.immediate_charge.summary.total_amount, currency: preview.immediate_charge.summary.currency, settlementAmount: preview.immediate_charge.summary.settlement_amount, settlementCurrency: preview.immediate_charge.summary.settlement_currency, lineItems: preview.immediate_charge.line_items, }); return { success: true, subscriptionId: activeDodoProSub.id, preview: { totalAmount: preview.immediate_charge.summary.total_amount, currency: preview.immediate_charge.summary.currency, settlementAmount: preview.immediate_charge.summary.settlement_amount, settlementCurrency: preview.immediate_charge.summary.settlement_currency, lineItems: preview.immediate_charge.line_items, }, }; } catch (error) { console.error('❌ [UPGRADE] previewMaxUpgrade error:', error); return { success: false, error: 'Failed to preview Max upgrade. Please try again.' }; } } export async function upgradeToMax() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const { dodoPayments } = await import('@/lib/auth'); const userData = await getComprehensiveUserData(); if (!userData) { return { success: false, error: 'User data not found' }; } if (userData.isMaxUser) { return { success: false, error: 'Already on Max plan' }; } const maxProductId = process.env.NEXT_PUBLIC_MAX_TIER; if (!maxProductId) { return { success: false, error: 'NEXT_PUBLIC_MAX_TIER environment variable is required' }; } if (userData.proSource === 'dodo') { const dodoProProductId = process.env.NEXT_PUBLIC_PREMIUM_TIER; if (!dodoProProductId) { return { success: false, error: 'NEXT_PUBLIC_PREMIUM_TIER environment variable is required' }; } const activeDodoProSub = await maindb.query.dodosubscription.findFirst({ where: and( eq(dodosubscription.userId, user.id), eq(dodosubscription.productId, dodoProProductId), eq(dodosubscription.status, 'active'), ), orderBy: (table, { desc }) => [desc(table.updatedAt), desc(table.createdAt)], }); if (!activeDodoProSub?.id) { return { success: false, error: 'Active Dodo Pro subscription not found' }; } console.log('ℹ️ [UPGRADE] upgradeToMax selected subscription:', { userId: user.id, subscriptionId: activeDodoProSub.id, productId: activeDodoProSub.productId, status: activeDodoProSub.status, amount: activeDodoProSub.amount, currency: activeDodoProSub.currency, interval: activeDodoProSub.interval, currentPeriodStart: activeDodoProSub.currentPeriodStart, currentPeriodEnd: activeDodoProSub.currentPeriodEnd, targetProductId: maxProductId, }); await dodoPayments.subscriptions.changePlan(activeDodoProSub.id, { product_id: maxProductId, quantity: 1, proration_billing_mode: 'prorated_immediately', on_payment_failure: 'prevent_change', }); return { success: true, redirect: '/success' }; } // Free users and Polar Pro users should complete Max via checkout. // Polar revocation happens in the Dodo webhook handler after Max becomes active. return { success: true, redirect: '/pricing' }; } catch (error) { console.error('❌ [UPGRADE] upgradeToMax error:', error); return { success: false, error: 'Something went wrong. Please try again.' }; } } export async function previewDowngradeToPro() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const { dodoPayments } = await import('@/lib/auth'); const userData = await getComprehensiveUserData(); if (!userData) { return { success: false, error: 'User data not found' }; } if (!userData.isMaxUser || userData.proSource !== 'dodo') { return { success: false, error: 'Preview is only available for active Dodo Max subscriptions' }; } const dodoMaxProductId = process.env.NEXT_PUBLIC_MAX_TIER; const dodoProProductId = process.env.NEXT_PUBLIC_PREMIUM_TIER; if (!dodoMaxProductId) { return { success: false, error: 'NEXT_PUBLIC_MAX_TIER environment variable is required' }; } if (!dodoProProductId) { return { success: false, error: 'NEXT_PUBLIC_PREMIUM_TIER environment variable is required' }; } const activeDodoMaxSub = await maindb.query.dodosubscription.findFirst({ where: and(eq(dodosubscription.userId, user.id), eq(dodosubscription.productId, dodoMaxProductId)), orderBy: (table, { desc }) => [desc(table.createdAt)], }); if (!activeDodoMaxSub?.id) { return { success: false, error: 'Active Dodo Max subscription not found' }; } const preview = await dodoPayments.subscriptions.previewChangePlan(activeDodoMaxSub.id, { product_id: dodoProProductId, quantity: 1, proration_billing_mode: 'difference_immediately', }); return { success: true, subscriptionId: activeDodoMaxSub.id, preview: { totalAmount: preview.immediate_charge.summary.total_amount, currency: preview.immediate_charge.summary.currency, settlementAmount: preview.immediate_charge.summary.settlement_amount, settlementCurrency: preview.immediate_charge.summary.settlement_currency, lineItems: preview.immediate_charge.line_items, }, }; } catch (error) { console.error('❌ [DOWNGRADE] previewDowngradeToPro error:', error); return { success: false, error: 'Failed to preview Pro downgrade. Please try again.' }; } } export async function downgradeToPro() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const { dodoPayments } = await import('@/lib/auth'); const userData = await getComprehensiveUserData(); if (!userData) { return { success: false, error: 'User data not found' }; } if (!userData.isMaxUser || userData.proSource !== 'dodo') { return { success: false, error: 'Downgrade is only available for active Dodo Max subscriptions' }; } const dodoMaxProductId = process.env.NEXT_PUBLIC_MAX_TIER; const dodoProProductId = process.env.NEXT_PUBLIC_PREMIUM_TIER; if (!dodoMaxProductId) { return { success: false, error: 'NEXT_PUBLIC_MAX_TIER environment variable is required' }; } if (!dodoProProductId) { return { success: false, error: 'NEXT_PUBLIC_PREMIUM_TIER environment variable is required' }; } const activeDodoMaxSub = await maindb.query.dodosubscription.findFirst({ where: and(eq(dodosubscription.userId, user.id), eq(dodosubscription.productId, dodoMaxProductId)), orderBy: (table, { desc }) => [desc(table.createdAt)], }); if (!activeDodoMaxSub?.id) { return { success: false, error: 'Active Dodo Max subscription not found' }; } await dodoPayments.subscriptions.changePlan(activeDodoMaxSub.id, { product_id: dodoProProductId, quantity: 1, proration_billing_mode: 'difference_immediately', on_payment_failure: 'prevent_change', }); return { success: true, redirect: '/success' }; } catch (error) { console.error('❌ [DOWNGRADE] downgradeToPro error:', error); return { success: false, error: 'Failed to downgrade to Pro. Please try again.' }; } } export async function getUserMessageCount(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return { count: 0, error: 'User not found' }; } // Check cache first const cacheKey = createMessageCountKey(user.id); const cached = usageCountCache.get(cacheKey); if (cached !== null) { console.log('⏱️ [USAGE] getUserMessageCount: cache hit'); return { count: cached, error: null }; } const start = Date.now(); const count = await getMessageCount({ userId: user.id, }); const durationMs = Date.now() - start; console.log(`⏱️ [USAGE] getUserMessageCount: DB usage lookup took ${durationMs}ms`); // Cache the result usageCountCache.set(cacheKey, count); return { count, error: null }; } catch (error) { console.error('Error getting user message count:', error); return { count: 0, error: 'Failed to get message count' }; } } export async function getUserExtremeSearchCount(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return { count: 0, error: 'User not found' }; } // Check cache first const cacheKey = createExtremeCountKey(user.id); const cached = usageCountCache.get(cacheKey); if (cached !== null) { console.log('⏱️ [USAGE] getUserExtremeSearchCount: cache hit'); return { count: cached, error: null }; } const start = Date.now(); const count = await getExtremeSearchCount({ userId: user.id, }); const durationMs = Date.now() - start; console.log(`⏱️ [USAGE] getUserExtremeSearchCount: DB usage lookup took ${durationMs}ms`); // Cache the result usageCountCache.set(cacheKey, count); return { count, error: null }; } catch (error) { console.error('Error getting user extreme search count:', error); return { count: 0, error: 'Failed to get extreme search count' }; } } export async function incrementUserMessageCount() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } await incrementMessageUsage({ userId: user.id, }); // Invalidate cache const cacheKey = createMessageCountKey(user.id); usageCountCache.delete(cacheKey); return { success: true, error: null }; } catch (error) { console.error('Error incrementing user message count:', error); return { success: false, error: 'Failed to increment message count' }; } } export async function getExtremeSearchUsageCount(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return { count: 0, error: 'User not found' }; } // Check cache first const cacheKey = createExtremeCountKey(user.id); const cached = usageCountCache.get(cacheKey); if (cached !== null) { console.log('⏱️ [USAGE] getExtremeSearchUsageCount: cache hit'); return { count: cached, error: null }; } const start = Date.now(); const count = await getExtremeSearchCount({ userId: user.id, }); const durationMs = Date.now() - start; console.log(`⏱️ [USAGE] getExtremeSearchUsageCount: DB usage lookup took ${durationMs}ms`); // Cache the result usageCountCache.set(cacheKey, count); return { count, error: null }; } catch (error) { console.error('Error getting extreme search usage count:', error); return { count: 0, error: 'Failed to get extreme search count' }; } } /** * Get message count by userId directly - avoids getUser() overhead. * Uses the same cache as getUserMessageCount for consistency. */ export async function getMessageCountByUserId(userId: string) { const cacheKey = createMessageCountKey(userId); const cached = usageCountCache.get(cacheKey); if (cached !== null) return { count: cached, error: null }; const count = await getMessageCount({ userId }); usageCountCache.set(cacheKey, count); return { count, error: null }; } /** * Get extreme search count by userId directly - avoids getUser() overhead. * Uses the same cache as getExtremeSearchUsageCount for consistency. */ export async function getExtremeSearchCountByUserId(userId: string) { const cacheKey = createExtremeCountKey(userId); const cached = usageCountCache.get(cacheKey); if (cached !== null) return { count: cached, error: null }; const count = await getExtremeSearchCount({ userId }); usageCountCache.set(cacheKey, count); return { count, error: null }; } /** * Get anthropic usage count by userId directly - avoids getUser() overhead. * Uses the same cache strategy as other usage counters for consistency. */ export async function getAnthropicUsageCountByUserId(userId: string) { const cacheKey = createAnthropicCountKey(userId); const cached = usageCountCache.get(cacheKey); if (cached !== null) return { count: cached, error: null }; const count = await getAnthropicUsageCount({ userId }); usageCountCache.set(cacheKey, count); return { count, error: null }; } export async function getAnthropicUsageCountAction(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return { count: 0, error: 'User not found' }; } const cacheKey = createAnthropicCountKey(user.id); const cached = usageCountCache.get(cacheKey); if (cached !== null) { console.log('⏱️ [USAGE] getAnthropicUsageCountAction: cache hit'); return { count: cached, error: null }; } const start = Date.now(); const count = await getAnthropicUsageCount({ userId: user.id, }); const durationMs = Date.now() - start; console.log(`⏱️ [USAGE] getAnthropicUsageCountAction: DB usage lookup took ${durationMs}ms`); usageCountCache.set(cacheKey, count); return { count, error: null }; } catch (error) { console.error('Error getting anthropic usage count:', error); return { count: 0, error: 'Failed to get anthropic usage count' }; } } export async function getAgentModeUsageCountAction(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return { count: 0, error: 'User not found' }; } const cacheKey = createAgentModeCountKey(user.id); const cached = usageCountCache.get(cacheKey); if (cached !== null) { console.log('⏱️ [USAGE] getAgentModeUsageCountAction: cache hit'); return { count: cached, error: null }; } const start = Date.now(); const count = await getAgentModeRequestCountForCurrentMonth({ userId: user.id, }); const durationMs = Date.now() - start; console.log(`⏱️ [USAGE] getAgentModeUsageCountAction: DB usage lookup took ${durationMs}ms`); usageCountCache.set(cacheKey, count); return { count, error: null }; } catch (error) { console.error('Error getting agent mode usage count:', error); return { count: 0, error: 'Failed to get agent mode usage count' }; } } export async function incrementAnthropicUsageAction(model?: string | null) { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } await incrementAnthropicUsage({ userId: user.id, model, }); const cacheKey = createAnthropicCountKey(user.id); usageCountCache.delete(cacheKey); return { success: true, error: null }; } catch (error) { console.error('Error incrementing anthropic usage count:', error); return { success: false, error: 'Failed to increment anthropic usage count' }; } } export async function getGoogleUsageCountByUserId(userId: string) { const cacheKey = createGoogleCountKey(userId); const cached = usageCountCache.get(cacheKey); if (cached !== null) return { count: cached, error: null }; const count = await getGoogleUsageCount({ userId }); usageCountCache.set(cacheKey, count); return { count, error: null }; } export async function getGoogleUsageCountAction(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return { count: 0, error: 'User not found' }; } const cacheKey = createGoogleCountKey(user.id); const cached = usageCountCache.get(cacheKey); if (cached !== null) { console.log('⏱️ [USAGE] getGoogleUsageCountAction: cache hit'); return { count: cached, error: null }; } const start = Date.now(); const count = await getGoogleUsageCount({ userId: user.id }); const durationMs = Date.now() - start; console.log(`⏱️ [USAGE] getGoogleUsageCountAction: DB usage lookup took ${durationMs}ms`); usageCountCache.set(cacheKey, count); return { count, error: null }; } catch (error) { console.error('Error getting google usage count:', error); return { count: 0, error: 'Failed to get google usage count' }; } } export async function incrementGoogleUsageAction(model?: string | null) { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } await incrementGoogleUsage({ userId: user.id, model }); const cacheKey = createGoogleCountKey(user.id); usageCountCache.delete(cacheKey); return { success: true, error: null }; } catch (error) { console.error('Error incrementing google usage count:', error); return { success: false, error: 'Failed to increment google usage count' }; } } /** * Get message count, extreme search count, and anthropic usage count in one parallel DB round-trip. * Updates usage caches. Use in search critical-checks to run usage fetch * in parallel with chat validation instead of after it. */ export async function getMessageCountAndExtremeSearchByUserIdAction(userId: string): Promise<{ messageCountResult: { count: number; error: null } | { count: undefined; error: Error }; extremeSearchUsage: { count: number; error: null } | { count: undefined; error: Error }; anthropicUsageResult: { count: number; error: null } | { count: undefined; error: Error }; }> { const messageCacheKey = createMessageCountKey(userId); const extremeCacheKey = createExtremeCountKey(userId); const anthropicCacheKey = createAnthropicCountKey(userId); const messageCached = usageCountCache.get(messageCacheKey); const extremeCached = usageCountCache.get(extremeCacheKey); const anthropicCached = usageCountCache.get(anthropicCacheKey); if (messageCached !== null && extremeCached !== null && anthropicCached !== null) { return { messageCountResult: { count: messageCached, error: null }, extremeSearchUsage: { count: extremeCached, error: null }, anthropicUsageResult: { count: anthropicCached, error: null }, }; } try { const { messageCount, extremeSearchCount, anthropicCount } = await getMessageCountAndExtremeSearchByUserId({ userId, }); if (messageCached === null) usageCountCache.set(messageCacheKey, messageCount); if (extremeCached === null) usageCountCache.set(extremeCacheKey, extremeSearchCount); if (anthropicCached === null) usageCountCache.set(anthropicCacheKey, anthropicCount); return { messageCountResult: { count: messageCount, error: null }, extremeSearchUsage: { count: extremeSearchCount, error: null }, anthropicUsageResult: { count: anthropicCount, error: null }, }; } catch (err) { const error = err instanceof Error ? err : new Error('Failed to verify usage limits'); return { messageCountResult: { count: undefined, error }, extremeSearchUsage: { count: undefined, error }, anthropicUsageResult: { count: undefined, error }, }; } } type DiscountConfigParams = { email?: string | null; isIndianUser?: boolean; }; export async function getDiscountConfigAction(params?: DiscountConfigParams) { try { let userEmail = params?.email ?? null; if (!userEmail) { const user = await getCurrentUser(); userEmail = user?.email ?? null; } let isIndianUser = params?.isIndianUser; if (isIndianUser === undefined) { try { const headersList = await headers(); const request = { headers: headersList }; const locationData = geolocation(request); const country = (locationData.country || '').toUpperCase(); isIndianUser = country === 'IN'; } catch (geoError) { console.warn('Geolocation lookup failed in getDiscountConfigAction:', geoError); isIndianUser = false; } } return await getDiscountConfig(userEmail ?? undefined, isIndianUser); } catch (error) { console.error('Error getting discount configuration:', error); return { enabled: false, }; } } export async function getHistoricalUsage(providedUser?: User | null, days: number = 30) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return []; } // Convert days to months for the database query (approximately 30 days per month) const months = Math.ceil(days / 30); const historicalData = await getHistoricalUsageData({ userId: user.id, months }); // Use the exact number of days requested const totalDays = days; const today = new Date(); const startDate = new Date(today); startDate.setDate(startDate.getDate() - (totalDays - 1)); // -1 to include today // Create a map of existing data for quick lookup const dataMap = new Map(); historicalData.forEach((record) => { const dateKey = record.date.toISOString().split('T')[0]; dataMap.set(dateKey, record.messageCount || 0); }); // Generate complete dataset for all days const completeData = []; for (let i = 0; i < totalDays; i++) { const currentDate = new Date(startDate); currentDate.setDate(startDate.getDate() + i); const dateKey = currentDate.toISOString().split('T')[0]; const count = dataMap.get(dateKey) || 0; let level: 0 | 1 | 2 | 3 | 4; // Define usage levels based on message count if (count === 0) level = 0; else if (count <= 3) level = 1; else if (count <= 7) level = 2; else if (count <= 12) level = 3; else level = 4; completeData.push({ date: dateKey, count, level, }); } return completeData; } catch (error) { console.error('Error getting historical usage:', error); return []; } } // Custom Instructions Server Actions export async function getCustomInstructions(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return null; } const instructions = await getCustomInstructionsByUserId({ userId: user.id }); return instructions; } catch (error) { console.error('Error getting custom instructions:', error); return null; } } export async function saveCustomInstructions(content: string) { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } if (!content.trim()) { return { success: false, error: 'Content cannot be empty' }; } // Check if instructions already exist const existingInstructions = await getCustomInstructionsByUserId({ userId: user.id }); let result; if (existingInstructions) { result = await updateCustomInstructions({ userId: user.id, content: content.trim() }); } else { result = await createCustomInstructions({ userId: user.id, content: content.trim() }); } return { success: true, data: result }; } catch (error) { console.error('Error saving custom instructions:', error); return { success: false, error: 'Failed to save custom instructions' }; } } export async function deleteCustomInstructionsAction() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } const result = await deleteCustomInstructions({ userId: user.id }); return { success: true, data: result }; } catch (error) { console.error('Error deleting custom instructions:', error); return { success: false, error: 'Failed to delete custom instructions' }; } } // User Preferences Actions export async function getUserPreferences(providedUser?: User | null) { 'use server'; try { const user = providedUser || (await getUser()); if (!user) { return null; } const preferences = await getCachedUserPreferencesByUserId(user.id); return preferences; } catch (error) { console.error('Error getting user preferences:', error); return null; } } export async function saveUserPreferences( preferences: Partial<{ 'scira-search-provider'?: 'exa' | 'parallel' | 'firecrawl'; 'scira-extreme-search-model'?: | 'scira-ext-1' | 'scira-ext-2' | 'scira-ext-4' | 'scira-ext-5' | 'scira-ext-6' | 'scira-ext-7' | 'scira-ext-8'; 'scira-group-order'?: string[]; 'scira-model-order-global'?: string[]; 'scira-blur-personal-info'?: boolean; 'scira-custom-instructions-enabled'?: boolean; 'scira-scroll-to-latest-on-open'?: boolean; 'scira-location-metadata-enabled'?: boolean; 'scira-auto-router-enabled'?: boolean; 'scira-auto-router-config'?: { routes: Array<{ name: string; description: string; model: string; }>; }; }>, ) { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } const result = await upsertUserPreferences({ userId: user.id, preferences }); // Clear cache after update clearUserPreferencesCache(user.id); return { success: true, data: result }; } catch (error) { console.error('Error saving user preferences:', error); return { success: false, error: 'Failed to save user preferences' }; } } export async function routeWithAutoRouter({ query, routes, hasImages = false, }: { query: string; routes: Array<{ name: string; description: string; model: string }>; hasImages?: boolean; }) { 'use server'; try { const user = await getCurrentUser(); if (!user) { return { success: false, error: 'User not found' }; } if (!user.isProUser) { return { success: false, error: 'pro_required' }; } const trimmedQuery = query.trim(); if (!trimmedQuery) { return { success: false, error: 'Query cannot be empty' }; } const sanitizedRoutes = routes .map((route) => ({ name: route.name.trim(), description: route.description.trim(), model: route.model.trim(), })) .filter((route) => route.name && route.description && route.model); if (!sanitizedRoutes.length) { return { success: false, error: 'No routes configured' }; } const routeConfig = sanitizedRoutes.map(({ name, description }) => ({ name, description, })); const conversation = [{ role: 'user', content: trimmedQuery }]; const taskInstruction = ` You are a helpful assistant designed to find the best suited route. You are provided with route description within XML tags: ${JSON.stringify(routeConfig)} ${JSON.stringify(conversation)} `; const imageContext = hasImages ? '\n\nIMPORTANT: The user attached image(s). Prefer a route whose model supports vision/image analysis. If none do, return {"route": "other"}.' : ''; const formatPrompt = ` Your task is to decide which route is best suit with user intent on the conversation in XML tags. Follow the instruction: 1. If the latest intent from user is irrelevant or user intent is full filled, response with other route {"route": "other"}. 2. You must analyze the route descriptions and find the best match route for user latest intent. 3. You only response the name of the route that best matches the user's request, use the exact name in the . ${imageContext} Based on your analysis, provide your response in the following JSON formats if you decide to match any route: {"route": "route_name"} `; const { text } = await generateText({ model: scira.languageModel('scira-arch-router'), messages: [{ role: 'user', content: taskInstruction + formatPrompt }], maxOutputTokens: 200, temperature: 0, }); const rawMatch = text.match(/\{[\s\S]*\}/); const parsed = rawMatch ? JSON.parse(jsonrepair(rawMatch[0])) : null; const routeName = parsed?.route as string | undefined; const matchedRoute = sanitizedRoutes.find((route) => route.name === routeName); let resolvedModel = matchedRoute?.model || 'scira-default'; if (hasImages && !hasVisionSupport(resolvedModel)) { const visionRoute = sanitizedRoutes.find((route) => hasVisionSupport(route.model)); resolvedModel = visionRoute?.model || 'scira-default'; } console.log('Resolved model:', resolvedModel); return { success: true, model: resolvedModel, route: matchedRoute?.name || 'other', }; } catch (error) { console.error('Error routing with auto router:', error); return { success: false, error: 'Failed to route query' }; } } export async function syncUserPreferences() { 'use server'; try { const user = await getUser(); if (!user) { return { success: false, error: 'User not found' }; } // This will be called from the client to migrate localStorage data // The actual migration logic will be in the hook return { success: true }; } catch (error) { console.error('Error syncing user preferences:', error); return { success: false, error: 'Failed to sync user preferences' }; } } // Fast pro user status check - UNIFIED VERSION export async function getProUserStatusOnly(): Promise { 'use server'; // Import here to avoid issues with SSR const { isUserPro } = await import('@/lib/user-data-server'); return await isUserPro(); } export async function getDodoSubscriptionHistory() { try { const user = await getUser(); if (!user) return null; const subscriptions = await getDodoSubscriptionsByUserId({ userId: user.id }); return subscriptions; } catch (error) { console.error('Error getting subscription history:', error); return null; } } export async function getDodoSubscriptionProStatus() { 'use server'; // Import here to avoid issues with SSR const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const userData = await getComprehensiveUserData(); if (!userData) return { isProUser: false, hasSubscriptions: false }; const isDodoProUser = userData.proSource === 'dodo' && userData.isProUser; return { isProUser: isDodoProUser, hasSubscriptions: Boolean(userData.dodoSubscription?.hasSubscriptions), expiresAt: userData.dodoSubscription?.expiresAt, source: userData.proSource, daysUntilExpiration: userData.dodoSubscription?.daysUntilExpiration, isExpired: userData.dodoSubscription?.isExpired, isExpiringSoon: userData.dodoSubscription?.isExpiringSoon, }; } export async function getDodoSubscriptionExpirationDate() { 'use server'; // Import here to avoid issues with SSR const { getComprehensiveUserData } = await import('@/lib/user-data-server'); const userData = await getComprehensiveUserData(); return userData?.dodoSubscription?.expiresAt || null; } // Initialize QStash client const qstash = new Client({ token: serverEnv.QSTASH_TOKEN }); // Helper function to convert frequency to cron schedule with timezone function frequencyToCron(frequency: string, time: string, timezone: string, dayOfWeek?: string): string { const [hours, minutes] = time.split(':').map(Number); let cronExpression = ''; switch (frequency) { case 'once': // For 'once', we'll handle it differently - no cron schedule needed return ''; case 'daily': cronExpression = `${minutes} ${hours} * * *`; break; case 'weekly': // Use the day of week if provided, otherwise default to Sunday (0) const day = dayOfWeek || '0'; cronExpression = `${minutes} ${hours} * * ${day}`; break; case 'monthly': // Run on the 1st of each month cronExpression = `${minutes} ${hours} 1 * *`; break; case 'yearly': // Run on January 1st cronExpression = `${minutes} ${hours} 1 1 *`; break; default: cronExpression = `${minutes} ${hours} * * *`; // Default to daily } // Prepend timezone to cron expression for QStash return `CRON_TZ=${timezone} ${cronExpression}`; } // Helper function to calculate next run time using cron-parser function calculateNextRun(cronSchedule: string, timezone: string): Date { try { // Extract the actual cron expression from the timezone-prefixed format // Format: "CRON_TZ=timezone 0 9 * * *" -> "0 9 * * *" const actualCronExpression = cronSchedule.startsWith('CRON_TZ=') ? cronSchedule.split(' ').slice(1).join(' ') : cronSchedule; const options = { currentDate: new Date(), tz: timezone, }; const interval = CronExpressionParser.parse(actualCronExpression, options); return interval.next().toDate(); } catch (error) { console.error('Error parsing cron expression:', cronSchedule, error); // Fallback to simple calculation const now = new Date(); const nextRun = new Date(now); nextRun.setDate(nextRun.getDate() + 1); return nextRun; } } // Helper function to calculate next run for 'once' frequency function calculateOnceNextRun(time: string, timezone: string, date?: string): Date { const [hours, minutes] = time.split(':').map(Number); if (date) { // If a specific date is provided, use it const targetDate = new Date(date); targetDate.setHours(hours, minutes, 0, 0); return targetDate; } // Otherwise, use today or tomorrow const now = new Date(); const targetDate = new Date(now); targetDate.setHours(hours, minutes, 0, 0); // If the time has already passed today, schedule for tomorrow if (targetDate <= now) { targetDate.setDate(targetDate.getDate() + 1); } return targetDate; } export async function createScheduledLookout({ title, prompt, frequency, time, timezone = 'UTC', date, searchMode = 'extreme', }: { title: string; prompt: string; frequency: 'once' | 'daily' | 'weekly' | 'monthly' | 'yearly'; time: string; // Format: "HH:MM" or "HH:MM:dayOfWeek" for weekly timezone?: string; date?: string; // For 'once' frequency searchMode?: string; // Search mode: 'extreme', 'web', 'academic', etc. }) { try { const user = await getCurrentUser(); if (!user) { throw new Error('Authentication required'); } // Check if user is Pro if (!user.isProUser) { throw new Error('Pro subscription required for scheduled searches'); } // Check lookout limits const existingLookouts = await getLookoutsByUserId({ userId: user.id }); if (existingLookouts.length >= 10) { throw new Error('You have reached the maximum limit of 10 lookouts'); } // Check daily lookout limit specifically if (frequency === 'daily') { const activeDailyLookouts = existingLookouts.filter( (lookout) => lookout.frequency === 'daily' && lookout.status === 'active', ); if (activeDailyLookouts.length >= 5) { throw new Error('You have reached the maximum limit of 5 active daily lookouts'); } } let cronSchedule = ''; let nextRunAt: Date; let actualTime = time; let dayOfWeek: string | undefined; // Extract day of week for weekly frequency if (frequency === 'weekly' && time.includes(':')) { const parts = time.split(':'); if (parts.length === 3) { actualTime = `${parts[0]}:${parts[1]}`; dayOfWeek = parts[2]; } } if (frequency === 'once') { // For 'once', calculate the next run time without cron nextRunAt = calculateOnceNextRun(actualTime, timezone, date); } else { // Generate cron schedule for recurring frequencies cronSchedule = frequencyToCron(frequency, actualTime, timezone, dayOfWeek); nextRunAt = calculateNextRun(cronSchedule, timezone); } // Create lookout in database first const lookout = await createLookout({ userId: user.id, title, prompt, frequency, cronSchedule, timezone, nextRunAt, qstashScheduleId: undefined, // Will be updated if needed searchMode, }); console.log('📝 Created lookout in database:', lookout.id, 'Now scheduling with QStash...'); // Small delay to ensure database transaction is committed await new Promise((resolve) => setTimeout(resolve, 100)); // Create QStash schedule for all frequencies (recurring and once) if (lookout.id) { try { if (frequency === 'once') { console.log('⏰ Creating QStash one-time execution for lookout:', lookout.id); console.log('📅 Scheduled time:', nextRunAt.toISOString()); const delay = Math.floor((nextRunAt.getTime() - Date.now()) / 1000); // Delay in seconds const minimumDelay = Math.max(delay, 5); // At least 5 seconds to ensure DB consistency if (delay > 0) { await qstash.publish({ // if dev env use localhost:3000/api/lookout, else use scira.ai/api/lookout url: process.env.NODE_ENV === 'development' ? process.env.NGROK_URL + '/api/lookout' : `https://scira.ai/api/lookout`, body: JSON.stringify({ lookoutId: lookout.id, prompt, userId: user.id, }), headers: { 'Content-Type': 'application/json', }, delay: minimumDelay, }); console.log( '✅ QStash one-time execution scheduled for lookout:', lookout.id, 'with delay:', minimumDelay, 'seconds', ); // For consistency, we don't store a qstashScheduleId for one-time executions // since they use the publish API instead of schedules API } else { throw new Error('Cannot schedule for a time in the past'); } } else { console.log('⏰ Creating QStash recurring schedule for lookout:', lookout.id); console.log('📅 Cron schedule with timezone:', cronSchedule); const scheduleResponse = await qstash.schedules.create({ // if dev env use localhost:3000/api/lookout, else use scira.ai/api/lookout destination: process.env.NODE_ENV === 'development' ? process.env.NGROK_URL + '/api/lookout' : `https://scira.ai/api/lookout`, method: 'POST', cron: cronSchedule, body: JSON.stringify({ lookoutId: lookout.id, prompt, userId: user.id, }), headers: { 'Content-Type': 'application/json', }, }); console.log('✅ QStash recurring schedule created:', scheduleResponse.scheduleId, 'for lookout:', lookout.id); // Update lookout with QStash schedule ID await updateLookout({ id: lookout.id, qstashScheduleId: scheduleResponse.scheduleId, }); lookout.qstashScheduleId = scheduleResponse.scheduleId; } } catch (qstashError) { console.error('Error creating QStash schedule:', qstashError); // Delete the lookout if QStash creation fails await deleteLookout({ id: lookout.id }); throw new Error( `Failed to ${frequency === 'once' ? 'schedule one-time search' : 'create recurring schedule'}. Please try again.`, ); } } return { success: true, lookout }; } catch (error) { console.error('Error creating scheduled lookout:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } export async function getUserLookouts() { try { const user = await getCurrentUser(); if (!user) { throw new Error('Authentication required'); } const lookouts = await getLookoutsByUserId({ userId: user.id }); // Update next run times for active lookouts const updatedLookouts = lookouts.map((lookout) => { if (lookout.status === 'active' && lookout.cronSchedule && lookout.frequency !== 'once') { try { const nextRunAt = calculateNextRun(lookout.cronSchedule, lookout.timezone); return { ...lookout, nextRunAt }; } catch (error) { console.error('Error calculating next run for lookout:', lookout.id, error); return lookout; } } return lookout; }); return { success: true, lookouts: updatedLookouts }; } catch (error) { console.error('Error getting user lookouts:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } export async function updateLookoutStatusAction({ id, status, }: { id: string; status: 'active' | 'paused' | 'archived' | 'running'; }) { try { const user = await getCurrentUser(); if (!user) { throw new Error('Authentication required'); } // Get lookout to verify ownership const lookout = await getLookoutById({ id }); if (!lookout || lookout.userId !== user.id) { throw new Error('Lookout not found or access denied'); } // Update QStash schedule status if it exists if (lookout.qstashScheduleId) { try { if (status === 'paused') { await qstash.schedules.pause({ schedule: lookout.qstashScheduleId }); } else if (status === 'active') { await qstash.schedules.resume({ schedule: lookout.qstashScheduleId }); // Update next run time when resuming if (lookout.cronSchedule) { const nextRunAt = calculateNextRun(lookout.cronSchedule, lookout.timezone); await updateLookout({ id, nextRunAt }); } } else if (status === 'archived') { await qstash.schedules.delete(lookout.qstashScheduleId); } } catch (qstashError) { console.error('Error updating QStash schedule:', qstashError); // Continue with database update even if QStash fails } } // Update database const updatedLookout = await updateLookoutStatus({ id, status }); return { success: true, lookout: updatedLookout }; } catch (error) { console.error('Error updating lookout status:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } export async function updateLookoutAction({ id, title, prompt, frequency, time, timezone, dayOfWeek, searchMode, }: { id: string; title: string; prompt: string; frequency: 'once' | 'daily' | 'weekly' | 'monthly' | 'yearly'; time: string; timezone: string; dayOfWeek?: string; searchMode?: string; }) { try { const user = await getCurrentUser(); if (!user) { throw new Error('Authentication required'); } // Get lookout to verify ownership const lookout = await getLookoutById({ id }); if (!lookout || lookout.userId !== user.id) { throw new Error('Lookout not found or access denied'); } // Check daily lookout limit if changing to daily frequency if (frequency === 'daily' && lookout.frequency !== 'daily') { const existingLookouts = await getLookoutsByUserId({ userId: user.id }); const activeDailyLookouts = existingLookouts.filter( (existingLookout) => existingLookout.frequency === 'daily' && existingLookout.status === 'active' && existingLookout.id !== id, ); if (activeDailyLookouts.length >= 5) { throw new Error('You have reached the maximum limit of 5 active daily lookouts'); } } // Handle weekly day selection let adjustedTime = time; if (frequency === 'weekly' && dayOfWeek) { adjustedTime = `${time}:${dayOfWeek}`; } // Generate new cron schedule if frequency changed let cronSchedule = ''; let nextRunAt: Date; if (frequency === 'once') { // For 'once', set next run to today/tomorrow at specified time const [hours, minutes] = time.split(':').map(Number); const now = new Date(); nextRunAt = new Date(now); nextRunAt.setHours(hours, minutes, 0, 0); if (nextRunAt <= now) { nextRunAt.setDate(nextRunAt.getDate() + 1); } } else { cronSchedule = frequencyToCron(frequency, time, timezone, dayOfWeek); nextRunAt = calculateNextRun(cronSchedule, timezone); } // Update QStash schedule if it exists and frequency/time changed if (lookout.qstashScheduleId && frequency !== 'once') { try { // Delete old schedule await qstash.schedules.delete(lookout.qstashScheduleId); console.log('⏰ Recreating QStash schedule for lookout:', id); console.log('📅 Updated cron schedule with timezone:', cronSchedule); // Create new schedule with updated cron const scheduleResponse = await qstash.schedules.create({ // if dev env use localhost:3000/api/lookout, else use scira.ai/api/lookout destination: process.env.NODE_ENV === 'development' ? process.env.NGROK_URL + '/api/lookout' : `https://scira.ai/api/lookout`, method: 'POST', cron: cronSchedule, body: JSON.stringify({ lookoutId: id, prompt: prompt.trim(), userId: user.id, }), headers: { 'Content-Type': 'application/json', }, }); // Update database with new details const updatedLookout = await updateLookout({ id, title: title.trim(), prompt: prompt.trim(), frequency, cronSchedule, timezone, nextRunAt, qstashScheduleId: scheduleResponse.scheduleId, searchMode, }); return { success: true, lookout: updatedLookout }; } catch (qstashError) { console.error('Error updating QStash schedule:', qstashError); throw new Error('Failed to update schedule. Please try again.'); } } else { // Update database only const updatedLookout = await updateLookout({ id, title: title.trim(), prompt: prompt.trim(), frequency, cronSchedule, timezone, nextRunAt, searchMode, }); return { success: true, lookout: updatedLookout }; } } catch (error) { console.error('Error updating lookout:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } export async function deleteLookoutAction({ id }: { id: string }) { try { const user = await getCurrentUser(); if (!user) { throw new Error('Authentication required'); } // Get lookout to verify ownership const lookout = await getLookoutById({ id }); if (!lookout || lookout.userId !== user.id) { throw new Error('Lookout not found or access denied'); } // Delete QStash schedule if it exists if (lookout.qstashScheduleId) { try { await qstash.schedules.delete(lookout.qstashScheduleId); } catch (error) { console.error('Error deleting QStash schedule:', error); // Continue with database deletion even if QStash deletion fails } } // Delete from database const deletedLookout = await deleteLookout({ id }); return { success: true, lookout: deletedLookout }; } catch (error) { console.error('Error deleting lookout:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } export async function testLookoutAction({ id }: { id: string }) { try { const user = await getCurrentUser(); if (!user) { throw new Error('Authentication required'); } // Get lookout to verify ownership const lookout = await getLookoutById({ id }); if (!lookout || lookout.userId !== user.id) { throw new Error('Lookout not found or access denied'); } // Only allow testing of active or paused lookouts if (lookout.status === 'archived' || lookout.status === 'running') { throw new Error(`Cannot test lookout with status: ${lookout.status}`); } // Make a POST request to the lookout API endpoint to trigger the run const lookoutUrl = process.env.NODE_ENV === 'development' ? process.env.NGROK_URL ? process.env.NGROK_URL + '/api/lookout' : 'http://localhost:3000/api/lookout' : `https://scira.ai/api/lookout`; const response = await fetch(lookoutUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ lookoutId: lookout.id, prompt: lookout.prompt, userId: user.id, }), }); if (!response.ok) { throw new Error(`Failed to trigger lookout test: ${response.statusText}`); } return { success: true, message: 'Lookout test started successfully' }; } catch (error) { console.error('Error testing lookout:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } // Server action to get user's geolocation using Vercel export async function getUserLocation() { try { const headersList = await headers(); const request = { headers: headersList, }; const locationData = geolocation(request); return { country: locationData.country || '', countryCode: locationData.country || '', city: locationData.city || '', region: locationData.region || '', isIndia: locationData.country === 'IN', loading: false, }; } catch (error) { console.error('Failed to get location from Vercel:', error); return { country: 'Unknown', countryCode: '', city: '', region: '', isIndia: false, loading: false, }; } } // Connector management actions export async function createConnectorAction(provider: ConnectorProvider) { 'use server'; try { const user = await getCurrentUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const authLink = await createConnection(provider, user.id); return { success: true, authLink }; } catch (error) { console.error('Error creating connector:', error); return { success: false, error: 'Failed to create connector' }; } } export async function listUserConnectorsAction() { 'use server'; try { const user = await getCurrentUser(); if (!user) { return { success: false, error: 'Authentication required', connections: [] }; } const connections = await listUserConnections(user.id); return { success: true, connections }; } catch (error) { console.error('Error listing connectors:', error); return { success: false, error: 'Failed to list connectors', connections: [] }; } } export async function deleteConnectorAction(connectionId: string) { 'use server'; try { const user = await getCurrentUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const result = await deleteConnection(connectionId); if (result) { return { success: true }; } else { return { success: false, error: 'Failed to delete connector' }; } } catch (error) { console.error('Error deleting connector:', error); return { success: false, error: 'Failed to delete connector' }; } } export async function manualSyncConnectorAction(provider: ConnectorProvider) { 'use server'; try { const user = await getCurrentUser(); if (!user) { return { success: false, error: 'Authentication required' }; } const result = await manualSync(provider, user.id); if (result) { return { success: true }; } else { return { success: false, error: 'Failed to start sync' }; } } catch (error) { console.error('Error syncing connector:', error); return { success: false, error: 'Failed to start sync' }; } } export async function getConnectorSyncStatusAction(provider: ConnectorProvider) { 'use server'; try { const user = await getCurrentUser(); if (!user) { return { success: false, error: 'Authentication required', status: null }; } const status = await getSyncStatus(provider, user.id); return { success: true, status }; } catch (error) { console.error('Error getting sync status:', error); return { success: false, error: 'Failed to get sync status', status: null }; } } // Server action to get supported student domains from Edge Config export async function getStudentDomainsAction() { 'use server'; try { const studentDomainsConfig = await get('student_domains'); if (studentDomainsConfig && typeof studentDomainsConfig === 'string') { // Parse CSV string to array, trim whitespace, and sort alphabetically const domains = studentDomainsConfig .split(',') .map((domain) => domain.trim()) .filter((domain) => domain.length > 0) .sort(); return { success: true, domains, count: domains.length, }; } // Fallback to hardcoded domains if Edge Config fails const fallbackDomains = ['.edu', '.ac.in'].sort(); return { success: true, domains: fallbackDomains, count: fallbackDomains.length, fallback: true, }; } catch (error) { console.error('Failed to fetch student domains from Edge Config:', error); // Return fallback domains on error const fallbackDomains = ['.edu', '.ac.in'].sort(); return { success: false, domains: fallbackDomains, count: fallbackDomains.length, fallback: true, error: error instanceof Error ? error.message : 'Unknown error', }; } } // Fetch chats for the authenticated user (paginated) interface ChatMeta { preview?: string; model?: string; } function stripMarkdown(text: string): string { return text .replace(/```[\s\S]*?```/g, '') // fenced code blocks .replace(/`[^`]*`/g, '') // inline code .replace(/!\[.*?\]\(.*?\)/g, '') // images .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → label only .replace(/#{1,6}\s+/g, '') // headings .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold .replace(/(\*|_)(.*?)\1/g, '$2') // italic .replace(/~~(.*?)~~/g, '$1') // strikethrough .replace(/^[-*+]\s+/gm, '') // unordered list bullets .replace(/^\d+\.\s+/gm, '') // ordered list numbers .replace(/^>\s+/gm, '') // blockquotes .replace( /^\|(.+)\|$/gm, ( _, row, // table rows → space-separated cells ) => row .split('|') .map((c: string) => c.trim()) .filter(Boolean) .join(' '), ) .replace(/^\|?[\s:|-]+\|[\s:|-|]*$/gm, '') // table separator rows (---|:---:|---) .replace(/[-]{3,}|[*]{3,}|[_]{3,}/g, '') // horizontal rules .replace(/\n{2,}/g, ' ') // collapse blank lines .replace(/\n/g, ' ') // newlines → space .replace(/\s{2,}/g, ' ') // collapse whitespace .trim(); } // Batch-fetch the first user message (preview) + first assistant message (model) per chat. // Two queries total, no N+1. async function buildPreviewMap(chatIds: string[]): Promise> { if (chatIds.length === 0) return {}; const rows = await db .select({ chatId: message.chatId, role: message.role, parts: message.parts, model: message.model }) .from(message) .where(and(inArray(message.chatId, chatIds))) .orderBy(asc(message.createdAt)); const seenUser = new Set(); const seenAssistant = new Set(); const map: Record = {}; for (const msg of rows) { if (!map[msg.chatId]) map[msg.chatId] = {}; if (msg.role === 'assistant' && !seenAssistant.has(msg.chatId)) { seenAssistant.add(msg.chatId); if (msg.model) map[msg.chatId].model = msg.model; const parts = Array.isArray(msg.parts) ? msg.parts : []; const raw = (parts as Array<{ type: string; text?: string }>) .filter((p) => p.type === 'text' && p.text) .map((p) => p.text!.trim()) .join(' '); const text = stripMarkdown(raw); if (text) map[msg.chatId].preview = text.length > 160 ? text.slice(0, 160) + '…' : text; } // Fallback: if no assistant message yet, use first user message if (msg.role === 'user' && !seenUser.has(msg.chatId) && !map[msg.chatId].preview) { seenUser.add(msg.chatId); const parts = Array.isArray(msg.parts) ? msg.parts : []; const raw = (parts as Array<{ type: string; text?: string }>) .filter((p) => p.type === 'text' && p.text) .map((p) => p.text!.trim()) .join(' '); const text = stripMarkdown(raw); if (text) map[msg.chatId].preview = text.length > 160 ? text.slice(0, 160) + '…' : text; } } return map; } export async function getAllChatsWithPreview(limit: number = 25, offset: number = 0) { 'use server'; try { const user = await getUser(); if (!user) { return { error: 'Unauthorized', status: 401 }; } const chats = await db.query.chat.findMany({ where: and( eq(chat.userId, user.id), notExists(db.select({ id: buildSession.id }).from(buildSession).where(eq(buildSession.chatId, chat.id))), ), orderBy: [desc(chat.isPinned), desc(chat.updatedAt), desc(chat.id)], limit, offset, }); const previewMap = await buildPreviewMap(chats.map((c) => c.id)); const chatsWithPreview = chats.map((c) => ({ ...c, preview: previewMap[c.id]?.preview ?? null, model: previewMap[c.id]?.model ?? null, })); return { chats: chatsWithPreview }; } catch (error) { console.error('Error fetching chats:', error); return { error: 'Failed to fetch chats', status: 500 }; } } // Search chats by title (paginated) export async function searchChatsByTitle(query: string, limit: number = 25, offset: number = 0) { 'use server'; try { const user = await getUser(); if (!user) { return { error: 'Unauthorized', status: 401 }; } const trimmedQuery = query?.trim() || ''; const excludeBuildChats = notExists( db.select({ id: buildSession.id }).from(buildSession).where(eq(buildSession.chatId, chat.id)), ); const chats = await db.query.chat.findMany({ where: trimmedQuery.length === 0 ? and(eq(chat.userId, user.id), excludeBuildChats) : and(eq(chat.userId, user.id), ilike(chat.title, `%${trimmedQuery}%`), excludeBuildChats), orderBy: [desc(chat.isPinned), desc(chat.updatedAt), desc(chat.id)], limit, offset, }); const previewMap = await buildPreviewMap(chats.map((c) => c.id)); const chatsWithPreview = chats.map((c) => ({ ...c, preview: previewMap[c.id]?.preview ?? null, model: previewMap[c.id]?.model ?? null, })); return { chats: chatsWithPreview }; } catch (error) { console.error('Error searching chats:', error); return { error: 'Failed to search chats', status: 500 }; } } ================================================ FILE: app/api/auth/[...all]/route.ts ================================================ import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth); ================================================ FILE: app/api/clean_images/route.ts ================================================ import { serverEnv } from '@/env/server'; import { ListObjectsV2Command, DeleteObjectsCommand } from '@aws-sdk/client-s3'; import { NextRequest, NextResponse } from 'next/server'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; export async function GET(req: NextRequest) { if (req.headers.get('Authorization') !== `Bearer ${serverEnv.CRON_SECRET}`) { return new NextResponse('Unauthorized', { status: 401 }); } try { const deletedCount = await deleteAllObjectsWithPrefix('scira/public'); return new NextResponse(`Deleted ${deletedCount} public files with scira/public prefix`, { status: 200, }); } catch (error) { console.error('An error occurred:', error); return new NextResponse('An error occurred while deleting files', { status: 500, }); } } async function deleteAllObjectsWithPrefix(prefix: string): Promise { let continuationToken: string | undefined; let totalDeleted = 0; do { // List objects with prefix const listResponse = await r2Client.send( new ListObjectsV2Command({ Bucket: R2_BUCKET_NAME, Prefix: prefix, MaxKeys: 1000, ContinuationToken: continuationToken, }) ); const objects = listResponse.Contents; if (objects && objects.length > 0) { // Delete objects in batch await r2Client.send( new DeleteObjectsCommand({ Bucket: R2_BUCKET_NAME, Delete: { Objects: objects.map((obj) => ({ Key: obj.Key })), Quiet: true, }, }) ); totalDeleted += objects.length; console.log(`Deleted ${objects.length} objects`); } continuationToken = listResponse.NextContinuationToken; } while (continuationToken); console.log(`All objects with prefix "${prefix}" were deleted. Total: ${totalDeleted}`); return totalDeleted; } ================================================ FILE: app/api/export/docx/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server'; import { Lexer } from 'marked'; import { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, HeadingLevel, AlignmentType, BorderStyle, WidthType, ShadingType, ExternalHyperlink, LevelFormat, convertInchesToTwip, PageBreak, IStylesOptions, INumberingOptions, } from 'docx'; interface DocxExportMeta { modelLabel?: string; createdAt?: string | number | Date; } interface DocxExportBody { title?: string | null; content: string; meta?: DocxExportMeta; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isString(value: unknown): value is string { return typeof value === 'string'; } function parseDocxExportBody(value: unknown): DocxExportBody | null { if (!isRecord(value) || !isString(value.content) || !value.content.trim()) return null; const title = isString(value.title) ? value.title : value.title === null ? null : undefined; const meta = isRecord(value.meta) ? value.meta : undefined; return { title, content: value.content, meta: { modelLabel: isString(meta?.modelLabel) ? meta?.modelLabel : undefined, createdAt: typeof meta?.createdAt === 'string' || typeof meta?.createdAt === 'number' || meta?.createdAt instanceof Date ? meta?.createdAt : undefined, }, }; } // Preprocess markdown for citations and convert LaTeX to readable text function preprocessMarkdown(md: string): string { if (!md) return ''; // Convert display math \[...\] to readable text FIRST (before markdown parsing) md = md.replace(/\\\[([\s\S]*?)\\\]/g, (_m: string, latex: string) => { return simplifyLatex(latex); }); // Convert display math $$...$$ to readable text md = md.replace(/\$\$([\s\S]*?)\$\$/g, (_m: string, latex: string) => { return simplifyLatex(latex); }); // Convert inline math $...$ to readable text md = md.replace(/\$([^$\n]+)\$/g, (_m: string, latex: string) => { return simplifyLatex(latex); }); // Convert inline math \(...\) to readable text md = md.replace(/\\\(([\s\S]*?)\\\)/g, (_m: string, latex: string) => { return simplifyLatex(latex); }); // Normalize matrix environments md = md.replace(/\\begin\{bmatrix\}([\s\S]*?)\\end\{bmatrix\}/g, (_m: string, content: string) => { const norm = content .replace(/(?:\\\\|\\cr|\\0|\\n)/g, '; ') .replace(/&/g, ', ') .replace(/\s+/g, ' ') .trim(); return `[${norm}]`; }); md = md.replace(/\\begin\{pmatrix\}([\s\S]*?)\\end\{pmatrix\}/g, (_m: string, content: string) => { const norm = content .replace(/(?:\\\\|\\cr|\\0|\\n)/g, '; ') .replace(/&/g, ', ') .replace(/\s+/g, ' ') .trim(); return `(${norm})`; }); // Extract footnote definitions const footnoteDefs: Record = {}; md = md.replace( /^\[\^([^\]]+)\]:\s*([\s\S]*?)(?=\n{2,}|\n\[\^|\s*$)/gm, (_m: string, lbl: string, txt: string) => { footnoteDefs[String(lbl)] = String(txt).trim(); return ''; }, ); // Replace footnote references with numeric indices const footnoteOrder: string[] = []; md = md.replace(/\[\^([^\]]+)\]/g, (_m: string, lbl: string) => { const label = String(lbl); let idx = footnoteOrder.indexOf(label); if (idx === -1) { footnoteOrder.push(label); idx = footnoteOrder.length - 1; } return `[${idx + 1}]`; }); // Replace pandoc-style citations [@key] with numeric indices const citationOrder: string[] = []; md = md.replace(/\[@([^\]]+)\]/g, (_m: string, key: string) => { const k = String(key); let idx = citationOrder.indexOf(k); if (idx === -1) { citationOrder.push(k); idx = citationOrder.length - 1; } return `[${idx + 1}]`; }); // Append Notes section let appendix = ''; if (footnoteOrder.length) { appendix += `\n\n## Notes`; footnoteOrder.forEach((label, i) => { const text = footnoteDefs[label] || label; appendix += `\n- [${i + 1}] ${text}`; }); } return md + appendix; } // Simplify LaTeX to readable text function simplifyLatex(lx: string): string { return (lx || '') // Spacing commands .replace(/\\,|\\;|\\:|\\quad|\\qquad/g, ' ') .replace(/\\displaystyle|\\textstyle|\\scriptstyle|\\left|\\right/g, '') // Text and formatting commands .replace(/\\text\{([^}]*)\}/g, '$1') .replace(/\\textbf\{([^}]*)\}/g, '$1') .replace(/\\textit\{([^}]*)\}/g, '$1') .replace(/\\mathrm\{([^}]*)\}/g, '$1') .replace(/\\mathbf\{([^}]*)\}/g, '$1') .replace(/\\mathit\{([^}]*)\}/g, '$1') .replace(/\\mathcal\{([^}]*)\}/g, '$1') .replace(/\\mathbb\{([^}]*)\}/g, '$1') .replace(/\\operatorname\{([^}]*)\}/g, '$1') .replace(/\\operatorname\*\{([^}]*)\}/g, '$1') // Fractions and roots .replace(/\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '($1)/($2)') .replace(/\\dfrac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '($1)/($2)') .replace(/\\tfrac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '($1)/($2)') .replace(/\\sqrt\[([^\]]+)\]\{([^}]*)\}/g, '($2)^(1/$1)') .replace(/\\sqrt\{([^}]*)\}/g, '√($1)') // Greek letters (lowercase) .replace(/\\alpha\b/g, 'α') .replace(/\\beta\b/g, 'β') .replace(/\\gamma\b/g, 'γ') .replace(/\\delta\b/g, 'δ') .replace(/\\epsilon\b/g, 'ε') .replace(/\\varepsilon\b/g, 'ε') .replace(/\\zeta\b/g, 'ζ') .replace(/\\eta\b/g, 'η') .replace(/\\theta\b/g, 'θ') .replace(/\\vartheta\b/g, 'θ') .replace(/\\iota\b/g, 'ι') .replace(/\\kappa\b/g, 'κ') .replace(/\\lambda\b/g, 'λ') .replace(/\\mu\b/g, 'μ') .replace(/\\nu\b/g, 'ν') .replace(/\\xi\b/g, 'ξ') .replace(/\\pi\b/g, 'π') .replace(/\\rho\b/g, 'ρ') .replace(/\\sigma\b/g, 'σ') .replace(/\\tau\b/g, 'τ') .replace(/\\upsilon\b/g, 'υ') .replace(/\\phi\b/g, 'φ') .replace(/\\varphi\b/g, 'φ') .replace(/\\chi\b/g, 'χ') .replace(/\\psi\b/g, 'ψ') .replace(/\\omega\b/g, 'ω') // Greek letters (uppercase) .replace(/\\Gamma\b/g, 'Γ') .replace(/\\Delta\b/g, 'Δ') .replace(/\\Theta\b/g, 'Θ') .replace(/\\Lambda\b/g, 'Λ') .replace(/\\Xi\b/g, 'Ξ') .replace(/\\Pi\b/g, 'Π') .replace(/\\Sigma\b/g, 'Σ') .replace(/\\Phi\b/g, 'Φ') .replace(/\\Psi\b/g, 'Ψ') .replace(/\\Omega\b/g, 'Ω') // Math operators and symbols .replace(/\\infty\b/g, '∞') .replace(/\\sum\b/g, 'Σ') .replace(/\\prod\b/g, 'Π') .replace(/\\int\b/g, '∫') .replace(/\\partial\b/g, '∂') .replace(/\\nabla\b/g, '∇') .replace(/\\times\b/g, '×') .replace(/\\cdot\b/g, '·') .replace(/\\cdots\b/g, '···') .replace(/\\ldots\b/g, '...') .replace(/\\dots\b/g, '...') .replace(/\\vdots\b/g, '⋮') .replace(/\\ddots\b/g, '⋱') .replace(/\\leq\b/g, '≤') .replace(/\\le\b/g, '≤') .replace(/\\geq\b/g, '≥') .replace(/\\ge\b/g, '≥') .replace(/\\neq\b/g, '≠') .replace(/\\ne\b/g, '≠') .replace(/\\approx\b/g, '≈') .replace(/\\sim\b/g, '~') .replace(/\\equiv\b/g, '≡') .replace(/\\pm\b/g, '±') .replace(/\\mp\b/g, '∓') .replace(/\\div\b/g, '÷') .replace(/\\to\b/g, '→') .replace(/\\rightarrow\b/g, '→') .replace(/\\leftarrow\b/g, '←') .replace(/\\Rightarrow\b/g, '⇒') .replace(/\\Leftarrow\b/g, '⇐') .replace(/\\iff\b/g, '⟺') .replace(/\\forall\b/g, '∀') .replace(/\\exists\b/g, '∃') .replace(/\\in\b/g, '∈') .replace(/\\notin\b/g, '∉') .replace(/\\subset\b/g, '⊂') .replace(/\\subseteq\b/g, '⊆') .replace(/\\supset\b/g, '⊃') .replace(/\\supseteq\b/g, '⊇') .replace(/\\cup\b/g, '∪') .replace(/\\cap\b/g, '∩') .replace(/\\emptyset\b/g, '∅') .replace(/\\varnothing\b/g, '∅') .replace(/\\neg\b/g, '¬') .replace(/\\land\b/g, '∧') .replace(/\\lor\b/g, '∨') .replace(/\\oplus\b/g, '⊕') .replace(/\\otimes\b/g, '⊗') .replace(/\\perp\b/g, '⊥') .replace(/\\parallel\b/g, '∥') .replace(/\\angle\b/g, '∠') .replace(/\\circ\b/g, '°') .replace(/\\degree\b/g, '°') .replace(/\\prime\b/g, '′') // Brackets .replace(/\\langle\b/g, '⟨') .replace(/\\rangle\b/g, '⟩') .replace(/\\lfloor\b/g, '⌊') .replace(/\\rfloor\b/g, '⌋') .replace(/\\lceil\b/g, '⌈') .replace(/\\rceil\b/g, '⌉') .replace(/\\vert\b/g, '|') .replace(/\\mid\b/g, '|') .replace(/\\\\\|/g, '‖') // Superscripts .replace(/\^(\{[^}]+\}|\w)/g, (_m, exp) => { const e = exp.startsWith('{') ? exp.slice(1, -1) : exp; const superscriptMap: Record = { '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', 'n': 'ⁿ', 'i': 'ⁱ', '+': '⁺', '-': '⁻', 'T': 'ᵀ', 'a': 'ᵃ', 'b': 'ᵇ', 'c': 'ᶜ', 'd': 'ᵈ', 'e': 'ᵉ', 'f': 'ᶠ', 'g': 'ᵍ', 'h': 'ʰ', 'j': 'ʲ', 'k': 'ᵏ', 'l': 'ˡ', 'm': 'ᵐ', 'o': 'ᵒ', 'p': 'ᵖ', 'r': 'ʳ', 's': 'ˢ', 't': 'ᵗ', 'u': 'ᵘ', 'v': 'ᵛ', 'w': 'ʷ', 'x': 'ˣ', 'y': 'ʸ', 'z': 'ᶻ', }; return e.split('').map((c: string) => superscriptMap[c] || `^${c}`).join(''); }) // Subscripts .replace(/_(\{[^}]+\}|\w)/g, (_m, sub) => { const s = sub.startsWith('{') ? sub.slice(1, -1) : sub; const subscriptMap: Record = { '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉', 'i': 'ᵢ', 'j': 'ⱼ', 'n': 'ₙ', '+': '₊', '-': '₋', 'a': 'ₐ', 'e': 'ₑ', 'o': 'ₒ', 'x': 'ₓ', 'h': 'ₕ', 'k': 'ₖ', 'l': 'ₗ', 'm': 'ₘ', 'p': 'ₚ', 's': 'ₛ', 't': 'ₜ', 'r': 'ᵣ', 'u': 'ᵤ', 'v': 'ᵥ', }; return s.split('').map((c: string) => subscriptMap[c] || `_${c}`).join(''); }) // Clean up remaining backslash commands and braces .replace(/\\\\/g, ' ') .replace(/\\[a-zA-Z]+\*?/g, ' ') .replace(/[{}]/g, '') .replace(/\s+/g, ' ') .trim(); } // Process inline text and return TextRun children function processInlineText( text: string, bold = false, italic = false, ): (TextRun | ExternalHyperlink)[] { const runs: (TextRun | ExternalHyperlink)[] = []; // Process display math blocks \[...\] first let processed = text.replace(/\\\[([\s\S]*?)\\\]/g, (_m, latex) => simplifyLatex(latex)); // Process display math blocks $$...$$ processed = processed.replace(/\$\$([\s\S]*?)\$\$/g, (_m, latex) => simplifyLatex(latex)); // Process inline math ($...$) processed = processed.replace(/\$([^$]+)\$/g, (_m, latex) => simplifyLatex(latex)); // Process inline math \(...\) processed = processed.replace(/\\\(([^\)]+)\\\)/g, (_m, latex) => simplifyLatex(latex)); // Process markdown links [text](url) const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; let lastIndex = 0; let match; while ((match = linkRegex.exec(processed)) !== null) { // Add text before the link if (match.index > lastIndex) { const beforeText = processed.slice(lastIndex, match.index); if (beforeText) { runs.push(new TextRun({ text: beforeText, bold, italics: italic })); } } // Add the hyperlink const linkText = match[1]; const linkUrl = match[2]; runs.push( new ExternalHyperlink({ children: [ new TextRun({ text: linkText, style: 'Hyperlink', bold, italics: italic, }), ], link: linkUrl, }), ); lastIndex = match.index + match[0].length; } // Add remaining text after last link if (lastIndex < processed.length) { const remainingText = processed.slice(lastIndex); if (remainingText) { runs.push(new TextRun({ text: remainingText, bold, italics: italic })); } } // If no links were found, just add the whole text if (runs.length === 0 && processed) { runs.push(new TextRun({ text: processed, bold, italics: italic })); } return runs; } // Flatten inline tokens to TextRun children function flattenInlineTokens( tokens: any[] | undefined, bold = false, italic = false, ): (TextRun | ExternalHyperlink)[] { if (!tokens || !Array.isArray(tokens)) return []; const runs: (TextRun | ExternalHyperlink)[] = []; for (const t of tokens) { switch (t.type) { case 'text': { const txt = String(t.text ?? t.raw ?? '').replace(/\r?\n/g, ' '); if (txt) runs.push(...processInlineText(txt, bold, italic)); break; } case 'strong': { const inner = t.tokens ?? [{ type: 'text', text: t.text }]; runs.push(...flattenInlineTokens(inner, true, italic)); break; } case 'em': { const inner = t.tokens ?? [{ type: 'text', text: t.text }]; runs.push(...flattenInlineTokens(inner, bold, true)); break; } case 'codespan': { const txt = String(t.text ?? ''); if (txt) { runs.push( new TextRun({ text: txt, font: 'Consolas', size: 20, // 10pt shading: { type: ShadingType.CLEAR, fill: 'F0F0F0' }, }), ); } break; } case 'link': { const linkText = String(t.text ?? t.href ?? t.raw ?? ''); const href = String(t.href ?? ''); if (linkText && href) { runs.push( new ExternalHyperlink({ children: [ new TextRun({ text: linkText, style: 'Hyperlink', bold, italics: italic, }), ], link: href, }), ); } break; } case 'escape': { const txt = String(t.text ?? '').trim() || String(t.raw ?? '').replace(/^\\/, ''); if (txt) runs.push(new TextRun({ text: txt, bold, italics: italic })); break; } case 'space': case 'br': { runs.push(new TextRun({ text: ' ', bold, italics: italic })); break; } case 'paragraph': { // Recursively process paragraph's inner tokens const inner = t.tokens ?? [{ type: 'text', text: t.text }]; runs.push(...flattenInlineTokens(inner, bold, italic)); break; } case 'list_item': { // Handle list item tokens const inner = t.tokens ?? [{ type: 'text', text: t.text }]; runs.push(...flattenInlineTokens(inner, bold, italic)); break; } default: { const txt = String(t.text ?? t.raw ?? '').replace(/\r?\n/g, ' '); if (txt) runs.push(...processInlineText(txt, bold, italic)); break; } } } return runs; } export async function POST(req: NextRequest) { try { const body = parseDocxExportBody(await req.json()); if (!body) { return NextResponse.json({ error: 'Invalid content' }, { status: 400 }); } const title = body.title ?? 'Scira AI'; const rawContent = body.content; const meta = body.meta ?? {}; // Preprocess markdown const content = preprocessMarkdown(rawContent); // Track citations for references section const citationIndex = new Map(); const citationText = new Map(); // Parse markdown into tokens const tokens: any[] = Lexer.lex(content); const children: (Paragraph | Table)[] = []; // Add title children.push( new Paragraph({ children: [new TextRun({ text: title, bold: true, size: 32 })], // 16pt heading: HeadingLevel.TITLE, spacing: { after: 200 }, }), ); // Add metadata const metaLines: string[] = []; if (meta.modelLabel) metaLines.push(`Model: ${meta.modelLabel}`); if (meta.createdAt) metaLines.push(`Date: ${new Date(meta.createdAt).toLocaleString()}`); if (metaLines.length) { children.push( new Paragraph({ children: [new TextRun({ text: metaLines.join(' • '), color: '666666', size: 20 })], // 10pt spacing: { after: 400 }, }), ); } // Add separator line children.push( new Paragraph({ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'CCCCCC' }, }, spacing: { after: 400 }, }), ); // Process markdown tokens for (const tk of tokens) { switch (tk.type) { case 'heading': { const depth = tk.depth ?? tk.level ?? 1; const headingLevels: Record = { 1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4, 5: HeadingLevel.HEADING_5, 6: HeadingLevel.HEADING_6, }; const level = headingLevels[Math.max(1, Math.min(6, depth))] || HeadingLevel.HEADING_1; const inlineRuns = tk.tokens ? flattenInlineTokens(tk.tokens, true) : [new TextRun({ text: tk.text || '', bold: true })]; children.push( new Paragraph({ children: inlineRuns, heading: level, spacing: { before: 240, after: 120 }, }), ); break; } case 'paragraph': { const inlineRuns = tk.tokens ? flattenInlineTokens(tk.tokens) : processInlineText(tk.text || ''); // Extract links for citations if (tk.tokens) { for (const t of tk.tokens) { if (t.type === 'link' && t.href) { let num = citationIndex.get(t.href); if (num == null) { num = citationIndex.size + 1; citationIndex.set(t.href, num); } if (t.text) citationText.set(t.href, t.text); } } } children.push( new Paragraph({ children: inlineRuns, spacing: { after: 200 }, }), ); break; } case 'blockquote': { const text = tk.text || ''; children.push( new Paragraph({ children: [new TextRun({ text, italics: true, color: '666666' })], indent: { left: convertInchesToTwip(0.5) }, border: { left: { style: BorderStyle.SINGLE, size: 24, color: 'CCCCCC' }, }, spacing: { after: 200 }, }), ); break; } case 'code': { const codeText = String(tk.text || ''); const codeLines = codeText.split('\n'); for (const line of codeLines) { children.push( new Paragraph({ children: [ new TextRun({ text: line || ' ', font: 'Consolas', size: 18, // 9pt }), ], shading: { type: ShadingType.CLEAR, fill: 'F5F5F5' }, indent: { left: convertInchesToTwip(0.25), right: convertInchesToTwip(0.25) }, spacing: { before: line === codeLines[0] ? 100 : 0, after: line === codeLines[codeLines.length - 1] ? 100 : 0 }, }), ); } children.push(new Paragraph({ spacing: { after: 200 } })); break; } case 'list': { const isOrdered = tk.ordered; let counter = tk.start || 1; for (const item of tk.items || []) { const bulletText = isOrdered ? `${counter}.` : '•'; // Get inline runs from the list item let inlineRuns: (TextRun | ExternalHyperlink)[] = []; if (item.tokens && item.tokens.length > 0) { // Process each token in the item for (const itemToken of item.tokens) { if (itemToken.type === 'text') { // Parse the text for inline markdown const inlineTokens = Lexer.lexInline(itemToken.text || ''); inlineRuns.push(...flattenInlineTokens(inlineTokens)); } else if (itemToken.type === 'paragraph' && itemToken.tokens) { inlineRuns.push(...flattenInlineTokens(itemToken.tokens)); } else if (itemToken.tokens) { inlineRuns.push(...flattenInlineTokens(itemToken.tokens)); } else { inlineRuns.push(...flattenInlineTokens([itemToken])); } } } else if (item.text) { // Fallback: parse the raw text for inline markdown const inlineTokens = Lexer.lexInline(item.text); inlineRuns.push(...flattenInlineTokens(inlineTokens)); } children.push( new Paragraph({ children: [ new TextRun({ text: `${bulletText} ` }), ...inlineRuns, ], indent: { left: convertInchesToTwip(0.5), hanging: convertInchesToTwip(0.25) }, spacing: { after: 80 }, }), ); if (isOrdered) counter++; } children.push(new Paragraph({ spacing: { after: 120 } })); break; } case 'table': { const headers = Array.isArray(tk.header) ? tk.header : []; const rows = Array.isArray(tk.rows) ? tk.rows : []; const nCols = Math.max(headers.length, rows[0]?.length || 0); if (nCols === 0) break; const colWidth = Math.floor(9360 / nCols); // US Letter content width in DXA const columnWidths = Array(nCols).fill(colWidth); const border = { style: BorderStyle.SINGLE, size: 1, color: 'CCCCCC' }; const borders = { top: border, bottom: border, left: border, right: border }; const tableRows: TableRow[] = []; // Header row if (headers.length) { tableRows.push( new TableRow({ children: headers.map((cell: any, i: number) => { const cellText = typeof cell === 'string' ? cell : String(cell?.text ?? cell?.raw ?? ''); return new TableCell({ borders, width: { size: columnWidths[i], type: WidthType.DXA }, shading: { fill: 'E8E8F0', type: ShadingType.CLEAR }, margins: { top: 80, bottom: 80, left: 120, right: 120 }, children: [ new Paragraph({ children: [new TextRun({ text: cellText, bold: true })], }), ], }); }), }), ); } // Body rows for (const row of rows) { tableRows.push( new TableRow({ children: (row as any[]).map((cell: any, i: number) => { const cellTokens = cell?.tokens; const inlineRuns = cellTokens ? flattenInlineTokens(cellTokens) : processInlineText(typeof cell === 'string' ? cell : String(cell?.text ?? cell?.raw ?? '')); return new TableCell({ borders, width: { size: columnWidths[i], type: WidthType.DXA }, margins: { top: 80, bottom: 80, left: 120, right: 120 }, children: [new Paragraph({ children: inlineRuns })], }); }), }), ); } children.push( new Table({ width: { size: 9360, type: WidthType.DXA }, columnWidths, rows: tableRows, }), ); children.push(new Paragraph({ spacing: { after: 300 } })); break; } case 'hr': { children.push( new Paragraph({ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: '999999' }, }, spacing: { before: 200, after: 200 }, }), ); break; } default: { if (tk.type === 'text' && tk.text) { children.push( new Paragraph({ children: processInlineText(tk.text), spacing: { after: 200 }, }), ); } break; } } } // Add References section const citations = Array.from(citationText.entries()); if (citations.length > 0) { children.push(new Paragraph({ spacing: { after: 400 } })); children.push( new Paragraph({ children: [new TextRun({ text: 'References', bold: true, size: 28 })], // 14pt heading: HeadingLevel.HEADING_2, spacing: { before: 400, after: 200 }, }), ); const refs = citations .map(([href, label]) => ({ href, label, num: citationIndex.get(href) || Infinity })) .filter((r) => r.num !== Infinity) .sort((a, b) => a.num - b.num); for (const { href, label, num } of refs) { let hostname = ''; try { hostname = new URL(String(href)).hostname; } catch { hostname = String(href).replace(/^https?:\/\/(www\.)?/, '').split('/')[0]; } children.push( new Paragraph({ children: [ new TextRun({ text: `[${num}] `, size: 20 }), new ExternalHyperlink({ children: [new TextRun({ text: label, style: 'Hyperlink', size: 20 })], link: href, }), new TextRun({ text: ` (${hostname})`, color: '666666', size: 18 }), ], spacing: { after: 80 }, }), ); } } // Define styles const styles: IStylesOptions = { default: { document: { run: { font: 'Arial', size: 24, // 12pt }, }, heading1: { run: { font: 'Arial', size: 32, // 16pt bold: true, }, paragraph: { spacing: { before: 240, after: 120 }, }, }, heading2: { run: { font: 'Arial', size: 28, // 14pt bold: true, }, paragraph: { spacing: { before: 200, after: 100 }, }, }, heading3: { run: { font: 'Arial', size: 26, // 13pt bold: true, }, paragraph: { spacing: { before: 160, after: 80 }, }, }, }, characterStyles: [ { id: 'Hyperlink', name: 'Hyperlink', basedOn: 'DefaultParagraphFont', run: { color: '2563EB', underline: { type: 'single' }, }, }, ], }; // Create document const doc = new Document({ styles, sections: [ { properties: { page: { size: { width: 12240, // 8.5 inches in DXA height: 15840, // 11 inches in DXA }, margin: { top: 1440, // 1 inch right: 1440, bottom: 1440, left: 1440, }, }, }, children, }, ], }); // Generate buffer const buffer = await Packer.toBuffer(doc); // Convert Buffer to ArrayBuffer for Response compatibility const arrayBuffer = new ArrayBuffer(buffer.byteLength); const view = new Uint8Array(arrayBuffer); view.set(new Uint8Array(buffer)); const filename = 'scira-export.docx'; return new Response(arrayBuffer, { status: 200, headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-store, no-cache, must-revalidate', Pragma: 'no-cache', }, }); } catch (e: any) { console.error('DOCX export error:', e); return NextResponse.json({ error: e?.message || 'Failed to generate DOCX' }, { status: 500 }); } } ================================================ FILE: app/api/export/pdf/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server'; import { PDFDocument, StandardFonts, rgb, PDFName, PDFString } from 'pdf-lib'; import { Lexer } from 'marked'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import fontkit from '@pdf-lib/fontkit'; import sharp from 'sharp'; import { mathjax } from '@mathjax/src/mjs/mathjax.js'; import { TeX } from '@mathjax/src/mjs/input/tex.js'; import { SVG } from '@mathjax/src/mjs/output/svg.js'; import { MathJaxNewcmFont } from '@mathjax/mathjax-newcm-font/mjs/svg.js'; import { liteAdaptor } from '@mathjax/src/mjs/adaptors/liteAdaptor.js'; import { RegisterHTMLHandler } from '@mathjax/src/mjs/handlers/html.js'; import '@mathjax/src/mjs/util/asyncLoad/esm.js'; import '@mathjax/src/mjs/input/tex/base/BaseConfiguration.js'; import '@mathjax/src/mjs/input/tex/ams/AmsConfiguration.js'; function wrapText(text: string, widthFn: (s: string) => number, maxWidth: number): string[] { const lines: string[] = []; const paragraphs = text.split(/\n{2,}/); // Helper function to break a long word at character level const breakLongWord = (word: string): string[] => { const parts: string[] = []; let current = ''; for (let i = 0; i < word.length; i++) { const char = word[i]; const tentative = current + char; if (widthFn(tentative) > maxWidth) { if (current) { parts.push(current); current = char; } else { // Even a single character is too wide, just add it parts.push(char); current = ''; } } else { current = tentative; } } if (current) parts.push(current); return parts; }; for (const para of paragraphs) { const words = para.split(/\s+/); let line = ''; for (const word of words) { const tentative = line ? `${line} ${word}` : word; if (widthFn(tentative) > maxWidth) { if (line) { lines.push(line); line = ''; } // Check if the word itself is too long if (widthFn(word) > maxWidth) { // Break the long word into smaller parts const wordParts = breakLongWord(word); for (let i = 0; i < wordParts.length; i++) { if (i === wordParts.length - 1) { // Last part becomes the new line line = wordParts[i]; } else { // Add complete parts as separate lines lines.push(wordParts[i]); } } } else { line = word; } } else { line = tentative; } } if (line) lines.push(line); // Add an empty line between paragraphs lines.push(''); } // Remove trailing empty line if (lines.length && lines[lines.length - 1] === '') lines.pop(); return lines; } function measureTextWidth(font: any, text: string, size: number, fallbackMultiplier = 0.5) { try { return font.widthOfTextAtSize(text, size); } catch { return text.length * size * fallbackMultiplier; } } function extractMathSvg(markup: string) { const match = markup.match(/]*>[\s\S]*?<\/svg>/); if (!match) throw new Error('No SVG element found in MathJax output'); return match[0]; } function cleanMathSvg( svg: string, options: { removeRectElements?: boolean; removeLineElements?: boolean; removeRectStrokes?: boolean; removeLineStrokes?: boolean; }, ) { let out = svg; out = out.replace(/stroke="[^"]*"/g, ''); out = out.replace(/stroke-width="[^"]*"/g, ''); out = out.replace(/stroke-opacity="[^"]*"/g, ''); out = out.replace(/stroke-dasharray="[^"]*"/g, ''); out = out.replace(/stroke-linecap="[^"]*"/g, ''); out = out.replace(/stroke-linejoin="[^"]*"/g, ''); out = out.replace(/outline="[^"]*"/g, ''); out = out.replace(/border="[^"]*"/g, ''); out = out.replace(/fill-opacity="[^"]*"/g, ''); out = out.replace(/opacity="[^"]*"/g, ''); out = out.replace(/style="([^"]*)"/g, (_match, styles) => { const cleaned = String(styles) .replace(/stroke[^;]*;?/g, '') .replace(/border[^;]*;?/g, '') .replace(/outline[^;]*;?/g, '') .replace(/;;+/g, ';') .replace(/^;|;$/g, ''); return cleaned ? `style="${cleaned}"` : ''; }); if (options.removeRectElements) { out = out.replace(/]*\/>/g, ''); out = out.replace(/]*>[\s\S]*?<\/rect>/g, ''); } if (options.removeLineElements) { out = out.replace(/]*\/>/g, ''); out = out.replace(/]*>[\s\S]*?<\/line>/g, ''); } if (options.removeRectStrokes) out = out.replace(/]*stroke[^>]*>/g, ''); if (options.removeLineStrokes) out = out.replace(/]*stroke[^>]*>/g, ''); return out; } interface PdfExportMeta { modelLabel?: string; createdAt?: string | number | Date; } interface PdfExportBody { title?: string | null; content: string; meta?: PdfExportMeta; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isString(value: unknown): value is string { return typeof value === 'string'; } function parsePdfExportBody(value: unknown): PdfExportBody | null { if (!isRecord(value) || !isString(value.content) || !value.content.trim()) return null; const title = isString(value.title) ? value.title : value.title === null ? null : undefined; const meta = isRecord(value.meta) ? value.meta : undefined; return { title, content: value.content, meta: { modelLabel: isString(meta?.modelLabel) ? meta?.modelLabel : undefined, createdAt: typeof meta?.createdAt === 'string' || typeof meta?.createdAt === 'number' || meta?.createdAt instanceof Date ? meta?.createdAt : undefined, }, }; } export async function POST(req: NextRequest) { try { const body = parsePdfExportBody(await req.json()); if (!body) { return NextResponse.json({ error: 'Invalid content' }, { status: 400 }); } const title = body.title ?? null; const rawContent = body.content; const meta = body.meta ?? {}; // Utility: preprocess markdown for citations and math markers function preprocessMarkdownForCitationsAndMath(md: string): string { if (!md) return ''; // Extract footnote definitions const footnoteDefs: Record = {}; md = md.replace( /^\[\^([^\]]+)\]:\s*([\s\S]*?)(?=\n{2,}|\n\[\^|\s*$)/gm, (_m: string, lbl: string, txt: string) => { footnoteDefs[String(lbl)] = String(txt).trim(); return ''; }, ); // Replace footnote references with numeric indices const footnoteOrder: string[] = []; md = md.replace(/\[\^([^\]]+)\]/g, (_m: string, lbl: string) => { const label = String(lbl); let idx = footnoteOrder.indexOf(label); if (idx === -1) { footnoteOrder.push(label); idx = footnoteOrder.length - 1; } return `[${idx + 1}]`; }); // Replace pandoc-style citations [@key] with numeric indices const citationOrder: string[] = []; md = md.replace(/\[@([^\]]+)\]/g, (_m: string, key: string) => { const k = String(key); let idx = citationOrder.indexOf(k); if (idx === -1) { citationOrder.push(k); idx = citationOrder.length - 1; } return `[${idx + 1}]`; }); // Normalize display math: convert $$...$$ blocks to \\[...\\] for consistent parsing md = md.replace(/\$\$([\s\S]*?)\$\$/g, (_m: string, block: string) => `\\[${block.trim()}\\]`); // Normalize common matrix environments inline to readable ASCII so they never leak raw LaTeX md = md.replace(/\\begin\{bmatrix\}([\s\S]*?)\\end\{bmatrix\}/g, (_m: string, content: string) => { const norm = content .replace(/(?:\\\\|\\cr|\\0|\\n)/g, '; ') // row breaks .replace(/&/g, ', ') // columns .replace(/\s+/g, ' ') // collapse whitespace .trim(); return `[${norm}]`; }); md = md.replace(/\\begin\{pmatrix\}([\s\S]*?)\\end\{pmatrix\}/g, (_m: string, content: string) => { const norm = content .replace(/(?:\\\\|\\cr|\\0|\\n)/g, '; ') .replace(/&/g, ', ') .replace(/\s+/g, ' ') .trim(); return `(${norm})`; }); // Append Notes section only (References are added later with proper formatting and links) let appendix = ''; if (footnoteOrder.length) { appendix += `\n\n## Notes`; footnoteOrder.forEach((label, i) => { const text = footnoteDefs[label] || label; appendix += `\n- [${i + 1}] ${text}`; }); } // Citations are handled separately in the References section with clickable links return md + appendix; } // Preprocess markdown for citations and display math const content: string = preprocessMarkdownForCitationsAndMath(rawContent); const pdfDoc = await PDFDocument.create(); (pdfDoc as any).registerFontkit(fontkit); // Load Geist fonts for better Unicode support and modern design const geistRegularPath = path.join(process.cwd(), 'app/api/export/pdf/fonts/Geist-Regular.ttf'); const geistBoldPath = path.join(process.cwd(), 'app/api/export/pdf/fonts/Geist-Bold.ttf'); const geistItalicPath = path.join(process.cwd(), 'app/api/export/pdf/fonts/Geist-RegularItalic.ttf'); const geistMonoPath = path.join(process.cwd(), 'app/api/export/pdf/fonts/GeistMono-Regular.ttf'); const font = await pdfDoc.embedFont(readFileSync(geistRegularPath)); const fontBold = await pdfDoc.embedFont(readFileSync(geistBoldPath)); const fontItalic = await pdfDoc.embedFont(readFileSync(geistItalicPath)); const fontCode = await pdfDoc.embedFont(readFileSync(geistMonoPath)); const fontSize = 12; const lineGap = 6; const margin = 50; const pageWidth = 595; // A4 width in points const pageHeight = 842; // A4 height in points // Check if a character is supported by a font const isSupportedByFont = (ch: string, testFont: any): boolean => { if (!testFont) return false; try { const width = testFont.widthOfTextAtSize(ch, 10); // Return false if width is 0 or negative (glyph not found in font) return width !== undefined && width > 0; } catch { return false; } }; // Draw text with font fallback support (tries main font, then symbol fonts) const drawTextWithFallback = (text: string, x: number, y: number, size: number, mainFont: any, color: any) => { if (!text) return; let currentX = x; for (const char of Array.from(text)) { if (char === '\t') { try { currentX += mainFont.widthOfTextAtSize(' ', size); } catch { currentX += size; } continue; } if (char === '\n') continue; let fontToUse = mainFont; // Use main font (Geist has excellent Unicode coverage) if (!isSupportedByFont(char, mainFont)) { // Log unsupported character for debugging const charCode = char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0'); console.warn(`Unsupported character: '${char}' (U+${charCode}) in Geist font`); continue; } try { page.drawText(char, { x: currentX, y: y, size: size, font: fontToUse, color: color, }); currentX += fontToUse.widthOfTextAtSize(char, size); } catch (err) { // Log error but continue rendering console.warn(`Failed to draw character '${char}':`, err); } } }; // Sanitize text to remove completely unsupported glyphs const sanitizeForFont = (text: string, testFont: any): string => { if (!text) return ''; return Array.from(text) .map((ch) => { if (ch === '\t') return ' '; if (ch === '\n') return ' '; // Keep character if supported by font if (isSupportedByFont(ch, testFont)) { return ch; } return ''; }) .join(''); }; // spacing constants for consistent vertical rhythm const SPACE_BEFORE_HEADING = 10; const SPACE_AFTER_HEADING = 8; const SPACE_AFTER_PARAGRAPH = 8; const SPACE_BEFORE_TABLE = 10; const SPACE_AFTER_TABLE = 18; const SPACE_AFTER_LIST = 8; const SPACE_AFTER_BLOCKQUOTE = 8; const SPACE_AFTER_CODE = 24; const KEEP_WITH_NEXT_MIN_SPACE_TABLE = 140; const KEEP_WITH_NEXT_MIN_SPACE_GENERIC = 60; const MATH_SVG_SCALE_FACTOR = 4; const MATH_DISPLAY_MAX_WIDTH_RATIO = 0.95; const MATH_DISPLAY_MAX_HEIGHT_RATIO = 0.28; const MATH_DISPLAY_MIN_HEIGHT_MULTIPLIER = 1.4; const MATH_DISPLAY_SPACING_MULTIPLIER = 0.65; const MATH_INLINE_HEIGHT_MULTIPLIER = 0.95; const MATH_INLINE_MIN_HEIGHT_MULTIPLIER = 0.75; const MATH_INLINE_MAX_WIDTH_MULTIPLIER = 3.5; const MATH_INLINE_BASELINE_MULTIPLIER = 0.36; const addPage = () => pdfDoc.addPage([pageWidth, pageHeight]); let page = addPage(); let y = pageHeight - margin; const addLinkAnnotation = (href: string, x: number, yPos: number, width: number, height: number) => { try { const linkRef = pdfDoc.context.register( pdfDoc.context.obj({ Type: 'Annot', Subtype: 'Link', Rect: [x, yPos, x + width, yPos + height], Border: [0, 0, 0], A: { Type: 'Action', S: 'URI', URI: PDFString.of(href) }, }), ); const existingAnnots: any = page.node.get(PDFName.of('Annots')); if (existingAnnots) (existingAnnots as any).push(linkRef); else page.node.set(PDFName.of('Annots'), pdfDoc.context.obj([linkRef])); } catch { } }; const svgToPngImage = async (svg: string, scaleFactor: number) => { const tempPngBuf = await sharp(Buffer.from(svg)).png().toBuffer(); const tempPngImg = await pdfDoc.embedPng(tempPngBuf); const pngBuf = await sharp(Buffer.from(svg)) .png({ quality: 100 }) .resize({ width: Math.round(tempPngImg.width * scaleFactor), height: Math.round(tempPngImg.height * scaleFactor), fit: 'contain', }) .toBuffer(); const pngImg = await pdfDoc.embedPng(pngBuf); return { pngImg, tempWidth: tempPngImg.width, tempHeight: tempPngImg.height }; }; const drawTextLine = (text: string, bold = false) => { const usedFont = bold ? fontBold : font; drawTextWithFallback(text, margin, y, fontSize, usedFont, rgb(0, 0, 0)); y -= fontSize + lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } }; // Professional header with Scira branding and chat title const drawProfessionalHeader = () => { // One-line: logo + app name/title const titleSize = 16; const logoWidth = 20; const logoColor = rgb(0.15, 0.15, 0.15); // Align logo top to sit above the text baseline (cap height ~0.7x font size) const capHeight = titleSize * 0.7; const baselineAdjust = 25; // nudge downward for visual alignment const logoTop = y + capHeight + baselineAdjust; const logoHeight = drawSciraLogo(margin, logoTop, logoWidth, logoColor); const textX = margin + logoWidth + 8; const headerText = title ?? 'Scira AI'; drawTextWithFallback(headerText, textX, y, titleSize, fontBold, rgb(0, 0, 0)); y -= Math.max(titleSize, logoHeight) + 12; // Metadata — left-aligned, muted const info: string[] = []; if (meta?.modelLabel) info.push(`Model: ${meta.modelLabel}`); if (meta?.createdAt) info.push(`Date: ${new Date(meta.createdAt).toLocaleString()}`); if (info.length) { const infoText = info.join(' • '); const infoSize = 10; drawTextWithFallback(infoText, margin, y, infoSize, font, rgb(0.4, 0.4, 0.4)); y -= infoSize + 16; } // Separator line full width const lineY = y + 8; page.drawLine({ start: { x: margin, y: lineY }, end: { x: pageWidth - margin, y: lineY }, thickness: 0.5, color: rgb(0.85, 0.85, 0.85), }); y -= 16; }; drawProfessionalHeader(); const maxLineWidth = pageWidth - margin * 2; // Calculate text width with Geist font const widthOf = (f: any, size: number) => (s: string) => { if (!s) return 0; let totalWidth = 0; for (const char of Array.from(s)) { if (char === '\t') { try { totalWidth += f.widthOfTextAtSize(' ', size); } catch { totalWidth += size; } continue; } if (char === '\n') continue; let charWidth = 0; if (isSupportedByFont(char, f)) { try { charWidth = f.widthOfTextAtSize(char, size); } catch { charWidth = size * 0.5; } } else { // Character not supported, skip it (width = 0) charWidth = 0; } totalWidth += charWidth; } return totalWidth; }; const fontGreek = await pdfDoc.embedFont(StandardFonts.Symbol); // Track link citations for numbered badges and references list const citationIndex = new Map(); const citationText = new Map(); const drawWrapped = (text: string, opts: { font: any; size: number; indent?: number }) => { const indent = opts.indent ?? 0; const wFn = widthOf(opts.font, opts.size); const lines = wrapText(text, wFn, maxLineWidth - indent); for (const line of lines) { if (line === '') { y -= opts.size; // extra paragraph space if (y <= margin) { page = addPage(); y = pageHeight - margin; } continue; } drawTextWithFallback(line, margin + indent, y, opts.size, opts.font, rgb(0, 0, 0)); y -= opts.size + lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } } }; // Render inline tokens with styles (strong/em/codespan/link) and proper wrapping type Seg = { text: string; font: any; size: number; color?: any; break?: boolean; href?: string; badge?: boolean; center?: boolean; superscript?: boolean; latex?: string; }; const flattenInline = (tokens: any[] | undefined, baseFont: any, baseSize: number): Seg[] => { if (!tokens || !Array.isArray(tokens)) return []; const segs: Seg[] = []; const sanitize = (s: string) => (s || '').replace(/\r?\n/g, ' '); // Helper to accumulate plain text for math buffers const appendText = (txt: string, buf: string) => buf + sanitize(txt); // Iterate with index to support cross-token math sequences like \( ... \) and \[ ... \] for (let idx = 0; idx < tokens.length; idx++) { const t: any = tokens[idx]; const raw = String(t?.raw ?? ''); // Handle display math start: \[ if (t?.type === 'escape' && raw.startsWith('\\[')) { let content = ''; let endIdx = idx + 1; for (; endIdx < tokens.length; endIdx++) { const tt: any = tokens[endIdx]; const r = String(tt?.raw ?? ''); if (tt?.type === 'escape' && r.startsWith('\\]')) break; // Collect text from intervening tokens if (typeof tt?.text === 'string') content = appendText(String(tt.text), content); else if (typeof tt?.raw === 'string') content = appendText(String(tt.raw), content); else if (tt?.type === 'space') content += ' '; } // If we found a closing \\], render centered math block if (endIdx < tokens.length) { const latex = content.trim(); segs.push({ text: '', font: baseFont, size: baseSize, break: true }); segs.push({ text: '', font: baseFont, size: baseSize + 2, center: true, latex }); segs.push({ text: '', font: baseFont, size: baseSize, break: true }); idx = endIdx; // skip through the closing token continue; } // No closing token: fall back to literal segs.push({ text: '(', font: baseFont, size: baseSize }); continue; } // Handle inline math start: \( if (t?.type === 'escape' && raw.startsWith('\\(')) { let content = ''; let endIdx = idx + 1; for (; endIdx < tokens.length; endIdx++) { const tt: any = tokens[endIdx]; const r = String(tt?.raw ?? ''); if (tt?.type === 'escape' && r.startsWith('\\)')) break; if (typeof tt?.text === 'string') content = appendText(String(tt.text), content); else if (typeof tt?.raw === 'string') content = appendText(String(tt.raw), content); else if (tt?.type === 'space') content += ' '; } if (endIdx < tokens.length) { const latex = content.trim(); segs.push({ text: '', font: baseFont, size: baseSize, latex }); idx = endIdx; // skip through the closing token continue; } segs.push({ text: '(', font: baseFont, size: baseSize }); continue; } // Default handling for remaining token types switch (t.type) { case 'text': { const txt = sanitize(String(t.text ?? t.raw ?? '')); if (txt) { const mathSegs = splitMathInline(txt, baseFont, baseSize); if (mathSegs.length) segs.push(...mathSegs); else segs.push({ text: txt, font: baseFont, size: baseSize }); } break; } case 'escape': { // Escaped non-math character; output literal without the backslash const txt = String(t.text ?? '').trim(); const out = txt || raw.replace(/^\\/, ''); if (out) segs.push({ text: out, font: baseFont, size: baseSize }); break; } case 'space': { segs.push({ text: ' ', font: baseFont, size: baseSize }); break; } case 'strong': { const inner = t.tokens ?? [{ type: 'text', text: t.text }]; segs.push(...flattenInline(inner, fontBold, baseSize)); break; } case 'em': { const inner = t.tokens ?? [{ type: 'text', text: t.text }]; segs.push(...flattenInline(inner, fontItalic, baseSize)); break; } case 'codespan': { const txt = sanitize(String(t.text ?? '')); if (txt) segs.push({ text: txt, font: fontCode, size: Math.max(8, baseSize - 1) }); break; } case 'link': { const display = sanitize(String(t.text ?? t.href ?? t.raw ?? '')); const href = String(t.href ?? ''); if (display || href) { let num = citationIndex.get(href); if (num == null) { num = citationIndex.size + 1; citationIndex.set(href, num); } if (display) citationText.set(href, display); const badgeText = String(num); segs.push({ text: badgeText, font: baseFont, size: baseSize * 0.7, href, superscript: true, color: rgb(0.4, 0.4, 0.4), }); } break; } case 'br': { segs.push({ text: '', font: baseFont, size: baseSize, break: true }); break; } case 'paragraph': { const inner = t.tokens ?? [{ type: 'text', text: t.text }]; segs.push(...flattenInline(inner, baseFont, baseSize)); break; } default: { const txt = sanitize(String(t.text ?? t.raw ?? '')); if (txt) segs.push({ text: txt, font: baseFont, size: baseSize }); break; } } } return segs; }; // Helper to simplify common LaTeX macros to ASCII for PDF compatibility const simplifyLatex = (lx: string) => (lx || '') .replace(/\\,|\\;|\\:\s*/g, ' ') .replace(/\\displaystyle|\\textstyle|\\scriptstyle/g, '') .replace(/\\text\{([^}]*)\}/g, '$1') .replace(/\\mathrm\{([^}]*)\}/g, '$1') .replace(/\\begin\{[bp]?matrix\}([\s\S]*?)\\end\{[bp]?matrix\}/g, (_m, content) => { const norm = String(content) .replace(/(?:\\\\|\\cr|\\0|\\n)/g, '; ') .replace(/&/g, ', ') .replace(/\s+/g, ' ') .trim(); return '[' + norm + ']'; }) .replace(/\\begin\{pmatrix\}([\s\S]*?)\\end\{pmatrix\}/g, (_m, content) => { const norm = String(content) .replace(/(?:\\\\|\\cr|\\0|\\n)/g, '; ') .replace(/&/g, ', ') .replace(/\s+/g, ' ') .trim(); return '(' + norm + ')'; }) .replace(/\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '$1/$2') // Keep greek commands ASCII-only in fallback to avoid WinAnsi issues .replace(/\\lambda\b/g, 'lambda') .replace(/\\alpha\b/g, 'alpha') .replace(/\\beta\b/g, 'beta') .replace(/\\gamma\b/g, 'gamma') .replace(/\\theta\b/g, 'theta') .replace(/\\tau\b/g, 'tau') .replace(/\\mu\b/g, 'mu') .replace(/\\Delta\b/g, 'Delta'); // Parse inline math in plain text: handle $...$ and \(...\), leave others function splitMathInline(input: string, baseFont: any, baseSize: number): Seg[] { const segs: Seg[] = []; const s = input || ''; let i = 0; // Check if a dollar sign at the given index is part of a monetary amount const isMonetaryAmount = (str: string, dollarIdx: number): boolean => { if (dollarIdx < 0 || dollarIdx >= str.length) return false; // Look ahead to see if it matches monetary pattern const afterDollar = str.slice(dollarIdx + 1); // Match: digits with optional commas/decimals followed by optional scale words const monetaryPattern = /^\d+(?:,\d{3})*(?:\.\d+)?(?:[kKmMbBtT]|\s+(?:thousand|million|billion|trillion|k|K|M|B|T))?/; return monetaryPattern.test(afterDollar); }; // Route any Greek unicode characters to a Unicode font (Inter) const pushPlain = (txt: string) => { if (!txt) return; let buf = ''; for (const ch of txt) { if (/[αβγθτμΔλ]/.test(ch)) { if (buf) segs.push({ text: buf, font: baseFont, size: baseSize }); segs.push({ text: ch, font: fontGreek, size: baseSize }); buf = ''; } else { buf += ch; } } if (buf) segs.push({ text: buf, font: baseFont, size: baseSize }); }; const readGroup = (start: number): [string, number] => { if (s[start] !== '{') return ['', start]; let depth = 0; let j = start; while (j < s.length) { const ch = s[j]; if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) return [s.slice(start + 1, j), j + 1]; } j++; } return [s.slice(start + 1), s.length]; }; while (i < s.length) { const idxDollar = s.indexOf('$', i); const idxInline = s.indexOf('\\(', i); const idxMacro = s.indexOf('\\', i); const cands = [idxDollar, idxInline, idxMacro].filter((n) => n !== -1); if (!cands.length) { pushPlain(s.slice(i)); break; } const next = Math.min(...cands); if (next > i) pushPlain(s.slice(i, next)); if (s[next] === '$') { // Check if this is a monetary amount (e.g., $100 billion) if (isMonetaryAmount(s, next)) { // Extract the monetary amount and treat it as plain text const afterDollar = s.slice(next + 1); const monetaryMatch = afterDollar.match( /^\d+(?:,\d{3})*(?:\.\d+)?(?:[kKmMbBtT]|\s+(?:thousand|million|billion|trillion|k|K|M|B|T))?/, ); if (monetaryMatch) { const monetaryText = '$' + monetaryMatch[0]; pushPlain(monetaryText); i = next + monetaryText.length; continue; } } // Otherwise, treat as LaTeX const end = s.indexOf('$', next + 1); if (end === -1) { pushPlain(s.slice(next)); break; } const content = s.slice(next + 1, end).trim(); segs.push({ text: '', font: baseFont, size: baseSize, latex: content }); i = end + 1; continue; } if (s.slice(next, next + 2) === '\\(') { const end = s.indexOf('\\)', next + 2); if (end === -1) { pushPlain(s.slice(next)); break; } const content = s.slice(next + 2, end).trim(); segs.push({ text: '', font: baseFont, size: baseSize, latex: content }); i = end + 2; continue; } if (s[next] === '\\') { let j = next + 1; while (j < s.length && /[A-Za-z]+/.test(s[j])) j++; const cmd = '\\' + s.slice(next + 1, j); if (cmd === '\\sqrt') { const [grp, after] = readGroup(j); const latex = grp ? `${cmd}{${grp}}` : cmd; segs.push({ text: '', font: baseFont, size: baseSize, latex }); i = after; continue; } if (cmd === '\\frac') { const [num, p1] = readGroup(j); const [den, p2] = readGroup(p1); const latex = `${cmd}{${num}}{${den}}`; segs.push({ text: '', font: baseFont, size: baseSize, latex }); i = p2; continue; } // Simple macros like \alpha, \Delta, etc segs.push({ text: '', font: baseFont, size: baseSize, latex: cmd }); i = j; continue; } // Fallback: emit the single character pushPlain(s[next]); i = next + 1; } return segs; } // Minimal LaTeX-to-segments renderer for inline math (superscripts/subscripts & greek) const latexToSegs = (lx: string, size: number): Seg[] => { const out: Seg[] = []; const s = String(lx || ''); const greek: Record = { '\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\theta': 'θ', '\\mu': 'μ', '\\tau': 'τ', '\\Delta': 'Δ', '\\lambda': 'λ', }; let i = 0; while (i < s.length) { const ch = s[i]; if (ch === '\\') { let j = i + 1; while (j < s.length && /[A-Za-z]+/.test(s[j])) j++; const cmd = '\\' + s.slice(i + 1, j); if (greek[cmd]) { // Render LaTeX greek commands with Symbol font to avoid WinAnsi errors out.push({ text: greek[cmd], font: fontGreek, size }); i = j; continue; } if (cmd === '\\frac') { const readGroup = (start: number): [string, number] => { if (s[start] === '{') { const end = s.indexOf('}', start + 1); return end !== -1 ? [s.slice(start + 1, end), end + 1] : [s.slice(start + 1), s.length]; } return [s[start] || '', start + 1]; }; const [num, p1] = readGroup(j); const [den, p2] = readGroup(p1); out.push(...latexToSegs(num, size)); out.push({ text: '/', font: fontItalic, size }); out.push(...latexToSegs(den, size)); i = p2; continue; } out.push({ text: s.slice(i, j), font: fontItalic, size }); i = j; continue; } if (ch === '^' || ch === '_') { const isSup = ch === '^'; let k = i + 1; let content = ''; if (s[k] === '{') { const end = s.indexOf('}', k + 1); if (end !== -1) { content = s.slice(k + 1, end); i = end + 1; } else { content = s.slice(k + 1); i = s.length; } } else { content = s[k] || ''; i = k + 1; } for (const c of content) { const isGreek = /[αβγθτμΔλ]/.test(c); out.push({ text: c, font: isGreek ? fontGreek : fontItalic, size, superscript: isSup, ...(isSup ? {} : { subscript: true }), } as any); } continue; } // Default char rendering; route Greek unicode to Symbol font const greekChar = /[αβγθτμΔλ]/.test(ch); out.push({ text: ch, font: greekChar ? fontGreek : fontItalic, size }); i++; } return out; }; // Draw the Scira logo (vector) using the same SVG paths as components/logos/scira-logo.tsx // Positions the logo with its top-left at (x, yTop). Width controls overall size. function drawSciraLogo(x: number, yTop: number, width: number, color = rgb(0, 0, 0)) { // Original viewBox: 910 x 934 const vbW = 910; const vbH = 934; const scale = width / vbW; const height = vbH * scale; const y = yTop - height; // convert to bottom-left anchor for PDF const border = (w: number) => Math.max(0.5, w * scale); // Paths extracted from /components/logos/scira-logo.tsx const p1 = 'M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z'; const p2 = 'M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z'; const p3 = 'M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z'; const p4 = 'M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z'; const p5 = 'M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z'; const p6 = 'M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z'; const p7 = 'M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50206 520.339 7.76432 455.354 24.4266 393.359C41.0889 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442'; // Draw strokes and fills page.drawSvgPath(p1, { x, y, scale, borderColor: color, borderWidth: border(8) }); page.drawSvgPath(p2, { x, y, scale, color, borderColor: color, borderWidth: border(8) }); page.drawSvgPath(p3, { x, y, scale, borderColor: color, borderWidth: border(20) }); page.drawSvgPath(p4, { x, y, scale, borderColor: color, borderWidth: border(20) }); page.drawSvgPath(p5, { x, y, scale, borderColor: color, borderWidth: border(8) }); page.drawSvgPath(p6, { x, y, scale, color, borderColor: color, borderWidth: border(8) }); page.drawSvgPath(p7, { x, y, scale, borderColor: color, borderWidth: border(30) }); return height; } // Add a variant of inline wrapping that applies a hanging indent to continuation lines const drawInlineWrappedHanging = (segments: Seg[], baseSize: number, firstIndent = 0, hangingIndent = 20) => { let indent = firstIndent; let available = maxLineWidth - indent; let line: Seg[] = []; const widthOfSeg = (seg: Seg) => measureTextWidth(seg.font, seg.text, seg.size); const flushLine = () => { let x = margin + indent; if (line.length === 1 && line[0].center) { const w = widthOfSeg(line[0]); x = margin + Math.max(0, (maxLineWidth - w) / 2); } for (const seg of line) { if (!seg.text) continue; const segWidth = widthOfSeg(seg); let advanceWidth = segWidth; // For superscript citations, render smaller and slightly raised with themed color (no circle) if (seg.superscript) { const yOffset = seg.size * 0.25; // Raise the text slightly const themeColor = rgb(0.2, 0.4, 0.8); // approx Shadcn primary drawTextWithFallback(seg.text, x, y + yOffset, seg.size, fontBold, themeColor); // Add a small gap after the superscript for breathing room advanceWidth = segWidth + 1; } else { drawTextWithFallback(seg.text, x, y, seg.size, seg.font, seg.color ?? rgb(0, 0, 0)); } // Add clickable URI annotation if this segment represents a link if (seg.href) addLinkAnnotation(String(seg.href), x, y, advanceWidth, seg.size); x += advanceWidth; } y -= baseSize + lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } line = []; indent = hangingIndent; // hanging indent for subsequent lines available = maxLineWidth - indent; }; for (const seg of segments ?? []) { if (seg.break) { if (line.length) flushLine(); else { y -= baseSize + lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } } continue; } let remaining = seg.text; while (remaining.length) { let w = 0; try { w = seg.font.widthOfTextAtSize(remaining, seg.size); } catch { w = remaining.length * seg.size * 0.5; } if (w <= available) { line.push({ ...seg, text: remaining }); available -= w; remaining = ''; } else { // character-based split to fit available space let cut = 0; for (let i = 1; i <= remaining.length; i++) { const candidate = remaining.slice(0, i); let cw = 0; try { cw = seg.font.widthOfTextAtSize(candidate, seg.size); } catch { cw = candidate.length * seg.size * 0.5; } if (cw > available) { cut = i - 1; break; } cut = i; } if (cut <= 0) { // nothing fits on this line, flush and retry if (line.length) flushLine(); else { y -= baseSize + lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } } available = maxLineWidth - indent; continue; } const fit = remaining.slice(0, cut); const rest = remaining.slice(cut).replace(/^\s+/, ''); line.push({ ...seg, text: fit }); remaining = rest; try { available -= seg.font.widthOfTextAtSize(fit, seg.size); } catch { available -= fit.length * seg.size * 0.5; } if (available <= 1) flushLine(); } } } if (line.length) flushLine(); }; // MathJax engine setup for SVG rendering of display math const mjAdaptor = liteAdaptor(); RegisterHTMLHandler(mjAdaptor); const mjTeX = new TeX({ packages: ['base', 'ams'] }); const mjSVG = new SVG({ fontCache: 'local', fontData: MathJaxNewcmFont }); const mjDocument = mathjax.document('', { InputJax: mjTeX, OutputJax: mjSVG }); const drawMathBlock = async (latex: string, baseSize: number) => { try { const node = mjDocument.convert(latex, { display: true }); const svg = mjAdaptor.outerHTML(node); const baseSvg = extractMathSvg(svg); const cleanSvg = cleanMathSvg(baseSvg, { removeRectElements: true, removeLineElements: true }); // Parse ex-based height to estimate natural display size const heightExMatch = baseSvg.match(/height\s*=\s*"([\d.]+)ex"/) || baseSvg.match(/height:\s*([\d.]+)ex/); const heightEx = heightExMatch ? parseFloat(heightExMatch[1]) : NaN; const naturalDisplayH = Number.isFinite(heightEx) ? heightEx * baseSize : baseSize * 2; // High-resolution conversion const scaleFactor = MATH_SVG_SCALE_FACTOR; let pngImg; try { ({ pngImg } = await svgToPngImage(cleanSvg, scaleFactor)); } catch { ({ pngImg } = await svgToPngImage(baseSvg, scaleFactor)); } // Clamp display size: not wider than 75% line, not taller than ~2.0 line heights const maxW = maxLineWidth * MATH_DISPLAY_MAX_WIDTH_RATIO; const maxH = Math.min(pageHeight * MATH_DISPLAY_MAX_HEIGHT_RATIO, baseSize * 2.75); const minH = baseSize * MATH_DISPLAY_MIN_HEIGHT_MULTIPLIER; const scaleW = maxW / pngImg.width; const scaleH = maxH / pngImg.height; const minScale = minH / pngImg.height; const scale = Math.min(scaleW, scaleH, Math.max(naturalDisplayH / pngImg.height, minScale)); const w = pngImg.width * scale; const h = pngImg.height * scale; // Center horizontally const x = margin + (maxLineWidth - w) / 2; // Spacing const spaceBefore = Math.max(10, baseSize * MATH_DISPLAY_SPACING_MULTIPLIER + lineGap * 0.5); const spaceAfter = Math.max(10, baseSize * MATH_DISPLAY_SPACING_MULTIPLIER + lineGap * 0.5); y -= spaceBefore; if (y - h <= margin) { page = addPage(); y = pageHeight - margin; } page.drawImage(pngImg, { x, y: y - h, width: w, height: h }); y -= h + spaceAfter; if (y <= margin) { page = addPage(); y = pageHeight - margin; } } catch (e) { console.warn('MathJax display render failed:', (e as any)?.message || e); } }; const prepareInlineMathSegments = async (segments: Seg[], baseSize: number) => { for (const seg of segments) { if ((seg as any).latex && !(seg as any).center) { try { const node = mjDocument.convert((seg as any).latex, { display: false }); const svg = mjAdaptor.outerHTML(node); const baseSvg = extractMathSvg(svg); const cleanSvg = cleanMathSvg(baseSvg, { removeRectStrokes: true, removeLineStrokes: true }); const scaleFactor = MATH_SVG_SCALE_FACTOR; let pngImg; try { ({ pngImg } = await svgToPngImage(cleanSvg, scaleFactor)); } catch { ({ pngImg } = await svgToPngImage(baseSvg, scaleFactor)); } (seg as any)._img = pngImg; const targetH = Math.min(baseSize, baseSize * MATH_INLINE_HEIGHT_MULTIPLIER); const aspectRatio = pngImg.width / pngImg.height; const targetW = targetH * aspectRatio; const maxInlineW = baseSize * MATH_INLINE_MAX_WIDTH_MULTIPLIER; const minInlineH = baseSize * MATH_INLINE_MIN_HEIGHT_MULTIPLIER; let finalH = Math.max(targetH, minInlineH); let finalW = finalH * aspectRatio; if (finalW > maxInlineW) { finalW = maxInlineW; finalH = finalW / aspectRatio; } (seg as any)._drawH = finalH; (seg as any)._drawW = finalW; } catch (e) { console.warn('MathJax inline render failed:', (e as any)?.message || e); const fb = simplifyLatex((seg as any).latex || ''); seg.text = fb; delete (seg as any).latex; } } } }; // Standard inline wrapping used in general text rendering const drawInlineWrapped = async (segments: Seg[], baseSize: number, indent = 0) => { let available = maxLineWidth - indent; let line: Seg[] = []; await prepareInlineMathSegments(segments, baseSize); const widthOfSeg = (seg: Seg) => { const w = (seg as any)._drawW; if (typeof w === 'number') return w; return measureTextWidth(seg.font, seg.text, seg.size); }; const flushLine = () => { let x = margin + indent; // Center the whole line if requested, regardless of segment count if (line.length && line[0].center) { const totalWidth = line.reduce((sum, s) => sum + widthOfSeg(s), 0); x = margin + Math.max(0, (maxLineWidth - totalWidth) / 2); } for (const seg of line) { if ((seg as any).latex && !(seg as any).center && (seg as any)._img) { const img = (seg as any)._img; const w = (seg as any)._drawW; const h = (seg as any)._drawH; // Move math symbols higher - center around baseline const imgY = y - h * MATH_INLINE_BASELINE_MULTIPLIER; page.drawImage(img, { x, y: imgY, width: w, height: h }); x += w; continue; } if (!seg.text) continue; const segWidth = widthOfSeg(seg); let advanceWidth = segWidth; if ((seg as any).superscript) { const supSize = Math.max(6, Math.round(seg.size * 0.8)); const yOffset = supSize * 0.35; drawTextWithFallback(seg.text, x, y + yOffset, supSize, seg.font ?? fontItalic, seg.color ?? rgb(0, 0, 0)); try { advanceWidth = (seg.font ?? fontItalic).widthOfTextAtSize(seg.text, supSize) + 0.5; } catch { advanceWidth = seg.text.length * supSize * 0.5 + 0.5; } } else if ((seg as any).subscript) { const subSize = Math.max(6, Math.round(seg.size * 0.8)); const yOffset = -subSize * 0.15; drawTextWithFallback(seg.text, x, y + yOffset, subSize, seg.font ?? fontItalic, seg.color ?? rgb(0, 0, 0)); try { advanceWidth = (seg.font ?? fontItalic).widthOfTextAtSize(seg.text, subSize) + 0.5; } catch { advanceWidth = seg.text.length * subSize * 0.5 + 0.5; } } else { drawTextWithFallback(seg.text, x, y, seg.size, seg.font, seg.color ?? rgb(0, 0, 0)); } if ((seg as any).href) { addLinkAnnotation(String((seg as any).href), x, y, advanceWidth, seg.size); } x += advanceWidth; } y -= baseSize + lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } line = []; available = maxLineWidth - indent; }; for (const seg of segments) { if ((seg as any).latex && (seg as any).center) { if (line.length) flushLine(); await drawMathBlock((seg as any).latex, baseSize); available = maxLineWidth - indent; continue; } if ((seg as any).break) { if (line.length) flushLine(); continue; } if ((seg as any).latex && !(seg as any).center && (seg as any)._drawW) { const w = (seg as any)._drawW as number; if (w <= available) { line.push(seg); available -= w; } else { if (line.length) flushLine(); available = maxLineWidth - indent; if (w <= available) { line.push(seg); available -= w; } else { /* If even a single inline math is wider than a line, reduce height for this segment */ const img = (seg as any)._img; if (img) { const targetH = Math.max(8, Math.round(baseSize * 0.9)); (seg as any)._drawH = targetH; (seg as any)._drawW = img.width * (targetH / img.height); const nw = (seg as any)._drawW as number; if (nw <= available) { line.push(seg); available -= nw; } else { line.push(seg); flushLine(); } } else { // fallback: treat as plain text seg.text = simplifyLatex((seg as any).latex || ''); delete (seg as any).latex; } } } continue; } // Word-aware wrapping: prefer breaking at spaces; only break mid-word if the word itself exceeds a full line. const tokens = seg.text.match(/[^\s]+|\s+/g) || []; for (let tIdx = 0; tIdx < tokens.length; tIdx++) { const token = tokens[tIdx]; const isSpace = /^\s+$/.test(token); let tokWidth = 0; try { tokWidth = seg.font.widthOfTextAtSize(token, seg.size); } catch { // Fallback: estimate width based on character count if font can't measure tokWidth = token.length * seg.size * 0.5; } if (tokWidth <= available) { // Token fits on current line if (!isSpace || line.length) { line.push({ ...seg, text: token }); available -= tokWidth; } continue; } // Doesn't fit. If there's already content on this line, flush first. if (line.length) { flushLine(); available = maxLineWidth - indent; } if (tokWidth <= available) { line.push({ ...seg, text: token }); available -= tokWidth; continue; } // Extremely long token (word) that can't fit even on a fresh line: break at character level. let start = 0; while (start < token.length) { let end = start; let part = ''; while (end < token.length) { const cand = token.slice(start, end + 1); let cw = 0; try { cw = seg.font.widthOfTextAtSize(cand, seg.size); } catch { // Fallback: estimate width based on character count cw = cand.length * seg.size * 0.5; } if (cw > available) break; part = cand; end++; } if (!part) { // If even a single character doesn't fit, flush and retry on next line. flushLine(); available = maxLineWidth - indent; continue; } line.push({ ...seg, text: part }); try { available -= seg.font.widthOfTextAtSize(part, seg.size); } catch { // Fallback: estimate width reduction based on character count available -= part.length * seg.size * 0.5; } start += part.length; if (start < token.length) { flushLine(); available = maxLineWidth - indent; } } } } if (line.length) flushLine(); }; const drawListItem = async (item: any, bullet: string, orderedIndex?: number, baseIndent = 0) => { const bulletText = orderedIndex != null ? `${orderedIndex}.` : bullet; let bulletWidth = 0; try { bulletWidth = font.widthOfTextAtSize(bulletText, fontSize); } catch { bulletWidth = bulletText.length * fontSize * 0.5; } // Draw bullet at current indent drawTextWithFallback(bullet, margin + baseIndent, y, fontSize, font, rgb(0, 0, 0)); const indent = baseIndent + bulletWidth + 10; // Build inline segments from possible block tokens inside list item let segs: Seg[] = []; const tks = item?.tokens; if (Array.isArray(tks) && tks.length) { for (const bt of tks) { // Handle nested lists inside a list item if (bt?.type === 'list') { if (segs.length) { await drawInlineWrapped(segs, fontSize, indent); segs = []; } let nestedCounter = bt.ordered ? bt.start || 1 : 1; for (const nItem of bt.items || []) { await drawListItem(nItem, '•', bt.ordered ? nestedCounter : undefined, indent); if (bt.ordered) nestedCounter++; } // Continue accumulating any following inline blocks continue; } if (bt && Array.isArray(bt.tokens)) { segs.push(...flattenInline(bt.tokens, font, fontSize)); // break between blocks within a single list item segs.push({ text: '', font, size: fontSize, break: true }); } else { segs.push(...flattenInline([bt], font, fontSize)); } } if (segs.length && segs[segs.length - 1].break) segs.pop(); if (segs.length) { await drawInlineWrapped(segs, fontSize, indent); } } else if (typeof item?.text === 'string') { let inlineTokens: any[] | undefined; try { inlineTokens = Lexer.lexInline(item.text); } catch { } const built = inlineTokens ? flattenInline(inlineTokens, font, fontSize) : [{ text: String(item.text), font, size: fontSize }]; await drawInlineWrapped(built, fontSize, indent); } else { await drawInlineWrapped([{ text: '', font, size: fontSize }], fontSize, indent); } }; // Draw a simple grid table for markdown `table` tokens const drawTable = async (tk: any) => { const headers = Array.isArray(tk.header) ? tk.header : []; const rows = Array.isArray(tk.rows) ? tk.rows : []; const nCols = Math.max(headers.length, rows[0]?.length || 0); if (!nCols) { return; } const padX = 8; const padY = 4; // slightly larger padding for better breathing room const headerSize = fontSize; const cellSize = Math.max(9, fontSize - 1); const colWidth = Math.floor(maxLineWidth / nCols); // Build inline segments for a cell, preserving link citations const buildTableSegmentsFromText = (text: string, baseFont: any, baseSize: number): Seg[] => { const hasLatexCommand = /\\[A-Za-z]+/.test(text); const hasMathDelim = /\$|\\\(|\\\[/.test(text); if (hasLatexCommand && !hasMathDelim) { return [{ text: '', font: baseFont, size: baseSize, latex: text }]; } return splitMathInline(text, baseFont, baseSize); }; const toSegments = (cell: any, baseFont: any, baseSize: number): Seg[] => { if (Array.isArray(cell?.tokens)) { const segs: Seg[] = []; for (const tt of cell.tokens) { if (tt?.type === 'link') { const display = String(tt.text ?? tt.href ?? tt.raw ?? ''); const href = String(tt.href ?? ''); let num = citationIndex.get(href); if (num == null) { num = citationIndex.size + 1; citationIndex.set(href, num); } if (display) citationText.set(href, display); segs.push({ text: String(num), font: baseFont, size: Math.max(6, Math.round(baseSize * 0.7)), href, superscript: true, color: rgb(0.4, 0.4, 0.4), }); } else { const s = String(tt.text ?? tt.raw ?? ''); if (s) segs.push(...buildTableSegmentsFromText(s, baseFont, baseSize)); } } return segs; } const s = typeof cell === 'string' ? cell : String(cell?.text ?? cell?.raw ?? ''); return buildTableSegmentsFromText(s, baseFont, baseSize); }; // Wrap segments into lines constrained to cell width const wrapSegments = (segments: Seg[], maxWidth: number, fallbackSize: number) => { const lines: Seg[][] = []; const lineHeights: number[] = []; let line: Seg[] = []; let available = maxWidth; const flush = () => { if (line.length) { lines.push(line); const maxHeight = Math.max(...line.map((seg) => (seg as any)._drawH ?? seg.size ?? fallbackSize)); lineHeights.push(maxHeight); } line = []; available = maxWidth; }; for (const seg of segments ?? []) { if ((seg as any).break) { flush(); continue; } const font = seg.font; const size = seg.size; let remaining = seg.text || ''; const inlineW = (seg as any)._drawW; const isInlineImage = typeof inlineW === 'number' && !remaining.length; while (remaining.length || isInlineImage) { let w = 0; if (isInlineImage) w = inlineW as number; else w = measureTextWidth(font, remaining, size); if (w <= available) { line.push({ ...seg, text: remaining }); available -= w; remaining = ''; if (isInlineImage) break; } else { if (isInlineImage && !line.length) { line.push({ ...seg, text: remaining }); flush(); break; } const firstLine = wrapText( remaining, (s) => { return measureTextWidth(font, s, size); }, available, )[0] ?? ''; if (!firstLine.length) { flush(); continue; } line.push({ ...seg, text: firstLine }); flush(); remaining = remaining.slice(firstLine.length); } } } if (line.length) { lines.push(line); const maxHeight = Math.max(...line.map((seg) => (seg as any)._drawH ?? seg.size ?? fallbackSize)); lineHeights.push(maxHeight); } return { lines, lineHeights }; }; // Measure row height and pre-wrapped lines for rendering const measureRow = (cells: Seg[][], usedFont: any, usedSize: number) => { const wraps: Seg[][][] = []; const lineHeights: number[][] = []; const lineGapInCell = Math.max(2, Math.round(usedSize * 0.2)); const cellHeights: number[] = []; let rowHeight = 0; for (let c = 0; c < nCols; c++) { const segs = cells[c] || []; const wrapped = wrapSegments(segs, colWidth - 2 * padX, usedSize); wraps.push(wrapped.lines); lineHeights.push(wrapped.lineHeights); const contentH = wrapped.lineHeights.reduce((sum, h) => sum + h, 0) + Math.max(0, wrapped.lineHeights.length - 1) * lineGapInCell; cellHeights.push(contentH); rowHeight = Math.max(rowHeight, 2 * padY + contentH); } return { wraps, lineHeights, cellHeights, rowHeight, lineGapInCell }; }; const renderMeasuredRow = ( measured: { wraps: Seg[][][]; lineHeights: number[][]; cellHeights: number[]; rowHeight: number; lineGapInCell: number; }, usedFont: any, usedSize: number, shaded = false, ) => { const { wraps, lineHeights, cellHeights, rowHeight, lineGapInCell } = measured; // Page break check; repeat header if a break occurs mid-table if (y - rowHeight <= margin) { page = addPage(); y = pageHeight - margin; if (headers.length) { const mh = measureRow(headers, fontBold, headerSize); renderMeasuredRow(mh, fontBold, headerSize, true); } } const rowBottom = y - rowHeight; // draw background + borders per cell for (let c = 0; c < nCols; c++) { const x = margin + c * colWidth; const fillColor = shaded ? usedFont === fontBold ? rgb(0.94, 0.94, 0.98) : rgb(0.98, 0.98, 0.995) : undefined; page.drawRectangle({ x, y: rowBottom, width: colWidth, height: rowHeight, color: fillColor, borderColor: rgb(0.85, 0.85, 0.88), borderWidth: 0.75, } as any); } // subtle accent line under header row for stronger separation if (shaded && usedFont === fontBold) { page.drawLine({ start: { x: margin, y: rowBottom }, end: { x: margin + nCols * colWidth, y: rowBottom }, thickness: 1.2, color: rgb(0.7, 0.7, 0.8), }); } // draw content segments (vertically center within each cell) for (let c = 0; c < nCols; c++) { const startX = margin + c * colWidth + padX; const contentH = cellHeights[c]; const freeSpace = rowHeight - 2 * padY - contentH; const vOffset = Math.max(0, Math.floor(freeSpace / 2)); let ty = y - padY - vOffset; for (let lIdx = 0; lIdx < wraps[c].length; lIdx++) { const lineSegs = wraps[c][lIdx]; const lineHeight = lineHeights[c][lIdx] ?? usedSize; const baselineY = ty - lineHeight * 0.85; let xCursor = startX; for (const seg of lineSegs) { const segFont = seg.font || usedFont; const segSize = seg.size || usedSize; const raise = (seg as any).superscript ? Math.round(segSize * 0.35) : 0; const drawY = baselineY + raise; const text = seg.text || ''; let advanceWidth = 0; const img = (seg as any)._img; const drawW = (seg as any)._drawW; const drawH = (seg as any)._drawH; if (img && typeof drawW === 'number' && typeof drawH === 'number') { const imgY = baselineY - drawH * MATH_INLINE_BASELINE_MULTIPLIER; page.drawImage(img, { x: xCursor, y: imgY, width: drawW, height: drawH }); advanceWidth = drawW; } else if (text) { advanceWidth = measureTextWidth(segFont, text, segSize); drawTextWithFallback(seg.text, xCursor, drawY, segSize, segFont, seg.color || rgb(0, 0, 0)); } if ((seg as any).href) { addLinkAnnotation(String((seg as any).href), xCursor, drawY, advanceWidth, segSize); } xCursor += advanceWidth; } ty -= lineHeight + lineGapInCell; } } y = rowBottom; }; // Render header (avoid orphan header at page end) if (headers.length) { const headerSegs = headers.map((cell: any) => toSegments(cell, fontBold, headerSize)); for (const segs of headerSegs) await prepareInlineMathSegments(segs, headerSize); const mh = measureRow(headerSegs, fontBold, headerSize); // If there are body rows, ensure header + first row fit; otherwise move to new page first if (rows.length) { const firstRowSegs = rows[0].map((cell: any) => toSegments(cell, font, cellSize)); for (const segs of firstRowSegs) await prepareInlineMathSegments(segs, cellSize); const firstRowMeasure = measureRow(firstRowSegs, font, cellSize); if (y - (mh.rowHeight + firstRowMeasure.rowHeight) <= margin) { page = addPage(); y = pageHeight - margin; } } else if (y - mh.rowHeight <= margin) { page = addPage(); y = pageHeight - margin; } renderMeasuredRow(mh, fontBold, headerSize, true); } // Render body rows with pagination and subtle zebra striping for (let rIdx = 0; rIdx < rows.length; rIdx++) { const rowSegs = rows[rIdx].map((cell: any) => toSegments(cell, font, cellSize)); for (const segs of rowSegs) await prepareInlineMathSegments(segs, cellSize); const mr = measureRow(rowSegs, font, cellSize); const shadedRow = rIdx % 2 === 1; // shade odd rows for subtle zebra renderMeasuredRow(mr, font, cellSize, shadedRow); } // Post-gap handled by caller }; // Parse markdown into tokens const tokens: any[] = Lexer.lex(content); let orderedCounter = 1; let lastWasParagraph = false; for (let idx = 0; idx < tokens.length; idx++) { const tk = tokens[idx]; const nextTk = tokens[idx + 1]; switch (tk.type) { case 'heading': { const sizeByLevel = [0, 20, 18, 16, 14, 13, 12]; const depth = tk.depth ?? tk.level ?? 1; const size = sizeByLevel[Math.max(1, Math.min(6, depth))]; // Keep heading with next block if near page bottom const minSpace = nextTk?.type === 'table' ? KEEP_WITH_NEXT_MIN_SPACE_TABLE : KEEP_WITH_NEXT_MIN_SPACE_GENERIC; if (y - (SPACE_BEFORE_HEADING + size + minSpace) <= margin) { page = addPage(); y = pageHeight - margin; } // Consistent pre-gap before heading y -= SPACE_BEFORE_HEADING; if (y <= margin) { page = addPage(); y = pageHeight - margin; } if (tk.tokens) { const segs = flattenInline(tk.tokens, fontBold, size); await drawInlineWrapped(segs, size); // normalize post-heading gap to our desired spacing if (SPACE_AFTER_HEADING > lineGap) { y -= SPACE_AFTER_HEADING - lineGap; if (y <= margin) { page = addPage(); y = pageHeight - margin; } } } else { drawTextWithFallback(tk.text || '', margin, y, size, fontBold, rgb(0, 0, 0)); y -= size + SPACE_AFTER_HEADING; if (y <= margin) { page = addPage(); y = pageHeight - margin; } } lastWasParagraph = false; break; } case 'paragraph': { if (tk.tokens) { const segs = flattenInline(tk.tokens, font, fontSize); await drawInlineWrapped(segs, fontSize); } else { // Process paragraph text for math blocks before drawing const text = tk.text || ''; const mathSegs = splitMathInline(text, font, fontSize); if (mathSegs.length > 1 || (mathSegs.length === 1 && mathSegs[0].center)) { // Contains math, use segment rendering await drawInlineWrapped(mathSegs, fontSize); } else { // Plain text, use simple wrapping drawWrapped(text, { font, size: fontSize }); } } // Consistent gap after paragraph y -= SPACE_AFTER_PARAGRAPH; lastWasParagraph = true; break; } case 'blockquote': { drawWrapped(tk.text, { font: fontItalic, size: fontSize, indent: 12 }); y -= SPACE_AFTER_BLOCKQUOTE; lastWasParagraph = false; break; } case 'code': { // Use IBM Plex Mono + sugar-high syntax highlighting, with a subtle background // Using pre-embedded IBM Plex Mono fontCode const padX = 10; const padY = 8; const codeSize = Math.max(9, fontSize - 1); const lineStep = codeSize + 4; const contentWidth = maxLineWidth - 2 * padX; // Wrap code to contentWidth while preserving characters const raw = String(tk.text || ''); const rawLines = raw.split('\n'); const wrappedLines: string[] = []; for (const ln of rawLines) { if (!ln.length) { wrappedLines.push(''); continue; } let start = 0; while (start < ln.length) { let end = ln.length; while ( end > start && (() => { try { return fontCode.widthOfTextAtSize(ln.slice(start, end), codeSize); } catch { return 0; } })() > contentWidth ) { end--; } if (end === start) end = Math.min(start + 1, ln.length); wrappedLines.push(ln.slice(start, end)); start = end; } } // Measure and ensure space (background first) const blockHeight = wrappedLines.length * lineStep + 2 * padY; if (y - blockHeight <= margin) { page = addPage(); y = pageHeight - margin; } const rectY = y - blockHeight + padY; // bottom of background page.drawRectangle({ x: margin, y: rectY, width: maxLineWidth, height: blockHeight - padY, color: rgb(0.965, 0.97, 0.985), borderColor: rgb(0.88, 0.9, 0.94), borderWidth: 0.5, }); // HTML entity decode const unescapeHtml = (s: string) => s .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/'/g, "'") .replace(/"/g, '"'); // Map sugar-high classes to colors const colorFor = (cls?: string) => { switch (cls) { case 'sh-keyword': case 'sh-k': return rgb(0.75, 0.25, 0.25); case 'sh-string': case 'sh-s': return rgb(0.2, 0.55, 0.3); case 'sh-number': case 'sh-n': return rgb(0.55, 0.35, 0.1); case 'sh-class': case 'sh-type': return rgb(0.35, 0.4, 0.75); case 'sh-property': case 'sh-p': return rgb(0.35, 0.45, 0.8); case 'sh-entity': return rgb(0.3, 0.4, 0.7); case 'sh-jsxliterals': return rgb(0.7, 0.35, 0.6); case 'sh-comment': case 'sh-c': return rgb(0.5, 0.55, 0.6); case 'sh-sign': return rgb(0.3, 0.3, 0.35); default: return rgb(0.2, 0.2, 0.25); } }; // Draw wrapped lines - plain text without syntax highlighting for PDF let drawY = y - padY - codeSize; for (const lineText of wrappedLines) { const safeLineText = sanitizeForFont(lineText, fontCode); let x = margin + padX; const color = rgb(0.2, 0.2, 0.25); drawTextWithFallback(safeLineText, x, drawY, codeSize, fontCode, color); drawY -= lineStep; } // Advance past block y = rectY - SPACE_AFTER_CODE; lastWasParagraph = false; break; } case 'list': { orderedCounter = tk.ordered ? tk.start || 1 : 1; for (const item of tk.items || []) { await drawListItem(item, '•', tk.ordered ? orderedCounter : undefined); if (tk.ordered) orderedCounter++; } y -= SPACE_AFTER_LIST; lastWasParagraph = false; break; } case 'table': { // consistent spacing before table y -= SPACE_BEFORE_TABLE; if (y <= margin) { page = addPage(); y = pageHeight - margin; } await drawTable(tk); // consistent spacing after table y -= SPACE_AFTER_TABLE; if (y <= margin) { page = addPage(); y = pageHeight - margin; } lastWasParagraph = false; break; } case 'hr': { // use a thin rectangle as divider const lineY = y - 4; page.drawRectangle({ x: margin, y: lineY, width: maxLineWidth, height: 1, color: rgb(0.6, 0.6, 0.6) }); y = lineY - (fontSize + lineGap); if (y <= margin) { page = addPage(); y = pageHeight - margin; } lastWasParagraph = false; break; } default: { if (tk.type === 'text') { drawWrapped(tk.text, { font, size: fontSize }); } break; } } } // References section with proper margin handling const citations = Array.from(citationText.entries()); // [href, display] if (citations && citations.length > 0) { // Add some space before references y -= 20; if (y <= margin + 100) { // Ensure enough space for references header page = addPage(); y = pageHeight - margin; } // References header const referencesTitle = 'References'; const refTitleSize = 14; drawTextWithFallback(referencesTitle, margin, y, refTitleSize, fontBold, rgb(0, 0, 0)); y -= refTitleSize + 16; // Update references rendering to clean clickable label + domain, with hanging indent // Sort references by citation number to match in-text order const refs = citations .map(([href, label]: [string, string]) => ({ href, label, num: citationIndex.get(href) || Infinity })) .filter((r) => r.num !== Infinity) .sort((a, b) => a.num - b.num); refs.forEach(({ href, label, num }) => { const refSize = fontSize - 1; let hostname = ''; try { hostname = new URL(String(href)).hostname; } catch { hostname = String(href) .replace(/^https?:\/\/(www\.)?/, '') .split('/')[0]; } const linkSegs = [ { text: `[${num}] `, font, size: refSize }, { text: String(label), font, size: refSize, href: String(href), color: rgb(0.2, 0.4, 0.8) }, { text: ` (${hostname})`, font, size: refSize - 1, color: rgb(0.3, 0.3, 0.3) }, { text: '', font, size: refSize, break: true }, ]; drawInlineWrappedHanging(linkSegs, refSize, 0, 20); }); // Unreferenced links appended unsorted citations.forEach(([href, label]: [string, string]) => { if (citationIndex.get(href) != null) return; const refSize = fontSize - 1; let hostname = ''; try { hostname = new URL(String(href)).hostname; } catch { hostname = String(href) .replace(/^https?:\/\/(www\.)?/, '') .split('/')[0]; } const linkSegs = [ { text: `[-] `, font, size: refSize }, { text: String(label), font, size: refSize, href: String(href), color: rgb(0.2, 0.4, 0.8) }, { text: ` (${hostname})`, font, size: refSize - 1, color: rgb(0.3, 0.3, 0.3) }, { text: '', font, size: refSize, break: true }, ]; drawInlineWrappedHanging(linkSegs, refSize, 0, 20); }); } const pdfBytes = await pdfDoc.save(); // Create a plain ArrayBuffer from Uint8Array to satisfy BodyInit typing const ab = new ArrayBuffer(pdfBytes.byteLength); const view = new Uint8Array(ab); view.set(pdfBytes); const filename = `scira-export.pdf`; return new Response(ab, { status: 200, headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-store, no-cache, must-revalidate', Pragma: 'no-cache', }, }); } catch (e: any) { console.error('PDF export error:', e); return NextResponse.json({ error: e?.message || 'Failed to generate PDF' }, { status: 500 }); } } ================================================ FILE: app/api/lookout/route.ts ================================================ // /app/api/lookout/route.ts import { generateTitleFromUserMessage } from '@/app/actions'; import { convertToModelMessages, streamText, createUIMessageStream, stepCountIs, JsonToSseTransformStream } from 'ai'; import { scira, shouldBypassRateLimits } from '@/ai/providers'; import { createStreamId, incrementExtremeSearchUsage, incrementMessageUsage, updateChatTitleById, getLookoutById, updateLookoutLastRun, updateLookout, updateLookoutStatus, getUserById, } from '@/lib/db/queries'; import { createResumableUIMessageStream } from 'ai-resumable-stream'; import { getResumableStreamClients } from '@/lib/redis'; import { after } from 'next/server'; import { v7 as uuidv7 } from 'uuid'; import { CronExpressionParser } from 'cron-parser'; import { sendLookoutCompletionEmail } from '@/lib/email'; import { db, maindb } from '@/lib/db'; import { chat as chatTable, message as messageTable, subscription, dodosubscription } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { all, flow } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; import { getCachedUserPreferencesByUserId } from '@/lib/user-data-server'; // Import search tools import { extremeSearchTool, webSearchTool, academicSearchTool, youtubeSearchTool, redditSearchTool, githubSearchTool, stockChartTool, currencyConverterTool, coinDataTool, coinOhlcTool, coinDataByContractTool, codeContextTool, xSearchTool, datetimeTool, greetingTool, retrieveTool, weatherTool, codeInterpreterTool, findPlaceOnMapTool, nearbyPlacesSearchTool, flightTrackerTool, movieTvSearchTool, trendingMoviesTool, trendingTvTool, textTranslateTool, } from '@/lib/tools'; import { ChatMessage } from '@/lib/types'; import { type UIMessageStreamWriter } from 'ai'; import { XaiProviderOptions } from '@ai-sdk/xai'; /** * Truncates markdown at a natural paragraph boundary to avoid broken links, * incomplete list items, or mid-sentence cuts. */ function truncateMarkdown(text: string, maxLength: number): string { if (text.length <= maxLength) return text; const softLimit = Math.min(text.length, maxLength + 400); function scanUntil(endIndex: number) { let isInInlineCode = false; let isInFence = false; let fenceTickCount = 0; let linkTextDepth = 0; let linkUrlDepth = 0; let shouldOpenUrlOnNextParen = false; let lastLinkStartIndex: number | null = null; let lastDoubleNewline = -1; let lastNewline = -1; let lastWhitespace = -1; let lastSentenceEnd = -1; for (let i = 0; i < endIndex; i++) { const char = text[i] ?? ''; const nextTwo = text.slice(i, i + 3); if (nextTwo === '```') { isInFence = !isInFence; fenceTickCount = 0; i += 2; continue; } if (!isInFence) { if (char === '`') isInInlineCode = !isInInlineCode; if (!isInInlineCode) { if (char === '\n') { if (text[i - 1] === '\n') lastDoubleNewline = i + 1; lastNewline = i + 1; } if (/\s/.test(char)) lastWhitespace = i + 1; if ((char === '.' || char === '!' || char === '?') && /\s/.test(text[i + 1] ?? '')) lastSentenceEnd = i + 2; if (char === '[') { if (linkTextDepth === 0) lastLinkStartIndex = i; linkTextDepth += 1; shouldOpenUrlOnNextParen = false; } else if (char === ']') { if (linkTextDepth > 0) linkTextDepth -= 1; shouldOpenUrlOnNextParen = linkTextDepth === 0; } else if (char === '(') { if (shouldOpenUrlOnNextParen) { linkUrlDepth += 1; shouldOpenUrlOnNextParen = false; } else if (linkUrlDepth > 0) { // allow nested parens inside the URL linkUrlDepth += 1; } } else if (char === ')') { if (linkUrlDepth > 0) linkUrlDepth -= 1; } else if (char !== ' ' && char !== '\t') { // any non-space breaks the immediate `](` pattern shouldOpenUrlOnNextParen = false; } } } else { // within fences, don't treat markdown punctuation as structure if (char === '`') fenceTickCount += 1; else fenceTickCount = 0; } } return { isInInlineCode, isInFence, linkTextDepth, linkUrlDepth, lastLinkStartIndex, lastDoubleNewline, lastNewline, lastWhitespace, lastSentenceEnd, }; } let cutIndex = maxLength; let stateAtCut = scanUntil(cutIndex); // If we are mid-markdown-link at the cut, extend forward a bit to close it cleanly. if (stateAtCut.linkTextDepth > 0 || stateAtCut.linkUrlDepth > 0) { for (let i = maxLength + 1; i <= softLimit; i++) { const nextState = scanUntil(i); if (nextState.linkTextDepth === 0 && nextState.linkUrlDepth === 0) { cutIndex = i; stateAtCut = nextState; break; } } // If we couldn't close the link within the soft limit, back up to before the link started. if (stateAtCut.linkTextDepth > 0 || stateAtCut.linkUrlDepth > 0) { const fallbackIndex = stateAtCut.lastLinkStartIndex ?? stateAtCut.lastNewline ?? stateAtCut.lastWhitespace; cutIndex = Math.max(0, Math.min(maxLength, fallbackIndex ?? maxLength)); stateAtCut = scanUntil(cutIndex); } } // If we ended inside a list item line, try to finish the line (avoid half-rendered bullets). if (cutIndex < softLimit) { const nextNewlineIndex = text.indexOf('\n', cutIndex); if (nextNewlineIndex !== -1 && nextNewlineIndex <= softLimit) cutIndex = nextNewlineIndex; } // Prefer cutting at clean boundaries (in order). const boundaryState = scanUntil(cutIndex); const preferredBoundary = boundaryState.lastDoubleNewline > 0 ? boundaryState.lastDoubleNewline : boundaryState.lastNewline > 0 ? boundaryState.lastNewline : boundaryState.lastSentenceEnd > 0 ? boundaryState.lastSentenceEnd : boundaryState.lastWhitespace > 0 ? boundaryState.lastWhitespace : cutIndex; const safeIndex = Math.max(0, Math.min(cutIndex, preferredBoundary)); const truncated = text.slice(0, safeIndex).trimEnd(); return `${truncated}`; } // Static tool instances (already created, don't need to be called as functions) const STATIC_TOOLS: Record = { youtube_search: youtubeSearchTool, stock_chart: stockChartTool, currency_converter: currencyConverterTool, coin_data: coinDataTool, coin_ohlc: coinOhlcTool, coin_data_by_contract: coinDataByContractTool, code_context: codeContextTool, datetime: datetimeTool, greeting: greetingTool, retrieve: retrieveTool, get_weather_data: weatherTool, code_interpreter: codeInterpreterTool, find_place_on_map: findPlaceOnMapTool, nearby_places_search: nearbyPlacesSearchTool, track_flight: flightTrackerTool, movie_or_tv_search: movieTvSearchTool, trending_movies: trendingMoviesTool, trending_tv: trendingTvTool, text_translate: textTranslateTool, }; // Tool factories that need dataStream parameter const DATASTREAM_TOOL_FACTORIES: Record) => any> = { extreme_search: (dataStream) => extremeSearchTool(dataStream), // overridden in getToolsForSearchMode when modelId is available web_search: webSearchTool, academic_search: academicSearchTool, reddit_search: redditSearchTool, github_search: githubSearchTool, x_search: xSearchTool, }; // Search mode to tools mapping (matching groupTools from actions.ts) const SEARCH_MODE_TOOLS: Record = { extreme: ['extreme_search'], web: [ 'web_search', 'greeting', 'code_interpreter', 'get_weather_data', 'retrieve', 'text_translate', 'nearby_places_search', 'track_flight', 'movie_or_tv_search', 'trending_movies', 'find_place_on_map', 'trending_tv', 'datetime', ], academic: ['academic_search', 'code_interpreter', 'datetime'], youtube: ['youtube_search', 'datetime'], reddit: ['reddit_search', 'datetime'], github: ['github_search', 'datetime'], stocks: ['stock_chart', 'currency_converter', 'datetime'], code: ['code_context'], x: ['x_search'], chat: [], }; // Get tools for a search mode function getToolsForSearchMode( searchMode: string, dataStream: UIMessageStreamWriter, options?: { extremeSearchModelId?: | 'scira-ext-1' | 'scira-ext-2' | 'scira-ext-4' | 'scira-ext-5' | 'scira-ext-6' | 'scira-ext-7' | 'scira-ext-8'; }, ): Record { const toolNames = SEARCH_MODE_TOOLS[searchMode] || SEARCH_MODE_TOOLS.extreme; const tools: Record = {}; for (const toolName of toolNames) { // Check if it's a tool that needs dataStream if (toolName === 'extreme_search') { tools[toolName] = extremeSearchTool(dataStream, [], options?.extremeSearchModelId || 'scira-ext-1'); } else if (toolName in DATASTREAM_TOOL_FACTORIES) { tools[toolName] = DATASTREAM_TOOL_FACTORIES[toolName](dataStream); } else if (toolName in STATIC_TOOLS) { // Static tool - use directly tools[toolName] = STATIC_TOOLS[toolName]; } } return tools; } // Get system prompt for a search mode function getSystemPromptForSearchMode(searchMode: string): string { const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit', weekday: 'short', }); const toolNamesForMode = SEARCH_MODE_TOOLS[searchMode] || SEARCH_MODE_TOOLS.extreme; const primaryToolName = toolNamesForMode[0]; const linkFormatRules = searchMode === 'reddit' ? ` --- ## 🔗 CITATION FORMAT - REDDIT SPECIFIC RULES ### Link Formatting (MANDATORY FOR REDDIT) - ⚠️ **USE POST TITLE FORMAT**: Citations must use format \`[Post Title](url)\` with the actual Reddit post title - ⚠️ **NO NUMBERED FOOTNOTES**: Never use [1], [2], [3] style references - ⚠️ **NO REFERENCE SECTIONS**: Never create separate "References", "Sources", or "Links" sections - ⚠️ **INLINE ONLY**: Citations must appear immediately after the sentence they support - ⚠️ **USE ACTUAL POST TITLES**: Never use generic link text like "text", "source", "link" - ⚠️ **NO BARE URLs**: Never include bare URLs - ⚠️ **NO FULL STOPS AFTER LINKS**: Never place a period (.) immediately after a citation link - ⚠️ **NO PIPE CHARACTERS**: Never use pipe characters (|) between links or inside citation text ` : ` --- ## 🔗 CITATION FORMAT - CRITICAL RULES ### Link Formatting (MANDATORY) - ⚠️ **USE INLINE TEXT CITATIONS**: Citations must use markdown link format with text as display text - ⚠️ **FORMAT**: \`[text](url)\` - ⚠️ **NO NUMBERED FOOTNOTES**: Never use [1], [2], [3] style references - ⚠️ **NO REFERENCE SECTIONS**: Never create separate "References", "Sources", or "Links" sections - ⚠️ **INLINE ONLY**: Citations must appear immediately after the sentence they support - ⚠️ **NO GENERIC LINK TEXT**: Never use generic link text like "text", "source", "link" — use the actual page/article/title text - ⚠️ **NO BARE URLs**: Never include bare URLs - ⚠️ **NO FULL STOPS AFTER LINKS**: Never place a period (.) immediately after a citation link - ⚠️ **NO PIPE CHARACTERS**: Never use pipe characters (|) between links or inside citation text `; const basePrompt = `# Scira AI Scheduled Research Assistant You are an advanced research assistant focused on deep analysis and comprehensive understanding, with a focus on being backed by citations. **Today's Date:** ${today} --- ## 🚨 CRITICAL OPERATION RULES ### Immediate Tool Execution - ⚠️ **MANDATORY**: ${primaryToolName ? `Run \`${primaryToolName}\` INSTANTLY when processing ANY scheduled query` : 'Do NOT call tools unless required by the user'} - NO EXCEPTIONS - ⚠️ **NO PRE-ANALYSIS**: Do NOT write any text before running the tool (if a tool is required) - ⚠️ **ONE TOOL ONLY**: Run the tool once and only once per scheduled search - ⚠️ **NO CLARIFICATION**: Never ask for clarification - make best interpretation and proceed - ⚠️ **DIRECT ANSWERS**: Go straight to answering after running the tool - ⚠️ **NO PREFACES**: Never begin with "I'm assuming..." or "Based on your query..." ### Response Format Requirements - ⚠️ **MANDATORY**: Always respond with markdown format - ⚠️ **CITATIONS REQUIRED**: EVERY factual claim MUST have a citation - ⚠️ **IMMEDIATE CITATIONS**: Citations must appear immediately after each sentence with factual content - ⚠️ **NO END CITATIONS**: Never put citations at the end of paragraphs/sections - ⚠️ **STRICT MARKDOWN**: All responses must use proper markdown formatting throughout ### Response Structure - MANDATORY - ⚠️ **CRITICAL**: ALWAYS start your response with "## Key Points" followed by a bulleted list of main findings - ⚠️ **MINIMUM REQUIRED**: The "## Key Points" section MUST contain at least 10 bullet points (10+). If you have fewer than 10, keep researching/synthesizing until you have 10. - After Key Points, write well formatted super detailed sections and finish with a conclusion`; // Add mode-specific instructions const modeInstructions: Record = { extreme: ` ## 🛠️ TOOL GUIDELINES ### Extreme Search Tool - **Purpose**: Multi-step research planning with parallel web and academic searches - **Output**: Comprehensive 3-page research paper with citations`, web: ` ## 🛠️ TOOL GUIDELINES ### Web Search Tool - **Purpose**: Search across the web for relevant information - **Output**: Well-structured summary with citations from web sources`, academic: ` ## 🛠️ TOOL GUIDELINES ### Academic Search Tool - **Purpose**: Search academic papers and research publications - **Output**: Academic summary with proper citations from research sources`, youtube: ` ## 🛠️ TOOL GUIDELINES ### YouTube Search Tool - **Purpose**: Search YouTube videos for relevant content - **Output**: Summary of video content with links to relevant videos`, reddit: ` ## 🛠️ TOOL GUIDELINES ### Reddit Search Tool - MULTI-QUERY FORMAT REQUIRED - ⚠️ **MANDATORY**: ALWAYS use MULTIPLE QUERIES (3-5 queries) in ARRAY FORMAT - NO SINGLE QUERIES ALLOWED - ⚠️ **FORMAT**: Use queries: ["query1", "query2", "query3"] - NEVER use a single string query - **Query Range**: 3-5 queries minimum (3 required, 5 maximum) - create variations and related searches - **All Parameters in Arrays**: queries, maxResults, timeRange must all be arrays - When searching Reddit, set maxResults array to at least [10, 10, 10] or higher for each query - Set timeRange array with appropriate values based on query (["week", "week", "month"], etc.) **Multi-Query Examples:** - ✅ CORRECT: queries: ["best AI tools 2025", "AI productivity tools Reddit", "latest AI software recommendations"] - ✅ CORRECT: queries: ["Python tips", "Python best practices", "Python coding advice"], timeRange: ["month", "month", "month"] - ❌ WRONG: query: "best AI tools" (single query - FORBIDDEN) - ❌ WRONG: queries: ["single query only"] (only one query - FORBIDDEN) ### Content Structure (REQUIRED) - Begin with a concise introduction summarizing the Reddit landscape on the topic - Include all relevant results in your response, not just the first one - Cite specific posts using their actual titles - All citations must be inline, placed immediately after the relevant information - Format citations as: [Actual Post Title](URL) ### Citation Format - Reddit Specific - ⚠️ **MANDATORY FORMAT**: Use [Post Title](URL) for all Reddit citations - use the actual post title from Reddit - ⚠️ **INLINE PLACEMENT**: Citations must appear immediately after the sentence containing the information - ⚠️ **NO REFERENCE SECTIONS**: Never create separate "References", "Sources", or "Links" sections - ⚠️ **NO NUMBERED FOOTNOTES**: Never use [1], [2], [3] style references - ⚠️ **MULTIPLE SOURCES**: For multiple Reddit posts, use: [Post Title 1](url1) [Post Title 2](url2) - ⚠️ **USE ACTUAL POST TITLES**: Always use the exact post title from Reddit, not generic text like "Source" or "Link" **Correct Reddit Citation Examples:** - "Many users recommend Python for beginners [Python Learning Guide](https://reddit.com/r/learnprogramming/...)" - "The community discusses AI safety [AI Safety Discussion](url1) [Ethics in AI](url2)" **Incorrect Examples (NEVER DO THIS):** - ❌ "[Source](url)" or "[Link](url)" - too generic, must use actual post title - ❌ "Post Title [1]" with "[1] https://..." at the end - numbered footnotes forbidden - ❌ Bare URLs: "See https://reddit.com/r/..."`, github: ` ## 🛠️ TOOL GUIDELINES ### GitHub Search Tool - MULTI-QUERY FORMAT REQUIRED - ⚠️ **MANDATORY**: ALWAYS use MULTIPLE QUERIES (3-5 queries) in ARRAY FORMAT - NO SINGLE QUERIES ALLOWED - ⚠️ **FORMAT**: Use queries: ["query1", "query2", "query3"] - NEVER use a single string query - **Query Range**: 3-5 queries minimum (3 required, 5 maximum) - create variations and related searches - When searching GitHub, set maxResults array to at least [10, 10, 10] or higher for each query - Use startDate and endDate for time-based filtering when relevant **Multi-Query Examples:** - ✅ CORRECT: queries: ["react state management", "react redux alternatives", "react zustand tutorial"] - ✅ CORRECT: queries: ["machine learning python", "ML frameworks comparison", "deep learning libraries"] - ❌ WRONG: query: "react state management" (single query - FORBIDDEN) - ❌ WRONG: queries: ["single query only"] (only one query - FORBIDDEN) ### Content Structure (REQUIRED) - Begin with a concise introduction summarizing the GitHub landscape on the topic - Include all relevant results in your response, not just the first one - Cite specific repositories using their names - Mention stars, languages, and other relevant metadata when available - All citations must be inline, placed immediately after the relevant information`, stocks: ` ## 🛠️ TOOL GUIDELINES ### Stock Chart Tool - **Purpose**: Get stock market data and charts - **Output**: Stock analysis with current prices and trends`, code: ` ## 🛠️ TOOL GUIDELINES ### Code Context Tool - **Purpose**: Retrieve technical context about languages/frameworks/libraries - **Output**: Technical explanation with concrete code examples`, x: ` ## 🛠️ TOOL GUIDELINES ### X Search Tool - MULTI-QUERY FORMAT REQUIRED - ⚠️ **MANDATORY**: ALWAYS use MULTIPLE QUERIES (3-5 queries) in ARRAY FORMAT - NO SINGLE QUERIES ALLOWED - ⚠️ **FORMAT**: Use queries: ["query1", "query2", "query3"] - NEVER use a single string query - **Query Range**: 3-5 queries minimum (3 required, 5 maximum) - create variations and related searches - **All Parameters in Arrays**: queries, maxResults must be in array format ### Query Writing Rules - CRITICAL - ⚠️ **NATURAL LANGUAGE ONLY**: Write queries in natural language - describe what you're looking for - ⚠️ **NO TWITTER SYNTAX**: NEVER use Twitter search syntax like "from:handle", "to:handle", "filter:links", etc. - ⚠️ **NO HANDLES IN QUERIES**: Do NOT include handles or "@username" in the query strings themselves - ⚠️ **EXTRACT HANDLES SEPARATELY**: When user mentions a handle (e.g., "@openai", "from @elonmusk"), extract it to the includeXHandles parameter - ⚠️ **CLEAN QUERIES**: Keep queries focused on the topic/content, not the author syntax ### Handle Extraction and Usage - **When to extract handles**: If user explicitly mentions a handle (e.g., "tweets from @openai", "posts by @elonmusk", "what did @sama say") - **How to extract**: Identify handles from user message (look for @username patterns) - **Parameter usage**: Use includeXHandles parameter with array of handles WITHOUT @ symbol (e.g., ["openai", "elonmusk"]) - **Query adjustment**: Remove handle references from queries - write queries about the topic/content instead - **Example transformation**: - User: "What did @openai post about GPT-5?" - ✅ CORRECT: queries: ["GPT-5 updates", "GPT-5 features", "GPT-5 release"], includeXHandles: ["openai"] - ❌ WRONG: queries: ["from:openai GPT-5", "GPT-5 @openai"] (contains Twitter syntax or handles in query) ### Date Parameters - **Optional**: Only use date parameters if user explicitly requests a specific date range - **Default behavior**: Tool defaults to past 15 days - don't specify dates unless user asks - **Format**: Use YYYY-MM-DD format for startDate and endDate **Multi-Query Examples:** - ✅ CORRECT: queries: ["AI developments 2025", "latest AI news", "AI breakthrough today"] - ✅ CORRECT: queries: ["Python tips", "Python best practices", "Python coding tricks"] - ✅ CORRECT (with handles): queries: ["AI safety research", "AI alignment progress"], includeXHandles: ["openai"] - ❌ WRONG: query: "AI news" (single query - FORBIDDEN) - ❌ WRONG: queries: ["from:openai AI updates"] (contains Twitter syntax - FORBIDDEN) - ❌ WRONG: queries: ["@openai GPT-5"] (contains handle in query - use includeXHandles instead) ### Citation Requirements - ⚠️ **MANDATORY**: Every factual claim must have a citation in the format [Title](Url) - Citations MUST be placed immediately after the sentence containing the information - ⚠️ **MINIMUM CITATION REQUIREMENT**: Every part of the answer must have more than 3 citations - NEVER group citations at the end of paragraphs or the response - Each distinct piece of information requires its own citation - ⚠️ **NO REFERENCE SECTIONS**: Never create "References", "Sources", or "Links" sections`, chat: ` ## 🛠️ TOOL GUIDELINES ### Chat Mode - **Purpose**: Respond directly without tool usage - **Output**: Helpful, concise answer in markdown`, }; return basePrompt + linkFormatRules + (modeInstructions[searchMode] || modeInstructions.extreme); } // Helper function to check if a user is pro by userId. // Uses flow() to race both queries — exits as soon as either finds an active subscription. async function checkUserIsProById(userId: string): Promise { try { const result = await flow( { async polarSubscriptions() { const subs = await db.select().from(subscription).where(eq(subscription.userId, userId)); const now = new Date(); const active = subs.find((sub) => sub.status === 'active' && new Date(sub.currentPeriodEnd) > now); if (active) this.$end(true); return subs; }, async dodoSubscriptions() { const subs = await db.select().from(dodosubscription).where(eq(dodosubscription.userId, userId)); const now = new Date(); const active = subs.find( (sub) => sub.status === 'active' && (!sub.currentPeriodEnd || new Date(sub.currentPeriodEnd) > now), ); if (active) this.$end(true); return subs; }, }, getBetterAllOptions(), ); return result ?? false; } catch (error) { console.error('Error checking pro status:', error); return false; // Fail closed - don't allow access if we can't verify } } export async function POST(req: Request) { console.log('🔍 Lookout API endpoint hit from QStash'); const requestStartTime = Date.now(); let runDuration = 0; let runError: string | undefined; try { const { lookoutId, prompt, userId } = await req.json(); console.log('--------------------------------'); console.log('Lookout ID:', lookoutId); console.log('User ID:', userId); console.log('Prompt:', prompt); console.log('--------------------------------'); // Verify lookout exists and get details with retry logic let lookout: any = null; let retryCount = 0; const maxRetries = 3; while (!lookout && retryCount < maxRetries) { lookout = await getLookoutById({ id: lookoutId }); if (!lookout) { retryCount++; if (retryCount < maxRetries) { // Actual exponential backoff: 500ms, 1000ms, 2000ms const delay = 500 * Math.pow(2, retryCount - 1); console.log(`Lookout not found on attempt ${retryCount}, retrying in ${delay}ms...`); await new Promise((resolve) => setTimeout(resolve, delay)); } } } if (!lookout) { console.error('Lookout not found after', maxRetries, 'attempts:', lookoutId); return new Response('Lookout not found', { status: 404 }); } // Get user details, check pro status, and fetch preferences in parallel (eliminate waterfall) const { userResult, isUserPro, userPrefs } = await all( { async userResult() { return getUserById(userId); }, async isUserPro() { return checkUserIsProById(userId); }, async userPrefs() { return getCachedUserPreferencesByUserId(userId); }, }, getBetterAllOptions(), ); if (!userResult) { console.error('User not found:', userId); return new Response('User not found', { status: 404 }); } if (!isUserPro) { console.error('User is not pro, cannot run lookout:', userId); return new Response('Lookouts require a Pro subscription', { status: 403 }); } // Generate a new chat ID for this scheduled search const chatId = uuidv7(); const streamId = 'stream-' + uuidv7(); // Create the chat await maindb.insert(chatTable).values({ id: chatId, createdAt: new Date(), userId: userResult.id, title: `Scheduled: ${lookout.title}`, visibility: 'private', }); // Verify chat persisted on primary (fail-fast) const persistedChat = await maindb.query.chat.findFirst({ where: eq(chatTable.id, chatId) }); if (!persistedChat) { throw new Error(`Failed to persist lookout chat (chatId=${chatId})`); } // Create user message const userMessage = { id: uuidv7(), role: 'user' as const, content: prompt, parts: [{ type: 'text' as const, text: prompt }], experimental_attachments: [], }; const initialMessageIds = new Set([userMessage.id]); // Insert user message first (required for FK constraint) await maindb .insert(messageTable) .values([ { chatId, id: userMessage.id, role: 'user', parts: userMessage.parts, attachments: [], createdAt: new Date(), model: 'scira-default', completionTime: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, }, ]) .onConflictDoNothing({ target: messageTable.id }); // Run verification, stream creation, and status update in parallel // (these are independent operations after message insert) await all( { async verifyMessage() { const msg = await maindb.query.message.findFirst({ where: eq(messageTable.id, userMessage.id) }); if (!msg) { throw new Error(`Failed to persist lookout user message (messageId=${userMessage.id}, chatId=${chatId})`); } }, async createStream() { await createStreamId({ streamId, chatId }); }, async updateStatus() { await updateLookoutStatus({ id: lookoutId, status: 'running', }); }, }, getBetterAllOptions(), ); // Get search mode from lookout (default to 'extreme' for backward compatibility) const searchMode = lookout.searchMode || 'extreme'; console.log('🔍 Using search mode:', searchMode); // Create data stream with execute function const abortController = new AbortController(); const stream = createUIMessageStream({ execute: async ({ writer: dataStream }) => { const streamStartTime = Date.now(); // Get tools and system prompt for the search mode const extremeSearchModelId = userPrefs?.preferences?.['scira-extreme-search-model'] as | 'scira-ext-1' | 'scira-ext-2' | 'scira-ext-4' | 'scira-ext-5' | 'scira-ext-6' | 'scira-ext-7' | 'scira-ext-8' | undefined; const tools = getToolsForSearchMode(searchMode, dataStream, { extremeSearchModelId }); const activeToolNames = Object.keys(tools); const systemPrompt = getSystemPromptForSearchMode(searchMode); console.log('🛠️ Active tools:', activeToolNames); // Start streaming const result = streamText({ model: scira.languageModel('scira-default'), messages: await convertToModelMessages([userMessage]), stopWhen: stepCountIs(2), maxRetries: 10, abortSignal: abortController.signal, activeTools: activeToolNames, system: systemPrompt, toolChoice: 'auto', tools, providerOptions: { xai: { parallel_function_calling: false, } satisfies XaiProviderOptions, }, onChunk(event) { if (event.chunk.type === 'tool-call') { console.log('Called Tool: ', event.chunk.toolName); } }, onStepFinish(event) { if (event.warnings) { console.log('Warnings: ', event.warnings); } }, onFinish: async (event) => { console.log('Finish reason: ', event.finishReason); console.log('Steps: ', event.steps); console.log('Usage: ', event.usage); if (event.finishReason === 'stop') { try { // Track usage (matches /app/api/search/route.ts) if (!shouldBypassRateLimits('scira-default', userResult)) { await incrementMessageUsage({ userId: userResult.id }); } // Generate title for the chat const title = await generateTitleFromUserMessage({ message: userMessage, }); console.log('Generated title: ', title); // Update the chat with the generated title await updateChatTitleById({ chatId, title: `Scheduled: ${title}`, }); // Track extreme search usage const extremeSearchUsed = event.steps?.some((step) => step.toolCalls?.some((toolCall) => toolCall.toolName === 'extreme_search'), ); if (extremeSearchUsed) { console.log('Extreme search was used, incrementing count'); await incrementExtremeSearchUsage({ userId: userResult.id }); } // Calculate run duration runDuration = Date.now() - requestStartTime; // Count tool calls performed (across any tool; persisted as `searchesPerformed` for backward compatibility) const searchesPerformed = event.steps?.reduce((total, step) => { return total + (step.toolCalls?.length ?? 0); }, 0) ?? 0; // Update lookout with last run info including metrics await updateLookoutLastRun({ id: lookoutId, lastRunAt: new Date(), lastRunChatId: chatId, runStatus: 'success', duration: runDuration, tokensUsed: event.usage?.totalTokens, searchesPerformed, }); // Calculate next run time for recurring lookouts if (lookout.frequency !== 'once' && lookout.cronSchedule) { try { const options = { currentDate: new Date(), tz: lookout.timezone, }; // Strip CRON_TZ= prefix if present const cleanCronSchedule = lookout.cronSchedule.startsWith('CRON_TZ=') ? lookout.cronSchedule.split(' ').slice(1).join(' ') : lookout.cronSchedule; const interval = CronExpressionParser.parse(cleanCronSchedule, options); const nextRunAt = interval.next().toDate(); await updateLookout({ id: lookoutId, nextRunAt, }); } catch (error) { console.error('Error calculating next run time:', error); } } else if (lookout.frequency === 'once') { // Mark one-time lookouts as paused after running await updateLookoutStatus({ id: lookoutId, status: 'paused', }); } // Send completion email to user if (userResult.email) { try { // Extract assistant response - use event.text which contains the full response let assistantResponseText = event.text || ''; // If event.text is empty, try extracting from messages if (!assistantResponseText.trim()) { const assistantMessages = event.response.messages.filter((msg: any) => msg.role === 'assistant'); for (const msg of assistantMessages) { if (typeof msg.content === 'string') { assistantResponseText += msg.content + '\n'; } else if (Array.isArray(msg.content)) { const textContent = msg.content .filter((part: any) => part.type === 'text') .map((part: any) => part.text) .join('\n'); assistantResponseText += textContent + '\n'; } } } console.log('📧 Assistant response length:', assistantResponseText.length); console.log('📧 First 200 chars:', assistantResponseText.substring(0, 200)); const trimmedResponse = assistantResponseText.trim() || 'No response available.'; const finalResponse = truncateMarkdown(trimmedResponse, 2000); await sendLookoutCompletionEmail({ to: userResult.email, chatTitle: title, assistantResponse: finalResponse, chatId, }); } catch (emailError) { console.error('Failed to send completion email:', emailError); } } // Set lookout status back to active after successful completion await updateLookoutStatus({ id: lookoutId, status: 'active', }); console.log('Scheduled search completed successfully'); } catch (error) { console.error('Error in onFinish:', error); } } // Calculate and log overall request processing time const requestEndTime = Date.now(); const processingTime = (requestEndTime - requestStartTime) / 1000; console.log('--------------------------------'); console.log(`Total request processing time: ${processingTime.toFixed(2)} seconds`); console.log('--------------------------------'); }, onError: async (event) => { console.log('Error: ', event.error); // Calculate run duration and capture error runDuration = Date.now() - requestStartTime; runError = (event.error as string) || 'Unknown error occurred'; // Update lookout with failed run info try { await updateLookoutLastRun({ id: lookoutId, lastRunAt: new Date(), lastRunChatId: chatId, runStatus: 'error', error: runError, duration: runDuration, }); } catch (updateError) { console.error('Failed to update lookout with error info:', updateError); } // Set lookout status back to active on error try { await updateLookoutStatus({ id: lookoutId, status: 'active', }); console.log('Reset lookout status to active after error'); } catch (statusError) { console.error('Failed to reset lookout status after error:', statusError); } const requestEndTime = Date.now(); const processingTime = (requestEndTime - requestStartTime) / 1000; console.log('--------------------------------'); console.log(`Request processing time (with error): ${processingTime.toFixed(2)} seconds`); console.log('--------------------------------'); }, }); result.consumeStream(); dataStream.merge( result.toUIMessageStream({ sendReasoning: true, messageMetadata: ({ part }) => { if (part.type === 'finish') { console.log('Finish part: ', part); const processingTime = (Date.now() - streamStartTime) / 1000; return { model: 'scira-default', completionTime: processingTime, createdAt: new Date().toISOString(), totalTokens: part.totalUsage?.totalTokens ?? null, inputTokens: part.totalUsage?.inputTokens ?? null, outputTokens: part.totalUsage?.outputTokens ?? null, }; } }, }), ); }, onError(error) { console.log('Error: ', error); return 'Oops, an error occurred in scheduled search!'; }, onFinish: async ({ messages: streamedMessages }) => { const newMessages = streamedMessages.filter((message) => !initialMessageIds.has(message.id)); if (newMessages.length === 0) { return; } await maindb .insert(messageTable) .values( newMessages.map((message) => { const attachments = (message as any).experimental_attachments ?? []; const createdAt = typeof message.metadata?.createdAt === 'string' ? new Date(message.metadata.createdAt) : new Date(); return { id: message.id, role: message.role, parts: message.parts, createdAt, attachments, chatId, model: 'scira-default', completionTime: message.metadata?.completionTime ?? 0, inputTokens: message.metadata?.inputTokens ?? 0, outputTokens: message.metadata?.outputTokens ?? 0, totalTokens: message.metadata?.totalTokens ?? 0, }; }), ) .onConflictDoNothing({ target: messageTable.id }); }, }); const clients = getResumableStreamClients(); if (clients) { const context = await createResumableUIMessageStream({ streamId, publisher: clients.publisher, subscriber: clients.subscriber, abortController, waitUntil: after, }); const resumableStream = await context.startStream(stream as ReadableStream); return new Response(resumableStream.pipeThrough(new JsonToSseTransformStream())); } return new Response(stream.pipeThrough(new JsonToSseTransformStream())); } catch (error) { console.error('Error in lookout API:', error); return new Response('Internal server error', { status: 500 }); } } ================================================ FILE: app/api/mcp/apps/bridge/route.ts ================================================ import { createMCPClient } from '@ai-sdk/mcp'; import { z } from 'zod'; import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById } from '@/lib/db/queries'; import { resolveMcpAuthHeaders } from '@/lib/mcp/auth-headers'; import { validateMcpServerUrl } from '@/lib/mcp/server-config'; import { ChatSDKError } from '@/lib/errors'; const bridgeRequestSchema = z.object({ serverId: z.string().min(1), method: z.enum([ 'tools/call', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', ]), params: z.record(z.string(), z.unknown()).optional(), }); export async function POST(request: Request) { try { const user = await getCurrentUser(); if (!user) return new ChatSDKError('unauthorized:auth').toResponse(); const input = bridgeRequestSchema.parse(await request.json()); const server = await getUserMcpServerById({ id: input.serverId, userId: user.id }); if (!server) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); validateMcpServerUrl(server.url); const client = await createMCPClient({ transport: { type: server.transportType, url: server.url, headers: await resolveMcpAuthHeaders({ server, userId: user.id, }), }, }); try { const params = input.params ?? {}; if (input.method === 'tools/call') { const toolName = typeof params.name === 'string' ? params.name : ''; const toolArgs = params.arguments && typeof params.arguments === 'object' ? params.arguments as Record : {}; if (!toolName) { return new ChatSDKError('bad_request:api', 'Tool name is required').toResponse(); } const toolSet = await client.tools(); const tool = toolSet[toolName] as any; if (!tool?.execute) { return new ChatSDKError('not_found:api', `Tool not found: ${toolName}`).toResponse(); } const result = await tool.execute(toolArgs, {}); return Response.json({ ok: true, result, }); } if (input.method === 'resources/list') { const result = await client.listResources(); return Response.json({ ok: true, result }); } if (input.method === 'resources/read') { const uri = typeof params.uri === 'string' ? params.uri : ''; if (!uri) return new ChatSDKError('bad_request:api', 'Resource URI is required').toResponse(); const result = await client.readResource({ uri }); return Response.json({ ok: true, result }); } if (input.method === 'resources/templates/list') { const result = await client.listResourceTemplates(); return Response.json({ ok: true, result }); } const result = await client.experimental_listPrompts(); return Response.json({ ok: true, result }); } finally { await client.close(); } } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof z.ZodError) { return new ChatSDKError('bad_request:api', error.issues[0]?.message || 'Invalid request payload').toResponse(); } if (error instanceof Error) { return new ChatSDKError('bad_request:api', error.message).toResponse(); } return new ChatSDKError('bad_request:api', 'Failed to handle MCP app bridge request').toResponse(); } } ================================================ FILE: app/api/mcp/apps/resource/read/route.ts ================================================ import { createMCPClient } from '@ai-sdk/mcp'; import { z } from 'zod'; import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById } from '@/lib/db/queries'; import { resolveMcpAuthHeaders } from '@/lib/mcp/auth-headers'; import { validateMcpServerUrl } from '@/lib/mcp/server-config'; import { ChatSDKError } from '@/lib/errors'; const readMcpAppResourceSchema = z.object({ serverId: z.string().min(1), resourceUri: z.string().min(1), }); function extractUiResourceMeta(resource: unknown, content: unknown) { const resourceMeta = (resource as any)?._meta; const contentMeta = (content as any)?._meta; const uiMeta = (contentMeta?.ui ?? resourceMeta?.ui ?? {}) as Record; const csp = uiMeta.csp ?? contentMeta?.['ui/csp'] ?? resourceMeta?.['ui/csp']; const permissions = uiMeta.permissions ?? contentMeta?.['ui/permissions'] ?? resourceMeta?.['ui/permissions']; const prefersBorder = uiMeta.prefersBorder ?? contentMeta?.['ui/prefersBorder'] ?? resourceMeta?.['ui/prefersBorder']; const domain = uiMeta.domain ?? contentMeta?.['ui/domain'] ?? resourceMeta?.['ui/domain']; return { csp: csp && typeof csp === 'object' ? csp : undefined, permissions: permissions && typeof permissions === 'object' ? permissions : undefined, prefersBorder: typeof prefersBorder === 'boolean' ? prefersBorder : undefined, domain: typeof domain === 'string' ? domain : undefined, }; } export async function POST(request: Request) { try { const user = await getCurrentUser(); if (!user) return new ChatSDKError('unauthorized:auth').toResponse(); const input = readMcpAppResourceSchema.parse(await request.json()); if (!input.resourceUri.startsWith('ui://')) { return new ChatSDKError('bad_request:api', 'Only ui:// resources are supported').toResponse(); } const server = await getUserMcpServerById({ id: input.serverId, userId: user.id }); if (!server) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); validateMcpServerUrl(server.url); const client = await createMCPClient({ transport: { type: server.transportType, url: server.url, headers: await resolveMcpAuthHeaders({ server, userId: user.id, }), }, }); try { const resource = await client.readResource({ uri: input.resourceUri }); const htmlContent = resource.contents.find( (content) => typeof (content as any)?.text === 'string' && typeof (content as any)?.mimeType === 'string' && ((content as any).mimeType as string).includes('text/html'), ) as { text?: string; mimeType?: string; uri?: string; _meta?: Record } | undefined; if (!htmlContent?.text) { return new ChatSDKError('bad_request:api', 'Resource did not return HTML content').toResponse(); } const resourceMeta = extractUiResourceMeta(resource, htmlContent); return Response.json({ ok: true, html: htmlContent.text, mimeType: htmlContent.mimeType, uri: htmlContent.uri ?? input.resourceUri, resourceMeta, }); } finally { await client.close(); } } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof z.ZodError) { return new ChatSDKError('bad_request:api', error.issues[0]?.message || 'Invalid request payload').toResponse(); } if (error instanceof Error) { return new ChatSDKError('bad_request:api', error.message).toResponse(); } return new ChatSDKError('bad_request:api', 'Failed to read MCP app resource').toResponse(); } } ================================================ FILE: app/api/mcp/elicitation/respond/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { ChatSDKError } from '@/lib/errors'; import { pendingElicitations } from '@/lib/tools/mcp-client'; import { z } from 'zod'; import { Redis } from '@upstash/redis'; const redis = Redis.fromEnv(); const ELICITATION_RESPONSE_KEY_PREFIX = 'mcp:elicitation:response:'; const ELICITATION_PENDING_KEY_PREFIX = 'mcp:elicitation:pending:'; function getElicitationResponseKey(elicitationId: string) { return `${ELICITATION_RESPONSE_KEY_PREFIX}${elicitationId}`; } function getElicitationPendingKey(elicitationId: string) { return `${ELICITATION_PENDING_KEY_PREFIX}${elicitationId}`; } const respondSchema = z.object({ elicitationId: z.string().min(1), action: z.enum(['accept', 'decline', 'cancel']), content: z.record(z.string(), z.unknown()).optional(), }); export async function POST(request: Request) { try { const user = await getCurrentUser(); if (!user) return new ChatSDKError('unauthorized:auth').toResponse(); const input = respondSchema.parse(await request.json()); const resolver = pendingElicitations.get(input.elicitationId); const responsePayload = { action: input.action, content: input.content, }; // Always persist response so waiting callback can pick it up cross-instance. await redis.set( getElicitationResponseKey(input.elicitationId), responsePayload, { ex: 10 * 60 }, ); if (resolver) { resolver(responsePayload); return Response.json({ ok: true }); } const stillPending = await redis.exists(getElicitationPendingKey(input.elicitationId)); if (stillPending) return Response.json({ ok: true, accepted: true }); return Response.json({ ok: false, error: 'Elicitation not found or already resolved' }, { status: 404 }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof z.ZodError) { return new ChatSDKError('bad_request:api').toResponse(); } return new ChatSDKError('bad_request:api').toResponse(); } } ================================================ FILE: app/api/mcp/oauth/callback/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById, updateUserMcpServer } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; import { exchangeMcpOAuthCode, verifyMcpOAuthState } from '@/lib/mcp/oauth'; import { injectManagedOAuthCredentials } from '@/lib/mcp/managed-credentials'; function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } function redirectToApps(request: Request, status: 'success' | 'error', message?: string) { const url = new URL('/apps', new URL(request.url).origin); url.searchParams.set('tab', 'my-servers'); url.searchParams.set('mcpOauth', status); if (message) url.searchParams.set('message', message.slice(0, 120)); return Response.redirect(url.toString(), 302); } export async function GET(request: Request) { let resolvedServerId: string | null = null; try { const user = assertProUser(await getCurrentUser()); const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get('code'); const state = requestUrl.searchParams.get('state'); const oauthError = requestUrl.searchParams.get('error'); const oauthErrorDesc = requestUrl.searchParams.get('error_description'); if (oauthError) { return redirectToApps(request, 'error', oauthErrorDesc ?? oauthError); } if (!code || !state) return redirectToApps(request, 'error', 'Missing OAuth callback params'); const payload = verifyMcpOAuthState({ state, expectedUserId: user.id, }); resolvedServerId = payload.serverId; const rawServer = await getUserMcpServerById({ id: payload.serverId, userId: user.id }); if (!rawServer) return redirectToApps(request, 'error', 'MCP server not found'); if (rawServer.authType !== 'oauth') return redirectToApps(request, 'error', 'Server is not OAuth'); const server = injectManagedOAuthCredentials(rawServer); await exchangeMcpOAuthCode({ server, userId: user.id, code, verifier: payload.verifier, requestOrigin: requestUrl.origin, }); await updateUserMcpServer({ id: payload.serverId, userId: user.id, values: { oauthError: null, }, }); return redirectToApps(request, 'success'); } catch (error) { const user = await getCurrentUser().catch(() => null); if (user?.id && resolvedServerId) { await updateUserMcpServer({ id: resolvedServerId, userId: user.id, values: { oauthError: error instanceof Error ? error.message.slice(0, 1000) : 'OAuth callback failed', }, }).catch(() => null); } return redirectToApps(request, 'error', error instanceof Error ? error.message : 'OAuth callback failed'); } } ================================================ FILE: app/api/mcp/oauth/client-metadata/[serverId]/route.ts ================================================ import { NextResponse } from 'next/server'; function getAppOrigin(request: Request) { const oauthOrigin = process.env.MCP_OAUTH_CALLBACK_ORIGIN?.trim(); if (oauthOrigin) return oauthOrigin.replace(/\/+$/, ''); const configured = process.env.NEXT_PUBLIC_APP_URL?.trim(); if (configured) return configured.replace(/\/+$/, ''); return new URL(request.url).origin.replace(/\/+$/, ''); } export async function GET( request: Request, { params }: { params: Promise<{ serverId: string }> }, ) { const { serverId } = await params; const origin = getAppOrigin(request); const callbackUri = `${origin}/api/mcp/oauth/callback`; const clientId = `${origin}/api/mcp/oauth/client-metadata/${serverId}`; return NextResponse.json({ client_id: clientId, client_name: 'Scira AI', client_uri: origin, redirect_uris: [callbackUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', }); } ================================================ FILE: app/api/mcp/servers/[id]/oauth/callback/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById, updateUserMcpServer } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; import { exchangeMcpOAuthCode, verifyMcpOAuthState } from '@/lib/mcp/oauth'; function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } function redirectToApps(request: Request, status: 'success' | 'error', message?: string) { const url = new URL('/apps', new URL(request.url).origin); url.searchParams.set('tab', 'my-servers'); url.searchParams.set('mcpOauth', status); if (message) url.searchParams.set('message', message.slice(0, 120)); return Response.redirect(url.toString(), 302); } export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, ) { try { const user = assertProUser(await getCurrentUser()); const { id } = await params; const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get('code'); const state = requestUrl.searchParams.get('state'); if (!code || !state) return redirectToApps(request, 'error', 'Missing OAuth callback params'); const server = await getUserMcpServerById({ id, userId: user.id }); if (!server) return redirectToApps(request, 'error', 'MCP server not found'); if (server.authType !== 'oauth') return redirectToApps(request, 'error', 'Server is not OAuth'); const payload = verifyMcpOAuthState({ state, expectedUserId: user.id, expectedServerId: id, }); await exchangeMcpOAuthCode({ server, userId: user.id, code, verifier: payload.verifier, requestOrigin: requestUrl.origin, }); await updateUserMcpServer({ id, userId: user.id, values: { oauthError: null, }, }); return redirectToApps(request, 'success'); } catch (error) { const { id } = await params; const user = await getCurrentUser().catch(() => null); if (user?.id) { await updateUserMcpServer({ id, userId: user.id, values: { oauthError: error instanceof Error ? error.message.slice(0, 1000) : 'OAuth callback failed', }, }).catch(() => null); } return redirectToApps(request, 'error', error instanceof Error ? error.message : 'OAuth callback failed'); } } ================================================ FILE: app/api/mcp/servers/[id]/oauth/disconnect/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById, updateUserMcpServer } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } export async function POST( _request: Request, { params }: { params: Promise<{ id: string }> }, ) { try { const user = assertProUser(await getCurrentUser()); const { id } = await params; const server = await getUserMcpServerById({ id, userId: user.id }); if (!server) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); await updateUserMcpServer({ id, userId: user.id, values: { oauthAccessTokenEncrypted: null, oauthRefreshTokenEncrypted: null, oauthAccessTokenExpiresAt: null, oauthConnectedAt: null, oauthError: null, }, }); return Response.json({ ok: true }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); return new ChatSDKError('bad_request:api', 'Failed to disconnect OAuth').toResponse(); } } ================================================ FILE: app/api/mcp/servers/[id]/oauth/start/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; import { buildMcpOAuthAuthorizationUrl } from '@/lib/mcp/oauth'; import { validateMcpOAuthConfig } from '@/lib/mcp/server-config'; import { injectManagedOAuthCredentials } from '@/lib/mcp/managed-credentials'; function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } export async function POST( request: Request, { params }: { params: Promise<{ id: string }> }, ) { try { const user = assertProUser(await getCurrentUser()); const { id } = await params; const rawServer = await getUserMcpServerById({ id, userId: user.id }); if (!rawServer) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); if (rawServer.authType !== 'oauth') return new ChatSDKError('bad_request:api', 'Server auth type is not OAuth').toResponse(); const server = injectManagedOAuthCredentials(rawServer); validateMcpOAuthConfig({ authType: 'oauth', oauthIssuerUrl: server.oauthIssuerUrl ?? undefined, oauthAuthorizationUrl: server.oauthAuthorizationUrl ?? undefined, oauthTokenUrl: server.oauthTokenUrl ?? undefined, oauthClientId: server.oauthClientId ?? undefined, }); const requestOrigin = new URL(request.url).origin; const { authorizationUrl } = await buildMcpOAuthAuthorizationUrl({ server, userId: user.id, requestOrigin, }); return Response.json({ authorizationUrl }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof Error) { return new ChatSDKError('bad_request:api', error.message).toResponse(); } return new ChatSDKError('bad_request:api', 'Failed to start OAuth flow').toResponse(); } } ================================================ FILE: app/api/mcp/servers/[id]/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { ChatSDKError } from '@/lib/errors'; import { deleteUserMcpServer, getUserMcpServerById, updateUserMcpServer, } from '@/lib/db/queries'; import { getEncryptedMcpCredentials, getEncryptedOAuthValue, normalizeMcpScopes, validateMcpOAuthConfig, validateMcpServerUrl, } from '@/lib/mcp/server-config'; import { z } from 'zod'; const optionalUrlField = z.preprocess( (value) => typeof value === 'string' && value.trim() === '' ? undefined : value, z.string().trim().url().optional(), ); const updateMcpServerSchema = z.object({ name: z.string().trim().min(1).max(80).optional(), transportType: z.enum(['http', 'sse']).optional(), url: z.string().trim().url().optional(), authType: z.enum(['none', 'bearer', 'header', 'oauth']).optional(), bearerToken: z.string().optional(), headerName: z.string().optional(), headerValue: z.string().optional(), oauthIssuerUrl: optionalUrlField, oauthAuthorizationUrl: optionalUrlField, oauthTokenUrl: optionalUrlField, oauthScopes: z.string().optional(), oauthClientId: z.string().optional(), oauthClientSecret: z.string().optional(), isEnabled: z.boolean().optional(), disabledTools: z.array(z.string()).optional(), clearOAuthTokens: z.boolean().optional(), clearCredentials: z.boolean().optional(), }); function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } function serializeMcpServer(server: { id: string; name: string; transportType: 'http' | 'sse'; url: string; authType: 'none' | 'bearer' | 'header' | 'oauth'; isEnabled: boolean; disabledTools?: string[] | null; lastTestedAt: Date | null; lastError: string | null; oauthConnectedAt: Date | null; oauthError: string | null; createdAt: Date; updatedAt: Date; encryptedCredentials: string | null; oauthClientId: string | null; oauthIssuerUrl: string | null; oauthAuthorizationUrl: string | null; oauthTokenUrl: string | null; oauthScopes: string | null; oauthAccessTokenEncrypted: string | null; oauthRefreshTokenEncrypted: string | null; }) { return { id: server.id, name: server.name, transportType: server.transportType, url: server.url, authType: server.authType, isEnabled: server.isEnabled, disabledTools: server.disabledTools ?? [], hasCredentials: Boolean(server.encryptedCredentials), isOAuthConnected: Boolean( server.oauthAccessTokenEncrypted || server.oauthRefreshTokenEncrypted || server.oauthConnectedAt, ), oauthConfigured: server.authType === 'oauth', oauthIssuerUrl: server.oauthIssuerUrl, oauthAuthorizationUrl: server.oauthAuthorizationUrl, oauthTokenUrl: server.oauthTokenUrl, oauthScopes: server.oauthScopes, oauthClientId: server.oauthClientId, oauthError: server.oauthError, oauthConnectedAt: server.oauthConnectedAt, lastTestedAt: server.lastTestedAt, lastError: server.lastError, createdAt: server.createdAt, updatedAt: server.updatedAt, }; } export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { try { const user = assertProUser(await getCurrentUser()); const { id } = await params; const existing = await getUserMcpServerById({ id, userId: user.id }); if (!existing) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); const input = updateMcpServerSchema.parse(await request.json()); if (input.url) validateMcpServerUrl(input.url); const nextAuthType = input.authType ?? existing.authType; validateMcpOAuthConfig({ authType: nextAuthType, oauthIssuerUrl: input.oauthIssuerUrl ?? existing.oauthIssuerUrl ?? undefined, oauthAuthorizationUrl: input.oauthAuthorizationUrl ?? existing.oauthAuthorizationUrl ?? undefined, oauthTokenUrl: input.oauthTokenUrl ?? existing.oauthTokenUrl ?? undefined, oauthClientId: input.oauthClientId ?? existing.oauthClientId ?? undefined, }); let encryptedCredentials = existing.encryptedCredentials; let oauthAccessTokenEncrypted = existing.oauthAccessTokenEncrypted; let oauthRefreshTokenEncrypted = existing.oauthRefreshTokenEncrypted; let oauthAccessTokenExpiresAt = existing.oauthAccessTokenExpiresAt; let oauthConnectedAt = existing.oauthConnectedAt; let oauthError = existing.oauthError; if (input.clearCredentials === true || nextAuthType === 'none' || nextAuthType === 'oauth') { encryptedCredentials = null; } else if ( nextAuthType === 'bearer' && input.bearerToken || nextAuthType === 'header' && input.headerName && input.headerValue ) { encryptedCredentials = getEncryptedMcpCredentials({ name: input.name ?? existing.name, transportType: input.transportType ?? existing.transportType, url: input.url ?? existing.url, authType: nextAuthType, bearerToken: input.bearerToken, headerName: input.headerName, headerValue: input.headerValue, }); } if (nextAuthType !== 'oauth') { oauthAccessTokenEncrypted = null; oauthRefreshTokenEncrypted = null; oauthAccessTokenExpiresAt = null; oauthConnectedAt = null; oauthError = null; } else { if (input.clearOAuthTokens === true) { oauthAccessTokenEncrypted = null; oauthRefreshTokenEncrypted = null; oauthAccessTokenExpiresAt = null; oauthConnectedAt = null; } if (input.oauthClientSecret !== undefined) { // Empty string clears client secret. oauthError = null; } } const updated = await updateUserMcpServer({ id, userId: user.id, values: { name: input.name, transportType: input.transportType, url: input.url, authType: input.authType, isEnabled: input.isEnabled, disabledTools: input.disabledTools, encryptedCredentials, oauthIssuerUrl: input.oauthIssuerUrl === undefined ? undefined : (input.oauthIssuerUrl.trim() || null), oauthAuthorizationUrl: input.oauthAuthorizationUrl === undefined ? undefined : (input.oauthAuthorizationUrl.trim() || null), oauthTokenUrl: input.oauthTokenUrl === undefined ? undefined : (input.oauthTokenUrl.trim() || null), oauthScopes: input.oauthScopes === undefined ? undefined : normalizeMcpScopes(input.oauthScopes), oauthClientId: input.oauthClientId === undefined ? undefined : (input.oauthClientId.trim() || null), oauthClientSecretEncrypted: input.oauthClientSecret === undefined ? undefined : getEncryptedOAuthValue(input.oauthClientSecret), oauthAccessTokenEncrypted, oauthRefreshTokenEncrypted, oauthAccessTokenExpiresAt, oauthConnectedAt, oauthError, }, }); if (!updated) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); return Response.json({ server: serializeMcpServer(updated) }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof z.ZodError) { return new ChatSDKError('bad_request:api', error.issues[0]?.message || 'Invalid request payload').toResponse(); } if (error instanceof Error) { return new ChatSDKError('bad_request:api', error.message).toResponse(); } console.error('Failed to update MCP server:', error); return new ChatSDKError('bad_request:api', 'Failed to update MCP server').toResponse(); } } export async function DELETE( _request: Request, { params }: { params: Promise<{ id: string }> }, ) { try { const user = assertProUser(await getCurrentUser()); const { id } = await params; const deleted = await deleteUserMcpServer({ id, userId: user.id }); if (!deleted) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); return Response.json({ ok: true }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); console.error('Failed to delete MCP server:', error); return new ChatSDKError('bad_request:api', 'Failed to delete MCP server').toResponse(); } } ================================================ FILE: app/api/mcp/servers/[id]/tools/route.ts ================================================ import { createMCPClient } from '@ai-sdk/mcp'; import { getCurrentUser } from '@/app/actions'; import { getUserMcpServerById } from '@/lib/db/queries'; import { resolveMcpAuthHeaders } from '@/lib/mcp/auth-headers'; import { validateMcpServerUrl } from '@/lib/mcp/server-config'; import { ChatSDKError } from '@/lib/errors'; export async function GET( _request: Request, { params }: { params: Promise<{ id: string }> }, ) { try { const user = await getCurrentUser(); if (!user) return new ChatSDKError('unauthorized:auth').toResponse(); const { id } = await params; const server = await getUserMcpServerById({ id, userId: user.id }); if (!server) return new ChatSDKError('not_found:api', 'MCP server not found').toResponse(); validateMcpServerUrl(server.url); const client = await createMCPClient({ transport: { type: server.transportType, url: server.url, headers: await resolveMcpAuthHeaders({ server, userId: user.id }), }, }); try { const toolsResult = await client.listTools(); const tools = toolsResult.tools.map((t) => ({ name: t.name, title: t.title ?? null, description: t.description ?? null, })); return Response.json({ ok: true, tools }); } finally { await client.close(); } } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof Error) { return new ChatSDKError('bad_request:api', error.message).toResponse(); } return new ChatSDKError('bad_request:api', 'Failed to list MCP server tools').toResponse(); } } ================================================ FILE: app/api/mcp/servers/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { ChatSDKError } from '@/lib/errors'; import { createUserMcpServer, getUserMcpServersByUserId } from '@/lib/db/queries'; import { getEncryptedMcpCredentials, getEncryptedOAuthValue, normalizeMcpScopes, validateMcpOAuthConfig, validateMcpServerUrl, } from '@/lib/mcp/server-config'; import { z } from 'zod'; const optionalUrlField = z.preprocess( (value) => typeof value === 'string' && value.trim() === '' ? undefined : value, z.string().trim().url().optional(), ); const createMcpServerSchema = z.object({ name: z.string().trim().min(1).max(80), transportType: z.enum(['http', 'sse']), url: z.string().trim().url(), authType: z.enum(['none', 'bearer', 'header', 'oauth']), bearerToken: z.string().optional(), headerName: z.string().optional(), headerValue: z.string().optional(), oauthIssuerUrl: optionalUrlField, oauthAuthorizationUrl: optionalUrlField, oauthTokenUrl: optionalUrlField, oauthScopes: z.string().optional(), oauthClientId: z.string().optional(), oauthClientSecret: z.string().optional(), isEnabled: z.boolean().optional(), }); function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } function serializeMcpServer(server: { id: string; name: string; transportType: 'http' | 'sse'; url: string; authType: 'none' | 'bearer' | 'header' | 'oauth'; isEnabled: boolean; lastTestedAt: Date | null; lastError: string | null; oauthConnectedAt: Date | null; oauthError: string | null; createdAt: Date; updatedAt: Date; encryptedCredentials: string | null; oauthClientId: string | null; oauthIssuerUrl: string | null; oauthAuthorizationUrl: string | null; oauthTokenUrl: string | null; oauthScopes: string | null; oauthAccessTokenEncrypted: string | null; oauthRefreshTokenEncrypted: string | null; }) { return { id: server.id, name: server.name, transportType: server.transportType, url: server.url, authType: server.authType, isEnabled: server.isEnabled, hasCredentials: Boolean(server.encryptedCredentials), isOAuthConnected: Boolean( server.oauthAccessTokenEncrypted || server.oauthRefreshTokenEncrypted || server.oauthConnectedAt, ), oauthConfigured: server.authType === 'oauth', oauthIssuerUrl: server.oauthIssuerUrl, oauthAuthorizationUrl: server.oauthAuthorizationUrl, oauthTokenUrl: server.oauthTokenUrl, oauthScopes: server.oauthScopes, oauthClientId: server.oauthClientId, oauthError: server.oauthError, oauthConnectedAt: server.oauthConnectedAt, lastTestedAt: server.lastTestedAt, lastError: server.lastError, createdAt: server.createdAt, updatedAt: server.updatedAt, }; } export async function GET() { try { const user = assertProUser(await getCurrentUser()); const servers = await getUserMcpServersByUserId({ userId: user.id }); return Response.json({ servers: servers.map(serializeMcpServer) }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); console.error('Failed to list MCP servers:', error); return new ChatSDKError('bad_request:api', 'Failed to list MCP servers').toResponse(); } } export async function POST(request: Request) { try { const user = assertProUser(await getCurrentUser()); const input = createMcpServerSchema.parse(await request.json()); validateMcpServerUrl(input.url); validateMcpOAuthConfig(input); const created = await createUserMcpServer({ userId: user.id, name: input.name, transportType: input.transportType, url: input.url, authType: input.authType, encryptedCredentials: getEncryptedMcpCredentials(input), oauthIssuerUrl: input.oauthIssuerUrl?.trim() || null, oauthAuthorizationUrl: input.oauthAuthorizationUrl?.trim() || null, oauthTokenUrl: input.oauthTokenUrl?.trim() || null, oauthScopes: normalizeMcpScopes(input.oauthScopes), oauthClientId: input.oauthClientId?.trim() || null, oauthClientSecretEncrypted: getEncryptedOAuthValue(input.oauthClientSecret), oauthAccessTokenEncrypted: null, oauthRefreshTokenEncrypted: null, oauthAccessTokenExpiresAt: null, oauthConnectedAt: null, oauthError: null, isEnabled: input.isEnabled ?? true, }); return Response.json({ server: serializeMcpServer(created) }); } catch (error) { if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof z.ZodError) { return new ChatSDKError('bad_request:api', error.issues[0]?.message || 'Invalid request payload').toResponse(); } if (error instanceof Error) { return new ChatSDKError('bad_request:api', error.message).toResponse(); } console.error('Failed to create MCP server:', error); return new ChatSDKError('bad_request:api', 'Failed to create MCP server').toResponse(); } } ================================================ FILE: app/api/mcp/servers/test/route.ts ================================================ import { createMCPClient } from '@ai-sdk/mcp'; import { getCurrentUser } from '@/app/actions'; import { ChatSDKError } from '@/lib/errors'; import { getUserMcpServerById, updateUserMcpServer } from '@/lib/db/queries'; import { resolveMcpAuthHeaders } from '@/lib/mcp/auth-headers'; import { validateMcpServerUrl } from '@/lib/mcp/server-config'; import { z } from 'zod'; const testMcpServerSchema = z.object({ serverId: z.string().optional(), transportType: z.enum(['http', 'sse']).optional(), url: z.string().url().optional(), authType: z.enum(['none', 'bearer', 'header', 'oauth']).optional(), bearerToken: z.string().optional(), headerName: z.string().optional(), headerValue: z.string().optional(), oauthAccessToken: z.string().optional(), }).refine( (value) => Boolean(value.serverId) || (Boolean(value.transportType) && Boolean(value.url)), 'Provide serverId or transportType/url', ); function assertProUser(user: Awaited>) { if (!user) throw new ChatSDKError('unauthorized:auth', 'Authentication required'); if (!user.isProUser) throw new ChatSDKError('upgrade_required:auth', 'Pro subscription required'); return user; } function normalizeMcpTestErrorMessage( message: string, context?: { transportType?: 'http' | 'sse'; url?: string }, ) { const lower = message.toLowerCase(); const isNotInitialized = lower.includes('server not initialized') || (lower.includes('"code":-32000') && lower.includes('not initialized')); if (isNotInitialized && context?.transportType === 'http') { return [ 'This HTTP MCP endpoint rejected requests before initialize.', 'Try switching this server to SSE transport, or confirm the MCP URL is the correct session endpoint.', context.url ? `Checked URL: ${context.url}` : null, `Raw error: ${message.slice(0, 260)}`, ].filter(Boolean).join(' '); } return message; } export async function POST(request: Request) { let userIdForUpdate: string | null = null; let serverIdForUpdate: string | null = null; let errorContext: { transportType?: 'http' | 'sse'; url?: string } | undefined; try { const user = assertProUser(await getCurrentUser()); userIdForUpdate = user.id; const input = testMcpServerSchema.parse(await request.json()); const serverConfig = input.serverId ? await (async () => { const stored = await getUserMcpServerById({ id: input.serverId!, userId: user.id }); if (!stored) throw new ChatSDKError('not_found:api', 'MCP server not found'); serverIdForUpdate = stored.id; return { transportType: stored.transportType, url: stored.url, headers: await resolveMcpAuthHeaders({ server: stored, userId: user.id, }), authType: stored.authType, }; })() : (() => { validateMcpServerUrl(input.url!); const headers: Record = {}; if (input.authType === 'bearer' && input.bearerToken) { headers.Authorization = `Bearer ${input.bearerToken}`; } else if (input.authType === 'header' && input.headerName && input.headerValue) { headers[input.headerName] = input.headerValue; } else if (input.authType === 'oauth' && input.oauthAccessToken) { headers.Authorization = `Bearer ${input.oauthAccessToken}`; } return { transportType: input.transportType!, url: input.url!, headers, authType: input.authType ?? 'none', }; })(); validateMcpServerUrl(serverConfig.url); errorContext = { transportType: serverConfig.transportType, url: serverConfig.url, }; const client = await createMCPClient({ transport: { type: serverConfig.transportType, url: serverConfig.url, headers: serverConfig.headers, }, }); try { const tools = await client.tools(); const toolNames = Object.keys(tools); if (serverIdForUpdate && userIdForUpdate) { const values: Parameters[0]['values'] = { lastTestedAt: new Date(), lastError: null, }; if (serverConfig.authType === 'oauth') values.oauthError = null; await updateUserMcpServer({ id: serverIdForUpdate, userId: userIdForUpdate, values, }); } return Response.json({ ok: true, toolCount: toolNames.length, toolNames: toolNames.slice(0, 20), }); } finally { await client.close(); } } catch (error) { const rawMessage = error instanceof Error ? error.message : 'Connection test failed'; const normalizedMessage = normalizeMcpTestErrorMessage(rawMessage, errorContext); if (serverIdForUpdate && userIdForUpdate) { const server = await getUserMcpServerById({ id: serverIdForUpdate, userId: userIdForUpdate }).catch(() => null); const values: Parameters[0]['values'] = { lastTestedAt: new Date(), lastError: normalizedMessage.slice(0, 1000), }; if (server?.authType === 'oauth') { values.oauthError = normalizedMessage.slice(0, 1000); } await updateUserMcpServer({ id: serverIdForUpdate, userId: userIdForUpdate, values, }).catch(() => null); } if (error instanceof ChatSDKError) return error.toResponse(); if (error instanceof z.ZodError) { return new ChatSDKError('bad_request:api', error.issues[0]?.message || 'Invalid request payload').toResponse(); } if (error instanceof Error) return new ChatSDKError('bad_request:api', normalizedMessage).toResponse(); console.error('Failed to test MCP server:', error); return new ChatSDKError('bad_request:api', normalizedMessage).toResponse(); } } ================================================ FILE: app/api/og/chat/[id]/route.tsx ================================================ /* eslint-disable @next/next/no-img-element */ import { ImageResponse } from 'next/og'; import { getChatWithUserById, getMessagesByChatId } from '@/lib/db/queries'; import fs from 'fs'; import path from 'path'; import { SciraLogo } from '@/components/logos/scira-logo'; interface TextPart { type: 'text'; text: string; } interface MessagePart { type: string; text?: string; } // Extract text content from message parts function getTextFromParts(parts: unknown): string { if (!Array.isArray(parts)) return ''; const textPart = parts.find((p: MessagePart) => p.type === 'text') as TextPart | undefined; return textPart?.text || ''; } // Strip markdown formatting for plain text display function stripMarkdown(text: string): string { return text // Remove code blocks .replace(/```[\s\S]*?```/g, '') .replace(/`([^`]+)`/g, '$1') // Remove headers .replace(/^#{1,6}\s+/gm, '') // Remove bold/italic .replace(/\*\*\*(.+?)\*\*\*/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/___(.+?)___/g, '$1') .replace(/__(.+?)__/g, '$1') .replace(/_(.+?)_/g, '$1') // Remove links but keep text .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove images .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // Remove blockquotes .replace(/^>\s+/gm, '') // Remove horizontal rules .replace(/^[-*_]{3,}\s*$/gm, '') // Remove list markers .replace(/^[\s]*[-*+]\s+/gm, '') .replace(/^[\s]*\d+\.\s+/gm, '') // Remove file references (e.g., filename.pdf, document.docx) .replace(/\s*\S+\.(pdf|docx?|xlsx?|csv|txt|png|jpg|jpeg|gif)\b/gi, '') // Remove citation-like patterns .replace(/\[\d+\]/g, '') .replace(/\(\d+\)/g, '') // Clean up extra whitespace .replace(/\s{2,}/g, ' ') .replace(/\n{2,}/g, ' ') .trim(); } // Truncate text with ellipsis function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.slice(0, maxLength).trim() + '...'; } // Theme colors (from globals.css dark theme) // --background: oklch(0.1776 0 0) → #141414 // --foreground: oklch(0.9491 0 0) → #f0f0f0 // --accent: oklch(0.285 0 0) → #2a2a2a (user message bubble uses bg-accent/80) // --muted-foreground: oklch(0.7699 0 0) → #b5b5b5 const colors = { background: '#141414', foreground: '#f0f0f0', mutedForeground: '#b5b5b5', accent: '#2a2a2a', // user message bubble }; export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { try { const id = (await params).id; const chatWithUser = await getChatWithUserById({ id }); // Load fonts const geistFontPath = path.join(process.cwd(), 'app/api/og/chat/[id]/fonts', 'Geist-Regular.ttf'); const interFontPath = path.join(process.cwd(), 'app/api/og/chat/[id]/fonts', 'Inter-Regular.ttf'); const beVietnamProFontPath = path.join(process.cwd(), 'app/api/og/chat/[id]/fonts', 'BeVietnamPro-Medium.ttf'); const geistFontData = await fs.promises.readFile(geistFontPath); const interFontData = await fs.promises.readFile(interFontPath); const beVietnamProFontData = await fs.promises.readFile(beVietnamProFontPath); const fonts = [ { name: 'Geist', data: geistFontData, style: 'normal' as const }, { name: 'Inter', data: interFontData, style: 'normal' as const }, { name: 'BeVietnamPro', data: beVietnamProFontData, style: 'normal' as const }, ]; // Default OG image for non-public or missing chats if (!chatWithUser || chatWithUser.visibility !== 'public') { return new ImageResponse( (
Scira
Minimalistic AI Search Engine
), { width: 1200, height: 630, fonts }, ); } // Fetch messages for the chat preview const messages = await getMessagesByChatId({ id, limit: 10 }); const userMessage = messages.find((m) => m.role === 'user'); const assistantMessage = messages.find((m) => m.role === 'assistant'); const userText = truncateText(getTextFromParts(userMessage?.parts), 120); const rawAssistantText = getTextFromParts(assistantMessage?.parts); const assistantText = truncateText(stripMarkdown(rawAssistantText), 700); return new ImageResponse( (
{/* Logo */}
Scira AI
{/* Messages */}
{/* User message */} {userText && (
{userText}
)} {/* Assistant message */} {assistantText && (
{assistantText}
)}
{/* Bottom blur/fade effect */}
), { width: 1200, height: 630, fonts }, ); } catch (error) { console.error('Error generating OG image:', error); return new Response('Error generating OG image', { status: 500 }); } } ================================================ FILE: app/api/og/x-wrapped/route.tsx ================================================ /* eslint-disable @next/next/no-img-element */ import { ImageResponse } from 'next/og'; import fs from 'fs'; import path from 'path'; export async function GET() { try { // Read the background image const bgImagePath = path.join(process.cwd(), 'public', 'og-bg.png'); const bgImageData = await fs.promises.readFile(bgImagePath); const bgImageBase64 = `data:image/png;base64,${bgImageData.toString('base64')}`; // Load custom fonts const interFontPath = path.join(process.cwd(), 'app/api/og/chat/[id]/fonts', 'Inter-Regular.ttf'); const beVietnamProFontPath = path.join(process.cwd(), 'app/api/og/chat/[id]/fonts', 'BeVietnamPro-Medium.ttf'); const interFontData = await fs.promises.readFile(interFontPath); const beVietnamProFontData = await fs.promises.readFile(beVietnamProFontPath); return new ImageResponse( (
{/* Clean overlay */}
𝕏 Wrapped
Your year on X, analyzed by AI
), { width: 1200, height: 630, fonts: [ { name: 'Inter', data: interFontData, style: 'normal', }, { name: 'BeVietnamPro', data: beVietnamProFontData, style: 'normal', }, ], }, ); } catch (error) { console.error('Error generating OG image:', error); return new Response('Error generating OG image', { status: 500 }); } } ================================================ FILE: app/api/preferences/route.ts ================================================ import { getUser } from '@/lib/auth-utils'; import { upsertUserPreferences } from '@/lib/db/queries'; import { clearUserPreferencesCache, getCachedUserPreferencesByUserId } from '@/lib/user-data-server'; export async function GET() { try { const user = await getUser(); if (!user) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } const preferences = await getCachedUserPreferencesByUserId(user.id); return Response.json(preferences); } catch (error) { console.error('Failed to fetch user preferences:', error); return Response.json({ error: 'Failed to fetch user preferences' }, { status: 500 }); } } export async function POST(request: Request) { try { const user = await getUser(); if (!user) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await request.json(); const preferences = body?.preferences; if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences)) { return Response.json({ error: 'Invalid preferences payload' }, { status: 400 }); } const result = await upsertUserPreferences({ userId: user.id, preferences, }); clearUserPreferencesCache(user.id); return Response.json({ success: true, data: result }); } catch (error) { console.error('Failed to save user preferences:', error); return Response.json({ error: 'Failed to save user preferences' }, { status: 500 }); } } ================================================ FILE: app/api/proxy-image/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { try { const url = request.nextUrl.searchParams.get('url'); if (!url) { return NextResponse.json({ error: 'URL parameter is required' }, { status: 400 }); } // Validate URL try { new URL(url); } catch { return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); } // Fetch the image const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', }, }); if (!response.ok) { return NextResponse.json({ error: 'Failed to fetch image' }, { status: response.status }); } // Get the content type const contentType = response.headers.get('content-type') || 'image/jpeg'; // Return the image with proper headers return new NextResponse(response.body, { headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', 'Access-Control-Allow-Origin': '*', }, }); } catch (error) { console.error('Image proxy error:', error); return NextResponse.json({ error: 'Failed to proxy image' }, { status: 500 }); } } ================================================ FILE: app/api/raycast/route.ts ================================================ import { webSearchTool } from '@/lib/tools'; import { xSearchTool } from '@/lib/tools/x-search'; import { convertToModelMessages, customProvider, generateText, stepCountIs } from 'ai'; import { xai } from '@ai-sdk/xai'; const scira = customProvider({ languageModels: { 'scira-default': xai('grok-4-1-fast-non-reasoning'), }, }); export const maxDuration = 800; // Define separate system prompts for each group const groupSystemPrompts = { web: `You are Scira for Raycast, a powerful AI web search assistant. Today's Date: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' })} Current Time: ${new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })} ### Core Guidelines: - Always run the web_search tool first before composing your response. - Provide concise, well-formatted responses optimized for Raycast's interface. - Use markdown formatting for better readability. - Avoid hallucinations or fabrications. Stick to verified facts with proper citations. - Respond in a direct, efficient manner suitable for quick information retrieval. ### Web Search Guidelines: - Always make multiple targeted queries (2-4) to get comprehensive results. - Never use the same query twice and always make more than 2 queries. - **⚠️ CRITICAL: Always include date/time context in search queries:** - For current events: "latest", "${new Date().getFullYear()}", "today", "current", "recent" - For historical info: specific years or date ranges - For time-sensitive topics: "newest", "updated", "${new Date().getFullYear()}" - **NO TEMPORAL ASSUMPTIONS**: Never assume time periods - always be explicit about dates/years - Examples: "latest AI news ${new Date().getFullYear()}", "current stock prices today", "recent developments in ${new Date().getFullYear()}" - You can select "general", "news" or "finance" in the search type. - Place citations directly after relevant sentences or paragraphs. - Citation format: [Source Title](URL) - Ensure citations adhere strictly to the required format. ### Response Formatting: - Start with a direct answer to the user's question. - Use markdown headings (h2, h3) to organize information. - Present information in a logical flow with proper citations. - Keep responses concise but informative, optimized for Raycast's interface. - Use bullet points or numbered lists for clarity when appropriate. ### Latex and Currency Formatting: - Use $ for inline equations and $$ for block equations. - Use "USD" instead of $ for currency. Remember, you are designed to be efficient and helpful in the Raycast environment, providing quick access to web information.`, x: `You are a X/Twitter content curator that helps find relevant posts. The current date is ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' })}. Once you get the content from the tools only write in paragraphs. No need to say that you are calling the tool, just call the tools first and run the search; then talk in long details in 2-6 paragraphs. make sure to use the start date and end date in the parameters. default is 1 month. If the user gives you a specific time like start date and end date, then add them in the parameters. default is 1 week. Always provide the citations at the end of each paragraph and in the end of sentences where you use it in which they are referred to with the given format to the information provided. Citation format: [Post Title](URL) The X handle can any company, person, or organization mentioned in the post that you know of or the user is asking about. # Latex and Currency Formatting to be used: - Always use '$' for inline equations and '$$' for block equations. - Avoid using '$' for dollar currency. Use "USD" instead.`, }; // Modify the POST function to use the new handler export async function POST(req: Request) { const { messages, model, group = 'web' } = await req.json(); console.log('Running with model: ', model.trim()); console.log('Group: ', group); // Get the appropriate system prompt based on the group const systemPrompt = groupSystemPrompts[group as keyof typeof groupSystemPrompts]; // Determine which tools to activate based on the group const activeTools = group === 'x' ? ['x_search' as const] : group === 'web' ? ['web_search' as const] : ['web_search' as const, 'x_search' as const]; const { text, steps } = await generateText({ model: scira.languageModel(model), system: systemPrompt, stopWhen: stepCountIs(2), messages: await convertToModelMessages(messages), temperature: 0, toolChoice: 'auto', experimental_activeTools: activeTools, tools: { web_search: webSearchTool(undefined, 'exa'), x_search: xSearchTool(undefined), }, }); console.log('Text: ', text); console.log('Steps: ', steps); return new Response(text); } ================================================ FILE: app/api/search/[id]/stop/route.ts ================================================ import { auth } from '@/lib/auth'; import { getChatById, getLatestStreamIdByChatId } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; import { createResumableUIMessageStream } from 'ai-resumable-stream'; import { getResumableStreamClients } from '@/lib/redis'; import { all } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id: chatId } = await params; const clients = getResumableStreamClients(); if (!clients) { return new Response(null, { status: 204 }); } if (!chatId) { return new ChatSDKError('bad_request:api').toResponse(); } const { session, chat, latestStreamId } = await all( { async session() { return auth.api.getSession(req); }, async chat() { return getChatById({ id: chatId }).catch(() => null); }, async latestStreamId() { return getLatestStreamIdByChatId({ chatId }); }, }, getBetterAllOptions(), ); if (!session?.user) { return new ChatSDKError('unauthorized:chat').toResponse(); } if (!chat) { return new ChatSDKError('not_found:chat').toResponse(); } if (chat.userId !== session.user.id) { return new ChatSDKError('forbidden:chat').toResponse(); } if (!latestStreamId) { return new Response(null, { status: 204 }); } const context = await createResumableUIMessageStream({ streamId: latestStreamId, publisher: clients.publisher, subscriber: clients.subscriber, }); await context.stopStream(); return new Response(null, { status: 200 }); } ================================================ FILE: app/api/search/[id]/stream/route.ts ================================================ import { auth } from '@/lib/auth'; import { getChatById, getMessagesByChatId, getStreamIdsByChatId } from '@/lib/db/queries'; import type { Chat } from '@/lib/db/schema'; import { ChatSDKError } from '@/lib/errors'; import type { ChatMessage } from '@/lib/types'; import { createUIMessageStream, JsonToSseTransformStream } from 'ai'; import { createResumableUIMessageStream } from 'ai-resumable-stream'; import { getResumableStreamClients } from '@/lib/redis'; import { differenceInSeconds } from 'date-fns'; import { all } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id: chatId } = await params; const clients = getResumableStreamClients(); const resumeRequestedAt = new Date(); if (!clients) { return new Response(null, { status: 204 }); } if (!chatId) { return new ChatSDKError('bad_request:api').toResponse(); } // Parallelize session, chat, and streamIds fetch (eliminate waterfall) const { session, chat, streamIds } = await all( { async session() { return auth.api.getSession(req); }, async chat() { return getChatById({ id: chatId }).catch(() => null); }, async streamIds() { return getStreamIdsByChatId({ chatId }); }, }, getBetterAllOptions(), ); if (!session?.user) { return new ChatSDKError('unauthorized:chat').toResponse(); } if (!chat) { return new ChatSDKError('not_found:chat').toResponse(); } if (chat.visibility === 'private' && chat.userId !== session.user.id) { return new ChatSDKError('forbidden:chat').toResponse(); } if (!streamIds.length) { return new ChatSDKError('not_found:stream').toResponse(); } const recentStreamId = streamIds.at(-1); if (!recentStreamId) { return new ChatSDKError('not_found:stream').toResponse(); } const context = await createResumableUIMessageStream({ streamId: recentStreamId, publisher: clients.publisher, subscriber: clients.subscriber, }); const stream = await context.resumeStream(); const emptyDataStream = createUIMessageStream({ execute: () => {}, }); /* * For when the generation is streaming during SSR * but the resumable stream has concluded at this point. */ if (!stream) { const messages = await getMessagesByChatId({ id: chatId }); console.log('Messages: ', messages); const mostRecentMessage = messages.at(-1); if (!mostRecentMessage) { console.log('No most recent message found'); return new Response(emptyDataStream.pipeThrough(new JsonToSseTransformStream()), { status: 200 }); } if (mostRecentMessage.role !== 'assistant') { console.log('Most recent message is not an assistant message'); return new Response(emptyDataStream.pipeThrough(new JsonToSseTransformStream()), { status: 200 }); } const messageCreatedAt = new Date(mostRecentMessage.createdAt); if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) { console.log('Most recent message is too old'); return new Response(emptyDataStream.pipeThrough(new JsonToSseTransformStream()), { status: 200 }); } const restoredStream = createUIMessageStream({ execute: ({ writer }) => { console.log('Restoring stream...'); console.log('Most recent message: ', mostRecentMessage); writer.write({ type: 'data-appendMessage', data: JSON.stringify(mostRecentMessage), transient: true, }); }, }); return new Response(restoredStream.pipeThrough(new JsonToSseTransformStream()), { status: 200 }); } return new Response((stream as ReadableStream).pipeThrough(new JsonToSseTransformStream()), { status: 200 }); } ================================================ FILE: app/api/search/route.ts ================================================ // /app/api/chat/route.ts import { convertToModelMessages, generateText, Output, streamText, pruneMessages, NoSuchToolError, createUIMessageStream, tool, stepCountIs, JsonToSseTransformStream, TextPart, ImagePart, FilePart, InferUIMessageChunk, AsyncIterableStream, } from 'ai'; import { pipeJsonRender } from '@json-render/core'; import { scira, requiresAuthentication, requiresProSubscription, requiresMaxSubscription, shouldBypassRateLimits, getModelParameters, getMaxOutputTokens, hasVisionSupport, getModelProvider, } from '@/ai/providers'; import { createStreamId, getChatByIdForValidation, getLatestStreamIdByChatId, getLatestUserMessageIdByChatId, getMessagesByChatId, saveNewChatWithStream, saveMessages, incrementExtremeSearchUsage, incrementMessageUsage, incrementAnthropicUsage, incrementGoogleUsage, updateChatTitleById, } from '@/lib/db/queries'; import { ChatSDKError } from '@/lib/errors'; import { after } from 'next/server'; import { CustomInstructions, Message as DbMessage } from '@/lib/db/schema'; import { v7 as uuidv7 } from 'uuid'; import { geolocation } from '@vercel/functions'; import { all } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; import { GroqProviderOptions } from '@ai-sdk/groq'; import { markdownJoinerTransform } from '@/lib/parser'; import { ChatMessage } from '@/lib/types'; import { OpenAIResponsesProviderOptions } from '@ai-sdk/openai'; import { AnthropicProviderOptions } from '@ai-sdk/anthropic'; import { getGroupConfig } from '@/lib/search/group-config'; import { getCurrentUser, getLightweightUser, getMessageCountAndExtremeSearchByUserIdAction, } from '@/lib/search/server-helpers'; import { getCachedCustomInstructionsByUserId, getCachedUserPreferencesByUserId } from '@/lib/user-data-server'; import { GoogleGenerativeAIProviderOptions, GoogleLanguageModelOptions } from '@ai-sdk/google'; import { unauthenticatedRateLimit, getClientIdentifier } from '@/lib/rate-limit'; import { loadConfiguredTools } from '@/lib/search/tool-loader'; import { CohereChatModelOptions } from '@ai-sdk/cohere'; import { xai } from '@ai-sdk/xai'; interface CriticalChecksResult { canProceed: boolean; error?: any; isProUser: boolean; isMaxUser: boolean; messageCount?: number; extremeSearchUsage?: number; subscriptionData?: any; shouldBypassLimits?: boolean; } interface ChatInitializationParams { chatQueryPromise: Promise; lightweightUser: { userId: string; email: string; isProUser: boolean; isMaxUser: boolean } | null; isProUser: boolean; isMaxUser: boolean; id: string; streamId: string; selectedVisibilityType: any; messages: any[]; model: string; isTemporaryChat: boolean; enableDetailedTiming?: boolean; } function initializeChatAndChecks({ chatQueryPromise, lightweightUser, isProUser, isMaxUser, id, streamId, selectedVisibilityType, messages, model, isTemporaryChat, enableDetailedTiming = false, }: ChatInitializationParams): { criticalChecksPromise: Promise; chatInitializationPromise: Promise<{ isNewChat: boolean; titlePromise: Promise | null }>; } { async function withTiming(label: string, promise: Promise): Promise { if (!enableDetailedTiming) return promise; const startedAt = Date.now(); try { const value = await promise; console.log(`⏱ ${label}: ${Date.now() - startedAt}ms`); return value; } catch (error) { console.log(`⏱ ${label}: ${Date.now() - startedAt}ms (failed)`); throw error; } } // Unauthenticated users don't need chat validation if (!lightweightUser) { return { criticalChecksPromise: Promise.resolve({ canProceed: true, isProUser: false, isMaxUser: false, messageCount: 0, extremeSearchUsage: 0, subscriptionData: null, shouldBypassLimits: false, }), chatInitializationPromise: Promise.resolve({ isNewChat: false, titlePromise: null }), }; } if (isTemporaryChat) { let criticalChecksPromise: Promise; if (isProUser) { // Pro users: known from lightweightUser — resolve immediately, no DB needed criticalChecksPromise = Promise.resolve({ canProceed: true, isProUser: true, isMaxUser, messageCount: 0, extremeSearchUsage: 0, subscriptionData: null, shouldBypassLimits: true, }); } else { criticalChecksPromise = (async () => { const { messageCountResult, extremeSearchUsage, anthropicUsageResult, googleUsageResult } = await getMessageCountAndExtremeSearchByUserIdAction(lightweightUser.userId); if (messageCountResult.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify usage limits'); } if (extremeSearchUsage.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify extreme search usage limits'); } if (anthropicUsageResult.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify anthropic usage limits'); } if (googleUsageResult.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify google usage limits'); } const shouldBypassLimits = shouldBypassRateLimits(model, lightweightUser); const isAnthropicModel = getModelProvider(model) === 'anthropic'; const isMaxGoogleModel = getModelProvider(model) === 'google' && lightweightUser.isMaxUser; if (!shouldBypassLimits && messageCountResult.count !== undefined && messageCountResult.count >= 100) { throw new ChatSDKError('rate_limit:chat', 'Daily search limit reached'); } if ( isAnthropicModel && lightweightUser.isMaxUser && anthropicUsageResult.count !== undefined && anthropicUsageResult.count >= 60 ) { throw new ChatSDKError('rate_limit:model', 'Daily Anthropic limit reached for Max users.'); } if ( isMaxGoogleModel && googleUsageResult.count !== undefined && googleUsageResult.count >= 80 ) { throw new ChatSDKError('rate_limit:model', 'Monthly Gemini limit reached for Max users.'); } return { canProceed: true, isProUser: false, isMaxUser: false, messageCount: messageCountResult.count, extremeSearchUsage: extremeSearchUsage.count, anthropicUsage: anthropicUsageResult.count, subscriptionData: { hasSubscription: false }, shouldBypassLimits, }; })().catch((error) => { if (error instanceof ChatSDKError) throw error; throw new ChatSDKError('bad_request:api', 'Failed to verify user access'); }); } return { criticalChecksPromise, chatInitializationPromise: Promise.resolve({ isNewChat: false, titlePromise: null }), }; } // Validate ownership once and get chat data const validatedChatPromise = withTiming( 'chat_init.existingChat_wait', chatQueryPromise.then((existingChat) => { if (existingChat && existingChat.userId !== lightweightUser.userId) { throw new ChatSDKError('forbidden:chat', 'This chat belongs to another user'); } return existingChat; }), ); // Build critical checks promise first (must complete before chat creation) let criticalChecksPromise: Promise; if (isProUser) { // Pro users: ownership check only, no usage DB calls or fullUserPromise needed. // validatedChatPromise is fast (cache + indexed lookup) and unblocks saveChat/createStreamId earlier. criticalChecksPromise = validatedChatPromise.then(() => ({ canProceed: true, isProUser: true, isMaxUser, messageCount: 0, extremeSearchUsage: 0, subscriptionData: null, shouldBypassLimits: true, })); } else { // Non-Pro users: validate ownership and check usage limits. // Run chat validation and usage fetch in parallel to save one RTT. criticalChecksPromise = (async () => { const { validatedChat, usageResult } = await all( { async validatedChat() { return validatedChatPromise; }, async usageResult() { return getMessageCountAndExtremeSearchByUserIdAction(lightweightUser.userId); }, }, getBetterAllOptions(), ); if (validatedChat && validatedChat.userId !== lightweightUser.userId) { throw new ChatSDKError('forbidden:chat', 'This chat belongs to another user'); } const { messageCountResult, extremeSearchUsage, anthropicUsageResult, googleUsageResult } = usageResult; if (messageCountResult.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify usage limits'); } if (extremeSearchUsage.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify extreme search usage limits'); } if (anthropicUsageResult.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify anthropic usage limits'); } if (googleUsageResult.error) { throw new ChatSDKError('bad_request:api', 'Failed to verify google usage limits'); } const shouldBypassLimits = shouldBypassRateLimits(model, lightweightUser); const isAnthropicModel = getModelProvider(model) === 'anthropic'; const isMaxGoogleModel = getModelProvider(model) === 'google' && lightweightUser.isMaxUser; if (!shouldBypassLimits && messageCountResult.count !== undefined && messageCountResult.count >= 100) { throw new ChatSDKError('rate_limit:chat', 'Daily search limit reached'); } if ( isAnthropicModel && lightweightUser.isMaxUser && anthropicUsageResult.count !== undefined && anthropicUsageResult.count >= 60 ) { throw new ChatSDKError('rate_limit:model', 'Daily Anthropic limit reached for Max users.'); } if ( isMaxGoogleModel && googleUsageResult.count !== undefined && googleUsageResult.count >= 80 ) { throw new ChatSDKError('rate_limit:model', 'Monthly Gemini limit reached for Max users.'); } return { canProceed: true, isProUser: false, isMaxUser: false, messageCount: messageCountResult.count, extremeSearchUsage: extremeSearchUsage.count, anthropicUsage: anthropicUsageResult.count, subscriptionData: { hasSubscription: false }, shouldBypassLimits, }; })().catch((error) => { if (error instanceof ChatSDKError) throw error; throw new ChatSDKError('bad_request:api', 'Failed to verify user access'); }); } criticalChecksPromise = withTiming('chat_init.criticalResult_wait', criticalChecksPromise); // For existing chats, start stream ID creation immediately (runs in parallel with critical checks) const earlyStreamIdPromise = withTiming( 'chat_init.streamIdCreated_wait', validatedChatPromise.then(async (existingChat) => { if (existingChat) { await createStreamId({ streamId, chatId: id }); return true; } return false; }), ); // Initialize chat (create if needed, create stream ID) // For new chats, wait for critical checks to complete first, then create chat (FK constraint) const chatInitializationPromise = withTiming( 'chat_init.total', all( { async existingChat() { return validatedChatPromise; }, async criticalResult() { return criticalChecksPromise; }, async streamIdCreated() { return earlyStreamIdPromise; }, }, getBetterAllOptions(), ) .then(async ({ existingChat, criticalResult }) => { // Verify critical checks passed before creating new chat if (!criticalResult.canProceed) { throw criticalResult.error || new ChatSDKError('bad_request:api', 'Failed to verify user access'); } if (!existingChat) { // New chat: save chat + stream ID in one CTE query (single DB round-trip) await saveNewChatWithStream({ chatId: id, userId: lightweightUser.userId, title: 'New Chat', visibility: selectedVisibilityType, streamId, }); // Fire off title generation without blocking chat creation const titlePromise = import('@/lib/search/chat-title') .then(({ generateTitleFromUserMessage }) => generateTitleFromUserMessage({ message: messages[messages.length - 1], }), ) .catch(() => 'New Chat'); return { isNewChat: true, titlePromise }; } else { // Stream ID already created in parallel via earlyStreamIdPromise return { isNewChat: false, titlePromise: null }; } }) .catch((error) => { if (error instanceof ChatSDKError) throw error; console.error('Chat initialization failed:', error); throw new ChatSDKError('bad_request:database', 'Failed to initialize chat'); }), ); return { criticalChecksPromise, chatInitializationPromise }; } export async function getStreamContext() { const { getResumableStreamClients } = await import('@/lib/redis'); return getResumableStreamClients(); } export async function POST(req: Request) { const requestStartTime = Date.now(); const preStreamTimings: { label: string; durationMs: number }[] = []; const shouldLogTimings = process.env.NODE_ENV !== 'production' && process.env.DEBUG_PERF === '1'; function recordTiming(label: string, startTime: number) { preStreamTimings.push({ label, durationMs: Date.now() - startTime, }); } let opStart = Date.now(); const { messages: requestMessages, model: requestedModel, group, timezone, id, selectedVisibilityType, isCustomInstructionsEnabled, searchProvider, extremeSearchModel, selectedConnectors, isTemporaryChat, isAutoRouted, autoRouterEnabled, autoRouterConfig, } = await req.json(); recordTiming('parse_request_body', opStart); if (!Array.isArray(requestMessages) || requestMessages.length === 0) { return new ChatSDKError('bad_request:api', 'Messages array is required and cannot be empty').toResponse(); } const incomingMessages = requestMessages as ChatMessage[]; const requestLastUserMessage = [...incomingMessages].reverse().find((message) => message.role === 'user'); if (!requestLastUserMessage) { return new ChatSDKError('bad_request:api', 'A user message is required').toResponse(); } opStart = Date.now(); const { latitude, longitude } = geolocation(req); recordTiming('geolocation_lookup', opStart); const streamId = 'stream-' + uuidv7(); // Initialize model - will be updated by auto-router if needed let model = requestedModel.trim(); let autoRouteName: string | undefined; console.log('🔍 Search API:', { model, requestedModel, group, latitude, longitude, isAutoRouted, autoRouterEnabled, }); // Start all independent operations in parallel immediately opStart = Date.now(); const lightweightUserPromise = getLightweightUser(); // Use lightweight validation query - only fetches id and userId const chatQueryPromise = isTemporaryChat ? Promise.resolve(null) : getChatByIdForValidation({ id }); const persistedMessagesPromise = isTemporaryChat || incomingMessages.length > 1 ? Promise.resolve([]) : getMessagesByChatId({ id }); const isDev = process.env.NODE_ENV === 'development'; const rateLimitPromise = lightweightUserPromise.then((user) => { if (user || isDev) return null; const identifier = getClientIdentifier(req); return unauthenticatedRateLimit.limit(identifier); }); recordTiming('start_parallel_operations', opStart); // Wait for lightweight user first (needed for early exit checks) opStart = Date.now(); const lightweightUser = await lightweightUserPromise; recordTiming('get_lightweight_user', opStart); // Start full user fetch immediately (doesn't block early exits) const isProUser = lightweightUser?.isProUser ?? false; const isMaxUser = lightweightUser?.isMaxUser ?? false; const shouldUseXaiMultiAgent = group === 'multi-agent' && isProUser; opStart = Date.now(); const fullUserPromise = lightweightUser ? getCurrentUser() : Promise.resolve(null); recordTiming('create_full_user_promise', opStart); // Rate limit check for unauthenticated users (skip in dev environment) if (!lightweightUser && !isDev) { opStart = Date.now(); const rateLimitResult = await rateLimitPromise; if (!rateLimitResult) { return new ChatSDKError('rate_limit:api', 'Rate limit check failed').toResponse(); } const { success, limit, reset } = rateLimitResult; recordTiming('unauthenticated_rate_limit', opStart); if (!success) { const resetDate = new Date(reset); return new ChatSDKError( 'rate_limit:api', `You've reached the limit of ${limit} searches per day for unauthenticated users. Sign in for more searches or wait until ${resetDate.toLocaleString()}.`, ).toResponse(); } } // Early exit checks (no DB operations needed) if (!lightweightUser) { if (requiresAuthentication(model)) { return new ChatSDKError('unauthorized:model', `${model} requires authentication`).toResponse(); } if (group === 'extreme') { return new ChatSDKError('unauthorized:auth', 'Authentication required to use Extreme Search mode').toResponse(); } if (group === 'mcp') { return new ChatSDKError('unauthorized:auth', 'Authentication required to use MCP mode').toResponse(); } } else { // Fast auth checks using lightweight user (no additional DB calls) if (requiresMaxSubscription(model) && !lightweightUser.isMaxUser) { return new ChatSDKError('upgrade_required:model', `${model} requires a Max subscription`).toResponse(); } if (requiresProSubscription(model) && !lightweightUser.isProUser && !lightweightUser.isMaxUser) { return new ChatSDKError('upgrade_required:model', `${model} requires a Pro subscription`).toResponse(); } if (group === 'mcp' && !lightweightUser.isProUser && !lightweightUser.isMaxUser) { return new ChatSDKError('upgrade_required:auth', 'MCP mode requires a Pro subscription').toResponse(); } } // Start config and custom instructions in parallel // Use lightweightUser.userId directly instead of waiting for fullUserPromise opStart = Date.now(); const configPromise = getGroupConfig(group, lightweightUser, fullUserPromise); const customInstructionsPromise = lightweightUser && (isCustomInstructionsEnabled ?? true) ? getCachedCustomInstructionsByUserId(lightweightUser.userId) : Promise.resolve(null); const userPreferencesPromise = lightweightUser ? getCachedUserPreferencesByUserId(lightweightUser.userId) : Promise.resolve(null); recordTiming('start_parallel_config_and_user_promises', opStart); // Initialize chat and perform critical checks (chatQueryPromise already started) opStart = Date.now(); const { criticalChecksPromise, chatInitializationPromise } = initializeChatAndChecks({ chatQueryPromise, lightweightUser, isProUser, isMaxUser, id, streamId, selectedVisibilityType, messages: incomingMessages, model, isTemporaryChat: Boolean(isTemporaryChat), enableDetailedTiming: shouldLogTimings, }); recordTiming('initialize_chat_and_checks', opStart); let customInstructions: CustomInstructions | null = null; // Wait for critical checks, config, and chat initialization in parallel // Chat initialization is critical: for new chats it must complete before streaming (FK constraint) const { criticalResult, config, customInstructionsResult, chatInitResult, userPreferencesResult, persistedMessages } = await all( { async criticalResult() { return criticalChecksPromise; }, async config() { return configPromise; }, async customInstructionsResult() { return customInstructionsPromise; }, async chatInitResult() { return chatInitializationPromise; // Must complete before streaming (especially for new chats) }, async userPreferencesResult() { return userPreferencesPromise; }, async persistedMessages() { return persistedMessagesPromise; }, }, getBetterAllOptions(), ); const { tools: activeTools, instructions } = config; recordTiming('await_parallel_setup', opStart); if (!criticalResult.canProceed) { throw criticalResult.error; } customInstructions = customInstructionsResult; const persistedDbMessages = persistedMessages as DbMessage[]; const persistedMessageIds = new Set(persistedDbMessages.map((message) => message.id)); const newIncomingMessages = incomingMessages.filter((message) => !persistedMessageIds.has(message.id)); const normalizedPersistedMessages: ChatMessage[] = persistedDbMessages.map((message) => ({ id: message.id, role: message.role as 'user' | 'assistant' | 'system', parts: (message.parts as ChatMessage['parts']) ?? [], metadata: { createdAt: message.createdAt.toISOString(), model: message.model ?? '', completionTime: message.completionTime ?? null, inputTokens: message.inputTokens ?? null, outputTokens: message.outputTokens ?? null, totalTokens: message.totalTokens ?? null, }, })); const hydratedMessages = !isTemporaryChat && normalizedPersistedMessages.length > 0 && incomingMessages.length === 1 ? [...normalizedPersistedMessages, ...newIncomingMessages] : incomingMessages; // Save user message (chat is guaranteed to exist now) - await synchronously (no background) if (lightweightUser && !isTemporaryChat && newIncomingMessages.length > 0) { const latestIncomingUserMessage = [...newIncomingMessages].reverse().find((message) => message.role === 'user'); if (latestIncomingUserMessage) { opStart = Date.now(); await saveMessages({ messages: [ { chatId: id, id: latestIncomingUserMessage.id, role: 'user', parts: latestIncomingUserMessage.parts, attachments: [], createdAt: new Date(), model: model, inputTokens: 0, outputTokens: 0, totalTokens: 0, completionTime: 0, }, ], }); recordTiming('save_user_message', opStart); } } const setupTimeMs = Date.now() - requestStartTime; if (shouldLogTimings) { console.log('⏱ Pre-stream operation timings (ms):', preStreamTimings); console.log(`🚀 Time to streamText: ${(setupTimeMs / 1000).toFixed(2)}s`); } const streamStartTime = Date.now(); const initialMessageIds = new Set(hydratedMessages.map((message: any) => message.id)); const requestLastUserMessageId: string | null = requestLastUserMessage?.id ?? null; const userMessageCount = hydratedMessages.filter((message: any) => message.role === 'user').length; const shouldPrune = userMessageCount > 10; const prunedMessages = shouldPrune ? await (async () => { console.log( `🔧 Pruning messages: ${userMessageCount} user messages (${hydratedMessages.length} total messages)`, ); const pruned = pruneMessages({ reasoning: 'all', messages: await convertToModelMessages(hydratedMessages, { ignoreIncompleteToolCalls: true, }), toolCalls: 'before-last-5-messages', }); console.log(`✂️ Pruned to ${pruned.length} messages`); return pruned; })() : await convertToModelMessages(hydratedMessages, { ignoreIncompleteToolCalls: true, }); // Extract document files from ALL messages for file_query_search tool // PDF support requires Pro subscription (enforced in form-component.tsx) const documentMimeTypes = [ 'application/pdf', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel', ]; // Collect all document files from all messages in the conversation const contextFiles: Array<{ url: string; contentType: string; name?: string }> = []; const seenUrls = new Set(); for (const msg of hydratedMessages) { const parts = (msg.parts as (TextPart | ImagePart | FilePart)[]) ?? []; for (const part of parts) { if (part.type === 'file') { const filePart = part as any; const mediaType = filePart.mediaType || ''; const url = filePart.url || ''; if (documentMimeTypes.includes(mediaType) && url && !seenUrls.has(url)) { seenUrls.add(url); contextFiles.push({ url, contentType: mediaType, name: filePart.name, }); } } } } // Process messages to remove document file parts from model input let processedMessages = prunedMessages.map((msg: any) => { if (msg.role === 'user' && Array.isArray(msg.content)) { // Filter out document file parts const filteredContent = msg.content.filter((part: any) => { if (part.type === 'file') { const mediaType = part.mimeType || part.mediaType || ''; return !documentMimeTypes.includes(mediaType); } return true; }); return { ...msg, content: filteredContent }; } return msg; }); // If there are document files in the conversation, add instruction to the last user message if (contextFiles.length > 0) { const fileNames = contextFiles.map((f) => f.name || 'unnamed file').join(', '); const fileInstruction = `\n\n[Attached files in conversation: ${fileNames}. Use the file_query_search tool to search and retrieve information from these files.]`; // Find the last user message and append the instruction for (let i = processedMessages.length - 1; i >= 0; i--) { const msg = processedMessages[i]; if (msg.role === 'user') { if (Array.isArray(msg.content)) { const lastTextIndex = msg.content.findLastIndex((p: any) => p.type === 'text'); if (lastTextIndex >= 0) { msg.content[lastTextIndex] = { ...msg.content[lastTextIndex], text: msg.content[lastTextIndex].text + fileInstruction, }; } else { msg.content.push({ type: 'text', text: fileInstruction.trim() }); } } else if (typeof msg.content === 'string') { msg.content = msg.content + fileInstruction; } break; } } } // Detect images in last user message for auto-routing const lastUserMessage = [...hydratedMessages].reverse().find((msg) => msg.role === 'user'); const lastUserParts = (lastUserMessage?.parts as (TextPart | ImagePart | FilePart)[]) ?? []; const hasImages = lastUserParts.some((part) => part.type === 'file' && (part as any).mediaType?.startsWith('image/')); // Auto-routing logic - run on server side if auto router is selected if (isAutoRouted && autoRouterEnabled && requestedModel === 'scira-auto') { // Extract last user query for routing let query = ''; for (let i = hydratedMessages.length - 1; i >= 0; i--) { const msg = hydratedMessages[i]; if (msg.role === 'user') { const parts = (msg.parts as (TextPart | ImagePart | FilePart)[]) ?? []; for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j]; if (part.type === 'text' && part.text) { query = part.text; break; } } if (query) break; } } // Run auto router with user's configured routes const routes = autoRouterConfig?.routes ?? []; if (query && routes.length > 0) { try { const { routeWithAutoRouter } = await import('@/lib/search/auto-router'); const routeResult = await routeWithAutoRouter({ query, routes, hasImages }); if (routeResult?.success && routeResult.model) { model = routeResult.model; autoRouteName = routeResult.route; } else { model = 'scira-default'; } } catch (error) { console.error('Auto router error:', error); model = 'scira-default'; } } else { model = 'scira-default'; } if (hasImages && !hasVisionSupport(model)) { model = 'scira-default'; autoRouteName = 'other'; } } const abortController = new AbortController(); let finalUsageMetadata: { completionTime: number | null; inputTokens: number | null; outputTokens: number | null; totalTokens: number | null; } = { completionTime: null, inputTokens: null, outputTokens: null, totalTokens: null, }; const stream = createUIMessageStream({ execute: async ({ writer: dataStream }) => { let mcpDynamicTools: Record = {}; let closeMcpTools = async () => {}; let mcpToolsClosed = false; const closeMcpToolsSafe = async () => { if (mcpToolsClosed) return; mcpToolsClosed = true; await closeMcpTools().catch((error) => { console.warn('Failed closing MCP clients:', error); }); }; const shouldLoadMcpTools = Boolean(lightweightUser?.isProUser && (group === 'mcp' || group === 'extreme')); if (shouldLoadMcpTools && lightweightUser) { const { resolveUserMcpTools } = await import('@/lib/tools/mcp-client'); const resolvedMcp = await resolveUserMcpTools({ userId: lightweightUser.userId, dataStream, }); mcpDynamicTools = resolvedMcp.tools; closeMcpTools = resolvedMcp.closeAll; if (resolvedMcp.errors.length > 0) { console.warn('MCP tool loading errors:', resolvedMcp.errors); } } const dynamicMcpToolNames = Object.keys(mcpDynamicTools); const configuredActiveTools = [ ...activeTools, ...(group === 'mcp' || group === 'extreme' ? dynamicMcpToolNames : []), ]; const streamActiveTools = model === 'scira-qwen-coder-plus' || model === 'scira-qwen-3-vl' || model === 'scira-qwen-3-vl-thinking' ? [...configuredActiveTools].filter((tool) => tool !== 'code_interpreter') : [...configuredActiveTools]; const loadedTools = await loadConfiguredTools({ activeToolNames: streamActiveTools, dataStream, searchProvider, timezone, contextFiles, extremeSearchModel, includeMcpTools: group === 'extreme' || group === 'mcp', mcpDynamicTools, lightweightUser, selectedConnectors, }); const streamTools = shouldUseXaiMultiAgent ? { ...loadedTools, xai_web_search: xai.tools.webSearch(), xai_x_search: xai.tools.xSearch(), } : loadedTools; function setUsageMetadataFromUsage( usage: | { inputTokens?: number; outputTokens?: number; totalTokens?: number; } | undefined, completionTime: number, ) { const inputTokens = usage?.inputTokens ?? null; const outputTokens = usage?.outputTokens ?? null; const totalTokens = usage?.totalTokens ?? (inputTokens !== null || outputTokens !== null ? (inputTokens ?? 0) + (outputTokens ?? 0) : null); finalUsageMetadata = { completionTime, inputTokens, outputTokens, totalTokens, }; } function setUsageMetadataFromSteps( steps: Array<{ usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number; }; }>, completionTime: number, ) { let inputTokens = 0; let outputTokens = 0; let totalTokens = 0; let hasInputTokens = false; let hasOutputTokens = false; let hasTotalTokens = false; for (const step of steps) { if (typeof step.usage?.inputTokens === 'number') { inputTokens += step.usage.inputTokens; hasInputTokens = true; } if (typeof step.usage?.outputTokens === 'number') { outputTokens += step.usage.outputTokens; hasOutputTokens = true; } if (typeof step.usage?.totalTokens === 'number') { totalTokens += step.usage.totalTokens; hasTotalTokens = true; } } finalUsageMetadata = { completionTime, inputTokens: hasInputTokens ? inputTokens : null, outputTokens: hasOutputTokens ? outputTokens : null, totalTokens: hasTotalTokens ? totalTokens : hasInputTokens || hasOutputTokens ? inputTokens + outputTokens : null, }; } // Stream the auto-routed model info to the client if (isAutoRouted && autoRouteName) { dataStream.write({ type: 'data-auto_routed_model', data: { model, route: autoRouteName }, transient: true, }); } // Stream chat title for new chats so client can update immediately if (chatInitResult.isNewChat && chatInitResult.titlePromise) { chatInitResult.titlePromise.then((chatTitle) => { dataStream.write({ type: 'data-chat_title', data: { title: chatTitle }, transient: true, }); // Update the placeholder title in the DB updateChatTitleById({ chatId: id, title: chatTitle }).catch(console.error); }); } const result = streamText({ model: shouldUseXaiMultiAgent ? xai.responses('grok-4.20-multi-agent') : scira.languageModel(model), messages: processedMessages, ...getModelParameters(shouldUseXaiMultiAgent ? 'grok-4.20-multi-agent' : model), stopWhen: stepCountIs(shouldUseXaiMultiAgent ? 5 : group === 'mcp' ? 50 : 5), ...(shouldUseXaiMultiAgent ? {} : model === 'scira-default' || model === 'scira-grok4.1-fast-thinking' || model === 'scira-glm-4.6' || model === 'scira-glm-4.6v-flash' || model === 'scira-glm-4.6v' ? { maxOutputTokens: getMaxOutputTokens(model), } : {}), maxRetries: 10, abortSignal: abortController.signal, activeTools: shouldUseXaiMultiAgent ? ['xai_web_search', 'xai_x_search'] : streamActiveTools, experimental_transform: markdownJoinerTransform(), system: instructions + (customInstructions && (isCustomInstructionsEnabled ?? true) ? `\n\nThe user's custom instructions are as follows and YOU MUST FOLLOW THEM AT ALL COSTS: ${customInstructions?.content}` : '\n') + (latitude && longitude && userPreferencesResult?.preferences?.['scira-location-metadata-enabled'] === true ? `\n\nThe user's location is ${latitude}, ${longitude}.` : '') + (shouldUseXaiMultiAgent ? '\n\nWhen multi-agent mode is enabled, you are operating in a high-agency research workflow. Use only the xAI server-side web search and X search tools available in this environment. Do not call any other research or search tools.\n\nYour job is to behave like a rigorous research analyst:\n- Break the request into sub-questions when useful.\n- Search broadly first, then narrow based on what you find.\n- Use multiple searches when the topic is ambiguous, fast-moving, comparative, or requires validation.\n- Cross-check important claims across multiple sources whenever possible.\n- Prefer recent and primary sources for news, releases, product changes, pricing, benchmarks, and policy updates.\n- Use X search when social signals, firsthand announcements, or fast-moving discourse are relevant.\n- Use web search when you need official documentation, articles, product pages, blogs, papers, or other published sources.\n- If both web and X are relevant, use both.\n\nOutput requirements:\n- Synthesize findings into a clear, direct answer instead of narrating every search step.\n- Be concise but complete.\n- Include uncertainty when evidence is mixed, incomplete, or time-sensitive.\n- Do not fabricate facts, sources, timelines, quotes, or consensus.\n- If you cannot verify a claim well enough, say so plainly.\n- Ground the final answer in the sources you found and make sure the answer actually reflects them.\n\nResponse structure guidelines:\n- Start with a direct answer or conclusion in 1-3 sentences.\n- Then present the most important findings as short sections or bullet points.\n- For comparative questions, explicitly compare the options point-by-point.\n- For fast-moving topics, clearly separate confirmed facts from tentative signals.\n- End with a brief takeaway, recommendation, or next step when useful.\n- Keep the response skimmable and avoid long, repetitive paragraphs.\n\nTool behavior requirements:\n- Do not mention internal tool limitations unless necessary.\n- Do not ask for permission to search.\n- Do not stop after a single weak search if the question clearly needs deeper verification.\n- Avoid redundant searches that do not add evidence.\n- Prefer quality of evidence over quantity of searches.' : ''), toolChoice: 'auto', tools: streamTools, ...(model === 'scira-anthropic' || model === 'scira-anthropic-think' || model === 'scira-anthropic-sonnet-4.6' || model === 'scira-anthropic-sonnet-4.6-think' || model === 'scira-anthropic-opus-4.6' || model === 'scira-anthropic-opus-4.6-think' ? { headers: { 'anthropic-beta': 'context-1m-2025-08-07', }, } : {}), providerOptions: { gateway: { only: [ 'openai', 'google', 'vertex', 'zai', 'arcee-ai', 'deepseek', 'alibaba', 'baseten', 'minimax', 'streamlake', 'fireworks', 'bedrock', 'vercel', 'xai', 'xai', 'bytedance', 'moonshotai', 'novita', 'togetherai', 'inception', ], ...(model === 'scira-kimi-k2-v2-thinking' ? { order: ['moonshotai'], } : {}), ...(model === 'scira-qwen-coder' || model === 'scira-deepseek-v3' || model === 'scira-qwen-235' ? { order: ['baseten'], } : {}), ...(model === 'scira-nova-2-lite' ? { order: ['bedrock'], } : {}), ...(model === 'scira-kat-coder' ? { order: ['streamlake'], } : {}), ...(model === 'scira-glm-4.7' || model === 'scira-glm-4.7-flash' ? { order: ['zai'], } : {}), ...(model === 'scira-kimi-k2.5' || model === 'scira-kimi-k2.5-thinking' ? { order: ['fireworks'], } : {}), }, 'workersai.chat': { chat_template_kwargs: { enable_thinking: false, }, }, sarvam: { reasoning_effort: 'high', }, openai: { ...(model !== 'scira-qwen-coder' ? { parallelToolCalls: false, } : {}), ...((model === 'scira-gpt5' || model === 'scira-gpt5-mini' || model === 'scira-o3' || model === 'scira-gpt5-nano' || model === 'scira-gpt5-codex' || model === 'scira-gpt5-medium' || model === 'scira-o4-mini' || model === 'scira-gpt-4.1' || model === 'scira-gpt-4.1-mini' || model === 'scira-gpt-4.1-nano' || model === 'scira-gpt-5.1' || model === 'scira-gpt-5.1-thinking' || model === 'scira-gpt-5.1-codex' || model === 'scira-gpt-5.1-codex-mini' || model === 'scira-gpt-5.1-codex-max' || model === 'scira-gpt-5.2' || model === 'scira-gpt-5.4' || model === 'scira-gpt-5.4-mini' || model === 'scira-gpt-5.4-nano' || model === 'scira-gpt-5.4-thinking' || model === 'scira-gpt-5.4-thinking-xhigh' || model === 'scira-gpt-5.2-thinking' || model === 'scira-gpt-5.2-thinking-xhigh' || model === 'scira-gpt-5.2-codex' || model === 'scira-gpt-5.3-codex' ? { reasoningEffort: model === 'scira-gpt5-nano' || model === 'scira-gpt5' || model === 'scira-gpt5-mini' ? 'minimal' : model === 'scira-gpt-5.2-thinking-xhigh' || model === 'scira-gpt-5.4-thinking-xhigh' ? 'xhigh' : model === 'scira-gpt-5.1' || model === 'scira-gpt-5.2' || model === 'scira-gpt-5.4' || model === 'scira-gpt-5.4-mini' || model === 'scira-gpt-5.4-nano' ? 'none' : 'medium', parallelToolCalls: model === 'scira-gpt-5.2-thinking-xhigh' || model === 'scira-gpt-5.4-thinking-xhigh' ? true : false, reasoningSummary: 'detailed', promptCacheKey: 'scira-oai', ...(model === 'scira-gpt-5.1' || model === 'scira-gpt-5.4' || model === 'scira-gpt-5.4-mini' || model === 'scira-gpt-5.4-nano' || model === 'scira-gpt-5.4-thinking' || model === 'scira-gpt-5.2' || model === 'scira-gpt-5.2-thinking' || model === 'scira-gpt-5.2-codex' || model === 'scira-gpt-5.3-codex' || model === 'scira-gpt-5.1-codex' || model === 'scira-gpt-5.1-codex-mini' || model === 'scira-gpt-5.1-codex-max' || model === 'scira-gpt5' || model === 'scira-gpt5-codex' || model === 'scira-gpt4.1' ? { promptCacheRetention: '24h', } : {}), store: false, ...(model === 'scira-gpt-5.4' || model === 'scira-gpt-5.4-mini' || model === 'scira-gpt-5.4-nano' || model === 'scira-gpt-5.4-thinking' || model === 'scira-gpt-5.4-thinking-xhigh' ? { serviceTier: 'priority', } : {}), // only for reasoning models ...(model === 'scira-gpt-5.1' || model === 'scira-gpt-5.1-codex' || model === 'scira-gpt-5.1-codex-mini' || model === 'scira-gpt5' || model === 'scira-gpt5-codex' || model === 'scira-gpt-5.1-thinking' || model === 'scira-gpt5-nano' || model === 'scira-gpt5-mini' || model === 'scira-gpt-5.4' || model === 'scira-gpt-5.4-mini' || model === 'scira-gpt-5.4-nano' || model === 'scira-gpt-5.4-thinking' || model === 'scira-gpt-5.4-thinking-xhigh' || model === 'scira-gpt-5.1-codex-max' || model === 'scira-gpt-5.2' || model === 'scira-gpt-5.2-thinking' || model === 'scira-gpt-5.2-codex' || model === 'scira-gpt-5.3-codex' ? { include: ['reasoning.encrypted_content'], } : {}), textVerbosity: model === 'scira-o3' || model === 'scira-gpt5-codex' || model === 'scira-gpt-5.1-codex' || model === 'scira-gpt-5.1-codex-mini' || model === 'scira-gpt-5.1-codex-max' || model === 'scira-gpt-5.2-codex' || model === 'scira-gpt-5.3-codex' || model === 'scira-o4-mini' || model === 'scira-gpt-4.1' || model === 'scira-gpt-4.1-mini' || model === 'scira-gpt-4.1-nano' ? 'medium' : 'high', } : {}) satisfies OpenAIResponsesProviderOptions), }, deepseek: { parallelToolCalls: false, }, groq: { ...(model === 'scira-gpt-oss-20' || model === 'scira-gpt-oss-120' ? { reasoningEffort: 'high', reasoningFormat: 'hidden', } : {}), ...(model === 'scira-qwen-32b' ? { reasoningEffort: 'none', } : {}), parallelToolCalls: false, structuredOutputs: true, serviceTier: 'auto', } satisfies GroqProviderOptions, xai: shouldUseXaiMultiAgent ? { reasoningEffort: 'high', parallel_function_calling: true, parallel_tool_calls: true, parallelToolCalls: true, paralelFunctionCalling: true, } : { parallel_function_calling: false, parallel_tool_calls: false, parallelToolCalls: false, paralelFunctionCalling: false, }, anannas: { parallel_function_calling: false, parallel_tool_calls: false, }, cohere: { ...(model === 'scira-cmd-a-think' ? { thinking: { type: 'enabled', tokenBudget: 1000, }, } : {}), } satisfies CohereChatModelOptions, zai: { ...(model === 'scira-glm-4.7' || model === 'scira-glm-4.7-flash' || model === 'scira-glm-5' || model === 'scira-pony-alpha-2' ? { thinking: { type: 'disabled', clear_thinking: true, }, } : {}), }, anthropic: { ...(model === 'scira-anthropic-think' || model === 'scira-anthropic-opus-think' ? { sendReasoning: true, thinking: { type: 'enabled', budgetTokens: 4000, }, } : {}), ...(model === 'scira-anthropic-sonnet-4.6-think' || model === 'scira-anthropic-opus-4.6-think' ? { sendReasoning: true, thinking: { type: 'adaptive' as const, }, effort: 'medium' as const, } : {}), disableParallelToolUse: true, } satisfies AnthropicProviderOptions, google: { ...(model === 'scira-google-think' || model === 'scira-google-pro-think' ? { thinkingConfig: { thinkingBudget: 400, includeThoughts: true, }, } : {}), ...(model === 'scira-gemini-3-flash-think' || model === 'scira-gemini-3.1-pro' || model === 'scira-gemini-3.1-flash-lite-think' ? { thinkingConfig: { thinkingLevel: 'medium', includeThoughts: true, }, } : {}), threshold: 'OFF', } satisfies GoogleGenerativeAIProviderOptions, vertex: { ...(model === 'scira-gemini-3-flash-think' || model === 'scira-gemini-3.1-pro' || model === 'scira-gemini-3.1-flash-lite-think' ? { thinkingConfig: { thinkingLevel: 'medium', includeThoughts: true, }, } : {}), threshold: 'OFF', } satisfies GoogleLanguageModelOptions, openrouter: { ...(model === 'scira-anthropic-think' || model === 'scira-anthropic-opus-think' ? { reasoning: { exclude: false, max_tokens: 400, }, } : {}), // ...(model === "scira-pony-alpha" ? { // reasoning: { // exclude: true, // }, // } : {}), }, bytedance: { reasoningEffort: 'minimal', }, ark: { thinking: { type: 'disabled' }, reasoning: { effort: 'minimal' }, }, alibaba: { ...(model === 'scira-qwen-3-max-preview-thinking' ? { enable_thinking: true, } : {}), ...(model === 'scira-qwen-3.5-flash' ? { enable_thinking: false, } : {}), }, moonshotai: { ...(model === 'scira-kimi-k2.5' ? { thinking: { type: 'disabled' }, } : {}), ...(model === 'scira-kimi-k2.5-thinking' ? { thinking: { type: 'enabled' }, } : {}), }, fireworks: { ...(model === 'scira-kimi-k2.5' ? { thinking: { type: 'disabled' }, } : {}), ...(model === 'scira-kimi-k2.5-thinking' ? { thinking: { type: 'enabled' }, } : {}), }, novita: { ...(model === 'scira-deepseek-chat-think-exp' ? { enable_thinking: true, } : {}), ...(model === 'scira-qwen-3.5' ? { enable_thinking: false, } : {}), }, mistral: { ...(model === 'scira-mistral-small-think' ? { reasoning_effort: 'high', } : {}), }, inception: { ...(model === 'scira-mercury-2' ? { reasoning_effort: 'high', reasoning_summary: true, reasoning_summary_wait: true, } : {}), }, }, experimental_context: (() => { // Extract images and files from the last user message's attachments const lastUserMessage = [...hydratedMessages].reverse().find((m) => m.role === 'user'); const attachments = (lastUserMessage?.parts as (TextPart | ImagePart | FilePart)[]) ?? []; const images = attachments .filter((att): att is FilePart => att.type === 'file' && (att as any).mediaType?.startsWith('image/')) .map((att) => ({ url: (att as any).url, contentType: (att as any).mediaType, name: (att as any).name, })); // Extract document files (PDF, CSV, DOCX, XLSX) const files = attachments .filter((att): att is FilePart => { if (att.type !== 'file') return false; const mediaType = (att as any).mediaType || ''; return ( mediaType === 'application/pdf' || mediaType === 'text/csv' || mediaType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || mediaType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || mediaType === 'application/vnd.ms-excel' ); }) .map((att) => ({ url: (att as any).url, contentType: (att as any).mediaType, name: (att as any).name, })); return { images, files }; })(), experimental_download: async (requestedDownloads) => { type DownloadResult = { data: Uint8Array; mediaType: string | undefined } | null; // Download for models that can't fetch R2 URLs directly const requiresDownload = model.startsWith('scira-anthropic') || model.startsWith('scira-google') || model.startsWith('scira-gemini'); if (!requiresDownload) { // Let other models handle URLs directly return requestedDownloads.map(() => null); } const downloadTasks = requestedDownloads.reduce( (acc, { url }, index) => { acc[`dl:${index}`] = async (): Promise => { console.log(`[experimental_download] Downloading for Anthropic: ${url.toString()}`); const response = await fetch(url.toString()); if (!response.ok) { console.error(`[experimental_download] Failed: ${url.toString()} - ${response.status}`); throw new Error(`Failed to download file: ${response.status} ${response.statusText}`); } const data = new Uint8Array(await response.arrayBuffer()); const mediaType = response.headers.get('content-type') || undefined; console.log( `[experimental_download] Success: ${url.toString()} (${data.byteLength} bytes, ${mediaType})`, ); return { data, mediaType }; }; return acc; }, {} as Record Promise>, ); const results = await all(downloadTasks, getBetterAllOptions()); // Convert back to ordered array return requestedDownloads.map((_, index) => results[`dl:${index}`]); }, prepareStep: async ({ steps }) => { const latestStep = steps[steps.length - 1]; const latestStepHasToolRoundTrip = Boolean(latestStep) && latestStep.toolCalls.length > 0 && latestStep.toolResults.length > 0; // MCP mode and xAI multi-agent mode: keep tools available across steps. if (group === 'mcp' || shouldUseXaiMultiAgent) { return shouldUseXaiMultiAgent ? { toolChoice: 'auto' as const, activeTools: ['xai_web_search', 'xai_x_search'], } : undefined; } // Other modes: disable tool calls after first completed tool round. const shouldDisableTools = steps.length > 0 && latestStepHasToolRoundTrip; // Only return object if tools need to be disabled if (shouldDisableTools && model !== 'scira-sarvam-105b') { return { toolChoice: 'none' as const, activeTools: [], }; } return { toolChoice: 'auto' as const, activeTools: streamActiveTools, }; }, experimental_repairToolCall: async ({ toolCall, tools, inputSchema, error }) => { if (NoSuchToolError.isInstance(error)) { return null; } console.log('Fixing tool call================================'); console.log('toolCall', toolCall); console.log('tools', tools); console.log('parameterSchema', inputSchema); console.log('error', error); const tool = tools[toolCall.toolName as keyof typeof tools]; if (!tool) { return null; } const { output: repairedArgs } = await generateText({ model: scira.languageModel('scira-default'), output: Output.object({ schema: tool.inputSchema }), prompt: [ `The model tried to call the tool "${toolCall.toolName}"` + ` with the following arguments:`, JSON.stringify(toolCall.input), `The tool accepts the following schema:`, JSON.stringify(inputSchema(toolCall)), 'Please fix the arguments.', 'For the code interpreter tool do not use print statements.', `For the web search make multiple queries to get the best results but avoid using the same query multiple times.`, `Today's date is ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })}`, ].join('\n'), }); console.log('repairedArgs', repairedArgs); return { ...toolCall, args: JSON.stringify(repairedArgs) }; }, onChunk(event) { if (event.chunk.type === 'tool-call') { console.log('Called Tool: ', event.chunk.toolName); } }, onStepFinish(event) { const processingTime = (Date.now() - streamStartTime) / 1000; setUsageMetadataFromUsage(event.usage, processingTime); }, onAbort(event) { const processingTime = (Date.now() - streamStartTime) / 1000; setUsageMetadataFromSteps(event.steps, processingTime); closeMcpToolsSafe().catch(() => null); }, onFinish: async (event) => { // console.log('Finish event: ', event); const processingTime = (Date.now() - streamStartTime) / 1000; setUsageMetadataFromUsage(event.totalUsage, processingTime); console.log(`✅ Request completed: ${processingTime.toFixed(2)}s (${event.finishReason})`); try { if (lightweightUser?.userId && event.finishReason === 'stop') { // Track usage synchronously - this is critical for billing and rate limiting try { const shouldTrackMessageUsage = !shouldBypassRateLimits(model, lightweightUser); const shouldTrackExtremeSearchUsage = group === 'extreme' && event.steps?.some((step) => step.toolCalls?.some((toolCall) => toolCall && toolCall.toolName === 'extreme_search'), ); const shouldTrackAnthropicUsage = getModelProvider(model) === 'anthropic' && lightweightUser.isMaxUser; const shouldTrackGoogleUsage = getModelProvider(model) === 'google' && lightweightUser.isMaxUser; if ( shouldTrackMessageUsage || shouldTrackExtremeSearchUsage || shouldTrackAnthropicUsage || shouldTrackGoogleUsage ) { await all( { async messageUsage() { if (!shouldTrackMessageUsage) return false; await incrementMessageUsage({ userId: lightweightUser.userId }); return true; }, async extremeSearchUsage() { if (!shouldTrackExtremeSearchUsage) return false; await incrementExtremeSearchUsage({ userId: lightweightUser.userId }); return true; }, async anthropicUsage() { if (!shouldTrackAnthropicUsage) return false; await incrementAnthropicUsage({ userId: lightweightUser.userId, model }); return true; }, async googleUsage() { if (!shouldTrackGoogleUsage) return false; await incrementGoogleUsage({ userId: lightweightUser.userId, model }); return true; }, }, getBetterAllOptions(), ); } } catch (error) { console.error('Failed to track usage:', error); } } } finally { await closeMcpToolsSafe(); } }, onError(event) { const processingTime = (Date.now() - requestStartTime) / 1000; console.error(`❌ Request failed: ${processingTime.toFixed(2)}s`, event.error); closeMcpToolsSafe().catch(() => null); }, }); result.consumeStream(); const assistantMessageCreatedAt = new Date().toISOString(); const uiMessageStream = result.toUIMessageStream({ sendReasoning: true, sendSources: true, messageMetadata: ({ part }) => { const baseMetadata = { model: model as string, createdAt: assistantMessageCreatedAt, multiAgentMode: shouldUseXaiMultiAgent, }; if (part.type === 'finish') { console.log('Finish part: ', part); const processingTime = (Date.now() - streamStartTime) / 1000; return { ...baseMetadata, completionTime: processingTime, totalTokens: part.totalUsage?.totalTokens ?? null, inputTokens: part.totalUsage?.inputTokens ?? null, outputTokens: part.totalUsage?.outputTokens ?? null, }; } return baseMetadata; }, }); dataStream.merge( (group === 'canvas' ? pipeJsonRender(uiMessageStream) : uiMessageStream) as AsyncIterableStream< InferUIMessageChunk >, ); }, onError(error) { console.log('Error: ', error); if (error instanceof Error && error.message.includes('Rate Limit')) { return 'Oops, you have reached the rate limit! Please try again later.'; } return 'Oops, an error occurred!'; }, // onStepFinish(event) { // console.log('Step finish event: ', event); // }, onFinish: async ({ messages: streamedMessages, isAborted }: { messages: ChatMessage[]; isAborted: boolean }) => { if (!lightweightUser || isTemporaryChat) { return; } const newMessages = streamedMessages.filter((message: ChatMessage) => !initialMessageIds.has(message.id)); if (newMessages.length === 0) { console.log('No new messages to persist for chat', id); return; } // Persist assistant output only for the latest stream on this chat. // If a newer request started while this one was running, this prevents // stale onFinish writes from older streams from being inserted out of order. const latestStreamId = await getLatestStreamIdByChatId({ chatId: id }); if (latestStreamId !== streamId) { console.log('Skipping stale stream message persistence', { chatId: id, streamId, latestStreamId, }); return; } // Persist only if this response still belongs to the latest user turn. // This blocks older in-flight generations from writing a different assistant // response after the user has already sent/edited/regenerated a newer turn. if (requestLastUserMessageId) { const latestUserMessageId = await getLatestUserMessageIdByChatId({ chatId: id }); if (latestUserMessageId !== requestLastUserMessageId) { console.log('Skipping stale turn message persistence', { chatId: id, streamId, requestLastUserMessageId, latestUserMessageId, }); return; } } const messagesToPersist = isAborted ? newMessages.filter((message: ChatMessage) => { if (message.role !== 'assistant') return false; if (!Array.isArray(message.parts) || message.parts.length === 0) return false; return message.parts.some((part: any) => { if (part.type === 'text') return typeof part.text === 'string' && part.text.trim().length > 0; if (part.type === 'reasoning') return typeof part.text === 'string' && part.text.trim().length > 0; if (part.type === 'tool-invocation') return true; if (part.type === 'file') return true; if (part.type === 'source-url') return true; return false; }); }) : newMessages; if (isAborted && messagesToPersist.length === 0) { console.log('Stream aborted with no persistable assistant output', { chatId: id, streamId }); return; } await saveMessages({ messages: messagesToPersist.map((message: ChatMessage) => { const attachments = (message as any).experimental_attachments ?? []; const createdAt = typeof message.metadata?.createdAt === 'string' ? new Date(message.metadata.createdAt) : new Date(); return { id: message.id, role: message.role, parts: message.parts, createdAt, attachments, chatId: id, model: message.metadata?.model ?? model, completionTime: message.metadata?.completionTime ?? finalUsageMetadata.completionTime, inputTokens: message.metadata?.inputTokens ?? finalUsageMetadata.inputTokens, outputTokens: message.metadata?.outputTokens ?? finalUsageMetadata.outputTokens, totalTokens: message.metadata?.totalTokens ?? finalUsageMetadata.totalTokens, }; }), }); }, }); const { getResumableStreamClients } = await import('@/lib/redis'); const clients = getResumableStreamClients(); if (clients) { const { createResumableUIMessageStream } = await import('ai-resumable-stream'); const context = await createResumableUIMessageStream({ streamId, publisher: clients.publisher, subscriber: clients.subscriber, abortController, waitUntil: after, }); const resumableStream = await context.startStream(stream as ReadableStream); return new Response(resumableStream.pipeThrough(new JsonToSseTransformStream())); } return new Response(stream.pipeThrough(new JsonToSseTransformStream())); } ================================================ FILE: app/api/suggest/route.ts ================================================ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const query = searchParams.get('q')?.trim() ?? ''; if (query.length < 1 || query.length > 200) { return Response.json( { suggestions: [] }, { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120', }, }, ); } try { const encoded = encodeURIComponent(query); const url = `https://duckduckgo.com/ac/?q=${encoded}&type=list`; const upstream = await fetch(url, { signal: AbortSignal.timeout(1500), }); if (!upstream.ok) { return Response.json( { suggestions: [] }, { headers: { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', }, }, ); } // DuckDuckGo returns [query, [suggestions]] const data = await upstream.json(); const raw: string[] = Array.isArray(data?.[1]) ? data[1] : []; const suggestions = raw.slice(0, 5); return Response.json( { suggestions }, { headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', }, }, ); } catch { return Response.json( { suggestions: [] }, { headers: { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', }, }, ); } } ================================================ FILE: app/api/transcribe/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server'; import { elevenlabs } from '@ai-sdk/elevenlabs'; import { experimental_transcribe as transcribe } from 'ai'; export async function POST(request: NextRequest) { try { const formData = await request.formData(); const audio = formData.get('audio'); if (!audio || !(audio instanceof Blob)) { return NextResponse.json({ error: 'No audio file found in form data.' }, { status: 400 }); } const result = await transcribe({ model: elevenlabs.transcription('scribe_v2'), audio: await audio.arrayBuffer(), }); console.log(result); return NextResponse.json({ text: result.text }); } catch (error) { console.error('Error processing transcription request:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } ================================================ FILE: app/api/upload/route.ts ================================================ import { PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { nanoid } from 'nanoid'; import { and, eq, sql } from 'drizzle-orm'; import { auth } from '@/lib/auth'; import { r2Client, R2_BUCKET_NAME, R2_PUBLIC_URL } from '@/lib/r2'; import { db } from '@/lib/db'; import { chat, message } from '@/lib/db/schema'; import { unauthenticatedRateLimit, getClientIdentifier } from '@/lib/rate-limit'; import { all } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; import { del as blobDel, head as blobHead } from '@vercel/blob'; // Image types (5MB limit) const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; // Document types (50MB limit) const DOCUMENT_TYPES = [ 'application/pdf', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx 'application/vnd.ms-excel', // .xls ]; const VALID_TYPES = [...IMAGE_TYPES, ...DOCUMENT_TYPES]; // File size limits const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB for images const MAX_DOCUMENT_SIZE = 50 * 1024 * 1024; // 50MB for documents function isImageType(contentType: string): boolean { return IMAGE_TYPES.includes(contentType); } function getMaxSizeForType(contentType: string): number { return isImageType(contentType) ? MAX_IMAGE_SIZE : MAX_DOCUMENT_SIZE; } // Request validation schema for getting presigned URL const UploadRequestSchema = z .object({ filename: z.string().min(1), contentType: z.string().refine((type) => VALID_TYPES.includes(type), { message: 'File type should be JPEG, PNG, GIF, PDF, CSV, DOCX, or XLSX', }), size: z.number(), }) .superRefine((data, ctx) => { const maxSize = getMaxSizeForType(data.contentType); if (data.size > maxSize) { const maxMB = maxSize / (1024 * 1024); const fileType = isImageType(data.contentType) ? 'Image' : 'Document'; ctx.addIssue({ code: 'custom', message: `${fileType} size should be less than ${maxMB}MB`, path: ['size'], }); } }); // Delete request validation const DeleteRequestSchema = z.object({ url: z.string().url(), }); // Only allow alphanumeric + a few safe chars as file extension to prevent key injection function sanitizeExtension(raw: string): string { const cleaned = raw.replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 10); return cleaned || 'bin'; } // Compare origins to prevent prefix-bypass attacks (e.g. https://cdn.x.com.evil.com) function isOwnR2Url(url: string): boolean { try { const target = new URL(url); const base = new URL(R2_PUBLIC_URL); return target.origin === base.origin; } catch { return false; } } // Safe integer parsing with bounds function parseLimit(raw: string | null, defaultVal: number, max: number): number { const n = parseInt(raw ?? String(defaultVal), 10); if (!Number.isFinite(n) || n < 1) return defaultVal; return Math.min(n, max); } interface UploadedFile { key: string; url: string; size: number; lastModified: string | null; filename: string; mediaType: string | null; chatId: string | null; source: 'r2' | 'legacy' | 'vercel-blob'; } const VERCEL_BLOB_PATTERN = '.public.blob.vercel-storage.com'; function isVercelBlobUrl(url: string): boolean { return url.includes(VERCEL_BLOB_PATTERN); } // Flat row returned by the LATERAL jsonb query type DbFileRow = { url: string; name: string | null; mediaType: string | null; chatId: string; [key: string]: unknown; }; export async function GET(request: NextRequest) { let session: Awaited> | null = null; try { session = await auth.api.getSession({ headers: request.headers }); } catch (error) { console.warn('Error checking authentication:', error); } if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const userId = session.user.id; const prefix = `scira/users/${userId}/`; const { searchParams } = new URL(request.url); const continuationToken = searchParams.get('cursor') ?? undefined; const maxKeys = parseLimit(searchParams.get('limit'), 50, 200); // Use better-all's `all()` with explicit this.$ dependencies so the library // can automatically maximise parallelism across the four tasks. // Errors in r2List / dbQuery are caught internally so dependent tasks always // receive a safe fallback — the overall `all()` never rejects. const { r2Res, r2Files, legacyFiles } = await all({ // ── Independent sources ── start immediately in parallel ────────────── async r2List() { try { return await r2Client.send(new ListObjectsV2Command({ Bucket: R2_BUCKET_NAME, Prefix: prefix, MaxKeys: maxKeys, ContinuationToken: continuationToken, })); } catch (e) { console.error('R2 list error:', e); return null; } }, async dbQuery() { try { if (continuationToken) return [] as DbFileRow[]; return await db.execute(sql` SELECT DISTINCT ON (elem->>'url') m.chat_id AS "chatId", elem->>'url' AS url, elem->>'name' AS name, elem->>'mediaType' AS "mediaType" FROM message m JOIN chat c ON m.chat_id = c.id CROSS JOIN LATERAL jsonb_array_elements(m.parts::jsonb) AS elem WHERE c."userId" = ${userId} AND m.role = 'user' AND elem->>'type' = 'file' AND ( elem->>'url' LIKE ${R2_PUBLIC_URL + '%'} OR elem->>'url' LIKE ${'%' + VERCEL_BLOB_PATTERN + '%'} ) ORDER BY elem->>'url', m.created_at DESC LIMIT 200 `).then((r) => r.rows); } catch (e) { console.error('DB file query error:', e); return [] as DbFileRow[]; } }, // ── r2Res ── alias so callers can read pagination metadata ──────────── async r2Res() { return await this.$.r2List; }, // ── r2Files ── waits for r2List + dbQuery to enrich with metadata ────── async r2Files() { const r2Result = await this.$.r2List; const dbRows = await this.$.dbQuery; const dbMeta = new Map(); for (const row of dbRows) dbMeta.set(row.url, row); return (r2Result?.Contents ?? []).map((obj) => { const url = `${R2_PUBLIC_URL}/${obj.Key}`; const meta = dbMeta.get(url); return { key: obj.Key!, url, size: obj.Size ?? 0, lastModified: obj.LastModified?.toISOString() ?? null, filename: meta?.name || (obj.Key!.split('/').pop() ?? obj.Key!), mediaType: meta?.mediaType ?? null, chatId: meta?.chatId ?? null, source: 'r2' as const, } satisfies UploadedFile; }); }, // ── legacyFiles ── waits for r2Files + dbQuery, then fires HeadObjects ─ async legacyFiles() { const r2Built = await this.$.r2Files; const dbRows = await this.$.dbQuery; const r2Urls = new Set(r2Built.map((f) => f.url)); const legacyRows = dbRows.filter((r) => !r2Urls.has(r.url)); const r2LegacyRows = legacyRows.filter((r) => !isVercelBlobUrl(r.url)).slice(0, 20); const blobLegacyRows = legacyRows.filter((r) => isVercelBlobUrl(r.url)).slice(0, 20); // Fetch metadata for both storage types in parallel const metaMap = Object.keys({ ...r2LegacyRows, ...blobLegacyRows }).length > 0 ? await all( { ...Object.fromEntries( r2LegacyRows.map((r, i) => [ `r2:${i}`, async () => r2Client.send(new HeadObjectCommand({ Bucket: R2_BUCKET_NAME, Key: new URL(r.url).pathname.slice(1), })).catch(() => null), ]), ), ...Object.fromEntries( blobLegacyRows.map((r, i) => [ `blob:${i}`, async () => blobHead(r.url).catch(() => null), ]), ), }, getBetterAllOptions(), ) : {} as Record; return legacyRows.map((r) => { const isBlob = isVercelBlobUrl(r.url); let size = 0; let lastModified: string | null = null; if (isBlob) { const idx = blobLegacyRows.findIndex((lr) => lr.url === r.url); const meta = idx >= 0 ? metaMap[`blob:${idx}`] : null; size = (meta as any)?.size ?? 0; lastModified = (meta as any)?.uploadedAt ? new Date((meta as any).uploadedAt).toISOString() : null; } else { const idx = r2LegacyRows.findIndex((lr) => lr.url === r.url); const meta = idx >= 0 ? metaMap[`r2:${idx}`] : null; size = (meta as any)?.ContentLength ?? 0; lastModified = (meta as any)?.LastModified ? new Date((meta as any).LastModified).toISOString() : null; } const key = isBlob ? r.url : r.url.replace(`${R2_PUBLIC_URL}/`, ''); return { key, url: r.url, size, lastModified, filename: r.name ?? key.split('/').pop() ?? key, mediaType: r.mediaType ?? null, chatId: r.chatId, source: (isBlob ? 'vercel-blob' : 'legacy') as UploadedFile['source'], } satisfies UploadedFile; }); }, }, getBetterAllOptions()); const nextCursor = r2Res?.NextContinuationToken ?? null; const isTruncated = r2Res?.IsTruncated ?? false; const files = [...r2Files, ...legacyFiles]; return NextResponse.json( { files, nextCursor, isTruncated }, { headers: { 'Cache-Control': 'private, max-age=0, stale-while-revalidate=60' } }, ); } export async function POST(request: NextRequest) { // Check for authentication but don't require it let session: Awaited> | null = null; try { session = await auth.api.getSession({ headers: request.headers, }); } catch (error) { console.warn('Error checking authentication:', error); } const isAuthenticated = !!session?.user?.id; try { const body = await request.json(); const validated = UploadRequestSchema.safeParse(body); if (!validated.success) { return NextResponse.json( { error: validated.error.issues[0]?.message || 'Invalid request' }, { status: 400 } ); } const { filename, contentType, size } = validated.data; // Rate-limit unauthenticated uploads by IP if (!isAuthenticated) { const identifier = getClientIdentifier(request); const { success } = await unauthenticatedRateLimit.limit(identifier); if (!success) { return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); } } // Sanitize extension to prevent path injection in the R2 key const rawExt = filename.split('.').pop() ?? ''; const ext = sanitizeExtension(rawExt); const key = isAuthenticated ? `scira/users/${session!.user.id}/${nanoid()}.${ext}` : `scira/public-${nanoid()}.${ext}`; const command = new PutObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key, ContentType: contentType, // Store owner so individual files can be attributed even if the key format changes Metadata: isAuthenticated ? { 'user-id': session!.user.id } : undefined, }); const presignedUrl = await getSignedUrl(r2Client, command, { expiresIn: 3600, // 1 hour }); // Construct the final public URL const publicUrl = `${R2_PUBLIC_URL}/${key}`; return NextResponse.json({ presignedUrl, url: publicUrl, key, authenticated: isAuthenticated, }); } catch (error) { console.error('Error generating presigned URL:', error); return NextResponse.json( { error: 'Failed to generate upload URL' }, { status: 500 } ); } } export async function DELETE(request: NextRequest) { let session: Awaited> | null = null; try { session = await auth.api.getSession({ headers: request.headers }); } catch (error) { console.warn('Error checking authentication:', error); } if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const userId = session.user.id; try { const body = await request.json(); const validated = DeleteRequestSchema.safeParse(body); if (!validated.success) { return NextResponse.json({ error: 'Invalid URL provided' }, { status: 400 }); } const { url } = validated.data; const isBlob = isVercelBlobUrl(url); // Verify URL belongs to one of our storage backends if (!isBlob && !isOwnR2Url(url)) { return NextResponse.json({ error: 'Invalid storage URL' }, { status: 400 }); } // Ownership check via DB for all non-new-format files const r2Key = isBlob ? '' : new URL(url).pathname.slice(1); const isOwnR2Key = !isBlob && r2Key.startsWith(`scira/users/${userId}/`); if (!isOwnR2Key) { const rows = await db .select({ parts: message.parts }) .from(message) .innerJoin(chat, eq(message.chatId, chat.id)) .where( and( eq(chat.userId, userId), eq(message.role, 'user'), sql`${message.parts}::text LIKE ${'%' + url + '%'}`, ), ) .limit(1); if (rows.length === 0) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } } if (isBlob) { await blobDel(url); } else { await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: r2Key })); } return NextResponse.json({ success: true }); } catch (error) { console.error('Error deleting file:', error); return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }); } } ================================================ FILE: app/api/x-wrapped/route.ts ================================================ import { NextRequest, NextResponse } from 'next/server'; import { xai } from '@ai-sdk/xai'; import { generateText, Output, stepCountIs } from 'ai'; import { z } from 'zod'; import { getTweet } from 'react-tweet/api'; import { Redis } from '@upstash/redis'; import { all } from 'better-all'; import { getBetterAllOptions } from '@/lib/better-all'; interface CitationSource { sourceType?: string; url?: string; } interface XWrappedData { username: string; displayName?: string; avatarUrl?: string; followersCount?: number; verified?: boolean; totalPosts: number; topTopics: string[]; sentiment: { positive: number; neutral: number; negative: number; }; mostActiveMonth: string; engagementScore: number; writingStyle: string; yearSummary: string; topPosts: Array<{ text: string; url: string; date: string; }>; // Debug/UX: what we searched for (fixed 16-step plan) searchSteps?: Array<{ step: number; title: string; query: string; purpose: string; }>; } function extractTweetId(url?: string | null): string | null { if (!url) return null; return url.match(/\/status\/(\d+)/)?.[1] ?? null; } function toMonthName(date: Date): string { return date.toLocaleDateString('en-US', { month: 'long' }); } function clampPercent(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, Math.round(value))); } const redis = Redis.fromEnv(); const CACHE_TTL_SECONDS = 5 * 60; // 5 minutes export async function POST(req: NextRequest) { try { const bodySchema = z.object({ username: z.string().min(1), year: z.number().int().min(2006).max(2100).optional(), }); const parsedBody = bodySchema.safeParse(await req.json()); if (!parsedBody.success) { return NextResponse.json({ error: 'Invalid request body', details: parsedBody.error.flatten() }, { status: 400 }); } const year = parsedBody.data.year ?? 2025; const cleanUsername = parsedBody.data.username.replace(/^@+/, '').trim(); if (!cleanUsername) return NextResponse.json({ error: 'Username is required' }, { status: 400 }); // Check cache const cacheKey = `x-wrapped:${cleanUsername}:${year}`; const cached = await redis.get(cacheKey); if (cached) { return NextResponse.json(cached); } const startDate = `${year}-01-01`; const endDate = `${year}-12-31`; // IMPORTANT: Use the same x_search tool wiring as lib/tools/x-search.ts (lines ~120-122). const xSearchToolConfig: Parameters[0] = { fromDate: startDate, toDate: endDate, allowedXHandles: [cleanUsername], }; const searchSteps = [ { step: 1, title: 'User profile', query: `[x_user_search] Find the user profile for @${cleanUsername} (bio, name, avatar, location, exactfollowers count, verified status, pinned post if available)`, purpose: 'Get basic user info and context.', }, { step: 2, title: 'Top posts (keyword)', query: `[x_keyword_search] Find top/most engaged ORIGINAL posts (not replies) by @${cleanUsername} in ${year}. Exclude replies to others.`, purpose: 'Collect representative/high-signal posts.', }, { step: 3, title: 'Top themes (semantic)', query: `[x_semantic_search] What topics does @${cleanUsername} discuss most in ${year}? Return ORIGINAL posts only (exclude replies to others).`, purpose: 'Capture the main topics with supporting posts.', }, { step: 4, title: 'Q1 activity (Jan-Mar)', query: `[x_keyword_search] Find original posts (not replies) by @${cleanUsername} from January, February, March ${year}`, purpose: 'Capture Q1 activity for month distribution.', }, { step: 5, title: 'Q2 activity (Apr-Jun)', query: `[x_keyword_search] Find original posts (not replies) by @${cleanUsername} from April, May, June ${year}`, purpose: 'Capture Q2 activity for month distribution.', }, { step: 6, title: 'Q3 activity (Jul-Sep)', query: `[x_keyword_search] Find original posts (not replies) by @${cleanUsername} from July, August, September ${year}`, purpose: 'Capture Q3 activity for month distribution.', }, { step: 7, title: 'Q4 activity (Oct-Dec)', query: `[x_keyword_search] Find original posts (not replies) by @${cleanUsername} from October, November, December ${year}`, purpose: 'Capture Q4 activity for month distribution.', }, { step: 8, title: 'Threads discovery', query: `[x_keyword_search] Find threads started by @${cleanUsername} in ${year} (replies-to-self are OK, but not replies to others)`, purpose: 'Find candidate threads worth fetching fully.', }, { step: 9, title: 'Thread fetch (deep)', query: `[x_thread_fetch] Pick the best thread started by @${cleanUsername} and fetch the full thread`, purpose: 'Get full context for a standout thread.', }, { step: 10, title: 'Announcements / launches', query: `[x_keyword_search] Find launch/ship/release/announce original posts by @${cleanUsername} in ${year} (exclude replies)`, purpose: 'Find key milestones and launches.', }, { step: 11, title: 'Opinions / takes', query: `[x_semantic_search] Find opinionated original posts by @${cleanUsername} in ${year} (strong statements, predictions). Exclude replies.`, purpose: 'Understand voice and point of view.', }, { step: 12, title: 'Shoutouts & mentions', query: `[x_keyword_search] Find original posts by @${cleanUsername} in ${year} where they mention or shoutout others (not replies to others)`, purpose: 'Capture social graph + community energy.', }, { step: 13, title: 'Learning & curiosity', query: `[x_semantic_search] Find original posts by @${cleanUsername} in ${year} about learning, curiosity, questions (exclude replies)`, purpose: 'Identify what they were learning and asking about.', }, { step: 14, title: 'Year-end reflection', query: `[x_keyword_search] Find year-end reflection or recap original posts by @${cleanUsername} in ${year}`, purpose: 'Find explicit reflection/recap posts.', }, { step: 15, title: 'Style sampling', query: `[x_keyword_search] Collect a diverse sample of original posts by @${cleanUsername} in ${year} (short, long, technical, casual). No replies.`, purpose: 'Better writing-style classification (grounded in examples).', }, { step: 16, title: 'Edge cases / contradictions', query: `[x_semantic_search] Find original posts by @${cleanUsername} in ${year} that show a different side or contradict earlier themes. Exclude replies.`, purpose: 'Avoid one-note summaries; capture range.', }, ] as const; const analysisSchema = z.object({ topTopics: z.array(z.string().min(1)).min(1).max(5), sentiment: z.object({ positive: z.number().min(0).max(100), neutral: z.number().min(0).max(100), negative: z.number().min(0).max(100), }), writingStyle: z.string().min(1), yearSummary: z.string().min(1), followersCount: z.number().int().min(0).optional(), verified: z.boolean().optional(), }); // generateText #1: // - Runs 16 x_search calls (one per step) using xai.tools.xSearch (same wiring as x-search.ts) // - Includes quarterly searches (Q1-Q4) to get month distribution // - Returns citations/sources only (NO structured output here) const { text, sources } = await generateText({ model: xai.responses('grok-4-fast'), system: `You are generating an \"X Wrapped\" for @${cleanUsername} for ${year}. Hard rules: - Use ONLY the x_search tool to gather posts. - Focus on ORIGINAL posts by @${cleanUsername} only. Exclude replies to other users. - Replies-to-self (threads) are OK, but NOT replies to others. - Do NOT include posts where @${cleanUsername} is just mentioned by someone else. - Do NOT invent posts, stats, topics, or user attributes. - Your analysis must be grounded in what you found through x_search. - You MUST perform exactly ${searchSteps.length} searches, in order, using the provided queries verbatim. - After the searches, Summarize the results but first mention the user's follower count and verified status if available.`, messages: [ { role: 'user', content: `Run the following searches in order. For each item, call x_search with the exact query text. ${searchSteps.map((s) => `${s.step}. ${s.query} (purpose: ${s.purpose})`).join('\n')} After completing all ${searchSteps.length} searches, stop.` }, ], tools: { x_search: xai.tools.xSearch(xSearchToolConfig), }, onStepFinish: (step) => { console.log('Step: ', step); }, // Allow enough tool-call steps for all searches. stopWhen: stepCountIs(searchSteps.length), }); console.log('Text for X Wrapped: ', text); const citations = (Array.isArray(sources) ? sources : []) as CitationSource[]; const tweetUrls = citations .filter((c) => c.sourceType === 'url' && typeof c.url === 'string' && c.url.length > 0) .map((c) => c.url as string); const seenTweetIds = new Set(); const tweetIds = tweetUrls .map((u) => extractTweetId(u)) .filter((id): id is string => !!id) .filter((id) => { if (seenTweetIds.has(id)) return false; seenTweetIds.add(id); return true; }) .slice(0, 60); const tweetResultsMap = await all( Object.fromEntries( tweetIds.map((id) => [ `tweet:${id}`, async () => { try { const tweet = await getTweet(id); if (!tweet?.text) return null; const createdAtRaw = (tweet as any).created_at as string | undefined; const createdAt = createdAtRaw ? new Date(createdAtRaw) : null; const dateIso = createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt.toISOString() : ''; const tweetUser = (tweet as any).user as | { name?: string; screen_name?: string; profile_image_url_https?: string; profile_image_url?: string; followers_count?: number; friends_count?: number; verified?: boolean; verified_type?: string | null; } | undefined; const favoriteCount = (tweet as any).favorite_count as number | undefined; return { id, text: tweet.text as string, url: `https://x.com/i/status/${id}`, date: dateIso, user: tweetUser, screenName: tweetUser?.screen_name?.toLowerCase() ?? '', favoriteCount: typeof favoriteCount === 'number' ? favoriteCount : 0, }; } catch { return null; } }, ]), ), getBetterAllOptions(), ); const tweetResults = Object.values(tweetResultsMap); // Filter to only include posts BY the target user (not mentions or replies from others) const authorPosts = tweetResults.filter( (t): t is NonNullable => !!t && t.screenName === cleanUsername.toLowerCase() ); // Sort by favorite_count (likes) descending and take top 5 const topPosts = authorPosts .sort((a, b) => (b.favoriteCount ?? 0) - (a.favoriteCount ?? 0)) .slice(0, 5); const totalPosts = authorPosts.length; const monthCounts = new Map(); for (const t of authorPosts) { if (!t.date) continue; const d = new Date(t.date); if (Number.isNaN(d.getTime())) continue; const month = toMonthName(d); monthCounts.set(month, (monthCounts.get(month) ?? 0) + 1); } const mostActiveMonth = Array.from(monthCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Unknown'; const firstUser = authorPosts.find((t) => t.user)?.user; const displayName = typeof firstUser?.name === 'string' && firstUser.name.length > 0 ? firstUser.name : undefined; const avatarUrlRaw = firstUser?.profile_image_url_https || firstUser?.profile_image_url || undefined; // Replace _normal with _400x400 for higher resolution avatar const avatarUrl = typeof avatarUrlRaw === 'string' && avatarUrlRaw.length > 0 ? avatarUrlRaw.replace('_normal.', '_400x400.') : undefined; if (totalPosts === 0) { const wrappedData: XWrappedData = { username: cleanUsername, displayName, avatarUrl, totalPosts: 0, topTopics: [], sentiment: { positive: 0, neutral: 0, negative: 0 }, mostActiveMonth: 'Unknown', engagementScore: 0, writingStyle: 'Unknown', yearSummary: `No posts found for @${cleanUsername} in ${year} (based on the X search results available).`, topPosts: [], searchSteps: [...searchSteps], }; // Cache empty result too await redis.set(cacheKey, wrappedData, { ex: CACHE_TTL_SECONDS }); return NextResponse.json(wrappedData); } const tweetTexts = authorPosts.map((t) => t.text).slice(0, 30); // generateText #2: // - NO tools // - Uses Zod output // - Must be grounded ONLY in tweet texts we fetched (no invention) // - Uses text from first generateText call for additional context const { output } = await generateText({ model: xai('grok-4-fast-non-reasoning'), system: `You are writing an \"X Wrapped\" summary based strictly on provided X post texts and search context. Rules: - Use ONLY the provided post texts (these are original posts by the user, not replies to others). - Focus on what the user posted, not what others said to/about them. - Use the search context to better understand patterns, themes, and user activity. - Extract follower count and verified status from the search context if available. - Do not invent topics or events not present in the text or context. - If evidence is weak, reflect uncertainty concisely. - The Interesting Posts should be the actual posts by the user, not replies or non author posts. - Keep it fun and crisp; no filler. - Derive sentiment directly from the posts: imagine each post as positive, neutral, or negative based on its language and then compute the overall percentages from that distribution. - Avoid defaulting to \"round\" or generic splits (like 33/33/33 or 50/50/0) unless the posts are truly that balanced; let the evidence drive the exact numbers. - It is OK if one sentiment clearly dominates the others when the posts support it, but do not exaggerate beyond what the texts justify. Output must match the schema exactly.`, messages: [ { role: 'user', content: `User: @${cleanUsername}\nYear: ${year}\n\nSearch Context (from X search results for details about users verified status and follower count):\n${text || 'No additional context available.'}\n\nPost texts (sample):\n${tweetTexts .map((t, i) => `(${i + 1}) ${t}`) .join('\n')}` }, ], // Lower temperature so sentiment percentages are more stable and less random. temperature: 0.2, output: Output.object({ schema: analysisSchema }), }); const sentiment = { positive: clampPercent(output.sentiment.positive), neutral: clampPercent(output.sentiment.neutral), negative: clampPercent(output.sentiment.negative), }; const sum = sentiment.positive + sentiment.neutral + sentiment.negative; if (sum !== 100 && sum > 0) { // Normalize to 100 to avoid weird totals from the model. sentiment.positive = clampPercent((sentiment.positive / sum) * 100); sentiment.neutral = clampPercent((sentiment.neutral / sum) * 100); sentiment.negative = clampPercent(100 - sentiment.positive - sentiment.neutral); } const engagementScore = Math.max(1, Math.min(100, Math.round((totalPosts / 30) * 100))); const wrappedData: XWrappedData = { username: cleanUsername, displayName, avatarUrl, followersCount: output.followersCount, verified: output.verified, totalPosts, topTopics: output.topTopics, sentiment, mostActiveMonth, engagementScore, writingStyle: output.writingStyle, yearSummary: output.yearSummary, topPosts, searchSteps: [...searchSteps], }; await redis.set(cacheKey, wrappedData, { ex: CACHE_TTL_SECONDS }); return NextResponse.json(wrappedData); } catch (error) { console.error('X Wrapped API error:', error); return NextResponse.json({ error: 'Failed to generate X Wrapped', details: String(error) }, { status: 500 }); } } ================================================ FILE: app/api/xql/route.ts ================================================ import { getCurrentUser } from '@/app/actions'; import { convertToModelMessages, streamText, ToolSet, tool, hasToolCall, UIMessage, UIDataTypes, InferUITools, generateText, stepCountIs, } from 'ai'; import { ChatSDKError } from '@/lib/errors'; import { markdownJoinerTransform } from '@/lib/parser'; import { scira } from '@/ai/providers'; import { z } from 'zod'; import { xai } from '@ai-sdk/xai'; const xqlTool = tool({ description: 'Search X posts for recent information and discussions with the ability to filter by X handles, date range, and post engagement metrics.', inputSchema: z .object({ query: z.string().describe('The new rephrased natural language query crafted by you.'), startDate: z .string() .describe('The start date of the search in the format YYYY-MM-DD (default to 15 days ago if not specified)'), endDate: z .string() .describe('The end date of the search in the format YYYY-MM-DD (default to today if not specified)'), includeXHandles: z .array(z.string()) .max(10) .optional() .describe('The X handles to include in the search (max 10). Cannot be used with excludeXHandles.'), excludeXHandles: z .array(z.string()) .max(10) .optional() .describe( 'The X handles to exclude in the search (max 10). Cannot be used with includeXHandles. Note: "grok" handle is excluded by default.', ), }) .refine( (data) => { // Ensure includeXHandles and excludeXHandles are not both specified with non-empty arrays const hasInclude = data.includeXHandles && data.includeXHandles.length > 0; const hasExclude = data.excludeXHandles && data.excludeXHandles.length > 0; return !(hasInclude && hasExclude); }, { message: 'Cannot specify both includeXHandles and excludeXHandles - use one or the other', path: ['includeXHandles', 'excludeXHandles'], }, ), async execute({ query, startDate, endDate, includeXHandles, excludeXHandles, }) { const sanitizeHandle = (handle: string) => handle.replace(/^@+/, '').trim(); const normalizedInclude = Array.isArray(includeXHandles) ? includeXHandles.map(sanitizeHandle).filter(Boolean) : undefined; const normalizedExclude = Array.isArray(excludeXHandles) ? excludeXHandles.map(sanitizeHandle).filter(Boolean) : undefined; const toYMD = (d: Date) => d.toISOString().slice(0, 10); const today = new Date(); const daysAgo = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000); const effectiveStart = startDate && startDate.trim().length > 0 ? startDate : toYMD(daysAgo); const effectiveEnd = endDate && endDate.trim().length > 0 ? endDate : toYMD(today); console.log('X search - includeHandles:', normalizedInclude, 'excludeHandles:', normalizedExclude); const xSearchToolConfig: Parameters[0] = { fromDate: effectiveStart, toDate: effectiveEnd, enableImageUnderstanding: true, enableVideoUnderstanding: true, }; // Add allowedXHandles if includeXHandles is provided if (normalizedInclude?.length) { xSearchToolConfig.allowedXHandles = normalizedInclude; } const result = await generateText({ model: xai.responses('grok-4-1-fast-non-reasoning'), prompt: query, stopWhen: stepCountIs(1), maxOutputTokens: 10, tools: { x_search: xai.tools.xSearch(xSearchToolConfig), }, }); const citations = result.sources?.map((source) => (source.sourceType === 'url' ? source.url : null)).filter((url) => url !== null) || []; console.log('XQL Result: ', result); console.log('XQL Sources: ', result.sources); return citations; }, }); const tools = { xql: xqlTool, }; export type XQLMessage = UIMessage>; export async function POST(req: Request) { console.log('🔍 Search API endpoint hit'); const requestStartTime = Date.now(); const { messages } = await req.json(); const user = await getCurrentUser(); if (!user) { return new ChatSDKError('unauthorized:auth', 'Authentication required to use this feature').toResponse(); } if (!user.isProUser) { return new ChatSDKError('upgrade_required:auth', 'This feature requires a Pro subscription').toResponse(); } const result = streamText({ model: scira.languageModel('scira-default'), messages: await convertToModelMessages(messages), stopWhen: hasToolCall('xql'), onAbort: ({ steps }) => { console.log('Stream aborted after', steps.length, 'steps'); }, prepareStep: ({ stepNumber }) => { if (stepNumber === 0) { return { toolChoice: { toolName: 'xql', type: 'tool' }, activeTools: ['xql'], }; } }, maxRetries: 10, experimental_transform: markdownJoinerTransform(), system: `You are a helpful assistant that searches for X posts, You will be given a search query and you will need to search for the posts and return the results in a structured format. Today's date is ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit', weekday: 'short' })}. The date range is from 15 days ago to today unless the user specifies otherwise. The tool to use is xql. The tool has the following parameters: - query: The natural language query - startDate: The start date of the search in the format YYYY-MM-DD (default to 15 days ago if not specified) - endDate: The end date of the search in the format YYYY-MM-DD (default to today if not specified) - includeXHandles: The X handles to include in the search (max 10 handles). Do not include the @ symbol. CANNOT be used together with excludeXHandles. - excludeXHandles: The X handles to exclude in the search (max 10 handles). Do not include the @ symbol. CANNOT be used together with includeXHandles. Note: "grok" handle is automatically excluded by default. - postFavoritesCount: The minimum number of favorites (likes) the post must have to be included - postViewCount: The minimum number of views the post must have to be included - maxResults: The maximum number of search results to return (default 15, max 100) IMPORTANT CONSTRAINTS: - Maximum 10 handles for include/exclude lists - Cannot use both includeXHandles and excludeXHandles in the same query - postFavoritesCount and postViewCount are minimum thresholds, not exact matches The tools name is xql it doesnt meant you should write SQL in the input of the tool! `, tools: { xql: xqlTool, } as ToolSet, onChunk(event) { if (event.chunk.type === 'tool-call') { console.log('Called Tool: ', event.chunk.toolName); } }, onStepFinish(event) { if (event.warnings) { console.log('Warnings: ', event.warnings); } }, onFinish: async (event) => { console.log('Fin reason: ', event.finishReason); console.log('Steps: ', event.steps); console.log('Tool calls: ', event.toolCalls); console.log('Tool Result: ', event.toolResults); console.log('Response: ', event.response); console.log('Provider metadata: ', event.providerMetadata); console.log('Sources: ', event.sources); console.log('Usage: ', event.usage); console.log('Total Usage: ', event.totalUsage); const requestEndTime = Date.now(); const processingTime = (requestEndTime - requestStartTime) / 1000; console.log('--------------------------------'); console.log(`Total request processing time: ${processingTime.toFixed(2)} seconds`); console.log('--------------------------------'); }, onError(event) { console.log('Error: ', event.error); const requestEndTime = Date.now(); const processingTime = (requestEndTime - requestStartTime) / 1000; console.log('--------------------------------'); console.log(`Request processing time (with error): ${processingTime.toFixed(2)} seconds`); console.log('--------------------------------'); }, }); result.consumeStream(); return result.toUIMessageStreamResponse({ sendReasoning: true, sendSources: true, }); } ================================================ FILE: app/apps/layout.tsx ================================================ import React from 'react'; import type { Metadata } from 'next'; import { SidebarLayout } from '@/components/sidebar-layout'; const title = 'Apps'; const description = 'Browse and connect apps to power your AI workflows. Integrate with GitHub, Notion, Linear, Figma, Stripe, and 50+ more services.'; export const metadata: Metadata = { title, description, openGraph: { title, description, url: 'https://scira.ai/apps', siteName: 'Scira AI', type: 'website', }, twitter: { card: 'summary_large_image', title, description, creator: '@sciraai', }, alternates: { canonical: 'https://scira.ai/apps', }, }; export default function AppsLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ================================================ FILE: app/apps/page.tsx ================================================ 'use client'; import { useState, useMemo, Suspense, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useUser } from '@/contexts/user-context'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { sileo } from 'sileo'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Tabs as KumoTabs } from '@cloudflare/kumo'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { cn, normalizeError } from '@/lib/utils'; import { Plus, Check, Search, Loader2, MoreHorizontal, Trash2, Zap, Link2Off, LinkIcon, Blocks, ArrowUpRight, Pencil, ChevronDown, Wrench } from 'lucide-react'; import { AppsIcon } from '@/components/icons/apps-icon'; import { parseAsString, useQueryState } from 'nuqs'; import { getMcpCatalogIcon, MCP_COMPONENT_ICON_URLS } from '@/lib/mcp/catalog-icons'; import { Github01Icon } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@/components/ui/hugeicons'; // ─── Types ──────────────────────────────────────────────────────────────────── type CatalogAuth = 'oauth' | 'apikey' | 'open'; type CategoryId = 'all' | 'dev' | 'productivity' | 'design' | 'crm' | 'payments' | 'database' | 'search' | 'data' | 'travel' | 'email' | 'shopping' | 'other'; interface CatalogField { label: string; placeholder: string; headerName: string; hintText?: string; hintUrl?: string; steps?: Array<{ text: string; url?: string; urlLabel?: string }>; } interface OAuthSetupField { label: string; placeholder: string; hintText?: string; hintUrl?: string; key: 'oauthClientId' | 'oauthClientSecret'; } interface CatalogItem { name: string; category: CategoryId; url: string; auth: CatalogAuth; maintainer: string; maintainerUrl: string; customIcon?: string; fields?: CatalogField[]; oauthSetup?: OAuthSetupField[]; } // ─── Catalog data ───────────────────────────────────────────────────────────── const CATEGORIES: { id: CategoryId; label: string }[] = [ { id: 'all', label: 'All' }, { id: 'dev', label: 'Dev Tools' }, { id: 'productivity', label: 'Productivity' }, { id: 'design', label: 'Design' }, { id: 'crm', label: 'CRM' }, { id: 'payments', label: 'Payments' }, { id: 'database', label: 'Database' }, { id: 'search', label: 'Search' }, { id: 'data', label: 'Data' }, { id: 'travel', label: 'Travel' }, { id: 'email', label: 'Email' }, { id: 'shopping', label: 'Shopping' }, { id: 'other', label: 'Other' }, ]; const CATALOG: CatalogItem[] = [ { name: 'Asana', category: 'productivity', url: 'https://mcp.asana.com/sse', auth: 'oauth', maintainer: 'Asana', maintainerUrl: 'https://asana.com' }, { name: 'Autosend', category: 'email', url: 'https://mcp.autosend.com/', auth: 'oauth', maintainer: 'Autosend', maintainerUrl: 'https://autosend.com' }, { name: 'Google Workspace', category: 'productivity', url: 'https://google-mcp.scira.app/mcp', auth: 'apikey', maintainer: 'Google', maintainerUrl: 'https://google.com', fields: [{ label: 'API Key', placeholder: 'gmc_…', headerName: 'Authorization', hintText: 'Get API key', hintUrl: 'https://google-mcp.scira.app', steps: [ { text: 'Go to the Google MCP dashboard and sign in with Google', url: 'https://google-mcp.scira.app', urlLabel: 'Open dashboard' }, { text: 'Google will show an "unverified app" warning — click Advanced → Go to Scira (unsafe) to continue. This is expected for developer tools.' }, { text: 'Select all services you want: Google Calendar, Google Sheets, Gmail, Google Docs, Google Drive' }, { text: 'Set API key expiration to Never (recommended)' }, { text: 'Copy the generated API key (starts with gmc_) and paste it above' }, { text: 'To Revoke or Manage the API Key, go to https://google-mcp.scira.app/revoke and paste the API key and click "Revoke".' }, ], }], }, { name: 'Atlassian', category: 'dev', url: 'https://mcp.atlassian.com/v1/sse', auth: 'oauth', maintainer: 'Atlassian', maintainerUrl: 'https://atlassian.com' }, { name: 'Attio', category: 'crm', url: 'https://mcp.attio.com/mcp', auth: 'oauth', maintainer: 'Attio', maintainerUrl: 'https://attio.com' }, { name: 'Box', category: 'productivity', url: 'https://mcp.box.com', auth: 'oauth', maintainer: 'Box', maintainerUrl: 'https://box.com' }, // { // name: 'Canva', // category: 'design', // url: 'https://mcp.canva.com/mcp', // auth: 'oauth', // maintainer: 'Canva', // maintainerUrl: 'https://canva.com', // oauthSetup: [ // { // key: 'oauthClientId', // label: 'Canva Client ID', // placeholder: 'xxxxxxxxxxxxxxxx', // hintText: 'Create a Canva app and copy Client ID', // hintUrl: 'https://www.canva.com/developers', // }, // { // key: 'oauthClientSecret', // label: 'Canva Client Secret', // placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // hintText: 'Allow your app host and add redirect URL in Canva app settings', // hintUrl: 'https://www.canva.com/developers', // }, // ], // }, { name: 'Close CRM', category: 'crm', url: 'https://mcp.close.com/mcp', auth: 'oauth', maintainer: 'Close', maintainerUrl: 'https://close.com' }, { name: 'Cloudflare', category: 'dev', url: 'https://mcp.cloudflare.com/mcp', auth: 'oauth', maintainer: 'Cloudflare', maintainerUrl: 'https://cloudflare.com' }, { name: 'Cloudflare Workers', category: 'dev', url: 'https://bindings.mcp.cloudflare.com/sse', auth: 'oauth', maintainer: 'Cloudflare', maintainerUrl: 'https://cloudflare.com' }, { name: 'Cloudflare Observability', category: 'dev', url: 'https://observability.mcp.cloudflare.com/sse', auth: 'oauth', maintainer: 'Cloudflare', maintainerUrl: 'https://cloudflare.com' }, { name: 'Cloudinary', category: 'design', url: 'https://asset-management.mcp.cloudinary.com/sse', auth: 'oauth', maintainer: 'Cloudinary', maintainerUrl: 'https://cloudinary.com' }, // { name: 'Figma', category: 'design', url: 'https://mcp.figma.com/mcp', auth: 'oauth', maintainer: 'Figma', maintainerUrl: 'https://figma.com' }, { name: 'GitHub', category: 'dev', url: 'https://api.githubcopilot.com/mcp', auth: 'oauth', maintainer: 'GitHub', maintainerUrl: 'https://github.com' }, { name: 'Hugging Face', category: 'dev', url: 'https://huggingface.co/mcp?login', auth: 'oauth', maintainer: 'Hugging Face', maintainerUrl: 'https://huggingface.co' }, { name: 'Intercom', category: 'crm', url: 'https://mcp.intercom.com/sse', auth: 'oauth', maintainer: 'Intercom', maintainerUrl: 'https://intercom.com' }, { name: 'Indeed', category: 'other', url: 'https://mcp.indeed.com/claude/mcp', auth: 'oauth', maintainer: 'Indeed', maintainerUrl: 'https://indeed.com' }, { name: 'InVideo', category: 'other', url: 'https://mcp.invideo.io/sse', auth: 'oauth', maintainer: 'InVideo', maintainerUrl: 'https://invideo.io' }, { name: 'Instant', category: 'dev', url: 'https://mcp.instantdb.com/mcp', auth: 'oauth', maintainer: 'Instant', maintainerUrl: 'https://instantdb.com' }, { name: 'Jam', category: 'dev', url: 'https://mcp.jam.dev/mcp', auth: 'oauth', maintainer: 'Jam.dev', maintainerUrl: 'https://jam.dev' }, { name: 'Knock', category: 'crm', url: 'https://mcp.knock.app/mcp', auth: 'oauth', maintainer: 'Knock', maintainerUrl: 'https://knock.app' }, { name: 'Linear', category: 'productivity', url: 'https://mcp.linear.app/mcp', auth: 'oauth', maintainer: 'Linear', maintainerUrl: 'https://linear.app' }, { name: 'Meta Ads', category: 'other', url: 'https://mcp.pipeboard.co/meta-ads-mcp', auth: 'oauth', maintainer: 'Pipeboard', maintainerUrl: 'https://pipeboard.co' }, { name: 'Morningstar', category: 'data', url: 'https://mcp.morningstar.com/mcp', auth: 'oauth', maintainer: 'Morningstar', maintainerUrl: 'https://morningstar.com' }, { name: 'monday.com', category: 'productivity', url: 'https://mcp.monday.com/sse', auth: 'oauth', maintainer: 'monday.com', maintainerUrl: 'https://monday.com' }, { name: 'Neon', category: 'database', url: 'https://mcp.neon.tech/mcp', auth: 'oauth', maintainer: 'Neon', maintainerUrl: 'https://neon.tech' }, { name: 'Netlify', category: 'dev', url: 'https://netlify-mcp.netlify.app/mcp', auth: 'oauth', maintainer: 'Netlify', maintainerUrl: 'https://netlify.com' }, { name: 'Notion', category: 'productivity', url: 'https://mcp.notion.com/mcp', auth: 'oauth', maintainer: 'Notion', maintainerUrl: 'https://notion.so' }, { name: 'Orshot', category: 'design', url: 'https://mcp.orshot.com/mcp', auth: 'oauth', maintainer: 'Orshot', maintainerUrl: 'https://orshot.com' }, { name: 'Parallel Task', category: 'search', url: 'https://task-mcp.parallel.ai/mcp', auth: 'oauth', maintainer: 'Parallel AI', maintainerUrl: 'https://parallel.ai' }, { name: 'Parallel Search', category: 'search', url: 'https://search-mcp.parallel.ai/mcp', auth: 'oauth', maintainer: 'Parallel AI', maintainerUrl: 'https://parallel.ai' }, { name: 'PayPal', category: 'payments', url: 'https://mcp.paypal.com/sse', auth: 'oauth', maintainer: 'PayPal', maintainerUrl: 'https://paypal.com' }, { name: 'Plaid', category: 'payments', url: 'https://api.dashboard.plaid.com/mcp/sse', auth: 'oauth', maintainer: 'Plaid', maintainerUrl: 'https://plaid.com' }, { name: 'Port IO', category: 'dev', url: 'https://mcp.port.io/v1', auth: 'oauth', maintainer: 'Port IO', maintainerUrl: 'https://port.io' }, { name: 'Prisma Postgres', category: 'database', url: 'https://mcp.prisma.io/mcp', auth: 'oauth', maintainer: 'Prisma', maintainerUrl: 'https://prisma.io' }, { name: 'Ramp', category: 'payments', url: 'https://ramp-mcp-remote.ramp.com/mcp', auth: 'oauth', maintainer: 'Ramp', maintainerUrl: 'https://ramp.com' }, { name: 'Rube', category: 'other', url: 'https://rube.app/mcp', auth: 'oauth', maintainer: 'Composio', maintainerUrl: 'https://rube.app' }, { name: 'Scorecard', category: 'other', url: 'https://scorecard-mcp.dare-d5b.workers.dev/sse', auth: 'oauth', maintainer: 'Scorecard', maintainerUrl: 'https://scorecard.io' }, { name: 'Sentry', category: 'dev', url: 'https://mcp.sentry.dev/sse', auth: 'oauth', maintainer: 'Sentry', maintainerUrl: 'https://sentry.io' }, { name: 'Simplescraper', category: 'search', url: 'https://mcp.simplescraper.io/mcp', auth: 'oauth', maintainer: 'Simplescraper', maintainerUrl: 'https://simplescraper.io' }, { name: 'Square', category: 'payments', url: 'https://mcp.squareup.com/sse', auth: 'oauth', maintainer: 'Square', maintainerUrl: 'https://squareup.com' }, { name: 'Stack Overflow', category: 'dev', url: 'https://mcp.stackoverflow.com', auth: 'oauth', maintainer: 'Stack Overflow', maintainerUrl: 'https://stackoverflow.com' }, { name: 'Stripe', category: 'payments', url: 'https://mcp.stripe.com/', auth: 'oauth', maintainer: 'Stripe', maintainerUrl: 'https://stripe.com' }, { name: 'Supabase', category: 'database', url: 'https://mcp.supabase.com/mcp', auth: 'oauth', maintainer: 'Supabase', maintainerUrl: 'https://supabase.com' }, { name: 'Vercel', category: 'dev', url: 'https://mcp.vercel.com', auth: 'oauth', maintainer: 'Vercel', maintainerUrl: 'https://vercel.com' }, { name: 'Webflow', category: 'design', url: 'https://mcp.webflow.com/sse', auth: 'oauth', maintainer: 'Webflow', maintainerUrl: 'https://webflow.com' }, { name: 'Wix', category: 'design', url: 'https://mcp.wix.com/sse', auth: 'oauth', maintainer: 'Wix', maintainerUrl: 'https://wix.com' }, { name: 'Dropbox', category: 'productivity', url: 'https://mcp.dropbox.com/mcp', auth: 'oauth', maintainer: 'Dropbox', maintainerUrl: 'https://dropbox.com' }, { name: 'Slack', category: 'productivity', url: 'https://mcp.slack.com/mcp', auth: 'oauth', maintainer: 'Slack', maintainerUrl: 'https://slack.com', }, { name: 'Context7', category: 'dev', url: 'https://mcp.context7.com/mcp', auth: 'open', maintainer: 'Context7', maintainerUrl: 'https://context7.com' }, { name: 'DeepWiki', category: 'search', url: 'https://mcp.deepwiki.com/mcp', auth: 'open', maintainer: 'Devin', maintainerUrl: 'https://devin.ai' }, { name: 'Exa Search', category: 'search', url: 'https://mcp.exa.ai/mcp', auth: 'open', maintainer: 'Exa', maintainerUrl: 'https://exa.ai' }, { name: 'Excalidraw', category: 'design', url: 'https://mcp.excalidraw.com/mcp', auth: 'open', maintainer: 'Excalidraw', maintainerUrl: 'https://excalidraw.com' }, { name: 'GitMCP', category: 'dev', url: 'https://gitmcp.io/docs', auth: 'open', maintainer: 'GitMCP', maintainerUrl: 'https://gitmcp.io' }, { name: 'Kiwi', category: 'travel', url: 'https://mcp.kiwi.com', auth: 'open', maintainer: 'Kiwi', maintainerUrl: 'https://kiwi.com' }, { name: 'Lastminute', category: 'travel', url: 'https://mcp.lastminute.com/mcp', auth: 'open', maintainer: 'lastminute.com', maintainerUrl: 'https://lastminute.com' }, { name: 'Trivago', category: 'travel', url: 'https://mcp.trivago.com/mcp', auth: 'open', maintainer: 'Trivago', maintainerUrl: 'https://trivago.com' }, { name: 'Kensho Finance', category: 'data', url: 'https://kfinance.kensho.com/integrations/mcp', auth: 'open', maintainer: 'Kensho', maintainerUrl: 'https://kensho.com' }, { name: 'PubMed', category: 'search', url: 'https://pubmed.mcp.claude.com/mcp', auth: 'open', maintainer: 'Anthropic', maintainerUrl: 'https://pubmed.ncbi.nlm.nih.gov' }, { name: 'Render', category: 'dev', url: 'https://mcp.render.com/mcp', auth: 'apikey', maintainer: 'Render', maintainerUrl: 'https://render.com', fields: [{ label: 'API Key', placeholder: 'rnd_…', headerName: 'Authorization', hintText: 'Get from Render dashboard', hintUrl: 'https://dashboard.render.com/u/settings#api-keys' }] }, { name: 'Dodo Payments', category: 'payments', url: 'https://mcp.dodopayments.com/sse', auth: 'oauth', maintainer: 'Dodo Payments', maintainerUrl: 'https://dodopayments.com', }, { name: 'Google BigQuery', category: 'data', url: 'https://bigquery.googleapis.com/mcp', auth: 'apikey', maintainer: 'Google', maintainerUrl: 'https://cloud.google.com/bigquery', fields: [{ label: 'Access Token', placeholder: 'ya29.…', headerName: 'Authorization', hintText: 'Get from Google Cloud credentials', hintUrl: 'https://console.cloud.google.com/apis/credentials' }] }, { name: 'Google Compute', category: 'dev', url: 'https://compute.googleapis.com/mcp', auth: 'apikey', maintainer: 'Google', maintainerUrl: 'https://cloud.google.com/compute', fields: [{ label: 'Access Token', placeholder: 'ya29.…', headerName: 'Authorization', hintText: 'Get from Google Cloud credentials', hintUrl: 'https://console.cloud.google.com/apis/credentials' }] }, { name: 'Google GKE', category: 'dev', url: 'https://container.googleapis.com/mcp', auth: 'apikey', maintainer: 'Google', maintainerUrl: 'https://cloud.google.com/kubernetes-engine', fields: [{ label: 'Access Token', placeholder: 'ya29.…', headerName: 'Authorization', hintText: 'Get from Google Cloud credentials', hintUrl: 'https://console.cloud.google.com/apis/credentials' }] }, { name: 'Google Maps', category: 'other', url: 'https://mapstools.googleapis.com/mcp', auth: 'apikey', maintainer: 'Google', maintainerUrl: 'https://developers.google.com/maps', fields: [{ label: 'API Key', placeholder: 'AIza…', headerName: 'Authorization', hintText: 'Get from Google Cloud credentials', hintUrl: 'https://console.cloud.google.com/apis/credentials' }] }, { name: 'HubSpot', category: 'crm', url: 'https://mcp.hubspot.com/', auth: 'oauth', maintainer: 'HubSpot', maintainerUrl: 'https://hubspot.com' }, { name: 'Zapier', category: 'productivity', url: 'https://mcp.zapier.com/api/mcp/mcp', auth: 'apikey', maintainer: 'Zapier', maintainerUrl: 'https://zapier.com', fields: [{ label: 'API Key', placeholder: 'sk_…', headerName: 'Authorization', hintText: 'Get from Zapier developer settings', hintUrl: 'https://zapier.com/app/developer' }] }, { name: 'Penny', category: 'other', url: 'https://penny.apps.trychannel3.com/mcp', auth: 'oauth', maintainer: 'Penny', maintainerUrl: 'https://penny.shop', customIcon: '/penny.png', }, ]; const FEATURED_NAMES = ['Notion', 'Rube', 'GitHub', 'Exa Search', 'Vercel', 'Slack', 'Google Workspace', 'Hugging Face', 'Kiwi', 'Excalidraw', 'Context7', 'Penny']; const CATALOG_URLS = new Set(CATALOG.map((i) => i.url.replace(/\/$/, ''))); // ─── Helpers ────────────────────────────────────────────────────────────────── function getTransportType(url: string): 'sse' | 'http' { const lower = url.toLowerCase(); return lower.endsWith('/sse') || lower.includes('/sse?') ? 'sse' : 'http'; } // Second-level TLDs that need 3 parts kept (e.g. mospi.gov.in → gov.in is the TLD) const SLD_TLDS = new Set([ 'gov.in', 'co.in', 'org.in', 'net.in', 'ac.in', 'co.uk', 'org.uk', 'me.uk', 'net.uk', 'ac.uk', 'co.jp', 'co.nz', 'co.za', 'co.kr', 'co.il', 'com.au', 'net.au', 'org.au', 'com.br', 'net.br', 'org.br', 'nih.gov' ]); function rootDomain(serverUrl: string): string { try { const parts = new URL(serverUrl).hostname.split('.'); if (parts.length <= 2) return parts.join('.'); const last2 = parts.slice(-2).join('.'); if (SLD_TLDS.has(last2)) return parts.slice(-3).join('.'); return last2; } catch { return ''; } } function faviconUrl(serverUrl: string): string { const domain = rootDomain(serverUrl); if (!domain) return ''; const google = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; return `/api/proxy-image?url=${encodeURIComponent(google)}`; } const AUTH_LABELS: Record = { oauth: 'OAuth', apikey: 'API Key', open: 'Free' }; function isOauthWithClientSetup(item: CatalogItem) { return item.auth === 'oauth' && Boolean(item.oauthSetup?.length); } // ─── Sub-components ─────────────────────────────────────────────────────────── function ServiceIcon({ url, name, size = 24, customIcon, serverUrl }: { url: string; name: string; size?: number; customIcon?: string; serverUrl?: string }) { const checkUrl = (serverUrl ?? url).replace(/\/+$/, ''); if (MCP_COMPONENT_ICON_URLS.has(checkUrl)) { return ; } const src = customIcon ?? getMcpCatalogIcon(serverUrl ?? url) ?? faviconUrl(url); return src ? ( // eslint-disable-next-line @next/next/no-img-element {name} ) : ( {name.slice(0, 2).toUpperCase()} ); } function CatalogCard({ item, isConnected, isAdding, onAdd, canConnect = true, }: { item: CatalogItem; isConnected: boolean; isAdding: boolean; onAdd: (item: CatalogItem) => void; canConnect?: boolean; }) { const catLabel = CATEGORIES.find((c) => c.id === item.category)?.label ?? item.category; const needsClientSetup = isOauthWithClientSetup(item); return (
{isConnected ? ( Added ) : ( )}
{item.name}
{catLabel} {AUTH_LABELS[item.auth]} {needsClientSetup && ( Client setup )}
e.stopPropagation()} className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 w-fit mt-3" > {item.maintainer}
); } function CardSkeleton() { return (
); } // ─── Page content ───────────────────────────────────────────────────────────── function McpMarketplaceContent() { const { user, isProUser, isLoading: isAuthLoading } = useUser(); const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const mcpEnabled = process.env.NEXT_PUBLIC_MCP_ENABLED === 'true'; useEffect(() => { if (!mcpEnabled) { router.replace('/'); return; } if (!isAuthLoading && !user) router.push('/sign-in'); }, [mcpEnabled, isAuthLoading, user, router]); // Handle OAuth callback redirect useEffect(() => { const oauthStatus = searchParams.get('mcpOauth'); const message = searchParams.get('message'); if (!oauthStatus) return; if (oauthStatus === 'success') { sileo.success({ title: 'App connected', description: 'OAuth authorization successful' }); } else { sileo.error({ title: 'OAuth failed', description: message ?? 'Authorization was not completed' }); } // Strip the params from the URL without a re-render const clean = new URL(window.location.href); clean.searchParams.delete('mcpOauth'); clean.searchParams.delete('message'); window.history.replaceState({}, '', clean.toString()); }, [searchParams]); const [activeTab, setActiveTab] = useState(() => searchParams.get('tab') === 'my-servers' ? 'my-servers' : 'browse'); const [search, setSearch] = useQueryState('q', parseAsString.withDefault('')); const [category, setCategory] = useState('all'); const isReadOnlyMarketplace = !isProUser; const [apiKeyTarget, setApiKeyTarget] = useState(null); const [apiKeyValues, setApiKeyValues] = useState>({}); const [oauthSetupTarget, setOauthSetupTarget] = useState(null); const [oauthSetupValues, setOauthSetupValues] = useState>({}); const [addingUrl, setAddingUrl] = useState(null); const oauthCallbackUri = useMemo(() => { const configuredOrigin = process.env.NEXT_PUBLIC_APP_URL?.trim(); const origin = configuredOrigin ? configuredOrigin.replace(/\/+$/, '') : (typeof window !== 'undefined' ? window.location.origin.replace(/\/+$/, '') : ''); return origin ? `${origin}/api/mcp/oauth/callback` : '/api/mcp/oauth/callback'; }, []); const [showCustomDialog, setShowCustomDialog] = useState(false); const [customForm, setCustomForm] = useState({ name: '', url: '', authType: 'none' as 'none' | 'bearer' | 'header' | 'oauth', bearerToken: '', headerName: '', headerValue: '', }); const resetCustomForm = () => setCustomForm({ name: '', url: '', authType: 'none', bearerToken: '', headerName: '', headerValue: '' }); type ServerRecord = { id: string; name: string; url: string; authType: 'none' | 'bearer' | 'header' | 'oauth'; isEnabled: boolean; hasCredentials: boolean; isOAuthConnected: boolean; oauthConfigured: boolean; oauthError: string | null; lastError: string | null; lastTestedAt: string | null; }; const { data: serversData, isLoading: serversLoading } = useQuery({ queryKey: ['mcpServers', user?.id], queryFn: async () => { const r = await fetch('/api/mcp/servers'); if (!r.ok) return { servers: [] as ServerRecord[] }; return r.json() as Promise<{ servers: ServerRecord[] }>; }, enabled: Boolean(user?.id && isProUser), staleTime: 10_000, }); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [testingId, setTestingId] = useState(null); const [connectingId, setConnectingId] = useState(null); const [deletingId, setDeletingId] = useState(null); // Tool management const [expandedToolsId, setExpandedToolsId] = useState(null); const [serverToolsCache, setServerToolsCache] = useState>>({}); const [toolsLoading, setToolsLoading] = useState>({}); const fetchServerTools = async (serverId: string) => { if (serverToolsCache[serverId] || toolsLoading[serverId]) return; setToolsLoading((prev) => ({ ...prev, [serverId]: true })); try { const res = await fetch(`/api/mcp/servers/${serverId}/tools`); const data = await res.json() as { ok: boolean; tools: Array<{ name: string; title: string | null; description: string | null }> }; if (data.ok) setServerToolsCache((prev) => ({ ...prev, [serverId]: data.tools })); } catch { /* ignore */ } finally { setToolsLoading((prev) => ({ ...prev, [serverId]: false })); } }; const patchDisabledTools = async (serverId: string, disabledToolList: string[]) => { // Optimistic update first queryClient.setQueryData(['mcpServers', user?.id], (old: any) => { if (!old?.servers) return old; return { ...old, servers: old.servers.map((s: any) => s.id === serverId ? { ...s, disabledTools: disabledToolList } : s) }; }); const res = await fetch(`/api/mcp/servers/${serverId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabledTools: disabledToolList }), }); // Only refetch on failure to revert optimistic update if (!res.ok) { queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); } }; const saveDisabledTools = (serverId: string, disabledToolList: string[]) => { void patchDisabledTools(serverId, disabledToolList); }; const toggleToolDisabled = (serverId: string, currentDisabled: string[], toolName: string) => { const next = currentDisabled.includes(toolName) ? currentDisabled.filter((t) => t !== toolName) : [...currentDisabled, toolName]; saveDisabledTools(serverId, next); }; type EditForm = { name: string; url: string; headerName: string; headerValue: string; bearerToken: string; oauthClientId: string; }; const [editTarget, setEditTarget] = useState(null); const [editForm, setEditForm] = useState({ name: '', url: '', headerName: '', headerValue: '', bearerToken: '', oauthClientId: '' }); const openEdit = (server: ServerRecord) => { setEditTarget(server); setEditForm({ name: server.name, url: server.url, headerName: '', headerValue: '', bearerToken: '', oauthClientId: '' }); }; const [togglingId, setTogglingId] = useState(null); const toggleMutation = useMutation({ mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => { setTogglingId(id); const r = await fetch(`/api/mcp/servers/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isEnabled }), }); if (!r.ok) throw new Error('Failed to update'); }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }), onError: () => sileo.error({ title: 'Update failed' }), onSettled: () => setTogglingId(null), }); const deleteMutation = useMutation({ mutationFn: async (id: string) => { setDeletingId(id); const r = await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE' }); if (!r.ok) throw new Error('Failed to delete'); }, onSuccess: () => { setDeletingId(null); queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); sileo.success({ title: 'App removed' }); }, onError: () => { setDeletingId(null); sileo.error({ title: 'Delete failed' }); }, }); const editMutation = useMutation({ mutationFn: async () => { if (!editTarget) return; const lower = editForm.url.toLowerCase(); const body: Record = { name: editForm.name.trim(), url: editForm.url.trim(), transportType: lower.endsWith('/sse') || lower.includes('/sse?') ? 'sse' : 'http', }; if (editTarget.authType === 'header' && editForm.headerValue.trim()) { body.headerName = editForm.headerName.trim() || 'Authorization'; body.headerValue = editForm.headerValue.trim(); } if (editTarget.authType === 'bearer' && editForm.bearerToken.trim()) { body.bearerToken = editForm.bearerToken.trim(); } if (editTarget.authType === 'oauth' && editForm.oauthClientId.trim()) { body.oauthClientId = editForm.oauthClientId.trim(); } const r = await fetch(`/api/mcp/servers/${editTarget.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await r.json(); if (!r.ok) throw new Error(data?.cause || data?.message || 'Failed to update'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); sileo.success({ title: 'App updated' }); setEditTarget(null); }, onError: (error: Error) => sileo.error({ title: 'Update failed', description: normalizeError(error) }), }); const testMutation = useMutation({ mutationFn: async (id: string) => { setTestingId(id); const r = await fetch('/api/mcp/servers/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverId: id }), }); const data = await r.json(); if (!r.ok) throw new Error(data?.cause || data?.message || 'Test failed'); return data as { toolCount: number }; }, onSuccess: (data) => { setTestingId(null); queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); sileo.success({ title: 'Connection successful', description: `${data.toolCount} tool${data.toolCount === 1 ? '' : 's'} loaded` }); }, onError: (error: Error) => { setTestingId(null); sileo.error({ title: 'Connection test failed', description: normalizeError(error) }); }, }); const oauthStartMutation = useMutation({ mutationFn: async (id: string) => { setConnectingId(id); const r = await fetch(`/api/mcp/servers/${id}/oauth/start`, { method: 'POST' }); const data = await r.json(); if (!r.ok) throw new Error(data?.cause || data?.message || 'OAuth failed'); return data as { authorizationUrl: string }; }, onSuccess: ({ authorizationUrl }) => { if (authorizationUrl) window.location.assign(authorizationUrl); }, onError: (error: Error) => { setConnectingId(null); sileo.error({ title: 'Authorization failed', description: normalizeError(error) }); }, }); const oauthDisconnectMutation = useMutation({ mutationFn: async (id: string) => { const r = await fetch(`/api/mcp/servers/${id}/oauth/disconnect`, { method: 'POST' }); if (!r.ok) throw new Error('Failed to disconnect'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); sileo.success({ title: 'Disconnected' }); }, onError: () => sileo.error({ title: 'Disconnect failed' }), }); const connectedUrls = useMemo( () => new Set((serversData?.servers ?? []).map((s) => s.url.replace(/\/$/, ''))), [serversData], ); const filteredItems = useMemo(() => { const q = search.toLowerCase().trim(); const filtered = CATALOG.filter((item) => { if (category !== 'all' && item.category !== category) return false; if (!q) return true; return item.name.toLowerCase().includes(q) || item.maintainer.toLowerCase().includes(q); }); // Prioritize true OAuth entries first, then OAuth requiring client setup. return [...filtered].sort((a, b) => { const rank = (item: CatalogItem) => { if (item.auth === 'oauth' && !isOauthWithClientSetup(item)) return 0; if (isOauthWithClientSetup(item)) return 1; return 2; }; const rankDiff = rank(a) - rank(b); if (rankDiff !== 0) return rankDiff; return a.name.localeCompare(b.name); }); }, [search, category]); const featuredItems = useMemo(() => { const catalogByName = new Map(CATALOG.map((item) => [item.name, item])); return FEATURED_NAMES .map((name) => catalogByName.get(name)) .filter((item): item is CatalogItem => Boolean(item)); }, []); const addMutation = useMutation({ mutationFn: async ({ item, apiKey, fieldValues, oauthCredentials }: { item: CatalogItem; apiKey?: string; fieldValues?: Record; oauthCredentials?: Record }) => { const firstField = item.fields?.[0]; const resolvedHeaderName = firstField?.headerName ?? 'Authorization'; const resolvedValue = firstField ? (fieldValues?.[firstField.label] ?? apiKey ?? '') : (apiKey ?? ''); const body: Record = { name: item.name, url: item.url, isEnabled: true, transportType: getTransportType(item.url), authType: item.auth === 'oauth' ? 'oauth' : item.auth === 'apikey' ? 'header' : 'none', ...(item.auth === 'apikey' && resolvedValue ? { headerName: resolvedHeaderName, headerValue: resolvedHeaderName.toLowerCase() === 'authorization' ? `Bearer ${resolvedValue}` : resolvedValue, } : {}), ...(oauthCredentials?.oauthClientId ? { oauthClientId: oauthCredentials.oauthClientId } : {}), ...(oauthCredentials?.oauthClientSecret ? { oauthClientSecret: oauthCredentials.oauthClientSecret } : {}), }; const r = await fetch('/api/mcp/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await r.json(); if (!r.ok) throw new Error(data?.cause || data?.message || 'Failed to add server'); if (item.auth === 'oauth') { const oauthR = await fetch(`/api/mcp/servers/${data.server.id}/oauth/start`, { method: 'POST' }); const oauthData = await oauthR.json(); if (!oauthR.ok) throw new Error(oauthData?.cause || oauthData?.message || 'Failed to start OAuth'); if (oauthData.authorizationUrl) { window.location.assign(oauthData.authorizationUrl); return data; } } return data; }, onSuccess: (_, { item }) => { if (item.auth !== 'oauth') sileo.success({ title: `${item.name} added` }); queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); setApiKeyTarget(null); setApiKeyValues({}); setOauthSetupTarget(null); setOauthSetupValues({}); }, onError: (error: Error) => sileo.error({ title: 'Failed to add app', description: normalizeError(error) }), onSettled: () => setAddingUrl(null), }); const customMutation = useMutation({ mutationFn: async () => { const { name, url, authType, bearerToken, headerName, headerValue } = customForm; const lower = url.toLowerCase(); const body: Record = { name: name.trim(), url: url.trim(), isEnabled: true, authType, transportType: lower.endsWith('/sse') || lower.includes('/sse?') ? 'sse' : 'http', ...(authType === 'bearer' && bearerToken ? { bearerToken: bearerToken.trim() } : {}), ...(authType === 'header' && headerName ? { headerName: headerName.trim(), headerValue: headerValue.trim() } : {}), }; const r = await fetch('/api/mcp/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await r.json(); if (!r.ok) throw new Error(data?.cause || data?.message || 'Failed to add server'); if (authType === 'oauth') { const oauthR = await fetch(`/api/mcp/servers/${data.server.id}/oauth/start`, { method: 'POST' }); const oauthData = await oauthR.json(); if (!oauthR.ok) throw new Error(oauthData?.cause || oauthData?.message || 'Failed to start OAuth'); if (oauthData.authorizationUrl) { window.location.assign(oauthData.authorizationUrl); return data; } } return data; }, onSuccess: () => { if (customForm.authType !== 'oauth') sileo.success({ title: `${customForm.name} added` }); queryClient.invalidateQueries({ queryKey: ['mcpServers', user?.id] }); setShowCustomDialog(false); resetCustomForm(); }, onError: (error: Error) => sileo.error({ title: 'Failed to add app', description: normalizeError(error) }), }); const handleAdd = (item: CatalogItem) => { if (!isProUser) { router.push('/pricing'); return; } if (item.auth === 'apikey') { setApiKeyTarget(item); setApiKeyValues({}); return; } if (item.auth === 'oauth' && item.oauthSetup?.length) { setOauthSetupTarget(item); setOauthSetupValues({}); return; } setAddingUrl(item.url); addMutation.mutate({ item }); }; const handleCustomOpen = () => { if (!isProUser) { router.push('/pricing'); return; } setShowCustomDialog(true); }; useEffect(() => { if (isReadOnlyMarketplace && activeTab !== 'browse') { setActiveTab('browse'); } }, [isReadOnlyMarketplace, activeTab]); if (!mcpEnabled) return null; if (isAuthLoading) { return (
{Array.from({ length: 8 }).map((_, i) => )}
); } return (
{/* ── Page header ──────────────────────────────────────── */}

scira apps

{/* Tabs + search (desktop: side by side, mobile: stacked) */}
0 ? ` (${connectedUrls.size})` : ''}` }, ]} /> {activeTab === 'browse' && (
setSearch(e.target.value)} className="pl-8 h-8 text-sm w-full sm:w-52" />
)}
{isReadOnlyMarketplace && (

Browse all apps for free. Upgrade to connect and run app tools in chat.

)} {/* Category pills — browse only */} {activeTab === 'browse' && (
{CATEGORIES.map((cat) => ( ))}
)}
{/* ── Browse tab ───────────────────────────────────────── */} {activeTab === 'browse' && (
{/* Featured section — only when no filter active */} {!search && category === 'all' && (

Featured

{featuredItems.length}
{featuredItems.map((item) => ( ))}
)} {/* All servers grid */}

{search || category !== 'all' ? 'Results' : 'All Servers'}

{filteredItems.length}
{serversLoading ? (
{Array.from({ length: 8 }).map((_, i) => )}
) : filteredItems.length > 0 ? (
{/* Add custom — always first */}
Add custom server
{filteredItems.map((item) => ( ))}
) : (

No servers match “{search}”

)}
)} {/* ── My Apps tab ──────────────────────────────────────── */} {activeTab === 'my-servers' && (

My Apps

{(serversData?.servers ?? []).length > 0 && ( {(serversData?.servers ?? []).length} )}
{serversLoading ? (
{Array.from({ length: 3 }).map((_, i) => (
))}
) : (serversData?.servers ?? []).length === 0 ? (

No apps connected

Browse to add your first app

) : (
{[...(serversData?.servers ?? [])].sort((a, b) => { const score = (s: ServerRecord) => { const ready = s.authType !== 'oauth' || s.isOAuthConnected; if (s.isEnabled && ready) return 3; if (s.isEnabled && !ready) return 2; if (!s.isEnabled && ready) return 1; return 0; }; return score(b) - score(a); }).map((server) => { const isToolsExpanded = expandedToolsId === server.id; const tools = serverToolsCache[server.id] ?? []; const isLoadingTools = toolsLoading[server.id] ?? false; const disabledForServer: string[] = Array.isArray((server as any).disabledTools) ? (server as any).disabledTools : []; const isReady = server.authType !== 'oauth' || server.isOAuthConnected; return (
{/* Icon + info — dimmed when inactive */}
{server.name} {server.authType === 'oauth' && !server.isOAuthConnected && ( )} {disabledForServer.length > 0 && ( {disabledForServer.length} hidden )}

{(() => { try { return new URL(server.url).hostname; } catch { return server.url; } })()}

{(server.oauthError || server.lastError) && (

{server.oauthError || server.lastError}

)}
{/* Actions — never dimmed */}
{server.authType === 'oauth' && !server.isOAuthConnected && ( )} {/* Lock toggle for OAuth servers that aren't connected yet */} {server.authType === 'oauth' && !server.isOAuthConnected ? ( ) : ( toggleMutation.mutate({ id: server.id, isEnabled: v })} disabled={togglingId === server.id} /> )} {!CATALOG_URLS.has(server.url.replace(/\/$/, '')) && ( openEdit(server)}> Edit )} testMutation.mutate(server.id)} disabled={testingId === server.id && testMutation.isPending} > {testingId === server.id && testMutation.isPending ? : } Test connection {server.authType === 'oauth' && server.isOAuthConnected && ( <> oauthStartMutation.mutate(server.id)}> Reconnect OAuth oauthDisconnectMutation.mutate(server.id)} className="text-muted-foreground"> Disconnect OAuth )} setConfirmDeleteId(server.id)} > Remove {/* Tool management toggle */} {isReady && ( )}
{/* Tool list */} {isToolsExpanded && isReady && (
Tools {!isLoadingTools && tools.length > 0 && ( {tools.length - disabledForServer.length}/{tools.length} enabled )}
{!isLoadingTools && tools.length > 0 && disabledForServer.length > 0 && ( )}
{isLoadingTools ? (
Loading tools…
) : tools.length === 0 ? (
No tools found
) : (
{tools.map((tool) => { const isDisabled = disabledForServer.includes(tool.name); return (
toggleToolDisabled(server.id, disabledForServer, tool.name)} className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-muted/40 transition-colors group" >
{tool.title ?? tool.name} toggleToolDisabled(server.id, disabledForServer, tool.name)} onClick={(e) => e.stopPropagation()} className="shrink-0 scale-75" />
); })}
)}
)}
); })}
)}
)}
{/* ── Edit dialog ──────────────────────────────────────────── */} { if (!v) setEditTarget(null); }}>
{editTarget && }
Edit {editTarget?.name}
setEditForm((p) => ({ ...p, name: e.target.value }))} autoFocus />
setEditForm((p) => ({ ...p, url: e.target.value }))} placeholder="https://…" />
{editTarget?.authType === 'header' && ( <>
setEditForm((p) => ({ ...p, headerName: e.target.value }))} placeholder="Authorization" />
setEditForm((p) => ({ ...p, headerValue: e.target.value }))} placeholder="Bearer sk-…" />
)} {editTarget?.authType === 'bearer' && (
setEditForm((p) => ({ ...p, bearerToken: e.target.value }))} placeholder="sk-…" />
)} {editTarget?.authType === 'oauth' && (
setEditForm((p) => ({ ...p, oauthClientId: e.target.value }))} placeholder="Client ID…" />
)}
{/* ── Delete confirmation ──────────────────────────────────── */} { if (!v) setConfirmDeleteId(null); }}> Remove app? This will disconnect any OAuth sessions and cannot be undone. Cancel { if (confirmDeleteId) { deleteMutation.mutate(confirmDeleteId); setConfirmDeleteId(null); } }} disabled={deletingId === confirmDeleteId && deleteMutation.isPending} > {deletingId === confirmDeleteId && deleteMutation.isPending ? : } Remove {/* ── Custom server dialog ─────────────────────────────────── */} { if (!v) { setShowCustomDialog(false); resetCustomForm(); } }}> Add custom app Connect any MCP-compatible remote endpoint.
setCustomForm((p) => ({ ...p, name: e.target.value }))} autoFocus />
setCustomForm((p) => ({ ...p, url: e.target.value }))} />
{customForm.authType === 'bearer' && (
setCustomForm((p) => ({ ...p, bearerToken: e.target.value }))} />
)} {customForm.authType === 'header' && (
setCustomForm((p) => ({ ...p, headerName: e.target.value }))} />
setCustomForm((p) => ({ ...p, headerValue: e.target.value }))} />
)} {customForm.authType === 'oauth' && (

OAuth endpoints will be auto-discovered from the server URL after adding.

)}
{/* ── OAuth setup dialog (pre-registered client credentials) ── */} { if (!v) { setOauthSetupTarget(null); setOauthSetupValues({}); } }}>
{oauthSetupTarget && }
Connect {oauthSetupTarget?.name}
{oauthSetupTarget?.name} requires a pre-registered OAuth app. You'll be redirected to authorize after entering your credentials.
{oauthSetupTarget?.oauthSetup?.map((field, i) => (
{field.hintUrl && ( {field.hintText ?? 'Get credentials'} )}
setOauthSetupValues((p) => ({ ...p, [field.key]: e.target.value }))} autoFocus={i === 0} />
))}
{oauthCallbackUri}

Stored securely · you'll be redirected to authorize

{/* ── API Key dialog ───────────────────────────────────────── */} { if (!v) { setApiKeyTarget(null); setApiKeyValues({}); } }}>
{apiKeyTarget && }
Connect {apiKeyTarget?.name}
{apiKeyTarget?.fields?.length ? 'Enter your credentials to connect this app.' : <>Sent as Authorization: Bearer …}
{apiKeyTarget?.fields?.length ? ( apiKeyTarget.fields.map((field, i) => (
{field.hintUrl && ( {field.hintText ?? 'Get key'} )}
setApiKeyValues((p) => ({ ...p, [field.label]: e.target.value }))} autoFocus={i === 0} /> {field.steps && field.steps.length > 0 && (

How to get your token

    {field.steps.map((step, si) => { const scopeMatch = step.text.match(/^(.*?add:\s*)(.+)$/); const scopes = scopeMatch ? scopeMatch[2].split(',').map(s => s.trim()).filter(Boolean) : null; return (
  1. {si + 1}. {scopes ? scopeMatch![1] : step.text} {step.url && ( {step.urlLabel ?? step.url} )} {scopes && ( {scopes.map(scope => ( {scope} ))} )}
  2. ); })}
)}
)) ) : (
setApiKeyValues({ key: e.target.value })} autoFocus />
)}

Stored securely · editable later in My Apps

); } export default function AppsPage() { return (
{Array.from({ length: 12 }).map((_, i) => )}
} > ); } ================================================ FILE: app/connectors/[provider]/callback/page.tsx ================================================ 'use client'; import { useEffect, useState } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { Loader2, CheckCircle, XCircle } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { CONNECTOR_CONFIGS, CONNECTOR_ICONS, type ConnectorProvider } from '@/lib/connectors'; import { getCurrentUser } from '@/app/actions'; export default function ConnectorCallbackPage() { const router = useRouter(); const params = useParams(); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [message, setMessage] = useState('Processing connection...'); const provider = params.provider as ConnectorProvider; const providerConfig = CONNECTOR_CONFIGS[provider]; useEffect(() => { const processCallback = async () => { try { if (!providerConfig) { setStatus('error'); setMessage('Invalid provider'); return; } // Get current user to verify authentication const user = await getCurrentUser(); if (!user) { setStatus('error'); setMessage('Authentication required'); return; } setMessage(`Connecting to ${providerConfig.name}...`); // Check if connection was successful by querying the connection status // The OAuth flow should have completed by now await new Promise((resolve) => setTimeout(resolve, 2000)); setStatus('success'); setMessage(`${providerConfig.name} connected successfully!`); // Redirect to settings connectors tab after a short delay setTimeout(() => { router.push('/?tab=connectors#settings'); }, 2000); } catch (error) { console.error('Callback processing error:', error); setStatus('error'); setMessage('Failed to process authorization'); } }; if (provider && providerConfig) { processCallback(); } else { setStatus('error'); setMessage('Invalid connector provider'); } }, [router, provider, providerConfig]); const handleReturnToSettings = () => { router.push('/?tab=connectors#settings'); }; return (
{status === 'loading' && } {status === 'success' && } {status === 'error' && } {providerConfig ? ( {(() => { const IconComponent = CONNECTOR_ICONS[providerConfig.icon as keyof typeof CONNECTOR_ICONS]; return IconComponent ? : null; })()} {providerConfig.name} ) : ( 'Connector Authorization' )}

{message}

{status === 'error' && ( )} {status === 'success' &&

Redirecting to settings...

}
); } ================================================ FILE: app/error.tsx ================================================ 'use client'; import { useEffect } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { motion } from 'framer-motion'; import { Button } from '@/components/ui/button'; import { ArrowLeft, RefreshCw } from 'lucide-react'; export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { // Log the error to an error reporting service console.error(error); }, [error]); return (
Computer Error

Something went wrong

An error occurred while trying to load this page. Please try again later.

); } ================================================ FILE: app/global-error.tsx ================================================ 'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { RefreshCw, Home, TriangleAlert, ChevronDown, ChevronUp, Copy } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Be_Vietnam_Pro, Baumans, Geist } from 'next/font/google'; import { AnimatePresence, motion } from 'framer-motion'; const beVietnamPro = Be_Vietnam_Pro({ subsets: ['latin'], variable: '--font-be-vietnam-pro', weight: ['300', '400', '500', '600', '700', '800'], display: 'swap', preload: true, }); const baumans = Baumans({ subsets: ['latin'], variable: '--font-baumans', weight: '400', display: 'swap', preload: true, }); const geist = Geist({ subsets: ['latin'], variable: '--font-sans', weight: ['400', '500', '600', '700'], display: 'swap', preload: true, }); interface GlobalErrorProps { error: Error & { digest?: string }; reset: () => void; } export default function GlobalError({ error, reset }: GlobalErrorProps) { const [showDetails, setShowDetails] = useState(false); const [copied, setCopied] = useState(false); useEffect(() => { // Central place to hook real error reporting (Sentry, PostHog, etc.) // e.g. reportError(error); // eslint-disable-next-line no-console console.error('[GlobalErrorBoundary]', error); }, [error]); const details = [ error.message && `Message: ${error.message}`, error.name && `Name: ${error.name}`, error.digest && `Digest: ${error.digest}`, error.stack && `Stack:\n${error.stack}`, ] .filter(Boolean) .join('\n\n'); const handleCopy = async () => { try { await navigator.clipboard.writeText(details); setCopied(true); setTimeout(() => setCopied(false), 2500); } catch { // swallow } }; return (
{/* Subtle background decoration */}

Something broke

A global application error occurred. You can try to recover, or head back to the home page. If this keeps happening, feel free to report it.

{showDetails && (
                          {details || 'No additional diagnostic information available.'}
                        
{details && (
)}
)}

Error boundary: global / Root. Runtime may have partial state loss.

); } ================================================ FILE: app/globals.css ================================================ @source "../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}"; @import '@cloudflare/kumo/styles/tailwind'; @import 'tailwindcss'; @custom-variant dark (&:is(.dark *)); @custom-variant light (&:is(.light *)); @custom-variant colourful (&:is(.colourful *)); @custom-variant t3chat (&:is(.t3chat *)); @custom-variant claudedark (&:is(.claudedark *)); @custom-variant claudelight (&:is(.claudelight *)); @plugin 'tailwind-scrollbar'; @import 'tw-animate-css'; @plugin "@tailwindcss/typography"; /* Line clamp utilities */ .line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } .root { isolation: isolate; } @keyframes spinner-fade { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes revealLine { 0% { transform: scaleX(0); transform-origin: left; opacity: 0.25; } 60% { opacity: 1; } 100% { transform: scaleX(1); transform-origin: left; opacity: 1; } } /* Shimmer animation for Pro badge */ @keyframes shimmer { 0% { background-position: -200% center; } 100% { background-position: 200% center; } } .animate-shimmer { position: relative; overflow: hidden; } .animate-shimmer::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.15) 50%, transparent 100%); background-size: 200% 100%; animation: shimmer 3s ease-in-out infinite; pointer-events: none; } @utility animate-reveal-line { animation: revealLine 420ms cubic-bezier(0.22, 1, 0.36, 1) both; } @media (prefers-reduced-motion: reduce) { .animate-reveal-line { animation: none !important; } } @theme { --animate-accordion-down: accordion-down 300ms ease-out; --animate-accordion-up: accordion-up 300ms ease-out; } @utility no-scrollbar { &::-webkit-scrollbar { display: none !important; } -ms-overflow-style: none !important; scrollbar-width: none !important; } @utility text-balance { text-wrap: balance; } @layer utilities { .markdown-body .katex { font-size: 1.1em; } .markdown-body .katex-display { overflow-x: auto; overflow-y: hidden; padding-top: 0.5em; padding-bottom: 0.5em; margin-top: 1em; margin-bottom: 1em; } .markdown-body .katex-display > .katex { font-size: 1.21em; } .markdown-body .katex-display > .katex > .katex-html { display: block; position: relative; } .markdown-body .katex-display > .katex > .katex-html > .tag { position: absolute; right: 0; } /* Enhanced list styling for better readability */ .markdown-body ol { counter-reset: item; list-style-type: none; } .markdown-body ol > li { counter-increment: item; position: relative; } .markdown-body ol > li::before { content: counter(item) '.'; font-weight: 600; color: hsl(var(--primary)); display: inline-block; width: 2em; margin-left: -2em; text-align: right; padding-right: 0.5em; } .markdown-body ul > li::marker { color: hsl(var(--primary) / 0.7); font-size: 1.1em; } .markdown-body ol > li > ol, .markdown-body ul > li > ul, .markdown-body ol > li > ul, .markdown-body ul > li > ol { margin-top: 0.75rem; margin-bottom: 0.75rem; } /* Nested list styling */ .markdown-body ol ol > li::before { content: counter(item, lower-alpha) '.'; } .markdown-body ol ol ol > li::before { content: counter(item, lower-roman) '.'; } /* Better spacing for paragraphs within list items */ .markdown-body li > p { margin-top: 0.5rem; margin-bottom: 0.5rem; } .markdown-body li > p:first-child { margin-top: 0; } .markdown-body li > p:last-child { margin-bottom: 0; } /* Horizontal rule styling */ .markdown-body hr { margin: 2rem 0; border: none; border-top: 2px solid hsl(var(--border)); opacity: 0.5; } /* Better heading differentiation */ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { line-height: 1.3; color: hsl(var(--foreground)); } .markdown-body h1 { letter-spacing: -0.02em; } .markdown-body h2 { letter-spacing: -0.015em; } /* Improve strong/bold visibility */ .markdown-body strong { font-weight: 700; color: hsl(var(--foreground)); } /* Better code inline spacing */ .markdown-body p > code, .markdown-body li > code { margin: 0 0.1em; } /* Improve blockquote spacing */ .markdown-body blockquote > *:first-child { margin-top: 0; } .markdown-body blockquote > *:last-child { margin-bottom: 0; } } @layer utilities { /* Tweet wrapper styling for horizontal layout - compact and minimal */ .tweet-wrapper { position: relative; height: 220px; overflow: hidden; } .tweet-wrapper [data-theme] { margin: 0 !important; border-radius: 0.5rem !important; border: 1px solid hsl(var(--border) / 0.5) !important; box-shadow: none !important; background: hsl(var(--card)) !important; height: 100%; transition: border-color 0.2s ease, background-color 0.2s ease; } .tweet-wrapper [data-theme]:hover { border-color: hsl(var(--border)) !important; } /* Tweet wrapper styling for sheet - clean and minimal */ .tweet-wrapper-sheet [data-theme] { margin: 0 !important; border-radius: 0.5rem !important; border: 1px solid hsl(var(--border) / 0.5) !important; box-shadow: none !important; background: hsl(var(--card)) !important; max-width: 100% !important; width: 100% !important; transition: border-color 0.2s ease; } .tweet-wrapper-sheet [data-theme]:hover { border-color: hsl(var(--border)) !important; } /* Ensure proper tweet spacing */ .tweet-wrapper .react-tweet-theme, .tweet-wrapper-sheet .react-tweet-theme { margin: 0 !important; } /* Override react-tweet default margins */ [data-tweet-container] { margin: 0 !important; } .linenumber { font-style: normal !important; font-weight: normal !important; } :is([data-theme='dark'], .dark) :where(.react-tweet-theme) { --tweet-skeleton-gradient: linear-gradient(270deg, #09090b, #18181b, #18181b, #09090b) !important; --tweet-border: 1px solid #27272a !important; --tweet-font-color: #fafafa !important; --tweet-font-color-secondary: #a1a1aa !important; --tweet-bg-color: #09090b !important; --tweet-bg-color-hover: #18181b !important; --tweet-quoted-bg-color: #18181b !important; --tweet-quoted-bg-color-hover: #27272a !important; --tweet-color-blue-primary: #3b82f6 !important; --tweet-color-blue-secondary-hover: rgba(59, 130, 246, 0.1) !important; --tweet-icon-color: #71717a !important; --tweet-icon-color-hover: #a1a1aa !important; } /* Colourful theme - warm dark tones */ :is(.colourful) :where(.react-tweet-theme) { --tweet-skeleton-gradient: linear-gradient(270deg, #3d3427, #4a3f33, #4a3f33, #3d3427) !important; --tweet-border: 1px solid #5a4d3e !important; --tweet-font-color: #ece0c9 !important; --tweet-font-color-secondary: #b8a88e !important; --tweet-bg-color: #3d3427 !important; --tweet-bg-color-hover: #4a3f33 !important; --tweet-quoted-bg-color: #4a3f33 !important; --tweet-quoted-bg-color-hover: #5a4d3e !important; --tweet-color-blue-primary: #c9a96e !important; --tweet-color-blue-secondary-hover: rgba(201, 169, 110, 0.12) !important; --tweet-icon-color: #8a7a63 !important; --tweet-icon-color-hover: #b8a88e !important; } /* T3Chat theme - purple/magenta dark tones */ :is(.t3chat) :where(.react-tweet-theme) { --tweet-skeleton-gradient: linear-gradient(270deg, #2a1f35, #352842, #352842, #2a1f35) !important; --tweet-border: 1px solid #4a3558 !important; --tweet-font-color: #d4b8e8 !important; --tweet-font-color-secondary: #a88dbf !important; --tweet-bg-color: #2a1f35 !important; --tweet-bg-color-hover: #352842 !important; --tweet-quoted-bg-color: #352842 !important; --tweet-quoted-bg-color-hover: #4a3558 !important; --tweet-color-blue-primary: #c43a5f !important; --tweet-color-blue-secondary-hover: rgba(196, 58, 95, 0.12) !important; --tweet-icon-color: #7a5f8f !important; --tweet-icon-color-hover: #a88dbf !important; } /* Claude Light theme - warm cream tones */ :is(.claudelight) :where(.react-tweet-theme) { --tweet-skeleton-gradient: linear-gradient(270deg, #f5f0e8, #ede7dc, #ede7dc, #f5f0e8) !important; --tweet-border: 1px solid #ddd5c8 !important; --tweet-font-color: #4a4132 !important; --tweet-font-color-secondary: #8a7f6f !important; --tweet-bg-color: #f5f0e8 !important; --tweet-bg-color-hover: #ede7dc !important; --tweet-quoted-bg-color: #ede7dc !important; --tweet-quoted-bg-color-hover: #e5ddd0 !important; --tweet-color-blue-primary: #c45a2d !important; --tweet-color-blue-secondary-hover: rgba(196, 90, 45, 0.08) !important; --tweet-icon-color: #a89b89 !important; --tweet-icon-color-hover: #8a7f6f !important; } /* Claude Dark theme - warm dark olive tones */ :is(.claudedark) :where(.react-tweet-theme) { --tweet-skeleton-gradient: linear-gradient(270deg, #3a3730, #45423a, #45423a, #3a3730) !important; --tweet-border: 1px solid #544f45 !important; --tweet-font-color: #c8bfa8 !important; --tweet-font-color-secondary: #9e9582 !important; --tweet-bg-color: #3a3730 !important; --tweet-bg-color-hover: #45423a !important; --tweet-quoted-bg-color: #45423a !important; --tweet-quoted-bg-color-hover: #544f45 !important; --tweet-color-blue-primary: #d4874a !important; --tweet-color-blue-secondary-hover: rgba(212, 135, 74, 0.12) !important; --tweet-icon-color: #7a7365 !important; --tweet-icon-color-hover: #9e9582 !important; } } @layer base { * { @apply border-border no-scrollbar!; } body { @apply bg-background text-foreground scrollbar!; } [role='button'], button { cursor: pointer; } :disabled { cursor: default; } .whatsize { field-sizing: content; min-height: 1lh; max-height: 10lh; resize: none; overflow-y: auto; /* Fallback for browsers that don't support field-sizing */ min-height: 28px; height: auto; /* fix for firefox */ @supports (-moz-appearance: none) { min-height: 1lh; max-height: 10lh; } } } :root { /* Sugar-high syntax highlighting - Light theme */ --sh-identifier: oklch(0.35 0.02 250); --sh-keyword: oklch(0.55 0.15 25); --sh-string: oklch(0.5 0.12 160); --sh-class: oklch(0.55 0.14 280); --sh-property: oklch(0.5 0.12 240); --sh-entity: oklch(0.48 0.1 200); --sh-jsxliterals: oklch(0.52 0.13 330); --sh-sign: oklch(0.45 0.03 250); --sh-comment: oklch(0.55 0.02 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(0.9821 0 0); --foreground: oklch(0.2435 0 0); --card: oklch(0.9911 0 0); --card-foreground: oklch(0.2435 0 0); --popover: oklch(0.9911 0 0); --popover-foreground: oklch(0.2435 0 0); --primary: oklch(0.4341 0.0392 41.9938); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.92 0.0651 74.3695); --secondary-foreground: oklch(0.3499 0.0685 40.8288); --muted: oklch(0.9521 0 0); --muted-foreground: oklch(0.5032 0 0); --accent: oklch(0.931 0 0); --accent-foreground: oklch(0.2435 0 0); --destructive: oklch(0.6271 0.1936 33.339); --destructive-foreground: oklch(1 0 0); --border: oklch(0.8822 0 0); --input: oklch(0.8822 0 0); --ring: oklch(0.4341 0.0392 41.9938); --chart-1: oklch(0.4341 0.0392 41.9938); --chart-2: oklch(0.92 0.0651 74.3695); --chart-3: oklch(0.931 0 0); --chart-4: oklch(0.9367 0.0523 75.5009); --chart-5: oklch(0.4338 0.0437 41.6746); --sidebar: oklch(0.9881 0 0); --sidebar-foreground: oklch(0.2645 0 0); --sidebar-primary: oklch(0.325 0 0); --sidebar-primary-foreground: oklch(0.9881 0 0); --sidebar-accent: oklch(0.9761 0 0); --sidebar-accent-foreground: oklch(0.325 0 0); --sidebar-border: oklch(0.9401 0 0); --sidebar-ring: oklch(0.7731 0 0); --radius: 0.875rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --tracking-normal: 0em; --spacing: 0.25rem; --chart-background: oklch(1 0 0); --chart-foreground: oklch(0.145 0.004 285); --chart-foreground-muted: oklch(0.55 0.014 260); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.4 0.1828 274.34); --chart-grid: oklch(0.9 0 0); --chart-tooltip-background: oklch(0.21 0.006 285 / 0.8); --chart-tooltip-foreground: oklch(0.985 0 0); --chart-tooltip-muted: oklch(0.65 0.01 260); --chart-marker-background: oklch(0.97 0.005 260); --chart-marker-border: oklch(0.85 0.01 260); --chart-marker-foreground: oklch(0.3 0.01 260); --chart-ring-background: oklch(0.9 0.005 260 / 0.25); --chart-label: oklch(0.45 0.01 260); } .light { /* Sugar-high syntax highlighting - Light theme */ --sh-identifier: oklch(0.35 0.02 250); --sh-keyword: oklch(0.55 0.15 25); --sh-string: oklch(0.5 0.12 160); --sh-class: oklch(0.55 0.14 280); --sh-property: oklch(0.5 0.12 240); --sh-entity: oklch(0.48 0.1 200); --sh-jsxliterals: oklch(0.52 0.13 330); --sh-sign: oklch(0.45 0.03 250); --sh-comment: oklch(0.55 0.02 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(0.9821 0 0); --foreground: oklch(0.2435 0 0); --card: oklch(0.9911 0 0); --card-foreground: oklch(0.2435 0 0); --popover: oklch(0.9911 0 0); --popover-foreground: oklch(0.2435 0 0); --primary: oklch(0.4341 0.0392 41.9938); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.92 0.0651 74.3695); --secondary-foreground: oklch(0.3499 0.0685 40.8288); --muted: oklch(0.9521 0 0); --muted-foreground: oklch(0.5032 0 0); --accent: oklch(0.931 0 0); --accent-foreground: oklch(0.2435 0 0); --destructive: oklch(0.6271 0.1936 33.339); --destructive-foreground: oklch(1 0 0); --border: oklch(0.8822 0 0); --input: oklch(0.8822 0 0); --ring: oklch(0.4341 0.0392 41.9938); --chart-1: oklch(0.4341 0.0392 41.9938); --chart-2: oklch(0.92 0.0651 74.3695); --chart-3: oklch(0.931 0 0); --chart-4: oklch(0.9367 0.0523 75.5009); --chart-5: oklch(0.4338 0.0437 41.6746); --sidebar: oklch(0.9881 0 0); --sidebar-foreground: oklch(0.2645 0 0); --sidebar-primary: oklch(0.325 0 0); --sidebar-primary-foreground: oklch(0.9881 0 0); --sidebar-accent: oklch(0.9761 0 0); --sidebar-accent-foreground: oklch(0.325 0 0); --sidebar-border: oklch(0.9401 0 0); --sidebar-ring: oklch(0.7731 0 0); --radius: 0.875rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --tracking-normal: 0em; --spacing: 0.25rem; --chart-background: oklch(1 0 0); --chart-foreground: oklch(0.145 0.004 285); --chart-foreground-muted: oklch(0.55 0.014 260); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.4 0.1828 274.34); --chart-grid: oklch(0.9 0 0); --chart-tooltip-background: oklch(0.21 0.006 285 / 0.9); --chart-tooltip-foreground: oklch(0.985 0 0); --chart-tooltip-muted: oklch(0.65 0.01 260); --chart-marker-background: oklch(0.97 0.005 260); --chart-marker-border: oklch(0.85 0.01 260); --chart-marker-foreground: oklch(0.3 0.01 260); --chart-ring-background: oklch(0.9 0.005 260 / 0.25); --chart-label: oklch(0.45 0.01 260); } .dark { /* Sugar-high syntax highlighting - Dark theme */ --sh-identifier: oklch(0.85 0.03 250); --sh-keyword: oklch(0.75 0.12 25); --sh-string: oklch(0.72 0.14 160); --sh-class: oklch(0.78 0.16 280); --sh-property: oklch(0.75 0.14 240); --sh-entity: oklch(0.73 0.12 200); --sh-jsxliterals: oklch(0.76 0.14 330); --sh-sign: oklch(0.7 0.04 250); --sh-comment: oklch(0.55 0.03 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(0.1776 0 0); --foreground: oklch(0.9491 0 0); --card: oklch(0.2134 0 0); --card-foreground: oklch(0.9491 0 0); --popover: oklch(0.2134 0 0); --popover-foreground: oklch(0.9491 0 0); --primary: oklch(0.9247 0.0524 66.1732); --primary-foreground: oklch(0.2029 0.024 200.1962); --secondary: oklch(0.3163 0.019 63.6992); --secondary-foreground: oklch(0.9247 0.0524 66.1732); --muted: oklch(0.252 0 0); --muted-foreground: oklch(0.7699 0 0); --accent: oklch(0.285 0 0); --accent-foreground: oklch(0.9491 0 0); --destructive: oklch(0.6271 0.1936 33.339); --destructive-foreground: oklch(1 0 0); --border: oklch(0.2351 0.0115 91.7467); --input: oklch(0.4017 0 0); --ring: oklch(0.9247 0.0524 66.1732); --chart-1: oklch(0.9247 0.0524 66.1732); --chart-2: oklch(0.3163 0.019 63.6992); --chart-3: oklch(0.285 0 0); --chart-4: oklch(0.3481 0.0219 67.0001); --chart-5: oklch(0.9245 0.0533 67.0855); --sidebar: oklch(0.2103 0.0059 285.8852); --sidebar-foreground: oklch(0.9674 0.0013 286.3752); --sidebar-primary: oklch(0.4882 0.2172 264.3763); --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.2739 0.0055 286.0326); --sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752); --sidebar-border: oklch(0.2739 0.0055 286.0326); --sidebar-ring: oklch(0.8711 0.0055 286.286); --radius: 0.875rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --chart-background: oklch(0.145 0 0); --chart-foreground: oklch(0.85 0.004 285); --chart-foreground-muted: oklch(0.65 0.01 260); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.55 0 0); --chart-grid: oklch(0.25 0 0); --chart-tooltip-background: oklch(0.18 0.006 285 / 0.95); --chart-tooltip-foreground: oklch(0.985 0 0); --chart-tooltip-muted: oklch(0.65 0.01 260); --chart-marker-background: oklch(0.25 0.01 260); --chart-marker-border: oklch(0.4 0.01 260); --chart-marker-foreground: oklch(0.9 0 0); --chart-ring-background: oklch(0.35 0.01 260 / 0.25); --chart-label: oklch(0.65 0.01 260); } .colourful { /* Sugar-high syntax highlighting - Dark theme */ --sh-identifier: oklch(0.85 0.03 250); --sh-keyword: oklch(0.75 0.12 25); --sh-string: oklch(0.72 0.14 160); --sh-class: oklch(0.78 0.16 280); --sh-property: oklch(0.75 0.14 240); --sh-entity: oklch(0.73 0.12 200); --sh-jsxliterals: oklch(0.76 0.14 330); --sh-sign: oklch(0.7 0.04 250); --sh-comment: oklch(0.55 0.03 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(0.2747 0.0139 57.6523); --foreground: oklch(0.9239 0.019 83.0636); --card: oklch(0.3237 0.0155 59.0603); --card-foreground: oklch(0.9239 0.019 83.0636); --popover: oklch(0.3237 0.0155 59.0603); --popover-foreground: oklch(0.9239 0.019 83.0636); --primary: oklch(0.7264 0.0581 66.6967); --primary-foreground: oklch(0.2747 0.0139 57.6523); --secondary: oklch(0.3795 0.0181 57.128); --secondary-foreground: oklch(0.9239 0.019 83.0636); --muted: oklch(0.2939 0.0125 62.1298); --muted-foreground: oklch(0.7982 0.0243 82.1078); --accent: oklch(0.4186 0.0281 56.3404); --accent-foreground: oklch(0.9239 0.019 83.0636); --destructive: oklch(0.5471 0.1438 32.9149); --destructive-foreground: oklch(1 0 0); --border: oklch(0.3795 0.0181 57.128); --input: oklch(0.3795 0.0181 57.128); --ring: oklch(0.7264 0.0581 66.6967); --chart-1: oklch(0.7264 0.0581 66.6967); --chart-2: oklch(0.6777 0.0624 64.7755); --chart-3: oklch(0.618 0.0778 65.5444); --chart-4: oklch(0.5604 0.0624 68.5805); --chart-5: oklch(0.4851 0.057 72.6827); --sidebar: oklch(0.2747 0.0139 57.6523); --sidebar-foreground: oklch(0.9239 0.019 83.0636); --sidebar-primary: oklch(0.7264 0.0581 66.6967); --sidebar-primary-foreground: oklch(0.2747 0.0139 57.6523); --sidebar-accent: oklch(0.4186 0.0281 56.3404); --sidebar-accent-foreground: oklch(0.9239 0.019 83.0636); --sidebar-border: oklch(0.3795 0.0181 57.128); --sidebar-ring: oklch(0.7264 0.0581 66.6967); --radius: 0.825rem; --shadow-x: 2px; --shadow-y: 3px; --shadow-blur: 5px; --shadow-spread: 0px; --shadow-opacity: 0.12; --shadow-color: hsl(28 13% 20%); --shadow-2xs: 2px 3px 5px 0px hsl(28 13% 20% / 0.06); --shadow-xs: 2px 3px 5px 0px hsl(28 13% 20% / 0.06); --shadow-sm: 2px 3px 5px 0px hsl(28 13% 20% / 0.12), 2px 1px 2px -1px hsl(28 13% 20% / 0.12); --shadow: 2px 3px 5px 0px hsl(28 13% 20% / 0.12), 2px 1px 2px -1px hsl(28 13% 20% / 0.12); --shadow-md: 2px 3px 5px 0px hsl(28 13% 20% / 0.12), 2px 2px 4px -1px hsl(28 13% 20% / 0.12); --shadow-lg: 2px 3px 5px 0px hsl(28 13% 20% / 0.12), 2px 4px 6px -1px hsl(28 13% 20% / 0.12); --shadow-xl: 2px 3px 5px 0px hsl(28 13% 20% / 0.12), 2px 8px 10px -1px hsl(28 13% 20% / 0.12); --shadow-2xl: 2px 3px 5px 0px hsl(28 13% 20% / 0.3); --chart-background: oklch(0.27 0.014 58); --chart-foreground: oklch(0.92 0.019 83); --chart-foreground-muted: oklch(0.65 0.01 60); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.55 0.02 60); --chart-grid: oklch(0.35 0.015 58); --chart-tooltip-background: oklch(0.25 0.012 58 / 0.95); --chart-tooltip-foreground: oklch(0.95 0.015 80); --chart-tooltip-muted: oklch(0.65 0.01 60); --chart-marker-background: oklch(0.35 0.015 58); --chart-marker-border: oklch(0.45 0.02 60); --chart-marker-foreground: oklch(0.92 0.019 83); --chart-ring-background: oklch(0.4 0.015 60 / 0.25); --chart-label: oklch(0.7 0.015 70); } .t3chat { /* T3Chat theme */ --sh-identifier: oklch(0.85 0.03 250); --sh-keyword: oklch(0.75 0.12 25); --sh-string: oklch(0.72 0.14 160); --sh-class: oklch(0.78 0.16 280); --sh-property: oklch(0.75 0.14 240); --sh-entity: oklch(0.73 0.12 200); --sh-jsxliterals: oklch(0.76 0.14 330); --sh-sign: oklch(0.7 0.04 250); --sh-comment: oklch(0.55 0.03 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(0.2409 0.0201 307.5346); --foreground: oklch(0.8398 0.0387 309.5391); --card: oklch(0.2803 0.0232 307.5413); --card-foreground: oklch(0.8456 0.0302 341.4597); --popover: oklch(0.1548 0.0132 338.9015); --popover-foreground: oklch(0.9647 0.0091 341.8035); --primary: oklch(0.4607 0.1853 4.0994); --primary-foreground: oklch(0.856 0.0618 346.3684); --secondary: oklch(0.3137 0.0306 310.061); --secondary-foreground: oklch(0.8483 0.0382 307.9613); --muted: oklch(0.2634 0.0219 309.4748); --muted-foreground: oklch(0.794 0.0372 307.1032); --accent: oklch(0.3649 0.0508 308.4911); --accent-foreground: oklch(0.9647 0.0091 341.8035); --destructive: oklch(0.2258 0.0524 12.6119); --destructive-foreground: oklch(1 0 0); --border: oklch(0.3286 0.0154 343.4461); --input: oklch(0.3387 0.0195 332.8347); --ring: oklch(0.5916 0.218 0.5844); --chart-1: oklch(0.5316 0.1409 355.1999); --chart-2: oklch(0.5633 0.1912 306.8561); --chart-3: oklch(0.7227 0.1502 60.5799); --chart-4: oklch(0.6193 0.2029 312.7422); --chart-5: oklch(0.6118 0.2093 6.1387); --sidebar: oklch(0.1893 0.0163 331.0475); --sidebar-foreground: oklch(0.8607 0.0293 343.6612); --sidebar-primary: oklch(0.4882 0.2172 264.3763); --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.2337 0.0261 338.1961); --sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752); --sidebar-border: oklch(0 0 0); --sidebar-ring: oklch(0.5916 0.218 0.5844); --radius: 0.825rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --chart-background: oklch(0.24 0.02 308); --chart-foreground: oklch(0.84 0.039 310); --chart-foreground-muted: oklch(0.65 0.03 308); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.5 0.15 4); --chart-grid: oklch(0.32 0.025 308); --chart-tooltip-background: oklch(0.2 0.018 308 / 0.95); --chart-tooltip-foreground: oklch(0.96 0.009 342); --chart-tooltip-muted: oklch(0.65 0.03 308); --chart-marker-background: oklch(0.32 0.025 308); --chart-marker-border: oklch(0.45 0.04 308); --chart-marker-foreground: oklch(0.86 0.03 344); --chart-ring-background: oklch(0.4 0.04 308 / 0.25); --chart-label: oklch(0.7 0.035 308); } .claudelight { --background: oklch(0.9818 0.0054 95.0986); --foreground: oklch(0.3438 0.0269 95.7226); --card: oklch(0.9818 0.0054 95.0986); --card-foreground: oklch(0.1908 0.002 106.5859); --popover: oklch(1 0 0); --popover-foreground: oklch(0.2671 0.0196 98.939); --primary: oklch(0.6171 0.1375 39.0427); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.9245 0.0138 92.9892); --secondary-foreground: oklch(0.4334 0.0177 98.6048); --muted: oklch(0.9341 0.0153 90.239); --muted-foreground: oklch(0.6059 0.0075 97.4233); --accent: oklch(0.9245 0.0138 92.9892); --accent-foreground: oklch(0.2671 0.0196 98.939); --destructive: oklch(0.1908 0.002 106.5859); --destructive-foreground: oklch(1 0 0); --border: oklch(0.8847 0.0069 97.3627); --input: oklch(0.7621 0.0156 98.3528); --ring: oklch(0.6171 0.1375 39.0427); --chart-1: oklch(0.5583 0.1276 42.9956); --chart-2: oklch(0.6898 0.1581 290.4107); --chart-3: oklch(0.8816 0.0276 93.128); --chart-4: oklch(0.8822 0.0403 298.1792); --chart-5: oklch(0.5608 0.1348 42.0584); --sidebar: oklch(0.9663 0.008 98.8792); --sidebar-foreground: oklch(0.359 0.0051 106.6524); --sidebar-primary: oklch(0.6171 0.1375 39.0427); --sidebar-primary-foreground: oklch(0.9881 0 0); --sidebar-accent: oklch(0.9245 0.0138 92.9892); --sidebar-accent-foreground: oklch(0.325 0 0); --sidebar-border: oklch(0.9401 0 0); --sidebar-ring: oklch(0.7731 0 0); --radius: 0.825rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --tracking-normal: 0em; --spacing: 0.25rem; --chart-background: oklch(0.98 0.005 95); --chart-foreground: oklch(0.34 0.027 96); --chart-foreground-muted: oklch(0.6 0.008 97); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.55 0.12 39); --chart-grid: oklch(0.9 0.007 97); --chart-tooltip-background: oklch(0.25 0.02 96 / 0.9); --chart-tooltip-foreground: oklch(0.98 0.005 95); --chart-tooltip-muted: oklch(0.65 0.01 97); --chart-marker-background: oklch(0.96 0.008 99); --chart-marker-border: oklch(0.88 0.007 97); --chart-marker-foreground: oklch(0.36 0.005 107); --chart-ring-background: oklch(0.9 0.01 95 / 0.25); --chart-label: oklch(0.5 0.01 97); } .claudedark { --background: oklch(0.2679 0.0036 106.6427); --foreground: oklch(0.8074 0.0142 93.0137); --card: oklch(0.2679 0.0036 106.6427); --card-foreground: oklch(0.9818 0.0054 95.0986); --popover: oklch(0.3085 0.0035 106.6039); --popover-foreground: oklch(0.9211 0.004 106.4781); --primary: oklch(0.6724 0.1308 38.7559); --primary-foreground: oklch(1 0 0); --secondary: oklch(0.9818 0.0054 95.0986); --secondary-foreground: oklch(0.3085 0.0035 106.6039); --muted: oklch(0.2213 0.0038 106.707); --muted-foreground: oklch(0.7713 0.0169 99.0657); --accent: oklch(0.213 0.0078 95.4245); --accent-foreground: oklch(0.9663 0.008 98.8792); --destructive: oklch(0.6368 0.2078 25.3313); --destructive-foreground: oklch(1 0 0); --border: oklch(0.3618 0.0101 106.8928); --input: oklch(0.4336 0.0113 100.2195); --ring: oklch(0.6724 0.1308 38.7559); --chart-1: oklch(0.5583 0.1276 42.9956); --chart-2: oklch(0.6898 0.1581 290.4107); --chart-3: oklch(0.213 0.0078 95.4245); --chart-4: oklch(0.3074 0.0516 289.323); --chart-5: oklch(0.5608 0.1348 42.0584); --sidebar: oklch(0.2357 0.0024 67.7077); --sidebar-foreground: oklch(0.8074 0.0142 93.0137); --sidebar-primary: oklch(0.325 0 0); --sidebar-primary-foreground: oklch(0.9881 0 0); --sidebar-accent: oklch(0.168 0.002 106.6177); --sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137); --sidebar-border: oklch(0.9401 0 0); --sidebar-ring: oklch(0.7731 0 0); --radius: 0.825rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --chart-background: oklch(0.27 0.004 107); --chart-foreground: oklch(0.81 0.014 93); --chart-foreground-muted: oklch(0.65 0.012 99); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.6 0.11 39); --chart-grid: oklch(0.35 0.008 107); --chart-tooltip-background: oklch(0.22 0.004 107 / 0.95); --chart-tooltip-foreground: oklch(0.98 0.005 95); --chart-tooltip-muted: oklch(0.65 0.012 99); --chart-marker-background: oklch(0.31 0.004 107); --chart-marker-border: oklch(0.43 0.011 100); --chart-marker-foreground: oklch(0.92 0.004 106); --chart-ring-background: oklch(0.4 0.01 99 / 0.25); --chart-label: oklch(0.7 0.012 97); } .neutrallight { /* Sugar-high syntax highlighting - Neutral light theme */ --sh-identifier: oklch(0.35 0.02 250); --sh-keyword: oklch(0.55 0.15 25); --sh-string: oklch(0.5 0.12 160); --sh-class: oklch(0.55 0.14 280); --sh-property: oklch(0.5 0.12 240); --sh-entity: oklch(0.48 0.1 200); --sh-jsxliterals: oklch(0.52 0.13 330); --sh-sign: oklch(0.45 0.03 250); --sh-comment: oklch(0.55 0.02 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.58 0.22 27); --destructive-foreground: oklch(1 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.809 0.105 251.813); --chart-2: oklch(0.623 0.214 259.815); --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); --radius: 0.825rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --tracking-normal: 0em; --spacing: 0.25rem; --chart-background: oklch(1 0 0); --chart-foreground: oklch(0.145 0 0); --chart-foreground-muted: oklch(0.556 0 0); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.4 0 0); --chart-grid: oklch(0.92 0 0); --chart-tooltip-background: oklch(0.205 0 0 / 0.92); --chart-tooltip-foreground: oklch(0.985 0 0); --chart-tooltip-muted: oklch(0.7 0 0); --chart-marker-background: oklch(0.985 0 0); --chart-marker-border: oklch(0.922 0 0); --chart-marker-foreground: oklch(0.205 0 0); --chart-ring-background: oklch(0.75 0 0 / 0.2); --chart-label: oklch(0.45 0 0); } .neutraldark { /* Sugar-high syntax highlighting - Neutral dark theme */ --sh-identifier: oklch(0.85 0.03 250); --sh-keyword: oklch(0.75 0.12 25); --sh-string: oklch(0.72 0.14 160); --sh-class: oklch(0.78 0.16 280); --sh-property: oklch(0.75 0.14 240); --sh-entity: oklch(0.73 0.12 200); --sh-jsxliterals: oklch(0.76 0.14 330); --sh-sign: oklch(0.7 0.04 250); --sh-comment: oklch(0.55 0.03 250); --font-be-vietnam-pro: 'Be Vietnam Pro'; --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.87 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.371 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(1 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.809 0.105 251.813); --chart-2: oklch(0.623 0.214 259.815); --chart-3: oklch(0.546 0.245 262.881); --chart-4: oklch(0.488 0.243 264.376); --chart-5: oklch(0.424 0.199 265.638); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); --radius: 0.825rem; --shadow-x: 0; --shadow-y: 1px; --shadow-blur: 3px; --shadow-spread: 0px; --shadow-opacity: 0.1; --shadow-color: oklch(0 0 0); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --tracking-normal: 0em; --spacing: 0.25rem; --chart-background: oklch(0.145 0 0); --chart-foreground: oklch(0.985 0 0); --chart-foreground-muted: oklch(0.708 0 0); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.6 0 0); --chart-grid: oklch(0.32 0 0); --chart-tooltip-background: oklch(0.205 0 0 / 0.95); --chart-tooltip-foreground: oklch(0.985 0 0); --chart-tooltip-muted: oklch(0.708 0 0); --chart-marker-background: oklch(0.205 0 0); --chart-marker-border: oklch(0.371 0 0); --chart-marker-foreground: oklch(0.985 0 0); --chart-ring-background: oklch(0.6 0 0 / 0.2); --chart-label: oklch(0.8 0 0); } @theme inline { --font-be-vietnam-pro: var(--font-be-vietnam-pro); --font-baumans: var(--font-baumans); --font-pixel: var(--font-geist-pixel-square); --font-pixel-grid: var(--font-geist-pixel-grid); --font-instrument-serif: var(--font-instrument-serif); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --shadow-2xs: var(--shadow-2xs); --shadow-xs: var(--shadow-xs); --shadow-sm: var(--shadow-sm); --shadow: var(--shadow); --shadow-md: var(--shadow-md); --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); --shadow-2xl: var(--shadow-2xl); --color-chart-label: var(--chart-label); --color-chart-ring-background: var(--chart-ring-background); --color-chart-marker-foreground: var(--chart-marker-foreground); --color-chart-marker-border: var(--chart-marker-border); --color-chart-marker-background: var(--chart-marker-background); --color-chart-tooltip-muted: var(--chart-tooltip-muted); --color-chart-tooltip-foreground: var(--chart-tooltip-foreground); --color-chart-tooltip-background: var(--chart-tooltip-background); --color-chart-grid: var(--chart-grid); --color-chart-crosshair: var(--chart-crosshair); --color-chart-line-secondary: var(--chart-line-secondary); --color-chart-line-primary: var(--chart-line-primary); --color-chart-foreground-muted: var(--chart-foreground-muted); --color-chart-foreground: var(--chart-foreground); --color-chart-background: var(--chart-background); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } /* Prevent iOS scroll bounce issues with fixed positioned elements */ body { /* Prevent iOS from shrinking viewport height when scrolling */ -webkit-overflow-scrolling: touch; overscroll-behavior: none; } /* Mobile viewport height fixes */ @media screen and (max-width: 1024px) { /* Fix for iOS viewport height issues */ .h-screen { height: 100vh; height: 100dvh; /* Dynamic viewport height for better mobile support */ } /* Ensure fixed elements work properly with viewport changes */ .fixed { /* Force hardware acceleration */ -webkit-transform: translateZ(0); transform: translateZ(0); } /* Better height handling for mobile containers */ body { /* Use dynamic viewport height on mobile */ height: 100dvh; min-height: 100dvh; } } /* iOS Safari specific fixes */ @supports (-webkit-touch-callout: none) { .h-screen { height: -webkit-fill-available; } body { height: -webkit-fill-available; min-height: -webkit-fill-available; } } /* Ensure fixed elements maintain position during scroll */ @media screen and (max-width: 1024px) { /* Prevent viewport height changes from affecting fixed elements */ .fixed { /* Force hardware acceleration for smoother rendering */ transform: translateZ(0); -webkit-transform: translateZ(0); /* Prevent iOS bounce from affecting positioning */ -webkit-backface-visibility: hidden; backface-visibility: hidden; } /* Ensure safe area calculations are consistent */ .fixed.bottom-0 { /* Use padding instead of margin for more reliable positioning */ padding-bottom: env(safe-area-inset-bottom); } } /* Hide Leaflet attribution globally */ .leaflet-control-attribution { display: none !important; } /* Polished Leaflet zoom control (light/dark aware) */ .leaflet-control-zoom, .custom-zoom-control.leaflet-bar { border: 1px solid hsl(var(--border)); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; background: rgba(255, 255, 255, 0.85); backdrop-filter: saturate(180%) blur(6px); } .dark .leaflet-control-zoom, .dark .custom-zoom-control.leaflet-bar { border-color: hsl(var(--border)); background: rgba(9, 9, 11, 0.6); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35); } .leaflet-control-zoom a, .custom-zoom-control .zoom-btn { width: 30px; height: 30px; line-height: 30px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 600; color: hsl(var(--foreground)); background: transparent; border: none; border-bottom: 1px solid hsl(var(--border)); transition: background-color 150ms ease, color 150ms ease; } .leaflet-control-zoom a:last-child, .custom-zoom-control .zoom-btn:last-child { border-bottom: none; } .leaflet-control-zoom a:hover, .custom-zoom-control .zoom-btn:hover { background: rgba(0, 0, 0, 0.04); } .dark .leaflet-control-zoom a:hover, .dark .custom-zoom-control .zoom-btn:hover { background: rgba(255, 255, 255, 0.06); } .leaflet-control-zoom a:active, .custom-zoom-control .zoom-btn:active { background: rgba(0, 0, 0, 0.08); } .dark .leaflet-control-zoom a:active, .dark .custom-zoom-control .zoom-btn:active { background: rgba(255, 255, 255, 0.12); } .leaflet-control-zoom a:focus, .custom-zoom-control .zoom-btn:focus { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; } .leaflet-control-zoom a.leaflet-disabled, .custom-zoom-control .zoom-btn:disabled { opacity: 0.5; cursor: not-allowed; background: transparent !important; } /* Touch-friendly sizing */ .leaflet-touch .leaflet-control-zoom a, .leaflet-touch .custom-zoom-control .zoom-btn { width: 34px; height: 34px; line-height: 34px; font-size: 18px; } /* Divider between zoom buttons */ .custom-zoom-control .divider { height: 1px; background: hsl(var(--border)); } /* Thin horizontal scrollbar for the map's place-card scroller */ .nearby-search-map::-webkit-scrollbar { height: 8px; } .nearby-search-map::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.25); border-radius: 9999px; } .nearby-search-map::-webkit-scrollbar-track { background: transparent; } /* Fade-in-up animation for page sections */ @keyframes fadeInUp { 0% { opacity: 0; transform: translateY(16px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes slideInFromRight { 0% { opacity: 0; transform: translateX(12px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes pulse-subtle { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } @keyframes grain { 0%, 100% { transform: translate(0, 0); } 10% { transform: translate(-5%, -10%); } 20% { transform: translate(-15%, 5%); } 30% { transform: translate(7%, -25%); } 40% { transform: translate(-5%, 25%); } 50% { transform: translate(-15%, 10%); } 60% { transform: translate(15%, 0%); } 70% { transform: translate(0%, 15%); } 80% { transform: translate(3%, 35%); } 90% { transform: translate(-10%, 10%); } } .animate-fade-in-up { animation: fadeInUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; opacity: 0; } .animate-fade-in { animation: fadeIn 0.5s ease-out forwards; opacity: 0; } .animate-slide-in-right { animation: slideInFromRight 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards; opacity: 0; } .animate-pulse-subtle { animation: pulse-subtle 3s ease-in-out infinite; } /* Staggered animation delays */ .delay-100 { animation-delay: 100ms; } .delay-200 { animation-delay: 200ms; } .delay-300 { animation-delay: 300ms; } .delay-400 { animation-delay: 400ms; } .delay-500 { animation-delay: 500ms; } .delay-600 { animation-delay: 600ms; } .delay-700 { animation-delay: 700ms; } .delay-800 { animation-delay: 800ms; } /* Pixel grid decorative background */ .pixel-grid-bg { background-image: linear-gradient(to right, hsl(var(--border) / 0.3) 1px, transparent 1px), linear-gradient(to bottom, hsl(var(--border) / 0.3) 1px, transparent 1px); background-size: 24px 24px; } /* Grain texture overlay */ .grain-overlay::before { content: ''; position: absolute; inset: 0; opacity: 0.03; pointer-events: none; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); background-repeat: repeat; animation: grain 8s steps(10) infinite; } /* ============================================================================= Canvas mode animations ============================================================================= */ /* Count-up animation for Metric values */ @property --canvas-num { syntax: ''; initial-value: 0; inherits: false; } .canvas-count-up { --canvas-num: var(--target, 0); animation: canvasCountUp 800ms ease-out forwards; counter-reset: canvasNum var(--canvas-num); } .canvas-count-up::after { content: counter(canvasNum); } @keyframes canvasCountUp { from { --canvas-num: 0; } } /* Staggered fade-in for canvas spec children */ @keyframes canvasFadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .canvas-stagger > * { opacity: 0; animation: canvasFadeInUp 400ms ease-out forwards; } .canvas-stagger > *:nth-child(1) { animation-delay: 0ms; } .canvas-stagger > *:nth-child(2) { animation-delay: 60ms; } .canvas-stagger > *:nth-child(3) { animation-delay: 120ms; } .canvas-stagger > *:nth-child(4) { animation-delay: 180ms; } .canvas-stagger > *:nth-child(5) { animation-delay: 240ms; } .canvas-stagger > *:nth-child(6) { animation-delay: 300ms; } .canvas-stagger > *:nth-child(7) { animation-delay: 360ms; } .canvas-stagger > *:nth-child(8) { animation-delay: 420ms; } .canvas-stagger > *:nth-child(9) { animation-delay: 480ms; } .canvas-stagger > *:nth-child(10) { animation-delay: 540ms; } .canvas-stagger > *:nth-child(n + 11) { animation-delay: 600ms; } /* -------------------------------- Sileo Toast Overrides -------------------------------- */ /* Override state colors so badge/title contrast well against --foreground fill */ [data-sileo-toast] :is([data-sileo-badge], [data-sileo-title])[data-state] { --_c: var(--background); --sileo-tone: var(--_c); --sileo-tone-bg: color-mix(in oklch, var(--_c) 20%, transparent); } /* Description text */ [data-sileo-description] { color: color-mix(in oklch, var(--background) 65%, transparent); } /* Button inside toast */ [data-sileo-button][data-state] { --_c: var(--background); --sileo-btn-color: var(--_c); --sileo-btn-bg: color-mix(in oklch, var(--_c) 15%, transparent); --sileo-btn-bg-hover: color-mix(in oklch, var(--_c) 25%, transparent); } /* Sugar-high syntax highlighting styles */ ================================================ FILE: app/layout.tsx ================================================ import './globals.css'; import 'katex/dist/katex.min.css'; import 'leaflet/dist/leaflet.css'; import { Metadata, Viewport } from 'next'; import { Be_Vietnam_Pro, Baumans, Geist, Instrument_Serif } from 'next/font/google'; import { GeistPixelSquare, GeistPixelGrid } from 'geist/font/pixel'; import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { Toaster } from '@/components/ui/sileo-toaster'; import { SidebarProvider } from '@/components/ui/sidebar'; import { NewChatHotkey } from '@/components/new-chat-hotkey'; import { ClientAnalytics } from '@/components/client-analytics'; import { HapticsProvider } from '@/components/haptics-provider'; import { Providers } from './providers'; export const metadata: Metadata = { metadataBase: new URL('https://scira.ai'), title: { default: 'Scira AI - Research anything. Do anything.', template: '%s | Scira AI', }, description: 'Scira is an AI assistant that searches the web in depth, cites sources, and connects to 100+ apps including GitHub, Notion, and Slack.', openGraph: { url: 'https://scira.ai', siteName: 'Scira AI', }, keywords: [ 'agentic research platform', 'agentic research', 'agentic search', 'agentic search engine', 'agentic search platform', 'agentic search tool', 'agentic search tool', 'scira.ai', 'free ai search', 'ai search', 'ai research tool', 'ai search tool', 'perplexity ai alternative', 'perplexity alternative', 'chatgpt alternative', 'ai search engine', 'search engine', 'scira ai', 'Scira AI', 'scira AI', 'SCIRA.AI', 'scira github', 'ai search engine', 'Scira', 'scira', 'scira.app', 'scira ai', 'scira ai app', 'scira', 'MiniPerplx', 'Scira AI', 'Perplexity alternatives', 'Perplexity AI alternatives', 'open source ai search engine', 'minimalistic ai search engine', 'minimalistic ai search alternatives', 'ai search', 'minimal ai search', 'minimal ai search alternatives', 'Scira (Formerly MiniPerplx)', 'AI Search Engine', 'mplx.run', 'mplx ai', 'zaid mukaddam', 'scira.how', 'search engine', 'AI', 'perplexity', ], robots: { index: true, follow: true, googleBot: { index: true, follow: true, }, }, alternates: { canonical: 'https://scira.ai', }, }; export const viewport: Viewport = { width: 'device-width', initialScale: 1, minimumScale: 1, maximumScale: 1, userScalable: false, viewportFit: 'cover', themeColor: [ { media: '(prefers-color-scheme: light)', color: '#F9F9F9' }, { media: '(prefers-color-scheme: dark)', color: '#111111' }, ], }; const beVietnamPro = Be_Vietnam_Pro({ subsets: ['latin'], variable: '--font-be-vietnam-pro', preload: true, display: 'swap', weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], }); const baumans = Baumans({ subsets: ['latin'], variable: '--font-baumans', preload: true, display: 'swap', weight: ['400'], }); const geist = Geist({ subsets: ['latin'], variable: '--font-sans', preload: true, display: 'swap', weight: ['400', '500', '600', '700'], }); const instrumentSerif = Instrument_Serif({ subsets: ['latin'], variable: '--font-instrument-serif', preload: true, display: 'swap', weight: ['400'], style: ['normal', 'italic'], }); export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: app/lookout/components/action-buttons.tsx ================================================ 'use client'; import React from 'react'; import { HugeiconsIcon } from '@/components/ui/hugeicons'; import { PauseIcon, PlayIcon, Archive01Icon, Delete02Icon, TestTubeIcon } from '@hugeicons/core-free-icons'; import { Button } from '@/components/ui/button'; import { BorderTrail } from '@/components/core/border-trail'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; interface ActionButtonsProps { lookoutId: string; status: 'active' | 'paused' | 'running' | 'archived'; isMutating?: boolean; onStatusChange: (id: string, status: 'active' | 'paused' | 'archived' | 'running') => void; onDelete: (id: string) => void; onTest: (id: string) => void; } export function ActionButtons({ lookoutId, status, isMutating = false, onStatusChange, onDelete, onTest, }: ActionButtonsProps) { const handleStatusChange = (newStatus: 'active' | 'paused' | 'archived' | 'running') => { onStatusChange(lookoutId, newStatus); }; const handleDelete = () => { onDelete(lookoutId); }; const handleTest = () => { onTest(lookoutId); }; // Don't show actions for archived lookouts in main view - they only get delete if (status === 'archived') { return (

Delete lookout

); } return (
{/* Primary action button - pause/resume/running indicator */} {status === 'active' && (

Pause lookout

)} {status === 'paused' && (

Resume lookout

)} {status === 'running' && (

Lookout is currently running

)} {/* Test button */}

{status === 'running' ? 'Cannot test while running' : 'Test lookout now'}

{/* Archive button */}

{status === 'running' ? 'Cannot archive while running' : 'Archive lookout'}

{/* Delete button */}

{status === 'running' ? 'Cannot delete while running' : 'Delete lookout'}

); } ================================================ FILE: app/lookout/components/empty-state.tsx ================================================ 'use client'; import React from 'react'; import { HugeiconsIcon } from '@/components/ui/hugeicons'; import { BinocularsIcon, Archive01Icon } from '@hugeicons/core-free-icons'; import { Card, CardContent } from '@/components/ui/card'; interface EmptyStateProps { icon?: any; title: string; description: string; children?: React.ReactNode; variant?: 'default' | 'dashed'; } export function EmptyState({ icon = BinocularsIcon, title, description, children, variant = 'dashed', }: EmptyStateProps) { return (

{title}

{description}

{children}
); } // Preset empty states for common scenarios export function NoActiveLookoutsEmpty() { return ( ); } export function NoArchivedLookoutsEmpty() { return ( ); } ================================================ FILE: app/lookout/components/index.ts ================================================ // Component exports for easier importing export { StatusBadge } from './status-badge'; export { LookoutSkeleton, LoadingSkeletons } from './loading-skeleton'; export { EmptyState, NoActiveLookoutsEmpty, NoArchivedLookoutsEmpty } from './empty-state'; export { WarningCard, TotalLimitWarning, DailyLimitWarning } from './warning-card'; export { ActionButtons } from './action-buttons'; export { LookoutCard } from './lookout-card'; export { LookoutDetailsSidebar } from './lookout-details-sidebar'; export { ProUpgradeScreen } from './pro-upgrade-screen'; export { LookoutForm } from './lookout-form'; export { TimezoneSelector } from './timezone-selector'; export { TimePicker } from './time-picker'; ================================================ FILE: app/lookout/components/loading-skeleton.tsx ================================================ 'use client'; import React from 'react'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; interface LoadingSkeletonProps { count?: number; showActions?: boolean; } export function LookoutSkeleton({ showActions = true }: { showActions?: boolean }) { return (
{showActions && }
{/* Prompt preview skeleton */}
{/* Status and run info footer */}
); } export function LoadingSkeletons({ count = 3, showActions = true }: LoadingSkeletonProps) { // Ensure count is a positive number to prevent rendering issues const validCount = Math.max(0, count || 3); if (validCount === 0) { return null; } return (
{Array.from({ length: validCount }).map((_, index) => ( ))}
); } ================================================ FILE: app/lookout/components/lookout-card.tsx ================================================ 'use client'; import React from 'react'; import Link from 'next/link'; import { HugeiconsIcon } from '@/components/ui/hugeicons'; import { BinocularsIcon, ArrowRight01Icon } from '@hugeicons/core-free-icons'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { BorderTrail } from '@/components/core/border-trail'; import { StatusBadge } from './status-badge'; import { RunStatusBadge, type LookoutRunStatus } from './run-status-badge'; import { ActionButtons } from './action-buttons'; import { formatNextRun } from '../utils/time-utils'; interface LookoutRun { runAt: string; chatId: string; status: LookoutRunStatus; error?: string; duration?: number; tokensUsed?: number; searchesPerformed?: number; } interface Lookout { id: string; title: string; prompt: string; frequency: string; timezone: string; nextRunAt: Date; status: 'active' | 'paused' | 'archived' | 'running'; lastRunAt?: Date | null; lastRunChatId?: string | null; runHistory?: LookoutRun[]; createdAt: Date; cronSchedule?: string; } interface LookoutCardProps { lookout: Lookout; isMutating?: boolean; onStatusChange: (id: string, status: 'active' | 'paused' | 'archived' | 'running') => void; onDelete: (id: string) => void; onTest: (id: string) => void; onOpenDetails: (lookout: Lookout) => void; showActions?: boolean; } export function LookoutCard({ lookout, isMutating = false, onStatusChange, onDelete, onTest, onOpenDetails, showActions = true, }: LookoutCardProps) { const lastRunStatus = React.useMemo(() => { const history = lookout.runHistory ?? []; if (!history.length) return null; return history[history.length - 1]?.status ?? null; }, [lookout.runHistory]); const handleCardClick = () => { onOpenDetails(lookout); }; const handleActionClick = (e: React.MouseEvent) => { e.stopPropagation(); }; return ( {/* Border trail for running lookouts */} {lookout.status === 'running' && ( )} {lookout.title} {showActions && (
)}
{/* Prompt preview */}

{lookout.prompt.slice(0, 100)}{lookout.prompt.length > 100 ? '...' : ''}

{/* Status and run info footer */}
{lastRunStatus && lookout.lastRunAt && }
{/* Next run information */} {lookout.nextRunAt && lookout.status === 'active' && (

Next: {formatNextRun(lookout.nextRunAt, lookout.timezone)}

)} {/* Last run information */} {lookout.lastRunAt && (

Last: {formatNextRun(lookout.lastRunAt, lookout.timezone)}

{lookout.lastRunChatId && ( e.stopPropagation()} > View )}
)} {/* Completed state for once frequency */} {!lookout.lastRunAt && lookout.frequency === 'once' && lookout.status === 'paused' && (

Completed

)}
); } ================================================ FILE: app/lookout/components/lookout-details-sidebar.tsx ================================================ 'use client'; import React from 'react'; import { format } from 'date-fns'; import { HugeiconsIcon } from '@/components/ui/hugeicons'; import { Activity01Icon, CheckmarkCircle01Icon, ArrowUpRightIcon, Chart01Icon, Settings01Icon, PlayIcon, AlertCircleIcon, Cancel01Icon, TestTubeIcon, } from '@hugeicons/core-free-icons'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { Button } from '@/components/ui/button'; import { BorderTrail } from '@/components/core/border-trail'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import Link from 'next/link'; import { LOOKOUT_SEARCH_MODES } from '../constants'; interface LookoutRun { runAt: string; chatId: string; status: 'success' | 'error' | 'timeout'; error?: string; duration?: number; tokensUsed?: number; searchesPerformed?: number; } interface LookoutWithHistory { id: string; title: string; prompt: string; frequency: string; timezone: string; nextRunAt: Date; status: 'active' | 'paused' | 'archived' | 'running'; searchMode?: string; lastRunAt?: Date | null; lastRunChatId?: string | null; runHistory: LookoutRun[]; createdAt: Date; updatedAt: Date; } interface LookoutDetailsSidebarProps { lookout: LookoutWithHistory; allLookouts: LookoutWithHistory[]; isOpen: boolean; onOpenChange: (open: boolean) => void; onLookoutChange?: (lookout: LookoutWithHistory) => void; onEditLookout?: (lookout: LookoutWithHistory) => void; onTest?: (id: string) => void; } export function LookoutDetailsSidebar({ lookout, allLookouts, isOpen, onOpenChange, onLookoutChange, onEditLookout, onTest, }: LookoutDetailsSidebarProps) { const modeConfig = React.useMemo(() => { const resolvedMode = lookout.searchMode || 'extreme'; return LOOKOUT_SEARCH_MODES.find((m) => m.value === resolvedMode) || null; }, [lookout.searchMode]); const runHistory = lookout.runHistory || []; const totalRuns = runHistory.length; const successfulRuns = runHistory.filter((run) => run.status === 'success').length; const failedRuns = runHistory.filter((run) => run.status === 'error').length; const successRate = totalRuns > 0 ? (successfulRuns / totalRuns) * 100 : 0; const averageDuration = runHistory.length > 0 ? runHistory.reduce((sum, run) => sum + (run.duration || 0), 0) / runHistory.length : 0; const lastWeekRuns = runHistory.filter( (run) => new Date(run.runAt) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), ).length; const runningLookouts = allLookouts.filter((l) => l.status === 'running'); const [showAnalytics, setShowAnalytics] = React.useState(false); const getStatusIcon = (status: string) => { const iconMap: Record = { success: { icon: CheckmarkCircle01Icon, className: 'text-green-500' }, error: { icon: Cancel01Icon, className: 'text-red-500' }, timeout: { icon: AlertCircleIcon, className: 'text-yellow-500' }, }; const config = iconMap[status] || { icon: Activity01Icon, className: 'text-muted-foreground' }; return ; }; const getStatusBadge = (status: string) => { switch (status) { case 'active': return Active; case 'paused': return Paused; case 'running': return ( Running ); case 'archived': return Archived; default: return {status}; } }; return (
{showAnalytics ? ( /* Analytics View */

Performance

{[ { label: 'Success Rate', value: `${successRate.toFixed(1)}%` }, { label: 'Avg Duration', value: averageDuration > 0 ? `${(averageDuration / 1000).toFixed(1)}s` : 'N/A' }, { label: 'Total Runs', value: `${totalRuns}` }, { label: 'Failed', value: `${failedRuns}`, className: failedRuns > 0 ? 'text-red-500' : '' }, ].map((item) => (
{item.label} {item.value}
))}

Activity

{[ { label: 'This Week', value: `${lastWeekRuns} runs` }, { label: 'Frequency', value: lookout.frequency }, { label: 'Timezone', value: lookout.timezone }, { label: 'Status', value: lookout.status }, ].map((item) => (
{item.label} {item.value}
))}
{failedRuns > 0 && (

Recent Errors

{runHistory .filter((run) => run.status === 'error') .slice(-3) .map((run, index) => (

{format(new Date(run.runAt), 'MMM d, h:mm a')}

{run.error || 'Unknown error'}

))}
)}
) : ( /* Normal View */ <> {/* Currently Running Lookouts */} {runningLookouts.length > 0 && (

Running ({runningLookouts.length})

{runningLookouts.map((runningLookout) => ( ))}
)} {/* Basic Info */}

{lookout.title}

{getStatusBadge(lookout.status)} {lookout.frequency}

Created {format(new Date(lookout.createdAt), 'MMM d, yyyy')}

{lookout.nextRunAt && lookout.status === 'active' && (

Next {format(new Date(lookout.nextRunAt), 'MMM d, h:mm a')}

)}

{lookout.prompt}

{modeConfig && (
{modeConfig.label} · {modeConfig.description}
)}
{/* Statistics */}

Stats

Runs

{totalRuns}

Success

{successRate.toFixed(0)}%

This Week

{lastWeekRuns}

Avg Time

{averageDuration > 0 ? `${(averageDuration / 1000).toFixed(1)}s` : '—'}

{/* Recent Runs */}

Runs ({runHistory.length})

{runHistory.length > 0 ? ( runHistory .slice(-10) .reverse() .map((run, index) => (
{getStatusIcon(run.status)}
{format(new Date(run.runAt), 'MMM d, h:mm a')} {run.duration && ( {(run.duration / 1000).toFixed(1)}s )}
{run.error &&

{run.error}

} {typeof run.searchesPerformed === 'number' && (

{run.searchesPerformed} searches

)}
{run.status === 'success' && ( )}
)) ) : (

No runs yet

)}
)}
{/* Footer */}

{lookout.status === 'running' ? 'Cannot edit while running' : 'Edit lookout settings'}

{lookout.status === 'running' ? 'Cannot test while running' : lookout.status === 'archived' ? 'Cannot test archived' : 'Run test now'}

{showAnalytics ? 'Show overview' : 'Show analytics'}

); } ================================================ FILE: app/lookout/components/lookout-form.tsx ================================================ 'use client'; import React from 'react'; import { format } from 'date-fns'; import { HugeiconsIcon } from '@/components/ui/hugeicons'; import { Calendar01Icon, AlarmClockIcon } from '@hugeicons/core-free-icons'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Calendar } from '@/components/ui/calendar'; import { ProgressRing } from '@/components/ui/progress-ring'; import { cn } from '@/lib/utils'; import { TimezoneSelector } from './timezone-selector'; import { TimePicker } from './time-picker'; import { frequencyOptions, dayOfWeekOptions, LOOKOUT_LIMITS, LOOKOUT_SEARCH_MODES } from '../constants'; import { LookoutFormHookReturn } from '../hooks/use-lookout-form'; interface LookoutFormProps { formHook: LookoutFormHookReturn; isMutating: boolean; activeDailyLookouts: number; totalLookouts: number; canCreateMore: boolean; canCreateDailyMore: boolean; createLookout: any; updateLookout: any; } export function LookoutForm({ formHook, isMutating, activeDailyLookouts, totalLookouts, canCreateMore, canCreateDailyMore, createLookout, updateLookout, }: LookoutFormProps) { const { selectedFrequency, selectedTime, selectedTimezone, selectedDate, selectedDayOfWeek, selectedSearchMode, selectedExample, editingLookout, setSelectedFrequency, setSelectedTime, setSelectedTimezone, setSelectedDate, setSelectedDayOfWeek, setSelectedSearchMode, createLookoutFromForm, updateLookoutFromForm, } = formHook; const handleSubmit = (formData: FormData) => { if (editingLookout) { updateLookoutFromForm(formData, updateLookout); } else { createLookoutFromForm(formData, createLookout); } }; const isSubmitDisabled = isMutating || (!editingLookout && selectedFrequency === 'daily' && !canCreateDailyMore) || (!editingLookout && !canCreateMore); return (
{/* Title */}
{/* Instructions */}