├── public ├── og.jpg ├── fileX.svg ├── logo.svg ├── stop.svg ├── send.svg ├── 3dots.svg ├── history.svg ├── star.svg ├── new-uploaded-file.svg ├── uploaded-file.svg ├── python.svg ├── upload.svg ├── tooltip.svg ├── github.svg ├── loading.svg ├── new.svg ├── suggestion.svg ├── githubLogo.svg ├── together.svg └── products-100.csv ├── src ├── app │ ├── api │ │ ├── s3-upload │ │ │ └── route.ts │ │ ├── limits │ │ │ └── route.ts │ │ ├── chat │ │ │ ├── history │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── generate-questions │ │ │ └── route.ts │ │ └── coding │ │ │ └── route.ts │ ├── favicon.ico │ ├── layout.tsx │ ├── chat │ │ └── [id] │ │ │ ├── page.tsx │ │ │ └── loading.tsx │ ├── globals.css │ └── page.tsx ├── components │ ├── hero-section.tsx │ ├── ui │ │ ├── ThinkingIndicator.tsx │ │ ├── sonner.tsx │ │ ├── ErrorBanner.tsx │ │ ├── input.tsx │ │ ├── tooltip.tsx │ │ ├── accordion.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx │ ├── chatTools │ │ ├── TerminalOutput.tsx │ │ ├── ErrorOutput.tsx │ │ ├── CodeRunning.tsx │ │ └── ImageFigure.tsx │ ├── question-suggestion-card.tsx │ ├── ChatInput.tsx │ ├── CsvPreviewModal.tsx │ ├── MemoizedMarkdown.tsx │ ├── GithubBanner.tsx │ ├── code-render.tsx │ ├── ModelDropdown.tsx │ ├── ReasoningAccordion.tsx │ ├── TooltipUsage.tsx │ ├── header.tsx │ ├── PromptInput.tsx │ ├── upload-area.tsx │ ├── DropdownFileActions.tsx │ ├── ChatHistoryMenu.tsx │ └── chat-screen.tsx ├── hooks │ ├── useLLMModel.ts │ ├── useDraftedInput.ts │ ├── useLocalStorage.ts │ ├── UserLimitsContext.tsx │ └── useAutoScroll.ts └── lib │ ├── clients.ts │ ├── limits.ts │ ├── models.ts │ ├── utils.ts │ ├── csvUtils.ts │ ├── coding.ts │ ├── chat-store.ts │ └── prompts.ts ├── TODO.md ├── postcss.config.mjs ├── next.config.ts ├── .example.env ├── components.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /public/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/csvtochat/HEAD/public/og.jpg -------------------------------------------------------------------------------- /src/app/api/s3-upload/route.ts: -------------------------------------------------------------------------------- 1 | export { POST } from "next-s3-upload/route"; 2 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Steps to MVP 2 | 3 | - extra: handle context length limit for longer chats 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/csvtochat/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /public/fileX.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | # API Keys 2 | TOGETHER_API_KEY=your_together_api_key 3 | 4 | # Helicone for Logs 5 | HELICONE_API_KEY= 6 | 7 | # S3 AWS Credentials to upload CSV files 8 | S3_UPLOAD_KEY= 9 | S3_UPLOAD_SECRET= 10 | S3_UPLOAD_BUCKET= 11 | S3_UPLOAD_REGION= 12 | 13 | # Upstash Redis 14 | UPSTASH_REDIS_REST_URL= 15 | UPSTASH_REDIS_REST_TOKEN= -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/hero-section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function HeroSection() { 4 | return ( 5 |
6 | 7 | {/* Title */} 8 |

9 | What do you want to analyze? 10 |

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/components/ui/ThinkingIndicator.tsx: -------------------------------------------------------------------------------- 1 | // Thinking indicator component 2 | export function ThinkingIndicator({ thought }: { thought?: string }) { 3 | return ( 4 |
5 | Thinking... 10 | 11 | {thought || "Thinking"} ... 12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /public/stop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/chatTools/TerminalOutput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CodeRender } from "../code-render"; 3 | 4 | interface TerminalOutputProps { 5 | data: string; 6 | } 7 | 8 | export const TerminalOutput: React.FC = ({ data }) => ( 9 |
10 |

11 | Bash Output (stdout): 12 |

13 | 14 |
15 | ); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /src/components/ui/ErrorBanner.tsx: -------------------------------------------------------------------------------- 1 | // ErrorBanner component for custom error and auto resolution prompt messages 2 | export function ErrorBanner({ isWaiting }: { isWaiting: boolean }) { 3 | return ( 4 |
5 | {isWaiting && ( 6 | Loading 11 | )} 12 | 13 | Something went wrong. Please hold tight while we fix things behind the 14 | scenes 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /public/3dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/history.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/api/limits/route.ts: -------------------------------------------------------------------------------- 1 | import { getRemainingMessages } from "@/lib/limits"; 2 | 3 | export async function GET(request: Request) { 4 | const { searchParams } = new URL(request.url); 5 | 6 | // Use IP address as a simple user fingerprint 7 | const ip = request.headers.get("x-forwarded-for") || "unknown"; 8 | 9 | try { 10 | const result = await getRemainingMessages(ip); 11 | return new Response(JSON.stringify(result), { 12 | status: 200, 13 | headers: { "Content-Type": "application/json" }, 14 | }); 15 | } catch (error) { 16 | return new Response(JSON.stringify({ error: (error as Error).message }), { 17 | status: 500, 18 | headers: { "Content-Type": "application/json" }, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/useLLMModel.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter, useSearchParams } from "next/navigation"; 4 | import { CHAT_MODELS } from "@/lib/models"; 5 | 6 | export function useLLMModel() { 7 | const router = useRouter(); 8 | const searchParams = useSearchParams(); 9 | 10 | const models = CHAT_MODELS; 11 | const defaultModel = models.find((m) => m.isDefault)?.slug; 12 | const slugs = models.map((m) => m.slug); 13 | const paramModel = searchParams.get("model"); 14 | const selectedModelSlug = 15 | paramModel && slugs.includes(paramModel) 16 | ? models.find((m) => m.slug === paramModel)?.slug 17 | : defaultModel; 18 | 19 | const setModel = (model: string) => { 20 | const params = new URLSearchParams(Array.from(searchParams.entries())); 21 | params.set("model", model); 22 | router.replace(`?${params.toString()}`, { scroll: false }); 23 | }; 24 | 25 | return { 26 | selectedModelSlug, 27 | setModel, 28 | models, 29 | defaultModel, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/hooks/useDraftedInput.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useDraftedInput( 4 | key: string = "chatInputDraft" 5 | ): [string, (v: string) => void, () => void] { 6 | const [inputValue, setInputValue] = useState(""); 7 | 8 | // Load draft from localStorage on mount 9 | useEffect(() => { 10 | if (typeof window !== "undefined") { 11 | const draft = localStorage.getItem(key); 12 | if (draft) { 13 | setInputValue(draft); 14 | } 15 | } 16 | }, [key]); 17 | 18 | // Save inputValue to localStorage on change 19 | useEffect(() => { 20 | if (typeof window !== "undefined") { 21 | if (inputValue) { 22 | localStorage.setItem(key, inputValue); 23 | } else { 24 | localStorage.removeItem(key); 25 | } 26 | } 27 | }, [inputValue, key]); 28 | 29 | const clearInputValue = () => { 30 | setInputValue(""); 31 | if (typeof window !== "undefined") { 32 | localStorage.removeItem(key); 33 | } 34 | }; 35 | 36 | return [inputValue, setInputValue, clearInputValue]; 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Riccardo Giorato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/new-uploaded-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Instrument_Sans } from 'next/font/google'; 3 | import './globals.css'; 4 | import { Toaster } from 'sonner'; 5 | import { UserLimitsProvider } from '@/hooks/UserLimitsContext'; 6 | import { APP_NAME } from '@/lib/utils'; 7 | import PlausibleProvider from 'next-plausible'; 8 | 9 | const instrumentSans = Instrument_Sans({ 10 | variable: '--font-instrument-sans', 11 | subsets: ['latin'], 12 | }); 13 | 14 | export const metadata: Metadata = { 15 | title: `${APP_NAME}`, 16 | description: 'Talk with your CSV with Together.ai', 17 | openGraph: { 18 | images: ['https://csvtochat.com/og.jpg'], 19 | }, 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/clients.ts: -------------------------------------------------------------------------------- 1 | import Together from "together-ai"; 2 | import { createTogetherAI } from "@ai-sdk/togetherai"; 3 | import { Redis } from "@upstash/redis"; 4 | 5 | const APP_NAME_HELICONE = "csvtochat"; 6 | 7 | const baseSDKOptions: ConstructorParameters[0] = { 8 | apiKey: process.env.TOGETHER_API_KEY, 9 | }; 10 | 11 | if (process.env.HELICONE_API_KEY) { 12 | baseSDKOptions.baseURL = "https://together.helicone.ai/v1"; 13 | baseSDKOptions.defaultHeaders = { 14 | "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`, 15 | "Helicone-Property-Appname": APP_NAME_HELICONE, 16 | }; 17 | } 18 | 19 | export const togetherClient = new Together(baseSDKOptions); 20 | 21 | export const togetherAISDKClient = createTogetherAI({ 22 | apiKey: process.env.TOGETHER_API_KEY, 23 | baseURL: "https://together.helicone.ai/v1", 24 | headers: { 25 | "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`, 26 | "Helicone-Property-AppName": APP_NAME_HELICONE, 27 | }, 28 | }); 29 | 30 | export const codeInterpreter = togetherClient.codeInterpreter; 31 | 32 | export const redis = new Redis({ 33 | url: process.env.UPSTASH_REDIS_REST_URL, 34 | token: process.env.UPSTASH_REDIS_REST_TOKEN, 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/question-suggestion-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card } from "@/components/ui/card"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export function QuestionSuggestionCard({ 7 | question, 8 | onClick, 9 | isLoading, 10 | }: { 11 | question: string; 12 | onClick?: () => void; 13 | isLoading?: boolean; 14 | }) { 15 | return ( 16 | 24 |
25 |
26 | suggestion 31 |
32 |

{question}

33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | function useLocalStorage(key: string, initialValue: T) { 4 | // Get from localStorage then fallback to initialValue 5 | const readValue = () => { 6 | if (typeof window === "undefined") return initialValue; 7 | try { 8 | const item = window.localStorage.getItem(key); 9 | return item ? (JSON.parse(item) as T) : initialValue; 10 | } catch (error) { 11 | return initialValue; 12 | } 13 | }; 14 | 15 | const [storedValue, setStoredValue] = useState(readValue); 16 | 17 | useEffect(() => { 18 | setStoredValue(readValue()); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, [key]); 21 | 22 | const setValue = (value: T | ((val: T) => T)) => { 23 | try { 24 | const valueToStore = 25 | value instanceof Function ? value(storedValue) : value; 26 | setStoredValue(valueToStore); 27 | if (typeof window !== "undefined") { 28 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 29 | } 30 | } catch (error) { 31 | // Ignore write errors 32 | } 33 | }; 34 | 35 | return [storedValue, setValue] as const; 36 | } 37 | 38 | export default useLocalStorage; 39 | -------------------------------------------------------------------------------- /src/lib/limits.ts: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from "@upstash/ratelimit"; 2 | import { Redis } from "@upstash/redis"; 3 | 4 | const redis = 5 | !!process.env.UPSTASH_REDIS_REST_URL && !!process.env.UPSTASH_REDIS_REST_TOKEN 6 | ? new Redis({ 7 | url: process.env.UPSTASH_REDIS_REST_URL, 8 | token: process.env.UPSTASH_REDIS_REST_TOKEN, 9 | }) 10 | : undefined; 11 | 12 | const isLocal = false; // process.env.NODE_ENV !== "production"; 13 | 14 | // 50 messages per day 15 | const ratelimit = 16 | !isLocal && redis 17 | ? new Ratelimit({ 18 | redis: redis, 19 | limiter: Ratelimit.fixedWindow(50, "1 d"), 20 | analytics: true, 21 | }) 22 | : undefined; 23 | 24 | export const getRemainingMessages = async (userFingerPrint: string) => { 25 | if (!ratelimit) return { remaining: 50 }; 26 | const result = await ratelimit.getRemaining(userFingerPrint); 27 | return { 28 | remaining: result.remaining, 29 | reset: result.reset, 30 | }; 31 | }; 32 | 33 | export const limitMessages = async (userFingerPrint: string) => { 34 | if (!ratelimit) return; 35 | const result = await ratelimit.limit(userFingerPrint); 36 | 37 | if (!result.success) { 38 | throw new Error("Too many messages"); 39 | } 40 | 41 | return result; 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/chatTools/ErrorOutput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { CodeRender } from "../code-render"; 3 | 4 | interface ErrorOutputProps { 5 | data: string; 6 | } 7 | 8 | export const ErrorOutput: React.FC = ({ data }) => { 9 | const [expanded, setExpanded] = useState(false); 10 | const lines = data.split("\n"); 11 | const isLong = lines.length > 10; 12 | const preview = lines.slice(0, 10).join("\n"); 13 | 14 | return ( 15 |
16 |

17 | Error during Code Execution 18 |

19 | 24 | {isLong && ( 25 | 32 | )} 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | import { useUserLimits } from "@/hooks/UserLimitsContext"; 5 | import { PromptInput } from "./PromptInput"; 6 | import { UploadedFile } from "@/lib/utils"; 7 | 8 | export function ChatInput({ 9 | isLLMAnswering, 10 | value, 11 | onChange, 12 | onSend, 13 | uploadedFile, 14 | onStopLLM, 15 | placeholder = "Ask anything...", 16 | }: { 17 | isLLMAnswering: boolean; 18 | value: string; 19 | onChange: (value: string) => void; 20 | onSend: () => void; 21 | onStopLLM: () => void; 22 | uploadedFile?: UploadedFile; 23 | placeholder?: string; 24 | }) { 25 | const { refetch } = useUserLimits(); 26 | 27 | const handleSendMessage = async () => { 28 | if (value.trim() === "") return; 29 | onSend(); 30 | setTimeout(() => { 31 | refetch(); 32 | }, 1000); 33 | }; 34 | 35 | return ( 36 | <> 37 |
38 |
39 | 48 |
49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/api/chat/history/route.ts: -------------------------------------------------------------------------------- 1 | import { loadChat } from "@/lib/chat-store"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST(req: NextRequest) { 5 | try { 6 | const { ids } = await req.json(); 7 | if (!Array.isArray(ids)) { 8 | return NextResponse.json( 9 | { error: "ids must be an array" }, 10 | { status: 400 } 11 | ); 12 | } 13 | const results = await Promise.all( 14 | ids.map(async (id: string) => { 15 | const chat = await loadChat(id); 16 | if (chat && chat.title) { 17 | return { 18 | id, 19 | title: chat.title, 20 | createdAt: chat.createdAt || new Date(), 21 | }; 22 | } else if (chat) { 23 | // fallback: use first user message as title 24 | const userMsg = chat.messages.find((msg) => msg.role === "user"); 25 | return { id, title: userMsg?.content || id }; 26 | } else { 27 | return null; 28 | } 29 | }) 30 | ); 31 | 32 | return NextResponse.json( 33 | results.filter(Boolean).sort((a, b) => { 34 | if (!b?.createdAt || !a?.createdAt) return 0; 35 | return ( 36 | new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 37 | ); 38 | }) 39 | ); 40 | } catch (e) { 41 | return NextResponse.json({ error: "Invalid request" }, { status: 400 }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/models.ts: -------------------------------------------------------------------------------- 1 | export type ChatModel = { 2 | logo: string; 3 | title: string; 4 | model: string; 5 | slug: string; 6 | isDefault?: boolean; 7 | hasReasoning?: boolean; 8 | contextLength: number; 9 | }; 10 | 11 | export const CHAT_MODELS: ChatModel[] = [ 12 | { 13 | logo: 'https://cdn.prod.website-files.com/650c3b59079d92475f37b68f/6798c7d1ee372a0b8f8122f4_66f41a073403f9e2b7806f05_qwen-logo.webp', 14 | title: 'Qwen 3 Coder', 15 | slug: 'qwen-3-coder-480b', 16 | model: 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 17 | isDefault: true, 18 | contextLength: 131072, 19 | }, 20 | { 21 | logo: 'https://cdn.prod.website-files.com/650c3b59079d92475f37b68f/6798c7d11669ad7315d427af_66f41a324f1d713df2cbfbf4_deepseek-logo.webp', 22 | title: 'DeepSeek V3.1', 23 | slug: 'deepseek-v3', 24 | model: 'deepseek-ai/DeepSeek-V3.1', 25 | contextLength: 128000, 26 | }, 27 | { 28 | logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/openai.png', 29 | title: 'GPT OSS 120B', 30 | model: 'openai/gpt-oss-120b', 31 | slug: 'gpt-oss-120b', 32 | contextLength: 128000, 33 | }, 34 | { 35 | logo: 'https://cdn.prod.website-files.com/650c3b59079d92475f37b68f/6798c7d256b428d5c7991fef_66f41918314a4184b51788ed_meta-logo.png', 36 | title: 'Llama 3.3 70B', 37 | model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', 38 | slug: 'llama-3-3', 39 | isDefault: true, 40 | contextLength: 128000, 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | CSV2Chat 3 | 4 | 5 |
6 |

CSV2Chat

7 |

8 | Chat with your CSV files using AI. Upload a CSV, ask questions, and get instant, code-backed answers and visualizations. 9 |

10 |
11 | 12 | ## Tech Stack 13 | 14 | - **Frontend**: Next.js, Typescript, Tailwind CSS, Shadcn UI 15 | - **Together AI LLM**: Generates Python code to answer questions and visualize data 16 | - [**Together Code Interpreter**: Executes Python code and returns results](https://www.together.ai/code-interpreter) 17 | 18 | ## How it works 19 | 20 | 1. User uploads a CSV file 21 | 2. The app analyzes the CSV headers and suggests insightful questions 22 | 3. User asks a question about the data 23 | 4. Together.ai generates Python code to answer the question, runs it, and returns results (including charts) using Together Code Interpreter 24 | 5. All chats and results are stored in Upstash Redis for fast retrieval 25 | 26 | ## Cloning & running 27 | 28 | 1. Fork or clone the repo 29 | 2. Create accounts at [Together.ai](https://together.ai/) and [Upstash](https://upstash.com/) for LLM and Redis 30 | 3. Create a `.env` file and add your API keys: 31 | - `TOGETHER_API_KEY` 32 | - `UPSTASH_REDIS_REST_URL` 33 | - `UPSTASH_REDIS_REST_TOKEN` 34 | 4. Run `pnpm install` and `pnpm run dev` to install dependencies and start the app locally 35 | 36 | Open [http://localhost:3000](http://localhost:3000) to use CSV2Chat. 37 | -------------------------------------------------------------------------------- /src/app/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { loadChat } from "@/lib/chat-store"; 2 | import { ChatScreen } from "@/components/chat-screen"; 3 | import type { Metadata } from "next"; 4 | import { APP_NAME } from "@/lib/utils"; 5 | 6 | export async function generateMetadata({ 7 | params, 8 | }: { 9 | params: Promise<{ id: string }>; 10 | }): Promise { 11 | const { id } = await params; 12 | const chat = await loadChat(id); 13 | if (!chat) { 14 | return { 15 | title: `Chat not found | ${APP_NAME}`, 16 | description: "No chat found for this ID.", 17 | }; 18 | } 19 | return { 20 | title: 21 | `${chat.title} | ${APP_NAME}` || 22 | `Chat "${ 23 | chat.messages.find((msg) => msg.role === "user")?.content 24 | }" | ${APP_NAME}`, 25 | description: chat.csvHeaders 26 | ? `Chat about CSV columns: ${chat.csvHeaders.join(", ")}` 27 | : "Chat with your CSV using Together.ai", 28 | openGraph: { 29 | images: ["https://csvtochat.com/og.jpg"], 30 | }, 31 | }; 32 | } 33 | 34 | export default async function Page({ 35 | params, 36 | }: { 37 | params: Promise<{ id: string }>; 38 | }) { 39 | const { id } = await params; 40 | 41 | const chat = await loadChat(id); 42 | 43 | return ( 44 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | // Add this helper function at the top-level (outside the component) 9 | export function formatLLMTimestamp(dateString: string | number | Date): string { 10 | const date = new Date(dateString); 11 | const now = new Date(); 12 | const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000); 13 | let timeAgo = ""; 14 | if (secondsAgo < 60) { 15 | timeAgo = `${secondsAgo}s`; 16 | } else if (secondsAgo < 3600) { 17 | timeAgo = `${Math.floor(secondsAgo / 60)}m`; 18 | } else { 19 | timeAgo = `${Math.floor(secondsAgo / 3600)}h`; 20 | } 21 | // Format: Apr 8, 06:17:50 PM 22 | const options: Intl.DateTimeFormatOptions = { 23 | month: "short", 24 | day: "numeric", 25 | hour: "2-digit", 26 | minute: "2-digit", 27 | second: "2-digit", 28 | hour12: true, 29 | }; 30 | const formatted = date.toLocaleString("en-US", options); 31 | return formatted; 32 | } 33 | 34 | export function extractCodeFromText(text: string) { 35 | const codeRegex = /```python\s*([\s\S]*?)\s*```/g; 36 | const match = codeRegex.exec(text); 37 | return match ? match[1] : null; 38 | } 39 | 40 | export type UploadedFile = { 41 | name?: string; 42 | url?: string; 43 | csvHeaders?: string[]; 44 | csvRows?: { [key: string]: string }[]; 45 | }; 46 | 47 | export const APP_NAME = "CSV2Chat"; 48 | 49 | export const EXAMPLE_FILE_URL = "/products-100.csv"; 50 | -------------------------------------------------------------------------------- /src/app/api/generate-questions/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { generateObject } from "ai"; 3 | import { z } from "zod"; 4 | import { togetherAISDKClient } from "@/lib/clients"; 5 | import { generateQuestionsPrompt } from "@/lib/prompts"; 6 | 7 | const questionSchema = z.object({ 8 | id: z.string(), 9 | text: z 10 | .string() 11 | .describe("A question that can be asked about the provided CSV columns."), 12 | }); 13 | 14 | export async function POST(req: Request) { 15 | try { 16 | const { columns } = await req.json(); 17 | 18 | if (!columns || !Array.isArray(columns) || columns.length === 0) { 19 | return NextResponse.json( 20 | { error: 'Invalid input: "columns" array is required.' }, 21 | { status: 400 } 22 | ); 23 | } 24 | 25 | console.log("Generating questions for columns:", columns); 26 | console.log("Prompt:", generateQuestionsPrompt({ csvHeaders: columns })); 27 | 28 | const { object: generatedQuestions } = await generateObject({ 29 | model: togetherAISDKClient("meta-llama/Llama-4-Scout-17B-16E-Instruct"), 30 | mode: "json", 31 | output: "array", 32 | schema: questionSchema, 33 | maxTokens: 1000, 34 | maxRetries: 1, 35 | prompt: generateQuestionsPrompt({ csvHeaders: columns }), 36 | }); 37 | 38 | return NextResponse.json( 39 | { questions: generatedQuestions.slice(0, 3) }, 40 | { status: 200 } 41 | ); 42 | } catch (error) { 43 | console.error("Error generating questions:", error); 44 | return NextResponse.json( 45 | { error: "Internal server error." }, 46 | { status: 500 } 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/csvUtils.ts: -------------------------------------------------------------------------------- 1 | import Papa from "papaparse"; 2 | 3 | export interface CsvData { 4 | headers: string[]; 5 | /** 6 | * Represents sample rows from the CSV file. Each inner array is a row, and each string is a cell value. 7 | * For example, if the CSV has columns "Name" and "Age", and the first row is "John,25", 8 | * the sampleRows would be: 9 | * [ 10 | * { Name: "John", Age: "25" }, 11 | * { Name: "Jane", Age: "30" }, 12 | * ... 13 | * ] 14 | * The keys are the column names, and the values are the cell values. 15 | */ 16 | sampleRows: { [key: string]: string }[]; 17 | } 18 | 19 | const AMOUNT_SAMPLE_ROWS = 4; 20 | 21 | export const extractCsvData = (file: File): Promise => { 22 | return new Promise((resolve, reject) => { 23 | Papa.parse(file, { 24 | header: true, 25 | skipEmptyLines: true, 26 | complete: (results) => { 27 | const headers = (results.meta.fields || []).map((field) => 28 | field.trim() 29 | ); 30 | const allRows = results.data as { [key: string]: string }[]; 31 | const sampleRows: { [key: string]: string }[] = []; 32 | 33 | if (allRows.length <= AMOUNT_SAMPLE_ROWS) { 34 | sampleRows.push(...allRows); 35 | } else { 36 | const step = (allRows.length - 1) / 3; 37 | for (let i = 0; i < AMOUNT_SAMPLE_ROWS; i++) { 38 | sampleRows.push(allRows[Math.floor(i * step)]); 39 | } 40 | } 41 | 42 | resolve({ headers, sampleRows }); 43 | }, 44 | error: (error) => { 45 | console.error("Error parsing CSV:", error.message); 46 | reject(error); 47 | }, 48 | }); 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/CsvPreviewModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Table, 5 | TableHeader, 6 | TableBody, 7 | TableHead, 8 | TableRow, 9 | TableCell, 10 | } from "@/components/ui/table"; 11 | import { 12 | Dialog, 13 | DialogContent, 14 | DialogTitle, 15 | DialogOverlay, 16 | } from "@/components/ui/dialog"; 17 | 18 | export function CsvPreviewModal({ 19 | open, 20 | onOpenChange, 21 | headers, 22 | rows, 23 | }: { 24 | open: boolean; 25 | onOpenChange: (open: boolean) => void; 26 | headers: string[]; 27 | rows: { [key: string]: string }[]; 28 | }) { 29 | return ( 30 | 31 | 32 | 33 | Preview CSV File 34 |
35 | 36 | 37 | 38 | {headers.map((header, idx) => ( 39 | {header} 40 | ))} 41 | 42 | 43 | 44 | {rows.map((row, rIdx) => ( 45 | 46 | {headers.map((header, cIdx) => ( 47 | {row[header]} 48 | ))} 49 | 50 | ))} 51 | 52 |
53 |
54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/hooks/UserLimitsContext.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { 3 | createContext, 4 | useContext, 5 | useEffect, 6 | useState, 7 | ReactNode, 8 | } from "react"; 9 | 10 | interface UserLimits { 11 | remainingMessages: number | null; 12 | resetTimestamp: number | null; 13 | loading: boolean; 14 | refetch: () => void; 15 | } 16 | 17 | const UserLimitsContext = createContext(undefined); 18 | 19 | export const UserLimitsProvider = ({ children }: { children: ReactNode }) => { 20 | const [remainingMessages, setRemainingMessages] = useState( 21 | null 22 | ); 23 | const [resetTimestamp, setResetTimestamp] = useState(null); 24 | const [loading, setLoading] = useState(true); 25 | 26 | // Fetch limits 27 | const fetchLimits = async () => { 28 | setLoading(true); 29 | try { 30 | const res = await fetch(`/api/limits`); 31 | const data = await res.json(); 32 | setRemainingMessages(data.remaining); 33 | setResetTimestamp(data.reset ?? null); 34 | } catch { 35 | setRemainingMessages(null); 36 | setResetTimestamp(null); 37 | } finally { 38 | setLoading(false); 39 | } 40 | }; 41 | 42 | useEffect(() => { 43 | fetchLimits(); 44 | }, []); 45 | 46 | const refetch = () => { 47 | fetchLimits(); 48 | }; 49 | 50 | return ( 51 | 54 | {children} 55 | 56 | ); 57 | }; 58 | 59 | export function useUserLimits(): UserLimits { 60 | const context = useContext(UserLimitsContext); 61 | if (context === undefined) { 62 | throw new Error("useUserLimits must be used within a UserLimitsProvider"); 63 | } 64 | return context; 65 | } 66 | -------------------------------------------------------------------------------- /public/uploaded-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "csvtochat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/react": "^1.2.12", 13 | "@ai-sdk/togetherai": "^0.2.14", 14 | "@radix-ui/react-accordion": "^1.2.11", 15 | "@radix-ui/react-dialog": "^1.1.14", 16 | "@radix-ui/react-dropdown-menu": "^2.1.15", 17 | "@radix-ui/react-select": "^2.2.5", 18 | "@radix-ui/react-slot": "^1.2.3", 19 | "@radix-ui/react-tooltip": "^1.2.7", 20 | "@radix-ui/react-visually-hidden": "^1.2.3", 21 | "@upstash/ratelimit": "^2.0.5", 22 | "@upstash/redis": "^1.35.0", 23 | "ai": "^4.3.16", 24 | "class-variance-authority": "^0.7.1", 25 | "clsx": "^2.1.1", 26 | "date-fns": "^4.1.0", 27 | "lucide-react": "^0.511.0", 28 | "marked": "^15.0.12", 29 | "next": "16.0.10", 30 | "next-plausible": "^3.12.5", 31 | "next-s3-upload": "^0.3.4", 32 | "next-themes": "^0.4.6", 33 | "papaparse": "^5.5.3", 34 | "react": "^19.0.0", 35 | "react-dom": "^19.0.0", 36 | "react-dropzone": "^14.3.8", 37 | "react-hot-toast": "^2.5.2", 38 | "react-markdown": "^10.1.0", 39 | "react-syntax-highlighter": "^15.6.1", 40 | "sonner": "^2.0.5", 41 | "tailwind-merge": "^3.3.0", 42 | "together-ai": "^0.16.0", 43 | "vaul": "^1.1.2", 44 | "zod": "^4.1.5" 45 | }, 46 | "devDependencies": { 47 | "@tailwindcss/postcss": "^4", 48 | "@types/node": "^20", 49 | "@types/papaparse": "^5.3.16", 50 | "@types/react": "^19", 51 | "@types/react-dom": "^19", 52 | "@types/react-syntax-highlighter": "^15.5.13", 53 | "tailwindcss": "^4", 54 | "tw-animate-css": "^1.3.3", 55 | "typescript": "^5" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/chatTools/CodeRunning.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { ThinkingIndicator } from '../ui/ThinkingIndicator'; 3 | import { CodeRender } from '../code-render'; 4 | 5 | // Subcomponent for code running progress 6 | export const CodeRunning = () => { 7 | const [progress, setProgress] = useState(0); 8 | const statuses = [ 9 | 'Starting the remote coding instance', 10 | 'Passing the python code', 11 | 'Downloading S3 file', 12 | 'Setting up environment', 13 | 'Installing dependencies', 14 | 'Executing code', 15 | 'Collecting outputs', 16 | 'Finalizing results', 17 | ]; 18 | // Divide progress into equal segments for each status 19 | const statusIndex = Math.min( 20 | statuses.length - 1, 21 | Math.floor((progress / 80) * statuses.length) 22 | ); 23 | const currentStatus = statuses[statusIndex]; 24 | 25 | useEffect(() => { 26 | let interval: NodeJS.Timeout | null = null; 27 | if (progress < 80) { 28 | interval = setInterval(() => { 29 | setProgress((prev) => { 30 | if (prev < 80) { 31 | // Increase by 1-3% randomly for realism 32 | const next = prev + Math.floor(Math.random() * 3) + 1; 33 | return next > 80 ? 80 : next; 34 | } 35 | return prev; 36 | }); 37 | }, 120); 38 | } 39 | return () => { 40 | if (interval) clearInterval(interval); 41 | }; 42 | }, [progress]); 43 | 44 | return ( 45 | <> 46 | 47 |
48 | 55 |
56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /public/python.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/MemoizedMarkdown.tsx: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import { memo, useMemo } from "react"; 3 | import ReactMarkdown, { Components } from "react-markdown"; 4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 5 | import { prism } from "react-syntax-highlighter/dist/esm/styles/prism"; 6 | import { CodeRender } from "./code-render"; 7 | 8 | interface CodeComponentProps { 9 | node?: any; 10 | inline?: boolean; 11 | className?: string; 12 | children?: React.ReactNode; 13 | } 14 | 15 | function parseMarkdownIntoBlocks(markdown: string): string[] { 16 | const tokens = marked.lexer(markdown); 17 | return tokens.map((token) => token.raw); 18 | } 19 | 20 | const MemoizedMarkdownBlock = memo( 21 | ({ content }: { content: string }) => { 22 | return ( 23 | 33 | ) : ( 34 | {children} 35 | ); 36 | }, 37 | }} 38 | > 39 | {content} 40 | 41 | ); 42 | }, 43 | (prevProps, nextProps) => { 44 | if (prevProps.content !== nextProps.content) return false; 45 | return true; 46 | } 47 | ); 48 | 49 | MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"; 50 | 51 | export const MemoizedMarkdown = memo( 52 | ({ content, id }: { content: string; id: string }) => { 53 | const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]); 54 | 55 | return blocks.map((block, index) => ( 56 | 57 | )); 58 | } 59 | ); 60 | 61 | MemoizedMarkdown.displayName = "MemoizedMarkdown"; 62 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 62 | -------------------------------------------------------------------------------- /public/tooltip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/GithubBanner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | interface GithubBannerProps { 4 | show: boolean; 5 | onClose: () => void; 6 | } 7 | 8 | export const GithubBanner: React.FC = ({ 9 | show, 10 | onClose, 11 | }) => { 12 | const [stars, setStars] = useState("-"); 13 | 14 | useEffect(() => { 15 | async function fetchStars() { 16 | try { 17 | const res = await fetch( 18 | "https://api.github.com/repos/Nutlope/csvtochat", 19 | { 20 | headers: { 21 | Accept: "application/vnd.github+json", 22 | "User-Agent": "csvtochat-app", 23 | }, 24 | } 25 | ); 26 | if (!res.ok) return; 27 | const data = await res.json(); 28 | setStars( 29 | typeof data.stargazers_count === "number" 30 | ? data.stargazers_count.toLocaleString() 31 | : "-" 32 | ); 33 | } catch { 34 | setStars("-"); 35 | } 36 | } 37 | fetchStars(); 38 | }, []); 39 | 40 | if (!show) return null; 41 | 42 | return ( 43 | 49 |
50 | star 51 |

{stars}

52 |
53 |
54 |

Open source on

55 | GitHub 56 |
57 | 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/code-render.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { 5 | Prism as SyntaxHighlighter, 6 | createElement, 7 | } from "react-syntax-highlighter"; 8 | import { prism, dark } from "react-syntax-highlighter/dist/esm/styles/prism"; 9 | 10 | export function CodeRender({ 11 | code, 12 | language, 13 | theme = "dark", 14 | }: { 15 | code: string; 16 | language: string; 17 | theme?: "light" | "dark"; 18 | }) { 19 | const selectedTheme = theme === "dark" ? dark : prism; 20 | 21 | return ( 22 |
23 | { 38 | return rows.map((row, index) => { 39 | const children = row.children; 40 | const lineNumberElement = children?.shift(); 41 | if (lineNumberElement) { 42 | row.children = [ 43 | lineNumberElement, 44 | { 45 | children, 46 | properties: { 47 | className: [], 48 | style: { whiteSpace: "pre-wrap", wordBreak: "break-all" }, 49 | }, 50 | tagName: "span", 51 | type: "element", 52 | }, 53 | ]; 54 | } 55 | return createElement({ 56 | node: row, 57 | stylesheet, 58 | useInlineStyles, 59 | key: index, 60 | }); 61 | }); 62 | }} 63 | > 64 | {code} 65 | 66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/ModelDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Select, 4 | SelectTrigger, 5 | SelectContent, 6 | SelectItem, 7 | } from "@/components/ui/select"; 8 | import { ChatModel } from "@/lib/models"; 9 | import { cn } from "@/lib/utils"; 10 | 11 | export function ModelDropdown({ 12 | models, 13 | value, 14 | onChange, 15 | }: { 16 | models: ChatModel[]; 17 | value?: string; 18 | onChange: (model: string) => void; 19 | }) { 20 | // Find the selected model for displaying logo in the trigger 21 | const selectedModel = models.find((m) => m.slug === value); 22 | 23 | return ( 24 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDownIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Accordion({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AccordionItem({ 16 | className, 17 | ...props 18 | }: React.ComponentProps) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | function AccordionTrigger({ 29 | className, 30 | children, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 35 | svg]:rotate-180", 39 | className 40 | )} 41 | {...props} 42 | > 43 | {children} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | function AccordionContent({ 51 | className, 52 | children, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 |
{children}
62 |
63 | ) 64 | } 65 | 66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 67 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/components/ReasoningAccordion.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useState } from "react"; 2 | 3 | import { 4 | Accordion, 5 | AccordionContent, 6 | AccordionItem, 7 | AccordionTrigger, 8 | } from "./ui/accordion"; 9 | 10 | type ReasoningUIPart = { 11 | type: "reasoning"; 12 | /** 13 | * The reasoning text. 14 | */ 15 | reasoning: string; 16 | details: Array< 17 | | { 18 | type: "text"; 19 | text: string; 20 | signature?: string; 21 | } 22 | | { 23 | type: "redacted"; 24 | data: string; 25 | } 26 | >; 27 | }; 28 | 29 | export default function ReasoningAccordion({ 30 | reasoning, 31 | isReasoningOver = false, 32 | }: { 33 | reasoning?: ReasoningUIPart; 34 | isReasoningOver?: boolean; 35 | }) { 36 | const [open, setOpen] = useState("reasoning"); 37 | 38 | useEffect(() => { 39 | if (isReasoningOver) { 40 | setOpen(undefined); 41 | } 42 | }, [isReasoningOver]); 43 | 44 | if (!reasoning?.details?.length) return null; 45 | return ( 46 |
47 | 54 | 55 | 56 | Thought for a few seconds... 57 | 58 | 62 | {reasoning.details.map((detail: any, idx: number) => { 63 | if (detail.type === "redacted") { 64 | return <redacted>; 65 | } 66 | return {detail.text}; 67 | })} 68 | 69 | 70 | 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/hooks/useAutoScroll.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect, RefObject } from "react"; 2 | 3 | export function useAutoScroll({ 4 | status, 5 | isCodeRunning, 6 | }: { 7 | status: string; 8 | isCodeRunning: boolean; 9 | }): { 10 | messagesContainerRef: RefObject; 11 | messagesEndRef: RefObject; 12 | isUserAtBottom: boolean; 13 | } { 14 | const messagesContainerRef = useRef(null); 15 | const messagesEndRef = useRef(null); 16 | const [isUserAtBottom, setIsUserAtBottom] = useState(true); 17 | const [lastManualScrollTime, setLastManualScrollTime] = useState(0); 18 | 19 | // Scroll handler to detect if user is at the bottom 20 | const handleScroll = () => { 21 | const container = messagesContainerRef.current; 22 | if (!container) return; 23 | const threshold = 40; // px from bottom to still count as "at bottom" 24 | const isAtBottom = 25 | container.scrollHeight - container.scrollTop - container.clientHeight < 26 | threshold; 27 | setIsUserAtBottom(isAtBottom); 28 | // If user scrolled away from bottom, update lastManualScrollTime 29 | if (!isAtBottom) { 30 | setLastManualScrollTime(Date.now()); 31 | } 32 | }; 33 | 34 | // Attach scroll event 35 | useEffect(() => { 36 | const container = messagesContainerRef.current; 37 | if (!container) return; 38 | container.addEventListener("scroll", handleScroll); 39 | // Initial check 40 | handleScroll(); 41 | return () => { 42 | container.removeEventListener("scroll", handleScroll); 43 | }; 44 | // eslint-disable-next-line react-hooks/exhaustive-deps 45 | }, []); 46 | 47 | // Auto-scroll every 2 seconds if user is at the bottom or hasn't scrolled elsewhere recently 48 | useEffect(() => { 49 | const interval = setInterval(() => { 50 | const now = Date.now(); 51 | // Only scroll if user is at bottom, or hasn't manually scrolled elsewhere in the last 5 seconds 52 | // AND only if LLM is streaming or code is running 53 | if ( 54 | (status === "streaming" || isCodeRunning) && 55 | (isUserAtBottom || now - lastManualScrollTime > 5000) && 56 | messagesEndRef.current 57 | ) { 58 | messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); 59 | } 60 | }, 2000); 61 | return () => clearInterval(interval); 62 | }, [isUserAtBottom, lastManualScrollTime, status, isCodeRunning]); 63 | 64 | return { messagesContainerRef, messagesEndRef, isUserAtBottom }; 65 | } 66 | -------------------------------------------------------------------------------- /src/app/api/coding/route.ts: -------------------------------------------------------------------------------- 1 | import { runPython } from "@/lib/coding"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { saveNewMessage } from "@/lib/chat-store"; 4 | import { generateId } from "ai"; 5 | 6 | export async function POST(req: NextRequest) { 7 | try { 8 | const { code, session_id, files, id } = await req.json(); 9 | 10 | if (!code) { 11 | return NextResponse.json({ error: "Code is required" }, { status: 400 }); 12 | } 13 | 14 | // Start timing 15 | const start = Date.now(); 16 | 17 | // Timeout logic: 60 seconds 18 | const TIMEOUT_MS = 60000; 19 | let timeoutHandle: NodeJS.Timeout | undefined = undefined; 20 | const timeoutPromise = new Promise((_, reject) => { 21 | timeoutHandle = setTimeout(() => { 22 | reject(new Error("Code execution timed out after 60 seconds.")); 23 | }, TIMEOUT_MS); 24 | }); 25 | 26 | let result; 27 | try { 28 | result = await Promise.race([ 29 | runPython(code, session_id, files), 30 | timeoutPromise, 31 | ]); 32 | } catch (err: any) { 33 | if (err.message && err.message.includes("timed out")) { 34 | return NextResponse.json({ error: err.message }, { status: 504 }); 35 | } 36 | throw err; 37 | } finally { 38 | if (timeoutHandle) clearTimeout(timeoutHandle); 39 | } 40 | 41 | const end = Date.now(); 42 | const duration = (end - start) / 1000; 43 | 44 | if (req.signal.aborted) { 45 | console.log("Request aborted already from the client"); 46 | // TODO persist on db that the code execution was aborted? 47 | return new Response("Request aborted", { status: 200 }); 48 | } 49 | 50 | // Persist the code execution output as an assistant message in the chat history 51 | if (id && !req.signal.aborted) { 52 | const toolCallMessage = { 53 | id: generateId(), 54 | role: "assistant" as const, 55 | content: "Code execution complete.", 56 | createdAt: new Date(), 57 | duration, 58 | toolCall: { 59 | toolInvocation: { 60 | toolName: "runCode", 61 | // args: code, // maybe we don't save code also here cause it's already in the previous llm message 62 | state: "result", 63 | result: result, 64 | }, 65 | }, 66 | }; 67 | await saveNewMessage({ id, message: toolCallMessage }); 68 | } 69 | 70 | return NextResponse.json(result); 71 | } catch (error: any) { 72 | return NextResponse.json({ error: error.message }, { status: 500 }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/coding.ts: -------------------------------------------------------------------------------- 1 | import { codeInterpreter } from "@/lib/clients"; 2 | import { CodeInterpreterExecuteParams } from "together-ai/resources.mjs"; 3 | 4 | interface CodeInterpreterOutput { 5 | type: string; 6 | data: string; 7 | } 8 | 9 | interface CodeInterpreterError { 10 | // Define error structure if available from the API, otherwise use any 11 | message: string; 12 | } 13 | 14 | export interface TogetherCodeInterpreterResponseData { 15 | session_id: string; 16 | status: string; 17 | outputs: CodeInterpreterOutput[]; 18 | errors?: CodeInterpreterError[]; 19 | } 20 | 21 | interface RunPythonResult { 22 | session_id: string | null; 23 | status: string; 24 | outputs: CodeInterpreterOutput[]; 25 | errors?: CodeInterpreterError[]; 26 | error_message?: string; 27 | } 28 | 29 | /** 30 | * Executes Python code using Together Code Interpreter and returns the result. 31 | * @param code The Python code to execute 32 | * @param session_id Optional session ID to maintain state between executions 33 | * @param files Optional list of files to upload to the code interpreter 34 | * Each file should be an object with 'name', 'encoding', and 'content' keys 35 | * @returns The output of the executed code as a JSON 36 | */ 37 | export async function runPython( 38 | code: string, 39 | session_id?: string, 40 | files?: Array<{ name: string; encoding: string; content: string }> 41 | ): Promise { 42 | try { 43 | const kwargs: CodeInterpreterExecuteParams = { code, language: "python" }; 44 | 45 | if (session_id) { 46 | kwargs.session_id = session_id; 47 | } 48 | 49 | if (files) { 50 | // kwargs.files = files; 51 | } 52 | 53 | const response = await codeInterpreter.execute(kwargs); 54 | 55 | const data = response.data as TogetherCodeInterpreterResponseData; 56 | 57 | console.log("Response data:"); 58 | console.dir(data); 59 | 60 | const result: RunPythonResult = { 61 | session_id: data.session_id || null, 62 | status: data.status || "unknown", 63 | outputs: [], 64 | }; 65 | 66 | if (data.outputs) { 67 | for (const output of data.outputs) { 68 | result.outputs.push({ type: output.type, data: output.data }); 69 | } 70 | } 71 | 72 | if (data.errors) { 73 | result.errors = data.errors; 74 | } 75 | 76 | return result; 77 | } catch (e: any) { 78 | const error_result: RunPythonResult = { 79 | status: "error", 80 | error_message: e.message || String(e), 81 | session_id: null, 82 | outputs: [], 83 | }; 84 | return error_result; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/components/chatTools/ImageFigure.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react"; 2 | 3 | interface ImageFigureProps { 4 | imageData: { [key: string]: string }; 5 | } 6 | 7 | export const ImageFigure: React.FC = ({ imageData }) => { 8 | const [isOpen, setIsOpen] = useState(false); 9 | 10 | const handleOpen = () => setIsOpen(true); 11 | const handleClose = useCallback(() => setIsOpen(false), []); 12 | 13 | // Close on ESC 14 | useEffect(() => { 15 | if (!isOpen) return; 16 | const onKeyDown = (e: KeyboardEvent) => { 17 | if (e.key === "Escape") handleClose(); 18 | }; 19 | window.addEventListener("keydown", onKeyDown); 20 | return () => window.removeEventListener("keydown", onKeyDown); 21 | }, [isOpen, handleClose]); 22 | 23 | return ( 24 | <> 25 |
29 |

Image:

30 | image 35 |
36 | {isOpen && ( 37 |
42 | {/* X Close Button */} 43 | 63 | image fullscreen e.stopPropagation()} 68 | /> 69 |
70 | )} 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /public/new.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/chat-store.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { Message as AIMsg, generateText } from "ai"; 3 | import { generateId } from "ai"; 4 | import { redis, togetherAISDKClient } from "./clients"; // Import your redis client 5 | import { generateTitlePrompt } from "./prompts"; 6 | const CHAT_KEY_PREFIX = "chat:"; 7 | 8 | // Extend the Message type to include duration for Redis persistence 9 | export type DbMessage = AIMsg & { 10 | duration?: number; 11 | model?: string; // which model was used to generate this message 12 | isAutoErrorResolution?: boolean; // if true then this message is an automatic error resolution prompt 13 | }; 14 | 15 | type ChatData = { 16 | messages: DbMessage[]; 17 | csvFileUrl: string | null; 18 | csvHeaders: string[] | null; 19 | csvRows: { [key: string]: string }[] | null; 20 | title: string | null; // inferring the title of the chat based on csvHeaders and first user messages 21 | createdAt?: Date; 22 | // ...future fields 23 | }; 24 | 25 | export async function createChat({ 26 | userQuestion, 27 | csvHeaders, 28 | csvRows, 29 | csvFileUrl, 30 | }: { 31 | userQuestion: string; 32 | csvHeaders: string[]; 33 | csvRows: { [key: string]: string }[]; 34 | csvFileUrl: string; 35 | }): Promise { 36 | const id = generateId(); 37 | 38 | // use userQuestion to generate a title for the chat 39 | const { text: title } = await generateText({ 40 | model: togetherAISDKClient("meta-llama/Llama-3.3-70B-Instruct-Turbo"), 41 | prompt: generateTitlePrompt({ csvHeaders, userQuestion }), 42 | maxTokens: 100, 43 | }); 44 | 45 | const initial: ChatData = { 46 | messages: [], 47 | csvHeaders, 48 | csvRows, 49 | csvFileUrl, 50 | title, 51 | createdAt: new Date(), 52 | }; 53 | await redis.set(`${CHAT_KEY_PREFIX}${id}`, JSON.stringify(initial)); 54 | return id; 55 | } 56 | 57 | export async function loadChat(id: string): Promise { 58 | const value = await redis.get(`${CHAT_KEY_PREFIX}${id}`); 59 | if (!value) return null; 60 | try { 61 | return typeof value === "string" ? JSON.parse(value) : (value as ChatData); 62 | } catch { 63 | return null; 64 | } 65 | } 66 | 67 | export async function saveNewMessage({ 68 | id, 69 | message, 70 | }: { 71 | id: string; 72 | message: DbMessage; 73 | }): Promise { 74 | const chat = await loadChat(id); 75 | if (chat) { 76 | const updatedMessages = [...(chat.messages || []), message]; 77 | await redis.set( 78 | `${CHAT_KEY_PREFIX}${id}`, 79 | JSON.stringify({ 80 | ...chat, 81 | messages: updatedMessages, 82 | }) 83 | ); 84 | } else { 85 | // If chat does not exist, create a new one with this message 86 | const newChat: ChatData = { 87 | messages: [message], 88 | csvHeaders: null, 89 | csvRows: null, 90 | csvFileUrl: null, 91 | title: null, 92 | }; 93 | await redis.set(`${CHAT_KEY_PREFIX}${id}`, JSON.stringify(newChat)); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | tr]:last:border-b-0", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 56 | return ( 57 | 65 | ) 66 | } 67 | 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 69 | return ( 70 |
[role=checkbox]]:translate-y-[2px]", 74 | className 75 | )} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 82 | return ( 83 | [role=checkbox]]:translate-y-[2px]", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | ) 92 | } 93 | 94 | function TableCaption({ 95 | className, 96 | ...props 97 | }: React.ComponentProps<"caption">) { 98 | return ( 99 |
104 | ) 105 | } 106 | 107 | export { 108 | Table, 109 | TableHeader, 110 | TableBody, 111 | TableFooter, 112 | TableHead, 113 | TableRow, 114 | TableCell, 115 | TableCaption, 116 | } 117 | -------------------------------------------------------------------------------- /src/components/TooltipUsage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState, useEffect } from "react"; 2 | import { 3 | Tooltip, 4 | TooltipTrigger, 5 | TooltipContent, 6 | } from "@/components/ui/tooltip"; 7 | import { intervalToDuration, differenceInSeconds } from "date-fns"; 8 | import { useUserLimits } from "@/hooks/UserLimitsContext"; 9 | 10 | function formatTimeRemaining(resetTimestamp: number) { 11 | const now = new Date(); 12 | const reset = 13 | typeof resetTimestamp === "string" 14 | ? new Date(parseInt(resetTimestamp, 10)) 15 | : new Date(resetTimestamp); 16 | if (isNaN(reset.getTime())) return "--:--:--"; 17 | // Only show if in the future 18 | if (reset.getTime() <= now.getTime()) return "00:00:00"; 19 | // Use date-fns to get the duration breakdown 20 | const duration = intervalToDuration({ start: now, end: reset }); 21 | // Calculate total hours (including days, months, years) 22 | const totalSeconds = differenceInSeconds(reset, now); 23 | const hours = Math.floor(totalSeconds / 3600) 24 | .toString() 25 | .padStart(2, "0"); 26 | const minutes = (duration.minutes ?? 0).toString().padStart(2, "0"); 27 | const seconds = (duration.seconds ?? 0).toString().padStart(2, "0"); 28 | return `${hours}:${minutes}:${seconds}`; 29 | } 30 | 31 | export default function TooltipUsage() { 32 | const { remainingMessages, resetTimestamp, loading } = useUserLimits(); 33 | 34 | const [open, setOpen] = useState(false); 35 | const [tick, setTick] = useState(0); 36 | 37 | useEffect(() => { 38 | if (!open) return; 39 | const interval = setInterval(() => setTick((t) => t + 1), 1000); 40 | return () => clearInterval(interval); 41 | }, [open]); 42 | 43 | const formattedTime = useMemo(() => { 44 | if (!resetTimestamp) return undefined; 45 | return formatTimeRemaining(resetTimestamp); 46 | // Include tick so it recalculates every second when open 47 | }, [resetTimestamp, tick]); 48 | 49 | return ( 50 | 51 | 52 |
53 |
54 |
55 | 56 |

57 | {remainingMessages} 58 |

59 |
60 |

credits

61 |
62 |
63 |
64 | {formattedTime && ( 65 | 66 |
67 |

68 | Time remaining until refill: 69 |

70 |

{formattedTime}

71 |
72 |
73 | )} 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import React, { useEffect, useState } from "react"; 6 | 7 | import { ChatHistoryMenu } from "./ChatHistoryMenu"; 8 | import { GithubBanner } from "./GithubBanner"; 9 | import useLocalStorage from "@/hooks/useLocalStorage"; 10 | import { cn } from "@/lib/utils"; 11 | import TooltipUsage from "./TooltipUsage"; 12 | 13 | interface HeaderProps { 14 | chatId?: string; 15 | } 16 | 17 | export function Header({ chatId }: HeaderProps) { 18 | const [showBanner, setShowBanner] = useLocalStorage( 19 | "showBanner", 20 | true 21 | ); 22 | const [mounted, setMounted] = useState(false); 23 | 24 | useEffect(() => { 25 | setMounted(true); 26 | }, []); 27 | 28 | if (!mounted) return <>; 29 | 30 | return ( 31 | <> 32 | 86 | {/* Spacer for mobile header height */} 87 | setShowBanner(false)} /> 88 |
94 |
100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/app/chat/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Header } from "@/components/header"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export default function Loading() { 7 | return ( 8 |
9 |
10 |
11 |
12 | {/* User bubble 1 (right) */} 13 |
14 |
18 |

19 |

20 |
21 | {/* Assistant bubble 1 (left) */} 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {/* User bubble 2 (right) */} 31 |
32 |
36 |

37 |

38 |
39 | {/* Assistant bubble 2 (left) */} 40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | {/* Assistant bubble 3 */} 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {/* Assistant bubble 4 */} 58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /public/suggestion.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /public/githubLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @theme inline { 5 | --color-background: var(--background); 6 | --color-foreground: var(--foreground); 7 | --font-sans: var(--font-instrument-sans); 8 | --font-mono: var(--font-geist-mono); 9 | --color-sidebar-ring: var(--sidebar-ring); 10 | --color-sidebar-border: var(--sidebar-border); 11 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 12 | --color-sidebar-accent: var(--sidebar-accent); 13 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 14 | --color-sidebar-primary: var(--sidebar-primary); 15 | --color-sidebar-foreground: var(--sidebar-foreground); 16 | --color-sidebar: var(--sidebar); 17 | --color-chart-5: var(--chart-5); 18 | --color-chart-4: var(--chart-4); 19 | --color-chart-3: var(--chart-3); 20 | --color-chart-2: var(--chart-2); 21 | --color-chart-1: var(--chart-1); 22 | --color-ring: var(--ring); 23 | --color-input: var(--input); 24 | --color-border: var(--border); 25 | --color-destructive: var(--destructive); 26 | --color-accent-foreground: var(--accent-foreground); 27 | --color-accent: var(--accent); 28 | --color-muted-foreground: var(--muted-foreground); 29 | --color-muted: var(--muted); 30 | --color-secondary-foreground: var(--secondary-foreground); 31 | --color-secondary: var(--secondary); 32 | --color-primary-foreground: var(--primary-foreground); 33 | --color-primary: var(--primary); 34 | --color-popover-foreground: var(--popover-foreground); 35 | --color-popover: var(--popover); 36 | --color-card-foreground: var(--card-foreground); 37 | --color-card: var(--card); 38 | --radius-sm: calc(var(--radius) - 4px); 39 | --radius-md: calc(var(--radius) - 2px); 40 | --radius-lg: var(--radius); 41 | --radius-xl: calc(var(--radius) + 4px); 42 | } 43 | 44 | :root { 45 | --radius: 0.625rem; 46 | --background: oklch(1 0 0); 47 | --foreground: oklch(0.145 0 0); 48 | --card: oklch(1 0 0); 49 | --card-foreground: oklch(0.145 0 0); 50 | --popover: oklch(1 0 0); 51 | --popover-foreground: oklch(0.145 0 0); 52 | --primary: oklch(0.205 0 0); 53 | --primary-foreground: oklch(0.985 0 0); 54 | --secondary: oklch(0.97 0 0); 55 | --secondary-foreground: oklch(0.205 0 0); 56 | --muted: oklch(0.97 0 0); 57 | --muted-foreground: oklch(0.556 0 0); 58 | --accent: oklch(0.97 0 0); 59 | --accent-foreground: oklch(0.205 0 0); 60 | --destructive: oklch(0.577 0.245 27.325); 61 | --border: oklch(0.922 0 0); 62 | --input: oklch(0.922 0 0); 63 | --ring: oklch(0.708 0 0); 64 | --chart-1: oklch(0.646 0.222 41.116); 65 | --chart-2: oklch(0.6 0.118 184.704); 66 | --chart-3: oklch(0.398 0.07 227.392); 67 | --chart-4: oklch(0.828 0.189 84.429); 68 | --chart-5: oklch(0.769 0.188 70.08); 69 | --sidebar: oklch(0.985 0 0); 70 | --sidebar-foreground: oklch(0.145 0 0); 71 | --sidebar-primary: oklch(0.205 0 0); 72 | --sidebar-primary-foreground: oklch(0.985 0 0); 73 | --sidebar-accent: oklch(0.97 0 0); 74 | --sidebar-accent-foreground: oklch(0.205 0 0); 75 | --sidebar-border: oklch(0.922 0 0); 76 | --sidebar-ring: oklch(0.708 0 0); 77 | } 78 | 79 | @layer base { 80 | * { 81 | @apply border-border outline-ring/50; 82 | } 83 | body { 84 | @apply bg-background text-foreground; 85 | } 86 | } 87 | 88 | code, pre, .code { 89 | font-size: 12px !important; 90 | } 91 | 92 | /* Custom light prose styles for lists */ 93 | .prose ol, 94 | .prose ul { 95 | padding-left: 1.5rem; 96 | margin-top: 0.5em; 97 | margin-bottom: 0.5em; 98 | } 99 | .prose ol { 100 | list-style-type: decimal; 101 | } 102 | .prose ul { 103 | list-style-type: disc; 104 | } 105 | .prose li { 106 | margin-top: 0.25em; 107 | margin-bottom: 0.25em; 108 | padding-left: 0.25em; 109 | } 110 | .prose ol > li::marker { 111 | color: var(--primary); 112 | font-weight: bold; 113 | } 114 | .prose ul > li::marker { 115 | color: var(--primary); 116 | } 117 | .dark .prose ol > li::marker, 118 | .dark .prose ul > li::marker { 119 | color: var(--primary-foreground); 120 | } 121 | -------------------------------------------------------------------------------- /src/app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { togetherAISDKClient } from "@/lib/clients"; 2 | import { 3 | streamText, 4 | generateId, 5 | CoreMessage, 6 | appendResponseMessages, 7 | wrapLanguageModel, 8 | extractReasoningMiddleware, 9 | } from "ai"; 10 | import { DbMessage, loadChat, saveNewMessage } from "@/lib/chat-store"; 11 | import { limitMessages } from "@/lib/limits"; 12 | import { generateCodePrompt } from "@/lib/prompts"; 13 | import { CHAT_MODELS } from "@/lib/models"; 14 | 15 | export async function POST(req: Request) { 16 | const { id, message, model } = await req.json(); 17 | 18 | // get from headers X-Auto-Error-Resolved 19 | const errorResolved = req.headers.get("X-Auto-Error-Resolved"); 20 | 21 | // Use IP address as a simple user fingerprint 22 | const ip = req.headers.get("x-forwarded-for") || "unknown"; 23 | try { 24 | if (!errorResolved) { 25 | await limitMessages(ip); 26 | } 27 | } catch (err) { 28 | return new Response("Too many messages. Daily limit reached.", { 29 | status: 429, 30 | }); 31 | } 32 | 33 | const chat = await loadChat(id); 34 | 35 | const newUserMessage: DbMessage = { 36 | id: generateId(), 37 | role: "user", 38 | content: message, 39 | createdAt: new Date(), 40 | isAutoErrorResolution: errorResolved === "true", 41 | }; 42 | 43 | // Save the new user message 44 | await saveNewMessage({ id, message: newUserMessage }); 45 | 46 | const messagesToSave: DbMessage[] = [ 47 | ...(chat?.messages || []), 48 | newUserMessage, 49 | ]; 50 | 51 | const coreMessagesForStream = messagesToSave 52 | .filter((msg) => msg.role === "user" || msg.role === "assistant") 53 | .map((msg) => ({ 54 | role: msg.role, 55 | content: msg.content, 56 | })); 57 | 58 | // Start timing 59 | const start = Date.now(); 60 | 61 | // Determine which model to use 62 | 63 | const defaultModel = CHAT_MODELS.find((m) => m.isDefault)?.model; 64 | 65 | const selectedModelSlug = typeof model === "string" ? model : undefined; 66 | 67 | const selectedModel = 68 | (selectedModelSlug && 69 | CHAT_MODELS.find((m) => m.slug === selectedModelSlug)?.model) || 70 | defaultModel; 71 | 72 | if (!selectedModel) { 73 | throw new Error("Invalid model selected."); 74 | } 75 | 76 | try { 77 | // Create a new model instance based on selectedModel 78 | const modelInstance = wrapLanguageModel({ 79 | model: togetherAISDKClient(selectedModel), 80 | middleware: extractReasoningMiddleware({ tagName: "think" }), 81 | }); 82 | 83 | // TODO: handling context length here cause coreMessagesForStream could be too long for the currently selected model? 84 | 85 | const stream = streamText({ 86 | model: modelInstance, 87 | system: generateCodePrompt({ 88 | csvFileUrl: chat?.csvFileUrl || "", 89 | csvHeaders: chat?.csvHeaders || [], 90 | csvRows: chat?.csvRows || [], 91 | }), 92 | messages: coreMessagesForStream.filter( 93 | (msg) => msg.role !== "system" 94 | ) as CoreMessage[], 95 | onError: (error) => { 96 | console.error("Error:", error); 97 | }, 98 | async onFinish({ response }) { 99 | // End timing 100 | const end = Date.now(); 101 | const duration = (end - start) / 1000; 102 | 103 | if (response.messages.length > 1) { 104 | console.log("response.messages", response.messages); 105 | return; 106 | } 107 | 108 | const responseMessages = appendResponseMessages({ 109 | messages: messagesToSave, 110 | responseMessages: response.messages, 111 | }); 112 | 113 | const responseMessage = responseMessages.at(-1); 114 | 115 | if (!responseMessage) { 116 | return; 117 | } 118 | 119 | await saveNewMessage({ 120 | id, 121 | message: { 122 | ...responseMessage, 123 | duration, 124 | model: selectedModel, 125 | }, 126 | }); 127 | }, 128 | }); 129 | 130 | return stream.toDataStreamResponse({ 131 | sendReasoning: true, 132 | }); 133 | } catch (err) { 134 | console.error(err); 135 | return new Response("Error generating response", { status: 500 }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/components/PromptInput.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { ModelDropdown } from "./ModelDropdown"; 6 | import { useLLMModel } from "@/hooks/useLLMModel"; 7 | import { useEffect, useRef } from "react"; 8 | import { cn, UploadedFile } from "@/lib/utils"; 9 | import { DropdownFileActions } from "./DropdownFileActions"; 10 | 11 | export function PromptInput({ 12 | isLLMAnswering, 13 | onStopLLM, 14 | textAreaClassName, 15 | value, 16 | onChange, 17 | onSend, 18 | uploadedFile, 19 | placeholder = "Ask anything...", 20 | }: { 21 | isLLMAnswering: boolean; 22 | onStopLLM: () => void; 23 | textAreaClassName?: string; 24 | value: string; 25 | onChange: (value: string) => void; 26 | onSend: () => void; 27 | uploadedFile?: UploadedFile; 28 | placeholder?: string; 29 | }) { 30 | const { selectedModelSlug, setModel, models } = useLLMModel(); 31 | // Autofocus logic 32 | const textareaRef = useRef(null); 33 | useEffect(() => { 34 | textareaRef.current?.focus(); 35 | }, []); 36 | 37 | const handleKeyDown = (e: React.KeyboardEvent) => { 38 | if (e.key === "Enter" && !e.shiftKey) { 39 | e.preventDefault(); 40 | onSend(); 41 | } 42 | }; 43 | 44 | const handlePaste = (e: React.ClipboardEvent) => { 45 | const pastedText = e.clipboardData.getData("text"); 46 | onChange(pastedText.trim()); 47 | e.preventDefault(); // Prevent default paste behavior 48 | }; 49 | 50 | return ( 51 |
52 |