diff --git a/WARP.md b/WARP.md new file mode 120000 index 0000000000..30d0565f90 --- /dev/null +++ b/WARP.md @@ -0,0 +1 @@ +copilot-instructions.md \ No newline at end of file diff --git a/copilot-instructions.md b/copilot-instructions.md new file mode 100644 index 0000000000..cd0cb90afc --- /dev/null +++ b/copilot-instructions.md @@ -0,0 +1,273 @@ +# WARP.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Quick Start + +### Prerequisites +- **Python**: >= 3.10 +- **Node.js**: >= 20 (24+ recommended) +- **uv**: 2.1.3+ (install via pipx) +- **pnpm**: Latest (install via npm) + +### Essential Setup Commands + +**CRITICAL**: Never cancel build operations - they can take several minutes and must complete. + +```bash +# Install Python dependencies (~2 minutes) +cd backend +uv sync --extra tests --extra mypy --extra dev --extra custom-data +# Timeout: Use 300+ seconds minimum + +# Install Node.js dependencies (~3 minutes, Cypress may fail - this is normal) +cd .. +pnpm install --frozen-lockfile +# Timeout: Use 600+ seconds minimum + +# Build frontend (~1 minute) +pnpm run buildUi +# Timeout: Use 300+ seconds minimum +``` + +### Development Servers + +```bash +# Backend (Terminal 1) +cd backend +export PATH="$HOME/.local/bin:$PATH" +uv run chainlit run chainlit/hello.py -h +# Available at http://localhost:8000 + +# Frontend (Terminal 2) - for UI development +cd frontend +pnpm run dev +# Available at http://localhost:5173/ +``` + +### Testing & Linting + +```bash +# Backend tests (~17 seconds) +cd backend +uv run pytest --cov=chainlit/ + +# Frontend tests (~4 seconds) +cd frontend +pnpm test + +# All linting (~2 minutes, required before commits) +pnpm run lint +``` + +## Architecture Overview + +Chainlit is a Python framework for building conversational AI applications with a **monorepo** structure: + +``` +/ +├── backend/ # Python backend (FastAPI + SocketIO) +│ ├── chainlit/ # Main package +│ ├── pyproject.toml # uv configuration +│ └── tests/ # Python tests +├── frontend/ # React frontend (Vite + TypeScript) +├── libs/ +│ ├── react-client/ # React hooks & API client +│ └── copilot/ # Embeddable widget +├── cypress/ # E2E tests +└── pnpm-workspace.yaml # Workspace definition +``` + +### Core Architecture Components + +**Backend (FastAPI + SocketIO)** +- **FastAPI** serves REST API and static files +- **SocketIO** handles real-time WebSocket communication +- **uv** manages Python dependencies +- **Entry point**: `backend/chainlit/server.py` + +**Frontend (React + Recoil)** +- **React 18+** with TypeScript and Tailwind CSS +- **Recoil** for state management +- **Vite** for development and building +- **Entry point**: `frontend/src/App.tsx` + +**Communication Flow** +1. Frontend connects via WebSocket to backend +2. Real-time bidirectional messaging through SocketIO +3. File uploads handled via REST endpoints +4. Authentication managed through JWT tokens + +## Development Guide + +### Core Patterns + +**Creating a Chainlit App** +```python +import chainlit as cl + +@cl.on_message +async def main(message: cl.Message): + await cl.Message(content=f"You said: {message.content}").send() + +# Run with: uv run chainlit run app.py -w +``` + +**Key Decorators & Hooks** +- `@cl.on_message` - Handle user messages +- `@cl.on_chat_start` - Initialize chat session +- `@cl.step(type="tool")` - Create structured steps +- `@cl.on_chat_end` - Cleanup on session end + +**Message Types** +- `cl.Message` - Standard chat messages +- `cl.AskUserMessage` - Request user input +- `cl.ErrorMessage` - Error notifications +- Elements: `cl.Image`, `cl.File`, `cl.Audio`, etc. + +### File Locations + +**Backend Core** +- `backend/chainlit/__init__.py` - Main package exports +- `backend/chainlit/server.py` - FastAPI application +- `backend/chainlit/cli/` - Command-line interface +- `backend/chainlit/socket.py` - WebSocket handlers +- `backend/chainlit/auth/` - Authentication modules + +**Frontend Core** +- `frontend/src/App.tsx` - Main React application +- `frontend/src/api/index.ts` - API client configuration +- `frontend/src/components/chat/` - Chat UI components +- `libs/react-client/src/` - Reusable React hooks + +### Common Development Tasks + +**Running Single Tests** +```bash +# Backend specific test +cd backend +uv run pytest tests/test_specific.py::test_function + +# Frontend specific test +cd frontend +pnpm test src/components/Button.test.tsx + +# E2E specific test +pnpm test -- --spec cypress/e2e/specific_test.cy.ts +``` + +**Format Code** +```bash +# Format frontend code +pnpm run formatUi + +# Format Python code (use ruff instead of black) +cd backend +uv run ruff format chainlit/ tests/ +``` + +**Manual Testing** +- Always test complete user workflows after changes +- Use `uv run chainlit run /path/to/test.py -h` for headless testing +- Frontend dev server connects to backend at localhost:8000 + +### Build & Deployment + +**Production Build** +```bash +# Build all frontend components +pnpm run buildUi + +# This builds: +# - libs/react-client (shared hooks) +# - libs/copilot (embeddable widget) +# - frontend (main app) +``` + +**Environment Variables** +- `CHAINLIT_AUTH_SECRET` - JWT signing secret (required for auth) +- `CHAINLIT_HOST` / `CHAINLIT_PORT` - Server binding +- `OPENAI_API_KEY` - For LLM integrations + +## Critical Gotchas + +### Build & Install +- **Never cancel** long-running operations (pnpm install, buildUi, tests) +- Cypress download often fails in CI - this is expected and normal +- Always use `uv run` prefix for Python commands in backend +- Use `export PATH="$HOME/.local/bin:$PATH"` to ensure uv availability +- `pnpm run formatPython` may fail - use `uv run ruff format` instead + +### Development +- Start backend before frontend for development +- Frontend dev server requires backend running on port 8000 +- WebSocket reconnection may require browser refresh during development +- File uploads require proper MIME type configuration +- Theme changes require CSS variable updates + +### Timing Expectations +- **pnpm install**: ~3 minutes +- **uv sync**: ~2 minutes +- **pnpm run buildUi**: ~1 minute +- **pnpm run lint**: ~2 minutes +- **Backend tests**: ~17 seconds +- **Frontend tests**: ~4 seconds + +## Authentication & Security + +Chainlit supports multiple authentication methods: + +**OAuth Providers** +- Configure in `backend/chainlit/oauth_providers.py` +- Supports Google, GitHub, Azure, etc. + +**Header-based Auth** +- For reverse proxy setups +- Configure via `@cl.header_auth_callback` + +**Password Auth** +- Simple username/password +- Configure via `@cl.password_auth_callback` + +**JWT Tokens** +- All auth methods generate JWT tokens +- Stored in HTTP-only cookies for security + +## Testing Strategy + +### Test Levels +1. **Unit Tests**: Backend (`pytest`) & Frontend (`vitest`) +2. **Integration Tests**: API endpoints and component interactions +3. **E2E Tests**: Full user workflows with Cypress + +### CI Pipeline +The CI runs 4 main jobs (see `.github/workflows/ci.yaml`): +- `pytest` - Backend unit tests +- `lint-backend` - Python code quality (mypy, ruff) +- `lint-ui` - Frontend code quality (eslint, tsc) +- `e2e-tests` - Full application testing + +All must pass for PR merge. Run `pnpm run lint` locally to prevent CI failures. + +## Performance Considerations + +**WebSocket Management** +- Connections auto-reconnect on disconnect +- Session state persists during reconnections +- Use connection pooling in production + +**File Handling** +- Files stored temporarily in `FILES_DIRECTORY` +- Cleanup happens on app shutdown +- Configure max file sizes via `max_size_mb` + +**State Management** +- Frontend uses Recoil for reactive state +- Chat history managed client-side +- Backend maintains session context per connection + +## Community Maintenance Notice + +⚠️ **Notice**: Chainlit is now community-maintained as of May 1st 2025. The original team has stepped back from active development. The project is maintained by @Chainlit/chainlit-maintainers under a formal Maintainer Agreement. + +**Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed contribution guidelines and development setup instructions. \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts index 87e26ec521..57a0a6b2e8 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -35,6 +35,8 @@ export default defineConfig({ baseUrl: `http://127.0.0.1:${CHAINLIT_APP_PORT}`, experimentalInteractiveRunEvents: true, async setupNodeEvents(on, config) { + // Ensure the spawned Chainlit uses a known free port + process.env.CHAINLIT_PORT = String(CHAINLIT_APP_PORT); await killChainlit(); // Fallback to ensure no previous instance is running await runChainlit(); // Start Chainlit before running tests as Cypress require diff --git a/cypress/support/run.ts b/cypress/support/run.ts index 625535d63d..3254c05d2f 100644 --- a/cypress/support/run.ts +++ b/cypress/support/run.ts @@ -12,62 +12,72 @@ export const runChainlit = async ( const CHAILIT_DIR = join(process.cwd(), 'backend', 'chainlit'); return new Promise((resolve, reject) => { - const testDir = spec ? dirname(spec.absolute) : CHAILIT_DIR; - const entryPointFileName = spec - ? spec.name.startsWith('async') - ? 'main_async.py' - : spec.name.startsWith('sync') - ? 'main_sync.py' - : 'main.py' - : 'hello.py'; + (async () => { + const testDir = spec ? dirname(spec.absolute) : CHAILIT_DIR; + const entryPointFileName = spec + ? spec.name.startsWith('async') + ? 'main_async.py' + : spec.name.startsWith('sync') + ? 'main_sync.py' + : 'main.py' + : 'hello.py'; - const entryPointPath = join(testDir, entryPointFileName); + const entryPointPath = join(testDir, entryPointFileName); - if (!access(entryPointPath)) { - return reject( - new Error(`Entry point file does not exist: ${entryPointPath}`) - ); - } + try { + await access(entryPointPath); + } catch (_err) { + reject(new Error(`Entry point file does not exist: ${entryPointPath}`)); + return; + } - const command = 'uv'; + // Prefer system uv and allow overrides via UV_PATH; ensure pipx path is in PATH + const command = process.env.UV_PATH || 'uv'; - const args = [ - '--project', - CHAILIT_DIR, - 'run', - 'chainlit', - 'run', - entryPointPath, - '-h', - '--ci' - ]; + const args = [ + '--project', + CHAILIT_DIR, + 'run', + 'chainlit', + 'run', + entryPointPath, + '-h', + '--ci' + ]; - const options: SpawnOptionsWithoutStdio = { - env: { - ...process.env, - CHAINLIT_APP_ROOT: testDir - } - }; + const options: SpawnOptionsWithoutStdio = { + env: { + ...process.env, + PATH: `${process.env.PATH}:${process.env.HOME}/.local/bin`, + CHAINLIT_APP_ROOT: testDir, + CHAINLIT_PORT: process.env.CHAINLIT_PORT || '8000' + } + }; - const chainlit = spawn(command, args, options); + try { + const chainlit = spawn(command, args, options); - chainlit.stdout.on('data', (data) => { - const output = data.toString(); - if (output.includes('Your app is available at')) { - resolve(chainlit); - } - }); + chainlit.stdout.on('data', (data) => { + const output = data.toString(); + if (output.includes('Your app is available at')) { + resolve(chainlit); + } + }); - chainlit.stderr.on('data', (data) => { - console.error(`[Chainlit stderr] ${data}`); - }); + chainlit.stderr.on('data', (data) => { + console.error(`[Chainlit stderr] ${data}`); + }); - chainlit.on('error', (error) => { - reject(error.message); - }); + chainlit.on('error', (error) => { + reject(error.message); + }); - chainlit.on('exit', function (code) { - reject('Chainlit process exited with code ' + code); - }); + chainlit.on('exit', function (code) { + reject('Chainlit process exited with code ' + code); + }); + } catch (_err) { + reject(_err instanceof Error ? _err : new Error(String(_err))); + } + })(); }); }; diff --git a/frontend/src/components/LeftSidebar/ThreadList.tsx b/frontend/src/components/LeftSidebar/ThreadList.tsx index 3b97b590ea..d354690ade 100644 --- a/frontend/src/components/LeftSidebar/ThreadList.tsx +++ b/frontend/src/components/LeftSidebar/ThreadList.tsx @@ -9,8 +9,7 @@ import { toast } from 'sonner'; import { ChainlitContext, ClientError, - ThreadHistory, - // sessionIdState, + ThreadHistory, // sessionIdState, threadHistoryState, useChatInteract, useChatMessages, @@ -20,6 +19,7 @@ import { import Alert from '@/components/Alert'; import { Loader } from '@/components/Loader'; +import ShareDialog from '@/components/share/ShareDialog'; import { AlertDialog, AlertDialogAction, @@ -31,7 +31,14 @@ import { AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -50,7 +57,6 @@ import { } from '@/components/ui/tooltip'; import { Translator } from '../i18n'; -import ShareDialog from '@/components/share/ShareDialog'; import ThreadOptions from './ThreadOptions'; interface ThreadListProps { @@ -90,7 +96,7 @@ export function ThreadList({ if (!threadSharingReady) return; setThreadIdToShare(threadId); setIsShareDialogOpen(true); - // ShareDialog handles its own internal state; we just open it + // ShareDialog handles its own internal state; we just open it }; const sortedTimeGroupKeys = useMemo(() => { diff --git a/frontend/src/components/ReadOnlyThread.tsx b/frontend/src/components/ReadOnlyThread.tsx index 575c633e50..e9c274d8b2 100644 --- a/frontend/src/components/ReadOnlyThread.tsx +++ b/frontend/src/components/ReadOnlyThread.tsx @@ -38,9 +38,13 @@ const ReadOnlyThread = ({ id }: Props) => { error: threadError, isLoading } = useApi( - id ? (isSharedRoute ? `/project/share/${id}` : `/project/thread/${id}`) : null, + id + ? isSharedRoute + ? `/project/share/${id}` + : `/project/thread/${id}` + : null, { - revalidateOnFocus: false + revalidateOnFocus: false } ); const navigate = useNavigate(); diff --git a/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx b/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx index ccce77e6ab..9d35d4834d 100644 --- a/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx +++ b/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx @@ -159,7 +159,9 @@ export const CommandPopoverButton = ({ 'hover:bg-muted hover:dark:bg-muted transition-all duration-200 transition-width-padding', 'focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0', open && 'bg-muted/50', - hasSelectedNonButtonCommand ? 'min-w-[36px] px-0 gap-0' : 'px-3 gap-1.5' + hasSelectedNonButtonCommand + ? 'min-w-[36px] px-0 gap-0' + : 'px-3 gap-1.5' )} disabled={disabled} onMouseEnter={scheduleTooltipOpen} diff --git a/frontend/src/components/header/Share.tsx b/frontend/src/components/header/Share.tsx index 68880cf8b1..366c68487a 100644 --- a/frontend/src/components/header/Share.tsx +++ b/frontend/src/components/header/Share.tsx @@ -1,11 +1,11 @@ import { hasMessage } from '@/lib/utils'; import { Share2 } from 'lucide-react'; import { useState } from 'react'; + import { useChatMessages, useConfig } from '@chainlit/react-client'; -import { Button } from '@/components/ui/button'; -import { Translator } from '../i18n'; import ShareDialog from '@/components/share/ShareDialog'; +import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, @@ -13,6 +13,8 @@ import { TooltipTrigger } from '@/components/ui/tooltip'; +import { Translator } from '../i18n'; + export default function ShareButton() { const { messages, threadId } = useChatMessages(); const [isOpen, setIsOpen] = useState(false); @@ -21,7 +23,12 @@ export default function ShareButton() { const threadSharingReady = Boolean((config as any)?.threadSharing); // Only show the button if messages, persistence is on, and feature is ready - if (!hasMessage(messages) || !dataPersistence || !threadId || !threadSharingReady) + if ( + !hasMessage(messages) || + !dataPersistence || + !threadId || + !threadSharingReady + ) return null; return ( @@ -49,4 +56,4 @@ export default function ShareButton() { ); -} \ No newline at end of file +} diff --git a/frontend/src/components/share/ShareDialog.tsx b/frontend/src/components/share/ShareDialog.tsx index 01696f3fa8..26eb947925 100644 --- a/frontend/src/components/share/ShareDialog.tsx +++ b/frontend/src/components/share/ShareDialog.tsx @@ -1,4 +1,5 @@ -import { useContext, useMemo, useState, useCallback } from 'react'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { toast } from 'sonner'; import { @@ -6,10 +7,14 @@ import { ClientError, threadHistoryState } from '@chainlit/react-client'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; import { Translator } from '../i18n'; @@ -19,7 +24,11 @@ type ShareDialogProps = { threadId?: string | null; }; -export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps) { +export function ShareDialog({ + open, + onOpenChange, + threadId +}: ShareDialogProps) { const apiClient = useContext(ChainlitContext); const [isCopying, setIsCopying] = useState(false); const [isCopied, setIsCopied] = useState(false); @@ -51,21 +60,26 @@ export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps) setHasBeenCopied(true); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); - toast.success(); + toast.success( + + ); return; } setIsCopying(true); if (typeof (apiClient as any)?.shareThread === 'function') { await (apiClient as any).shareThread(threadId, true); } else { - const putRes = await (apiClient as any).put?.(`/project/thread/share`, { - threadId, - isShared: true - }); + const putRes = await (apiClient as any).put?.( + `/project/thread/share`, + { + threadId, + isShared: true + } + ); await putRes?.json?.(); } setSharedThreadId(threadId); - await navigator.clipboard.writeText(shareLink); + await navigator.clipboard.writeText(shareLink); await new Promise((resolve) => setTimeout(resolve, 1000)); setIsCopying(false); setHasBeenCopied(true); @@ -80,7 +94,9 @@ export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps) } return next; }); - toast.success(); + toast.success( + + ); } else { await navigator.clipboard.writeText(shareLink); } @@ -92,7 +108,9 @@ export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps) // Show server-provided detail when available toast.error(err.toString()); } else { - toast.error(); + toast.error( + + ); } } }; @@ -123,14 +141,18 @@ export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps) return next; }); setIsCopying(false); - toast.success(); + toast.success( + + ); onOpenChange(false); } catch (err: any) { setIsCopying(false); if (err instanceof ClientError) { toast.error(err.toString()); } else { - toast.error(); + toast.error( + + ); } } }, [apiClient, onOpenChange, setThreadHistory, threadId]); @@ -148,7 +170,7 @@ export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps) } }} > - + @@ -164,7 +186,10 @@ export function ShareDialog({ open, onOpenChange, threadId }: ShareDialogProps)
- {isAlreadyShared ? ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 053bd486cb..eb3928b7a2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -151,4 +151,4 @@ code { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; } -} \ No newline at end of file +} diff --git a/frontend/src/pages/Env.tsx b/frontend/src/pages/Env.tsx index 94c3973718..1bb7b97a84 100644 --- a/frontend/src/pages/Env.tsx +++ b/frontend/src/pages/Env.tsx @@ -86,7 +86,7 @@ const Env = () => { <> - {isSharedRoute ? : null} + {isSharedRoute ? : null} {config?.threadResumable && !isCurrentThread && !isSharedRoute ? ( ) : null} diff --git a/libs/react-client/src/api/index.tsx b/libs/react-client/src/api/index.tsx index e6b2911c84..ca868ff892 100644 --- a/libs/react-client/src/api/index.tsx +++ b/libs/react-client/src/api/index.tsx @@ -376,7 +376,10 @@ export class ChainlitAPI extends APIBase { getOAuthEndpoint(provider: string) { return this.buildEndpoint(`/auth/oauth/${provider}`); } - async shareThread(threadId: string, isShared: boolean): Promise<{ success: boolean }> { + async shareThread( + threadId: string, + isShared: boolean + ): Promise<{ success: boolean }> { const res = await this.put(`/project/thread/share`, { threadId, isShared diff --git a/libs/react-client/src/useChatSession.ts b/libs/react-client/src/useChatSession.ts index 1aab70b720..47aa368918 100644 --- a/libs/react-client/src/useChatSession.ts +++ b/libs/react-client/src/useChatSession.ts @@ -241,7 +241,9 @@ const useChatSession = () => { }); socket.on('resume_thread', (thread: IThread) => { - const isReadOnlyView = Boolean((thread as any)?.metadata?.viewer_read_only); + const isReadOnlyView = Boolean( + (thread as any)?.metadata?.viewer_read_only + ); if (!isReadOnlyView && idToResume && thread.id !== idToResume) { window.location.href = `/thread/${thread.id}`; } diff --git a/libs/react-client/src/useConfig.ts b/libs/react-client/src/useConfig.ts index 13c367912a..eeb1a2d666 100644 --- a/libs/react-client/src/useConfig.ts +++ b/libs/react-client/src/useConfig.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { useApi, useAuth } from './api'; -import { configState, chatProfileState } from './state'; +import { chatProfileState, configState } from './state'; import { IChainlitConfig } from './types'; const useConfig = () => { @@ -13,8 +13,10 @@ const useConfig = () => { const prevChatProfileRef = useRef(chatProfile); // Build the API URL with optional chat profile parameter - const apiUrl = isAuthenticated - ? `/project/settings?language=${language}${chatProfile ? `&chat_profile=${encodeURIComponent(chatProfile)}` : ''}` + const apiUrl = isAuthenticated + ? `/project/settings?language=${language}${ + chatProfile ? `&chat_profile=${encodeURIComponent(chatProfile)}` : '' + }` : null; // Always fetch if we don't have config and we're authenticated