This commit is contained in:
delibae 2026-02-20 14:20:40 +09:00
commit c543c0b263
72 changed files with 12239 additions and 0 deletions

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @open-prism/web build
- run: pnpm --filter @open-prism/latex-api build

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem

1
.npmrc Normal file
View file

@ -0,0 +1 @@
node-linker=hoisted

65
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,65 @@
# Contributing to Open-Prism
## Development Environment
### Requirements
- Node.js 20+
- pnpm 10+
- TeX Live (for local latex-api development)
### Setup
```bash
# Clone the repository
git clone https://github.com/assistant-ui/open-prism.git
cd open-prism
# Install dependencies
pnpm install
# Copy environment variables
cp apps/web/.env.example apps/web/.env.local
# Edit apps/web/.env.local with your configuration
# Start development
pnpm dev:web
```
### Running LaTeX API locally
```bash
# Requires TeX Live installed
cd apps/latex-api
pnpm dev
```
## Code Style
This project uses [Biome](https://biomejs.dev/) for linting and formatting.
```bash
# Check code
pnpm lint
# Auto-fix issues
pnpm lint:fix
```
## Pull Request Process
1. Fork the repository
2. Create a feature branch (`git checkout -b feat/my-feature`)
3. Make your changes
4. Run `pnpm lint` to ensure code quality
5. Commit with a descriptive message
6. Push to your fork and open a PR
### Commit Convention
Use conventional commits:
- `feat:` new feature
- `fix:` bug fix
- `docs:` documentation
- `refactor:` code refactoring
- `chore:` maintenance tasks

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 assistant-ui
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

91
README.md Normal file
View file

@ -0,0 +1,91 @@
# Open-Prism
Open-source AI-powered LaTeX writing workspace with live preview.
![Open-Prism Screenshot](./assets/OpenPrism.png)
## Features
- **AI-Assisted Writing** - Powered by assistant-ui for intelligent LaTeX assistance
- **Live PDF Preview** - Real-time compilation and preview of your documents
- **CodeMirror Editor** - Syntax highlighting and LaTeX language support
- **Local Storage** - Documents saved in browser IndexedDB
- **Dark/Light Theme** - Automatic theme switching support
## Quick Start
```bash
# Clone the repository
git clone https://github.com/assistant-ui/open-prism.git
cd open-prism
# Install dependencies
pnpm install
# Copy environment variables
cp apps/web/.env.example apps/web/.env.local
# Configure your environment variables in apps/web/.env.local
# - OPENAI_API_KEY: Your OpenAI API key
# - LATEX_API_URL: URL to the LaTeX compilation service
# - KV_REST_API_URL: KV REST API URL (for rate limiting)
# - KV_REST_API_TOKEN: KV REST API token
# Start development server
pnpm dev:web
```
## Project Structure
```
open-prism/
├── apps/
│ ├── web/ # Next.js frontend application
│ └── latex-api/ # LaTeX compilation API (Hono + TeX Live)
├── packages/ # Shared packages (if any)
├── biome.json # Biome linter configuration
└── turbo.json # Turborepo configuration
```
### apps/web
Next.js 16 application with:
- assistant-ui for AI chat interface
- CodeMirror for LaTeX editing
- react-pdf for PDF preview
- Upstash Redis for rate limiting
### apps/latex-api
Hono-based API for LaTeX compilation:
- Accepts LaTeX source code
- Compiles using TeX Live (pdflatex)
- Returns compiled PDF
## Deployment
### Web App (Vercel)
1. Import the repository to Vercel
2. Set root directory to `apps/web`
3. Configure environment variables:
- `OPENAI_API_KEY`
- `LATEX_API_URL`
- `KV_REST_API_URL`
- `KV_REST_API_TOKEN`
### LaTeX API (Docker)
```bash
cd apps/latex-api
docker build -t open-prism-latex-api .
docker run -p 3001:3001 open-prism-latex-api
```
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and contribution guidelines.
## License
[MIT](./LICENSE)

23
apps/latex-api/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM node:22-slim AS base
RUN corepack enable pnpm
RUN apt-get update && apt-get install -y --no-install-recommends \
texlive \
texlive-latex-extra \
texlive-pictures \
texlive-fonts-recommended \
texlive-science \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY . .
RUN pnpm build
EXPOSE 3001
CMD ["node", "dist/index.js"]

48
apps/latex-api/README.md Normal file
View file

@ -0,0 +1,48 @@
# LaTeX API
A simple LaTeX compilation API service built with Hono + Node.js.
## API
### `POST /builds/sync`
Compiles LaTeX documents and returns PDF.
**Request:**
```json
{
"compiler": "pdflatex",
"resources": [
{
"path": "main.tex",
"content": "\\documentclass{article}\\begin{document}Hello\\end{document}",
"main": true
},
{
"path": "image.png",
"file": "<base64-encoded-content>"
}
]
}
```
**Response:**
- Success: `application/pdf` binary
- Failure: `application/json` with `{ error, log_files }`
## Local Development
```bash
# Install dependencies (from workspace root)
pnpm install
# Run development server
pnpm dev
# Test
curl -X POST http://localhost:3001/builds/sync \
-H "Content-Type: application/json" \
-d '{"compiler":"pdflatex","resources":[{"main":true,"content":"\\documentclass{article}\\begin{document}Hello\\end{document}"}]}'
```
Requires TeX Live installed locally for development.

View file

@ -0,0 +1,21 @@
{
"name": "@open-prism/latex-api",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.6.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"typescript": "^5.7.0"
}
}

220
apps/latex-api/src/index.ts Normal file
View file

@ -0,0 +1,220 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { bodyLimit } from "hono/body-limit";
import { mkdir, rm, writeFile, readFile, access } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
const app = new Hono();
const MAX_CONCURRENT = 3;
const COMPILE_TIMEOUT_MS = 30000;
let activeCompilations = 0;
function sanitizePath(workDir: string, filePath: string): string | null {
if (filePath.includes("..")) return null;
const normalized = resolve(workDir, filePath);
if (!normalized.startsWith(`${workDir}/`) && normalized !== workDir) {
return null;
}
return normalized;
}
app.use("/*", cors());
app.use("/*", bodyLimit({ maxSize: 10 * 1024 * 1024 }));
interface Resource {
path?: string;
content?: string;
file?: string;
main?: boolean;
}
interface CompileRequest {
compiler?: string;
resources: Resource[];
}
interface CompileError {
error: string;
log_files?: Record<string, string>;
}
app.get("/", (c) => {
return c.json({ status: "ok", service: "latex-api" });
});
app.post("/builds/sync", async (c) => {
if (activeCompilations >= MAX_CONCURRENT) {
return c.json(
{ error: "Server busy, try again later" } satisfies CompileError,
503,
);
}
const body = await c.req.json<CompileRequest>();
const { compiler = "pdflatex", resources } = body;
if (!resources || resources.length === 0) {
return c.json(
{ error: "No resources provided" } satisfies CompileError,
400,
);
}
const mainResource = resources.find((r) => r.main) || resources[0];
const mainPath = mainResource.path || "main.tex";
const mainFileName = mainPath.replace(/\.tex$/, "");
const workDir = join(tmpdir(), `latex-${randomUUID()}`);
await mkdir(workDir, { recursive: true });
activeCompilations++;
try {
const hasBib = resources.some((r) => r.path?.endsWith(".bib"));
for (const resource of resources) {
const filePath =
resource.path || (resource.main ? "main.tex" : `file-${randomUUID()}`);
const fullPath = sanitizePath(workDir, filePath);
if (!fullPath) {
return c.json({ error: "Invalid path" } satisfies CompileError, 400);
}
const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/"));
if (parentDir && parentDir !== workDir) {
await mkdir(parentDir, { recursive: true });
}
if (resource.file) {
const buffer = Buffer.from(resource.file, "base64");
await writeFile(fullPath, buffer);
} else if (resource.content) {
await writeFile(fullPath, resource.content, "utf-8");
}
}
const compilerCmd =
compiler === "xelatex"
? "xelatex"
: compiler === "lualatex"
? "lualatex"
: "pdflatex";
const runWithTimeout = (
cmd: string[],
): Promise<{ exitCode: number; timedOut: boolean }> => {
return new Promise((resolve) => {
const [command, ...args] = cmd;
const proc = spawn(command, args, {
cwd: workDir,
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
proc.kill();
}, COMPILE_TIMEOUT_MS);
proc.on("close", (code) => {
clearTimeout(timeout);
resolve({ exitCode: code ?? 1, timedOut });
});
proc.on("error", () => {
clearTimeout(timeout);
resolve({ exitCode: 1, timedOut: false });
});
});
};
const latexCmd = [compilerCmd, "-interaction=nonstopmode", mainPath];
if (hasBib) {
let result = await runWithTimeout(latexCmd);
if (result.timedOut) {
return c.json(
{ error: "Compilation timed out" } satisfies CompileError,
500,
);
}
const auxPath = join(workDir, `${mainFileName}.aux`);
const auxExists = await access(auxPath)
.then(() => true)
.catch(() => false);
if (auxExists) {
result = await runWithTimeout(["bibtex", mainFileName]);
if (result.timedOut) {
return c.json(
{ error: "BibTeX timed out" } satisfies CompileError,
500,
);
}
}
for (let i = 0; i < 2; i++) {
result = await runWithTimeout(latexCmd);
if (result.timedOut) {
return c.json(
{ error: "Compilation timed out" } satisfies CompileError,
500,
);
}
}
} else {
for (let i = 0; i < 2; i++) {
const result = await runWithTimeout(latexCmd);
if (result.timedOut) {
return c.json(
{ error: "Compilation timed out" } satisfies CompileError,
500,
);
}
}
}
const pdfPath = join(workDir, `${mainFileName}.pdf`);
const logPath = join(workDir, `${mainFileName}.log`);
let logContent = "";
try {
logContent = await readFile(logPath, "utf-8");
} catch {}
try {
const pdfBuffer = await readFile(pdfPath);
return new Response(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename=${mainFileName}.pdf`,
},
});
} catch {
return c.json(
{
error: "Compilation failed",
log_files: {
"__main_document__.log": logContent,
},
} satisfies CompileError,
500,
);
}
} finally {
activeCompilations--;
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
});
const port = parseInt(process.env.PORT || "3001", 10);
serve({
fetch: app.fetch,
port,
});
console.log(`LaTeX API server running on port ${port}`);

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node20",
outDir: "dist",
clean: true,
splitting: false,
});

5
apps/web/.env.example Normal file
View file

@ -0,0 +1,5 @@
OPENAI_API_KEY=""
LATEX_API_URL=""
KV_REST_API_URL=""
KV_REST_API_TOKEN=""

42
apps/web/.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# 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 (can opt-in for committing if needed)
.env
.env.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
apps/web/README.md Normal file
View file

@ -0,0 +1 @@
# Open Prism

View file

@ -0,0 +1,82 @@
import { openai } from "@ai-sdk/openai";
import { frontendTools } from "@assistant-ui/react-ai-sdk";
import {
streamText,
convertToModelMessages,
stepCountIs,
type ToolSet,
} from "ai";
import { NextResponse } from "next/server";
import { chatRatelimit, getIP } from "@/lib/ratelimit";
export const maxDuration = 30;
const SYSTEM_PROMPT = `You are a helpful LaTeX assistant. You help users write and edit LaTeX documents.
When providing LaTeX code:
- Use proper LaTeX syntax
- Explain what each part does
- Suggest best practices
- Use code blocks with \`\`\`latex for LaTeX code
You have access to the user's current document which is provided in the context.
When the user asks you to help with their document:
- Reference specific parts of their document
- Suggest improvements and fixes
- Provide complete code snippets they can use
You have tools available to directly modify the document:
- Use insert_latex to insert code at the user's cursor position
- Use replace_selection to replace selected text (only when user has selected text)
- Use find_and_replace to find and replace specific text in the document
When the user asks you to add, insert, or write LaTeX code to their document, use the insert_latex tool.
When the user asks you to replace or modify selected text, use the replace_selection tool.
When the user asks you to change, modify, or replace specific text in the document, use the find_and_replace tool.
Common tasks you help with:
- Writing mathematical equations
- Document structure (sections, chapters)
- Tables and figures
- Bibliography and citations
- Formatting and styling
- Package recommendations
- Debugging LaTeX errors`;
export async function POST(req: Request) {
if (chatRatelimit) {
const ip = getIP(req);
const { success, limit, remaining, reset } = await chatRatelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
}
const { messages, system, tools } = await req.json();
const fullSystemPrompt = system
? `${SYSTEM_PROMPT}\n\n${system}`
: SYSTEM_PROMPT;
const result = streamText({
model: openai("gpt-4o"),
system: fullSystemPrompt,
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(10),
tools: frontendTools(tools) as unknown as ToolSet,
});
return result.toUIMessageStreamResponse();
}

View file

@ -0,0 +1,125 @@
import { NextResponse } from "next/server";
import { compileRatelimit, getIP } from "@/lib/ratelimit";
export const maxDuration = 60;
interface CompileResource {
path: string;
content?: string;
file?: string;
main?: boolean;
}
export async function POST(req: Request) {
if (compileRatelimit) {
const ip = getIP(req);
const { success, limit, remaining, reset } =
await compileRatelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
}
try {
const { resources } = (await req.json()) as {
resources: CompileResource[];
};
if (!resources || resources.length === 0) {
return NextResponse.json(
{ error: "No resources provided" },
{ status: 400 },
);
}
const apiResources = resources.map((r) => {
const resource: Record<string, unknown> = {
path: r.path,
};
const isBase64Image =
r.content &&
!r.file &&
(r.content.startsWith("/9j/") || r.content.startsWith("iVBOR"));
if (isBase64Image) {
const cleanBase64 = r.content?.replace(/\s/g, "");
resource.file = cleanBase64;
} else if (r.content) {
resource.content = r.content;
}
if (r.file) {
const cleanBase64 = r.file.replace(/\s/g, "");
resource.file = cleanBase64;
}
if (r.main) resource.main = r.main;
return resource;
});
const latexApiUrl = process.env.LATEX_API_URL || "http://localhost:3001";
const response = await fetch(`${latexApiUrl}/builds/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
compiler: "pdflatex",
resources: apiResources,
}),
});
const contentType = response.headers.get("content-type") ?? "";
if (!response.ok || contentType.includes("application/json")) {
const errorData = await response.json();
const logContent = errorData.log_files?.["__main_document__.log"] ?? "";
const errorLines = logContent
.split("\n")
.filter(
(line: string) =>
line.includes("Error") ||
line.includes("!") ||
line.includes("Missing"),
)
.slice(0, 10)
.join("\n");
return NextResponse.json(
{
error: `Compilation failed: ${errorData.error || "Unknown error"}`,
details: errorLines || logContent.slice(-1000),
},
{ status: 500 },
);
}
const pdfBuffer = await response.arrayBuffer();
return new NextResponse(pdfBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "inline; filename=document.pdf",
},
});
} catch (error) {
console.error("Compilation error:", error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Unknown compilation error",
},
{ status: 500 },
);
}
}

BIN
apps/web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

50
apps/web/app/layout.tsx Normal file
View file

@ -0,0 +1,50 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css";
import { RootProvider } from "./provider";
import { cn } from "@/lib/utils";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Open-Prism | LaTeX Writing Workspace",
description: "AI-powered LaTeX writing workspace inspired by OpenAI Prism",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full" suppressHydrationWarning>
<head>
{process.env.NODE_ENV === "development" && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
</head>
<body
className={cn(
geistSans.className,
geistMono.variable,
"h-full antialiased",
)}
>
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}

11
apps/web/app/page.tsx Normal file
View file

@ -0,0 +1,11 @@
"use client";
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
export default function Home() {
return (
<main className="h-full">
<WorkspaceLayout />
</main>
);
}

26
apps/web/app/provider.tsx Normal file
View file

@ -0,0 +1,26 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { ThemeProvider } from "next-themes";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import type { ReactNode } from "react";
import { Toaster } from "@/components/ui/sonner";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
export function RootProvider({ children }: { children: ReactNode }) {
const runtime = useChatRuntime({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
useKeyboardShortcuts();
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<AssistantRuntimeProvider runtime={runtime}>
{children}
<Toaster />
</AssistantRuntimeProvider>
</ThemeProvider>
);
}

22
apps/web/components.json Normal file
View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,19 @@
"use client";
import { Thread } from "./thread";
import { useDocumentContext } from "@/hooks/use-document-context";
export function AssistantPanel() {
useDocumentContext();
return (
<div className="flex h-full flex-col border-border border-l bg-background">
<div className="flex items-center border-border border-b px-4 py-2">
<h2 className="font-medium text-sm">AI Assistant</h2>
</div>
<div className="flex-1 overflow-hidden">
<Thread />
</div>
</div>
);
}

View file

@ -0,0 +1,229 @@
"use client";
import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
} from "@assistant-ui/react";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
RefreshCwIcon,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
import remarkGfm from "remark-gfm";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export const Thread: FC = () => {
return (
<ThreadPrimitive.Root className="aui-root aui-thread-root flex h-full flex-col bg-background">
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
>
<AuiIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex w-full flex-col gap-4 overflow-visible rounded-t-3xl pb-4">
<ThreadScrollToBottom />
<Composer />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const ThreadWelcome: FC = () => {
return (
<div className="mx-auto my-auto flex w-full grow flex-col items-center justify-center px-4">
<h1 className="mb-2 font-semibold text-xl">LaTeX Assistant</h1>
<p className="text-center text-muted-foreground text-sm">
Ask me anything about LaTeX! I can help you with equations, document
structure, formatting, and more.
</p>
</div>
);
};
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="relative flex w-full flex-col">
<div className="flex w-full flex-col rounded-2xl border border-input bg-background px-1 pt-2 outline-none transition-shadow has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-ring/20">
<ComposerPrimitive.Input
placeholder="Ask about LaTeX..."
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
autoFocus
aria-label="Message input"
/>
<ComposerAction />
</div>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
return (
<div className="relative mx-2 mb-2 flex items-center justify-end">
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send message"
side="bottom"
type="submit"
variant="default"
size="icon"
className="size-8 rounded-full"
aria-label="Send message"
>
<ArrowUpIcon className="size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="relative w-full py-3"
data-role="assistant"
>
<div className="px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
}}
/>
</div>
<div className="mt-1 ml-2 flex">
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const MarkdownText: FC = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md prose prose-sm dark:prose-invert max-w-none"
/>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="-ml-1 flex gap-1 text-muted-foreground"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={({ message }) => !message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Regenerate">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="flex w-full flex-col items-end py-3"
data-role="user"
>
<div className="max-w-[85%] rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<MessagePrimitive.Parts />
</div>
<BranchPicker className="mr-2 justify-end" />
</MessagePrimitive.Root>
);
};
const BranchPicker: FC<{ className?: string }> = ({ className }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
className,
)}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};

View file

@ -0,0 +1,42 @@
"use client";
import { ComponentPropsWithRef, forwardRef } from "react";
import { Slottable } from "@radix-ui/react-slot";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("size-6 p-1", className)}
ref={ref}
>
<Slottable>{children}</Slottable>
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
);
});
TooltipIconButton.displayName = "TooltipIconButton";

View file

@ -0,0 +1,64 @@
import * as React from "react";
import { Slot as SlotPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-semibold text-lg leading-none", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View file

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };

View file

@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View file

@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-9 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
position === "popper" &&
"data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View file

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -0,0 +1,40 @@
"use client";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View file

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"field-sizing-content flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };

View file

@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View file

@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,384 @@
"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useThreadRuntime,
} from "@assistant-ui/react";
import {
MarkdownTextPrimitive,
type SyntaxHighlighterProps,
} from "@assistant-ui/react-markdown";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
LoaderIcon,
MessageCircleIcon,
PlusIcon,
RefreshCwIcon,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
import { useDocumentStore } from "@/stores/document-store";
import { useDocumentContext } from "@/hooks/use-document-context";
const MIN_HEIGHT = 150;
const DEFAULT_HEIGHT = 180;
export function AIDrawer() {
useDocumentContext();
const threadRuntime = useThreadRuntime();
const [isOpen, setIsOpen] = useState(false);
const [height, setHeight] = useState(DEFAULT_HEIGHT);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const hasDraggedRef = useRef(false);
const heightRef = useRef(height);
heightRef.current = height;
useEffect(() => {
return threadRuntime.subscribe(() => {
const state = threadRuntime.getState();
if (state.isRunning) {
setIsOpen(true);
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
setHeight(maxHeight);
heightRef.current = maxHeight;
if (panelRef.current) {
panelRef.current.style.height = `${maxHeight}px`;
}
}
});
}, [threadRuntime]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
hasDraggedRef.current = false;
const startY = e.clientY;
const startHeight = heightRef.current;
const handleMouseMove = (e: MouseEvent) => {
hasDraggedRef.current = true;
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
const delta = startY - e.clientY;
const newHeight = Math.min(
Math.max(startHeight + delta, MIN_HEIGHT),
maxHeight,
);
heightRef.current = newHeight;
if (panelRef.current) {
panelRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => {
setIsDragging(false);
setHeight(heightRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-4 pb-6"
>
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn(
"pointer-events-auto absolute right-4 bottom-6 flex size-12 items-center justify-center rounded-full border border-border bg-background shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl",
isOpen
? "pointer-events-none scale-50 opacity-0"
: "scale-100 opacity-100",
)}
aria-label="Open AI Assistant"
>
<MessageCircleIcon className="size-5 text-foreground" />
</button>
<ThreadPrimitive.Root
ref={panelRef}
className={cn(
"aui-root pointer-events-auto flex w-full max-w-2xl origin-bottom flex-col overflow-hidden rounded-3xl border border-border bg-background shadow-2xl transition-all duration-300 ease-out",
isOpen
? "scale-100 opacity-100"
: "pointer-events-none scale-95 opacity-0",
isDragging && "!transition-none",
)}
style={{ height: isOpen ? height : 0 }}
data-dragging={isDragging}
>
<div
className="group flex cursor-row-resize items-center justify-center gap-2 py-2 transition-colors hover:bg-muted/50"
onMouseDown={handleMouseDown}
onClick={() => {
if (!hasDraggedRef.current) {
setIsOpen(false);
}
}}
>
<div className="h-1 w-10 rounded-full bg-muted-foreground/30 transition-all group-hover:w-8" />
<ChevronDownIcon className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<ThreadMessages />
<Composer />
</ThreadPrimitive.Root>
</div>
);
}
const ThreadMessages: FC = () => {
return (
<div className="relative min-h-0 flex-1 overflow-hidden">
<ThreadPrimitive.Viewport
turnAnchor="bottom"
className="aui-thread-viewport absolute inset-0 overflow-y-auto scroll-smooth px-4"
>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
}}
/>
<ThreadLoading />
</ThreadPrimitive.Viewport>
<ThreadScrollToBottom />
</div>
);
};
const ThreadLoading: FC = () => {
return (
<AuiIf condition={({ thread }) => thread.isRunning}>
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span className="text-sm">Thinking...</span>
</div>
</AuiIf>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="absolute right-4 bottom-2 z-10 rounded-full p-2 disabled:invisible dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon className="size-4" />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="shrink-0 p-3">
<div className="flex w-full flex-col rounded-2xl border border-input bg-muted/30 transition-colors focus-within:border-ring focus-within:bg-background">
<ComposerPrimitive.Input
placeholder="Ask about LaTeX..."
className="max-h-40 min-h-10 w-full resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
autoFocus
aria-label="Message input"
/>
<div className="flex items-center justify-end px-2 pb-2">
<ComposerAction />
</div>
</div>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
return (
<>
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send"
side="top"
type="submit"
variant="default"
size="icon"
className="size-8 rounded-full"
aria-label="Send message"
>
<ArrowUpIcon className="size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<TooltipIconButton
tooltip="Stop"
side="top"
variant="secondary"
size="icon"
className="size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="size-3 fill-current" />
</TooltipIconButton>
</ComposerPrimitive.Cancel>
</AuiIf>
</>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="group relative w-full py-1.5"
data-role="assistant"
>
<div className="px-1 text-foreground text-sm leading-relaxed">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
}}
/>
</div>
<div className="ml-1 flex">
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const CodeBlock: FC<SyntaxHighlighterProps> = ({ language, code }) => {
const insertAtCursor = useDocumentStore((s) => s.insertAtCursor);
const isLatex = language === "latex" || language === "tex";
const handleInsert = useCallback(() => {
insertAtCursor(code);
}, [insertAtCursor, code]);
return (
<div className="group relative my-1">
<pre className="overflow-x-auto rounded bg-muted p-2 text-sm">
<code>{code}</code>
</pre>
{isLatex && (
<button
type="button"
onClick={handleInsert}
className="absolute top-1 right-1 flex items-center gap-0.5 rounded bg-primary px-1.5 py-0.5 text-primary-foreground text-xs opacity-0 transition-opacity group-hover:opacity-100"
>
<PlusIcon className="size-3" />
Insert
</button>
)}
</div>
);
};
const MarkdownText: FC = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
SyntaxHighlighter: CodeBlock,
}}
className="aui-md prose prose-sm dark:prose-invert max-w-none"
/>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
className="-ml-1 flex gap-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={({ message }) => !message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Regenerate">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="flex w-full flex-col items-end py-1.5"
data-role="user"
>
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
<MessagePrimitive.Parts />
</div>
<BranchPicker className="mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
const BranchPicker: FC<{ className?: string }> = ({ className }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
className,
)}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};

View file

@ -0,0 +1,193 @@
"use client";
import { RefObject } from "react";
import type { EditorView } from "@codemirror/view";
import {
BoldIcon,
ItalicIcon,
ListIcon,
Heading1Icon,
Heading2Icon,
CodeIcon,
FunctionSquareIcon,
FileTextIcon,
ImageIcon,
MinusIcon,
PlusIcon,
} from "lucide-react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useDocumentStore } from "@/stores/document-store";
const ZOOM_OPTIONS = [
{ value: "0.5", label: "50%" },
{ value: "0.75", label: "75%" },
{ value: "1", label: "100%" },
{ value: "1.25", label: "125%" },
{ value: "1.5", label: "150%" },
{ value: "2", label: "200%" },
{ value: "3", label: "300%" },
{ value: "4", label: "400%" },
];
interface EditorToolbarProps {
editorView: RefObject<EditorView | null>;
fileType?: "tex" | "image";
imageScale?: number;
onImageScaleChange?: (scale: number) => void;
}
export function EditorToolbar({
editorView,
fileType = "tex",
imageScale = 1,
onImageScaleChange,
}: EditorToolbarProps) {
const fileName = useDocumentStore((s) => {
const activeFile = s.files.find((f) => f.id === s.activeFileId);
return activeFile?.name ?? "document.tex";
});
const insertText = (before: string, after: string = "") => {
const view = editorView.current;
if (!view) return;
const { from, to } = view.state.selection.main;
const selectedText = view.state.sliceDoc(from, to);
view.dispatch({
changes: {
from,
to,
insert: before + selectedText + after,
},
selection: {
anchor: from + before.length,
head: from + before.length + selectedText.length,
},
});
view.focus();
};
const wrapSelection = (wrapper: string) => {
insertText(wrapper, wrapper);
};
const zoomIn = () => onImageScaleChange?.(Math.min(4, imageScale + 0.25));
const zoomOut = () => onImageScaleChange?.(Math.max(0.25, imageScale - 0.25));
if (fileType === "image") {
return (
<div className="flex h-9 items-center justify-between border-border border-b bg-muted/30 px-2">
<div className="flex items-center gap-1">
<ImageIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-muted-foreground text-sm">
{fileName}
</span>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={zoomOut}
disabled={imageScale <= 0.25}
>
<MinusIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={zoomIn}
disabled={imageScale >= 4}
>
<PlusIcon className="size-3.5" />
</Button>
<Select
value={imageScale.toString()}
onValueChange={(v) => onImageScaleChange?.(Number(v))}
>
<SelectTrigger size="sm" className="h-6! w-auto text-xs">
<SelectValue>{Math.round(imageScale * 100)}%</SelectValue>
</SelectTrigger>
<SelectContent>
{ZOOM_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
return (
<div className="flex h-9 items-center gap-1 border-border border-b bg-muted/30 px-2">
<FileTextIcon className="size-4 text-muted-foreground" />
<span className="mr-2 font-medium text-muted-foreground text-sm">
{fileName}
</span>
<div className="mx-2 h-4 w-px bg-border" />
<TooltipIconButton
tooltip="Bold (\\textbf)"
onClick={() => insertText("\\textbf{", "}")}
>
<BoldIcon className="size-4" />
</TooltipIconButton>
<TooltipIconButton
tooltip="Italic (\\textit)"
onClick={() => insertText("\\textit{", "}")}
>
<ItalicIcon className="size-4" />
</TooltipIconButton>
<TooltipIconButton
tooltip="Code (\\texttt)"
onClick={() => insertText("\\texttt{", "}")}
>
<CodeIcon className="size-4" />
</TooltipIconButton>
<div className="mx-2 h-4 w-px bg-border" />
<TooltipIconButton
tooltip="Section"
onClick={() => insertText("\\section{", "}")}
>
<Heading1Icon className="size-4" />
</TooltipIconButton>
<TooltipIconButton
tooltip="Subsection"
onClick={() => insertText("\\subsection{", "}")}
>
<Heading2Icon className="size-4" />
</TooltipIconButton>
<TooltipIconButton
tooltip="List item"
onClick={() => insertText("\\item ")}
>
<ListIcon className="size-4" />
</TooltipIconButton>
<div className="mx-2 h-4 w-px bg-border" />
<TooltipIconButton
tooltip="Inline math ($...$)"
onClick={() => wrapSelection("$")}
>
<FunctionSquareIcon className="size-4" />
</TooltipIconButton>
<TooltipIconButton
tooltip="Display math (\\[...\\])"
onClick={() => insertText("\\[\n ", "\n\\]")}
>
<span className="font-mono text-xs"></span>
</TooltipIconButton>
</div>
);
}

View file

@ -0,0 +1,36 @@
"use client";
import { ImageIcon } from "lucide-react";
import type { ProjectFile } from "@/stores/document-store";
interface ImagePreviewProps {
file: ProjectFile;
scale: number;
}
export function ImagePreview({ file, scale }: ImagePreviewProps) {
if (!file.dataUrl) {
return (
<div className="flex h-full flex-col items-center justify-center bg-muted/30 p-8">
<ImageIcon className="mb-4 size-16 text-muted-foreground/50" />
<p className="text-muted-foreground text-sm">No image data available</p>
</div>
);
}
return (
<div className="h-full overflow-auto bg-muted/50 p-4">
<div className="flex justify-center">
<img
src={file.dataUrl}
alt={file.name}
style={{
transform: `scale(${scale})`,
transformOrigin: "top center",
}}
className="max-w-none transition-transform"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,593 @@
"use client";
import { useEffect, useRef, useState, useMemo } from "react";
import { EditorState, Prec } from "@codemirror/state";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
highlightActiveLineGutter,
scrollPastEnd,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
insertNewlineAndIndent,
} from "@codemirror/commands";
import { syntaxHighlighting } from "@codemirror/language";
import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark";
import {
search,
highlightSelectionMatches,
SearchQuery,
setSearchQuery as setSearchQueryEffect,
findNext,
findPrevious,
} from "@codemirror/search";
import { latex } from "codemirror-lang-latex";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { compileLatex, type CompileResource } from "@/lib/latex-compiler";
import { EditorToolbar } from "./editor-toolbar";
import { AIDrawer } from "./ai-drawer";
import { ImagePreview } from "./image-preview";
import { LatexTools } from "./latex-tools";
import { SearchPanel } from "./search-panel";
interface StickyItem {
type: "section" | "begin";
name: string;
content: string;
html: string;
line: number;
}
interface ParsedLine {
type: "section" | "begin" | "end";
name: string;
content: string;
line: number;
}
function parseLatexStructure(content: string): ParsedLine[] {
const lines = content.split("\n");
const result: ParsedLine[] = [];
const sectionRegex =
/\\(part|chapter|section|subsection|subsubsection)\*?\s*\{[^}]*\}/;
const beginRegex = /\\begin\{([^}]+)\}/;
const endRegex = /\\end\{([^}]+)\}/;
lines.forEach((lineContent, index) => {
const sectionMatch = lineContent.match(sectionRegex);
if (sectionMatch) {
result.push({
type: "section",
name: sectionMatch[1],
content: lineContent,
line: index + 1,
});
return;
}
const beginMatch = lineContent.match(beginRegex);
if (beginMatch) {
result.push({
type: "begin",
name: beginMatch[1],
content: lineContent,
line: index + 1,
});
return;
}
const endMatch = lineContent.match(endRegex);
if (endMatch) {
result.push({
type: "end",
name: endMatch[1],
content: lineContent,
line: index + 1,
});
}
});
return result;
}
function getStickyLines(
parsedLines: ParsedLine[],
currentLine: number,
): StickyItem[] {
const stack: StickyItem[] = [];
const sectionLevelMap: Record<string, number> = {
part: 0,
chapter: 1,
section: 2,
subsection: 3,
subsubsection: 4,
};
for (const item of parsedLines) {
if (item.line > currentLine) break;
if (item.type === "section") {
const level = sectionLevelMap[item.name] ?? 2;
while (
stack.length > 0 &&
stack[stack.length - 1].type === "section" &&
sectionLevelMap[stack[stack.length - 1].name] >= level
) {
stack.pop();
}
stack.push({
type: "section",
name: item.name,
content: item.content,
html: "",
line: item.line,
});
} else if (item.type === "begin") {
stack.push({
type: "begin",
name: item.name,
content: item.content,
html: "",
line: item.line,
});
} else if (item.type === "end") {
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].type === "begin" && stack[i].name === item.name) {
stack.splice(i, 1);
break;
}
}
}
}
return stack;
}
function gatherResources(files: ProjectFile[]): CompileResource[] {
return files.map((f) => {
if (f.type === "tex") {
return {
path: f.name,
content: f.content ?? "",
main: f.name === "document.tex",
};
}
const dataUrl = f.dataUrl ?? "";
let base64 = dataUrl.includes(",") ? dataUrl.split(",")[1] : dataUrl;
base64 = base64.replace(/\s/g, "");
return {
path: f.name,
file: base64,
};
});
}
function getActiveFileContent(): string {
const state = useDocumentStore.getState();
const activeFile = state.files.find((f) => f.id === state.activeFileId);
return activeFile?.content ?? "";
}
export function LatexEditor() {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const files = useDocumentStore((s) => s.files);
const activeFileId = useDocumentStore((s) => s.activeFileId);
const setContent = useDocumentStore((s) => s.setContent);
const setCursorPosition = useDocumentStore((s) => s.setCursorPosition);
const setSelectionRange = useDocumentStore((s) => s.setSelectionRange);
const jumpToPosition = useDocumentStore((s) => s.jumpToPosition);
const clearJumpRequest = useDocumentStore((s) => s.clearJumpRequest);
const isCompiling = useDocumentStore((s) => s.isCompiling);
const setIsCompiling = useDocumentStore((s) => s.setIsCompiling);
const setPdfData = useDocumentStore((s) => s.setPdfData);
const setCompileError = useDocumentStore((s) => s.setCompileError);
const activeFile = files.find((f) => f.id === activeFileId);
const isTexFile = activeFile?.type === "tex";
const activeFileContent = activeFile?.content;
const [imageScale, setImageScale] = useState(0.5);
const [currentLine, setCurrentLine] = useState(1);
const [gutterWidth, setGutterWidth] = useState(0);
const [lineHtmlCache, setLineHtmlCache] = useState<Record<number, string>>(
{},
);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [matchCount, setMatchCount] = useState(0);
const [currentMatch, setCurrentMatch] = useState(0);
const parsedLines = useMemo(
() => parseLatexStructure(activeFileContent ?? ""),
[activeFileContent],
);
const stickyLines = useMemo(() => {
const items = getStickyLines(parsedLines, currentLine);
return items.map((item) => ({
...item,
html: lineHtmlCache[item.line] || "",
}));
}, [parsedLines, currentLine, lineHtmlCache]);
const compileRef = useRef<() => void>(() => {});
const isSearchOpenRef = useRef(false);
useEffect(() => {
isSearchOpenRef.current = isSearchOpen;
}, [isSearchOpen]);
useEffect(() => {
if (!searchQuery || !activeFileContent) {
setMatchCount(0);
setCurrentMatch(0);
return;
}
const regex = new RegExp(
searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"gi",
);
const matches = activeFileContent.match(regex);
setMatchCount(matches?.length ?? 0);
if (matches && matches.length > 0) {
setCurrentMatch(1);
} else {
setCurrentMatch(0);
}
}, [searchQuery, activeFileContent]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "f") {
e.preventDefault();
setIsSearchOpen(true);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const query = new SearchQuery({
search: searchQuery,
caseSensitive: false,
literal: true,
});
view.dispatch({
effects: setSearchQueryEffect.of(query),
});
if (searchQuery) {
findNext(view);
}
}, [searchQuery]);
const handleFindNext = () => {
const view = viewRef.current;
if (!view) return;
findNext(view);
view.focus();
};
const handleFindPrevious = () => {
const view = viewRef.current;
if (!view) return;
findPrevious(view);
view.focus();
};
compileRef.current = async () => {
if (isCompiling) return;
setIsCompiling(true);
try {
const currentFiles = useDocumentStore.getState().files;
const resources = gatherResources(currentFiles);
const data = await compileLatex(resources);
setPdfData(data);
} catch (error) {
setCompileError(
error instanceof Error ? error.message : "Compilation failed",
);
} finally {
setIsCompiling(false);
}
};
useEffect(() => {
if (!containerRef.current || !isTexFile) return;
const currentContent = getActiveFileContent();
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
setContent(update.state.doc.toString());
}
if (update.selectionSet) {
const { from, to, head } = update.state.selection.main;
setCursorPosition(head);
if (from !== to) {
setSelectionRange({ start: from, end: to });
} else {
setSelectionRange(null);
}
}
});
const scrollListener = EditorView.domEventHandlers({
scroll: (_, view) => {
const scrollTop = view.scrollDOM.scrollTop;
const lineBlock = view.lineBlockAtHeight(scrollTop);
const lineNumber = view.state.doc.lineAt(lineBlock.from).number;
setCurrentLine(lineNumber);
const gutter = view.dom.querySelector(".cm-gutters");
if (gutter) {
setGutterWidth(gutter.getBoundingClientRect().width);
}
const cmLines = view.dom.querySelectorAll(".cm-line");
const newCache: Record<number, string> = {};
cmLines.forEach((el) => {
const lineInfo = view.lineBlockAt(
view.posAtDOM(el as HTMLElement, 0),
);
const ln = view.state.doc.lineAt(lineInfo.from).number;
newCache[ln] = el.innerHTML;
});
setLineHtmlCache((prev) => ({ ...prev, ...newCache }));
},
});
const compileKeymap = Prec.highest(
keymap.of([
{
key: "Enter",
run: (view) => {
if (isSearchOpenRef.current) {
findNext(view);
return true;
}
compileRef.current();
return true;
},
},
{
key: "Shift-Enter",
run: (view) => {
if (isSearchOpenRef.current) {
findPrevious(view);
return true;
}
return insertNewlineAndIndent(view);
},
},
{
key: "Mod-s",
run: () => {
const { setIsSaving } = useDocumentStore.getState();
setIsSaving(true);
setTimeout(() => setIsSaving(false), 1000);
return true;
},
},
{
key: "Mod-f",
run: () => {
setIsSearchOpen(true);
return true;
},
},
{
key: "Escape",
run: () => {
if (isSearchOpenRef.current) {
setIsSearchOpen(false);
return true;
}
return false;
},
},
]),
);
const state = EditorState.create({
doc: currentContent,
extensions: [
compileKeymap,
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
latex(),
oneDark,
syntaxHighlighting(oneDarkHighlightStyle),
search(),
highlightSelectionMatches(),
updateListener,
scrollListener,
EditorView.lineWrapping,
scrollPastEnd(),
EditorView.theme({
"&": {
height: "100%",
fontSize: "14px",
},
".cm-scroller": {
overflow: "auto",
},
".cm-gutters": {
paddingRight: "4px",
},
".cm-lineNumbers .cm-gutterElement": {
paddingLeft: "8px",
paddingRight: "4px",
},
".cm-content": {
paddingLeft: "8px",
paddingRight: "12px",
},
".cm-searchMatch": {
backgroundColor: "#facc15 !important",
color: "#000 !important",
borderRadius: "2px",
boxShadow: "0 0 0 1px #eab308",
},
".cm-searchMatch-selected": {
backgroundColor: "#f97316 !important",
color: "#fff !important",
borderRadius: "2px",
boxShadow: "0 0 0 2px #ea580c",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "rgba(100, 150, 255, 0.3)",
},
}),
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [
activeFileId,
isTexFile,
setContent,
setCursorPosition,
setSelectionRange,
]);
useEffect(() => {
const view = viewRef.current;
if (!view || !isTexFile) return;
const content = activeFileContent ?? "";
const currentContent = view.state.doc.toString();
if (currentContent !== content) {
view.dispatch({
changes: {
from: 0,
to: currentContent.length,
insert: content,
},
});
}
}, [activeFileContent, isTexFile]);
useEffect(() => {
const view = viewRef.current;
if (!view || jumpToPosition === null) return;
view.dispatch({
selection: { anchor: jumpToPosition },
effects: EditorView.scrollIntoView(jumpToPosition, { y: "center" }),
});
view.focus();
clearJumpRequest();
}, [jumpToPosition, clearJumpRequest]);
if (!isTexFile && activeFile) {
return (
<div className="flex h-full flex-col bg-background">
<EditorToolbar
editorView={viewRef}
fileType="image"
imageScale={imageScale}
onImageScaleChange={setImageScale}
/>
<div className="relative min-h-0 flex-1 overflow-hidden">
<ImagePreview file={activeFile} scale={imageScale} />
<AIDrawer />
</div>
</div>
);
}
return (
<div className="flex h-full flex-col bg-background">
<EditorToolbar editorView={viewRef} />
{isSearchOpen && (
<SearchPanel
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onClose={() => {
setIsSearchOpen(false);
setSearchQuery("");
viewRef.current?.focus();
}}
onFindNext={handleFindNext}
onFindPrevious={handleFindPrevious}
matchCount={matchCount}
currentMatch={currentMatch}
/>
)}
<div className="relative min-h-0 flex-1 overflow-hidden">
{stickyLines.length > 0 && (
<div className="absolute inset-x-0 top-0 z-10 border-border border-b bg-[#282c34] font-mono text-[14px] leading-[1.4] shadow-md">
{stickyLines.map((section) => (
<div
key={section.line}
className="flex cursor-pointer items-center hover:bg-white/5"
onClick={() => {
const view = viewRef.current;
if (!view) return;
const line = view.state.doc.line(section.line);
view.dispatch({
selection: { anchor: line.from },
effects: EditorView.scrollIntoView(line.from, {
y: "start",
}),
});
view.focus();
}}
>
<span
className="shrink-0 bg-[#282c34] py-px text-right text-[#636d83]"
style={{ width: gutterWidth ? gutterWidth - 8 : 32 }}
>
{section.line}
</span>
{section.html ? (
<span
className="py-px pl-5.5"
dangerouslySetInnerHTML={{ __html: section.html }}
/>
) : (
<span className="py-px pl-5.5 text-[#abb2bf]">
{section.content}
</span>
)}
</div>
))}
</div>
)}
<div ref={containerRef} className="absolute inset-0" />
<AIDrawer />
</div>
<LatexTools />
</div>
);
}

View file

@ -0,0 +1,143 @@
"use client";
import { useAssistantTool } from "@assistant-ui/react";
import {
CheckIcon,
LoaderIcon,
PlusIcon,
ReplaceIcon,
SearchIcon,
} from "lucide-react";
import type { FC } from "react";
import { z } from "zod";
import { useDocumentStore } from "@/stores/document-store";
export const LatexTools: FC = () => {
const insertAtCursor = useDocumentStore((s) => s.insertAtCursor);
const replaceSelection = useDocumentStore((s) => s.replaceSelection);
const findAndReplace = useDocumentStore((s) => s.findAndReplace);
const selectionRange = useDocumentStore((s) => s.selectionRange);
useAssistantTool({
toolName: "insert_latex",
description:
"Insert LaTeX code at the current cursor position in the document",
parameters: z.object({
code: z
.string()
.describe("The LaTeX code to insert at the cursor position"),
}),
execute: async ({ code }: { code: string }) => {
insertAtCursor(code);
return { success: true, message: "Code inserted at cursor position" };
},
render: function InsertLatexRender({ result }) {
const isComplete = result != null;
return (
<div className="my-2 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
{isComplete ? (
<CheckIcon className="size-4 text-green-600" />
) : (
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
)}
<PlusIcon className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
{isComplete ? "Inserted LaTeX code" : "Inserting LaTeX code..."}
</span>
</div>
);
},
});
useAssistantTool({
toolName: "replace_selection",
description:
"Replace the currently selected text in the document with LaTeX code",
parameters: z.object({
code: z.string().describe("The LaTeX code to replace the selection with"),
}),
execute: async ({ code }: { code: string }) => {
if (!selectionRange) {
return {
success: false,
error: "No text is currently selected in the editor",
};
}
replaceSelection(selectionRange.start, selectionRange.end, code);
return { success: true, message: "Selection replaced with code" };
},
render: function ReplaceSelectionRender({ result }) {
const isComplete = result != null;
const hasError = result?.success === false;
return (
<div className="my-2 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
{isComplete ? (
hasError ? (
<span className="size-4 text-amber-600">!</span>
) : (
<CheckIcon className="size-4 text-green-600" />
)
) : (
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
)}
<ReplaceIcon className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
{hasError
? result.error
: isComplete
? "Replaced selection"
: "Replacing selection..."}
</span>
</div>
);
},
});
useAssistantTool({
toolName: "find_and_replace",
description:
"Find and replace text in the document. Use this to modify existing content.",
parameters: z.object({
find: z.string().describe("The exact text to find in the document"),
replace: z.string().describe("The text to replace it with"),
}),
execute: async ({ find, replace }: { find: string; replace: string }) => {
const success = findAndReplace(find, replace);
if (!success) {
return {
success: false,
error: `Could not find "${find}" in the document`,
};
}
return { success: true, message: `Replaced "${find}" with "${replace}"` };
},
render: function FindAndReplaceRender({ result }) {
const isComplete = result != null;
const hasError = result?.success === false;
return (
<div className="my-2 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
{isComplete ? (
hasError ? (
<span className="size-4 text-amber-600">!</span>
) : (
<CheckIcon className="size-4 text-green-600" />
)
) : (
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
)}
<SearchIcon className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
{hasError
? result.error
: isComplete
? "Text replaced"
: "Finding and replacing..."}
</span>
</div>
);
},
});
return null;
};

View file

@ -0,0 +1,95 @@
"use client";
import { useEffect, useRef } from "react";
import { XIcon, ChevronUpIcon, ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface SearchPanelProps {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onClose: () => void;
onFindNext: () => void;
onFindPrevious: () => void;
matchCount: number;
currentMatch: number;
}
export function SearchPanel({
searchQuery,
onSearchQueryChange,
onClose,
onFindNext,
onFindPrevious,
matchCount,
currentMatch,
}: SearchPanelProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
return (
<div className="flex h-9 items-center gap-2 border-border border-b bg-[#282c34] px-2">
<Input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="h-6 w-48 bg-[#1e2127] text-[#abb2bf] text-sm placeholder:text-[#636d83]"
/>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-6 text-[#abb2bf] hover:bg-white/10 hover:text-[#abb2bf]"
onClick={onFindPrevious}
disabled={!searchQuery || matchCount === 0}
>
<ChevronUpIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-[#abb2bf] hover:bg-white/10 hover:text-[#abb2bf]"
onClick={onFindNext}
disabled={!searchQuery || matchCount === 0}
>
<ChevronDownIcon className="size-4" />
</Button>
</div>
{searchQuery && (
<span className="text-[#636d83] text-xs">
{matchCount === 0 ? "No results" : `${currentMatch} of ${matchCount}`}
</span>
)}
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className="size-6 text-[#abb2bf] hover:bg-white/10 hover:text-[#abb2bf]"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
);
}

View file

@ -0,0 +1,345 @@
"use client";
import dynamic from "next/dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import {
FileTextIcon,
AlertCircleIcon,
LoaderIcon,
RefreshCwIcon,
MinusIcon,
PlusIcon,
DownloadIcon,
} from "lucide-react";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { compileLatex, type CompileResource } from "@/lib/latex-compiler";
const ZOOM_OPTIONS = [
{ value: "0.5", label: "50%" },
{ value: "0.75", label: "75%" },
{ value: "1", label: "100%" },
{ value: "1.25", label: "125%" },
{ value: "1.5", label: "150%" },
{ value: "2", label: "200%" },
{ value: "3", label: "300%" },
{ value: "4", label: "400%" },
];
const PdfViewer = dynamic(
() => import("./pdf-viewer").then((mod) => mod.PdfViewer),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<LoaderIcon className="size-6 animate-spin text-muted-foreground" />
</div>
),
},
);
function gatherResources(files: ProjectFile[]): CompileResource[] {
return files.map((f) => {
if (f.type === "tex") {
return {
path: f.name,
content: f.content ?? "",
main: f.name === "document.tex",
};
}
const dataUrl = f.dataUrl ?? "";
const base64 = dataUrl.includes(",") ? dataUrl.split(",")[1] : dataUrl;
return {
path: f.name,
content: base64,
encoding: "base64",
};
});
}
export function PdfPreview() {
const pdfData = useDocumentStore((s) => s.pdfData);
const compileError = useDocumentStore((s) => s.compileError);
const isCompiling = useDocumentStore((s) => s.isCompiling);
const isSaving = useDocumentStore((s) => s.isSaving);
const setPdfData = useDocumentStore((s) => s.setPdfData);
const setCompileError = useDocumentStore((s) => s.setCompileError);
const setIsCompiling = useDocumentStore((s) => s.setIsCompiling);
const content = useDocumentStore((s) => s.content);
const requestJumpToPosition = useDocumentStore(
(s) => s.requestJumpToPosition,
);
const [pdfError, setPdfError] = useState<string | null>(null);
const [numPages, setNumPages] = useState<number>(0);
const [scale, setScale] = useState<number>(1.0);
const hasInitialCompile = useRef(false);
const initialized = useDocumentStore((s) => s.initialized);
const handleTextClick = useCallback(
(text: string) => {
let index = content.indexOf(text);
if (index === -1) {
const cleanText = text.replace(/[{}\\$]/g, "");
if (cleanText.length > 2) {
index = content.indexOf(cleanText);
}
}
if (index === -1 && text.length > 5) {
const words = text.split(/\s+/).filter((w) => w.length > 3);
for (const word of words) {
index = content.indexOf(word);
if (index !== -1) break;
}
}
if (index !== -1) {
requestJumpToPosition(index);
}
},
[content, requestJumpToPosition],
);
useEffect(() => {
if (hasInitialCompile.current) return;
if (!initialized) return;
if (pdfData || isCompiling || compileError) return;
hasInitialCompile.current = true;
const compile = async () => {
setIsCompiling(true);
try {
const currentFiles = useDocumentStore.getState().files;
const resources = gatherResources(currentFiles);
const data = await compileLatex(resources);
setPdfData(data);
} catch (error) {
setCompileError(
error instanceof Error ? error.message : "Compilation failed",
);
} finally {
setIsCompiling(false);
}
};
compile();
}, [
initialized,
pdfData,
isCompiling,
compileError,
setIsCompiling,
setPdfData,
setCompileError,
]);
const zoomIn = () => setScale((s) => Math.min(4, s + 0.1));
const zoomOut = () => setScale((s) => Math.max(0.25, s - 0.1));
const handleDownload = () => {
if (!pdfData) return;
const blob = new Blob([new Uint8Array(pdfData)], {
type: "application/pdf",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.pdf";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleLoadSuccess = (pages: number) => {
setNumPages(pages);
};
const handleScaleChange = (newScale: number) => {
setScale(newScale);
};
const handleCompile = async () => {
if (isCompiling) return;
setIsCompiling(true);
setPdfError(null);
try {
const currentFiles = useDocumentStore.getState().files;
const resources = gatherResources(currentFiles);
const data = await compileLatex(resources);
setPdfData(data);
} catch (error) {
setCompileError(
error instanceof Error ? error.message : "Compilation failed",
);
} finally {
setIsCompiling(false);
}
};
const renderContent = () => {
if (compileError) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
<AlertCircleIcon className="mb-4 size-12 text-destructive" />
<h2 className="mb-2 font-medium text-destructive text-lg">
Compilation Error
</h2>
<p className="max-w-md text-center text-muted-foreground text-sm">
{compileError}
</p>
</div>
);
}
if (!pdfData) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
<FileTextIcon className="mb-4 size-16 text-muted-foreground/50" />
<h2 className="mb-2 font-medium text-lg text-muted-foreground">
PDF Preview
</h2>
<p className="text-center text-muted-foreground text-sm">
Click &quot;Compile&quot; to preview your document
</p>
</div>
);
}
if (pdfError) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 p-8">
<AlertCircleIcon className="mb-4 size-12 text-destructive" />
<h2 className="mb-2 font-medium text-destructive text-lg">
PDF Load Error
</h2>
<p className="max-w-md text-center text-muted-foreground text-sm">
{pdfError}
</p>
</div>
);
}
return (
<PdfViewer
data={pdfData}
scale={scale}
onError={setPdfError}
onLoadSuccess={handleLoadSuccess}
onScaleChange={handleScaleChange}
onTextClick={handleTextClick}
/>
);
};
return (
<div className="flex h-full flex-col bg-muted/50">
<div className="flex h-9 items-center justify-between border-border border-b bg-background px-2">
<div className="flex items-center gap-1.5">
{isSaving && (
<>
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground text-xs">Saving...</span>
</>
)}
{!isSaving && isCompiling && (
<>
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground text-xs">
Compiling...
</span>
</>
)}
{!isSaving && !isCompiling && pdfData && (
<>
<span className="text-muted-foreground text-xs">Ready</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={handleCompile}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</>
)}
{!isSaving && !isCompiling && compileError && (
<>
<span className="text-destructive text-xs">Error</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={handleCompile}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</>
)}
</div>
{pdfData && (
<div className="flex items-center gap-0.5">
<span className="mr-2 text-muted-foreground text-xs">
{numPages} {numPages === 1 ? "page" : "pages"}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={zoomOut}
disabled={scale <= 0.25}
>
<MinusIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={zoomIn}
disabled={scale >= 4}
>
<PlusIcon className="size-3.5" />
</Button>
<Select
value={scale.toString()}
onValueChange={(v) => setScale(Number(v))}
>
<SelectTrigger size="sm" className="h-6! w-auto text-xs">
<SelectValue>{Math.round(scale * 100)}%</SelectValue>
</SelectTrigger>
<SelectContent>
{ZOOM_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={handleDownload}
title="Download PDF"
>
<DownloadIcon className="size-3.5" />
</Button>
</div>
)}
</div>
{renderContent()}
</div>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useCallback, useMemo, useRef, useEffect, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import { LoaderIcon } from "lucide-react";
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface PdfViewerProps {
data: Uint8Array;
scale: number;
onError?: (error: string) => void;
onLoadSuccess?: (numPages: number) => void;
onScaleChange?: (scale: number) => void;
onTextClick?: (text: string) => void;
}
export function PdfViewer({
data,
scale,
onError,
onLoadSuccess,
onScaleChange,
onTextClick,
}: PdfViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const hasSetInitialScale = useRef(false);
const [numPages, setNumPages] = useState(0);
const file = useMemo(() => {
const pdfData =
data instanceof Uint8Array ? data : new Uint8Array(Object.values(data));
hasSetInitialScale.current = false;
return { data: pdfData.slice() };
}, [data]);
const handleLoadSuccess = useCallback(
({ numPages }: { numPages: number }) => {
setNumPages(numPages);
onLoadSuccess?.(numPages);
},
[onLoadSuccess],
);
const handlePageLoadSuccess = useCallback(
({ width }: { width: number }) => {
if (hasSetInitialScale.current) return;
if (containerRef.current && onScaleChange) {
hasSetInitialScale.current = true;
const containerWidth = containerRef.current.clientWidth - 32;
const fitScale = containerWidth / width;
onScaleChange(Math.min(fitScale, 2));
}
},
[onScaleChange],
);
const handleLoadError = useCallback(
(error: Error) => {
onError?.(error.message);
},
[onError],
);
const handleTextLayerClick = useCallback(
(e: React.MouseEvent) => {
if (!onTextClick) return;
const target = e.target as HTMLElement;
if (
target.tagName === "SPAN" &&
target.closest(".react-pdf__Page__textContent")
) {
const text = target.textContent?.trim();
if (text && text.length > 2) {
onTextClick(text);
}
}
},
[onTextClick],
);
useEffect(() => {
const container = containerRef.current;
if (!container || !onScaleChange) return;
const handleWheel = (e: WheelEvent) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
const delta = -e.deltaY * 0.001;
onScaleChange(Math.max(0.25, Math.min(4, scale + delta)));
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, [scale, onScaleChange]);
return (
<div ref={containerRef} className="flex-1 overflow-auto">
<div
className="flex flex-col items-center gap-4 p-4"
onClick={handleTextLayerClick}
>
<Document
file={file}
onLoadSuccess={handleLoadSuccess}
onLoadError={handleLoadError}
loading={
<div className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
Loading PDF...
</div>
}
>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={i + 1}
pageNumber={i + 1}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
className="mb-4 shadow-lg"
onLoadSuccess={i === 0 ? handlePageLoadSuccess : undefined}
/>
))}
</Document>
</div>
</div>
);
}

View file

@ -0,0 +1,429 @@
"use client";
import { useState, useRef, useCallback, useMemo } from "react";
import {
FileTextIcon,
FolderIcon,
ImageIcon,
PlusIcon,
MoreHorizontalIcon,
Trash2Icon,
PencilIcon,
UploadIcon,
SunIcon,
MoonIcon,
MonitorIcon,
ListIcon,
HashIcon,
GithubIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface TocItem {
level: number;
title: string;
line: number;
}
function parseTableOfContents(content: string): TocItem[] {
const lines = content.split("\n");
const toc: TocItem[] = [];
const sectionRegex =
/\\(section|subsection|subsubsection|chapter|part)\*?\s*\{([^}]*)\}/;
const levelMap: Record<string, number> = {
part: 0,
chapter: 1,
section: 2,
subsection: 3,
subsubsection: 4,
};
lines.forEach((line, index) => {
const match = line.match(sectionRegex);
if (match) {
const [, type, title] = match;
toc.push({
level: levelMap[type] ?? 2,
title: title.trim(),
line: index + 1,
});
}
});
return toc;
}
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import packageJson from "@/package.json";
export function Sidebar() {
const files = useDocumentStore((s) => s.files);
const activeFileId = useDocumentStore((s) => s.activeFileId);
const setActiveFile = useDocumentStore((s) => s.setActiveFile);
const addFile = useDocumentStore((s) => s.addFile);
const deleteFile = useDocumentStore((s) => s.deleteFile);
const renameFile = useDocumentStore((s) => s.renameFile);
const content = useDocumentStore((s) => s.content);
const requestJumpToPosition = useDocumentStore(
(s) => s.requestJumpToPosition,
);
const { theme, setTheme } = useTheme();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const toc = useMemo(() => parseTableOfContents(content), [content]);
const handleTocClick = useCallback(
(line: number) => {
const lines = content.split("\n");
let position = 0;
for (let i = 0; i < line - 1 && i < lines.length; i++) {
position += lines[i].length + 1;
}
requestJumpToPosition(position);
},
[content, requestJumpToPosition],
);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameFileId, setRenameFileId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [newFileName, setNewFileName] = useState("");
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAddTexFile = () => {
const name = newFileName.trim() || "untitled.tex";
const finalName = name.endsWith(".tex") ? name : `${name}.tex`;
addFile({
name: finalName,
type: "tex",
content: `\\documentclass{article}\n\n\\begin{document}\n\n% Your content here\n\n\\end{document}\n`,
});
setNewFileName("");
setAddDialogOpen(false);
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileUpload = useCallback(
(uploadedFiles: FileList | null) => {
if (!uploadedFiles) return;
Array.from(uploadedFiles).forEach((file) => {
const reader = new FileReader();
if (file.type.startsWith("image/")) {
reader.onload = () => {
addFile({
name: file.name,
type: "image",
dataUrl: reader.result as string,
});
};
reader.readAsDataURL(file);
} else if (file.name.endsWith(".tex")) {
reader.onload = () => {
addFile({
name: file.name,
type: "tex",
content: reader.result as string,
});
};
reader.readAsText(file);
}
});
},
[addFile],
);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFileUpload(e.dataTransfer.files);
};
const openRenameDialog = (file: ProjectFile) => {
setRenameFileId(file.id);
setRenameValue(file.name);
setRenameDialogOpen(true);
};
const handleRename = () => {
if (renameFileId && renameValue.trim()) {
renameFile(renameFileId, renameValue.trim());
}
setRenameDialogOpen(false);
setRenameFileId(null);
setRenameValue("");
};
const getFileIcon = (file: ProjectFile) => {
if (file.type === "image") {
return <ImageIcon className="size-4" />;
}
return <FileTextIcon className="size-4" />;
};
return (
<div
className={cn(
"flex h-full flex-col bg-sidebar text-sidebar-foreground",
isDragging && "ring-2 ring-primary ring-inset",
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex h-12 items-center border-sidebar-border border-b px-3">
<div className="flex flex-col">
<span className="font-semibold text-sm">OpenPrism</span>
<span className="text-muted-foreground text-xs">
By{" "}
<a
href="https://www.assistant-ui.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
assistant-ui
</a>
</span>
</div>
</div>
<div className="flex h-9 items-center justify-between border-sidebar-border border-b px-3">
<div className="flex items-center gap-2">
<FolderIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-xs">Files</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-6" title="Add">
<PlusIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setAddDialogOpen(true)}>
<FileTextIcon className="mr-2 size-4" />
New LaTeX File
</DropdownMenuItem>
<DropdownMenuItem onClick={handleUploadClick}>
<UploadIcon className="mr-2 size-4" />
Upload File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".tex,image/*"
multiple
onChange={(e) => handleFileUpload(e.target.files)}
/>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{isDragging && (
<div className="mb-2 flex items-center justify-center rounded-md border-2 border-primary border-dashed p-4">
<span className="text-muted-foreground text-xs">
Drop files here
</span>
</div>
)}
{files.map((file) => (
<div
key={file.id}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
file.id === activeFileId
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50",
)}
>
<button
className="flex flex-1 items-center gap-2 overflow-hidden"
onClick={() => setActiveFile(file.id)}
>
{getFileIcon(file)}
<span className="truncate">{file.name}</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100"
>
<MoreHorizontalIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openRenameDialog(file)}>
<PencilIcon className="mr-2 size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => deleteFile(file.id)}
disabled={files.length <= 1}
>
<Trash2Icon className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
<div className="flex h-9 items-center gap-2 border-sidebar-border border-t px-3">
<ListIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-xs">Outline</span>
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{toc.length > 0 ? (
toc.map((item, index) => (
<button
key={index}
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-sidebar-accent/50"
style={{ paddingLeft: `${(item.level - 1) * 12 + 8}px` }}
onClick={() => handleTocClick(item.line)}
>
<HashIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">{item.title}</span>
</button>
))
) : (
<div className="px-2 py-1 text-muted-foreground text-xs">
No sections found
</div>
)}
</div>
<div className="flex items-center justify-between border-sidebar-border border-t px-3 py-2 text-muted-foreground text-xs">
<span>OpenPrism v{packageJson.version}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-6" asChild>
<a
href="https://github.com/assistant-ui/open-prism"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<GithubIcon className="size-3.5" />
</a>
</Button>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
if (theme === "system") setTheme("light");
else if (theme === "light") setTheme("dark");
else setTheme("system");
}}
title={
theme === "system"
? "System theme"
: theme === "light"
? "Light mode"
: "Dark mode"
}
>
{theme === "system" ? (
<MonitorIcon className="size-3.5" />
) : theme === "light" ? (
<SunIcon className="size-3.5" />
) : (
<MoonIcon className="size-3.5" />
)}
</Button>
</div>
</div>
{/* Add File Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New LaTeX File</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
placeholder="filename.tex"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTexFile();
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddTexFile}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRenameDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleRename}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { Sidebar } from "./sidebar";
import { LatexEditor } from "./editor/latex-editor";
import { PdfPreview } from "./preview/pdf-preview";
import { useProjectInit } from "@/hooks/use-project-init";
import { useStorageReady } from "@/hooks/use-storage-ready";
export function WorkspaceLayout() {
const storageReady = useStorageReady();
useProjectInit();
if (!storageReady) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
return (
<PanelGroup direction="horizontal" className="h-full">
<Panel defaultSize={15} minSize={10} maxSize={25}>
<Sidebar />
</Panel>
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
<Panel defaultSize={42.5} minSize={25}>
<LatexEditor />
</Panel>
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
<Panel defaultSize={42.5} minSize={25}>
<PdfPreview />
</Panel>
</PanelGroup>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { useAui } from "@assistant-ui/store";
import { useDocumentStore } from "@/stores/document-store";
export function useDocumentContext() {
const aui = useAui();
const fileName = useDocumentStore((s) => s.fileName);
const content = useDocumentStore((s) => s.content);
const selectionRange = useDocumentStore((s) => s.selectionRange);
const hasSelection = selectionRange !== null;
const selectedText = hasSelection
? content.slice(selectionRange.start, selectionRange.end)
: null;
useEffect(() => {
const selectionInfo = hasSelection
? `The user has selected the following text:\n\`\`\`\n${selectedText}\n\`\`\`\nYou can use the replace_selection tool to replace this text.`
: "The user has NOT selected any text. Do NOT use the replace_selection tool.";
return aui.modelContext().register({
getModelContext: () => ({
system: `The user is currently editing a LaTeX document named "${fileName}".
Here is the current content of the document:
\`\`\`latex
${content}
\`\`\`
${selectionInfo}
When helping the user, reference this document and provide relevant suggestions.`,
}),
});
}, [aui, fileName, content, hasSelection, selectedText]);
}

View file

@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { useDocumentStore } from "@/stores/document-store";
export function useKeyboardShortcuts() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
const { setIsSaving } = useDocumentStore.getState();
setIsSaving(true);
setTimeout(() => setIsSaving(false), 1000);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
}

View file

@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View file

@ -0,0 +1,65 @@
"use client";
import { useEffect } from "react";
import { useDocumentStore } from "@/stores/document-store";
const DEFAULT_IMAGE_FILE = {
name: "hand-write.jpg",
path: "/hand-write.jpg",
};
async function loadImageAsDataUrl(path: string): Promise<string> {
const res = await fetch(path);
const blob = await res.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
export function useProjectInit() {
const files = useDocumentStore((s) => s.files);
const addFile = useDocumentStore((s) => s.addFile);
const initialized = useDocumentStore((s) => s.initialized);
const setInitialized = useDocumentStore((s) => s.setInitialized);
useEffect(() => {
if (initialized) return;
const existingImage = files.find(
(f) => f.type === "image" && f.name === DEFAULT_IMAGE_FILE.name,
);
if (existingImage?.dataUrl) {
setInitialized();
return;
}
const currentActiveId = useDocumentStore.getState().activeFileId;
loadImageAsDataUrl(DEFAULT_IMAGE_FILE.path)
.then((dataUrl) => {
if (existingImage) {
useDocumentStore.setState((state) => ({
files: state.files.map((f) =>
f.id === existingImage.id ? { ...f, dataUrl } : f,
),
}));
} else {
addFile({
name: DEFAULT_IMAGE_FILE.name,
type: "image",
dataUrl,
});
useDocumentStore.getState().setActiveFile(currentActiveId);
}
setInitialized();
})
.catch((err) => {
console.error("Failed to load default image:", err);
setInitialized();
});
}, [files, addFile, initialized, setInitialized]);
}

View file

@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
import {
waitForStorageReady,
isStorageReady,
} from "@/lib/storage/indexeddb-storage";
export function useStorageReady(): boolean {
const [ready, setReady] = useState(isStorageReady);
useEffect(() => {
if (ready) return;
waitForStorageReady().then(() => {
setReady(true);
});
}, [ready]);
return ready;
}

View file

@ -0,0 +1,29 @@
export interface CompileResource {
path: string;
content?: string;
file?: string;
main?: boolean;
}
export async function compileLatex(
resources: CompileResource[],
): Promise<Uint8Array> {
const response = await fetch("/api/compile", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ resources }),
});
if (!response.ok) {
const data = await response.json();
const message = data.details
? `${data.error}\n\n${data.details}`
: data.error || "Compilation failed";
throw new Error(message);
}
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
}

33
apps/web/lib/ratelimit.ts Normal file
View file

@ -0,0 +1,33 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const isConfigured =
process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN;
const redis = isConfigured
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
: null;
export const chatRatelimit = redis
? new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "1m"),
prefix: "openprism::ratelimit::chat",
})
: null;
export const compileRatelimit = redis
? new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(30, "1m"),
prefix: "openprism::ratelimit::compile",
})
: null;
export function getIP(req: Request): string {
const xff = req.headers.get("x-forwarded-for");
return xff ? xff.split(",")[0].trim() : "127.0.0.1";
}

View file

@ -0,0 +1,166 @@
import { openDB, type IDBPDatabase } from "idb";
import type { StateStorage } from "zustand/middleware";
import type { ProjectFile } from "@/stores/document-store";
import {
type OpenPrismDB,
DB_NAME,
DB_VERSION,
STORAGE_VERSION,
} from "./schema";
import { migrateFromLocalStorage } from "./migrate";
let dbPromise: Promise<IDBPDatabase<OpenPrismDB>> | null = null;
function getDB(): Promise<IDBPDatabase<OpenPrismDB>> {
if (!dbPromise) {
dbPromise = openDB<OpenPrismDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains("documentState")) {
db.createObjectStore("documentState");
}
if (!db.objectStoreNames.contains("blobs")) {
db.createObjectStore("blobs", { keyPath: "id" });
}
},
});
}
return dbPromise;
}
interface PersistedState {
files: ProjectFile[];
activeFileId: string;
pdfData?: Uint8Array | null;
}
export const indexedDBStorage: StateStorage = {
async getItem(name: string): Promise<string | null> {
const migrated = await migrateFromLocalStorage(name);
if (migrated) {
await indexedDBStorage.setItem(name, JSON.stringify({ state: migrated }));
}
const db = await getDB();
const stored = await db.get("documentState", "state");
if (!stored) return null;
const files = await Promise.all(
stored.files.map(async (file) => {
if (file.type === "image") {
const blob = await db.get("blobs", file.id);
if (blob && typeof blob.data === "string") {
return { ...file, dataUrl: blob.data };
}
}
return file;
}),
);
const pdfBlob = await db.get("blobs", "pdf");
const pdfData =
pdfBlob && pdfBlob.data instanceof Uint8Array ? pdfBlob.data : null;
const state: PersistedState = {
files,
activeFileId: stored.activeFileId,
pdfData,
};
return JSON.stringify({ state });
},
async setItem(_name: string, value: string): Promise<void> {
const db = await getDB();
const parsed = JSON.parse(value);
const state = parsed.state as PersistedState;
const filesToStore: ProjectFile[] = [];
const blobsToStore: {
id: string;
data: string | Uint8Array;
type: "image" | "pdf";
}[] = [];
for (const file of state.files) {
if (file.type === "image" && file.dataUrl) {
blobsToStore.push({
id: file.id,
data: file.dataUrl,
type: "image",
});
filesToStore.push({ ...file, dataUrl: undefined });
} else {
filesToStore.push(file);
}
}
if (state.pdfData) {
blobsToStore.push({
id: "pdf",
data: state.pdfData,
type: "pdf",
});
}
const existingBlobs = await db.getAllKeys("blobs");
const newBlobIds = new Set(blobsToStore.map((b) => b.id));
const orphanIds = existingBlobs.filter(
(id) => id !== "pdf" && !newBlobIds.has(id),
);
const tx = db.transaction(["documentState", "blobs"], "readwrite");
await tx.objectStore("documentState").put(
{
files: filesToStore,
activeFileId: state.activeFileId,
version: STORAGE_VERSION,
},
"state",
);
const blobStore = tx.objectStore("blobs");
for (const blob of blobsToStore) {
await blobStore.put(blob);
}
for (const id of orphanIds) {
await blobStore.delete(id);
}
if (!state.pdfData) {
await blobStore.delete("pdf");
}
await tx.done;
},
async removeItem(_name: string): Promise<void> {
const db = await getDB();
const tx = db.transaction(["documentState", "blobs"], "readwrite");
await tx.objectStore("documentState").clear();
await tx.objectStore("blobs").clear();
await tx.done;
},
};
let storageReady = false;
let storageReadyPromise: Promise<void> | null = null;
export function waitForStorageReady(): Promise<void> {
if (storageReady) return Promise.resolve();
if (!storageReadyPromise) {
storageReadyPromise = (async () => {
await getDB();
storageReady = true;
})();
}
return storageReadyPromise;
}
export function isStorageReady(): boolean {
return storageReady;
}

View file

@ -0,0 +1,39 @@
import type { ProjectFile } from "@/stores/document-store";
const LOCALSTORAGE_KEY = "open-prism-document";
const MIGRATION_FLAG = "open-prism-migrated-to-indexeddb";
interface OldPersistedState {
state: {
files: ProjectFile[];
activeFileId: string;
};
}
export async function migrateFromLocalStorage(
_name: string,
): Promise<{ files: ProjectFile[]; activeFileId: string } | null> {
if (typeof window === "undefined") return null;
if (localStorage.getItem(MIGRATION_FLAG)) return null;
const oldData = localStorage.getItem(LOCALSTORAGE_KEY);
if (!oldData) {
localStorage.setItem(MIGRATION_FLAG, "1");
return null;
}
try {
const parsed: OldPersistedState = JSON.parse(oldData);
if (parsed.state?.files && parsed.state?.activeFileId) {
localStorage.setItem(MIGRATION_FLAG, "1");
localStorage.removeItem(LOCALSTORAGE_KEY);
return {
files: parsed.state.files,
activeFileId: parsed.state.activeFileId,
};
}
} catch {}
localStorage.setItem(MIGRATION_FLAG, "1");
return null;
}

View file

@ -0,0 +1,25 @@
import type { DBSchema } from "idb";
import type { ProjectFile } from "@/stores/document-store";
export interface OpenPrismDB extends DBSchema {
documentState: {
key: "state";
value: {
files: ProjectFile[];
activeFileId: string;
version: number;
};
};
blobs: {
key: string;
value: {
id: string;
data: string | Uint8Array;
type: "image" | "pdf";
};
};
}
export const DB_NAME = "open-prism";
export const DB_VERSION = 1;
export const STORAGE_VERSION = 1;

6
apps/web/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

7
apps/web/next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: false,
};
export default nextConfig;

75
apps/web/package.json Normal file
View file

@ -0,0 +1,75 @@
{
"name": "@open-prism/web",
"version": "0.0.1",
"private": true,
"description": "Open-source LaTeX editor with live preview",
"homepage": "https://github.com/assistant-ui/open-prism",
"repository": {
"type": "git",
"url": "https://github.com/assistant-ui/open-prism"
},
"author": "assistant-ui",
"license": "MIT",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.21",
"@assistant-ui/react": "^0.12.1",
"@assistant-ui/react-ai-sdk": "^1.3.1",
"@assistant-ui/react-markdown": "^0.12.0",
"@assistant-ui/store": "^0.1.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-slot": "^1.2.4",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.1",
"ai": "^6.0.57",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror-lang-latex": "^0.2.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"idb": "^8.0.3",
"input-otp": "^1.4.2",
"katex": "^0.16.28",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-pdf": "^10.3.0",
"react-resizable-panels": "^3.0.6",
"recharts": "2.15.4",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.10"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/katex": "^0.16.8",
"@types/node": "^25.0.10",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -0,0 +1,327 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { indexedDBStorage } from "@/lib/storage/indexeddb-storage";
const DEFAULT_TEX_CONTENT = `\\documentclass[11pt]{article}
\\usepackage[margin=1in]{geometry}
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage{tikz-cd}
\\usepackage{multicol}
\\setlength{\\parindent}{0pt}
\\setlength{\\parskip}{1\\baselineskip}
\\begin{document}
\\section*{What is Open-Prism?}
\\textbf{Open-Prism} is an AI-powered \\LaTeX{} editor for writing scientific documents. It features built-in AI assistance to help you draft and edit text, reason through ideas, and handle formatting.
\\section*{Features}
\\begin{multicols}{2}
Open-Prism integrates AI directly in the editor with access to your project, so you can ask it to:
\`\`Add the Laplace transform of $t\\cos(at)$ in the introduction.''
\\[
\\mathcal{L}\\left\\{ t \\cos(a t) \\right\\} = \\frac{ s^2 - a^2 }{ (s^2 + a^2)^2 }
\\]
\`\`Add a 4\\,$\\times$\\,4 table in the results section.''
\\begin{center}
\\resizebox{0.5\\linewidth}{!}{%
\\begin{tabular}{|c|c|c|c|}
\\hline
1 & 2 & 3 & 4 \\\\
\\hline
5 & 6 & 7 & 8 \\\\
\\hline
9 & 10 & 11 & 12 \\\\
\\hline
13 & 14 & 15 & 16 \\\\
\\hline
\\end{tabular}%
}
\\end{center}
\`\`Please proofread this section, flag any errors or logical gaps, and suggest improvements for clarity.''
\`\`Am I missing corollaries or implications of Theorem 3.1? Are all bounds tight, or can some be relaxed?''
\\columnbreak
\`\`Write an abstract based on the rest of the paper.''
\`\`Add references to my paper and suggest related work I may have missed.''
\`\`Convert this hand-drawn diagram to \\LaTeX{}.''
\\par\\noindent
\\begin{minipage}[t]{0.49\\linewidth}
\\vspace{0pt}
\\centering
\\includegraphics[width=\\linewidth]{hand-write.jpg}
\\end{minipage}\\hfill
\\begin{minipage}[t]{0.49\\linewidth}
\\vspace{0pt}
\\centering
\\resizebox{\\linewidth}{!}{$
\\begin{tikzcd}[row sep=2em, column sep=1.5em, ampersand replacement=\\&]
E
\\arrow[dr, "e"']
\\arrow[drr, "p_2"]
\\arrow[ddr, "p_1"']
\\& \\& \\\\
\\& A \\times B \\arrow[r, "\\pi_2"'] \\arrow[d, "\\pi_1"] \\& B \\arrow[d, "g"] \\\\
\\& A \\arrow[r, "f"'] \\& C
\\end{tikzcd}
$}
\\end{minipage}
\\par
\`\`Fill in all missing dependencies in my project.''
\`\`Generate a 200-word summary for a general audience.''
\`\`Create a Beamer presentation with each slide in a separate file.''
\\end{multicols}
\\section*{Getting Started}
Press \\textbf{Enter} to compile your document. Use \\textbf{Shift+Enter} for a new line. The AI assistant panel at the bottom of the editor is ready to help with any \\LaTeX{} questions or tasks.
\\end{document}
`;
export interface ProjectFile {
id: string;
name: string;
type: "tex" | "image";
content?: string;
dataUrl?: string;
}
interface DocumentState {
files: ProjectFile[];
activeFileId: string;
cursorPosition: number;
selectionRange: { start: number; end: number } | null;
jumpToPosition: number | null;
isThreadOpen: boolean;
pdfData: Uint8Array | null;
compileError: string | null;
isCompiling: boolean;
isSaving: boolean;
initialized: boolean;
setActiveFile: (id: string) => void;
addFile: (file: Omit<ProjectFile, "id">) => string;
deleteFile: (id: string) => void;
renameFile: (id: string, name: string) => void;
updateFileContent: (id: string, content: string) => void;
setCursorPosition: (position: number) => void;
setSelectionRange: (range: { start: number; end: number } | null) => void;
requestJumpToPosition: (position: number) => void;
clearJumpRequest: () => void;
setThreadOpen: (open: boolean) => void;
setPdfData: (data: Uint8Array | null) => void;
setCompileError: (error: string | null) => void;
setIsCompiling: (isCompiling: boolean) => void;
setIsSaving: (isSaving: boolean) => void;
insertAtCursor: (text: string) => void;
replaceSelection: (start: number, end: number, text: string) => void;
findAndReplace: (find: string, replace: string) => boolean;
setInitialized: () => void;
get fileName(): string;
get content(): string;
setFileName: (name: string) => void;
setContent: (content: string) => void;
}
function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
function getActiveFile(state: { files: ProjectFile[]; activeFileId: string }) {
return state.files.find((f) => f.id === state.activeFileId);
}
export const useDocumentStore = create<DocumentState>()(
persist(
(set, get) => ({
files: [
{
id: "default-tex",
name: "document.tex",
type: "tex",
content: DEFAULT_TEX_CONTENT,
},
],
activeFileId: "default-tex",
cursorPosition: 0,
selectionRange: null,
jumpToPosition: null,
isThreadOpen: false,
pdfData: null,
compileError: null,
isCompiling: false,
isSaving: false,
initialized: false,
setActiveFile: (id) =>
set({ activeFileId: id, cursorPosition: 0, selectionRange: null }),
setSelectionRange: (range) => set({ selectionRange: range }),
requestJumpToPosition: (position) => set({ jumpToPosition: position }),
clearJumpRequest: () => set({ jumpToPosition: null }),
addFile: (file) => {
const id = generateId();
set((state) => ({
files: [...state.files, { ...file, id }],
activeFileId: id,
}));
return id;
},
deleteFile: (id) => {
const state = get();
if (state.files.length <= 1) return;
const newFiles = state.files.filter((f) => f.id !== id);
const newActiveId =
state.activeFileId === id ? newFiles[0].id : state.activeFileId;
set({ files: newFiles, activeFileId: newActiveId });
},
renameFile: (id, name) => {
set((state) => ({
files: state.files.map((f) => (f.id === id ? { ...f, name } : f)),
}));
},
updateFileContent: (id, content) => {
set((state) => ({
files: state.files.map((f) => (f.id === id ? { ...f, content } : f)),
}));
},
setThreadOpen: (open) => set({ isThreadOpen: open }),
setPdfData: (data) => set({ pdfData: data, compileError: null }),
setCompileError: (error) => set({ compileError: error, pdfData: null }),
setIsCompiling: (isCompiling) => set({ isCompiling }),
setIsSaving: (isSaving) => set({ isSaving }),
setCursorPosition: (position) => set({ cursorPosition: position }),
insertAtCursor: (text) => {
const state = get();
const activeFile = getActiveFile(state);
if (!activeFile || activeFile.type !== "tex") return;
const content = activeFile.content ?? "";
const { cursorPosition } = state;
const newContent =
content.slice(0, cursorPosition) +
text +
content.slice(cursorPosition);
set({
files: state.files.map((f) =>
f.id === activeFile.id ? { ...f, content: newContent } : f,
),
cursorPosition: cursorPosition + text.length,
});
},
replaceSelection: (start, end, text) => {
const state = get();
const activeFile = getActiveFile(state);
if (!activeFile || activeFile.type !== "tex") return;
const content = activeFile.content ?? "";
const newContent = content.slice(0, start) + text + content.slice(end);
set({
files: state.files.map((f) =>
f.id === activeFile.id ? { ...f, content: newContent } : f,
),
cursorPosition: start + text.length,
});
},
findAndReplace: (find, replace) => {
const state = get();
const activeFile = getActiveFile(state);
if (!activeFile || activeFile.type !== "tex") return false;
const content = activeFile.content ?? "";
if (!content.includes(find)) return false;
const newContent = content.replace(find, replace);
set({
files: state.files.map((f) =>
f.id === activeFile.id ? { ...f, content: newContent } : f,
),
});
return true;
},
setInitialized: () => set({ initialized: true }),
get fileName() {
const activeFile = getActiveFile(get());
return activeFile?.name ?? "document.tex";
},
get content() {
const activeFile = getActiveFile(get());
return activeFile?.content ?? "";
},
setFileName: (name) => {
const state = get();
set({
files: state.files.map((f) =>
f.id === state.activeFileId ? { ...f, name } : f,
),
});
},
setContent: (content) => {
const state = get();
set({
files: state.files.map((f) =>
f.id === state.activeFileId ? { ...f, content } : f,
),
});
},
}),
{
name: "open-prism-document",
storage: createJSONStorage(() => indexedDBStorage),
partialize: (state) => ({
files: state.files,
activeFileId: state.activeFileId,
pdfData: state.pdfData,
}),
merge: (persisted, current) => {
const merged = { ...current, ...(persisted as object) };
const files = merged.files as ProjectFile[];
const docTex = files.find((f) => f.name === "document.tex");
if (docTex) {
merged.activeFileId = docTex.id;
} else if (files.length > 0) {
merged.activeFileId = files[0].id;
}
return merged;
},
},
),
);

138
apps/web/styles/globals.css Normal file
View file

@ -0,0 +1,138 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply overscroll-y-none scroll-smooth;
}
body {
@apply bg-background text-foreground;
}
}
.react-pdf__Page__textContent span {
cursor: pointer;
transition: background-color 0.15s ease;
}
.react-pdf__Page__textContent span:hover {
background-color: oklch(0.9 0.1 250 / 30%);
border-radius: 2px;
}

34
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"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": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

BIN
assets/OpenPrism.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 MiB

156
biome.json Normal file
View file

@ -0,0 +1,156 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80,
"lineEnding": "lf"
},
"assist": {
"enabled": false
},
"linter": {
"enabled": true,
"domains": {
"react": "all",
"next": "all"
},
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noEmptyInterface": "off",
"useIterableCallbackReturn": "off",
"noThenProperty": "off",
"noConfusingVoidType": "off",
"noImplicitAnyLet": "off",
"noAssignInExpressions": "off",
"noRedeclare": "off",
"noConfusingLabels": {
"level": "error",
"options": {
"allowedLabels": ["DEV"]
}
},
"noDoubleEquals": "off",
"noPrototypeBuiltins": "off",
"noDocumentCookie": "off"
},
"style": {
"noNamespace": "off",
"useImportType": "off",
"noNonNullAssertion": "off",
"useComponentExportOnlyModules": "off"
},
"correctness": {
"noUnusedVariables": {
"level": "warn",
"options": {
"ignoreRestSiblings": true
}
},
"useExhaustiveDependencies": "off",
"useHookAtTopLevel": "off",
"noSwitchDeclarations": "off",
"noUnsafeOptionalChaining": "off",
"useUniqueElementIds": "off",
"noNestedComponentDefinitions": "off"
},
"complexity": {
"noForEach": "off",
"noBannedTypes": "off",
"noUselessConstructor": "off",
"noStaticOnlyClass": "off",
"useArrowFunction": "off",
"noThisInStatic": "off",
"useFlatMap": "off",
"useLiteralKeys": "off"
},
"a11y": {
"useKeyWithClickEvents": "off",
"noSvgWithoutTitle": "off",
"noStaticElementInteractions": "off",
"useFocusableInteractive": "off",
"useButtonType": "off",
"useSemanticElements": "off",
"noRedundantAlt": "off",
"noRedundantRoles": "off",
"useAriaPropsForRole": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"performance": {
"noImgElement": "off"
},
"nursery": {
"useSortedClasses": {
"fix": "safe",
"level": "error",
"options": {
"attributes": ["className"],
"functions": ["clsx", "cva", "tw", "twMerge", "cn", "twJoin", "tv"]
}
}
}
}
},
"javascript": {
"formatter": {
"enabled": true,
"quoteStyle": "double",
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"attributePosition": "auto"
},
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
},
"json": {
"formatter": {
"enabled": true,
"trailingCommas": "none"
},
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
},
"css": {
"parser": {
"cssModules": false,
"tailwindDirectives": true
},
"formatter": {
"enabled": false
},
"linter": {
"enabled": false
}
},
"files": {
"includes": [
"**",
"!**/dist",
"!**/node_modules",
"!**/.next",
"!**/.vercel",
"!**/out",
"!**/next-env.d.ts"
]
}
}

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "@open-prism/root",
"description": "Open-Source AI LaTeX writing workspace",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/assistant-ui/open-prism"
},
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev:web": "pnpm turbo dev --filter=@open-prism/web",
"lint": "pnpm exec biome check",
"lint:fix": "pnpm exec biome check --fix"
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"turbo": "^2.7.6"
},
"packageManager": "pnpm@10.28.2"
}

6279
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

23
turbo.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", ".wrangler/**", "dist/**"],
"env": [
"OPENAI_API_KEY",
"LATEX_API_URL",
"KV_REST_API_URL",
"KV_REST_API_TOKEN"
]
},
"dev": {
"cache": false,
"persistent": true
}
},
"globalEnv": [],
"globalDependencies": [".env", ".env.local"]
}