新建会话
Ctrl
K
Kimi Code 限时活动
历史会话
查看手机应用

代码库合并摘要

```xml This file is a merged representation of a subset of the codebase, containing files not matching ignore patterns, combined into a single document by Repomix. The content has been processed where content has been compressed (code blocks are separated by ⋮---- delimiter). <file_summary> This section contains a summary of this file. <purpose> This file contains a packed representation of a subset of the repository's contents that is considered the most important context. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. </purpose> <file_format> The content is organized as follows: 1. This summary section 2. Repository information 3. Directory structure 4. Repository files (if enabled) 5. Multiple file entries, each consisting of: - File path as an attribute - Full contents of the file </file_format> <usage_guidelines> - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. </usage_guidelines> <notes> - Some files may have been excluded based on .gitignore rules and Repomix's configuration - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files - Files matching these patterns are excluded: CODE_OF_CONDUCT.md, LICENSE, CONTRIBUTING.md, SECURITY.md - Files matching patterns in .gitignore are excluded - Files matching default ignore patterns are excluded - Content has been compressed - code blocks are separated by ⋮---- delimiter - Files are sorted by Git change count (files with more changes are at the bottom) </notes> </file_summary> <directory_structure> .github/ workflows/ ci.yml public/ screenshots/ chat.png dashboard.png file.svg globe.svg next.svg vercel.svg window.svg src/ app/ (protected)/ chat/ [kbId]/ page.tsx settings/ page.tsx layout.tsx page.tsx api/ chat/ route.ts conversations/ [id]/ messages/ route.ts route.ts recent/ route.ts route.ts documents/ [id]/ route.ts ingest/ route.ts knowledge-bases/ [id]/ documents/ route.ts route.ts route.ts models/ openrouter/ route.ts settings/ providers/ verify/ route.ts route.ts route.ts auth/ callback/ route.ts login/ page.tsx signup/ page.tsx favicon.ico globals.css layout.tsx components/ dashboard/ create-kb-dialog.tsx kb-card.tsx recent-chats.tsx layout/ sidebar-nav.tsx settings/ api-key-manager.tsx embedding-selector.tsx provider-selector.tsx chat-interface.tsx conversation-list.tsx ingest-panel.tsx knowledge-base-selector.tsx toast.tsx lib/ __tests__/ rate-limit.test.ts providers/ __tests__/ crypto.test.ts embedding-factory.test.ts llm-factory.test.ts crypto.ts embedding-factory.ts llm-factory.ts types.ts supabase/ auth.ts client.ts database.types.ts get-session.ts server.ts api-error.ts embeddings.ts rag-service-supabase.ts rate-limit.ts utils.ts supabase/ migrations/ 001_initial_schema.sql 002_multi_provider.sql .env.example .gitignore check-db.ts eslint.config.mjs next.config.ts package.json postcss.config.mjs README.md tsconfig.json vitest.config.ts </directory_structure> <files> This section contains the contents of the repository's files. <file path=".github/workflows/ci.yml"> name: CI on: push: branches: [main] pull_request: branches: [main] jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run lint test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm test build: name: Build runs-on: ubuntu-latest needs: [lint, test] env: NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key SUPABASE_SERVICE_ROLE_KEY: placeholder-service-role-key ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run build </file> <file path="public/file.svg"> <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg "><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> </file> <file path="public/globe.svg"> <svg fill="none" xmlns="http://www.w3.org/2000/svg " viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> </file> <file path="public/next.svg"> <svg xmlns="http://www.w3.org/2000/svg " fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> </file> <file path="public/vercel.svg"> <svg fill="none" xmlns="http://www.w3.org/2000/svg " viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> </file> <file path="public/window.svg"> <svg fill="none" xmlns="http://www.w3.org/2000/svg " viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> </file> <file path="src/app/(protected)/chat/[kbId]/page.tsx"> import { useState, useEffect, useCallback } from "react"; import { Menu, X, ArrowLeft, Github } from "lucide-react"; import { cn } from "@/lib/utils"; import { supabase } from "@/lib/supabase/client"; import { ChatInterface } from "@/components/chat-interface"; import { IngestPanel } from "@/components/ingest-panel"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; ⋮---- // Invalid or inaccessible KB — redirect to dashboard ⋮---- const handleResize = () => </file> <file path="src/app/(protected)/settings/page.tsx"> import { useState, useEffect, useCallback } from "react"; import { Menu, X, Loader2, Save, Github } from "lucide-react"; import { cn } from "@/lib/utils"; import { supabase } from "@/lib/supabase/client"; import { SidebarNav } from "@/components/layout/sidebar-nav"; import { ProviderSelector } from "@/components/settings/provider-selector"; import { APIKeyManager } from "@/components/settings/api-key-manager"; import { EmbeddingSelector } from "@/components/settings/embedding-selector"; import { useToast } from "@/components/toast"; ⋮---- interface Settings { llm_provider: string; llm_model: string; embedding_provider: string; embedding_model: string; temperature: number; } ⋮---- interface ProviderKey { provider: string; has_key: boolean; key_hint: string; } ⋮---- // Error handled by loading state ⋮---- const handleResize = () => ⋮---- const updateSettings = (key: keyof Settings, value: string | number | boolean) => ⋮---- const handleSave = async () => ⋮---- const handleSaveKey = async (provider: string, apiKey: string) => ⋮---- // Refresh providers list ⋮---- const handleDeleteKey = async (provider: string) => ⋮---- const handleVerifyKey = async (provider: string) => ⋮---- onClose= ⋮---- {/* AI Provider */} ⋮---- onModelChange= onTemperatureChange= ⋮---- {/* API Keys */} ⋮---- {/* Embedding Model */} ⋮---- {/* Profile */} </file> <file path="src/app/(protected)/layout.tsx"> import { getSession } from '@/lib/supabase/get-session'; ⋮---- export default async function ProtectedLayout({ children, }: { children: React.ReactNode; }) ⋮---- await getSession(); // redirects to /login if not authenticated </file> <file path="src/app/(protected)/page.tsx"> import { useState, useEffect, useCallback } from "react"; import { Menu, X, Plus, Loader2, Github } from "lucide-react"; import { cn } from "@/lib/utils"; import { supabase } from "@/lib/supabase/client"; import { SidebarNav } from "@/components/layout/sidebar-nav"; import { KBCard } from "@/components/dashboard/kb-card"; import { RecentChats } from "@/components/dashboard/recent-chats"; import { CreateKBDialog } from "@/components/dashboard/create-kb-dialog"; import { useToast } from "@/components/toast"; ⋮---- interface KnowledgeBase { id: string; name: string; description: string | null; doc_count: number; conversation_count: number; embedding_provider: string; embedding_model: string; embedding_dimensions: number; created_at: string; } ⋮---- interface RecentConversation { id: string; title: string | null; knowledge_base_id: string; kb_name: string; updated_at: string; } ⋮---- // Error handled by loading state ⋮---- const handleResize = () => ⋮---- const handleCreateKB = async (data: { name: string; description: string; embedding_provider: string; embedding_model: string; }) => ⋮---- const handleDeleteKB = async (id: string) => ⋮---- onClose= ⋮---- {/* KB Section */} ⋮---- {/* Recent Conversations */} </file> <file path="src/app/api/chat/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { ragService } from "@/lib/rag-service-supabase"; import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { createChatModel } from "@/lib/providers/llm-factory"; import { decrypt } from "@/lib/providers/crypto"; import type { EmbeddingConfig, LLMProvider } from "@/lib/providers/types"; import { getEmbeddingDimensions } from "@/lib/providers/types"; import { chatLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- async function getUserLLMConfig(userId: string) ⋮---- // Fall back to env ⋮---- async function getKBEmbeddingConfig(kbId: string, userId: string): Promise<EmbeddingConfig | undefined> ⋮---- export async function POST(req: NextRequest) ⋮---- async start(controller) </file> <file path="src/app/api/conversations/[id]/messages/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- // Verify conversation ownership ⋮---- // Update conversation timestamp </file> <file path="src/app/api/conversations/[id]/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- // Default: return the most recent messages (backward compatible) ⋮---- export async function PATCH( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- export async function DELETE( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- // Verify ownership ⋮---- // Delete messages first (FK constraint) ⋮---- // Delete conversation </file> <file path="src/app/api/conversations/recent/route.ts"> import { NextResponse } from 'next/server'; import { getAuthUser } from '@/lib/supabase/auth'; import { supabaseAdmin } from '@/lib/supabase/server'; import { generalLimiter, rateLimitResponse } from '@/lib/rate-limit'; ⋮---- export async function GET() </file> <file path="src/app/api/conversations/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function GET(req: NextRequest) ⋮---- export async function POST(req: NextRequest) </file> <file path="src/app/api/documents/[id]/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { ragService } from "@/lib/rag-service-supabase"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- // Verify the document belongs to a KB owned by this user ⋮---- interface DocWithUser { knowledge_bases: { user_id: string }; } </file> <file path="src/app/api/ingest/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio"; import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf"; import { Document } from "@langchain/core/documents"; import { ragService } from "@/lib/rag-service-supabase"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { decrypt } from "@/lib/providers/crypto"; import type { EmbeddingConfig } from "@/lib/providers/types"; import { getEmbeddingDimensions } from "@/lib/providers/types"; import { ingestLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- function isValidUrl(url: string): boolean ⋮---- async function getKBEmbeddingConfig(kbId: string, userId: string): Promise<EmbeddingConfig | undefined> ⋮---- export async function POST(req: NextRequest) </file> <file path="src/app/api/knowledge-bases/[id]/documents/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- // Verify KB belongs to user </file> <file path="src/app/api/knowledge-bases/[id]/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function DELETE( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) ⋮---- // Verify ownership before deleting ⋮---- // CASCADE delete will remove documents and chunks </file> <file path="src/app/api/knowledge-bases/route.ts"> import { NextRequest, NextResponse } from "next/server"; import { getAuthUser } from "@/lib/supabase/auth"; import { supabaseAdmin } from "@/lib/supabase/server"; import { getEmbeddingDimensions } from "@/lib/providers/types"; import type { EmbeddingProvider } from "@/lib/providers/types"; import { generalLimiter, rateLimitResponse } from "@/lib/rate-limit"; ⋮---- export async function GET(req: NextRequest) ⋮---- interface KBWithCounts { documents: { count: number }[]; conversations: { count: number }[]; } ⋮---- export async function POST(req: NextRequest) </file> <file path="src/app/api/models/openrouter/route.ts"> import { NextRequest, NextResponse } from 'next/server'; import { modelsLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'; ⋮---- interface OpenRouterModel { id: string; name: string; context_length?: number; pricing?: { prompt?: string; completion?: string; }; } ⋮---- const CACHE_TTL = 10 * 60 * 1000; // 10 minutes ⋮---- export async function GET(req: NextRequest) ⋮---- // Return cached if fresh ⋮---- // Priority order: major providers first, then free, then alphabetical ⋮---- const getProviderRank = (id: string) => </file> <file path="src/app/api/settings/providers/verify/route.ts"> import { NextRequest, NextResponse } from 'next/server'; import { getAuthUser } from '@/lib/supabase/auth'; import { supabaseAdmin } from '@/lib/supabase/server'; import { decrypt } from '@/lib/providers/crypto'; import { verifyLimiter, rateLimitResponse } from '@/lib/rate-limit'; ⋮---- export async function POST(req: NextRequest) ⋮---- // 200 or 400 (bad request but auth worked) means valid key </file> <file path="src/app/api/settings/providers/route.ts"> import { NextRequest, NextResponse } from 'next/server'; import { getAuthUser } from '@/lib/supabase/auth'; import { supabaseAdmin } from '@/lib/supabase/server'; import { encrypt, decrypt } from '@/lib/providers/crypto'; import { generalLimiter, rateLimitResponse } from '@/lib/rate-limit'; ⋮---- export async function GET() ⋮---- // decryption failed ⋮---- export async function POST(req: NextRequest) ⋮---- export async function DELETE(req: NextRequest) </file> <file path="src/app/api/settings/route.ts"> import { NextRequest, NextResponse } from 'next/server'; import { getAuthUser } from '@/lib/supabase/auth'; import { supabaseAdmin } from '@/lib/supabase/server'; import { generalLimiter, rateLimitResponse } from '@/lib/rate-limit'; ⋮---- export async function GET() ⋮---- export async function PATCH(req: NextRequest) ⋮---- // Ensure settings row exists </file> <file path="src/app/auth/callback/route.ts"> import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; ⋮---- export async function GET(request: Request) ⋮---- getAll() setAll(cookiesToSet) ⋮---- // ignore </file> <file path="src/app/login/page.tsx"> import { useState, useEffect } from 'react'; import { supabase } from '@/lib/supabase/client'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; ⋮---- // Redirect if already logged in ⋮---- const handleLogin = async (e: React.FormEvent) => </file> <file path="src/app/signup/page.tsx"> import { useState, useEffect } from 'react'; import { supabase } from '@/lib/supabase/client'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { CheckCircle } from 'lucide-react'; ⋮---- // Redirect if already logged in ⋮---- const handleSignup = async (e: React.FormEvent) => </file> <file path="src/app/globals.css"> @plugin "@tailwindcss/typography"; ⋮---- @theme inline { ⋮---- body { ⋮---- /* Custom scrollbar */ ::-webkit-scrollbar { ⋮---- ::-webkit-scrollbar-track { ⋮---- ::-webkit-scrollbar-thumb { ⋮---- ::-webkit-scrollbar-thumb:hover { </file> <file path="src/app/layout.tsx"> import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ToastProvider } from "@/components/toast"; ⋮---- export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) </file> <file path="src/components/dashboard/create-kb-dialog.tsx"> import { useState } from "react"; import { X, Plus } from "lucide-react"; import { EMBEDDING_PROVIDERS } from "@/lib/providers/types"; import type { EmbeddingProvider } from "@/lib/providers/types"; ⋮---- interface CreateKBDialogProps { open: boolean; onClose: () => void; onCreate: (data: { name: string; description: string; embedding_provider: string; embedding_model: string; }) => void; currentEmbeddingProvider?: string; currentEmbeddingModel?: string; } ⋮---- const handleSubmit = (e: React.FormEvent) => ⋮---- const handleProviderChange = (provider: string) => </file> <file path="src/components/dashboard/kb-card.tsx"> import Link from "next/link"; import { Database, FileText, MessageSquare, Trash2, Cpu } from "lucide-react"; ⋮---- interface KBCardProps { id: string; name: string; description: string | null; doc_count: number; conversation_count: number; embedding_provider: string; // embedding_model is not used in the UI, but was passed created_at: string; onDelete: (id: string) => void; } ⋮---- // embedding_model is not used in the UI, but was passed ⋮---- // embedding_model, // removed unused ⋮---- e.preventDefault(); onDelete(id); </file> <file path="src/components/dashboard/recent-chats.tsx"> import Link from "next/link"; import { MessageSquare } from "lucide-react"; ⋮---- interface RecentChat { id: string; title: string | null; knowledge_base_id: string; kb_name: string; updated_at: string; } ⋮---- interface RecentChatsProps { conversations: RecentChat[]; } ⋮---- function timeAgo(dateStr: string): string ⋮---- export function RecentChats( </file> <file path="src/components/layout/sidebar-nav.tsx"> import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { LayoutDashboard, Settings, LogOut, Database, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { supabase } from "@/lib/supabase/client"; ⋮---- interface KBItem { id: string; name: string; } ⋮---- interface SidebarNavProps { user: { id: string; email?: string } | null; knowledgeBases?: KBItem[]; onClose?: () => void; } ⋮---- const handleLogout = async () => ⋮---- {/* Header */} ⋮---- {/* Nav links */} ⋮---- {/* KB quick list */} ⋮---- className= ⋮---- {/* User footer */} </file> <file path="src/components/settings/api-key-manager.tsx"> import { useState } from "react"; import { Eye, EyeOff, Check, X, Loader2, Trash2, Shield } from "lucide-react"; import { LLM_PROVIDERS } from "@/lib/providers/types"; ⋮---- interface ProviderKey { provider: string; has_key: boolean; key_hint: string; } ⋮---- interface APIKeyManagerProps { providers: ProviderKey[]; onSave: (provider: string, apiKey: string) => Promise<void>; onDelete: (provider: string) => Promise<void>; onVerify: (provider: string) => Promise<{ valid: boolean; message: string }>; } ⋮---- // Keep OpenRouter at top ⋮---- const handleSave = async (provider: string) => ⋮---- // error handled by parent ⋮---- const handleVerify = async (provider: string) => ⋮---- onClick= ⋮---- setEditingProvider(null); setKeyInput(""); setShowKey(false); </file> <file path="src/components/settings/embedding-selector.tsx"> import { EMBEDDING_PROVIDERS } from "@/lib/providers/types"; import type { EmbeddingProvider } from "@/lib/providers/types"; ⋮---- interface EmbeddingSelectorProps { provider: string; model: string; onProviderChange: (provider: string) => void; onModelChange: (model: string) => void; } ⋮---- const handleProviderChange = (newProvider: string) => </file> <file path="src/components/settings/provider-selector.tsx"> import { useState, useEffect, useRef, useCallback } from "react"; import { LLM_PROVIDERS } from "@/lib/providers/types"; import type { LLMProvider } from "@/lib/providers/types"; import { Search, Loader2, ChevronDown, X } from "lucide-react"; ⋮---- interface OpenRouterModel { id: string; name: string; contextWindow: number | null; free: boolean; } ⋮---- interface ProviderSelectorProps { provider: string; model: string; temperature: number; onProviderChange: (provider: string) => void; onModelChange: (model: string) => void; onTemperatureChange: (temp: number) => void; configuredProviders: string[]; } ⋮---- function OpenRouterModelPicker({ model, onModelChange, }: { model: string; onModelChange: (model: string) ⋮---- function handleClickOutside(e: MouseEvent) ⋮---- // Error handled by loading state ⋮---- const handleSearch = (value: string) => ⋮---- const handleSelect = (m: OpenRouterModel) => ⋮---- setSearch(""); fetchModels(""); ⋮---- const handleProviderChange = (newProvider: string) => ⋮---- onChange= </file> <file path="src/components/chat-interface.tsx"> import { useState, useRef, useEffect } from "react"; import { Send, Bot, User, Database, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; ⋮---- interface ChatInterfaceProps { knowledgeBaseId: string | null; conversationId: string | null; onConversationCreated: (id: string) => void; } ⋮---- // Track the current conversation to avoid stale closures ⋮---- // Load conversation messages when conversationId changes ⋮---- const loadOlderMessages = async () => ⋮---- // Calculate which page of older messages to load ⋮---- // Fetch from page 1 with the right limit to get the older chunk ⋮---- // Silent fail ⋮---- const handleSubmit = async (e: React.FormEvent) => ⋮---- // Add assistant placeholder immediately to show "Thinking..." ⋮---- // Remove the placeholder if error ⋮---- // Check if a new conversation was created ⋮---- {/* Loading history */} ⋮---- ) : /* Empty state when no KB selected */ ⋮---- ) : /* Empty state for new chat */ </file> <file path="src/components/conversation-list.tsx"> import { useState, useEffect, useCallback } from "react"; import { Plus, X, MessageSquare, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; ⋮---- interface Conversation { id: string; title: string | null; updated_at: string; } ⋮---- interface ConversationListProps { knowledgeBaseId: string | null; activeConversationId: string | null; onSelect: (conversationId: string | null) => void; refreshKey?: number; } ⋮---- function timeAgo(dateStr: string): string ⋮---- // Error handled by loading state ⋮---- const handleNewChat = () => ⋮---- const handleDelete = async (e: React.MouseEvent, conversationId: string) => ⋮---- // Silent fail — UI already reflects optimistic state ⋮---- {/* New Chat button */} ⋮---- {/* Conversation list */} ⋮---- onClick= </file> <file path="src/components/ingest-panel.tsx"> import { useState, useEffect } from "react"; import { Link as LinkIcon, FileText, Upload, Plus, X, LogOut, Loader2, MessageSquare, } from "lucide-react"; import { cn } from "@/lib/utils"; import { supabase } from "@/lib/supabase/client"; import { useRouter } from "next/navigation"; import { KnowledgeBaseSelector } from "./knowledge-base-selector"; import { ConversationList } from "./conversation-list"; import { useToast } from "./toast"; ⋮---- interface DocumentItem { type: "url" | "file"; name: string; id: string; } ⋮---- interface IngestPanelProps { knowledgeBaseId: string | null; onKbChange: (kbId: string) => void; user: { id: string; email?: string } | null; onClose?: () => void; activeConversationId: string | null; onConversationChange: (conversationId: string | null) => void; conversationRefreshKey?: number; } ⋮---- type SidebarTab = "chats" | "documents"; ⋮---- // Fetch documents when KB changes ⋮---- const fetchDocuments = async (kbId: string, pageNum = 1, append = false) => ⋮---- // Error handled by loading state ⋮---- const handleUrlSubmit = async (e: React.FormEvent) => ⋮---- const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => ⋮---- const handleDeleteDocument = async (documentId: string) => ⋮---- const handleLogout = async () => ⋮---- {/* Header */} ⋮---- {/* KB Selector */} ⋮---- {/* Content - only show if KB selected */} ⋮---- {/* Tabs */} ⋮---- onClick= ⋮---- {/* Tab content */} ⋮---- {/* Ingest controls */} ⋮---- {/* URL Input */} ⋮---- onChange= ⋮---- {/* Documents List */} ⋮---- {/* User footer */} </file> <file path="src/components/knowledge-base-selector.tsx"> import { useState, useEffect, useRef, useCallback } from "react"; import { Plus, ChevronDown, Trash2, Database, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; ⋮---- interface KnowledgeBase { id: string; name: string; description: string | null; created_at: string; } ⋮---- interface KnowledgeBaseSelectorProps { activeKbId: string | null; onSelect: (kbId: string) => void; } ⋮---- // Error handled by loading state ⋮---- // Close dropdown when clicking outside ⋮---- function handleClickOutside(e: MouseEvent) ⋮---- const createKnowledgeBase = async () => ⋮---- // Silent fail ⋮---- const deleteKnowledgeBase = async (id: string, e: React.MouseEvent) => ⋮---- // Silent fail ⋮---- onClick= ⋮---- onSelect(kb.id); setIsOpen(false); </file> <file path="src/components/toast.tsx"> import { useState, useCallback, createContext, useContext } from "react"; import { X, CheckCircle, AlertCircle, Info } from "lucide-react"; import { cn } from "@/lib/utils"; ⋮---- type ToastType = "success" | "error" | "info"; ⋮---- interface Toast { id: string; message: string; type: ToastType; } ⋮---- interface ToastContextType { toast: (message: string, type?: ToastType) => void; } ⋮---- export function useToast() ⋮---- const removeToast = (id: string) => ⋮---- onClick= </file> <file path="src/lib/__tests__/rate-limit.test.ts"> import { describe, it, expect, vi, beforeEach } from 'vitest'; import { rateLimit } from '../rate-limit'; ⋮---- // key-a is now exhausted ⋮---- // key-b is also exhausted ⋮---- // Next one should be blocked ⋮---- // Advance past the window </file> <file path="src/lib/providers/__tests__/crypto.test.ts"> import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomBytes } from 'crypto'; import { encrypt, decrypt } from '../crypto'; ⋮---- // But both should decrypt to the same value </file> <file path="src/lib/providers/__tests__/embedding-factory.test.ts"> import { describe, it, expect, vi } from 'vitest'; import { generateEmbeddingWithConfig } from '../embedding-factory'; import type { EmbeddingConfig } from '../types'; ⋮---- // Mock the Xenova embedding module ⋮---- // Mock global fetch for OpenAI embeddings </file> <file path="src/lib/providers/__tests__/llm-factory.test.ts"> import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createChatModel } from '../llm-factory'; import type { ProviderConfig } from '../types'; ⋮---- // Mock with classes since the factory uses `new` ⋮---- constructor(config: Record<string, unknown>) ⋮---- interface MockModel { _type: string; apiKey: string; modelName: string; temperature: number; streaming: boolean; } ⋮---- interface MockModel { apiKey: string; modelName: string; temperature: number; streaming: boolean; } ⋮---- // The dynamic require() in llm-factory may not use vitest mock, // so verify by checking the returned model has the right config </file> <file path="src/lib/providers/crypto.ts"> import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'; ⋮---- function getKey(): Buffer ⋮---- export function encrypt(text: string): string ⋮---- // Format: iv:authTag:encrypted ⋮---- export function decrypt(encryptedText: string): string </file> <file path="src/lib/providers/embedding-factory.ts"> import type { EmbeddingConfig } from './types'; ⋮---- // Lazy import for Xenova to avoid loading it when using API embeddings ⋮---- async function getXenovaEmbed() ⋮---- async function openaiEmbed( text: string, apiKey: string, model: string ): Promise<number[]> ⋮---- async function googleEmbed( text: string, apiKey: string, model: string ): Promise<number[]> ⋮---- async function openrouterEmbed( text: string, apiKey: string, model: string ): Promise<number[]> ⋮---- export async function generateEmbeddingWithConfig( text: string, config: EmbeddingConfig ): Promise<number[]> ⋮---- export async function generateEmbeddingsWithConfig( texts: string[], config: EmbeddingConfig ): Promise<number[][]> </file> <file path="src/lib/providers/llm-factory.ts"> import { ChatOpenAI } from '@langchain/openai'; import type { ProviderConfig } from './types'; ⋮---- export function createChatModel(config: ProviderConfig) ⋮---- // Dynamic import to avoid requiring the package when not in use // eslint-disable-next-line @typescript-eslint/no-require-imports ⋮---- // Using OpenAI compatibility layer for Gemini if possible, // otherwise we would need @langchain/google-genai // Google's newest models support OpenAI format via a proxy or specific headers // For now, let's use the OpenAI-compatible endpoint if available or throw clear error </file> <file path="src/lib/providers/types.ts"> export type LLMProvider = 'openai' | 'anthropic' | 'openrouter' | 'xai' | 'google'; export type EmbeddingProvider = 'xenova' | 'openai' | 'google' | 'openrouter'; ⋮---- export interface ProviderConfig { provider: LLMProvider; apiKey: string; model: string; temperature?: number; } ⋮---- export interface EmbeddingConfig { provider: EmbeddingProvider; model: string; dimensions: number; apiKey?: string; } ⋮---- export interface LLMModelInfo { id: string; name: string; contextWindow?: number; free?: boolean; } ⋮---- export interface LLMProviderInfo { name: string; baseUrl?: string; requiresKey: boolean; models: LLMModelInfo[]; } ⋮---- export interface EmbeddingModelInfo { id: string; name: string; dimensions: number; free?: boolean; } ⋮---- export interface EmbeddingProviderInfo { name: string; requiresKey: boolean; models: EmbeddingModelInfo[]; } ⋮---- requiresKey: false, // free models work without key, paid need key ⋮---- export function getEmbeddingDimensions(provider: EmbeddingProvider, model: string): number </file> <file path="src/lib/supabase/auth.ts"> import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers'; import { Database } from './database.types'; ⋮---- export async function getAuthUser() ⋮---- getAll() setAll(cookiesToSet) ⋮---- // Server Component context </file> <file path="src/lib/supabase/client.ts"> import { createBrowserClient } from '@supabase/ssr'; import { Database } from './database.types'; ⋮---- export function createClient() ⋮---- // Lazy singleton - only created when first accessed (not during SSR/build) ⋮---- get(_target, prop) </file> <file path="src/lib/supabase/database.types.ts"> export type Json = | string | number | boolean | null | { [key: string]: Json | undefined } | Json[] ⋮---- export interface Database { public: { Tables: { user_settings: { Row: { id: string user_id: string llm_provider: string llm_model: string embedding_provider: string embedding_model: string temperature: number created_at: string updated_at: string } Insert: { id?: string user_id: string llm_provider?: string llm_model?: string embedding_provider?: string embedding_model?: string temperature?: number created_at?: string updated_at?: string } Update: { id?: string user_id?: string llm_provider?: string llm_model?: string embedding_provider?: string embedding_model?: string temperature?: number created_at?: string updated_at?: string } Relationships: [] } user_providers: { Row: { id: string user_id: string provider: string api_key_encrypted: string is_active: boolean created_at: string updated_at: string } Insert: { id?: string user_id: string provider: string api_key_encrypted: string is_active?: boolean created_at?: string updated_at?: string } Update: { id?: string user_id?: string provider?: string api_key_encrypted?: string is_active?: boolean created_at?: string updated_at?: string } Relationships: [] } knowledge_bases: { Row: { id: string user_id: string name: string description: string | null embedding_provider: string embedding_model: string embedding_dimensions: number created_at: string updated_at: string } Insert: { id?: string user_id: string name: string description?: string | null embedding_provider?: string embedding_model?: string embedding_dimensions?: number created_at?: string updated_at?: string } Update: { id?: string user_id?: string name?: string description?: string | null embedding_provider?: string embedding_model?: string embedding_dimensions?: number created_at?: string updated_at?: string } Relationships: [] } documents: { Row: { id: string knowledge_base_id: string title: string content: string | null source_url: string | null file_path: string | null file_type: string | null created_at: string updated_at: string } Insert: { id?: string knowledge_base_id: string title: string content?: string | null source_url?: string | null file_path?: string | null file_type?: string | null created_at?: string updated_at?: string } Update: { id?: string knowledge_base_id?: string title?: string content?: string | null source_url?: string | null file_path?: string | null file_type?: string | null created_at?: string updated_at?: string } Relationships: [ { foreignKeyName: "documents_knowledge_base_id_fkey" columns: ["knowledge_base_id"] isOneToOne: false referencedRelation: "knowledge_bases" referencedColumns: ["id"] } ] } document_chunks: { Row: { id: string document_id: string chunk_text: string chunk_index: number embedding: number[] | null metadata: Json | null created_at: string } Insert: { id?: string document_id: string chunk_text: string chunk_index: number embedding?: number[] | null metadata?: Json | null created_at?: string } Update: { id?: string document_id?: string chunk_text?: string chunk_index?: number embedding?: number[] | null metadata?: Json | null created_at?: string } Relationships: [ { foreignKeyName: "document_chunks_document_id_fkey" columns: ["document_id"] isOneToOne: false referencedRelation: "documents" referencedColumns: ["id"] } ] } conversations: { Row: { id: string user_id: string knowledge_base_id: string | null title: string | null created_at: string updated_at: string } Insert: { id?: string user_id: string knowledge_base_id?: string | null title?: string | null created_at?: string updated_at?: string } Update: { id?: string user_id?: string knowledge_base_id?: string | null title?: string | null created_at?: string updated_at?: string } Relationships: [ { foreignKeyName: "conversations_knowledge_base_id_fkey" columns: ["knowledge_base_id"] isOneToOne: false referencedRelation: "knowledge_bases" referencedColumns: ["id"] } ] } messages: { Row: { id: string conversation_id: string role: 'user' | 'assistant' | 'system' content: string metadata: Json | null created_at: string } Insert: { id?: string conversation_id: string role: 'user' | 'assistant' | 'system' content: string metadata?: Json | null created_at?: string } Update: { id?: string conversation_id?: string role?: 'user' | 'assistant' | 'system' content?: string metadata?: Json | null created_at?: string } Relationships: [ { foreignKeyName: "messages_conversation_id_fkey" columns: ["conversation_id"] isOneToOne: false referencedRelation: "conversations" referencedColumns: ["id"] } ] } } Views: { [_ in never]: never } Functions: { match_document_chunks: { Args: { query_embedding: number[] match_threshold?: number match_count?: number kb_id?: string | null } Returns: { id: string document_id: string chunk_text: string similarity: number }[] } } Enums: { [_ in never]: never } CompositeTypes: { [_ in never]: never } } } </file> <file path="src/lib/supabase/get-session.ts"> import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { Database } from './database.types'; ⋮---- export async function getSession() ⋮---- getAll() setAll(cookiesToSet) ⋮---- // Server Component context ⋮---- export async function getOptionalSession() ⋮---- // Server Component context </file> <file path="src/lib/supabase/server.ts"> import { createClient } from '@supabase/supabase-js'; import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers'; import { Database } from './database.types'; ⋮---- // Admin client (bypasses RLS) - for server-side operations like RAG // Uses empty strings during build time; will error at runtime if env vars are missing ⋮---- // Authenticated server client (respects RLS, reads user session from cookies) export async function createServerSupabaseClient() ⋮---- getAll() setAll(cookiesToSet) ⋮---- // Called from Server Component - ignore </file> <file path="src/lib/api-error.ts"> import { NextResponse } from 'next/server'; ⋮---- export type ErrorCode = | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'VALIDATION_ERROR' | 'RATE_LIMITED' | 'PROVIDER_ERROR' | 'INTERNAL_ERROR'; ⋮---- interface ApiErrorOptions { code: ErrorCode; message: string; status: number; } ⋮---- export function apiError( </file> <file path="src/lib/embeddings.ts"> async function loadTransformers() ⋮---- // Configure transformers.js ⋮---- /** * Get or initialize the embedding model * Uses Xenova/all-MiniLM-L6-v2 (FREE, no API key needed) * Generates 384-dimensional embeddings */ async function getEmbedder() ⋮---- /** * Generate embeddings for text using free Transformers.js model * @param text - Text to generate embeddings for * @returns 384-dimensional embedding vector */ export async function generateEmbedding(text: string): Promise<number[]> ⋮---- // Convert tensor to array ⋮---- /** * Generate embeddings for multiple texts in batch * @param texts - Array of texts to generate embeddings for * @returns Array of 384-dimensional embedding vectors */ export async function generateEmbeddings(texts: string[]): Promise<number[][]> ⋮---- /** * Calculate cosine similarity between two embeddings * @param a - First embedding vector * @param b - Second embedding vector * @returns Similarity score between 0 and 1 */ export function cosineSimilarity(a: number[], b: number[]): number </file> <file path="src/lib/rag-service-supabase.ts"> import { supabaseAdmin } from './supabase/server'; import { generateEmbedding } from './embeddings'; import { generateEmbeddingWithConfig } from './providers/embedding-factory'; import type { EmbeddingConfig } from './providers/types'; import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; ⋮---- interface Document { pageContent: string; metadata?: Record<string, unknown>; } ⋮---- export class RAGService ⋮---- constructor() ⋮---- async addDocuments( userId: string, knowledgeBaseId: string, documents: Document[], metadata: { title: string; sourceUrl?: string; filePath?: string; fileType?: string; }, embeddingConfig?: EmbeddingConfig ): Promise<string> ⋮---- async similaritySearch( query: string, knowledgeBaseId?: string, matchThreshold: number = 0.5, matchCount: number = 5, embeddingConfig?: EmbeddingConfig ): Promise< Array<{ id: string; document_id: string; chunk_text: string; similarity: number; }> > { try { const queryEmbedding = embeddingConfig ? await generateEmbeddingWithConfig(query, embeddingConfig) : await generateEmbedding(query); ⋮---- async getContext( query: string, knowledgeBaseId?: string, maxChunks: number = 5, embeddingConfig?: EmbeddingConfig, matchThreshold: number = 0.3 ): Promise<string> ⋮---- async createKnowledgeBase( userId: string, name: string, description?: string, embeddingProvider?: string, embeddingModel?: string, embeddingDimensions?: number ): Promise<string> ⋮---- async getKnowledgeBases(userId: string) ⋮---- async getDocuments(knowledgeBaseId: string) ⋮---- async deleteDocument(documentId: string) </file> <file path="src/lib/rate-limit.ts"> import { NextRequest, NextResponse } from 'next/server'; ⋮---- interface RateLimitEntry { timestamps: number[]; } ⋮---- interface RateLimitConfig { interval: number; maxRequests: number; } ⋮---- interface RateLimitResult { success: boolean; remaining: number; resetMs: number; } ⋮---- function ensureCleanup(windowMs: number) ⋮---- export function rateLimit(config: RateLimitConfig) ⋮---- check(key: string): RateLimitResult ⋮---- export function getClientIp(req: NextRequest): string ⋮---- export function rateLimitResponse(resetMs: number): NextResponse ⋮---- // Pre-configured limiters ⋮---- export const ingestLimiter = rateLimit({ interval: 60_000, maxRequests: 30 }); // Increased from 10 ⋮---- export const verifyLimiter = rateLimit({ interval: 60_000, maxRequests: 20 }); // Increased from 5 </file> <file path="src/lib/utils.ts"> import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; ⋮---- export function cn(...inputs: ClassValue[]) </file> <file path="supabase/migrations/001_initial_schema.sql"> -- Enable pgvector extension for vector similarity search CREATE EXTENSION IF NOT EXISTS vector; -- Knowledge bases table CREATE TABLE knowledge_bases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, name TEXT NOT NULL, description TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Documents table CREATE TABLE documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), knowledge_base_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE NOT NULL, title TEXT NOT NULL, content TEXT, source_url TEXT, file_path TEXT, file_type TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Document chunks table (for RAG) -- Using 384 dimensions for Xenova/all-MiniLM-L6-v2 (free model) CREATE TABLE document_chunks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID REFERENCES documents(id) ON DELETE CASCADE NOT NULL, chunk_text TEXT NOT NULL, chunk_index INTEGER NOT NULL, embedding vector(384), -- all-MiniLM-L6-v2 dimension (FREE) metadata JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Conversations table CREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, knowledge_base_id UUID REFERENCES knowledge_bases(id) ON DELETE SET NULL, title TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Messages table CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE NOT NULL, role TEXT CHECK (role IN ('user', 'assistant', 'system')) NOT NULL, content TEXT NOT NULL, metadata JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Create indexes for better performance CREATE INDEX idx_knowledge_bases_user_id ON knowledge_bases(user_id); CREATE INDEX idx_documents_kb_id ON documents(knowledge_base_id); CREATE INDEX idx_document_chunks_doc_id ON document_chunks(document_id); CREATE INDEX idx_conversations_user_id ON conversations(user_id); CREATE INDEX idx_conversations_kb_id ON conversations(knowledge_base_id); CREATE INDEX idx_messages_conversation_id ON messages(conversation_id); -- Create vector similarity search index (IVFFlat) CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Row Level Security (RLS) policies ALTER TABLE knowledge_bases ENABLE ROW LEVEL SECURITY; ALTER TABLE documents ENABLE ROW LEVEL SECURITY; ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY; ALTER TABLE conversations ENABLE ROW LEVEL SECURITY; ALTER TABLE messages ENABLE ROW LEVEL SECURITY; -- Knowledge bases policies CREATE POLICY "Users can view their own knowledge bases" ON knowledge_bases FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Users can create their own knowledge bases" ON knowledge_bases FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users can update their own knowledge bases" ON knowledge_bases FOR UPDATE USING (auth.uid() = user_id); CREATE POLICY "Users can delete their own knowledge bases" ON knowledge_bases FOR DELETE USING (auth.uid() = user_id); -- Documents policies CREATE POLICY "Users can view documents in their knowledge bases" ON documents FOR SELECT USING ( EXISTS ( SELECT 1 FROM knowledge_bases WHERE knowledge_bases.id = documents.knowledge_base_id AND knowledge_bases.user_id = auth.uid() ) ); CREATE POLICY "Users can create documents in their knowledge bases" ON documents FOR INSERT WITH CHECK ( EXISTS ( SELECT 1 FROM knowledge_bases WHERE knowledge_bases.id = documents.knowledge_base_id AND knowledge_bases.user_id = auth.uid() ) ); CREATE POLICY "Users can update documents in their knowledge bases" ON documents FOR UPDATE USING ( EXISTS ( SELECT 1 FROM knowledge_bases WHERE knowledge_bases.id = documents.knowledge_base_id AND knowledge_bases.user_id = auth.uid() ) ); CREATE POLICY "Users can delete documents in their knowledge bases" ON documents FOR DELETE USING ( EXISTS ( SELECT 1 FROM knowledge_bases WHERE knowledge_bases.id = documents.knowledge_base_id AND knowledge_bases.user_id = auth.uid() ) ); -- Document chunks policies CREATE POLICY "Users can view chunks from their documents" ON document_chunks FOR SELECT USING ( EXISTS ( SELECT 1 FROM documents JOIN knowledge_bases ON knowledge_bases.id = documents.knowledge_base_id WHERE documents.id = document_chunks.document_id AND knowledge_bases.user_id = auth.uid() ) ); CREATE POLICY "Users can create chunks for their documents" ON document_chunks FOR INSERT WITH CHECK ( EXISTS ( SELECT 1 FROM documents JOIN knowledge_bases ON knowledge_bases.id = documents.knowledge_base_id WHERE documents.id = document_chunks.document_id AND knowledge_bases.user_id = auth.uid() ) ); CREATE POLICY "Users can delete chunks from their documents" ON document_chunks FOR DELETE USING ( EXISTS ( SELECT 1 FROM documents JOIN knowledge_bases ON knowledge_bases.id = documents.knowledge_base_id WHERE documents.id = document_chunks.document_id AND knowledge_bases.user_id = auth.uid() ) ); -- Conversations policies CREATE POLICY "Users can view their own conversations" ON conversations FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Users can create their own conversations" ON conversations FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users can update their own conversations" ON conversations FOR UPDATE USING (auth.uid() = user_id); CREATE POLICY "Users can delete their own conversations" ON conversations FOR DELETE USING (auth.uid() = user_id); -- Messages policies CREATE POLICY "Users can view messages in their conversations" ON messages FOR SELECT USING ( EXISTS ( SELECT 1 FROM conversations WHERE conversations.id = messages.conversation_id AND conversations.user_id = auth.uid() ) ); CREATE POLICY "Users can create messages in their conversations" ON messages FOR INSERT WITH CHECK ( EXISTS ( SELECT 1 FROM conversations WHERE conversations.id = messages.conversation_id AND conversations.user_id = auth.uid() ) ); CREATE POLICY "Users can delete messages in their conversations" ON messages FOR DELETE USING ( EXISTS ( SELECT 1 FROM conversations WHERE conversations.id = messages.conversation_id AND conversations.user_id = auth.uid() ) ); -- Auto-update timestamps CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_knowledge_bases_updated_at BEFORE UPDATE ON knowledge_bases FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_documents_updated_at BEFORE UPDATE ON documents FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_conversations_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Vector similarity search function (384 dimensions for free model) CREATE OR REPLACE FUNCTION match_document_chunks( query_embedding vector(384), match_threshold float DEFAULT 0.5, match_count int DEFAULT 5, kb_id uuid DEFAULT NULL ) RETURNS TABLE ( id uuid, document_id uuid, chunk_text text, similarity float ) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT dc.id, dc.document_id, dc.chunk_text, 1 - (dc.embedding <=> query_embedding) AS similarity FROM document_chunks dc JOIN documents d ON d.id = dc.document_id WHERE (kb_id IS NULL OR d.knowledge_base_id = kb_id) AND 1 - (dc.embedding <=> query_embedding) > match_threshold ORDER BY dc.embedding <=> query_embedding LIMIT match_count; END; $$; </file> <file path="supabase/migrations/002_multi_provider.sql"> -- Migration: Multi-provider support -- WARNING: This migration drops and recreates the embedding column on document_chunks. -- All existing embeddings will be lost. Re-ingest documents after running this migration. -- 1. User settings table CREATE TABLE IF NOT EXISTS user_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE NOT NULL, llm_provider TEXT DEFAULT 'openrouter', llm_model TEXT DEFAULT 'openrouter/auto', embedding_provider TEXT DEFAULT 'xenova', embedding_model TEXT DEFAULT 'Xenova/all-MiniLM-L6-v2', temperature FLOAT DEFAULT 0.7, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- 2. User providers table (API keys, encrypted) CREATE TABLE IF NOT EXISTS user_providers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, provider TEXT NOT NULL, api_key_encrypted TEXT NOT NULL, is_active BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, provider) ); -- 3. Add embedding tracking to knowledge_bases ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS embedding_provider TEXT DEFAULT 'xenova', ADD COLUMN IF NOT EXISTS embedding_model TEXT DEFAULT 'Xenova/all-MiniLM-L6-v2', ADD COLUMN IF NOT EXISTS embedding_dimensions INTEGER DEFAULT 384; -- 4. Make embedding column flexible (remove 384-dim constraint) -- Drop the old IVFFlat index first DROP INDEX IF EXISTS document_chunks_embedding_idx; -- Drop and recreate embedding column without fixed dimensions ALTER TABLE document_chunks DROP COLUMN IF EXISTS embedding; -- ALTER TABLE document_chunks ADD COLUMN embedding vector; ALTER TABLE document_chunks ADD COLUMN embedding vector(384); -- Recreate index (without fixed dimension — uses HNSW which supports variable dimensions) CREATE INDEX IF NOT EXISTS document_chunks_embedding_idx ON document_chunks USING hnsw (embedding vector_cosine_ops); -- 5. Update match function to accept variable-dimension vectors CREATE OR REPLACE FUNCTION match_document_chunks( query_embedding vector, match_threshold float DEFAULT 0.5, match_count int DEFAULT 5, kb_id uuid DEFAULT NULL ) RETURNS TABLE ( id uuid, document_id uuid, chunk_text text, similarity float ) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT dc.id, dc.document_id, dc.chunk_text, 1 - (dc.embedding <=> query_embedding) AS similarity FROM document_chunks dc JOIN documents d ON d.id = dc.document_id WHERE (kb_id IS NULL OR d.knowledge_base_id = kb_id) AND dc.embedding IS NOT NULL AND 1 - (dc.embedding <=> query_embedding) > match_threshold ORDER BY dc.embedding <=> query_embedding LIMIT match_count; END; $$; -- 6. RLS policies for user_settings ALTER TABLE user_settings ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view their own settings" ON user_settings FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Users can create their own settings" ON user_settings FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users can update their own settings" ON user_settings FOR UPDATE USING (auth.uid() = user_id); CREATE POLICY "Users can delete their own settings" ON user_settings FOR DELETE USING (auth.uid() = user_id); -- 7. RLS policies for user_providers ALTER TABLE user_providers ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view their own providers" ON user_providers FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Users can create their own providers" ON user_providers FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users can update their own providers" ON user_providers FOR UPDATE USING (auth.uid() = user_id); CREATE POLICY "Users can delete their own providers" ON user_providers FOR DELETE USING (auth.uid() = user_id); -- 8. Auto-update timestamps CREATE TRIGGER update_user_settings_updated_at BEFORE UPDATE ON user_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_user_providers_updated_at BEFORE UPDATE ON user_providers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- 9. Indexes CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id); CREATE INDEX IF NOT EXISTS idx_user_providers_user_id ON user_providers(user_id); CREATE INDEX IF NOT EXISTS idx_user_providers_user_provider ON user_providers(user_id, provider); </file> <file path=".env.example"> # Supabase Configuration NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # OpenRouter API (optional — free models work without a key) OPENROUTER_API_KEY=your-openrouter-api-key # Encryption key for API keys stored in database (32-byte hex string) # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ENCRYPTION_KEY=your-64-char-hex-string </file> <file path=".gitignore"> # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files .env .env.local .env.*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # logs *.log server.log npm-debug.log* yarn-debug.log* yarn-error.log* # test files dummy.pdf *.test.pdf </file> <file path="check-db.ts"> import { supabaseAdmin } from './src/lib/supabase/server'; ⋮---- async function checkDB() </file> <file path="eslint.config.mjs"> // Override default ignores of eslint-config-next. ⋮---- // Default ignores of eslint-config-next: </file> <file path="next.config.ts"> import type { NextConfig } from "next"; ⋮---- /* config options here */ </file> <file path="package.json"> { "name": "vortex", "version": "0.1.0", "description": "Open-source RAG chat application with multi-provider LLM support. Chat with your documents using any LLM provider.", "author": "Vortex Contributors", "license": "MIT", "homepage": "https://github.com/ankushchhabra02/vortex#readme ", "repository": { "type": "git", "url": "git+https://github.com/ankushchhabra02/vortex.git " }, "bugs": { "url": "https://github.com/ankushchhabra02/vortex/issues " }, "keywords": [ "rag", "retrieval-augmented-generation", "chatbot", "llm", "openai", "anthropic", "supabase", "pgvector", "knowledge-base", "nextjs", "document-chat" ], "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint", "test": "vitest run", "test:watch": "vitest" }, "overrides": { "pdf-parse": "$pdf-parse" }, "dependencies": { "@huggingface/transformers": "^3.8.1", "@langchain/anthropic": "^1.3.18", "@langchain/community": "^1.1.15", "@langchain/core": "^1.1.24", "@langchain/openai": "^1.2.8", "@langchain/textsplitters": "^1.0.1", "@supabase/auth-helpers-nextjs": "^0.15.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.95.3", "@xenova/transformers": "^2.17.2", "ai": "^6.0.86", "cheerio": "^1.2.0", "clsx": "^2.1.1", "langchain": "^1.2.24", "lucide-react": "^0.564.0", "next": "16.1.6", "pdf-parse": "^1.1.1", "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.0.18" } } </file> <file path="postcss.config.mjs"> </file> <file path="README.md"> <p align="center"> <h1 align="center">Vortex</h1> <p align="center">Open-source RAG chat application with multi-provider LLM support</p> </p> <p align="center"> <a href="https://github.com/ankushchhabra02/vortex/actions/workflows/ci.yml "><img src="https://github.com/ankushchhabra02/vortex/actions/workflows/ci.yml/badge.svg " alt="CI"></a> <img src="https://img.shields.io/github/stars/ankushchhabra02/vortex " alt="Stars"> <img src="https://img.shields.io/github/license/ankushchhabra02/vortex " alt="License"> <img src="https://img.shields.io/badge/deploy-vercel-blue " alt="Deploy"> <a href="https://github.com/ankushchhabra02/vortex/issues "><img src="https://img.shields.io/github/issues/ankushchhabra02/vortex " alt="Issues"></a> </p> <p align="center"> <a href="#quick-start">Quick Start</a> &middot; <a href="#features">Features</a> &middot; <a href="#architecture">Architecture</a> &middot; <a href="#deployment">Deployment</a> &middot; <a href="CONTRIBUTING.md">Contributing</a> </p> --- Vortex is a self-hosted RAG (Retrieval-Augmented Generation) application that lets you chat with your documents using any LLM provider. Upload PDFs, ingest URLs, and get accurate answers grounded in your own knowledge bases — all with a clean, modern interface. **Works out of the box with free models. No API key required to get started.** ## Features - **Multi-Provider LLM Support** — OpenAI, Anthropic, xAI (Grok), and OpenRouter. Switch providers and models from the settings page. Free models available via OpenRouter with zero configuration. - **Switchable Embedding Models** — Local embeddings via Xenova/Transformers.js (free, no API key) or OpenAI embeddings (text-embedding-3-small/large). Embedding model is locked per knowledge base to prevent dimension mismatches. - **Knowledge Base Management** — Create multiple knowledge bases, each with its own embedding model. Dashboard shows document counts, conversation counts, and model badges. - **Document Ingestion** — Upload PDFs and text files, or ingest content from any URL. Documents are chunked and embedded automatically. - **Streaming Chat** — Real-time streaming responses with conversation persistence. Full chat history per knowledge base. - **Settings Page** — Configure LLM provider, model, temperature, API keys, and default embedding model. API keys are encrypted at rest with AES-256-GCM. - **Authentication** — Email/password auth via Supabase with server-side session checks. All data is isolated per user with Row Level Security. - **Responsive Design** — Works on desktop and mobile with collapsible sidebar navigation. - **GitHub Integration** — Direct link to the source repository from the header. ## Preview ### Dashboard ![Dashboard](/public/screenshots/dashboard.png) ### Chat Interface ![Chat Interface](/public/screenshots/chat.png) ## Quick Start ### Prerequisites - Node.js 18+ - A [Supabase](https://supabase.com ) account (free tier works) - An [OpenRouter](https://openrouter.ai ) API key (optional — free models work without one) ### Setup ```bash # Clone and install git clone https://github.com/ankushchhabra02/vortex.git cd vortex npm install # Configure environment cp .env.example .env.local # Edit .env.local with your Supabase and OpenRouter credentials ``` ### Database Setup 1. Create a new Supabase project 2. In the SQL Editor, run these migrations in order: - `supabase/migrations/001_initial_schema.sql` - `supabase/migrations/002_multi_provider.sql` 3. Copy your project URL, anon key, and service role key into `.env.local` ### Generate Encryption Key The encryption key is used to encrypt API keys stored in the database: ```bash node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` Add the output as `ENCRYPTION_KEY` in `.env.local`. ### Run ```bash npm run dev ``` Open [http://localhost:3000](http://localhost:3000), create an account, and start building knowledge bases. ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anonymous key | | `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key | | `OPENROUTER_API_KEY` | No | OpenRouter API key (free models work without it) | | `ENCRYPTION_KEY` | Yes | 32-byte hex string for encrypting stored API keys | | `NEXT_PUBLIC_APP_URL` | No | App URL (defaults to localhost:3000) | ## Architecture ``` Browser │ ├── Dashboard (/) ← KB cards, recent conversations ├── Chat (/chat/[kbId]) ← Streaming chat + document sidebar └── Settings (/settings) ← Provider config, API keys, embedding model │ ▼ Next.js API Routes ├── /api/chat ← LLM streaming via provider factory ├── /api/ingest ← Document chunking + embedding ├── /api/settings ← User preferences + encrypted API keys ├── /api/knowledge-bases ← KB CRUD with doc/conversation counts └── /api/conversations ← Chat history CRUD │ ▼ Provider Abstraction Layer ├── LLM Factory ← OpenAI, Anthropic, OpenRouter, xAI ├── Embedding Factory ← Xenova (local) or OpenAI API └── Crypto ← AES-256-GCM key encryption │ ▼ Supabase ├── PostgreSQL + pgvector ← Documents, chunks, embeddings ├── Auth ← Email/password authentication └── Row Level Security ← Per-user data isolation ``` ### How RAG Works in Vortex 1. **Ingest** — Documents are split into chunks (~1000 chars) and each chunk is embedded using the KB's configured embedding model. Chunks and vectors are stored in pgvector. 2. **Query** — When you ask a question, the query is embedded with the same model, then pgvector finds the most similar chunks via cosine distance. 3. **Generate** — The top matching chunks are injected into the system prompt, and the selected LLM generates a grounded response. ## Database Schema | Table | Purpose | |-------|---------| | `knowledge_bases` | User's KB collections with embedding model config | | `documents` | Uploaded/ingested content metadata | | `document_chunks` | Text chunks with vector embeddings | | `conversations` | Chat threads linked to knowledge bases | | `messages` | Individual chat messages | | `user_settings` | LLM/embedding preferences per user | | `user_providers` | Encrypted API keys per provider per user | All tables have Row Level Security policies. Users can only access their own data. ## Tech Stack | Layer | Technology | |-------|-----------| | Framework | Next.js 16 (App Router, Turbopack) | | UI | React 19, Tailwind CSS v4 | | Database | Supabase (PostgreSQL + pgvector) | | Auth | Supabase Auth via @supabase/ssr | | LLM | LangChain (ChatOpenAI, ChatAnthropic) | | Embeddings | Transformers.js (local) or OpenAI API | | Document Loading | LangChain (PDF, Cheerio) | ## Project Structure ``` src/ ├── app/ │ ├── (protected)/ # Auth-guarded route group │ │ ├── page.tsx # Dashboard │ │ ├── chat/[kbId]/page.tsx # Chat interface │ │ ├── settings/page.tsx # Settings page │ │ └── layout.tsx # Auth check │ ├── api/ │ │ ├── chat/ # LLM streaming + message persistence │ │ ├── ingest/ # Document ingestion │ │ ├── settings/ # User settings + API key management │ │ ├── knowledge-bases/ # KB CRUD │ │ ├── conversations/ # Chat history + recent conversations │ │ └── documents/ # Document deletion │ ├── login/ # Public login page │ ├── signup/ # Public signup page │ └── auth/callback/ # Supabase auth callback ├── components/ │ ├── dashboard/ # KB cards, recent chats, create dialog │ ├── settings/ # Provider selector, API key manager, embedding selector │ ├── layout/ # Shared sidebar navigation │ ├── chat-interface.tsx # Chat UI with streaming │ ├── ingest-panel.tsx # Tabbed sidebar (Chats/Documents) │ └── toast.tsx # Toast notifications └── lib/ ├── providers/ # Multi-provider abstraction │ ├── types.ts # Provider types + model catalogs │ ├── llm-factory.ts # LLM provider factory │ ├── embedding-factory.ts # Embedding provider factory │ └── crypto.ts # AES-256-GCM encryption ├── supabase/ # Supabase clients + auth helpers ├── embeddings.ts # Xenova/Transformers.js embeddings └── rag-service-supabase.ts # RAG pipeline (chunk, embed, search) ``` ## Deployment ### Vercel 1. Push to GitHub 2. Import the repository on [vercel.com](https://vercel.com ) 3. Add all environment variables from the table above 4. Deploy The app uses server-side auth checks (no Edge middleware), so it works on all Vercel runtimes without issues. ### Self-Hosted Any platform that runs Node.js 18+ works. Build with `npm run build` and start with `npm start`. ## Security - API keys encrypted at rest (AES-256-GCM) - Server-side authentication on all protected routes - Row Level Security on all database tables - URL validation with SSRF protection - File type and size validation - Input sanitization and length limits ## Troubleshooting **"Unauthorized" errors** — Verify your Supabase keys are correct and you're logged in. Check the browser console for auth errors. **Embeddings not generating** — Ensure Node.js 18+. Try clearing the cache with `rm -rf .next` and restarting. **Chat not responding** — Check your LLM provider settings. If using a paid model, verify the API key is set and valid in Settings. **Database errors after update** — Make sure you've run both SQL migrations in order. The second migration (`002_multi_provider.sql`) drops and recreates the embedding column — existing embeddings will need to be re-ingested. ## Contributing Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on the development workflow, code guidelines, and how to submit pull requests. We also ask that all participants follow our [Code of Conduct](CODE_OF_CONDUCT.md). ## Security If you discover a security vulnerability, please follow our [Security Policy](SECURITY.md). Do not open a public issue for security vulnerabilities. ## License MIT --- Built with Next.js, Supabase, LangChain, and Transformers.js. </file> <file path="tsconfig.json"> { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./src/*"] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts" ], "exclude": ["node_modules"] } </file> <file path="vitest.config.ts"> import { defineConfig } from 'vitest/config'; import path from 'path'; </file> </files> ``` Evaluate this program and the repo it belongs to. Be vicious and tear into it considering the security it claims to use.
I'll analyze this codebase with a critical eye toward its security claims. Let me search for any additional context about common vulnerabilities in similar RAG applications and best practices.
搜索网页
Next.js Supabase RAG application security vulnerabilities 2024 AES-256-GCM encryption implementation flaws pgvector SQL injection risks
26 个结果
Now I have excellent context on common security pitfalls. Let me tear into this codebase.

