Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c75b1952 | ||
|
|
c834b114a7 | ||
|
|
59d2dfca8b | ||
|
|
4e4d9d99a3 | ||
|
|
39043c12d6 | ||
|
|
0d0f30f4aa | ||
|
|
f460842bbc | ||
|
|
cd8df15e94 | ||
|
|
b07b0a73e1 | ||
|
|
9b34461c2d | ||
|
|
5e41222c63 | ||
|
|
0c7b9e0537 | ||
|
|
caed6d373c | ||
|
|
ba845e7852 | ||
|
|
52c3e8d727 | ||
|
|
022747ba04 | ||
|
|
6429f48763 | ||
|
|
5e69116f47 | ||
|
|
51fafb485c | ||
|
|
ab73226b87 | ||
|
|
840fbb4ad6 | ||
|
|
12dc6433c3 | ||
|
|
4e282ea1de | ||
|
|
2c8391007c | ||
|
|
4f87d827aa | ||
|
|
dffc1aa5fb | ||
|
|
8159950ffd | ||
|
|
55d54bce60 | ||
|
|
0ed5dbaa67 | ||
|
|
59026addde | ||
|
|
d2b6f8013e | ||
|
|
6126405ab0 | ||
|
|
e7dd5776c3 | ||
|
|
03d95e70b7 | ||
|
|
ba9e6c9f3f | ||
|
|
ccad616e02 | ||
|
|
78e01032ae | ||
|
|
b810f639aa | ||
|
|
bfe2bd5816 | ||
|
|
68ecdebbc0 | ||
|
|
33c914e19b | ||
|
|
ab9f3c0220 | ||
|
|
6c7422f832 | ||
|
|
03dd988f35 | ||
|
|
9a825d5afc | ||
|
|
f53bc6867b | ||
|
|
25857bc354 | ||
|
|
62000dcdac | ||
|
|
ace85a6c69 | ||
|
|
43dc39df96 | ||
|
|
8049c6bebe | ||
|
|
5087ebacdc | ||
|
|
a3200e09cb | ||
|
|
71b07c488b | ||
|
|
4680b9d3e8 | ||
|
|
ec7f1ff6dd | ||
|
|
089c1c9f9e | ||
|
|
78f0e364a6 | ||
|
|
32a6bf5abd | ||
|
|
272fd5606a | ||
|
|
1e68fca937 | ||
|
|
0ce394f53c | ||
|
|
644b3b663d | ||
|
|
d15c88419c | ||
|
|
1f80d7040b | ||
|
|
1514c6470e | ||
|
|
812fdf92e3 | ||
|
|
c3624e6bbc | ||
|
|
b1f7709a05 | ||
|
|
11360f38fc | ||
|
|
3461d3c900 | ||
|
|
50e97bb613 | ||
|
|
6a79264315 | ||
|
|
b33bde6b0e | ||
|
|
433eac58f1 | ||
|
|
d47124b8b3 | ||
|
|
f94a80cbd9 | ||
|
|
668ecaff91 | ||
|
|
15bb63670a | ||
|
|
55c1756f36 | ||
|
|
6ebb156fe6 | ||
|
|
769475cecc | ||
|
|
d25f6c19a3 | ||
|
|
e94958865a | ||
|
|
f5f1943ac5 |
79 changed files with 18855 additions and 210 deletions
|
|
@ -1,3 +1,5 @@
|
|||
pnpm-lock.yaml
|
||||
*.md
|
||||
examples
|
||||
.wrangler
|
||||
.vscode
|
||||
test-results
|
||||
|
|
|
|||
4
AGENT.md
4
AGENT.md
|
|
@ -27,6 +27,10 @@
|
|||
- Log errors with appropriate levels (debug, info, warn, error)
|
||||
- Defensive error handling with specific error types when available
|
||||
|
||||
## Development Workflow
|
||||
- **Commit changes frequently** after each logical change or debugging step
|
||||
- Use descriptive commit messages that explain what was changed and why
|
||||
|
||||
### React Patterns
|
||||
- Use React hooks with useRef for mutable values
|
||||
- Stable callbacks with useCallback and appropriate dependencies
|
||||
30
examples/chat-ui/.gitignore
vendored
Normal file
30
examples/chat-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# 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/
|
||||
7
examples/chat-ui/.prettierrc
Normal file
7
examples/chat-ui/.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 140
|
||||
}
|
||||
73
examples/chat-ui/AGENT.md
Normal file
73
examples/chat-ui/AGENT.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# 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, React Router, use-mcp
|
||||
|
||||
## Chat UI Specific Guidelines
|
||||
|
||||
### Background Animation
|
||||
- Use CSS `hue-rotate` filter for color cycling (more performant than gradient animation)
|
||||
- Isolate animations to `::before` pseudo-elements with `z-index: -1` to prevent inheritance
|
||||
- Use `background-size: 100% 100vh` and `background-repeat: repeat-y` for repeating patterns
|
||||
- Initialize with random animation delay using CSS custom properties
|
||||
|
||||
### Modal Patterns
|
||||
- Use `rgba(0,0,0,0.8)` for modal backdrops (avoid gradients that cause banding)
|
||||
- Implement click-outside-to-dismiss with `onClick` on backdrop and `stopPropagation` on content
|
||||
- Add `cursor-pointer` to all clickable elements including close buttons
|
||||
- Use `document.body.style.overflow = 'hidden'` to prevent background scrolling
|
||||
|
||||
### MCP Server Management
|
||||
- Store multiple servers in localStorage as JSON array (`mcpServers`)
|
||||
- Track tool counts separately (`mcpServerToolCounts`) for disabled servers
|
||||
- Use server-specific IDs for state management
|
||||
- Each MCP server gets unique `useMcp` hook instance for proper isolation
|
||||
- OAuth state parameter automatically handles multiple server differentiation
|
||||
|
||||
### State Persistence
|
||||
- Use localStorage for user preferences (model selection, server configurations)
|
||||
- Use sessionStorage for temporary state (single server URL in legacy components)
|
||||
- Clear related data when removing servers or models
|
||||
|
||||
### UI Indicators
|
||||
- Use emoji-based indicators (🧠 for models, 🔌 for MCP servers)
|
||||
- Format counts as `enabled/total` (e.g., "1/2 servers, 3/5 tools")
|
||||
- Place model selector on left, MCP servers on right for balance
|
||||
- Show "none" instead of red symbols for cleaner unconfigured states
|
||||
|
||||
### Component Organization
|
||||
- Keep disabled components in React tree but hidden with CSS (`{false && ...}`)
|
||||
- This prevents MCP connections from being destroyed when modals close
|
||||
- Use conditional rendering sparingly, prefer CSS visibility for stateful components
|
||||
|
||||
### Routing and OAuth
|
||||
- Use React Router for OAuth callback routes (`/oauth/callback`)
|
||||
- OAuth callback should match main app styling with loading indicators
|
||||
- use-mcp library handles multiple server OAuth flows automatically via state parameters
|
||||
|
||||
### Performance Considerations
|
||||
- Avoid animating gradients directly (causes repainting)
|
||||
- Use transform and filter animations (hardware accelerated)
|
||||
- Aggregate tools from multiple sources efficiently
|
||||
- Minimize localStorage reads in render loops
|
||||
30
examples/chat-ui/README.md
Normal file
30
examples/chat-ui/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# 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`)
|
||||
118
examples/chat-ui/api/index.ts
Normal file
118
examples/chat-ui/api/index.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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
|
||||
45
examples/chat-ui/e2e/build.spec.ts
Normal file
45
examples/chat-ui/e2e/build.spec.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
178
examples/chat-ui/e2e/chat.spec.ts
Normal file
178
examples/chat-ui/e2e/chat.spec.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
25
examples/chat-ui/eslint.config.js
Normal file
25
examples/chat-ui/eslint.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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 }],
|
||||
},
|
||||
}
|
||||
)
|
||||
13
examples/chat-ui/index.html
Normal file
13
examples/chat-ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!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>use-mcp chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
examples/chat-ui/package.json
Normal file
56
examples/chat-ui/package.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"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",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"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"
|
||||
}
|
||||
41
examples/chat-ui/playwright.config.ts
Normal file
41
examples/chat-ui/playwright.config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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,
|
||||
},
|
||||
})
|
||||
5181
examples/chat-ui/pnpm-lock.yaml
generated
Normal file
5181
examples/chat-ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
1
examples/chat-ui/public/vite.svg
Normal file
1
examples/chat-ui/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
16
examples/chat-ui/src/App.tsx
Normal file
16
examples/chat-ui/src/App.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import ChatApp from './components/ChatApp'
|
||||
import { OAuthCallback } from './components/OAuthCallback'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/" element={<ChatApp />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
104
examples/chat-ui/src/components/ApiKeyModal.tsx
Normal file
104
examples/chat-ui/src/components/ApiKeyModal.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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
|
||||
145
examples/chat-ui/src/components/ChatApp.tsx
Normal file
145
examples/chat-ui/src/components/ChatApp.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
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'
|
||||
import { type Tool } from 'use-mcp/react'
|
||||
|
||||
// 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 [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel())
|
||||
const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0)
|
||||
const [mcpTools, setMcpTools] = useState<Tool[]>([])
|
||||
const [animationDelay] = useState<number>(() => -Math.random() * 60)
|
||||
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 min-h-screen w-screen animated-bg-container"
|
||||
style={{ '--random-delay': `${animationDelay}s` } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row flex-grow flex-1 min-h-screen relative">
|
||||
{/* Sidebar and Navbar components hidden but kept in codebase */}
|
||||
{/*{false && (*/}
|
||||
{/* <>*/}
|
||||
{/* <ChatSidebar*/}
|
||||
{/* sidebarVisible={sidebarVisible}*/}
|
||||
{/* setSidebarVisible={setSidebarVisible}*/}
|
||||
{/* conversations={conversations}*/}
|
||||
{/* conversationId={conversationId}*/}
|
||||
{/* setConversationId={setConversationId}*/}
|
||||
{/* deleteConversation={deleteConversation}*/}
|
||||
{/* editConversationTitle={editConversationTitle}*/}
|
||||
{/* startNewConversation={startNewConversation}*/}
|
||||
{/* selectedModel={selectedModel}*/}
|
||||
{/* onModelChange={handleModelChange}*/}
|
||||
{/* apiKeyUpdateTrigger={apiKeyUpdateTrigger}*/}
|
||||
{/* onMcpToolsUpdate={setMcpTools}*/}
|
||||
{/* />*/}
|
||||
{/* <ChatNavbar*/}
|
||||
{/* sidebarVisible={sidebarVisible}*/}
|
||||
{/* setSidebarVisible={setSidebarVisible}*/}
|
||||
{/* />*/}
|
||||
{/* </>*/}
|
||||
{/*)}*/}
|
||||
<div className="flex flex-col flex-grow h-full w-full">
|
||||
<ConversationThread
|
||||
conversations={conversations}
|
||||
conversationId={conversationId}
|
||||
setConversationId={setConversationId}
|
||||
setConversations={setConversations}
|
||||
db={db as IDBPDatabase}
|
||||
selectedModel={selectedModel}
|
||||
onApiKeyUpdate={handleApiKeyUpdate}
|
||||
onModelChange={handleModelChange}
|
||||
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
|
||||
mcpTools={mcpTools}
|
||||
onMcpToolsUpdate={setMcpTools}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatApp
|
||||
65
examples/chat-ui/src/components/ChatInput.tsx
Normal file
65
examples/chat-ui/src/components/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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
|
||||
id="chat-input"
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
rows={1}
|
||||
onFocus={() => console.clear()}
|
||||
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
|
||||
34
examples/chat-ui/src/components/ChatNavbar.tsx
Normal file
34
examples/chat-ui/src/components/ChatNavbar.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue