fixing prettier
This commit is contained in:
parent
51fafb485c
commit
5e69116f47
33 changed files with 7830 additions and 7525 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export const storeName = "conversations";
|
||||
export const dbName = "localgpt";
|
||||
export const storeName = 'conversations'
|
||||
export const dbName = 'localgpt'
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue