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
|
pnpm-lock.yaml
|
||||||
*.md
|
*.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)
|
- Log errors with appropriate levels (debug, info, warn, error)
|
||||||
- Defensive error handling with specific error types when available
|
- 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
|
### React Patterns
|
||||||
- Use React hooks with useRef for mutable values
|
- Use React hooks with useRef for mutable values
|
||||||
- Stable callbacks with useCallback and appropriate dependencies
|
- 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