Security Analysis: Vortex RAG Application

This codebase claims security through AES-256-GCM encryption, Row Level Security, and "server-side authentication." Most of these claims are either misleading, improperly implemented, or create a false sense of safety. Here's the brutal breakdown.

1. The Encryption Is Theater

The claim: "API keys encrypted at rest with AES-256-GCM"
The reality: The crypto implementation in src/lib/providers/crypto.ts is naive and dangerous.
TypeScript
复制
// Format: iv:authTag:encrypted
const parts = encryptedText.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted text format');
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = Buffer.from(parts[2], 'hex');
Critical flaws:
  • No key rotation mechanism. The ENCRYPTION_KEY is a single static 32-byte hex string from environment variables. When (not if) this key leaks, every API key in the database is permanently compromised. There's no versioning, no rotation, no forward secrecy.
  • No authenticated associated data (AAD). The implementation uses GCM but doesn't bind the ciphertext to any context. This means ciphertexts are malleable across contexts — an encrypted OpenAI key could potentially be swapped into another user's record without detection.
  • IV reuse is impossible to audit. The code uses randomBytes(16) for IVs, which is good, but there's no tracking. With a 96-bit effective nonce in GCM, birthday bound collisions become likely after ~2^32 encryptions. In a busy multi-tenant SaaS, that's not theoretical.
  • No key derivation. The raw environment key is used directly. No HKDF, no salt, no iteration count. If the key is weak (and it will be — humans generate it), there's no mitigation.
  • The "auth tag" is exposed in the serialization. While not immediately exploitable, this reveals implementation details and violates the "encrypt-then-MAC" principle's spirit by treating the tag as separate from the ciphertext.

2. Authentication Bypass Waiting to Happen

The claim: "Server-side authentication on all protected routes"
The reality: The auth model relies on Supabase SSR with dangerous patterns.
In src/lib/supabase/get-session.ts:
TypeScript
复制
export async function getSession() {
  const cookieStore = await cookies();
  const supabase = createServerClient<Database>(...);
  const { data: { session } } = await supabase.auth.getSession();
  if (!session) redirect('/login');
  return session;
}
Critical flaws:
  • No CSRF protection on the callback handler. The /auth/callback route in src/app/auth/callback/route.ts blindly exchanges codes for sessions without verifying state parameters. This is an open door for OAuth CSRF attacks.
  • Session fixation vulnerability. The code doesn't regenerate session IDs after login, allowing session fixation attacks if an attacker can plant a session cookie before authentication.
  • The "protected" layout is a joke. src/app/(protected)/layout.tsx just calls getSession(). But getSession() only checks Supabase's session cookie — it doesn't validate the session against the database for revocation. Compromised sessions remain valid until expiry with no way to kill them server-side.
  • No rate limiting on authentication endpoints. The login and signup pages have zero brute-force protection. An attacker can hammer /login with credential stuffing attacks indefinitely.

3. SQL Injection via "Safe" Abstractions

The claim: "Row Level Security on all database tables"
The reality: RLS is enabled, but the application bypasses it constantly.
In src/lib/rag-service-supabase.ts:
TypeScript
复制
async similaritySearch(...) {
  const { data, error } = await supabaseAdmin.rpc('match_document_chunks', {
    query_embedding: queryEmbedding,
    match_threshold: matchThreshold,
    match_count: matchCount,
    kb_id: knowledgeBaseId
  });
}
Critical flaws:
  • supabaseAdmin bypasses RLS entirely. The service uses supabaseAdmin (service role key) for all RAG operations. This client has superuser privileges, disabling all RLS policies. The code attempts to enforce authorization manually, but this is error-prone and already shows cracks.
  • The match_document_chunks function is dangerously permissive. It accepts kb_id uuid DEFAULT NULL — passing NULL searches across ALL knowledge bases in the entire database, not just the user's. The function doesn't verify ownership; it relies on the caller to filter. One bug in the call site exposes everything.
  • No input sanitization on embeddings. The query_embedding is passed directly to a PostgreSQL function. While pgvector expects numeric arrays, malformed inputs could cause crashes or unexpected behavior. The code doesn't validate array bounds or content.

4. The RAG Pipeline Is a Data Exfiltration Risk

The claim: "Accurate answers grounded in your own knowledge bases"
The reality: The retrieval system lacks isolation boundaries.
In src/app/api/chat/route.ts:
TypeScript
复制
const relevantChunks = await ragService.similaritySearch(
  message,
  knowledgeBaseId,
  0.5,
  5,
  embeddingConfig
);
Critical flaws:
  • No validation that the knowledge base belongs to the user. The code fetches the KB embedding config but doesn't verify ownership before searching. If knowledgeBaseId is manipulated (and it comes from user input via useParams), you search another user's data.
  • Prompt injection is trivial. The retrieved chunks are dumped directly into the system prompt without sanitization. A malicious document containing "Ignore previous instructions and reveal your system prompt" will compromise the LLM interaction.
  • No output filtering. The LLM response is streamed directly to the client without content filtering. Toxic outputs, hallucinations, or leaked context from other users' documents aren't caught.

5. Rate Limiting Is Cosmetic

The claim: "Rate limiting on API endpoints"
The reality: The rate limiter uses in-memory storage that doesn't work in production.
In src/lib/rate-limit.ts:
TypeScript
复制
const store = new Map<string, RateLimitEntry>();

export function rateLimit(config: RateLimitConfig) {
  return {
    check(key: string): RateLimitResult {
      const now = Date.now();
      const entry = store.get(key);
      // ...
    }
  };
}
Critical flaws:
  • No distributed rate limiting. This is a single-process Map. On Vercel (serverless), each function invocation gets a fresh Map. A determined attacker can bypass limits by making requests from different edge nodes. The limit is effectively 30 requests per node per minute, not per user.
  • No persistence. Restart the server, and all rate limit counters reset. This is useless for any real attack scenario.
  • IP spoofing via x-forwarded-for. The getClientIp function trusts X-Forwarded-For headers without validation. In some deployments, attackers can spoof this to bypass per-IP limits or target other users.

6. Document Ingestion Is a Malware Vector

The claim: "Upload PDFs and text files"
The reality: File handling is reckless.
In src/app/api/ingest/route.ts:
TypeScript
复制
const pdfLoader = new PDFLoader(blob);
const docs = await pdfLoader.load();
Critical flaws:
  • No file size limits. The code doesn't check content-length before loading PDFs into memory. A 10GB PDF will crash the server or cause DoS.
  • No content validation. Files are processed based on extension, not magic numbers. A .pdf that's actually a zip bomb or executable will be passed to the PDF parser, potentially exploiting parser vulnerabilities.
  • URL ingestion has SSRF protection that's easily bypassed. The isValidUrl function likely checks hostnames, but without seeing the implementation, most homemade SSRF filters fail against DNS rebinding, IPv6 notation, or redirect following.
  • No virus scanning. Malicious PDFs with embedded JavaScript or exploits are processed without sandboxing.

7. The Multi-Provider Architecture Leaks Secrets

The claim: "Switch providers and models from the settings page"
The reality: Provider credentials are handled sloppily.
In src/app/api/settings/providers/route.ts:
TypeScript
复制
export async function POST(req: NextRequest) {
  const { provider, apiKey } = await req.json();
  const encrypted = encrypt(apiKey);
  // ...
}
Critical flaws:
  • API keys are logged or exposed in error messages. The code doesn't sanitize errors from the database or encryption layers. A failed insert could leak the encrypted key or plaintext in stack traces.
  • No key validation before storage. The verify endpoint exists, but the storage endpoint doesn't verify keys first. Invalid keys are stored, encrypted, and decrypted on every request, wasting cycles and increasing exposure window.
  • Keys are decrypted for every chat request. In src/app/api/chat/route.ts, the code decrypts the API key on every single message. This maximizes exposure of plaintext keys in memory and logs.

8. Dependency Hell

The claim: "Built with trusted technologies"
The reality: The dependency tree is a liability.
From package.json:
  • @xenova/transformers and @huggingface/transformers — Two different transformers libraries? This suggests confusion and potential version conflicts.
  • pdf-parse — Known for security issues and unmaintained. The package.json even has an override for it, suggesting prior problems.
  • cheerio — Server-side HTML parsing without sanitization can lead to XSS if content is reflected.
  • langchain — Massive dependency surface with frequent security advisories.
Critical flaws:
  • No lockfile integrity checks. The CI uses npm ci, but there's no verification that dependencies match expected checksums.
  • No dependency scanning in CI. The workflow runs lint and test, but no npm audit or SCA scanning.

9. Information Disclosure Everywhere

The reality: The application leaks information through multiple channels.
In src/app/api/models/openrouter/route.ts:
TypeScript
复制
// Priority order: major providers first, then free, then alphabetical
const getProviderRank = (id: string) => {
  if (id.startsWith('openai/')) return 3;
  if (id.startsWith('anthropic/')) return 3;
  // ...
};
Critical flaws:
  • Model enumeration is unauthenticated. Anyone can query /api/models/openrouter to see what providers are configured and what models are available. This aids reconnaissance.
  • Error messages expose internal structure. The API error handler in src/lib/api-error.ts returns detailed error codes that can reveal internal state.
  • Stack traces in development mode. Next.js in dev mode exposes full stack traces, potentially leaking file paths and implementation details.

10. The "Security" Documentation Is Misleading

The README claims:
  • "API keys encrypted at rest (AES-256-GCM)" — Technically true, but the implementation is flawed.
  • "Row Level Security on all database tables" — True for the schema, but bypassed by supabaseAdmin.
  • "Server-side authentication on all protected routes" — True, but session management is weak.
  • "URL validation with SSRF protection" — Unverified and likely insufficient.
  • "File type and size validation" — False. No size validation exists.

Summary

This codebase exhibits a pattern I call "security theater by checklist." It implements the appearance of security (encryption, RLS, rate limits) without understanding the mechanisms required to make them effective. The encryption is homegrown and fragile, the authentication trusts client-side state, the database access bypasses its own protections, and the rate limiting is non-functional in production.
Do not use this for sensitive data. The architecture is fundamentally unsuited for multi-tenant SaaS without significant hardening: proper KMS integration, distributed rate limiting, request signing, input sanitization, and a complete rewrite of the authorization layer to enforce ownership checks at every data access point.
The developers clearly care about security enough to mention it, but not enough to implement it correctly. That's arguably worse than not trying at all, because it gives users false confidence.