Compare commits

...
Author SHA1 Message Date
Glen Maddern
40c75b1952 Claude Code
> When the assistant responds with a longer message including markdown markup, the UI renders it out but it ends up looking too spaced out. Can you investigate what library we're using to render the
  mark down and whether it's possible to tweak the visual spacing to keep things a little bit tighter. I'm thinking in particular headings and lists as well as the overall line height is a touch too
  big.

⏺ I'll investigate the markdown rendering library and spacing issues in your chat UI. Let me first explore the codebase to understand how markdown is being rendered.

⏺ Update Todos
  ⎿  ☐ Find and identify the markdown rendering library being used
     ☐ Analyze current CSS styling for markdown elements
     ☐ Adjust spacing for headings, lists, and line height

⏺ Search(pattern: "markdown|marked|remark|react-markdown", include: "*.json")
  ⎿  Found 1 file (ctrl+r to expand)

⏺ Search(pattern: "markdown|Markdown", include: "*.{js,jsx,ts,tsx}")
  ⎿  Found 2 files (ctrl+r to expand)

⏺ Search(pattern: "package.json")
  ⎿  Found 1 file (ctrl+r to expand)

⏺ Read(package.json)
  ⎿  Read 57 lines (ctrl+r to expand)

⏺ Read(src/components/messages/ChatMessage.tsx)
  ⎿  Read 54 lines (ctrl+r to expand)

⏺ Read(src/components/messages/AssistantMessage.tsx)
  ⎿  Read 32 lines (ctrl+r to expand)

⏺ Update Todos
  ⎿  ☒ Find and identify the markdown rendering library being used
     ☐ Analyze current CSS styling for markdown elements
     ☐ Adjust spacing for headings, lists, and line height

⏺ Now let me check the Tailwind typography configuration and look for any custom CSS:

⏺ Search(pattern: "**/*.css")
  ⎿  Found 3 files (ctrl+r to expand)

⏺ Search(pattern: "tailwind.config.*")
  ⎿  Found 1 file (ctrl+r to expand)

⏺ Search(pattern: "prose", include: "*.{js,jsx,ts,tsx,css}")
  ⎿  Found 2 files (ctrl+r to expand)

⏺ Read(tailwind.config.js)
  ⎿  Read 20 lines (ctrl+r to expand)

⏺ Read(src/index.css)
  ⎿  Read 32 lines (ctrl+r to expand)

⏺ Read(src/styles/github.css)
  ⎿  Read 120 lines (ctrl+r to expand)

⏺ Update Todos
  ⎿  ☒ Find and identify the markdown rendering library being used
     ☒ Analyze current CSS styling for markdown elements
     ☐ Adjust spacing for headings, lists, and line height

⏺ Perfect! I found the issue. You're using react-markdown with @tailwindcss/typography plugin (the prose classes). The spacing is controlled by Tailwind's typography plugin. Let me add custom CSS
  overrides to tighten the spacing:

⏺ Update(tailwind.config.js)
  ⎿  Updated tailwind.config.js with 45 additions
       11                  border: '0',
       12                  backgroundColor: 'transparent',
       13                },
       14 +              lineHeight: '1.5',
       15 +              h1: {
       16 +                marginTop: '1rem',
       17 +                marginBottom: '0.5rem',
       18 +              },
       19 +              h2: {
       20 +                marginTop: '1rem',
       21 +                marginBottom: '0.5rem',
       22 +              },
       23 +              h3: {
       24 +                marginTop: '0.75rem',
       25 +                marginBottom: '0.5rem',
       26 +              },
       27 +              h4: {
       28 +                marginTop: '0.75rem',
       29 +                marginBottom: '0.5rem',
       30 +              },
       31 +              h5: {
       32 +                marginTop: '0.75rem',
       33 +                marginBottom: '0.5rem',
       34 +              },
       35 +              h6: {
       36 +                marginTop: '0.75rem',
       37 +                marginBottom: '0.5rem',
       38 +              },
       39 +              p: {
       40 +                marginTop: '0.5rem',
       41 +                marginBottom: '0.5rem',
       42 +              },
       43 +              ul: {
       44 +                marginTop: '0.5rem',
       45 +                marginBottom: '0.5rem',
       46 +              },
       47 +              ol: {
       48 +                marginTop: '0.5rem',
       49 +                marginBottom: '0.5rem',
       50 +              },
       51 +              li: {
       52 +                marginTop: '0.25rem',
       53 +                marginBottom: '0.25rem',
       54 +              },
       55 +              blockquote: {
       56 +                marginTop: '0.5rem',
       57 +                marginBottom: '0.5rem',
       58 +              },
       59              },
       60            },
       61          },

⏺ Update Todos
  ⎿  ☒ Find and identify the markdown rendering library being used
     ☒ Analyze current CSS styling for markdown elements
     ☒ Adjust spacing for headings, lists, and line height

⏺ Fixed! You're using react-markdown with Tailwind's @tailwindcss/typography plugin. I've updated tailwind.config.js:14-58 to tighten the spacing by:

  - Reducing line height from default 1.75 to 1.5
  - Cutting heading margins (top: 1rem, bottom: 0.5rem for h1/h2, smaller for h3-h6)
  - Reducing paragraph and list margins to 0.5rem
  - Shrinking list item margins to 0.25rem

  The changes will take effect after restarting your dev server.

Total cost:            $0.1342
Total duration (API):  1m 7.4s
Total duration (wall): 5m 14.3s
Total code changes:    45 lines added, 0 lines removed
Token usage by model:
    claude-3-5-haiku:  638 input, 24 output, 0 cache read, 0 cache write
       claude-sonnet:  227 input, 2.5k output, 166.5k cache read, 12.1k cache write
2025-06-27 12:05:30 +10:00
Glen Maddern
c834b114a7 Round corners more on blue connected server transport type badge
Change from rounded-md to rounded-lg for better visual consistency
2025-06-26 09:50:50 +10:00
Glen Maddern
59d2dfca8b Move transport toggle to left and make it only affect new servers
- Add newServerTransportType state for next server to be added
- Move toggle to left side directly after 'Add New Server' label
- Add transportType to McpServer interface to store per-server transport
- Update connections to use server-specific transport type
- Change toggle styling to rounded-full (pill style)
2025-06-26 09:50:30 +10:00
Glen Maddern
4e4d9d99a3 Default transport type to HTTP instead of auto 2025-06-26 09:49:14 +10:00
Glen Maddern
39043c12d6 fin 2025-06-25 23:37:23 +10:00
Glen Maddern
0d0f30f4aa Almost ready 2025-06-25 22:14:36 +10:00
Glen Maddern
f460842bbc Ok _much_ better timing and just me understanding this stuff 2025-06-25 21:52:21 +10:00
Glen Maddern
cd8df15e94 Only updating one conversation at a time 2025-06-25 20:45:44 +10:00
Glen Maddern
b07b0a73e1 visual tweaks 2025-06-25 20:38:05 +10:00
Glen Maddern
9b34461c2d tool responses are easy now too 2025-06-25 20:21:25 +10:00
Glen Maddern
5e41222c63 Handling reasoning then messages at least 2025-06-25 20:20:14 +10:00
Glen Maddern
0c7b9e0537 Ok turns out I can be dumb too 2025-06-25 19:45:53 +10:00
Glen Maddern
caed6d373c Starting to realise how dumb the vibecoded UI was oops 2025-06-25 19:42:07 +10:00
Glen Maddern
ba845e7852 Big sigh, finally getting control of my markup 2025-06-25 19:01:30 +10:00
Glen Maddern
52c3e8d727 wip, at least prettier likes it 2025-06-25 17:13:45 +10:00
Glen Maddern
022747ba04 wip, changing the logic in useStreamResponse 2025-06-25 17:06:50 +10:00
Glen Maddern
6429f48763 lfg manual style 2025-06-25 15:12:03 +10:00
Glen Maddern
5e69116f47 fixing prettier 2025-06-25 15:09:09 +10:00
Glen Maddern
51fafb485c Branching off 2025-06-25 15:06:17 +10:00
Glen Maddern
ab73226b87 Remove all duplicate detection logic
- Remove existingToolCall and existingToolResult checks
- Add every API event as a new message regardless of duplicates
- Provides complete unfiltered log of all API data after middleware
- Useful for debugging and understanding raw data flow
2025-06-25 12:09:31 +10:00
Glen Maddern
840fbb4ad6 Simplify debug logging to show raw JSON messages
- Replace complex message summaries with JSON.stringify(conv.messages)
- Provides direct, unfiltered view of message data structure
- Easier to inspect actual data and debug issues
2025-06-25 12:06:57 +10:00
Glen Maddern
12dc6433c3 Enhance debug logging to show complete conversation state
- Show entire messages array after each new message addition
- Include message index, role, content/reasoning status, and tool info
- Add final conversation state with content/reasoning lengths
- Provides complete visibility into data structure evolution
2025-06-25 12:05:00 +10:00
Glen Maddern
4e282ea1de Add targeted debug logging for message operations
- Add debugMessages helper function that logs with [Messages] prefix
- Log when new messages are added to conversation (not updates)
- Log reasoning assistant messages, tool calls, tool results, and content messages
- Log stream processing completion and function completion
- Debug logging enabled with localStorage.setItem('USE_MCP_DEBUG', 'true')
2025-06-25 12:02:43 +10:00
Glen Maddern
2c8391007c Clean up debug logging in useStreamResponse.ts
- Remove all debugLog calls and debug infrastructure
- Simplify error logging by removing verbose prefixes
- Fix unused options parameter to eliminate warnings
- Clean up formatting and extra blank lines
2025-06-25 11:56:27 +10:00
Glen Maddern
4f87d827aa Fix reasoning/content separation and eliminate empty message divs
- Remove extractReasoningMiddleware as it expects <think> tags but API sends reasoning fields directly
- Ensure reasoning and content tokens create separate assistant messages
- Filter empty assistant messages at conversation level to prevent empty divs
- Add deduplication for tool results to prevent duplicates
- Only render content div when message has actual content
2025-06-25 11:51:41 +10:00
Glen Maddern
dffc1aa5fb Make thinking block collapse immediately when tool call starts
- End reasoning phase as soon as first tool-call event is detected
- Set reasoningEndTime and stop reasoning streaming immediately
- Creates linear flow: thinking expands → tool call (thinking collapses) → tool result → final response
- More aggressive/responsive UX that doesn't wait for final response to collapse thinking
2025-06-25 08:28:43 +10:00
Glen Maddern
8159950ffd Fix message ordering - create final response at end instead of updating existing
- Remove logic that updates existing assistant message
- Always create new final response message at end of conversation
- This ensures proper order: thinking → tool call → tool result → final response
- Still prevents duplicates by checking existing content
2025-06-25 08:25:03 +10:00
Glen Maddern
55d54bce60 Fix duplicate assistant messages by updating existing message
- Check if there's already an assistant message with only reasoning content
- Update existing assistant message with final content instead of creating new one
- Add duplicate detection to prevent multiple messages with same content
- Ensures only one assistant response appears after tool calls
2025-06-25 08:23:29 +10:00
Glen Maddern
0ed5dbaa67 Add intelligent content filtering to extract clean final answer
- Extract only the final answer from post-tool reasoning content
- Filter out thinking/reasoning patterns to prevent duplication
- Use regex matching and line filtering to identify clean response content
- Skip content that appears to be thinking rather than final answer
- Limit response length to prevent long thinking content from appearing
2025-06-25 08:21:08 +10:00
Glen Maddern
59026addde Fix message ordering and prevent content duplication in thinking/final response
- Collect post-tool reasoning content separately instead of processing immediately
- Process collected content as final assistant response after stream completes
- This ensures proper message order: thinking → tool call → tool result → final response
- Prevents duplication of thinking content in final response
2025-06-25 08:14:51 +10:00
Glen Maddern
d2b6f8013e Fix duplicate tool results and post-tool reasoning being hidden
- Add deduplication logic for tool results to prevent duplicates
- Detect when tool results have been processed and convert subsequent reasoning events to text content
- This fixes the issue where the final assistant response was hidden in the thinking block
- Tool results should now appear once and final response should appear as separate text
2025-06-24 22:49:20 +10:00
Glen Maddern
6126405ab0 Fix tool results being mixed into thinking blocks
- Add missing tool-result event handling in useStreamResponse
- Tool results now appear as separate ToolResultMessage components
- Prevents tool response content from being incorrectly added to reasoning content
- Maintains proper message order: thinking → tool call → tool result → assistant response
2025-06-24 22:43:06 +10:00
Glen Maddern
e7dd5776c3 Replace httpOnly with transportType option and add UI controls
- Replace httpOnly boolean with transportType ('auto'/'http'/'sse') option
- Add transport type cycling button next to 'Add New Server'
- Add blue transport badge next to connected server status
- Save transport preference to localStorage
- Update connection orchestration to handle sse-only and http-only modes
- Remove explicit fallback messaging since orchestration now controls it
2025-06-24 22:38:35 +10:00
Glen Maddern
03d95e70b7 Add httpOnly option to disable SSE fallback for debugging
- Added httpOnly option to UseMcpOptions to skip SSE fallback
- Updated all HTTP transport failure paths to respect httpOnly flag
- Temporarily enabled httpOnly in chat UI for streamable HTTP debugging
- Updated AGENT.md to require frequent commits after changes
2025-06-24 22:28:37 +10:00
Glen Maddern
ba9e6c9f3f Add debug logging and Accept headers for MCP transport debugging
- Added extensive debug logging to transport creation and connection flows
- Added proper Accept header (application/json, text/event-stream) to transport options
- Fixed scroll jittering during streaming with instant scroll behavior
- Investigating HTTP transport 'Not connected' errors and SSE connection hanging
2025-06-24 22:25:57 +10:00
Glen Maddern
ccad616e02 feat: Add sliding window effect for streaming reasoning text
- Use overflow:hidden + whitespace:nowrap to prevent line breaks
- Apply justify-end flexbox to push content to the right
- New tokens push older ones out to the left edge and hide them
- Creates smooth sliding window effect showing only recent tokens
- Only applies during streaming, keeps normal wrap after completion

This creates a nice visual effect where you can see the latest reasoning
tokens flowing in from right to left as the model thinks.
2025-06-24 20:18:40 +10:00
Glen Maddern
78e01032ae fix: Remove delay for instant reasoning block collapse
- Remove 500ms setTimeout delay when reasoning finishes
- Reasoning block now collapses immediately when response starts
- Creates smoother transition from thinking to response phase
- Eliminates jarring delay between reasoning end and collapse

The reasoning block now collapses instantly as soon as the model
starts generating the actual response, providing better UX flow.
2025-06-24 19:08:36 +10:00
Glen Maddern
b810f639aa fix: Create assistant message immediately when reasoning starts
- Create assistant message as soon as first reasoning event arrives
- This ensures reasoning block is visible from the beginning of thinking
- Prevent duplicate message creation in text-delta handler
- Move reasoning end time logic to text-delta start (when thinking finishes)

This fixes the issue where reasoning blocks weren't appearing because
the assistant message was only created after reasoning completed.
2025-06-24 19:03:43 +10:00
Glen Maddern
bfe2bd5816 feat: Implement live reasoning streaming with timing display
- Update ReasoningBlock to start expanded and show live reasoning content
- Add timing tracking with start/end timestamps for reasoning duration
- Auto-collapse reasoning blocks after completion with 500ms delay
- Show timing summary (e.g. 'Thought for 0.4s') instead of content when collapsed
- Add streaming cursor animation and real-time content updates
- Update types to support reasoning timing and streaming state
- Stream reasoning content live as tokens arrive during thinking phase

This provides a much better UX where users can see the model thinking
in real-time, then get a clean timing summary after completion.
2025-06-24 18:21:22 +10:00
Glen Maddern
68ecdebbc0 fix: Clean think tags from reasoning content before display
- Add cleaning logic to remove <think> and </think> tags from reasoning
- Trim whitespace after tag removal for clean display
- Add debug logging to show before/after cleaning
- The middleware extracts reasoning but includes the original tags

This ensures the reasoning blocks show clean thinking content without
the XML tags that were being captured along with the reasoning text.
2025-06-24 18:15:57 +10:00
Glen Maddern
33c914e19b debug: Add more detailed logging for reasoning event handling
- Add debug logs inside reasoning event handler to see if it's being triggered
- Log reasoning content length as it's being collected
- Add final reasoning content summary at end of stream
- Log why reasoning isn't being added if conditions aren't met
- Cast event to any to avoid potential TypeScript issues

This will help identify why reasoning events aren't being captured
despite appearing in the event stream logs.
2025-06-24 18:11:24 +10:00
Glen Maddern
ab9f3c0220 fix: Capture reasoning events during streaming instead of from result
- Add back reasoning variable to track content during streaming
- Handle reasoning events from extractReasoningMiddleware in stream processing
- Build up reasoning content from individual reasoning textDelta events
- Remove dependency on result.reasoning which wasn't being populated
- Add reasoning to assistant message after stream completes

The logs showed reasoning events were coming through correctly but we
weren't capturing them. This fixes the issue by collecting reasoning
content during streaming as the events arrive.
2025-06-24 17:53:12 +10:00
Glen Maddern
6c7422f832 fix: Add detailed debugging and use result.text for cleaned content
- Add debug logging to track aiResponse vs result.text content
- Use result.text from extractReasoningMiddleware for final content
- This should provide the properly cleaned text without thinking tags
- Comment out unused sidebar and edit functionality to fix TS errors

The result.text should contain content that has been automatically
cleaned by the extractReasoningMiddleware, while result.reasoning
contains the extracted thinking content.
2025-06-24 17:40:53 +10:00
Glen Maddern
03dd988f35 fix: Improve thinking tag removal with more aggressive cleaning
- Add multiple regex passes to handle various think tag scenarios
- Remove standalone <think> and </think> tags that might remain
- Clean leading whitespace after tag removal
- Add debug logging to track text cleaning process
- Handle streaming cases where tags appear before closing tags arrive

This provides more robust cleaning to ensure no think tags appear
in the final message content during streaming scenarios.
2025-06-24 17:35:14 +10:00
Glen Maddern
9a825d5afc fix: Remove thinking XML tags from main message content
- Add regex-based cleaning to remove <think>...</think> tags from display text
- Handle both closed and unclosed think tags in streaming content
- Apply cleaning only for reasoning-capable models to avoid unnecessary processing
- Update conversation content with cleaned response instead of raw response

This ensures thinking tags are extracted for reasoning display but don't
appear in the main message content, providing a clean user experience.
2025-06-24 17:28:08 +10:00
Glen Maddern
f53bc6867b Added a simpe Hono server to test 2025-06-24 17:26:16 +10:00
Glen Maddern
25857bc354 fix: Use extractReasoningMiddleware to properly parse thinking blocks
- Import extractReasoningMiddleware and wrapLanguageModel from AI SDK
- Wrap reasoning-capable models with extractReasoningMiddleware
- Use 'think' as tagName to parse <think> XML blocks from models
- Enable startWithReasoning option to handle models missing reasoning tags
- Extract reasoning from result.reasoning after stream completes
- Remove manual reasoning event handling and provider-specific config

This fixes the issue where thinking blocks appeared as raw XML tags
instead of parsed reasoning content. The middleware automatically
extracts reasoning from <think> tags and exposes it via result.reasoning.
2025-06-24 16:47:04 +10:00
Glen Maddern
62000dcdac feat: Add reasoning token support with expandable thinking blocks
- Add reasoning field to AssistantMessage type
- Create ReasoningBlock component with truncate/expand functionality
- Add reasoning-capable models (Qwen QwQ, DeepSeek R1, Qwen 3 32B)
- Implement reasoning event handling in stream processing
- Enable reasoning format for Groq and Anthropic models
- Style reasoning blocks with grey border and reduced font size
- Display reasoning above assistant messages when available

The reasoning blocks show truncated thinking tokens by default and
expand on click to show full reasoning process from models like
Qwen3-32b that support step-by-step reasoning output.
2025-06-24 16:41:56 +10:00
Glen Maddern
ace85a6c69 Added a simple example server for the demo 2025-06-24 16:19:32 +10:00
Glen Maddern
43dc39df96 BG css tweaks 2025-06-24 15:58:42 +10:00
Glen Maddern
8049c6bebe I am dum. CSS is smrt 2025-06-24 14:04:27 +10:00
Glen Maddern
5087ebacdc Update AGENT.md with chat UI development guidelines
- Add React Router and use-mcp to tech stack
- Document background animation best practices (hue-rotate, pseudo-elements)
- Modal patterns for UX (click-outside, cursor-pointer, backdrop styling)
- MCP server management (multiple servers, localStorage patterns, OAuth)
- State persistence guidelines (localStorage vs sessionStorage)
- UI indicator patterns (emoji-based, count formatting)
- Component organization for stateful elements
- Routing and OAuth callback implementation
- Performance considerations for animations and aggregation
2025-06-24 08:21:55 +10:00
Glen Maddern
a3200e09cb Doing some manual CSS hax for a change 2025-06-23 21:54:26 +10:00
Glen Maddern
71b07c488b Make gradient background repeat every 100vh
- Add background-size: 100% 100vh to fix gradient height to viewport
- Add background-repeat: repeat-y to tile gradient vertically
- Gradient pattern now repeats down the page as content grows
- Animation applies consistently to all repeated background tiles
- Creates seamless repeating pattern while maintaining hue rotation
2025-06-23 21:29:22 +10:00
Glen Maddern
4680b9d3e8 Make gradient background fill entire content height
- Change from h-dvh to min-h-screen on main container
- Remove overflow-clip to allow content to expand
- Update inner containers to use min-h-screen for proper height
- Background gradient now extends with content rather than fixed to viewport
- Allows for scrolling when content exceeds viewport height
2025-06-23 21:26:36 +10:00
Glen Maddern
ec7f1ff6dd Add Qwen 3 32B model under Groq provider
- Add qwen-3-32b model entry using qwen/qwen3-32b model ID
- Uses same Groq provider as Llama 3.3, sharing API token
- Model appears in selection modal between Llama and Claude
- Clean name 'Qwen 3 32B' for user interface
2025-06-23 21:18:22 +10:00
Glen Maddern
089c1c9f9e Deploying to chat.use-mcp.dev 2025-06-23 20:26:01 +10:00
Glen Maddern
78f0e364a6 Implement OAuth callback support for MCP servers
- Add react-router-dom dependency for routing support
- Create OAuthCallback component with animated background and loading spinner
- Add routing to App.tsx with /oauth/callback route
- Match styling with main app using animated-bg-container
- Use onMcpAuthorization from use-mcp library for OAuth handling
- Library already handles multiple server differentiation via state parameter
- Each MCP server gets unique OAuth flow identified by state containing server URL
- Clean up unused imports and variables in McpServerModal
2025-06-23 20:19:10 +10:00
Glen Maddern
32a6bf5abd Replace gradient with uniform 80% black background
- Remove radial gradient to fix banding issues
- Use solid 80% black background (rgba(0,0,0,0.8))
- Apply to both ModelSelectionModal and McpServerModal
- Clean, consistent backdrop without visual artifacts
2025-06-23 20:11:37 +10:00
Glen Maddern
272fd5606a Soften modal background with radial gradient
- Replace solid black 50% opacity with radial gradient
- Center: 80% black (rgba(0,0,0,0.8))
- Corners: 70% black (rgba(0,0,0,0.7))
- Apply to both ModelSelectionModal and McpServerModal
- Creates softer, more visually appealing backdrop
- Remove bg-black bg-opacity-50 classes and use inline style
2025-06-23 20:10:18 +10:00
Glen Maddern
1e68fca937 Change plug indicator format to use forward slashes
- Change from '0 (1) servers, 0 (2) tools' to '0/1 servers, 0/2 tools'
- Use forward slash format showing enabled/total for both servers and tools
- Cleaner, more compact visual representation
- Examples:
  - No servers: '0/0 servers, 0/0 tools'
  - One disabled server with 2 tools: '0/1 servers, 0/2 tools'
  - One enabled server with 2 tools: '1/1 servers, 2/2 tools'
2025-06-23 19:26:57 +10:00
Glen Maddern
0ce394f53c Update plug indicator format with comma separation
- Change format from '0 (1 servers) 0 (2 tools)' to '0 (1) servers, 0 (2) tools'
- Move words 'servers' and 'tools' outside brackets
- Add comma separation between server and tool counts
- More concise and readable format
2025-06-23 19:26:06 +10:00
Glen Maddern
644b3b663d Update plug indicator to show enabled vs total counts
- Change indicator format to show enabled/total for both servers and tools
- Format: 'X (Y servers) A (B tools)' where:
  - X = enabled servers, Y = total servers
  - A = tools from enabled servers, B = total tools from all servers
- Track tool counts per server in localStorage (mcpServerToolCounts)
- Persist tool counts even when servers are disabled
- Examples:
  - No servers: '0 (0 servers) 0 (0 tools)'
  - One disabled server with 2 tools: '0 (1 servers) 0 (2 tools)'
  - One enabled server with 2 tools: '1 (1 servers) 2 (2 tools)'
- Clean up tool counts when servers are deleted
2025-06-23 19:17:17 +10:00
Glen Maddern
d15c88419c Implement multiple MCP servers support
- Completely rewrite McpServerModal to support multiple servers
- Add McpServer interface with id, url, enabled, and name fields
- Use localStorage 'mcpServers' array instead of single server URL
- Add server list UI with status badges, toggle, and delete buttons
- Add 'Add New Server' section at bottom with input and Add button
- Support enabling/disabling individual servers with Power/PowerOff icons
- Delete servers with Trash2 icon in top right of each server block
- Aggregate tools from all enabled servers for parent component
- Render multiple McpConnection components for enabled servers
- Update plug indicator to show correct server count
- Add debug information panel showing server and tool counts
- Handle server names using hostname from URL
- Each server gets unique connection state tracking by server ID
2025-06-23 16:52:14 +10:00
Glen Maddern
1f80d7040b Add cursor pointer and click-outside-to-dismiss to model selector modal
- Add cursor-pointer class to X button in ModelSelectionModal
- Add onClick handler to backdrop overlay to close modal
- Add stopPropagation to modal content to prevent backdrop clicks
- Consistent modal behavior between model selector and MCP server modals
2025-06-23 16:13:33 +10:00
Glen Maddern
1514c6470e Add cursor pointer to X button and click-outside-to-dismiss
- Add cursor-pointer class to modal X button
- Add onClick handler to backdrop overlay to close modal
- Add stopPropagation to modal content to prevent backdrop clicks
- Now modal can be dismissed by clicking outside or using X button
2025-06-23 16:12:52 +10:00
Glen Maddern
812fdf92e3 Fix MCP tools count update in plug icon indicator
- Add onMcpToolsUpdate prop to ConversationThread interface
- Pass setMcpTools function from ChatApp down to ConversationThread
- Connect McpServerModal onToolsUpdate to parent state update
- Now plug icon will correctly show '1 (N)' when N tools are available
- Fixes issue where tool count remained at 0 despite successful connection
2025-06-23 16:11:42 +10:00
Glen Maddern
c3624e6bbc Keep MCP modal in React tree to maintain connection
- Replace conditional rendering with CSS visibility (hidden/block)
- Remove early return null when modal is closed
- McpConnection component stays mounted to preserve MCP server connection
- Modal is always in DOM but visually hidden when isOpen is false
- Prevents MCP disconnection when modal is dismissed
2025-06-23 16:09:15 +10:00
Glen Maddern
b1f7709a05 Move number to left of plug emoji
- Change layout from '🔌 0' to '0 🔌'
- Number now appears before the plug icon for better visual balance
- Maintains same functionality and styling
2025-06-23 16:02:59 +10:00
Glen Maddern
11360f38fc Add MCP server indicator with plug emoji and modal
- Create McpServerModal component based on existing McpServers component
- Add plug emoji (🔌) indicator on right side, opposite to brain emoji
- Show '0' when no servers connected, '1 (N)' when connected with N tools
- Fullscreen modal with MCP server configuration UI
- Support for single MCP server connection (foundation for multiple servers)
- Integrate with existing MCP tools logic and useMcp hook
- Clean layout with brain emoji on left, plug emoji on right
2025-06-23 16:02:05 +10:00
Glen Maddern
3461d3c900 Replace red X with grey 'none' text and add pointer cursor
- Change unconfigured model indicator from red ✕ to grey 'none' text
- Add explicit cursor-pointer class to brain emoji button
- Maintains grey text color consistent with configured model names
- Cleaner, less alarming visual for unconfigured state
2025-06-23 15:54:04 +10:00
Glen Maddern
50e97bb613 Isolate hue-rotate animation to background layer only
- Create separate background layer using ::before pseudo-element
- Move hue-rotate animation to background layer with z-index: -1
- Remove filter from main container to prevent inheritance
- Use equivalent gradient colors in CSS (pink-100, purple-50, indigo-100)
- Foreground elements (brain emoji, text) now maintain original colors
- Background still animates smoothly without affecting content
2025-06-23 15:52:34 +10:00
Glen Maddern
6a79264315 Add brain emoji model selector with fullscreen modal
- Create ModelSelectionModal component with list-style interface
- Add brain emoji (🧠) indicator below chat input, left-aligned
- Show current model name in lowercase or red X if not configured
- Fullscreen modal displays all available models with configuration status
- Green checkmark for configured models, red alert for unconfigured
- Integrate with existing API key management and model selection logic
- Modal handles API key setup workflow when selecting unconfigured models
- Clean separation between compact indicator and full selection interface
2025-06-23 15:49:45 +10:00
Glen Maddern
b33bde6b0e Use CSS custom property for cleaner random animation delay
- Move animation-delay to CSS using custom property --random-delay
- JavaScript still required to generate random value (CSS has no random functions)
- Cleaner separation between styling (CSS) and logic (JavaScript)
- Maintains same functionality with better organization
2025-06-21 13:58:11 +10:00
Glen Maddern
433eac58f1 Add random starting point for hue-rotate animation
- Generate random negative animation-delay between 0 and -60 seconds
- Starts animation at random point in the 60-second cycle
- Each page load will show different initial colors
- Uses useState with initializer function to set delay once on mount
- CSS doesn't have random functions, so JavaScript handles the randomization
2025-06-20 21:05:39 +10:00
Glen Maddern
d47124b8b3 Slow down hue-rotate animation to 60 seconds
- Change animation duration from 8s to 60s for subtle color shifting
- Creates gentle, barely noticeable background color transitions
- Maintains visual interest without being distracting
2025-06-20 21:04:37 +10:00
Glen Maddern
f94a80cbd9 Switch to hue-rotate animation for background gradient
- Revert complex conical gradient approach
- Return to original linear gradient (pink-100 → purple-50 → indigo-100)
- Add hue-rotate filter animation cycling through 360 degrees
- 8s duration for fast testing
- Simpler implementation with continuous color shifting
- Less control over saturation but smoother performance
2025-06-20 21:03:52 +10:00
Glen Maddern
668ecaff91 Add animated rotating conical gradient background
- Create large 500% conical gradient with pastel color cycle
- Position gradient off-screen (-200% top/left) for smooth rotation
- Add 8s linear infinite rotation animation for fast testing
- Use ::before pseudo-element to avoid repainting main content
- Cycle through various pastel shades (pink, purple, blue, green, yellow, orange)
- Performance-optimized: only transforms, no gradient repainting
2025-06-20 20:46:24 +10:00
Glen Maddern
15bb63670a Simplify chat UI to focus on main input with pastel gradient background
- Hide sidebar (conversations, model picker, MCP servers) while preserving components
- Hide navbar/title bar while preserving component
- Remove title text and description above input field
- Add pastel gradient background (pink-purple-indigo)
- Expand conversation thread to full width
- Create minimal, focused interface with only white input field visible
2025-06-20 17:19:54 +10:00
Glen Maddern
55c1756f36 deployng to workers.dev for sharing 2025-06-20 17:00:39 +10:00
Glen Maddern
6ebb156fe6 working tool call ui! 2025-06-19 19:46:33 +10:00
Glen Maddern
769475cecc Working on tool call display 2025-06-19 19:34:28 +10:00
Glen Maddern
d25f6c19a3 Moving most of the debug logging behind localStorage.USE_MCP_DEBUG 2025-06-19 14:31:30 +10:00
Glen Maddern
e94958865a MCP tool calling woo! 2025-06-19 14:07:28 +10:00
Glen Maddern
f5f1943ac5 Copying chat example across 2025-06-19 11:51:02 +10:00
79 changed files with 18855 additions and 210 deletions

View file

@ -1,3 +1,5 @@
pnpm-lock.yaml pnpm-lock.yaml
*.md *.md
examples .wrangler
.vscode
test-results

View file

@ -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
View 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/

View file

@ -0,0 +1,7 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"printWidth": 140
}

73
examples/chat-ui/AGENT.md Normal file
View 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

View 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`)

View 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

View 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}`)
}
})
})

View 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()
})
})

View 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 }],
},
}
)

View 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>

View 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"
}

View 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

File diff suppressed because it is too large Load diff

View 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

View 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

View 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

View 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

View 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

View 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