fixing prettier

This commit is contained in:
Glen Maddern 2025-06-25 15:09:09 +10:00
commit 5e69116f47
33 changed files with 7830 additions and 7525 deletions

View file

@ -12,15 +12,14 @@ interface ExecError extends Error {
test.describe('Build Tests', () => {
test('should build without type errors', async () => {
try {
const { stderr } = await execAsync('pnpm build', {
const { stderr } = await execAsync('pnpm build', {
cwd: process.cwd(),
timeout: 60000
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)
@ -30,14 +29,13 @@ test.describe('Build Tests', () => {
test('should lint without errors', async () => {
try {
const { stderr } = await execAsync('pnpm lint', {
const { stderr } = await execAsync('pnpm lint', {
cwd: process.cwd(),
timeout: 30000
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)

View file

@ -3,10 +3,10 @@ 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 => {
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: {
@ -16,10 +16,10 @@ test.describe('Chat Functionality', () => {
})
})
await page.route('https://api.anthropic.com/**', async route => {
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: {
@ -30,15 +30,17 @@ test.describe('Chat Functionality', () => {
})
// Mock the local API endpoint
await page.route('/api/chat', async route => {
await page.route('/api/chat', async (route) => {
const mockResponse = {
choices: [{
delta: {
content: 'Hello! This is a mocked response from the local API.'
}
}]
choices: [
{
delta: {
content: 'Hello! This is a mocked response from the local API.',
},
},
],
}
route.fulfill({
status: 200,
headers: {
@ -51,16 +53,19 @@ test.describe('Chat Functionality', () => {
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()
const sendButton = page
.locator('button')
.filter({ hasText: /send|submit/i })
.first()
if (await sendButton.isVisible()) {
await expect(sendButton).toBeVisible()
}
@ -68,36 +73,36 @@ test.describe('Chat Functionality', () => {
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 => {
await page.route('https://api.groq.com/**', async (route) => {
route.fulfill({
status: 500,
headers: {
@ -107,7 +112,7 @@ test.describe('Chat Functionality', () => {
})
})
await page.route('https://api.anthropic.com/**', async route => {
await page.route('https://api.anthropic.com/**', async (route) => {
route.fulfill({
status: 401,
headers: {
@ -118,49 +123,55 @@ test.describe('Chat Functionality', () => {
})
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()
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()
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

@ -19,10 +19,7 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
}
)

View file

@ -1,6 +1,6 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import ChatApp from "./components/ChatApp";
import { OAuthCallback } from "./components/OAuthCallback";
import ChatApp from './components/ChatApp'
import { OAuthCallback } from './components/OAuthCallback'
function App() {
return (
@ -10,7 +10,7 @@ function App() {
<Route path="/" element={<ChatApp />} />
</Routes>
</Router>
);
)
}
export default App;
export default App

View file

@ -10,12 +10,7 @@ interface ApiKeyModalProps {
onSave: (apiKey: string) => void
}
const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
isOpen,
onClose,
provider,
onSave,
}) => {
const ApiKeyModal: React.FC<ApiKeyModalProps> = ({ isOpen, onClose, provider, onSave }) => {
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
@ -36,22 +31,13 @@ const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
}
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="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"
>
<button onClick={handleClose} className="text-zinc-400 hover:text-zinc-600 p-1">
<X size={20} />
</button>
</div>
@ -95,11 +81,7 @@ const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
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"
>
<button type="button" onClick={handleClose} className="px-4 py-2 text-sm text-zinc-600 hover:text-zinc-800">
Cancel
</button>
<button

View file

@ -1,74 +1,69 @@
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";
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 [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 [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);
};
setApiKeyUpdateTrigger((prev) => prev + 1)
}
const handleModelChange = (model: Model) => {
setSelectedModel(model);
saveSelectedModel(model);
};
setSelectedModel(model)
saveSelectedModel(model)
}
// set up conversations on app load
useEffect(() => {
getConversations();
deleteUnusedConversations();
startNewConversation();
}, [db]);
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;
if (!db) return
const conversations = (await db.getAll(storeName)) as Conversation[];
const inverseConversations = conversations.reverse();
setConversations(inverseConversations);
};
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;
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);
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);
console.error('Failed to delete conversation:', error)
}
};
}
// const editConversationTitle = async (id: number, newTitle: string) => {
// const conversation = (await db!.get(storeName, id)) as Conversation;
@ -81,27 +76,25 @@ const ChatApp: React.FC<ChatAppProps> = () => {
const startNewConversation = async () => {
//create unique id for new conversation
setConversationId(Date.now() + Math.floor(Math.random() * 1000));
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
);
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);
deleteConversation(conversation.id as number, false)
}
};
}
return (
<div
<div
className="flex min-h-screen w-screen animated-bg-container"
style={{ '--random-delay': `${animationDelay}s` } as React.CSSProperties}
>
@ -146,7 +139,7 @@ const ChatApp: React.FC<ChatAppProps> = () => {
</div>
</div>
</div>
);
};
)
}
export default ChatApp;
export default ChatApp

View file

@ -1,53 +1,47 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { type Message } from "../types";
import ToolCallMessage from "./ToolCallMessage";
import ToolResultMessage from "./ToolResultMessage";
import ReasoningBlock from "./ReasoningBlock";
import React from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { type Message } from '../types'
import ToolCallMessage from './ToolCallMessage'
import ToolResultMessage from './ToolResultMessage'
import ReasoningBlock from './ReasoningBlock'
interface ChatMessageProps {
message: Message;
message: Message
}
const ChatMessage: React.FC<ChatMessageProps> = ({
message,
}) => {
const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
// Handle tool calls and results differently
if (message.role === "tool-call") {
if (message.role === 'tool-call') {
return <ToolCallMessage message={message} />
}
if (message.role === "tool-result") {
if (message.role === 'tool-result') {
return <ToolResultMessage message={message} />
}
// Handle regular messages (user, assistant, system)
return (
<div
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`
${
message.role === "user"
? "max-w-[80%] rounded-2xl px-3 py-2 border border-zinc-200/10 bg-white text-black"
: " text-zinc-900 w-full"
message.role === 'user'
? 'max-w-[80%] rounded-2xl px-3 py-2 border border-zinc-200/10 bg-white text-black'
: ' text-zinc-900 w-full'
}`}
>
{/* Show reasoning block for assistant messages with reasoning */}
{message.role === "assistant" && "reasoning" in message && (message.reasoning || message.isReasoningStreaming) && (
<ReasoningBlock
reasoning={message.reasoning || ""}
{message.role === 'assistant' && 'reasoning' in message && (message.reasoning || message.isReasoningStreaming) && (
<ReasoningBlock
reasoning={message.reasoning || ''}
isStreaming={message.isReasoningStreaming}
startTime={message.reasoningStartTime}
endTime={message.reasoningEndTime}
/>
)}
{/* Only render content div if there's actual content */}
{message.content && message.content.trim() && (
<div className="prose prose-zinc">
@ -55,9 +49,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
table: ({ children }) => (
<div className="overflow-x-scroll text-sm">{children}</div>
),
table: ({ children }) => <div className="overflow-x-scroll text-sm">{children}</div>,
}}
>
{message.content}
@ -66,7 +58,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
)}
</div>
</div>
);
};
)
}
export default ChatMessage;
export default ChatMessage

View file

@ -1,15 +1,12 @@
import React from "react";
import { PanelLeftOpen } from "lucide-react";
import React from 'react'
import { PanelLeftOpen } from 'lucide-react'
interface ChatNavbarProps {
sidebarVisible: boolean;
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>;
sidebarVisible: boolean
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>
}
const ChatNavbar: React.FC<ChatNavbarProps> = ({
sidebarVisible,
setSidebarVisible,
}) => {
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">
@ -27,13 +24,11 @@ const ChatNavbar: React.FC<ChatNavbarProps> = ({
</button>
</div>
)}
<h1 className="text-base font-semibold text-zinc-600 ml-2">
AI Chat Template
</h1>
<h1 className="text-base font-semibold text-zinc-600 ml-2">AI Chat Template</h1>
</div>
</div>
</div>
);
};
)
}
export default ChatNavbar;
export default ChatNavbar

View file

@ -1,36 +1,30 @@
import React from "react";
import {
Edit,
Trash2,
PanelLeftClose,
PanelLeftOpen,
SquarePen,
} from "lucide-react";
import ModelSelector from "./ModelSelector";
import { McpServers } from "./McpServers";
import { type Model } from "../types/models";
import { type Message } from "../types";
import { type Tool } from "use-mcp/react";
import React from 'react'
import { Edit, Trash2, PanelLeftClose, PanelLeftOpen, SquarePen } from 'lucide-react'
import ModelSelector from './ModelSelector'
import { McpServers } from './McpServers'
import { type Model } from '../types/models'
import { type Message } from '../types'
import { type Tool } from 'use-mcp/react'
interface Conversation {
id?: number;
title: string;
messages: Message[];
id?: number
title: string
messages: Message[]
}
interface ChatSidebarProps {
sidebarVisible: boolean;
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>;
conversations: Conversation[];
conversationId: number | undefined;
setConversationId: React.Dispatch<React.SetStateAction<number | undefined>>;
deleteConversation: (id: number) => void;
editConversationTitle: (id: number, newTitle: string) => void;
startNewConversation: () => void;
selectedModel: Model;
onModelChange: (model: Model) => void;
apiKeyUpdateTrigger: number;
onMcpToolsUpdate: (tools: Tool[]) => void;
sidebarVisible: boolean
setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>>
conversations: Conversation[]
conversationId: number | undefined
setConversationId: React.Dispatch<React.SetStateAction<number | undefined>>
deleteConversation: (id: number) => void
editConversationTitle: (id: number, newTitle: string) => void
startNewConversation: () => void
selectedModel: Model
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
onMcpToolsUpdate: (tools: Tool[]) => void
}
const ChatSidebar: React.FC<ChatSidebarProps> = ({
@ -48,21 +42,16 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
onMcpToolsUpdate,
}) => {
const handleConversationClick = (id: number | undefined) => {
setConversationId(id);
if (window.matchMedia("(max-width: 768px)").matches) {
setSidebarVisible(false);
setConversationId(id)
if (window.matchMedia('(max-width: 768px)').matches) {
setSidebarVisible(false)
}
};
}
return (
<>
{/* Mobile overlay */}
{sidebarVisible && (
<div
className="md:hidden fixed inset-0 bg-black/30 z-20"
onClick={() => setSidebarVisible(false)}
/>
)}
{sidebarVisible && <div className="md:hidden fixed inset-0 bg-black/30 z-20" onClick={() => setSidebarVisible(false)} />}
<div
className={`
fixed md:relative
@ -71,16 +60,12 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
bg-zinc-50
transition-all duration-300
${
sidebarVisible
? "w-64 lg:w-[32rem] translate-x-0"
: "w-0 -translate-x-full md:translate-x-0"
}
${sidebarVisible ? 'w-64 lg:w-[32rem] translate-x-0' : 'w-0 -translate-x-full md:translate-x-0'}
`}
>
<div
className={`m-2 flex items-center justify-between
${sidebarVisible ? "sticky" : "hidden"}
${sidebarVisible ? 'sticky' : 'hidden'}
`}
>
<button
@ -90,11 +75,7 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
hover:bg-zinc-100 hover:cursor-pointer
transition-colors text-zinc-600 hover:text-zinc-800"
>
{sidebarVisible ? (
<PanelLeftClose size={20} />
) : (
<PanelLeftOpen size={20} />
)}
{sidebarVisible ? <PanelLeftClose size={20} /> : <PanelLeftOpen size={20} />}
</button>
<button
className="rounded-lg p-[0.4em]
@ -116,28 +97,21 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
<li
key={conversation.id}
className={`cursor-pointer p-2 transition-colors rounded-lg ${
conversation.id === conversationId ||
(!conversationId && !conversation.id)
? "bg-zinc-200 text-zinc-900"
: "hover:bg-zinc-200 text-zinc-600"
conversation.id === conversationId || (!conversationId && !conversation.id)
? 'bg-zinc-200 text-zinc-900'
: 'hover:bg-zinc-200 text-zinc-600'
}`}
onClick={() => handleConversationClick(conversation.id)}
>
<div className="flex items-center justify-between">
<span className="truncate flex-grow text-sm">
{conversation.title}
</span>
<span className="truncate flex-grow text-sm">{conversation.title}</span>
<div className="flex space-x-2 ml-2">
<button
className="opacity-60 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
const newTitle = prompt(
"Enter new title:",
conversation.title
);
if (newTitle)
editConversationTitle(conversation.id!, newTitle);
e.stopPropagation()
const newTitle = prompt('Enter new title:', conversation.title)
if (newTitle) editConversationTitle(conversation.id!, newTitle)
}}
>
<Edit size={14} />
@ -145,9 +119,9 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
<button
className="opacity-60 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteConversation(conversation.id!);
setConversationId(undefined);
e.stopPropagation()
deleteConversation(conversation.id!)
setConversationId(undefined)
}}
>
<Trash2 size={14} />
@ -158,20 +132,16 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
))}
</ul>
</div>
{/* Model Selector and MCP Servers at bottom */}
<div className="p-2 border-t border-zinc-200 space-y-3">
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/>
<ModelSelector selectedModel={selectedModel} onModelChange={onModelChange} apiKeyUpdateTrigger={apiKeyUpdateTrigger} />
<McpServers onToolsUpdate={onMcpToolsUpdate} />
</div>
</div>
</div>
</>
);
};
)
}
export default ChatSidebar;
export default ChatSidebar

View file

@ -205,12 +205,14 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
{currentConversation.messages
.filter((message) => {
// Filter out empty assistant messages (no content and no reasoning)
if (message.role === "assistant" &&
(!message.content || !message.content.trim()) &&
!("reasoning" in message && (message.reasoning || message.isReasoningStreaming))) {
return false;
if (
message.role === 'assistant' &&
(!message.content || !message.content.trim()) &&
!('reasoning' in message && (message.reasoning || message.isReasoningStreaming))
) {
return false
}
return true;
return true
})
.map((message, index) => (
<ChatMessage key={index} message={message} />
@ -233,7 +235,7 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
controller={controller}
messagesCount={currentConversation.messages.length}
/>
{/* Model selector and MCP server indicators */}
<div className="flex items-center justify-between mt-2">
<button
@ -242,14 +244,12 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
>
<span className="text-lg">🧠</span>
<span className="text-sm text-zinc-500">
{hasApiKey(selectedModel.provider.id) ? (
selectedModel.name.toLowerCase() + ' (' + selectedModel.provider.name.toLowerCase() + ')'
) : (
"none"
)}
{hasApiKey(selectedModel.provider.id)
? selectedModel.name.toLowerCase() + ' (' + selectedModel.provider.name.toLowerCase() + ')'
: 'none'}
</span>
</button>
<button
onClick={() => setMcpServerModal(true)}
className="flex items-center gap-2 text-zinc-600 hover:text-zinc-900 transition-colors cursor-pointer"
@ -259,23 +259,23 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
try {
const servers = JSON.parse(localStorage.getItem('mcpServers') || '[]')
const toolCounts = JSON.parse(localStorage.getItem('mcpServerToolCounts') || '{}')
const enabledServers = servers.filter((s: any) => s.enabled).length
const totalServers = servers.length
const enabledTools = mcpTools.length
// Calculate total tools across all servers (including disabled ones that were previously connected)
const totalTools = servers.reduce((sum: number, server: any) => {
return sum + (toolCounts[server.id] || 0)
}, 0)
if (totalServers === 0) {
return "0/0 servers, 0/0 tools"
return '0/0 servers, 0/0 tools'
}
return `${enabledServers}/${totalServers} servers, ${enabledTools}/${totalTools} tools`
} catch {
return mcpTools.length > 0 ? `1/1 servers, ${mcpTools.length}/${mcpTools.length} tools` : "0/0 servers, 0/0 tools"
return mcpTools.length > 0 ? `1/1 servers, ${mcpTools.length}/${mcpTools.length} tools` : '0/0 servers, 0/0 tools'
}
})()}
</span>
@ -300,11 +300,7 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/>
<McpServerModal
isOpen={mcpServerModal}
onClose={() => setMcpServerModal(false)}
onToolsUpdate={onMcpToolsUpdate}
/>
<McpServerModal isOpen={mcpServerModal} onClose={() => setMcpServerModal(false)} onToolsUpdate={onMcpToolsUpdate} />
</div>
)
}

View file

@ -31,14 +31,7 @@ function McpConnection({
// Update parent component with connection data
useEffect(() => {
onConnectionUpdate(server.id, connection)
}, [
server.id,
connection.state,
connection.tools,
connection.error,
connection.log.length,
connection.authUrl,
])
}, [server.id, connection.state, connection.tools, connection.error, connection.log.length, connection.authUrl])
// Return null as this is just a hook wrapper
return null
@ -50,11 +43,7 @@ interface McpServerModalProps {
onToolsUpdate?: (tools: Tool[]) => void
}
const McpServerModal: React.FC<McpServerModalProps> = ({
isOpen,
onClose,
onToolsUpdate,
}) => {
const McpServerModal: React.FC<McpServerModalProps> = ({ isOpen, onClose, onToolsUpdate }) => {
const [servers, setServers] = useState<McpServer[]>(() => {
const stored = localStorage.getItem('mcpServers')
return stored ? JSON.parse(stored) : []
@ -73,12 +62,16 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
// Helper to cycle through transport types
const cycleTransportType = () => {
setTransportType(current => {
setTransportType((current) => {
switch (current) {
case 'auto': return 'http'
case 'http': return 'sse'
case 'sse': return 'auto'
default: return 'auto'
case 'auto':
return 'http'
case 'http':
return 'sse'
case 'sse':
return 'auto'
default:
return 'auto'
}
})
}
@ -105,7 +98,7 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
@ -114,13 +107,12 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
// Aggregate all tools from enabled servers and notify parent
useEffect(() => {
const allTools: Tool[] = []
servers.forEach(server => {
servers.forEach((server) => {
if (server.enabled && connectionData[server.id]?.tools) {
const serverTools = connectionData[server.id].tools.map((t: Tool) => ({
...t,
callTool: (args: Record<string, unknown>) =>
connectionData[server.id].callTool(t.name, args),
callTool: (args: Record<string, unknown>) => connectionData[server.id].callTool(t.name, args),
}))
allTools.push(...serverTools)
}
@ -134,15 +126,15 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
// Handle adding a new server
const handleAddServer = () => {
if (!newServerUrl.trim()) return
const newServer: McpServer = {
id: Date.now().toString(),
url: newServerUrl.trim(),
enabled: true,
name: new URL(newServerUrl.trim()).hostname
name: new URL(newServerUrl.trim()).hostname,
}
setServers(prev => [...prev, newServer])
setServers((prev) => [...prev, newServer])
setNewServerUrl('')
}
@ -153,14 +145,14 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
if (connection?.disconnect) {
connection.disconnect()
}
setServers(prev => prev.filter(s => s.id !== serverId))
setConnectionData(prev => {
setServers((prev) => prev.filter((s) => s.id !== serverId))
setConnectionData((prev) => {
const newData = { ...prev }
delete newData[serverId]
return newData
})
setServerToolCounts(prev => {
setServerToolCounts((prev) => {
const newCounts = { ...prev }
delete newCounts[serverId]
return newCounts
@ -169,14 +161,10 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
// Handle toggling server enabled state
const handleToggleServer = (serverId: string) => {
setServers(prev => prev.map(server =>
server.id === serverId
? { ...server, enabled: !server.enabled }
: server
))
setServers((prev) => prev.map((server) => (server.id === serverId ? { ...server, enabled: !server.enabled } : server)))
// If disabling, disconnect
const server = servers.find(s => s.id === serverId)
const server = servers.find((s) => s.id === serverId)
if (server?.enabled && connectionData[serverId]?.disconnect) {
connectionData[serverId].disconnect()
}
@ -184,16 +172,16 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
// Handle connection update for a specific server
const handleConnectionUpdate = (serverId: string, data: any) => {
setConnectionData(prev => ({
setConnectionData((prev) => ({
...prev,
[serverId]: data
[serverId]: data,
}))
// Store tool count for this server (even if it gets disabled later)
if (data.tools && Array.isArray(data.tools)) {
setServerToolCounts(prev => ({
setServerToolCounts((prev) => ({
...prev,
[serverId]: data.tools.length
[serverId]: data.tools.length,
}))
}
}
@ -216,80 +204,43 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
switch (state) {
case 'discovering':
return (
<span className={`${baseClasses} bg-blue-100 text-blue-800`}>
Discovering
</span>
)
return <span className={`${baseClasses} bg-blue-100 text-blue-800`}>Discovering</span>
case 'authenticating':
return (
<span className={`${baseClasses} bg-purple-100 text-purple-800`}>
Authenticating
</span>
)
return <span className={`${baseClasses} bg-purple-100 text-purple-800`}>Authenticating</span>
case 'connecting':
return (
<span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>
Connecting
</span>
)
return <span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>Connecting</span>
case 'loading':
return (
<span className={`${baseClasses} bg-orange-100 text-orange-800`}>
Loading
</span>
)
return <span className={`${baseClasses} bg-orange-100 text-orange-800`}>Loading</span>
case 'ready':
return (
<span className={`${baseClasses} bg-green-100 text-green-800`}>
Connected
</span>
)
return <span className={`${baseClasses} bg-green-100 text-green-800`}>Connected</span>
case 'failed':
return (
<span className={`${baseClasses} bg-red-100 text-red-800`}>
Failed
</span>
)
return <span className={`${baseClasses} bg-red-100 text-red-800`}>Failed</span>
case 'not-connected':
default:
return (
<span className={`${baseClasses} bg-gray-100 text-gray-800`}>
Not Connected
</span>
)
return <span className={`${baseClasses} bg-gray-100 text-gray-800`}>Not Connected</span>
}
}
return (
<>
<div
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 ${isOpen ? 'block' : 'hidden'}`}
style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-zinc-200">
<h2 className="text-xl font-semibold text-zinc-900">MCP Servers</h2>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-gray-200 p-1 hover:bg-gray-50"
onClick={() => setShowSettings(!showSettings)}
>
<button className="rounded-md border border-gray-200 p-1 hover:bg-gray-50" onClick={() => setShowSettings(!showSettings)}>
<Settings size={16} className="text-gray-500" />
</button>
<button
onClick={onClose}
className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer"
>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
<X size={24} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[60vh]">
<div className="flex items-center mb-6">
<Info size={16} className="text-gray-400 mr-2" />
@ -303,7 +254,7 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
{servers.map((server) => {
const connection = connectionData[server.id] || { state: 'not-connected', tools: [], error: undefined, authUrl: undefined }
const { state, tools, error, authUrl } = connection
return (
<div
key={server.id}
@ -316,7 +267,7 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
<div className="text-sm text-zinc-500 break-all">{server.url}</div>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(server.enabled ? state : 'disabled')}
{server.enabled && state === 'ready' && (
@ -344,16 +295,12 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
{server.enabled && (
<>
{error && state === 'failed' && (
<div className="text-sm text-red-600 p-3 bg-red-50 rounded border mb-3">
{error}
</div>
<div className="text-sm text-red-600 p-3 bg-red-50 rounded border mb-3">{error}</div>
)}
{authUrl && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded mb-3">
<p className="text-sm mb-2">
Authentication required. Please click the link below:
</p>
<p className="text-sm mb-2">Authentication required. Please click the link below:</p>
<a
href={authUrl}
target="_blank"
@ -368,16 +315,12 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
{state === 'ready' && tools.length > 0 && (
<div>
<h4 className="font-medium text-sm mb-2">
Available Tools ({tools.length})
</h4>
<h4 className="font-medium text-sm mb-2">Available Tools ({tools.length})</h4>
<div className="border border-gray-200 rounded p-2 bg-gray-50 max-h-24 overflow-y-auto space-y-1">
{tools.map((tool: Tool, index: number) => (
<div key={index} className="text-xs">
<span className="font-medium">{tool.name}</span>
{tool.description && (
<span className="text-gray-600 ml-2">- {tool.description}</span>
)}
{tool.description && <span className="text-gray-600 ml-2">- {tool.description}</span>}
</div>
))}
</div>
@ -441,7 +384,7 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
<h3 className="font-medium text-sm mb-3">Debug Information</h3>
<div className="text-xs space-y-2">
<div>Total Servers: {servers.length}</div>
<div>Enabled Servers: {servers.filter(s => s.enabled).length}</div>
<div>Enabled Servers: {servers.filter((s) => s.enabled).length}</div>
<div>Connected Servers: {Object.values(connectionData).filter((c: any) => c.state === 'ready').length}</div>
<div>Total Tools: {Object.values(connectionData).reduce((sum: number, c: any) => sum + (c.tools?.length || 0), 0)}</div>
</div>
@ -452,14 +395,11 @@ const McpServerModal: React.FC<McpServerModalProps> = ({
</div>
{/* Render MCP connections for all enabled servers */}
{servers.filter(s => s.enabled).map(server => (
<McpConnection
key={server.id}
server={server}
onConnectionUpdate={handleConnectionUpdate}
transportType={transportType}
/>
))}
{servers
.filter((s) => s.enabled)
.map((server) => (
<McpConnection key={server.id} server={server} onConnectionUpdate={handleConnectionUpdate} transportType={transportType} />
))}
</>
)
}

View file

@ -3,13 +3,7 @@ import { useMcp, type Tool } from 'use-mcp/react'
import { Settings, Info } from 'lucide-react'
// MCP Connection wrapper that only renders when active
function McpConnection({
serverUrl,
onConnectionUpdate,
}: {
serverUrl: string
onConnectionUpdate: (data: any) => void
}) {
function McpConnection({ serverUrl, onConnectionUpdate }: { serverUrl: string; onConnectionUpdate: (data: any) => void }) {
// Use the MCP hook with the server URL
const connection = useMcp({
url: serverUrl,
@ -21,23 +15,13 @@ function McpConnection({
// Update parent component with connection data
useEffect(() => {
onConnectionUpdate(connection)
}, [
connection.state,
connection.tools,
connection.error,
connection.log.length,
connection.authUrl,
])
}, [connection.state, connection.tools, connection.error, connection.log.length, connection.authUrl])
// Return null as this is just a hook wrapper
return null
}
export function McpServers({
onToolsUpdate,
}: {
onToolsUpdate?: (tools: Tool[]) => void
}) {
export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[]) => void }) {
const [serverUrl, setServerUrl] = useState(() => {
return sessionStorage.getItem('mcpServerUrl') || ''
})
@ -52,15 +36,13 @@ export function McpServers({
retry: () => {},
disconnect: () => {},
authenticate: () => Promise.resolve(undefined),
callTool: (_name: string, _args?: Record<string, unknown>) =>
Promise.resolve(undefined),
callTool: (_name: string, _args?: Record<string, unknown>) => Promise.resolve(undefined),
clearStorage: () => {},
})
const logRef = useRef<HTMLDivElement>(null)
// Extract connection properties
const { state, tools, error, log, authUrl, disconnect, authenticate } =
connectionData
const { state, tools, error, log, authUrl, disconnect, authenticate } = connectionData
// Notify parent component when tools change
useEffect(() => {
@ -68,9 +50,8 @@ export function McpServers({
onToolsUpdate(
tools.map((t: Tool) => ({
...t,
callTool: (args: Record<string, unknown>) =>
connectionData.callTool(t.name, args),
})),
callTool: (args: Record<string, unknown>) => connectionData.callTool(t.name, args),
}))
)
}
}, [tools, onToolsUpdate])
@ -94,8 +75,7 @@ export function McpServers({
retry: () => {},
disconnect: () => {},
authenticate: () => Promise.resolve(undefined),
callTool: (_name: string, _args?: Record<string, unknown>) =>
Promise.resolve(undefined),
callTool: (_name: string, _args?: Record<string, unknown>) => Promise.resolve(undefined),
clearStorage: () => {},
})
}
@ -122,48 +102,20 @@ export function McpServers({
switch (state) {
case 'discovering':
return (
<span className={`${baseClasses} bg-blue-100 text-blue-800`}>
Discovering
</span>
)
return <span className={`${baseClasses} bg-blue-100 text-blue-800`}>Discovering</span>
case 'authenticating':
return (
<span className={`${baseClasses} bg-purple-100 text-purple-800`}>
Authenticating
</span>
)
return <span className={`${baseClasses} bg-purple-100 text-purple-800`}>Authenticating</span>
case 'connecting':
return (
<span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>
Connecting
</span>
)
return <span className={`${baseClasses} bg-yellow-100 text-yellow-800`}>Connecting</span>
case 'loading':
return (
<span className={`${baseClasses} bg-orange-100 text-orange-800`}>
Loading
</span>
)
return <span className={`${baseClasses} bg-orange-100 text-orange-800`}>Loading</span>
case 'ready':
return (
<span className={`${baseClasses} bg-green-100 text-green-800`}>
Connected
</span>
)
return <span className={`${baseClasses} bg-green-100 text-green-800`}>Connected</span>
case 'failed':
return (
<span className={`${baseClasses} bg-red-100 text-red-800`}>
Failed
</span>
)
return <span className={`${baseClasses} bg-red-100 text-red-800`}>Failed</span>
case 'not-connected':
default:
return (
<span className={`${baseClasses} bg-gray-100 text-gray-800`}>
Not Connected
</span>
)
return <span className={`${baseClasses} bg-gray-100 text-gray-800`}>Not Connected</span>
}
}
@ -183,17 +135,13 @@ export function McpServers({
<Info size={14} className="text-gray-400" />
</div>
</div>
<button
className="rounded-md border border-gray-200 p-1 hover:bg-gray-50"
onClick={() => setShowSettings(!showSettings)}
>
<button className="rounded-md border border-gray-200 p-1 hover:bg-gray-50" onClick={() => setShowSettings(!showSettings)}>
<Settings size={16} className="text-gray-500" />
</button>
</div>
<p className="text-gray-500 text-xs mt-1 mb-3">
Connect to Model Context Protocol (MCP) servers to access additional AI
capabilities.
Connect to Model Context Protocol (MCP) servers to access additional AI capabilities.
</p>
<div className="space-y-3">
@ -202,11 +150,7 @@ export function McpServers({
{getStatusBadge()}
</div>
{error && state === 'failed' && (
<div className="text-xs text-red-600 p-2 bg-red-50 rounded border">
{error}
</div>
)}
{error && state === 'failed' && <div className="text-xs text-red-600 p-2 bg-red-50 rounded border">{error}</div>}
<div className="space-y-2">
<input
@ -221,9 +165,8 @@ export function McpServers({
}}
disabled={isActive && state !== 'failed'}
/>
{state === 'ready' ||
(isActive && state !== 'not-connected' && state !== 'failed') ? (
{state === 'ready' || (isActive && state !== 'not-connected' && state !== 'failed') ? (
<button
className="w-full px-3 py-2 bg-orange-100 hover:bg-orange-200 text-orange-900 rounded text-sm font-medium"
onClick={handleDisconnect}
@ -244,9 +187,7 @@ export function McpServers({
{/* Authentication Link if needed */}
{authUrl && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
<p className="text-xs mb-2">
Authentication required. Please click the link below:
</p>
<p className="text-xs mb-2">Authentication required. Please click the link below:</p>
<a
href={authUrl}
target="_blank"
@ -262,21 +203,12 @@ export function McpServers({
{/* Tools display when connected */}
{state === 'ready' && tools.length > 0 && (
<div>
<h3 className="font-medium text-xs mb-2">
Available Tools ({tools.length})
</h3>
<h3 className="font-medium text-xs mb-2">Available Tools ({tools.length})</h3>
<div className="border border-gray-200 rounded p-2 bg-gray-50 max-h-32 overflow-y-auto space-y-2">
{tools.map((tool: Tool, index: number) => (
<div
key={index}
className="text-xs pb-2 border-b border-gray-100 last:border-b-0"
>
<div key={index} className="text-xs pb-2 border-b border-gray-100 last:border-b-0">
<span className="font-medium">{tool.name}</span>
{tool.description && (
<p className="text-gray-500 mt-1 text-xs">
{tool.description}
</p>
)}
{tool.description && <p className="text-gray-500 mt-1 text-xs">{tool.description}</p>}
</div>
))}
</div>
@ -287,10 +219,7 @@ export function McpServers({
{showSettings && (
<div>
<label className="font-medium text-xs block mb-2">Debug Log</label>
<div
ref={logRef}
className="border border-gray-200 rounded p-2 bg-gray-50 h-32 overflow-y-auto font-mono text-xs"
>
<div ref={logRef} className="border border-gray-200 rounded p-2 bg-gray-50 h-32 overflow-y-auto font-mono text-xs">
{log.length > 0 ? (
log.map((entry: any, index: number) => (
<div
@ -330,12 +259,7 @@ export function McpServers({
</div>
{/* Only render the actual MCP connection when active */}
{isActive && (
<McpConnection
serverUrl={serverUrl}
onConnectionUpdate={setConnectionData}
/>
)}
{isActive && <McpConnection serverUrl={serverUrl} onConnectionUpdate={setConnectionData} />}
</section>
)
}

View file

@ -27,7 +27,7 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
useEffect(() => {
const statuses: Record<string, boolean> = {}
availableModels.forEach(model => {
availableModels.forEach((model) => {
statuses[model.provider.id] = hasApiKey(model.provider.id)
})
setApiKeyStatuses(statuses)
@ -39,7 +39,7 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
@ -47,7 +47,7 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
const handleModelSelect = (model: Model) => {
const hasKey = hasApiKey(model.provider.id)
if (!hasKey) {
setApiKeyModal({ isOpen: true, model })
} else {
@ -59,7 +59,7 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyStatuses(prev => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
setApiKeyStatuses((prev) => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
onModelChange(apiKeyModal.model)
setApiKeyModal({ isOpen: false, model: null })
onClose()
@ -69,12 +69,12 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
e.stopPropagation()
clearApiKey(providerId)
setApiKeyStatuses(prev => ({ ...prev, [providerId]: false }))
setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false }))
}
const getStatusIcon = (providerId: string) => {
const hasKey = apiKeyStatuses[providerId]
if (hasKey) {
return <CheckCircle size={20} className="text-green-500" />
} else {
@ -86,38 +86,30 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
return (
<>
<div
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-zinc-200">
<h2 className="text-xl font-semibold text-zinc-900">Select Model</h2>
<button
onClick={onClose}
className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer"
>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto">
<div className="space-y-3">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel.id
const hasKey = apiKeyStatuses[model.provider.id]
return (
<div
key={model.id}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
isSelected
? 'border-blue-500 bg-blue-50'
: 'border-zinc-200 hover:border-zinc-300 hover:bg-zinc-50'
isSelected ? 'border-blue-500 bg-blue-50' : 'border-zinc-200 hover:border-zinc-300 hover:bg-zinc-50'
}`}
onClick={() => handleModelSelect(model)}
>
@ -129,7 +121,7 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
<div className="text-sm text-zinc-500">{model.provider.name}</div>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(model.provider.id)}
<span className={`text-sm ${hasKey ? 'text-green-600' : 'text-red-600'}`}>

View file

@ -10,11 +10,7 @@ interface ModelSelectorProps {
apiKeyUpdateTrigger: number
}
const ModelSelector: React.FC<ModelSelectorProps> = ({
selectedModel,
onModelChange,
apiKeyUpdateTrigger,
}) => {
const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange, apiKeyUpdateTrigger }) => {
const [isOpen, setIsOpen] = useState(false)
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
@ -25,7 +21,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
useEffect(() => {
const statuses: Record<string, boolean> = {}
availableModels.forEach(model => {
availableModels.forEach((model) => {
statuses[model.provider.id] = hasApiKey(model.provider.id)
})
setApiKeyStatuses(statuses)
@ -46,7 +42,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
const handleModelSelect = (model: Model) => {
const hasKey = hasApiKey(model.provider.id)
if (!hasKey) {
setApiKeyModal({ isOpen: true, model })
} else {
@ -58,7 +54,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyStatuses(prev => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
setApiKeyStatuses((prev) => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
onModelChange(apiKeyModal.model)
}
}
@ -66,19 +62,15 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
e.stopPropagation()
clearApiKey(providerId)
setApiKeyStatuses(prev => ({ ...prev, [providerId]: false }))
setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false }))
}
const getApiKeyIcon = (providerId: string) => {
const hasKey = apiKeyStatuses[providerId]
if (hasKey) {
return (
<button
onClick={(e) => handleClearApiKey(providerId, e)}
className="text-zinc-400 hover:text-red-500 p-1"
title="Clear token"
>
<button onClick={(e) => handleClearApiKey(providerId, e)} className="text-zinc-400 hover:text-red-500 p-1" title="Clear token">
<X size={14} />
</button>
)

View file

@ -12,7 +12,7 @@ export function OAuthCallback() {
<h1 className="text-2xl font-bold text-zinc-900 mb-4">Authenticating...</h1>
<p className="text-zinc-600 mb-2">Please wait while we complete your authentication.</p>
<p className="text-sm text-zinc-500">This window should close automatically.</p>
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>

View file

@ -1,91 +1,82 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react'
interface ReasoningBlockProps {
reasoning: string;
isStreaming?: boolean;
startTime?: number;
endTime?: number;
reasoning: string
isStreaming?: boolean
startTime?: number
endTime?: number
}
const ReasoningBlock: React.FC<ReasoningBlockProps> = ({
reasoning,
isStreaming = false,
startTime,
endTime
}) => {
const [isExpanded, setIsExpanded] = useState(isStreaming); // Start expanded when streaming
// Auto-collapse instantly when streaming finishes
useEffect(() => {
if (!isStreaming && startTime && endTime) {
setIsExpanded(false);
}
}, [isStreaming, startTime, endTime]);
const ReasoningBlock: React.FC<ReasoningBlockProps> = ({ reasoning, isStreaming = false, startTime, endTime }) => {
const [isExpanded, setIsExpanded] = useState(isStreaming) // Start expanded when streaming
if (!reasoning || reasoning.trim().length === 0) {
return null;
// Auto-collapse instantly when streaming finishes
useEffect(() => {
if (!isStreaming && startTime && endTime) {
setIsExpanded(false)
}
}, [isStreaming, startTime, endTime])
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
if (!reasoning || reasoning.trim().length === 0) {
return null
}
// Calculate thinking duration
const duration = startTime && endTime ? (endTime - startTime) / 1000 : null;
// Format duration nicely
const formatDuration = (seconds: number) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
}
return `${seconds.toFixed(1)}s`;
};
const toggleExpanded = () => {
setIsExpanded(!isExpanded)
}
return (
<div className="mb-3">
<div
className={`text-sm text-zinc-600 border border-zinc-200 rounded-lg p-3 bg-zinc-50 cursor-pointer hover:bg-zinc-100 transition-all duration-300 ${
isExpanded ? "border-zinc-300" : ""
}`}
onClick={toggleExpanded}
>
<div className="flex items-start gap-2">
<span className="text-zinc-400 text-xs mt-0.5 flex-shrink-0">💭</span>
{isExpanded ? (
// Expanded view: show reasoning content
<div className={`flex-1 ${isStreaming ? "overflow-hidden whitespace-nowrap flex justify-end" : "whitespace-pre-wrap"}`}>
<span className={isStreaming ? "inline-block" : ""}>
{reasoning}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-zinc-400 ml-1 animate-pulse"></span>
)}
</span>
</div>
) : (
// Collapsed view: show timing summary
<div className="flex-1">
<span className="text-zinc-500">
{isStreaming ? "Thinking..." :
duration ? `Thought for ${formatDuration(duration)}` :
"Thought process"}
</span>
</div>
)}
<button
className="text-zinc-400 hover:text-zinc-600 text-xs ml-auto flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
toggleExpanded();
}}
>
{isExpanded ? "↑" : "↓"}
</button>
</div>
// Calculate thinking duration
const duration = startTime && endTime ? (endTime - startTime) / 1000 : null
// Format duration nicely
const formatDuration = (seconds: number) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`
}
return `${seconds.toFixed(1)}s`
}
return (
<div className="mb-3">
<div
className={`text-sm text-zinc-600 border border-zinc-200 rounded-lg p-3 bg-zinc-50 cursor-pointer hover:bg-zinc-100 transition-all duration-300 ${
isExpanded ? 'border-zinc-300' : ''
}`}
onClick={toggleExpanded}
>
<div className="flex items-start gap-2">
<span className="text-zinc-400 text-xs mt-0.5 flex-shrink-0">💭</span>
{isExpanded ? (
// Expanded view: show reasoning content
<div className={`flex-1 ${isStreaming ? 'overflow-hidden whitespace-nowrap flex justify-end' : 'whitespace-pre-wrap'}`}>
<span className={isStreaming ? 'inline-block' : ''}>
{reasoning}
{isStreaming && <span className="inline-block w-2 h-4 bg-zinc-400 ml-1 animate-pulse"></span>}
</span>
</div>
</div>
);
};
) : (
// Collapsed view: show timing summary
<div className="flex-1">
<span className="text-zinc-500">
{isStreaming ? 'Thinking...' : duration ? `Thought for ${formatDuration(duration)}` : 'Thought process'}
</span>
</div>
)}
export default ReasoningBlock;
<button
className="text-zinc-400 hover:text-zinc-600 text-xs ml-auto flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
toggleExpanded()
}}
>
{isExpanded ? '↑' : '↓'}
</button>
</div>
</div>
</div>
)
}
export default ReasoningBlock

View file

@ -20,12 +20,10 @@ const ToolCallMessage: React.FC<ToolCallMessageProps> = ({ message }) => {
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-blue-800 text-sm">Tool Call</span>
<span className="text-blue-600 text-sm font-mono bg-blue-100 px-2 py-0.5 rounded">
{message.toolName}
</span>
<span className="text-blue-600 text-sm font-mono bg-blue-100 px-2 py-0.5 rounded">{message.toolName}</span>
</div>
<div
<div
className={`text-sm text-blue-700 font-mono bg-blue-100 p-2 rounded ${shouldTruncate ? 'cursor-pointer hover:bg-blue-200' : ''}`}
onClick={() => shouldTruncate && setExpanded(!expanded)}
>
@ -34,15 +32,11 @@ const ToolCallMessage: React.FC<ToolCallMessageProps> = ({ message }) => {
{expanded ? JSON.stringify(message.toolArgs, null, 2) : `${argsPreview}${shouldTruncate ? '...' : ''}`}
</span>
{shouldTruncate && (
<button className="ml-2 flex-shrink-0">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<button className="ml-2 flex-shrink-0">{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</button>
)}
</div>
{expanded && shouldTruncate && (
<pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{JSON.stringify(message.toolArgs, null, 2)}
</pre>
<pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto">{JSON.stringify(message.toolArgs, null, 2)}</pre>
)}
</div>
</div>

View file

@ -20,12 +20,10 @@ const ToolResultMessage: React.FC<ToolResultMessageProps> = ({ message }) => {
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-green-800 text-sm">Tool Result</span>
<span className="text-green-600 text-sm font-mono bg-green-100 px-2 py-0.5 rounded">
{message.toolName}
</span>
<span className="text-green-600 text-sm font-mono bg-green-100 px-2 py-0.5 rounded">{message.toolName}</span>
</div>
<div
<div
className={`text-sm text-green-700 font-mono bg-green-100 p-2 rounded ${shouldTruncate ? 'cursor-pointer hover:bg-green-200' : ''}`}
onClick={() => shouldTruncate && setExpanded(!expanded)}
>
@ -34,9 +32,7 @@ const ToolResultMessage: React.FC<ToolResultMessageProps> = ({ message }) => {
{expanded ? JSON.stringify(message.toolResult, null, 2) : `${resultPreview}${shouldTruncate ? '...' : ''}`}
</span>
{shouldTruncate && (
<button className="ml-2 flex-shrink-0">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<button className="ml-2 flex-shrink-0">{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</button>
)}
</div>
{expanded && shouldTruncate && (

View file

@ -1,2 +1,2 @@
export const storeName = "conversations";
export const dbName = "localgpt";
export const storeName = 'conversations'
export const dbName = 'localgpt'

View file

@ -1,36 +1,34 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef } from 'react'
export const useAutoscroll = () => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const container = messagesContainerRef.current
if (!container) return
const handleScroll = () => {
const isAtBottom =
container.scrollHeight - container.clientHeight - container.scrollTop <=
200;
shouldAutoScrollRef.current = isAtBottom;
};
const isAtBottom = container.scrollHeight - container.clientHeight - container.scrollTop <= 200
shouldAutoScrollRef.current = isAtBottom
}
container.addEventListener("scroll", handleScroll);
container.addEventListener('scroll', handleScroll)
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
container.removeEventListener('scroll', handleScroll)
}
}, [])
const scrollToBottom = (force?: boolean) => {
if (shouldAutoScrollRef.current || force) {
messagesEndRef.current?.scrollIntoView({ behavior: force ? "instant" : "smooth" });
messagesEndRef.current?.scrollIntoView({ behavior: force ? 'instant' : 'smooth' })
}
};
}
return {
messagesEndRef,
messagesContainerRef,
scrollToBottom,
};
};
}
}

Some files were not shown because too many files have changed in this diff Show more