Deleting chat ui example as it's not ready
This commit is contained in:
parent
52a409c1e2
commit
b43914c7c1
44 changed files with 0 additions and 7675 deletions
30
examples/chat-ui/.gitignore
vendored
30
examples/chat-ui/.gitignore
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
.wrangler
|
|
||||||
|
|
||||||
# Playwright test results
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 140
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# AI Chat Template - Development Guide
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- **Dev server**: `pnpm dev`
|
|
||||||
- **Build**: `pnpm build` (runs TypeScript compilation then Vite build)
|
|
||||||
- **Lint**: `pnpm lint` (ESLint)
|
|
||||||
- **Deploy**: `pnpm deploy` (builds and deploys with Wrangler)
|
|
||||||
- **Test**: `pnpm test` (Playwright E2E tests)
|
|
||||||
- **Test UI**: `pnpm test:ui` (Playwright test runner with UI)
|
|
||||||
- **Test headed**: `pnpm test:headed` (Run tests in visible browser)
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
- **Formatting**: Prettier with 2-space tabs, single quotes, no semicolons, 140 char line width
|
|
||||||
- **Imports**: Use `.tsx`/`.ts` extensions in imports, group by external/internal
|
|
||||||
- **Components**: React FC with explicit typing, PascalCase names
|
|
||||||
- **Hooks**: Custom hooks start with `use`, camelCase
|
|
||||||
- **Types**: Define interfaces in `src/types/index.ts`, use `type` for unions
|
|
||||||
- **Files**: Use PascalCase for components, camelCase for hooks/utilities
|
|
||||||
- **State**: Use proper TypeScript typing for all state variables
|
|
||||||
- **Error handling**: Use try/catch blocks with proper error propagation
|
|
||||||
- **Database**: IndexedDB with typed interfaces, async/await pattern
|
|
||||||
- **Styling**: Tailwind CSS classes, responsive design patterns
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
React 19, TypeScript, Vite, Tailwind CSS, Hono API, Cloudflare Workers, IndexedDB
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# AI chat template
|
|
||||||
|
|
||||||
An unofficial template for ⚛️ React ⨉ ⚡️ Vite ⨉ ⛅️ Cloudflare Workers AI.
|
|
||||||
|
|
||||||
Full-stack AI chat application using Workers for the APIs (using the Cloudflare [vite-plugin](https://www.npmjs.com/package/@cloudflare/vite-plugin)) and Vite for the React application (hosted using [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/)). Provides chat functionality with [Workers AI](https://developers.cloudflare.com/workers-ai/), stores conversations in the browser's [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API), and uses [ai-sdk](https://sdk.vercel.ai/docs/introduction), [tailwindcss](https://tailwindcss.com/) and [workers-ai-provider](https://github.com/cloudflare/workers-ai-provider).
|
|
||||||
|
|
||||||
## Get started
|
|
||||||
|
|
||||||
Create the project using [create-cloudflare](https://www.npmjs.com/package/create-cloudflare):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm create cloudflare@latest -- --template thomasgauvin/ai-chat-template
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the project and deploy it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd <project-name>
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
## What's next?
|
|
||||||
|
|
||||||
- Change the name of the package (in `package.json`)
|
|
||||||
- Change the name of the worker (in `wrangler.jsonc`)
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import type { LanguageModelV1StreamPart } from 'ai'
|
|
||||||
import { streamText, extractReasoningMiddleware, wrapLanguageModel } from 'ai'
|
|
||||||
import { createWorkersAI } from 'workers-ai-provider'
|
|
||||||
import { Hono } from 'hono'
|
|
||||||
|
|
||||||
interface Env {
|
|
||||||
ASSETS: Fetcher
|
|
||||||
AI: Ai
|
|
||||||
}
|
|
||||||
|
|
||||||
type message = {
|
|
||||||
role: 'system' | 'user' | 'assistant' | 'data'
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Env }>()
|
|
||||||
|
|
||||||
// Handle the /api/chat endpoint
|
|
||||||
app.post('/api/chat', async (c) => {
|
|
||||||
try {
|
|
||||||
const { messages, reasoning }: { messages: message[]; reasoning: boolean } = await c.req.json()
|
|
||||||
|
|
||||||
const workersai = createWorkersAI({ binding: c.env.AI })
|
|
||||||
|
|
||||||
// Choose model based on reasoning preference
|
|
||||||
const model = reasoning
|
|
||||||
? wrapLanguageModel({
|
|
||||||
model: workersai('@cf/deepseek-ai/deepseek-r1-distill-qwen-32b'),
|
|
||||||
middleware: [
|
|
||||||
extractReasoningMiddleware({ tagName: 'think' }),
|
|
||||||
//custom middleware to inject <think> tag at the beginning of a reasoning if it is missing
|
|
||||||
{
|
|
||||||
wrapGenerate: async ({ doGenerate }) => {
|
|
||||||
const result = await doGenerate()
|
|
||||||
|
|
||||||
if (!result.text?.includes('<think>')) {
|
|
||||||
result.text = `<think>${result.text}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
wrapStream: async ({ doStream }) => {
|
|
||||||
const { stream, ...rest } = await doStream()
|
|
||||||
|
|
||||||
let generatedText = ''
|
|
||||||
const transformStream = new TransformStream<LanguageModelV1StreamPart, LanguageModelV1StreamPart>({
|
|
||||||
transform(chunk, controller) {
|
|
||||||
//we are manually adding the <think> tag because some times, distills of reasoning models omit it
|
|
||||||
if (chunk.type === 'text-delta') {
|
|
||||||
if (!generatedText.includes('<think>')) {
|
|
||||||
generatedText += '<think>'
|
|
||||||
controller.enqueue({
|
|
||||||
type: 'text-delta',
|
|
||||||
textDelta: '<think>',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
generatedText += chunk.textDelta
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.enqueue(chunk)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
stream: stream.pipeThrough(transformStream),
|
|
||||||
...rest,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
: workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast')
|
|
||||||
|
|
||||||
const systemPrompt: message = {
|
|
||||||
role: 'system',
|
|
||||||
content: `
|
|
||||||
- Do not wrap your responses in html tags.
|
|
||||||
- Do not apply any formatting to your responses.
|
|
||||||
- You are an expert conversational chatbot. Your objective is to be as helpful as possible.
|
|
||||||
- You must keep your responses relevant to the user's prompt.
|
|
||||||
- You must respond with a maximum of 512 tokens (300 words).
|
|
||||||
- You must respond clearly and concisely, and explain your logic if required.
|
|
||||||
- You must not provide any personal information.
|
|
||||||
- Do not respond with your own personal opinions, and avoid topics unrelated to the user's prompt.
|
|
||||||
${
|
|
||||||
messages.length <= 1 &&
|
|
||||||
`- Important REMINDER: You MUST provide a 5 word title at the END of your response using <chat-title> </chat-title> tags.
|
|
||||||
If you do not do this, this session will error.
|
|
||||||
For example, <chat-title>Hello and Welcome</chat-title> Hi, how can I help you today?
|
|
||||||
`
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await streamText({
|
|
||||||
model,
|
|
||||||
messages: [systemPrompt, ...messages],
|
|
||||||
maxTokens: 2048,
|
|
||||||
maxRetries: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
return text.toDataStreamResponse({
|
|
||||||
sendReasoning: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
return c.json({ error: `Chat completion failed. ${(error as Error)?.message}` }, 500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle static assets and fallback routes
|
|
||||||
app.all('*', async (c) => {
|
|
||||||
if (c.env.ASSETS) {
|
|
||||||
return c.env.ASSETS.fetch(c.req.raw)
|
|
||||||
}
|
|
||||||
return c.text('Not found', 404)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test'
|
|
||||||
import { exec } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
interface ExecError extends Error {
|
|
||||||
stdout?: string
|
|
||||||
stderr?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Build Tests', () => {
|
|
||||||
test('should build without type errors', async () => {
|
|
||||||
try {
|
|
||||||
const { stderr } = await execAsync('pnpm build', {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
timeout: 60000
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check for TypeScript compilation errors
|
|
||||||
expect(stderr).not.toContain('error TS')
|
|
||||||
expect(stderr).not.toContain('Type error')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const execError = error as ExecError
|
|
||||||
console.error('Build failed:', execError.stdout, execError.stderr)
|
|
||||||
throw new Error(`Build failed: ${execError.message}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should lint without errors', async () => {
|
|
||||||
try {
|
|
||||||
const { stderr } = await execAsync('pnpm lint', {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
timeout: 30000
|
|
||||||
})
|
|
||||||
|
|
||||||
// ESLint should pass without errors
|
|
||||||
expect(stderr).not.toContain('error')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const execError = error as ExecError
|
|
||||||
console.error('Lint failed:', execError.stdout, execError.stderr)
|
|
||||||
throw new Error(`Lint failed: ${execError.message}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
test.describe('Chat Functionality', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Mock the external AI API calls
|
|
||||||
await page.route('https://api.groq.com/**', async route => {
|
|
||||||
// Mock streaming response for Groq
|
|
||||||
const mockResponse = 'Hello! This is a mocked response from the AI assistant.'
|
|
||||||
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
},
|
|
||||||
body: mockResponse,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.route('https://api.anthropic.com/**', async route => {
|
|
||||||
// Mock streaming response for Anthropic
|
|
||||||
const mockResponse = 'Hello! This is a mocked response from Claude.'
|
|
||||||
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
},
|
|
||||||
body: mockResponse,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock the local API endpoint
|
|
||||||
await page.route('/api/chat', async route => {
|
|
||||||
const mockResponse = {
|
|
||||||
choices: [{
|
|
||||||
delta: {
|
|
||||||
content: 'Hello! This is a mocked response from the local API.'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(mockResponse),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should load the chat interface', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
// Check if the main elements are present
|
|
||||||
await expect(page.locator('body')).toBeVisible()
|
|
||||||
|
|
||||||
// Look for chat-related elements (adjust selectors based on your UI)
|
|
||||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
|
||||||
await expect(messageInput).toBeVisible()
|
|
||||||
|
|
||||||
// Check for a send button or similar
|
|
||||||
const sendButton = page.locator('button').filter({ hasText: /send|submit/i }).first()
|
|
||||||
if (await sendButton.isVisible()) {
|
|
||||||
await expect(sendButton).toBeVisible()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should send a message and receive a response', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
// Wait for the page to load
|
|
||||||
await page.waitForLoadState('networkidle')
|
|
||||||
|
|
||||||
// Find the message input field
|
|
||||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
|
||||||
await expect(messageInput).toBeVisible()
|
|
||||||
|
|
||||||
// Type a test message
|
|
||||||
const testMessage = 'Hello, can you help me?'
|
|
||||||
await messageInput.fill(testMessage)
|
|
||||||
await expect(messageInput).toHaveValue(testMessage)
|
|
||||||
|
|
||||||
// Send the message by pressing Enter (most common method)
|
|
||||||
await messageInput.press('Enter')
|
|
||||||
|
|
||||||
// Wait a moment for the form submission to process
|
|
||||||
await page.waitForTimeout(1000)
|
|
||||||
|
|
||||||
// Check that a conversation has started - look for any text content that might be a message
|
|
||||||
const pageContent = await page.textContent('body')
|
|
||||||
expect(pageContent).toContain(testMessage)
|
|
||||||
|
|
||||||
// The input may or may not clear depending on implementation details
|
|
||||||
// This is less critical than the message appearing
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should handle API errors gracefully', async ({ page }) => {
|
|
||||||
// Override mocks to simulate API errors
|
|
||||||
await page.route('https://api.groq.com/**', async route => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ error: 'Internal server error' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.route('https://api.anthropic.com/**', async route => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ error: 'Unauthorized' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
// Try to send a message
|
|
||||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
|
||||||
await messageInput.fill('Test message')
|
|
||||||
|
|
||||||
const sendButton = page.locator('button').filter({ hasText: /send|submit/i }).first()
|
|
||||||
if (await sendButton.isVisible()) {
|
|
||||||
await sendButton.click()
|
|
||||||
} else {
|
|
||||||
await messageInput.press('Enter')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should handle the error without crashing
|
|
||||||
await page.waitForTimeout(2000)
|
|
||||||
|
|
||||||
// The app should still be functional (not showing a white screen or error page)
|
|
||||||
await expect(page.locator('body')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should persist conversations', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
// Send a message
|
|
||||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
|
||||||
await messageInput.fill('Test message for persistence')
|
|
||||||
|
|
||||||
const sendButton = page.locator('button').filter({ hasText: /send|submit/i }).first()
|
|
||||||
if (await sendButton.isVisible()) {
|
|
||||||
await sendButton.click()
|
|
||||||
} else {
|
|
||||||
await messageInput.press('Enter')
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForTimeout(2000)
|
|
||||||
|
|
||||||
// Refresh the page
|
|
||||||
await page.reload()
|
|
||||||
await page.waitForLoadState('networkidle')
|
|
||||||
|
|
||||||
// Check if the conversation persisted (this depends on your IndexedDB implementation)
|
|
||||||
// We'll just verify the page loads correctly after refresh
|
|
||||||
await expect(page.locator('body')).toBeVisible()
|
|
||||||
|
|
||||||
const inputAfterReload = page.locator('textarea, input[type="text"]').first()
|
|
||||||
await expect(inputAfterReload).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist'] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Vite + React + TS</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
{
|
|
||||||
"name": "ai-chat-template",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"deploy": "npm run build && npm exec wrangler deploy",
|
|
||||||
"test": "playwright test",
|
|
||||||
"test:ui": "playwright test --ui",
|
|
||||||
"test:headed": "playwright test --headed",
|
|
||||||
"test:html": "playwright test --reporter=html"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/anthropic": "^1.2.12",
|
|
||||||
"@ai-sdk/groq": "^1.2.9",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@tailwindcss/vite": "^4.0.14",
|
|
||||||
"ai": "^4.1.61",
|
|
||||||
"hono": "^4.7.9",
|
|
||||||
"idb": "^8.0.2",
|
|
||||||
"lucide-react": "^0.482.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"rehype-highlight": "^7.0.2",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"tailwindcss": "^4.0.14",
|
|
||||||
"use-mcp": "link:../..",
|
|
||||||
"workers-ai-provider": "^0.1.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@ai-sdk/ui-utils": "^1.2.11",
|
|
||||||
"@cloudflare/vite-plugin": "^0.1.12",
|
|
||||||
"@cloudflare/workers-types": "^4.20250317.0",
|
|
||||||
"@eslint/js": "^9.21.0",
|
|
||||||
"@playwright/test": "^1.52.0",
|
|
||||||
"@types/react": "^19.0.10",
|
|
||||||
"@types/react-dom": "^19.0.4",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"eslint": "^9.21.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
|
||||||
"globals": "^15.15.0",
|
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"typescript": "~5.7.2",
|
|
||||||
"typescript-eslint": "^8.24.1",
|
|
||||||
"vite": "^6.2.0",
|
|
||||||
"wrangler": "^4.1.0"
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { defineConfig, devices } from '@playwright/test'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see https://playwright.dev/docs/test-configuration
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './e2e',
|
|
||||||
/* Run tests in files in parallel */
|
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
||||||
reporter: 'list',
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
baseURL: 'http://localhost:5173',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
|
||||||
webServer: {
|
|
||||||
command: 'pnpm dev',
|
|
||||||
url: 'http://localhost:5173',
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
5136
examples/chat-ui/pnpm-lock.yaml
generated
5136
examples/chat-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,11 +0,0 @@
|
||||||
import ChatApp from "./components/ChatApp";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ChatApp />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import { X, Eye, EyeOff } from 'lucide-react'
|
|
||||||
import { type ModelProvider } from '../types/models'
|
|
||||||
|
|
||||||
interface ApiKeyModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
provider: ModelProvider
|
|
||||||
onSave: (apiKey: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
provider,
|
|
||||||
onSave,
|
|
||||||
}) => {
|
|
||||||
const [apiKey, setApiKey] = useState('')
|
|
||||||
const [showApiKey, setShowApiKey] = useState(false)
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (apiKey.trim()) {
|
|
||||||
onSave(apiKey.trim())
|
|
||||||
setApiKey('')
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setApiKey('')
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalContent = (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-lg p-6 w-full max-w-md mx-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-zinc-900">
|
|
||||||
{provider.logo} {provider.name} API Key
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="text-zinc-400 hover:text-zinc-600 p-1"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-zinc-600 mb-4">
|
|
||||||
Enter your {provider.name} API key to use this model. Your key will be stored locally in your browser.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label htmlFor="apiKey" className="block text-sm font-medium text-zinc-700 mb-2">
|
|
||||||
API Key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showApiKey ? 'text' : 'password'}
|
|
||||||
id="apiKey"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-zinc-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent pr-10"
|
|
||||||
placeholder={`Enter your ${provider.name} API key`}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600"
|
|
||||||
>
|
|
||||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<a
|
|
||||||
href={provider.documentationUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Get API key →
|
|
||||||
</a>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="px-4 py-2 text-sm text-zinc-600 hover:text-zinc-800"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!apiKey.trim()}
|
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-zinc-300 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return createPortal(modalContent, document.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ApiKeyModal
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import ChatSidebar from "./ChatSidebar.tsx";
|
|
||||||
import ChatNavbar from "./ChatNavbar.tsx";
|
|
||||||
import ConversationThread from "./ConversationThread.tsx";
|
|
||||||
import { storeName } from "../consts.ts";
|
|
||||||
import { type Conversation } from "../types";
|
|
||||||
import { useIndexedDB } from "../hooks/useIndexedDB";
|
|
||||||
import { type Model } from "../types/models";
|
|
||||||
import { getSelectedModel, setSelectedModel as saveSelectedModel } from "../utils/modelPreferences";
|
|
||||||
import { type IDBPDatabase } from "idb";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
interface ChatAppProps {}
|
|
||||||
|
|
||||||
const ChatApp: React.FC<ChatAppProps> = () => {
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
||||||
const [conversationId, setConversationId] = useState<number | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [sidebarVisible, setSidebarVisible] = useState<boolean>(true);
|
|
||||||
const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel());
|
|
||||||
const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0);
|
|
||||||
const db = useIndexedDB();
|
|
||||||
|
|
||||||
const handleApiKeyUpdate = () => {
|
|
||||||
setApiKeyUpdateTrigger(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelChange = (model: Model) => {
|
|
||||||
setSelectedModel(model);
|
|
||||||
saveSelectedModel(model);
|
|
||||||
};
|
|
||||||
|
|
||||||
// set up conversations on app load
|
|
||||||
useEffect(() => {
|
|
||||||
getConversations();
|
|
||||||
deleteUnusedConversations();
|
|
||||||
startNewConversation();
|
|
||||||
}, [db]);
|
|
||||||
|
|
||||||
// Initialize sidebar visibility based on screen size
|
|
||||||
useEffect(() => {
|
|
||||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
|
||||||
setSidebarVisible(!isMobile);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getConversations = async () => {
|
|
||||||
if (!db) return;
|
|
||||||
|
|
||||||
const conversations = (await db.getAll(storeName)) as Conversation[];
|
|
||||||
const inverseConversations = conversations.reverse();
|
|
||||||
setConversations(inverseConversations);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteConversation = async (id: number, showPromptToUser = true) => {
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
showPromptToUser &&
|
|
||||||
!window.confirm("Are you sure you want to delete this conversation?")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db?.delete(storeName, id);
|
|
||||||
setConversations((prev) => prev.filter((conv) => conv.id !== id));
|
|
||||||
setConversationId(conversations[0]?.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete conversation:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const editConversationTitle = async (id: number, newTitle: string) => {
|
|
||||||
const conversation = (await db!.get(storeName, id)) as Conversation;
|
|
||||||
conversation.title = newTitle;
|
|
||||||
await db!.put(storeName, conversation);
|
|
||||||
setConversations((prev) =>
|
|
||||||
prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle } : conv))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startNewConversation = async () => {
|
|
||||||
//create unique id for new conversation
|
|
||||||
setConversationId(Date.now() + Math.floor(Math.random() * 1000));
|
|
||||||
if (window.matchMedia("(max-width: 768px)").matches) {
|
|
||||||
setSidebarVisible(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// delete conversations with no messages
|
|
||||||
const deleteUnusedConversations = async () => {
|
|
||||||
if (!db) return;
|
|
||||||
const conversations = (await db.getAll(storeName)) as Conversation[];
|
|
||||||
const unusedConversations = conversations.filter(
|
|
||||||
(conversation) => conversation.messages.length === 0
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const conversation of unusedConversations) {
|
|
||||||
deleteConversation(conversation.id as number, false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-dvh w-screen overflow-clip bg-white">
|
|
||||||
<div className="flex flex-row flex-grow flex-1 overflow-hidden relative">
|
|
||||||
<ChatSidebar
|
|
||||||
sidebarVisible={sidebarVisible}
|
|
||||||
setSidebarVisible={setSidebarVisible}
|
|
||||||
conversations={conversations}
|
|
||||||
conversationId={conversationId}
|
|
||||||
setConversationId={setConversationId}
|
|
||||||
deleteConversation={deleteConversation}
|
|
||||||
editConversationTitle={editConversationTitle}
|
|
||||||
startNewConversation={startNewConversation}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onModelChange={handleModelChange}
|
|
||||||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col flex-grow h-full w-[calc(100%-16rem)] lg:w-[calc(100%-32rem)]">
|
|
||||||
<ChatNavbar
|
|
||||||
sidebarVisible={sidebarVisible}
|
|
||||||
setSidebarVisible={setSidebarVisible}
|
|
||||||
/>
|
|
||||||
<ConversationThread
|
|
||||||
conversations={conversations}
|
|
||||||
conversationId={conversationId}
|
|
||||||
setConversationId={setConversationId}
|
|
||||||
setConversations={setConversations}
|
|
||||||
db={db as IDBPDatabase}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onApiKeyUpdate={handleApiKeyUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatApp;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import React, { FormEvent, KeyboardEvent, useRef, useEffect, Dispatch, SetStateAction } from 'react'
|
|
||||||
import { Send, Pause } from 'lucide-react'
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
|
||||||
input: string
|
|
||||||
setInput: Dispatch<SetStateAction<string>>
|
|
||||||
handleSubmit: (e: FormEvent) => void
|
|
||||||
isLoading: boolean
|
|
||||||
streamStarted: boolean
|
|
||||||
controller: AbortController
|
|
||||||
messagesCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatInput: React.FC<ChatInputProps> = ({ input, setInput, handleSubmit, isLoading, streamStarted, controller, messagesCount }) => {
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto'
|
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
|
||||||
}
|
|
||||||
}, [input])
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSubmit(e as unknown as FormEvent)
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
setInput((prev: string) => prev + '\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative rounded-lg border border-zinc-200 bg-white shadow-sm">
|
|
||||||
<form onSubmit={handleSubmit} className={`w-full items-center`}>
|
|
||||||
<div className="flex justify-end items-center">
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
rows={1}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="w-full flex-1 p-3 bg-transparent resize-none focus:outline-none"
|
|
||||||
placeholder={messagesCount === 0 ? 'Ask anything...' : 'Type your message...'}
|
|
||||||
style={{ maxHeight: '200px' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type={isLoading && streamStarted ? 'button' : 'submit'}
|
|
||||||
onClick={isLoading && streamStarted ? () => controller.abort() : undefined}
|
|
||||||
className="m-2 hover:bg-zinc-100 rounded-lg p-2 text-zinc-600 hover:text-zinc-900"
|
|
||||||
disabled={!isLoading && !input.trim()}
|
|
||||||
>
|
|
||||||
{isLoading && streamStarted ? <Pause size={20} /> : <Send size={20} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChatInput
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import rehypeHighlight from "rehype-highlight";
|
|
||||||
import { type Message } from "../types";
|
|
||||||
|
|
||||||
interface ChatMessageProps {
|
|
||||||
message: Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatMessage: React.FC<ChatMessageProps> = ({
|
|
||||||
message,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex ${
|
|
||||||
message.role === "user" ? "justify-end" : "justify-start"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
${
|
|
||||||
message.role === "user"
|
|
||||||
? "max-w-[80%] rounded-2xl px-3 py-2 border border-zinc-200/10 bg-zinc-100 text-black"
|
|
||||||
: " text-zinc-900 w-full"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="prose prose-zinc">
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
rehypePlugins={[rehypeHighlight]}
|
|
||||||
components={{
|
|
||||||
table: ({ children }) => (
|
|
||||||
<div className="overflow-x-scroll text-sm">{children}</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatMessage;
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { PanelLeftOpen } from "lucide-react";
|
|
||||||
|
|
||||||
interface ChatNavbarProps {
|
|
||||||
sidebarVisible: boolean;
|
|
||||||
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
|
||||||
sidebarVisible,
|
|
||||||
setSidebarVisible,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="sticky top-0 bg-white border-b border-zinc-200 z-10">
|
|
||||||
<div className="my-3 mx-2 flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
{!sidebarVisible && (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarVisible(!sidebarVisible)}
|
|
||||||
className="
|
|
||||||
rounded-lg p-[0.4em]
|
|
||||||
hover:bg-zinc-100 hover:cursor-pointer
|
|
||||||
mr-2 transition-colors text-zinc-600 hover:text-zinc-800"
|
|
||||||
>
|
|
||||||
<PanelLeftOpen size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h1 className="text-base font-semibold text-zinc-600 ml-2">
|
|
||||||
AI Chat Template
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatNavbar;
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
PanelLeftClose,
|
|
||||||
PanelLeftOpen,
|
|
||||||
SquarePen,
|
|
||||||
} from "lucide-react";
|
|
||||||
import ModelSelector from "./ModelSelector";
|
|
||||||
import { McpServers } from "./McpServers";
|
|
||||||
import { type Model } from "../types/models";
|
|
||||||
import { type Message } from "../types";
|
|
||||||
|
|
||||||
interface Conversation {
|
|
||||||
id?: number;
|
|
||||||
title: string;
|
|
||||||
messages: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
|
||||||
sidebarVisible: boolean;
|
|
||||||
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
conversations: Conversation[];
|
|
||||||
conversationId: number | undefined;
|
|
||||||
setConversationId: React.Dispatch<React.SetStateAction<number | undefined>>;
|
|
||||||
deleteConversation: (id: number) => void;
|
|
||||||
editConversationTitle: (id: number, newTitle: string) => void;
|
|
||||||
startNewConversation: () => void;
|
|
||||||
selectedModel: Model;
|
|
||||||
onModelChange: (model: Model) => void;
|
|
||||||
apiKeyUpdateTrigger: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
|
||||||
sidebarVisible,
|
|
||||||
setSidebarVisible,
|
|
||||||
conversations,
|
|
||||||
conversationId,
|
|
||||||
setConversationId,
|
|
||||||
deleteConversation,
|
|
||||||
editConversationTitle,
|
|
||||||
startNewConversation,
|
|
||||||
selectedModel,
|
|
||||||
onModelChange,
|
|
||||||
apiKeyUpdateTrigger,
|
|
||||||
}) => {
|
|
||||||
const handleConversationClick = (id: number | undefined) => {
|
|
||||||
setConversationId(id);
|
|
||||||
if (window.matchMedia("(max-width: 768px)").matches) {
|
|
||||||
setSidebarVisible(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Mobile overlay */}
|
|
||||||
{sidebarVisible && (
|
|
||||||
<div
|
|
||||||
className="md:hidden fixed inset-0 bg-black/30 z-20"
|
|
||||||
onClick={() => setSidebarVisible(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
fixed md:relative
|
|
||||||
z-30 md:z-auto
|
|
||||||
h-full
|
|
||||||
bg-zinc-50
|
|
||||||
transition-all duration-300
|
|
||||||
|
|
||||||
${
|
|
||||||
sidebarVisible
|
|
||||||
? "w-64 lg:w-[32rem] translate-x-0"
|
|
||||||
: "w-0 -translate-x-full md:translate-x-0"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`m-2 flex items-center justify-between
|
|
||||||
${sidebarVisible ? "sticky" : "hidden"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarVisible(!sidebarVisible)}
|
|
||||||
className="
|
|
||||||
rounded-lg p-[0.4em]
|
|
||||||
hover:bg-zinc-100 hover:cursor-pointer
|
|
||||||
transition-colors text-zinc-600 hover:text-zinc-800"
|
|
||||||
>
|
|
||||||
{sidebarVisible ? (
|
|
||||||
<PanelLeftClose size={20} />
|
|
||||||
) : (
|
|
||||||
<PanelLeftOpen size={20} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-lg p-[0.4em]
|
|
||||||
hover:bg-zinc-100 hover:cursor-pointer
|
|
||||||
transition-colors text-zinc-600 hover:text-zinc-800"
|
|
||||||
onClick={startNewConversation}
|
|
||||||
>
|
|
||||||
<SquarePen size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className=" h-[calc(100%-3rem)]
|
|
||||||
overflow-y-scroll scrollbar-thin
|
|
||||||
flex flex-col justify-between border-r border-zinc-200 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<ul className="p-2 space-y-1">
|
|
||||||
{conversations.map((conversation) => (
|
|
||||||
<li
|
|
||||||
key={conversation.id}
|
|
||||||
className={`cursor-pointer p-2 transition-colors rounded-lg ${
|
|
||||||
conversation.id === conversationId ||
|
|
||||||
(!conversationId && !conversation.id)
|
|
||||||
? "bg-zinc-200 text-zinc-900"
|
|
||||||
: "hover:bg-zinc-200 text-zinc-600"
|
|
||||||
}`}
|
|
||||||
onClick={() => handleConversationClick(conversation.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="truncate flex-grow text-sm">
|
|
||||||
{conversation.title}
|
|
||||||
</span>
|
|
||||||
<div className="flex space-x-2 ml-2">
|
|
||||||
<button
|
|
||||||
className="opacity-60 hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const newTitle = prompt(
|
|
||||||
"Enter new title:",
|
|
||||||
conversation.title
|
|
||||||
);
|
|
||||||
if (newTitle)
|
|
||||||
editConversationTitle(conversation.id!, newTitle);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="opacity-60 hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteConversation(conversation.id!);
|
|
||||||
setConversationId(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Selector and MCP Servers at bottom */}
|
|
||||||
<div className="p-2 border-t border-zinc-200 space-y-3">
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
|
||||||
/>
|
|
||||||
<McpServers />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatSidebar;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue