Merge 4ad45b2d10 into 18ddaea2ae
This commit is contained in:
commit
54b4e5518f
|
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
searxng:
|
||||
container_name: searxng
|
||||
image: searxng/searxng:latest
|
||||
ports:
|
||||
- "8181:8080"
|
||||
volumes:
|
||||
- ./searxng:/etc/searxng:rw
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- DAC_OVERRIDE
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "1m"
|
||||
max-file: "1"
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# To execute this docker compose yml file use `docker compose -f docker-compose-v3-node-all-browsers.yml up`
|
||||
# Add the `-d` flag at the end for detached execution
|
||||
# To stop the execution, hit Ctrl+C, and then `docker compose -f docker-compose-v3-node-all-browsers.yml down`
|
||||
services:
|
||||
selenium-hub:
|
||||
image: selenium/hub:4.36.0-20251001
|
||||
container_name: selenium-hub
|
||||
ports:
|
||||
- "4442:4442"
|
||||
- "4443:4443"
|
||||
- "4444:4444"
|
||||
|
||||
all-browsers:
|
||||
image: selenium/node-all-browsers:4.36.0-20251001
|
||||
shm_size: 3gb
|
||||
depends_on:
|
||||
- selenium-hub
|
||||
environment:
|
||||
- SE_EVENT_BUS_HOST=selenium-hub
|
||||
# Uncomment the following lines to set a value to environment variable of particular browser when GENERATE_CONFIG=true
|
||||
# - SE_NODE_STEREOTYPE_CHROME=
|
||||
# - SE_NODE_BROWSER_NAME_CHROME=
|
||||
# - SE_NODE_BROWSER_VERSION_CHROME=
|
||||
# - SE_NODE_PLATFORM_NAME_CHROME=
|
||||
# - SE_BROWSER_BINARY_LOCATION_CHROME=
|
||||
# - SE_NODE_STEREOTYPE_EXTRA_CHROME=
|
||||
# - SE_NODE_MAX_SESSIONS_CHROME=
|
||||
# - SE_NODE_STEREOTYPE_EDGE=
|
||||
# - SE_NODE_BROWSER_NAME_EDGE=
|
||||
# - SE_NODE_BROWSER_VERSION_EDGE=
|
||||
# - SE_NODE_PLATFORM_NAME_EDGE=
|
||||
# - SE_BROWSER_BINARY_LOCATION_EDGE=
|
||||
# - SE_NODE_STEREOTYPE_EXTRA_EDGE=
|
||||
# - SE_NODE_MAX_SESSIONS_EDGE=
|
||||
# - SE_NODE_STEREOTYPE_FIREFOX=
|
||||
# - SE_NODE_BROWSER_NAME_FIREFOX=
|
||||
# - SE_NODE_BROWSER_VERSION_FIREFOX=
|
||||
# - SE_NODE_PLATFORM_NAME_FIREFOX=
|
||||
# - SE_BROWSER_BINARY_LOCATION_FIREFOX=
|
||||
# - SE_NODE_STEREOTYPE_EXTRA_FIREFOX=
|
||||
# - SE_NODE_MAX_SESSIONS_FIREFOX=
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Web Tools MCP Server
|
||||
|
||||
Provides web search and web scraping capabilities via MCP protocol.
|
||||
|
||||
Tools:
|
||||
- search_web: Search using SearxNG at localhost:8181
|
||||
- scrape_website: Scrape websites using Selenium Grid at localhost:4444
|
||||
|
||||
Copyright 2025
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
# Redirect stdout to stderr before MCP imports (MCP uses stdout for protocol)
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
from mcp import ServerSession, StdioServerParameters
|
||||
import mcp.server.stdio
|
||||
|
||||
# Configure logging to stderr
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] [%(name)s] %(message)s',
|
||||
handlers=[logging.StreamHandler(sys.stderr)]
|
||||
)
|
||||
logger = logging.getLogger("web_tools_mcp")
|
||||
|
||||
# Restore stdout for MCP protocol
|
||||
sys.stdout = original_stdout
|
||||
|
||||
# Initialize MCP server
|
||||
app = Server("web-tools")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TOOLS
|
||||
# ============================================================================
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""List available web tools"""
|
||||
return [
|
||||
Tool(
|
||||
name="search_web",
|
||||
description=(
|
||||
"Search the web for current information using SearxNG.\n\n"
|
||||
"Use this to:\n"
|
||||
"- Find current information and facts\n"
|
||||
"- Research topics\n"
|
||||
"- Stay up-to-date with recent events\n"
|
||||
"- Gather information not in your training data\n\n"
|
||||
"Requires: SearxNG running at localhost:8181"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 5)",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="scrape_website",
|
||||
description=(
|
||||
"Scrape a website to extract content using Selenium Grid.\n\n"
|
||||
"Use this to:\n"
|
||||
"- Extract information from specific websites\n"
|
||||
"- Gather detailed content from web pages\n"
|
||||
"- Analyze web content\n"
|
||||
"- Access dynamic web pages\n\n"
|
||||
"Requires: Selenium Grid running at localhost:4444"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Website URL to scrape (must include http:// or https://)"
|
||||
},
|
||||
"extract_text": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to extract text content (default: true)",
|
||||
"default": True
|
||||
},
|
||||
"extract_links": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to extract links from the page (default: false)",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Handle tool calls"""
|
||||
|
||||
if name == "search_web":
|
||||
query = arguments["query"]
|
||||
limit = arguments.get("limit", 5)
|
||||
logger.info(f"search_web: query='{query}' limit={limit}")
|
||||
result = await _search_web(query=query, limit=limit)
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
elif name == "scrape_website":
|
||||
url = arguments["url"]
|
||||
logger.info(f"scrape_website: url='{url}'")
|
||||
result = await _scrape_website(
|
||||
url=url,
|
||||
extract_text=arguments.get("extract_text", True),
|
||||
extract_links=arguments.get("extract_links", False)
|
||||
)
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TOOL IMPLEMENTATIONS
|
||||
# ============================================================================
|
||||
|
||||
async def _search_web(query: str, limit: int = 5) -> str:
|
||||
"""
|
||||
Search the web using SearxNG
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Formatted search results or error message
|
||||
"""
|
||||
try:
|
||||
url = "http://localhost:8181/search"
|
||||
params = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"language": "en",
|
||||
"categories": "general",
|
||||
"safesearch": 1,
|
||||
"count": limit
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
results = data.get("results", [])[:limit]
|
||||
|
||||
if not results:
|
||||
return f"No results found for: {query}"
|
||||
|
||||
output = f"Search results for '{query}':\n\n"
|
||||
for i, r in enumerate(results, 1):
|
||||
output += f"{i}. {r.get('title', 'No title')}\n"
|
||||
output += f" URL: {r.get('url', 'No URL')}\n"
|
||||
snippet = r.get('content', '')
|
||||
if snippet:
|
||||
output += f" {snippet[:200]}...\n"
|
||||
output += "\n"
|
||||
|
||||
return output
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error("Could not connect to SearxNG at localhost:8181")
|
||||
return "Error: SearxNG is not available. Make sure it's running at localhost:8181"
|
||||
except Exception as e:
|
||||
logger.error(f"search_web failed: {e}")
|
||||
return f"Error searching web: {str(e)}"
|
||||
|
||||
|
||||
async def _scrape_website(
|
||||
url: str,
|
||||
extract_text: bool = True,
|
||||
extract_links: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Scrape a website using Selenium Grid
|
||||
|
||||
Args:
|
||||
url: Website URL to scrape
|
||||
extract_text: Whether to extract text content
|
||||
extract_links: Whether to extract links
|
||||
|
||||
Returns:
|
||||
Scraped content or error message
|
||||
"""
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
return "Error: Invalid URL. Must start with http:// or https://"
|
||||
|
||||
try:
|
||||
# Check Selenium Grid availability
|
||||
grid_url = "http://localhost:4444/wd/hub"
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
status_resp = await client.get(f"{grid_url}/status")
|
||||
if status_resp.status_code != 200:
|
||||
return "Error: Selenium Grid is not available at localhost:4444"
|
||||
except:
|
||||
return "Error: Selenium Grid is not available at localhost:4444"
|
||||
|
||||
# Create session
|
||||
capabilities = {
|
||||
"capabilities": {
|
||||
"alwaysMatch": {
|
||||
"browserName": "chrome",
|
||||
"platformName": "ANY",
|
||||
"goog:chromeOptions": {
|
||||
"args": ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--headless"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session_resp = await client.post(
|
||||
f"{grid_url}/session",
|
||||
json=capabilities,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if session_resp.status_code != 200:
|
||||
return f"Error: Failed to create browser session"
|
||||
|
||||
session_data = session_resp.json()
|
||||
session_id = session_data['value']['sessionId']
|
||||
|
||||
try:
|
||||
# Navigate to URL
|
||||
await client.post(
|
||||
f"{grid_url}/session/{session_id}/url",
|
||||
json={"url": url},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
# Wait a moment for page to load
|
||||
await asyncio.sleep(2)
|
||||
|
||||
output = f"Scraped content from: {url}\n\n"
|
||||
|
||||
# Extract text if requested
|
||||
if extract_text:
|
||||
script = "return document.body.innerText || document.body.textContent || '';"
|
||||
text_resp = await client.post(
|
||||
f"{grid_url}/session/{session_id}/execute/sync",
|
||||
json={"script": script, "args": []},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if text_resp.status_code == 200:
|
||||
text_data = text_resp.json()
|
||||
text = text_data['value']
|
||||
# Truncate if too long
|
||||
if len(text) > 5000:
|
||||
text = text[:5000] + "... [truncated]"
|
||||
output += f"Text Content:\n{text}\n\n"
|
||||
|
||||
# Extract links if requested
|
||||
if extract_links:
|
||||
link_script = """
|
||||
const links = Array.from(document.querySelectorAll('a[href]'));
|
||||
return links.map(link => ({
|
||||
text: link.innerText || link.textContent || '',
|
||||
href: link.href
|
||||
})).filter(link => link.href && link.href.startsWith('http')).slice(0, 20);
|
||||
"""
|
||||
link_resp = await client.post(
|
||||
f"{grid_url}/session/{session_id}/execute/sync",
|
||||
json={"script": link_script, "args": []},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if link_resp.status_code == 200:
|
||||
link_data = link_resp.json()
|
||||
links = link_data['value']
|
||||
output += f"Links Found ({len(links)}):\n"
|
||||
for i, link in enumerate(links[:10], 1):
|
||||
output += f"{i}. {link.get('text', 'No text')[:50]} - {link.get('href')}\n"
|
||||
|
||||
return output
|
||||
|
||||
finally:
|
||||
# Clean up session
|
||||
try:
|
||||
await client.delete(f"{grid_url}/session/{session_id}", timeout=10)
|
||||
except:
|
||||
pass
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error("Could not connect to Selenium Grid at localhost:4444")
|
||||
return "Error: Selenium Grid is not available at localhost:4444"
|
||||
except Exception as e:
|
||||
logger.error(f"scrape_website failed: {e}")
|
||||
return f"Error scraping website: {str(e)}"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
async def main():
|
||||
"""Run the MCP server"""
|
||||
logger.info("Web Tools MCP Server starting...")
|
||||
logger.info("Tools: search_web, scrape_website")
|
||||
logger.info("Requirements:")
|
||||
logger.info(" - SearxNG at localhost:8181 (for search_web)")
|
||||
logger.info(" - Selenium Grid at localhost:4444 (for scrape_website)")
|
||||
|
||||
# Run server with stdio transport
|
||||
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
||||
await app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
app.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -4,13 +4,14 @@
|
|||
ChatMessageActions,
|
||||
ChatMessageStatistics,
|
||||
ChatMessageThinkingBlock,
|
||||
ChatMessageToolExecution, // [AI] Tool execution indicator component
|
||||
CopyToClipboardIcon,
|
||||
MarkdownContent,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { isLoading, activeToolExecutions } from '$lib/stores/chat.svelte'; // [AI] Tool execution state
|
||||
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Check, X, Wrench } from '@lucide/svelte';
|
||||
|
|
@ -87,6 +88,10 @@
|
|||
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
|
||||
);
|
||||
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
|
||||
// [AI] Filter tool executions for this message
|
||||
const messageToolExecutions = $derived(
|
||||
activeToolExecutions().filter((t) => t.messageId === message.id)
|
||||
);
|
||||
|
||||
const processingState = useProcessingState();
|
||||
|
||||
|
|
@ -332,6 +337,20 @@
|
|||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- [AI] Tool execution indicators -->
|
||||
{#if messageToolExecutions.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each messageToolExecutions as execution (execution.toolName)}
|
||||
<ChatMessageToolExecution
|
||||
toolName={execution.toolName}
|
||||
status={execution.status}
|
||||
result={execution.result}
|
||||
error={execution.error}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.timestamp && !isEditing}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<!-- [AI] Tool execution status indicator component -->
|
||||
<script lang="ts">
|
||||
import { Loader2, CheckCircle2, XCircle, Wrench } from '@lucide/svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
toolName: string;
|
||||
status: 'pending' | 'executing' | 'completed' | 'error';
|
||||
result?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { toolName, status, result, error }: Props = $props();
|
||||
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
icon: Wrench,
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted-foreground/10',
|
||||
label: 'Pending'
|
||||
},
|
||||
executing: {
|
||||
icon: Loader2,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
label: 'Executing'
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'Completed'
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10',
|
||||
label: 'Error'
|
||||
}
|
||||
};
|
||||
|
||||
const config = $derived(statusConfig[status]);
|
||||
const Icon = $derived(config.icon);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm {config.bgColor}"
|
||||
transition:fade
|
||||
>
|
||||
<svelte:component
|
||||
this={Icon}
|
||||
class="h-4 w-4 {config.color} {status === 'executing' ? 'animate-spin' : ''}"
|
||||
/>
|
||||
<span class="font-mono">{toolName}</span>
|
||||
<span class="text-xs {config.color}">{config.label}</span>
|
||||
|
||||
{#if error}
|
||||
<span class="text-xs text-red-500">- {error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
// [AI] MCP Settings button integration
|
||||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import { DialogChatSettings } from '$lib/components/app';
|
||||
import { Settings, Puzzle } from '@lucide/svelte';
|
||||
import { DialogChatSettings, DialogMCPSettings } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let settingsOpen = $state(false);
|
||||
let mcpSettingsOpen = $state(false);
|
||||
|
||||
function toggleSettings() {
|
||||
settingsOpen = true;
|
||||
}
|
||||
|
||||
function toggleMCPSettings() {
|
||||
mcpSettingsOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
|
|
@ -19,6 +25,9 @@
|
|||
: ''}"
|
||||
>
|
||||
<div class="pointer-events-auto flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" onclick={toggleMCPSettings}>
|
||||
<Puzzle class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={toggleSettings}>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -26,3 +35,4 @@
|
|||
</header>
|
||||
|
||||
<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
|
||||
<DialogMCPSettings open={mcpSettingsOpen} onOpenChange={(open) => (mcpSettingsOpen = open)} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<!-- [AI] MCP Settings dialog wrapper -->
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import MCPSettings from '../mcp/MCPSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = false }: Props = $props();
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||
<Dialog.Content
|
||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
|
||||
md:h-[70vh] md:max-h-[70vh] md:min-h-0 md:rounded-lg"
|
||||
style="max-width: 48rem;"
|
||||
>
|
||||
<MCPSettings onSave={handleSave} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -21,6 +21,7 @@ export { default as ChatMessageBranchingControls } from './chat/ChatMessages/Cha
|
|||
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
|
||||
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
|
||||
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
|
||||
export { default as ChatMessageToolExecution } from './chat/ChatMessages/ChatMessageToolExecution.svelte';
|
||||
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
|
||||
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svel
|
|||
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
|
||||
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
|
||||
export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
|
||||
export { default as DialogMCPSettings } from './dialogs/DialogMCPSettings.svelte';
|
||||
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
|
||||
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
<!-- [AI] MCP Settings UI component -->
|
||||
<script lang="ts">
|
||||
import { Server, Plus, Trash2, Power, PowerOff } from '@lucide/svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { MCPServerStatus, type MCPServer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
let { onSave }: Props = $props();
|
||||
|
||||
let newServer = $state({
|
||||
id: '',
|
||||
name: '',
|
||||
command: '',
|
||||
args: '',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
let isAdding = $state(false);
|
||||
|
||||
async function handleAddServer() {
|
||||
console.log('handleAddServer called', { id: newServer.id, name: newServer.name, command: newServer.command, args: newServer.args });
|
||||
if (!newServer.id || !newServer.name || !newServer.command) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mcpStore.addServer({
|
||||
id: newServer.id,
|
||||
name: newServer.name,
|
||||
command: newServer.command,
|
||||
args: newServer.args
|
||||
.split(" ")
|
||||
.map(a => a.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter((a) => a),
|
||||
enabled: newServer.enabled
|
||||
});
|
||||
|
||||
// Reset form
|
||||
newServer = {
|
||||
id: '',
|
||||
name: '',
|
||||
command: '',
|
||||
args: '',
|
||||
enabled: true
|
||||
};
|
||||
isAdding = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to add server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleServer(server: MCPServer) {
|
||||
try {
|
||||
await mcpStore.updateServer(server.id, { enabled: !server.enabled });
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveServer(serverId: string) {
|
||||
if (!confirm('Are you sure you want to remove this server?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mcpStore.removeServer(serverId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: MCPServerStatus): string {
|
||||
switch (status) {
|
||||
case MCPServerStatus.RUNNING:
|
||||
return 'text-green-500';
|
||||
case MCPServerStatus.STARTING:
|
||||
return 'text-yellow-500';
|
||||
case MCPServerStatus.ERROR:
|
||||
return 'text-red-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: MCPServerStatus) {
|
||||
return status === MCPServerStatus.RUNNING ? Power : PowerOff;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Header -->
|
||||
<div class="border-b p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Server class="h-6 w-6" />
|
||||
<h2 class="text-2xl font-semibold">MCP Servers</h2>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Manage Model Context Protocol servers for tool calling
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<ScrollArea class="flex-1 p-6">
|
||||
<div class="space-y-4">
|
||||
<!-- Server List -->
|
||||
{#each mcpStore.servers as server (server.id)}
|
||||
{@const StatusIcon = getStatusIcon(server.status)}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium">{server.name}</h3>
|
||||
<StatusIcon class="h-4 w-4 {getStatusColor(server.status)}" />
|
||||
<span class="text-xs text-muted-foreground">{server.status}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{server.command}
|
||||
{server.args.join(' ')}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">ID: {server.id}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={server.enabled}
|
||||
onCheckedChange={() => handleToggleServer(server)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => handleRemoveServer(server.id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if mcpStore.servers.length === 0 && !isAdding}
|
||||
<div class="rounded-lg border border-dashed p-8 text-center">
|
||||
<Server class="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">No MCP servers configured</p>
|
||||
<Button class="mt-4" onclick={() => (isAdding = true)}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Server Form -->
|
||||
{#if isAdding}
|
||||
<div class="rounded-lg border bg-muted/50 p-4">
|
||||
<h3 class="mb-4 font-medium">Add New Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label for="server-id">Server ID</Label>
|
||||
<Input
|
||||
id="server-id"
|
||||
bind:value={newServer.id}
|
||||
placeholder="web-tools"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="server-name">Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
bind:value={newServer.name}
|
||||
placeholder="Web Tools"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="server-command">Command</Label>
|
||||
<Input
|
||||
id="server-command"
|
||||
bind:value={newServer.command}
|
||||
placeholder="python"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="server-args">Arguments (space-separated)</Label>
|
||||
<Input
|
||||
id="server-args"
|
||||
bind:value={newServer.args}
|
||||
placeholder="path/to/server.py"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch bind:checked={newServer.enabled} id="server-enabled" />
|
||||
<Label for="server-enabled">Enable immediately</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button onclick={handleAddServer}>Add Server</Button>
|
||||
<Button variant="outline" onclick={() => (isAdding = false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mcpStore.servers.length > 0 && !isAdding}
|
||||
<Button variant="outline" class="w-full" onclick={() => (isAdding = true)}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Add Another Server
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Tool List -->
|
||||
{#if mcpStore.tools.length > 0}
|
||||
<div class="mt-6">
|
||||
<h3 class="mb-3 font-medium">Available Tools ({mcpStore.tools.length})</h3>
|
||||
<div class="space-y-2">
|
||||
{#each mcpStore.tools as tool}
|
||||
<div class="rounded-md border p-3">
|
||||
<div class="font-mono text-sm">{tool.function.name}</div>
|
||||
{#if tool.function.description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{tool.function.description}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-muted-foreground">Server: {tool.serverId}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t p-4">
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{mcpStore.servers.filter((s) => s.enabled).length} enabled •
|
||||
{mcpStore.tools.length} tools available
|
||||
</div>
|
||||
<Button onclick={onSave}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -40,6 +40,8 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
|||
dry_penalty_last_n: -1,
|
||||
max_tokens: -1,
|
||||
custom: '', // custom json-stringified object
|
||||
// [AI] Tool calling limits
|
||||
maxToolCalls: 10,
|
||||
// experimental features
|
||||
pyInterpreterEnabled: false,
|
||||
enableContinueGeneration: false
|
||||
|
|
@ -85,6 +87,7 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
|||
dry_penalty_last_n:
|
||||
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
|
||||
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
|
||||
maxToolCalls: 'Maximum number of tool calls allowed per message. Prevents infinite tool calling loops. Use -1 for unlimited.',
|
||||
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
|
||||
showThoughtInProgress: 'Expand thought process by default when generating messages.',
|
||||
showToolCalls:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
// [AI] MCP Bridge - Manages MCP server processes via stdio JSON-RPC
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { MCPServerStatus } from '../types';
|
||||
import type {
|
||||
MCPServer,
|
||||
MCPTool,
|
||||
MCPToolExecutionRequest,
|
||||
MCPToolExecutionResult
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* MCPBridge - Manages MCP server processes and communication
|
||||
*
|
||||
* This class handles:
|
||||
* - Spawning/stopping MCP server processes
|
||||
* - stdio-based JSON-RPC communication with MCP servers
|
||||
* - Tool schema fetching and caching
|
||||
* - Tool execution routing
|
||||
*
|
||||
* Architecture:
|
||||
* - MCP servers run as child processes
|
||||
* - Communication via stdio using JSON-RPC
|
||||
* - Each server provides tools via list_tools method
|
||||
* - Tool execution routed to appropriate server
|
||||
*/
|
||||
export class MCPBridge {
|
||||
private processes = new Map<string, ChildProcess>();
|
||||
private servers = new Map<string, MCPServer>();
|
||||
private toolCache = new Map<string, MCPTool[]>();
|
||||
private messageId = 1;
|
||||
private pendingRequests = new Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
|
||||
constructor() {
|
||||
// Initialize with default servers (can be configured later)
|
||||
this.loadDefaultServers();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server Management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private loadDefaultServers(): void {
|
||||
// Load MCP servers from config (for now, empty - will be configured via UI)
|
||||
const defaultServers: MCPServer[] = [];
|
||||
|
||||
for (const server of defaultServers) {
|
||||
this.servers.set(server.id, server);
|
||||
}
|
||||
}
|
||||
|
||||
async listServers(): Promise<MCPServer[]> {
|
||||
return Array.from(this.servers.values());
|
||||
}
|
||||
|
||||
async addServer(server: Omit<MCPServer, 'status'>): Promise<MCPServer> {
|
||||
console.log("[MCPBridge] addServer called:", server.id, "enabled:", server.enabled);
|
||||
const newServer: MCPServer = {
|
||||
...server,
|
||||
status: MCPServerStatus.STOPPED
|
||||
};
|
||||
|
||||
this.servers.set(server.id, newServer);
|
||||
|
||||
if (server.enabled) {
|
||||
console.log("[MCPBridge] Starting server:", server.id);
|
||||
await this.startServer(server.id);
|
||||
}
|
||||
|
||||
return newServer;
|
||||
}
|
||||
|
||||
async updateServer(serverId: string, updates: Partial<MCPServer>): Promise<MCPServer> {
|
||||
const server = this.servers.get(serverId);
|
||||
if (!server) {
|
||||
throw new Error(`Server ${serverId} not found`);
|
||||
}
|
||||
|
||||
const wasEnabled = server.enabled;
|
||||
const updatedServer = { ...server, ...updates };
|
||||
this.servers.set(serverId, updatedServer);
|
||||
|
||||
// Handle enable/disable state changes
|
||||
if (wasEnabled && !updatedServer.enabled) {
|
||||
await this.stopServer(serverId);
|
||||
} else if (!wasEnabled && updatedServer.enabled) {
|
||||
await this.startServer(serverId);
|
||||
}
|
||||
|
||||
return updatedServer;
|
||||
}
|
||||
|
||||
async removeServer(serverId: string): Promise<void> {
|
||||
await this.stopServer(serverId);
|
||||
this.servers.delete(serverId);
|
||||
this.toolCache.delete(serverId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Process Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async startServer(serverId: string): Promise<void> {
|
||||
const server = this.servers.get(serverId);
|
||||
if (!server) {
|
||||
throw new Error(`Server ${serverId} not found`);
|
||||
}
|
||||
|
||||
if (this.processes.has(serverId)) {
|
||||
console.warn(`Server ${serverId} already running`);
|
||||
return;
|
||||
}
|
||||
|
||||
server.status = MCPServerStatus.STARTING;
|
||||
|
||||
try {
|
||||
const childProcess = spawn(server.command, server.args, {
|
||||
env: { ...process.env, ...server.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
this.processes.set(serverId, childProcess);
|
||||
|
||||
// Handle stdout (JSON-RPC responses)
|
||||
let buffer = '';
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
this.handleMessage(serverId, message);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse message from ${serverId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr (logging)
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
console.error(`[${serverId}] ${data.toString()}`);
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code) => {
|
||||
console.log(`Server ${serverId} exited with code ${code}`);
|
||||
this.processes.delete(serverId);
|
||||
server.status = code === 0 ? MCPServerStatus.STOPPED : MCPServerStatus.ERROR;
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
console.error(`Server ${serverId} error:`, error);
|
||||
server.status = MCPServerStatus.ERROR;
|
||||
this.processes.delete(serverId);
|
||||
});
|
||||
|
||||
// Initialize connection and fetch tools
|
||||
await this.initializeServer(serverId);
|
||||
|
||||
server.status = MCPServerStatus.RUNNING;
|
||||
} catch (error) {
|
||||
server.status = MCPServerStatus.ERROR;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async stopServer(serverId: string): Promise<void> {
|
||||
const childProcess = this.processes.get(serverId);
|
||||
if (!childProcess) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
childProcess.on('exit', () => {
|
||||
this.processes.delete(serverId);
|
||||
const server = this.servers.get(serverId);
|
||||
if (server) {
|
||||
server.status = MCPServerStatus.STOPPED;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
childProcess.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.processes.has(serverId)) {
|
||||
childProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JSON-RPC Communication
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async sendRequest(serverId: string, method: string, params?: unknown): Promise<unknown> {
|
||||
const childProcess = this.processes.get(serverId);
|
||||
if (!childProcess) {
|
||||
throw new Error(`Server ${serverId} not running`);
|
||||
}
|
||||
|
||||
const id = this.messageId++;
|
||||
const request = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
...(params && { params })
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
childProcess.stdin?.write(JSON.stringify(request) + '\n');
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout for ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(serverId: string, message: {
|
||||
jsonrpc: string;
|
||||
id?: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}): void {
|
||||
if (message.id === undefined) return;
|
||||
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (!pending) return;
|
||||
|
||||
this.pendingRequests.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP Protocol Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async initializeServer(serverId: string): Promise<void> {
|
||||
// Send initialize request
|
||||
await this.sendRequest(serverId, 'initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'llama.cpp-webui',
|
||||
version: '1.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch available tools
|
||||
await this.fetchTools(serverId);
|
||||
}
|
||||
|
||||
private async fetchTools(serverId: string): Promise<void> {
|
||||
const result = await this.sendRequest(serverId, 'tools/list') as { tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}>};
|
||||
|
||||
const tools: MCPTool[] = result.tools.map(tool => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
},
|
||||
serverId
|
||||
}));
|
||||
|
||||
this.toolCache.set(serverId, tools);
|
||||
}
|
||||
|
||||
async listTools(): Promise<MCPTool[]> {
|
||||
const allTools: MCPTool[] = [];
|
||||
|
||||
for (const server of this.servers.values()) {
|
||||
if (server.enabled && server.status === MCPServerStatus.RUNNING) {
|
||||
const tools = this.toolCache.get(server.id) || [];
|
||||
allTools.push(...tools);
|
||||
}
|
||||
}
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
async executeTool(request: MCPToolExecutionRequest): Promise<MCPToolExecutionResult> {
|
||||
const { serverId, toolName, arguments: args } = request;
|
||||
|
||||
try {
|
||||
const result = await this.sendRequest(serverId, 'tools/call', {
|
||||
name: toolName,
|
||||
arguments: args
|
||||
}) as { content: Array<{ type: string; text: string }> };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: result.content[0]?.text || JSON.stringify(result)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cleanup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const stopPromises = Array.from(this.servers.keys()).map(id => this.stopServer(id));
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
// [AI] MCP Bridge - Manages MCP server processes via stdio JSON-RPC
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import type {
|
||||
MCPServer,
|
||||
MCPServerStatus,
|
||||
MCPTool,
|
||||
MCPToolExecutionRequest,
|
||||
MCPToolExecutionResult
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* MCPBridge - Manages MCP server processes and communication
|
||||
*
|
||||
* This class handles:
|
||||
* - Spawning/stopping MCP server processes
|
||||
* - stdio-based JSON-RPC communication with MCP servers
|
||||
* - Tool schema fetching and caching
|
||||
* - Tool execution routing
|
||||
*
|
||||
* Architecture:
|
||||
* - MCP servers run as child processes
|
||||
* - Communication via stdio using JSON-RPC
|
||||
* - Each server provides tools via list_tools method
|
||||
* - Tool execution routed to appropriate server
|
||||
*/
|
||||
export class MCPBridge {
|
||||
private processes = new Map<string, ChildProcess>();
|
||||
private servers = new Map<string, MCPServer>();
|
||||
private toolCache = new Map<string, MCPTool[]>();
|
||||
private messageId = 1;
|
||||
private pendingRequests = new Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
|
||||
constructor() {
|
||||
// Initialize with default servers (can be configured later)
|
||||
this.loadDefaultServers();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server Management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private loadDefaultServers(): void {
|
||||
// Load MCP servers from config (for now, empty - will be configured via UI)
|
||||
const defaultServers: MCPServer[] = [];
|
||||
|
||||
for (const server of defaultServers) {
|
||||
this.servers.set(server.id, server);
|
||||
}
|
||||
}
|
||||
|
||||
async listServers(): Promise<MCPServer[]> {
|
||||
return Array.from(this.servers.values());
|
||||
}
|
||||
|
||||
async addServer(server: Omit<MCPServer, 'status'>): Promise<MCPServer> {
|
||||
const newServer: MCPServer = {
|
||||
...server,
|
||||
status: MCPServerStatus.STOPPED
|
||||
};
|
||||
|
||||
this.servers.set(server.id, newServer);
|
||||
|
||||
if (server.enabled) {
|
||||
await this.startServer(server.id);
|
||||
}
|
||||
|
||||
return newServer;
|
||||
}
|
||||
|
||||
async updateServer(serverId: string, updates: Partial<MCPServer>): Promise<MCPServer> {
|
||||
const server = this.servers.get(serverId);
|
||||
if (!server) {
|
||||
throw new Error(`Server ${serverId} not found`);
|
||||
}
|
||||
|
||||
const wasEnabled = server.enabled;
|
||||
const updatedServer = { ...server, ...updates };
|
||||
this.servers.set(serverId, updatedServer);
|
||||
|
||||
// Handle enable/disable state changes
|
||||
if (wasEnabled && !updatedServer.enabled) {
|
||||
await this.stopServer(serverId);
|
||||
} else if (!wasEnabled && updatedServer.enabled) {
|
||||
await this.startServer(serverId);
|
||||
}
|
||||
|
||||
return updatedServer;
|
||||
}
|
||||
|
||||
async removeServer(serverId: string): Promise<void> {
|
||||
await this.stopServer(serverId);
|
||||
this.servers.delete(serverId);
|
||||
this.toolCache.delete(serverId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Process Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async startServer(serverId: string): Promise<void> {
|
||||
const server = this.servers.get(serverId);
|
||||
if (!server) {
|
||||
throw new Error(`Server ${serverId} not found`);
|
||||
}
|
||||
|
||||
if (this.processes.has(serverId)) {
|
||||
console.warn(`Server ${serverId} already running`);
|
||||
return;
|
||||
}
|
||||
|
||||
server.status = MCPServerStatus.STARTING;
|
||||
|
||||
try {
|
||||
const process = spawn(server.command, server.args, {
|
||||
env: { ...process.env, ...server.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
this.processes.set(serverId, process);
|
||||
|
||||
// Handle stdout (JSON-RPC responses)
|
||||
let buffer = '';
|
||||
process.stdout?.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
this.handleMessage(serverId, message);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse message from ${serverId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr (logging)
|
||||
process.stderr?.on('data', (data) => {
|
||||
console.error(`[${serverId}] ${data.toString()}`);
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
process.on('exit', (code) => {
|
||||
console.log(`Server ${serverId} exited with code ${code}`);
|
||||
this.processes.delete(serverId);
|
||||
server.status = code === 0 ? MCPServerStatus.STOPPED : MCPServerStatus.ERROR;
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error(`Server ${serverId} error:`, error);
|
||||
server.status = MCPServerStatus.ERROR;
|
||||
this.processes.delete(serverId);
|
||||
});
|
||||
|
||||
// Initialize connection and fetch tools
|
||||
await this.initializeServer(serverId);
|
||||
|
||||
server.status = MCPServerStatus.RUNNING;
|
||||
} catch (error) {
|
||||
server.status = MCPServerStatus.ERROR;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async stopServer(serverId: string): Promise<void> {
|
||||
const process = this.processes.get(serverId);
|
||||
if (!process) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
process.on('exit', () => {
|
||||
this.processes.delete(serverId);
|
||||
const server = this.servers.get(serverId);
|
||||
if (server) {
|
||||
server.status = MCPServerStatus.STOPPED;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
process.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.processes.has(serverId)) {
|
||||
process.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JSON-RPC Communication
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async sendRequest(serverId: string, method: string, params?: unknown): Promise<unknown> {
|
||||
const process = this.processes.get(serverId);
|
||||
if (!process) {
|
||||
throw new Error(`Server ${serverId} not running`);
|
||||
}
|
||||
|
||||
const id = this.messageId++;
|
||||
const request = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
...(params && { params })
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
process.stdin?.write(JSON.stringify(request) + '\n');
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout for ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(serverId: string, message: {
|
||||
jsonrpc: string;
|
||||
id?: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}): void {
|
||||
if (message.id === undefined) return;
|
||||
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (!pending) return;
|
||||
|
||||
this.pendingRequests.delete(message.id);
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP Protocol Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async initializeServer(serverId: string): Promise<void> {
|
||||
// Send initialize request
|
||||
await this.sendRequest(serverId, 'initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'llama.cpp-webui',
|
||||
version: '1.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch available tools
|
||||
await this.fetchTools(serverId);
|
||||
}
|
||||
|
||||
private async fetchTools(serverId: string): Promise<void> {
|
||||
const result = await this.sendRequest(serverId, 'tools/list') as { tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}>};
|
||||
|
||||
const tools: MCPTool[] = result.tools.map(tool => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
},
|
||||
serverId
|
||||
}));
|
||||
|
||||
this.toolCache.set(serverId, tools);
|
||||
}
|
||||
|
||||
async listTools(): Promise<MCPTool[]> {
|
||||
const allTools: MCPTool[] = [];
|
||||
|
||||
for (const server of this.servers.values()) {
|
||||
if (server.enabled && server.status === MCPServerStatus.RUNNING) {
|
||||
const tools = this.toolCache.get(server.id) || [];
|
||||
allTools.push(...tools);
|
||||
}
|
||||
}
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
async executeTool(request: MCPToolExecutionRequest): Promise<MCPToolExecutionResult> {
|
||||
const { serverId, toolName, arguments: args } = request;
|
||||
|
||||
try {
|
||||
const result = await this.sendRequest(serverId, 'tools/call', {
|
||||
name: toolName,
|
||||
arguments: args
|
||||
}) as { content: Array<{ type: string; text: string }> };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: result.content[0]?.text || JSON.stringify(result)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cleanup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const stopPromises = Array.from(this.servers.keys()).map(id => this.stopServer(id));
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// [AI] Vite plugin for MCP integration - HTTP API middleware
|
||||
import type { Plugin } from 'vite';
|
||||
import { MCPBridge } from './mcp-bridge';
|
||||
import type {
|
||||
MCPServer,
|
||||
MCPServerListResponse,
|
||||
MCPToolsListResponse,
|
||||
MCPToolExecutionRequest,
|
||||
MCPToolExecutionResult
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Vite plugin for MCP server management
|
||||
*
|
||||
* Provides HTTP endpoints for MCP server and tool management:
|
||||
* - GET /mcp/servers - List all configured MCP servers
|
||||
* - POST /mcp/servers - Add a new MCP server
|
||||
* - PUT /mcp/servers/:id - Update MCP server configuration
|
||||
* - DELETE /mcp/servers/:id - Remove MCP server
|
||||
* - GET /mcp/tools - List all available tools from enabled servers
|
||||
* - POST /mcp/execute - Execute a tool call
|
||||
*/
|
||||
export function mcpPlugin(): Plugin {
|
||||
let bridge: MCPBridge | null = null;
|
||||
|
||||
return {
|
||||
name: 'llama.cpp:mcp',
|
||||
apply: 'serve' as const,
|
||||
|
||||
configureServer(server) {
|
||||
bridge = new MCPBridge();
|
||||
|
||||
// Cleanup on server close
|
||||
server.httpServer?.on('close', async () => {
|
||||
await bridge?.shutdown();
|
||||
});
|
||||
|
||||
// Add middleware for MCP endpoints
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url?.startsWith('/mcp/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
// GET /mcp/servers - List servers
|
||||
if (path === '/mcp/servers' && req.method === 'GET') {
|
||||
const servers = await bridge!.listServers();
|
||||
const response: MCPServerListResponse = { servers };
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /mcp/servers - Add server
|
||||
if (path === '/mcp/servers' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const serverConfig = JSON.parse(body) as Omit<MCPServer, 'status'>;
|
||||
const server = await bridge!.addServer(serverConfig);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(server));
|
||||
return;
|
||||
}
|
||||
|
||||
// PUT /mcp/servers/:id - Update server
|
||||
const updateMatch = path.match(/^\/mcp\/servers\/([^/]+)$/);
|
||||
if (updateMatch && req.method === 'PUT') {
|
||||
const serverId = decodeURIComponent(updateMatch[1]);
|
||||
const body = await readBody(req);
|
||||
const updates = JSON.parse(body) as Partial<MCPServer>;
|
||||
const server = await bridge!.updateServer(serverId, updates);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(server));
|
||||
return;
|
||||
}
|
||||
|
||||
// DELETE /mcp/servers/:id - Remove server
|
||||
const deleteMatch = path.match(/^\/mcp\/servers\/([^/]+)$/);
|
||||
if (deleteMatch && req.method === 'DELETE') {
|
||||
const serverId = decodeURIComponent(deleteMatch[1]);
|
||||
await bridge!.removeServer(serverId);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /mcp/tools - List tools
|
||||
if (path === '/mcp/tools' && req.method === 'GET') {
|
||||
const tools = await bridge!.listTools();
|
||||
const response: MCPToolsListResponse = { tools };
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /mcp/execute - Execute tool
|
||||
if (path === '/mcp/execute' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const request = JSON.parse(body) as MCPToolExecutionRequest;
|
||||
const result = await bridge!.executeTool(request);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown MCP endpoint
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not found' }));
|
||||
} catch (error) {
|
||||
console.error('MCP endpoint error:', error);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error'
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function readBody(req: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
|
@ -66,6 +66,10 @@ export class ChatService {
|
|||
// Generation parameters
|
||||
temperature,
|
||||
max_tokens,
|
||||
// [AI] Tool calling support
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
// Sampling parameters
|
||||
dynatemp_range,
|
||||
dynatemp_exponent,
|
||||
|
|
@ -115,7 +119,8 @@ export class ChatService {
|
|||
const requestBody: ApiChatCompletionRequest = {
|
||||
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
content: msg.content,
|
||||
...(msg.name && { name: msg.name })
|
||||
})),
|
||||
stream,
|
||||
return_progress: stream ? true : undefined
|
||||
|
|
@ -161,6 +166,13 @@ export class ChatService {
|
|||
|
||||
if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
|
||||
|
||||
// [AI] Include tool calling parameters if tools provided
|
||||
if (tools && tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
if (tool_choice !== undefined) requestBody.tool_choice = tool_choice;
|
||||
if (parallel_tool_calls !== undefined) requestBody.parallel_tool_calls = parallel_tool_calls;
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
try {
|
||||
const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
|
||||
|
|
@ -171,6 +183,13 @@ export class ChatService {
|
|||
}
|
||||
|
||||
try {
|
||||
// [AI] Log request for debugging
|
||||
console.log("[ChatService] Sending request to /v1/chat/completions:", {
|
||||
messages: requestBody.messages.map(m => ({ role: m.role, content: typeof m.content === "string" ? m.content.substring(0, 100) + "..." : "[multipart]" })),
|
||||
tools: requestBody.tools?.length || 0,
|
||||
tool_choice: requestBody.tool_choice,
|
||||
temperature: requestBody.temperature
|
||||
});
|
||||
const response = await fetch(`./v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: getJsonHeaders(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { ChatService } from './chat';
|
||||
export { DatabaseService } from './database';
|
||||
export { MCPService } from './mcp';
|
||||
export { ModelsService } from './models';
|
||||
export { PropsService } from './props';
|
||||
export { ParameterSyncService } from './parameter-sync';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
// [AI] MCP Service - Client-side service layer for MCP server management
|
||||
import { base } from '$app/paths';
|
||||
import { getJsonHeaders } from '$lib/utils';
|
||||
import type {
|
||||
MCPServer,
|
||||
MCPServerListResponse,
|
||||
MCPToolsListResponse,
|
||||
MCPTool,
|
||||
MCPToolExecutionRequest,
|
||||
MCPToolExecutionResult
|
||||
} from '$lib/types';
|
||||
|
||||
/**
|
||||
* MCPService - Stateless service for MCP server and tool management
|
||||
*
|
||||
* This service handles communication with MCP-related endpoints:
|
||||
* - `/mcp/servers` - MCP server configuration and management
|
||||
* - `/mcp/tools` - Tool listing from enabled servers
|
||||
* - `/mcp/execute` - Tool execution requests
|
||||
*
|
||||
* **Responsibilities:**
|
||||
* - List/add/update/remove MCP servers
|
||||
* - Fetch available tools from running servers
|
||||
* - Execute tool calls and return results
|
||||
*
|
||||
* **Used by:**
|
||||
* - mcpStore: Primary consumer for MCP state management
|
||||
* - chatStore: For tool calling during conversations
|
||||
*/
|
||||
export class MCPService {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server Management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all configured MCP servers
|
||||
*/
|
||||
static async listServers(): Promise<MCPServer[]> {
|
||||
const response = await fetch(`${base}/mcp/servers`, {
|
||||
headers: getJsonHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch MCP servers (status ${response.status})`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MCPServerListResponse;
|
||||
return data.servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new MCP server
|
||||
* @param server - Server configuration (without status field)
|
||||
*/
|
||||
static async addServer(server: Omit<MCPServer, 'status'>): Promise<MCPServer> {
|
||||
const response = await fetch(`${base}/mcp/servers`, {
|
||||
method: 'POST',
|
||||
headers: getJsonHeaders(),
|
||||
body: JSON.stringify(server)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Failed to add MCP server (status ${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<MCPServer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing MCP server
|
||||
* @param serverId - Server identifier
|
||||
* @param updates - Partial server configuration to update
|
||||
*/
|
||||
static async updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<MCPServer>
|
||||
): Promise<MCPServer> {
|
||||
const response = await fetch(`${base}/mcp/servers/${encodeURIComponent(serverId)}`, {
|
||||
method: 'PUT',
|
||||
headers: getJsonHeaders(),
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error || `Failed to update MCP server (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<MCPServer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MCP server
|
||||
* @param serverId - Server identifier
|
||||
*/
|
||||
static async removeServer(serverId: string): Promise<void> {
|
||||
const response = await fetch(`${base}/mcp/servers/${encodeURIComponent(serverId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getJsonHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error || `Failed to remove MCP server (status ${response.status})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tool Management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all available tools from enabled MCP servers
|
||||
* Returns tools in OpenAI-compatible format
|
||||
*/
|
||||
static async listTools(): Promise<MCPTool[]> {
|
||||
const response = await fetch(`${base}/mcp/tools`, {
|
||||
headers: getJsonHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch MCP tools (status ${response.status})`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MCPToolsListResponse;
|
||||
return data.tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool call on an MCP server
|
||||
* @param request - Tool execution request with serverId, toolName, and arguments
|
||||
*/
|
||||
static async executeTool(request: MCPToolExecutionRequest): Promise<MCPToolExecutionResult> {
|
||||
const response = await fetch(`${base}/mcp/execute`, {
|
||||
method: 'POST',
|
||||
headers: getJsonHeaders(),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error || `Failed to execute tool (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<MCPToolExecutionResult>;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { DatabaseService, ChatService } from '$lib/services';
|
||||
import { DatabaseService, ChatService, MCPService } from '$lib/services';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
modelsStore,
|
||||
selectedModelContextSize
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
normalizeModelName,
|
||||
filterByLeafNodeId,
|
||||
|
|
@ -16,6 +17,15 @@ import {
|
|||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
|
||||
|
||||
// [AI] Tool execution state tracking type
|
||||
interface ToolExecution {
|
||||
messageId: string;
|
||||
toolName: string;
|
||||
status: 'pending' | 'executing' | 'completed' | 'error';
|
||||
result?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* chatStore - Active AI interaction and streaming state management
|
||||
*
|
||||
|
|
@ -76,7 +86,10 @@ class ChatStore {
|
|||
private isStreamingActive = $state(false);
|
||||
private isEditModeActive = $state(false);
|
||||
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
|
||||
|
||||
// [AI] Tool execution state tracking for real-time UI indicators
|
||||
activeToolExecutions = $state<ToolExecution[]>([]);
|
||||
private toolExecutionCleanupTimeouts = new Map<string, number>();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Loading State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -253,6 +266,7 @@ class ChatStore {
|
|||
*/
|
||||
stopStreaming(): void {
|
||||
this.isStreamingActive = false;
|
||||
this.clearToolExecutionTimeouts();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -262,6 +276,16 @@ class ChatStore {
|
|||
return this.isStreamingActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* [AI] Clear all pending tool execution cleanup timeouts
|
||||
*/
|
||||
private clearToolExecutionTimeouts(): void {
|
||||
for (const timeoutId of this.toolExecutionCleanupTimeouts.values()) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
this.toolExecutionCleanupTimeouts.clear();
|
||||
}
|
||||
|
||||
private getContextTotal(): number {
|
||||
const activeState = this.getActiveProcessingState();
|
||||
|
||||
|
|
@ -475,6 +499,172 @@ class ChatStore {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [AI] Handle tool execution from assistant tool calls
|
||||
* Executes tools, creates tool result messages, and continues conversation
|
||||
*/
|
||||
private async handleToolExecution(
|
||||
toolCallsJson: string,
|
||||
assistantMessage: DatabaseMessage
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Parse tool calls from JSON
|
||||
const toolCalls = JSON.parse(toolCallsJson) as Array<{
|
||||
id?: string;
|
||||
type: string;
|
||||
function?: {
|
||||
name: string;
|
||||
arguments?: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) return;
|
||||
|
||||
// [AI] Cache tools list once to avoid redundant API calls
|
||||
const availableTools = await MCPService.listTools();
|
||||
|
||||
// Execute each tool call
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!toolCall.function?.name) continue;
|
||||
|
||||
const toolName = toolCall.function.name;
|
||||
const argsJson = toolCall.function.arguments || '{}';
|
||||
let args: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
args = JSON.parse(argsJson);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse tool arguments:', error);
|
||||
args = {};
|
||||
}
|
||||
|
||||
// Add to active tool executions
|
||||
this.activeToolExecutions = [
|
||||
...this.activeToolExecutions,
|
||||
{ messageId: assistantMessage.id, toolName, status: 'pending' }
|
||||
];
|
||||
|
||||
try {
|
||||
// Determine server ID from tool name using cached list
|
||||
const matchingTool = availableTools.find((t) => t.function.name === toolName);
|
||||
|
||||
if (!matchingTool) {
|
||||
console.error(`Tool ${toolName} not found in available MCP tools`);
|
||||
// Update status to error
|
||||
this.activeToolExecutions = this.activeToolExecutions.map((t) =>
|
||||
t.messageId === assistantMessage.id && t.toolName === toolName
|
||||
? { ...t, status: 'error' as const, error: 'Tool not found' }
|
||||
: t
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update status to executing
|
||||
this.activeToolExecutions = this.activeToolExecutions.map((t) =>
|
||||
t.messageId === assistantMessage.id && t.toolName === toolName
|
||||
? { ...t, status: 'executing' as const }
|
||||
: t
|
||||
);
|
||||
|
||||
// Execute the tool
|
||||
const result = await MCPService.executeTool({
|
||||
serverId: matchingTool.serverId,
|
||||
toolName,
|
||||
arguments: args
|
||||
});
|
||||
|
||||
// Create tool result message
|
||||
const toolResultContent = result.success
|
||||
? typeof result.result === 'string'
|
||||
? result.result
|
||||
: JSON.stringify(result.result)
|
||||
: `Error: ${result.error}`;
|
||||
|
||||
// Update status to completed or error
|
||||
this.activeToolExecutions = this.activeToolExecutions.map((t) =>
|
||||
t.messageId === assistantMessage.id && t.toolName === toolName
|
||||
? {
|
||||
...t,
|
||||
status: (result.success ? 'completed' : 'error') as const,
|
||||
result: result.success ? toolResultContent : undefined,
|
||||
error: result.success ? undefined : result.error
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
// Add tool result as a new message
|
||||
const toolMessage = await DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId: assistantMessage.convId,
|
||||
type: 'text',
|
||||
role: 'tool',
|
||||
name: toolName,
|
||||
content: toolResultContent,
|
||||
timestamp: Date.now(),
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
children: [],
|
||||
model: null
|
||||
},
|
||||
assistantMessage.id
|
||||
);
|
||||
|
||||
if (toolMessage) {
|
||||
conversationsStore.addMessageToActive(toolMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
// Update status to error
|
||||
this.activeToolExecutions = this.activeToolExecutions.map((t) =>
|
||||
t.messageId === assistantMessage.id && t.toolName === toolName
|
||||
? {
|
||||
...t,
|
||||
status: 'error' as const,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
: t
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [AI] Clear completed tool executions after delay (keep errors visible)
|
||||
// Cancel any existing cleanup timeout for this message
|
||||
const existingTimeout = this.toolExecutionCleanupTimeouts.get(assistantMessage.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Schedule cleanup of completed executions only
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.activeToolExecutions = this.activeToolExecutions.filter(
|
||||
(t) => !(t.messageId === assistantMessage.id && t.status === 'completed')
|
||||
);
|
||||
this.toolExecutionCleanupTimeouts.delete(assistantMessage.id);
|
||||
}, 3000);
|
||||
|
||||
this.toolExecutionCleanupTimeouts.set(assistantMessage.id, timeoutId as unknown as number);
|
||||
|
||||
// After all tools executed, create new assistant message to generate final response
|
||||
const continuationMessage = await this.createAssistantMessage(
|
||||
conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]?.id
|
||||
);
|
||||
|
||||
if (!continuationMessage) {
|
||||
throw new Error('Failed to create continuation message');
|
||||
}
|
||||
|
||||
conversationsStore.addMessageToActive(continuationMessage);
|
||||
|
||||
// Continue conversation with tool results included
|
||||
await this.streamChatCompletion(
|
||||
conversationsStore.activeMessages.slice(0, -1),
|
||||
continuationMessage
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Tool execution error:', error);
|
||||
// Don't throw - conversation can continue even if tools fail
|
||||
}
|
||||
}
|
||||
|
||||
private async streamChatCompletion(
|
||||
allMessages: DatabaseMessage[],
|
||||
assistantMessage: DatabaseMessage,
|
||||
|
|
@ -588,6 +778,15 @@ class ChatStore {
|
|||
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
|
||||
await conversationsStore.updateCurrentNode(assistantMessage.id);
|
||||
|
||||
// Handle tool calls if present
|
||||
const finalToolCalls = toolCallContent || streamedToolCallContent;
|
||||
if (finalToolCalls && finalToolCalls.trim()) {
|
||||
// Execute tools and continue conversation
|
||||
await this.handleToolExecution(finalToolCalls, assistantMessage);
|
||||
// Don't clear loading state yet - handleToolExecution will trigger another completion
|
||||
return;
|
||||
}
|
||||
|
||||
if (onComplete) await onComplete(streamedContent);
|
||||
this.setChatLoading(assistantMessage.convId, false);
|
||||
this.clearChatStreaming(assistantMessage.convId);
|
||||
|
|
@ -1463,6 +1662,14 @@ class ChatStore {
|
|||
if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
|
||||
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
|
||||
|
||||
// Include MCP tools if available
|
||||
console.log("MCP tools check:", { hasTools: mcpStore.hasTools(), toolCount: mcpStore.tools.length, tools: mcpStore.getToolsForAPI() });
|
||||
if (mcpStore.hasTools()) {
|
||||
apiOptions.tools = mcpStore.getToolsForAPI();
|
||||
apiOptions.tool_choice = 'auto';
|
||||
apiOptions.parallel_tool_calls = true;
|
||||
}
|
||||
|
||||
return apiOptions;
|
||||
}
|
||||
}
|
||||
|
|
@ -1470,6 +1677,8 @@ class ChatStore {
|
|||
export const chatStore = new ChatStore();
|
||||
|
||||
export const activeProcessingState = () => chatStore.activeProcessingState;
|
||||
// [AI] Export tool execution state accessor
|
||||
export const activeToolExecutions = () => chatStore.activeToolExecutions;
|
||||
export const clearEditMode = () => chatStore.clearEditMode();
|
||||
export const currentResponse = () => chatStore.currentResponse;
|
||||
export const errorDialog = () => chatStore.errorDialogState;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
// [AI] MCP Store - Reactive Svelte 5 store for MCP servers and tools
|
||||
import { MCPService } from '$lib/services';
|
||||
import type { MCPServer, MCPTool } from '$lib/types';
|
||||
import { MCPServerStatus } from '$lib/types';
|
||||
|
||||
const MCP_SERVERS_STORAGE_KEY = 'mcp_servers';
|
||||
|
||||
/**
|
||||
* mcpStore - MCP server and tool state management
|
||||
*
|
||||
* Manages:
|
||||
* - List of configured MCP servers
|
||||
* - Available tools from enabled servers
|
||||
* - Server enable/disable state
|
||||
* - Persistence to localStorage
|
||||
*/
|
||||
class MCPStore {
|
||||
servers = $state<MCPServer[]>([]);
|
||||
tools = $state<MCPTool[]>([]);
|
||||
isLoading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Load servers from localStorage on construction
|
||||
this.loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load servers from localStorage
|
||||
*/
|
||||
private loadFromLocalStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(MCP_SERVERS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Array<Omit<MCPServer, 'status'>>;
|
||||
// Don't set servers directly - they'll be synced with backend during initialize
|
||||
// Just store them for use during initialization
|
||||
this.pendingServers = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load MCP servers from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save servers to localStorage
|
||||
*/
|
||||
private saveToLocalStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Store servers without status (status is runtime only)
|
||||
const toStore = this.servers.map(({ status, ...rest }) => rest);
|
||||
localStorage.setItem(MCP_SERVERS_STORAGE_KEY, JSON.stringify(toStore));
|
||||
} catch (error) {
|
||||
console.error('Failed to save MCP servers to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private pendingServers: Array<Omit<MCPServer, 'status'>> = [];
|
||||
|
||||
/**
|
||||
* Initialize store by syncing with backend and loading persisted servers
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log("[MCPStore] Initializing, pending servers:", this.pendingServers.length);
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// First, restore any persisted servers to the backend
|
||||
if (this.pendingServers.length > 0) {
|
||||
// Fetch existing servers from backend to avoid duplicates
|
||||
const existingServers = await MCPService.listServers();
|
||||
const existingIds = new Set(existingServers.map(s => s.id));
|
||||
|
||||
for (const server of this.pendingServers) {
|
||||
try {
|
||||
// Only add if server doesn't already exist in backend
|
||||
if (!existingIds.has(server.id)) {
|
||||
await MCPService.addServer(server);
|
||||
} else {
|
||||
console.log('[MCPStore] Server already exists in backend:', server.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// Server might already exist, that's okay
|
||||
console.warn('Failed to restore server:', server.id, error);
|
||||
}
|
||||
}
|
||||
console.log("[MCPStore] Restored servers from localStorage");
|
||||
this.pendingServers = [];
|
||||
}
|
||||
|
||||
// Then fetch current state from backend
|
||||
await this.fetchServers();
|
||||
console.log("[MCPStore] Fetched servers from backend:", this.servers.length, "servers");
|
||||
|
||||
// [AI] Ensure all enabled servers are running (handles page reload case)
|
||||
for (const server of this.servers) {
|
||||
if (server.enabled && server.status !== 'running' && server.status !== 'starting') {
|
||||
console.log('[MCPStore] Starting enabled server after reload:', server.id);
|
||||
try {
|
||||
await MCPService.updateServer(server.id, { enabled: true });
|
||||
} catch (error) {
|
||||
console.error('[MCPStore] Failed to start server:', server.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.fetchTools();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : 'Failed to initialize MCP';
|
||||
console.error('MCP initialization error:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of configured MCP servers
|
||||
*/
|
||||
async fetchServers(): Promise<void> {
|
||||
try {
|
||||
this.servers = await MCPService.listServers();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MCP servers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available tools from enabled servers
|
||||
*/
|
||||
async fetchTools(): Promise<void> {
|
||||
try {
|
||||
console.log("Fetching MCP tools...");
|
||||
this.tools = await MCPService.listTools();
|
||||
console.log("Fetched MCP tools:", this.tools);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MCP tools:', error);
|
||||
// Don't throw - tools list can be empty
|
||||
this.tools = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new MCP server
|
||||
*/
|
||||
async addServer(server: Omit<MCPServer, 'status'>): Promise<void> {
|
||||
try {
|
||||
const newServer = await MCPService.addServer(server);
|
||||
this.servers = [...this.servers, newServer];
|
||||
this.saveToLocalStorage();
|
||||
|
||||
if (newServer.enabled) {
|
||||
await this.fetchTools();
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : 'Failed to add server';
|
||||
console.error('Failed to add MCP server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing MCP server
|
||||
*/
|
||||
async updateServer(serverId: string, updates: Partial<MCPServer>): Promise<void> {
|
||||
try {
|
||||
const updatedServer = await MCPService.updateServer(serverId, updates);
|
||||
this.servers = this.servers.map((s) => (s.id === serverId ? updatedServer : s));
|
||||
this.saveToLocalStorage();
|
||||
|
||||
// Refetch tools if enabled state changed
|
||||
if ('enabled' in updates) {
|
||||
await this.fetchTools();
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : 'Failed to update server';
|
||||
console.error('Failed to update MCP server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MCP server
|
||||
*/
|
||||
async removeServer(serverId: string): Promise<void> {
|
||||
try {
|
||||
await MCPService.removeServer(serverId);
|
||||
this.servers = this.servers.filter((s) => s.id !== serverId);
|
||||
this.saveToLocalStorage();
|
||||
await this.fetchTools();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : 'Failed to remove server';
|
||||
console.error('Failed to remove MCP server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools in OpenAI-compatible format for chat completion requests
|
||||
*/
|
||||
getToolsForAPI(): Array<{ type: 'function'; function: { name: string; description?: string; parameters?: Record<string, unknown> } }> {
|
||||
return this.tools.map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any servers are enabled
|
||||
*/
|
||||
hasEnabledServers(): boolean {
|
||||
return this.servers.some((s) => s.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tools are available
|
||||
*/
|
||||
hasTools(): boolean {
|
||||
return this.tools.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpStore = new MCPStore();
|
||||
|
|
@ -35,6 +35,8 @@ export interface ApiChatMessageData {
|
|||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
timestamp?: number;
|
||||
// [AI] Tool message name field
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,6 +217,20 @@ export interface ApiChatCompletionRequest {
|
|||
// Custom parameters (JSON string)
|
||||
custom?: Record<string, unknown>;
|
||||
timings_per_token?: boolean;
|
||||
// [AI] Tool calling support
|
||||
tools?: ApiChatCompletionTool[];
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
parallel_tool_calls?: boolean;
|
||||
}
|
||||
|
||||
// [AI] OpenAI-compatible tool definition
|
||||
export interface ApiChatCompletionTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionToolCallFunctionDelta {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
|
||||
export type ChatRole = 'user' | 'assistant' | 'system';
|
||||
export type ChatRole = 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
export interface ChatUploadedFile {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type {
|
|||
ApiModelListResponse,
|
||||
ApiLlamaCppServerProps,
|
||||
ApiChatCompletionRequest,
|
||||
ApiChatCompletionTool,
|
||||
ApiChatCompletionToolCallFunctionDelta,
|
||||
ApiChatCompletionToolCallDelta,
|
||||
ApiChatCompletionToolCall,
|
||||
|
|
@ -68,3 +69,15 @@ export type {
|
|||
SettingsChatServiceOptions,
|
||||
SettingsConfigType
|
||||
} from './settings';
|
||||
|
||||
// [AI] MCP types
|
||||
export type {
|
||||
MCPServer,
|
||||
MCPServerStatus,
|
||||
MCPTool,
|
||||
MCPToolExecutionRequest,
|
||||
MCPToolExecutionResult,
|
||||
MCPServerListResponse,
|
||||
MCPToolsListResponse
|
||||
} from './mcp';
|
||||
export { MCPServerStatus } from './mcp';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
// [AI] MCP type definitions for Model Context Protocol integration
|
||||
import type { ApiChatCompletionTool } from './api';
|
||||
|
||||
/**
|
||||
* MCP Server configuration
|
||||
*/
|
||||
export interface MCPServer {
|
||||
/** Unique identifier for the server */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Command to execute (e.g., "python", "node") */
|
||||
command: string;
|
||||
/** Arguments to pass (e.g., ["path/to/server.py"]) */
|
||||
args: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Whether the server is currently enabled */
|
||||
enabled: boolean;
|
||||
/** Current connection status */
|
||||
status: MCPServerStatus;
|
||||
}
|
||||
|
||||
export enum MCPServerStatus {
|
||||
STOPPED = 'stopped',
|
||||
STARTING = 'starting',
|
||||
RUNNING = 'running',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Tool (converted from MCP tool schema to OpenAI format)
|
||||
*/
|
||||
export interface MCPTool extends ApiChatCompletionTool {
|
||||
/** Server ID that provides this tool */
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Tool execution request
|
||||
*/
|
||||
export interface MCPToolExecutionRequest {
|
||||
serverId: string;
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Tool execution result
|
||||
*/
|
||||
export interface MCPToolExecutionResult {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Server list response from backend
|
||||
*/
|
||||
export interface MCPServerListResponse {
|
||||
servers: MCPServer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Tools list response from backend
|
||||
*/
|
||||
export interface MCPToolsListResponse {
|
||||
tools: MCPTool[];
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import type { ChatMessageTimings } from './chat';
|
||||
import type { ChatMessageTimings, ChatMessagePromptProgress } from './chat';
|
||||
import type { ApiChatCompletionTool } from './api';
|
||||
|
||||
export type SettingsConfigValue = string | number | boolean;
|
||||
|
||||
|
|
@ -46,6 +47,10 @@ export interface SettingsChatServiceOptions {
|
|||
// Custom parameters
|
||||
custom?: string;
|
||||
timings_per_token?: boolean;
|
||||
// [AI] Tool calling
|
||||
tools?: ApiChatCompletionTool[];
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
parallel_tool_calls?: boolean;
|
||||
// Callbacks
|
||||
onChunk?: (chunk: string) => void;
|
||||
onReasoningChunk?: (chunk: string) => void;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import { Toaster } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
|
||||
|
|
@ -141,6 +142,20 @@
|
|||
}
|
||||
});
|
||||
|
||||
// [AI] Initialize MCP store on app load (run once)
|
||||
let mcpInitialized = false;
|
||||
|
||||
$effect(() => {
|
||||
if (!mcpInitialized) {
|
||||
mcpInitialized = true;
|
||||
untrack(() => {
|
||||
mcpStore.initialize().catch((error) => {
|
||||
console.error('MCP initialization failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor API key changes and redirect to error page if removed or changed when required
|
||||
$effect(() => {
|
||||
const apiKey = config().apiKey;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { resolve } from 'path';
|
|||
import { defineConfig } from 'vite';
|
||||
import devtoolsJson from 'vite-plugin-devtools-json';
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
// [AI] MCP integration plugin
|
||||
import { mcpPlugin } from './src/lib/server/vite-plugin-mcp';
|
||||
|
||||
const GUIDE_FOR_FRONTEND = `
|
||||
<!--
|
||||
|
|
@ -105,7 +107,7 @@ export default defineConfig({
|
|||
}
|
||||
}
|
||||
},
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson(), llamaCppBuildPlugin()],
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson(), mcpPlugin(), llamaCppBuildPlugin()],
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue