├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── bun.lockb ├── components.json ├── e2e └── conversation.spec.ts ├── eslint.config.js ├── gptme.toml ├── index.html ├── jest.config.ts ├── jest.setup.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── prettier.config.cjs ├── src ├── App.css ├── App.tsx ├── components │ ├── BrowserPreview.tsx │ ├── ChatInput.tsx │ ├── ChatMessage.tsx │ ├── CodeDisplay.tsx │ ├── ConnectionButton.tsx │ ├── ConversationContent.tsx │ ├── ConversationList.tsx │ ├── ConversationSettings.tsx │ ├── Conversations.tsx │ ├── DeleteConversationConfirmationDialog.tsx │ ├── LeftSidebar.tsx │ ├── MenuBar.tsx │ ├── MessageAvatar.tsx │ ├── RightSidebar.tsx │ ├── TabbedCodeBlock.tsx │ ├── ThemeToggle.tsx │ ├── ToolConfirmationDialog.tsx │ ├── __tests__ │ │ └── ChatMessage.test.tsx │ ├── settings │ │ ├── EnvironmentVariables.tsx │ │ ├── McpConfiguration.tsx │ │ └── ToolsConfiguration.tsx │ ├── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ └── workspace │ │ ├── FileList.tsx │ │ ├── FilePreview.tsx │ │ ├── PathSegments.tsx │ │ └── WorkspaceExplorer.tsx ├── contexts │ └── ApiContext.tsx ├── democonversations.ts ├── hooks │ ├── use-toast.ts │ ├── useConversation.ts │ └── useConversationSettings.ts ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── pages │ └── Index.tsx ├── schemas │ └── conversationSettings.ts ├── stores │ ├── conversations.ts │ └── sidebar.ts ├── types │ ├── api.ts │ ├── conversation.ts │ └── workspace.ts ├── utils │ ├── __tests__ │ │ ├── markdownRenderer.test.ts │ │ └── markdownUtils.test.ts │ ├── api.ts │ ├── consoleProxy.ts │ ├── conversation.ts │ ├── highlightUtils.ts │ ├── markdownRenderer.ts │ ├── markdownUtils.ts │ ├── messageUtils.ts │ ├── smd.d.ts │ ├── smd.js │ ├── time.ts │ ├── title.ts │ └── workspaceApi.ts └── vite-env.d.ts ├── tailwind.config.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.test.json └── vite.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master, 'dev/*' ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [20.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Type check 29 | run: npm run typecheck 30 | 31 | - name: Lint 32 | run: npm run lint 33 | 34 | - name: Test 35 | run: npm test 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | - name: Start gptme-server in background 41 | env: 42 | ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 43 | run: | 44 | # pipx install gptme[server] 45 | pipx install "gptme[server] @ git+https://github.com/gptme/gptme.git" 46 | gptme-server --cors-origin="http://localhost:5701" & # run in background 47 | sleep 3 # sleep so we get initial logs 48 | 49 | - name: Install Playwright browsers 50 | run: npx playwright install --with-deps chromium 51 | 52 | - name: Check that server is up 53 | run: curl --retry 2 --retry-delay 5 --retry-connrefused -sSfL http://localhost:5700/api 54 | 55 | - name: Run e2e tests 56 | run: npm run test:e2e 57 | 58 | - name: Upload test results 59 | if: always() 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: playwright-report 63 | path: playwright-report/ 64 | retention-days: 30 65 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | # Allow manual deployment 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow only one concurrent deployment 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | deploy: 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | cache: 'npm' 35 | 36 | - name: Install dependencies 37 | run: npm ci 38 | 39 | - name: Build 40 | run: npm run build 41 | 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v4 44 | 45 | - name: Upload artifact 46 | uses: actions/upload-pages-artifact@v3 47 | with: 48 | path: dist 49 | 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | 9 | - repo: local 10 | hooks: 11 | - id: lint 12 | name: lint 13 | stages: [pre-commit] 14 | types: [javascript, jsx, ts, tsx] 15 | entry: npm run lint:fix 16 | language: system 17 | pass_filenames: false 18 | always_run: true 19 | 20 | # handled by lint:fix 21 | #- id: format 22 | # name: format 23 | # stages: [commit] 24 | # types: [javascript, jsx, ts, tsx] 25 | # entry: npm run format 26 | # language: system 27 | # pass_filenames: false 28 | # always_run: true 29 | 30 | - id: typecheck 31 | name: typecheck 32 | stages: [pre-commit] 33 | types: [javascript, jsx, ts, tsx] 34 | entry: npm run typecheck 35 | language: system 36 | pass_filenames: false 37 | always_run: true 38 | 39 | # Uncomment the following lines to enable testing hooks 40 | # Don't commit them uncommented since they take too long to run every time 41 | 42 | #- id: test 43 | # name: test 44 | # types: [javascript, jsx, ts, tsx] 45 | # entry: npm test 46 | # language: system 47 | # pass_filenames: false 48 | # always_run: true 49 | 50 | #- id: test-e2e 51 | # name: test-e2e 52 | # types: [javascript, jsx, ts, tsx] 53 | # entry: npm run test:e2e 54 | # language: system 55 | # pass_filenames: false 56 | # always_run: true 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gptme-webui 2 | 3 | [![CI](https://github.com/gptme/gptme-webui/actions/workflows/ci.yml/badge.svg)](https://github.com/gptme/gptme-webui/actions/workflows/ci.yml) 4 | 5 | A fancy web UI for [gptme][gptme], built with [lovable.dev](https://lovable.dev). 6 | 7 | An alternative to the minimal UI currently provided by `gptme`. 8 | 9 | 10 | ## Features 11 | 12 | - Chat with LLMs using gptme, just like in the CLI, but with a fancy UI 13 | - Generate responses and run tools by connecting to your local gptme-server instance 14 | - Read bundled conversations without running gptme locally (useful for sharing) 15 | 16 | ## Usage 17 | 18 | You can use the web UI hosted at [chat.gptme.org](https://chat.gptme.org/), or run it locally: 19 | 20 | ```sh 21 | git clone https://github.com/gptme/gptme-webui 22 | cd gptme-webui 23 | npm i 24 | npm run dev 25 | ``` 26 | 27 | To connect to a local `gptme-server` instance, you need to start one with `gptme-server --cors-origin='https://chat.gptme.org'` (or whatever the origin of your web UI is). 28 | 29 | ## Tech stack 30 | 31 | This project is built with: 32 | 33 | - Vite 34 | - TypeScript 35 | - React 36 | - shadcn-ui 37 | - Tailwind CSS 38 | 39 | ## Development 40 | 41 | Available commands: 42 | 43 | - `npm run dev` - Start development server 44 | - `npm run typecheck` - Run type checking 45 | - `npm run typecheck:watch` - Run type checking in watch mode 46 | - `npm run build` - Build for production (includes type checking) 47 | - `npm run lint` - Run linting and type checking 48 | 49 | ## Project info 50 | 51 | **URL**: https://run.gptengineer.app/projects/b6f40770-f632-4741-8247-3d47b9beac4e/improve 52 | 53 | [gptme]: https://github.com/gptme/gptme 54 | [gptengineer.app]: https://gptengineer.app 55 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gptme/gptme-webui/6cd2dff1a25cd9bcd702a03e195fcb274a73c24f/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /e2e/conversation.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Connecting', () => { 4 | test('should connect and list conversations', async ({ page }) => { 5 | // Go to the app 6 | await page.goto('/'); 7 | 8 | // Should show demo conversations initially 9 | await expect(page.getByText('Introduction to gptme')).toBeVisible(); 10 | 11 | // Should show connection status 12 | const connectionButton = page.getByRole('button', { name: /Connect/i }); 13 | await expect(connectionButton).toBeVisible(); 14 | 15 | // Click the demo conversation 16 | await page.getByText('Introduction to gptme').click(); 17 | 18 | // Should show the conversation content 19 | await expect(page.getByText(/Hello! I'm gptme, your AI programming assistant/)).toBeVisible(); 20 | await page.goto('/'); 21 | 22 | // Should show demo conversations immediately 23 | await expect(page.getByText('Introduction to gptme')).toBeVisible(); 24 | 25 | // Wait for successful connection 26 | await expect(page.getByRole('button', { name: /Connect/i })).toHaveClass(/text-green-600/, { 27 | timeout: 10000, 28 | }); 29 | 30 | // Wait for success toast to confirm API connection 31 | await expect(page.getByText('Connected to gptme server')).toBeVisible(); 32 | 33 | // Wait for conversations to load 34 | // Should show both demo conversations and connected conversations 35 | await expect(page.getByText('Introduction to gptme')).toBeVisible(); 36 | 37 | // Wait for loading state to finish 38 | await expect(page.getByText('Loading conversations...')).toBeHidden(); 39 | 40 | // Get the conversation list 41 | const conversationList = page.getByTestId('conversation-list'); 42 | 43 | // Get all conversation titles 44 | const conversationTitles = await conversationList 45 | .locator('[data-testid="conversation-title"]') 46 | .allTextContents(); 47 | 48 | // Should have both demo and API conversations 49 | const demoConversations = conversationTitles.filter((title) => title.includes('Introduction')); 50 | const apiConversations = conversationTitles.filter((title) => /^\d+$/.test(title)); 51 | 52 | expect(demoConversations.length).toBeGreaterThan(0); 53 | 54 | if (apiConversations.length > 0) { 55 | // Check for historical timestamps if we have API conversations 56 | const timestamps = await conversationList 57 | .getByRole('button') 58 | .locator('time') 59 | .allTextContents(); 60 | expect(timestamps.length).toBeGreaterThan(1); 61 | 62 | // There should be some timestamps that aren't "just now" 63 | const nonJustNowTimestamps = timestamps.filter((t) => t !== 'just now'); 64 | expect(nonJustNowTimestamps.length).toBeGreaterThan(0); 65 | } else { 66 | // This happens when e2e tests are run in CI with a fresh gptme-server 67 | console.log('No API conversations found, skipping timestamp check'); 68 | } 69 | }); 70 | 71 | test('should handle connection errors gracefully', async ({ page }) => { 72 | // Start with server unavailable 73 | await page.goto('/'); 74 | 75 | // Should still show demo conversations 76 | await expect(page.getByText('Introduction to gptme')).toBeVisible(); 77 | 78 | // Click connect button and try to connect to non-existent server 79 | const connectionButton = page.getByRole('button', { name: /Connect/i }); 80 | await connectionButton.click(); 81 | 82 | // Fill in invalid server URL and try to connect 83 | await page.getByLabel('Server URL').fill('http://localhost:1'); 84 | await page.getByRole('button', { name: /^(Connect|Reconnect)$/ }).click(); 85 | 86 | // Wait for error toast to appear 87 | await expect(page.getByText('Could not connect to gptme instance')).toBeVisible({ 88 | timeout: 10000, 89 | }); 90 | 91 | // Close the connection dialog by clicking outside 92 | await page.keyboard.press('Escape'); 93 | 94 | // Verify connection button is in disconnected state 95 | await expect(connectionButton).toBeVisible(); 96 | await expect(connectionButton).not.toHaveClass(/text-green-600/); 97 | 98 | // Should show demo conversations 99 | await expect(page.getByText('Introduction to gptme')).toBeVisible(); 100 | 101 | // Should not show any API conversations 102 | const conversationList = page.getByTestId('conversation-list'); 103 | const conversationTitles = await conversationList 104 | .locator('[data-testid="conversation-title"]') 105 | .allTextContents(); 106 | 107 | const apiConversations = conversationTitles.filter((title) => /^\d+$/.test(title)); 108 | expect(apiConversations.length).toBe(0); 109 | }); 110 | }); 111 | 112 | test.describe('Conversation Flow', () => { 113 | test('should be able to create a new conversation and send a message', async ({ page }) => { 114 | await page.goto('/'); 115 | 116 | // Click the "New Conversation" button to start a new conversation 117 | await page.locator('[data-testid="new-conversation-button"]').click(); 118 | 119 | // Wait for the new conversation page to load 120 | await expect(page).toHaveURL(/\?conversation=\d+$/); 121 | 122 | const message = 'Hello. We are testing, just say exactly "Hello world" without anything else.'; 123 | 124 | // Type a message 125 | await page.getByTestId('chat-input').fill(message); 126 | await page.keyboard.press('Enter'); 127 | 128 | // Should show the message in the conversation 129 | // Look specifically for the user's message in a user message container 130 | await expect( 131 | page.locator('.role-user', { 132 | hasText: message, 133 | }) 134 | ).toBeVisible(); 135 | 136 | // Should show the AI's response 137 | await expect( 138 | page.locator('.role-assistant', { 139 | hasText: 'Hello world', 140 | }) 141 | ).toBeVisible(); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | import prettier from 'eslint-plugin-prettier'; 7 | 8 | export default tseslint.config( 9 | { ignores: ['dist', 'playwright.config.ts'] }, 10 | { 11 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 12 | files: ['**/*.{ts,tsx}'], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | globals: { 16 | ...globals.browser, 17 | ...globals.jest, 18 | React: 'readonly', 19 | }, 20 | parserOptions: { 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | sourceType: 'module', 25 | }, 26 | }, 27 | plugins: { 28 | 'react-hooks': reactHooks, 29 | 'react-refresh': reactRefresh, 30 | prettier: prettier, 31 | }, 32 | rules: { 33 | ...reactHooks.configs.recommended.rules, 34 | 'prettier/prettier': 'warn', 35 | '@typescript-eslint/no-unused-vars': [ 36 | 'warn', 37 | { 38 | argsIgnorePattern: '^_', 39 | varsIgnorePattern: '^_', 40 | ignoreRestSiblings: true, 41 | }, 42 | ], 43 | 'react-hooks/rules-of-hooks': 'error', 44 | 'react-hooks/exhaustive-deps': 'warn', 45 | 'no-undef': 'error', 46 | 'no-unused-vars': 'off', 47 | '@typescript-eslint/no-explicit-any': 'warn', 48 | '@typescript-eslint/consistent-type-imports': [ 49 | 'warn', 50 | { 51 | prefer: 'type-imports', 52 | fixStyle: 'inline-type-imports', 53 | }, 54 | ], 55 | //"@typescript-eslint/no-restricted-imports": [ 56 | // "error", 57 | // { 58 | // patterns: [ 59 | // { 60 | // group: ["react"], 61 | // message: "Import specific entities from react instead of the entire module", 62 | // allowTypeImports: true 63 | // } 64 | // ] 65 | // } 66 | //] 67 | }, 68 | } 69 | ); 70 | -------------------------------------------------------------------------------- /gptme.toml: -------------------------------------------------------------------------------- 1 | files = ["README.md", "package.json", "src/main.tsx", "src/App.tsx", "src/types/*.ts", "../gptme/gptme/server/api.py"] 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | gptme 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: 'jsdom', 3 | setupFilesAfterEnv: ['/jest.setup.ts'], 4 | moduleNameMapper: { 5 | '^@/(.*)$': '/src/$1', 6 | '\\.(css|less|sass|scss)$': 'identity-obj-proxy', 7 | }, 8 | transform: { 9 | '^.+\\.(ts|tsx|js|jsx)$': [ 10 | 'ts-jest', 11 | { 12 | useESM: true, 13 | tsconfig: 'tsconfig.test.json', 14 | }, 15 | ], 16 | }, 17 | testPathIgnorePatterns: ['/node_modules/', '/e2e/'], 18 | extensionsToTreatAsEsm: ['.ts', '.tsx'], 19 | }; 20 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gptme-webui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "typecheck": "tsc -p tsconfig.app.json --noEmit", 9 | "typecheck:watch": "tsc -p tsconfig.app.json --noEmit --watch", 10 | "build": "tsc -p tsconfig.app.json --noEmit && vite build", 11 | "build:dev": "tsc -p tsconfig.app.json --noEmit && vite build --mode development", 12 | "lint": "eslint . && tsc -p tsconfig.app.json --noEmit", 13 | "lint:fix": "eslint . --fix && tsc -p tsconfig.app.json --noEmit", 14 | "format": "prettier --list-different --write \"**/*.{ts,tsx,js,jsx,json,css,html}\"", 15 | "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,html}\"", 16 | "preview": "vite preview", 17 | "test": "NODE_OPTIONS=--no-warnings jest", 18 | "test:watch": "jest --watch", 19 | "test:coverage": "jest --coverage", 20 | "test:e2e": "playwright test --reporter=list", 21 | "test:e2e:ui": "playwright test --ui" 22 | }, 23 | "dependencies": { 24 | "@hookform/resolvers": "^3.9.0", 25 | "@legendapp/state": "^3.0.0-beta.30", 26 | "@radix-ui/react-accordion": "^1.2.0", 27 | "@radix-ui/react-alert-dialog": "^1.1.1", 28 | "@radix-ui/react-aspect-ratio": "^1.1.0", 29 | "@radix-ui/react-avatar": "^1.1.0", 30 | "@radix-ui/react-checkbox": "^1.1.1", 31 | "@radix-ui/react-collapsible": "^1.1.0", 32 | "@radix-ui/react-context-menu": "^2.2.1", 33 | "@radix-ui/react-dialog": "^1.1.1", 34 | "@radix-ui/react-dropdown-menu": "^2.1.1", 35 | "@radix-ui/react-hover-card": "^1.1.1", 36 | "@radix-ui/react-label": "^2.1.0", 37 | "@radix-ui/react-menubar": "^1.1.1", 38 | "@radix-ui/react-navigation-menu": "^1.2.0", 39 | "@radix-ui/react-popover": "^1.1.1", 40 | "@radix-ui/react-progress": "^1.1.0", 41 | "@radix-ui/react-radio-group": "^1.2.0", 42 | "@radix-ui/react-scroll-area": "^1.1.0", 43 | "@radix-ui/react-select": "^2.1.1", 44 | "@radix-ui/react-separator": "^1.1.0", 45 | "@radix-ui/react-slider": "^1.2.0", 46 | "@radix-ui/react-slot": "^1.1.0", 47 | "@radix-ui/react-switch": "^1.1.0", 48 | "@radix-ui/react-tabs": "^1.1.0", 49 | "@radix-ui/react-toast": "^1.2.1", 50 | "@radix-ui/react-toggle": "^1.1.0", 51 | "@radix-ui/react-toggle-group": "^1.1.0", 52 | "@radix-ui/react-tooltip": "^1.1.2", 53 | "@tanstack/react-query": "^5.56.2", 54 | "class-variance-authority": "^0.7.0", 55 | "clsx": "^2.1.1", 56 | "cmdk": "^1.0.0", 57 | "date-fns": "^3.6.0", 58 | "embla-carousel-react": "^8.3.0", 59 | "highlight.js": "^11.10.0", 60 | "input-otp": "^1.2.4", 61 | "lovable-tagger": "^1.0.19", 62 | "lucide-react": "^0.451.0", 63 | "marked": "^14.1.3", 64 | "marked-highlight": "^2.1.1", 65 | "next-themes": "^0.3.0", 66 | "react": "^18.3.1", 67 | "react-day-picker": "^8.10.1", 68 | "react-dom": "^18.3.1", 69 | "react-hook-form": "^7.53.0", 70 | "react-resizable-panels": "^2.1.3", 71 | "react-router-dom": "^6.26.2", 72 | "recharts": "^2.12.7", 73 | "sonner": "^1.5.0", 74 | "tailwind-merge": "^2.5.2", 75 | "tailwindcss-animate": "^1.0.7", 76 | "vaul": "^0.9.3", 77 | "zod": "^3.23.8" 78 | }, 79 | "devDependencies": { 80 | "@eslint/js": "^9.9.0", 81 | "@playwright/test": "^1.51.1", 82 | "@tailwindcss/typography": "^0.5.15", 83 | "@testing-library/jest-dom": "^6.6.3", 84 | "@testing-library/react": "^16.3.0", 85 | "@types/jest": "^29.5.14", 86 | "@types/node": "^22.5.5", 87 | "@types/react": "^18.3.3", 88 | "@types/react-dom": "^18.3.0", 89 | "@vitejs/plugin-react-swc": "^3.5.0", 90 | "@vitest/coverage-v8": "^3.1.1", 91 | "autoprefixer": "^10.4.20", 92 | "eslint": "^9.9.0", 93 | "eslint-plugin-prettier": "^5.2.3", 94 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 95 | "eslint-plugin-react-refresh": "^0.4.9", 96 | "globals": "^15.9.0", 97 | "identity-obj-proxy": "^3.0.0", 98 | "jest": "^29.7.0", 99 | "jest-environment-jsdom": "^29.7.0", 100 | "jsdom": "^26.0.0", 101 | "postcss": "^8.4.47", 102 | "prettier": "^3.2.5", 103 | "prettier-plugin-tailwindcss": "^0.5.12", 104 | "tailwindcss": "^3.4.11", 105 | "ts-jest": "^29.2.5", 106 | "ts-node": "^10.9.2", 107 | "typescript": "^5.5.3", 108 | "typescript-eslint": "^8.0.1", 109 | "vite": "^5.4.1", 110 | "vitest": "^3.1.1" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import { defineConfig, devices } from '@playwright/test'; 4 | 5 | const port = process.env.PORT || 5701; 6 | 7 | export default defineConfig({ 8 | testDir: './e2e', 9 | fullyParallel: true, 10 | forbidOnly: !!process.env.CI, 11 | retries: process.env.CI ? 2 : 0, 12 | workers: process.env.CI ? 1 : undefined, 13 | reporter: 'html', 14 | use: { 15 | baseURL: 'http://localhost:' + port, 16 | trace: 'on-first-retry', 17 | }, 18 | projects: [ 19 | { 20 | name: 'chromium', 21 | use: { ...devices['Desktop Chrome'] }, 22 | }, 23 | ], 24 | webServer: { 25 | command: 'npm run dev', 26 | url: 'http://localhost:' + port, 27 | reuseExistingServer: !process.env.CI, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | tabWidth: 2, 5 | trailingComma: "es5", 6 | printWidth: 100, 7 | bracketSameLine: false, 8 | useTabs: false, 9 | arrowParens: "always", 10 | endOfLine: "lf", 11 | plugins: ["prettier-plugin-tailwindcss"], 12 | }; 13 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from '@/components/ui/toaster'; 2 | import { Toaster as Sonner } from '@/components/ui/sonner'; 3 | import { TooltipProvider } from '@/components/ui/tooltip'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import { ApiProvider } from './contexts/ApiContext'; 7 | import Index from './pages/Index'; 8 | import type { FC } from 'react'; 9 | 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | // Disable automatic background refetching 14 | refetchOnWindowFocus: false, 15 | refetchOnMount: false, 16 | refetchOnReconnect: false, 17 | // Reduce stale time to ensure updates are visible immediately 18 | staleTime: 0, 19 | // Keep cached data longer 20 | gcTime: 1000 * 60 * 5, 21 | // Ensure we get updates 22 | notifyOnChangeProps: 'all', 23 | }, 24 | mutations: { 25 | // Ensure mutations trigger immediate updates 26 | onSuccess: () => { 27 | queryClient.invalidateQueries(); 28 | }, 29 | }, 30 | }, 31 | }); 32 | 33 | const App: FC = () => { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default App; 50 | -------------------------------------------------------------------------------- /src/components/BrowserPreview.tsx: -------------------------------------------------------------------------------- 1 | import { RefreshCw, Smartphone, Monitor, Terminal, Globe } from 'lucide-react'; 2 | import { Button } from '@/components/ui/button'; 3 | import { Input } from '@/components/ui/input'; 4 | import { useState, useEffect, useRef } from 'react'; 5 | import { consoleProxyScript } from '@/utils/consoleProxy'; 6 | import { ScrollArea } from '@/components/ui/scroll-area'; 7 | import type { FC } from 'react'; 8 | 9 | interface ConsoleMessage { 10 | level: 'log' | 'info' | 'warn' | 'error' | 'debug'; 11 | args: unknown[]; 12 | timestamp: number; 13 | } 14 | 15 | interface Props { 16 | defaultUrl?: string; 17 | } 18 | 19 | export const BrowserPreview: FC = ({ defaultUrl = 'http://localhost:8080' }) => { 20 | const [inputValue, setInputValue] = useState(defaultUrl); 21 | const [currentUrl, setCurrentUrl] = useState(defaultUrl); 22 | const [isMobile, setIsMobile] = useState(false); 23 | const [showConsole, setShowConsole] = useState(true); 24 | const [logs, setLogs] = useState([]); 25 | const iframeRef = useRef(null); 26 | 27 | // Clear logs when URL changes 28 | useEffect(() => { 29 | setLogs([]); 30 | }, [currentUrl]); 31 | 32 | const handleRefresh = () => { 33 | // Force refresh by appending a dummy parameter if URL is unchanged 34 | setCurrentUrl( 35 | inputValue === currentUrl 36 | ? `${inputValue}${inputValue.includes('?') ? '&' : '?'}_refresh=${Date.now()}` 37 | : inputValue 38 | ); 39 | setLogs([]); // Clear logs on refresh 40 | }; 41 | 42 | const toggleMode = () => { 43 | setIsMobile((prev) => !prev); 44 | }; 45 | 46 | const toggleConsole = () => { 47 | setShowConsole((prev) => !prev); 48 | }; 49 | 50 | useEffect(() => { 51 | const handleMessage = (event: MessageEvent) => { 52 | if (event.data?.type === 'console') { 53 | setLogs((prev) => [ 54 | ...prev, 55 | { 56 | level: event.data.level, 57 | args: event.data.args, 58 | timestamp: Date.now(), 59 | }, 60 | ]); 61 | } 62 | }; 63 | 64 | window.addEventListener('message', handleMessage); 65 | return () => window.removeEventListener('message', handleMessage); 66 | }, []); 67 | 68 | // Inject console proxy script when iframe loads 69 | // NOTE: only works with same-origin URLs (we need a workaround to capture logs from cross-origin iframes) 70 | const handleIframeLoad = () => { 71 | const iframe = iframeRef.current; 72 | if (iframe?.contentWindow) { 73 | // Use Function constructor instead of eval for better type safety 74 | const script = new Function(consoleProxyScript); 75 | iframe.contentWindow.document.head.appendChild( 76 | Object.assign(iframe.contentWindow.document.createElement('script'), { 77 | textContent: `(${script.toString()})();`, 78 | }) 79 | ); 80 | } 81 | }; 82 | 83 | const clearLogs = () => { 84 | setLogs([]); 85 | }; 86 | 87 | return ( 88 |
89 |
90 |
91 | 92 | setInputValue(e.target.value)} 96 | onKeyDown={(e) => { 97 | if (e.key === 'Enter') { 98 | handleRefresh(); 99 | } 100 | }} 101 | className="flex-1 pl-8" 102 | /> 103 |
104 | 107 | 115 | 123 |
124 |
125 |
126 | 108 | )} 109 |
110 |
111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from 'lucide-react'; 2 | import { Button } from '@/components/ui/button'; 3 | import { useEffect, useState } from 'react'; 4 | import type { FC } from 'react'; 5 | 6 | export const ThemeToggle: FC = () => { 7 | const [theme, setTheme] = useState<'light' | 'dark'>('light'); 8 | 9 | useEffect(() => { 10 | const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 11 | setTheme(isDark ? 'dark' : 'light'); 12 | }, []); 13 | 14 | useEffect(() => { 15 | document.documentElement.classList.toggle('dark', theme === 'dark'); 16 | }, [theme]); 17 | 18 | return ( 19 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/__tests__/ChatMessage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { ChatMessage } from '../ChatMessage'; 3 | import '@testing-library/jest-dom'; 4 | import type { Message } from '@/types/conversation'; 5 | import { observable } from '@legendapp/state'; 6 | 7 | // Mock the ApiContext 8 | jest.mock('@/contexts/ApiContext', () => ({ 9 | useApi: () => ({ 10 | baseUrl: 'http://localhost:5700', 11 | }), 12 | })); 13 | 14 | describe('ChatMessage', () => { 15 | const testConversationId = 'test-conversation'; 16 | 17 | it('renders user message', () => { 18 | const message$ = observable({ 19 | role: 'user', 20 | content: 'Hello!', 21 | timestamp: new Date().toISOString(), 22 | }); 23 | 24 | render(); 25 | expect(screen.getByText('Hello!')).toBeInTheDocument(); 26 | }); 27 | 28 | it('renders assistant message', () => { 29 | const message$ = observable({ 30 | role: 'assistant', 31 | content: 'Hi there!', 32 | timestamp: new Date().toISOString(), 33 | }); 34 | 35 | render(); 36 | expect(screen.getByText('Hi there!')).toBeInTheDocument(); 37 | }); 38 | 39 | it('renders system message with monospace font', () => { 40 | const message$ = observable({ 41 | role: 'system', 42 | content: 'System message', 43 | timestamp: new Date().toISOString(), 44 | }); 45 | 46 | const { container } = render( 47 | 48 | ); 49 | const messageElement = container.querySelector('.font-mono'); 50 | expect(messageElement).toBeInTheDocument(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/components/settings/EnvironmentVariables.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { FormDescription, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; 3 | import { Input } from '@/components/ui/input'; 4 | import { Plus, X } from 'lucide-react'; 5 | import { useState } from 'react'; 6 | import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form'; 7 | import type { FormSchema } from '@/schemas/conversationSettings'; 8 | 9 | interface EnvironmentVariablesProps { 10 | form: UseFormReturn; 11 | fieldArray: UseFieldArrayReturn; 12 | isSubmitting: boolean; 13 | description?: string; 14 | className?: string; 15 | } 16 | 17 | export const EnvironmentVariables = ({ 18 | form, 19 | fieldArray, 20 | isSubmitting, 21 | description, 22 | className, 23 | }: EnvironmentVariablesProps) => { 24 | const [newEnvKey, setNewEnvKey] = useState(''); 25 | const [newEnvValue, setNewEnvValue] = useState(''); 26 | const { fields, append, remove } = fieldArray; 27 | 28 | const handleAddEnvVar = () => { 29 | const trimmedKey = newEnvKey.trim(); 30 | if (trimmedKey) { 31 | append({ key: trimmedKey, value: newEnvValue }); 32 | setNewEnvKey(''); 33 | setNewEnvValue(''); 34 | } 35 | }; 36 | 37 | return ( 38 | 39 | Environment Variables 40 |
41 | {fields.map((field, index) => ( 42 |
43 | 49 | 55 | 65 |
66 | ))} 67 |
68 |
69 | setNewEnvKey(e.target.value)} 73 | disabled={isSubmitting} 74 | className="w-1/3" 75 | /> 76 | setNewEnvValue(e.target.value)} 80 | disabled={isSubmitting} 81 | className="flex-grow" 82 | onKeyDown={(e) => { 83 | if (e.key === 'Enter') { 84 | e.preventDefault(); 85 | handleAddEnvVar(); 86 | } 87 | }} 88 | /> 89 | 99 |
100 | {description && {description}} 101 | {form.formState.errors.chat?.env && ( 102 | {form.formState.errors.chat.env.message} 103 | )} 104 |
105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/settings/ToolsConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { 3 | FormDescription, 4 | FormField, 5 | FormItem, 6 | FormLabel, 7 | FormControl, 8 | FormMessage, 9 | } from '@/components/ui/form'; 10 | import { Input } from '@/components/ui/input'; 11 | import { 12 | Select, 13 | SelectContent, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue, 17 | } from '@/components/ui/select'; 18 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; 19 | import { ChevronDown, ChevronRight, X } from 'lucide-react'; 20 | import { useState } from 'react'; 21 | import { type UseFieldArrayReturn, type UseFormReturn } from 'react-hook-form'; 22 | import type { FormSchema } from '@/schemas/conversationSettings'; 23 | import { ToolFormat } from '@/types/api'; 24 | 25 | interface ToolsConfigurationProps { 26 | form: UseFormReturn; 27 | toolFields: UseFieldArrayReturn; 28 | isSubmitting: boolean; 29 | } 30 | 31 | export const ToolsConfiguration = ({ form, toolFields, isSubmitting }: ToolsConfigurationProps) => { 32 | const [toolsOpen, setToolsOpen] = useState(false); 33 | const [newToolName, setNewToolName] = useState(''); 34 | const { fields, append, remove } = toolFields; 35 | 36 | const handleAddTool = () => { 37 | const trimmedName = newToolName.trim(); 38 | if (trimmedName) { 39 | append({ name: trimmedName }); 40 | setNewToolName(''); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 |

Tools

47 | 48 | ( 52 | 53 | Tool Format 54 | 72 | 73 | 74 | )} 75 | /> 76 | 77 | 78 | 79 | 80 |
81 | Enabled Tools 82 | {toolsOpen ? ( 83 | 84 | ) : ( 85 | 86 | )} 87 |
88 | 89 | List of tools that the agent can use. 90 | 91 |
92 | 93 |
94 | {fields.map((field, index) => ( 95 |
96 | {field.name} 97 | 107 |
108 | ))} 109 |
110 |
111 | setNewToolName(e.target.value)} 115 | disabled={isSubmitting} 116 | onKeyDown={(e) => { 117 | if (e.key === 'Enter') { 118 | e.preventDefault(); 119 | handleAddTool(); 120 | } 121 | }} 122 | /> 123 | 131 |
132 |
133 |
134 |
135 |
136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as AccordionPrimitive from '@radix-ui/react-accordion'; 3 | import { ChevronDown } from 'lucide-react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const Accordion = AccordionPrimitive.Root; 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 14 | )); 15 | AccordionItem.displayName = 'AccordionItem'; 16 | 17 | const AccordionTrigger = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, children, ...props }, ref) => ( 21 | 22 | svg]:rotate-180', 26 | className 27 | )} 28 | {...props} 29 | > 30 | {children} 31 | 32 | 33 | 34 | )); 35 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 36 | 37 | const AccordionContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 46 |
{children}
47 |
48 | )); 49 | 50 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 51 | 52 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 53 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | import { buttonVariants } from '@/components/ui/button'; 6 | 7 | const AlertDialog = AlertDialogPrimitive.Root; 8 | 9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger; 10 | 11 | const AlertDialogPortal = AlertDialogPrimitive.Portal; 12 | 13 | const AlertDialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )); 26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; 27 | 28 | const AlertDialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 33 | 34 | 42 | 43 | )); 44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; 45 | 46 | const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( 47 |
48 | ); 49 | AlertDialogHeader.displayName = 'AlertDialogHeader'; 50 | 51 | const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 52 |
56 | ); 57 | AlertDialogFooter.displayName = 'AlertDialogFooter'; 58 | 59 | const AlertDialogTitle = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )); 69 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; 70 | 71 | const AlertDialogDescription = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >(({ className, ...props }, ref) => ( 75 | 80 | )); 81 | AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; 82 | 83 | const AlertDialogAction = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 88 | )); 89 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; 90 | 91 | const AlertDialogCancel = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )); 101 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; 102 | 103 | export { 104 | AlertDialog, 105 | AlertDialogPortal, 106 | AlertDialogOverlay, 107 | AlertDialogTrigger, 108 | AlertDialogContent, 109 | AlertDialogHeader, 110 | AlertDialogFooter, 111 | AlertDialogTitle, 112 | AlertDialogDescription, 113 | AlertDialogAction, 114 | AlertDialogCancel, 115 | }; 116 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const alertVariants = cva( 7 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | destructive: 13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: 'default', 18 | }, 19 | } 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
27 | )); 28 | Alert.displayName = 'Alert'; 29 | 30 | const AlertTitle = React.forwardRef>( 31 | ({ className, ...props }, ref) => ( 32 |
37 | ) 38 | ); 39 | AlertTitle.displayName = 'AlertTitle'; 40 | 41 | const AlertDescription = React.forwardRef< 42 | HTMLParagraphElement, 43 | React.HTMLAttributes 44 | >(({ className, ...props }, ref) => ( 45 |
46 | )); 47 | AlertDescription.displayName = 'AlertDescription'; 48 | 49 | export { Alert, AlertTitle, AlertDescription }; 50 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; 2 | 3 | const AspectRatio = AspectRatioPrimitive.Root; 4 | 5 | export { AspectRatio }; 6 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as AvatarPrimitive from '@radix-ui/react-avatar'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 15 | )); 16 | Avatar.displayName = AvatarPrimitive.Root.displayName; 17 | 18 | const AvatarImage = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 27 | )); 28 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 29 | 30 | const AvatarFallback = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 42 | )); 43 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 44 | 45 | export { Avatar, AvatarImage, AvatarFallback }; 46 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | } 23 | ); 24 | 25 | export interface BadgeProps 26 | extends React.HTMLAttributes, 27 | VariantProps {} 28 | 29 | function Badge({ className, variant, ...props }: BadgeProps) { 30 | return
; 31 | } 32 | 33 | export { Badge, badgeVariants }; 34 | -------------------------------------------------------------------------------- /src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { ChevronRight, MoreHorizontal } from 'lucide-react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<'nav'> & { 10 | separator?: React.ReactNode; 11 | } 12 | >(({ ...props }, ref) =>