|
|
import React, { |
|
|
useState, |
|
|
useEffect, |
|
|
useCallback, |
|
|
useRef, |
|
|
useMemo, |
|
|
} from "react"; |
|
|
import { openDB, type IDBPDatabase } from "idb"; |
|
|
import { |
|
|
Play, |
|
|
Plus, |
|
|
Zap, |
|
|
RotateCcw, |
|
|
Settings, |
|
|
X, |
|
|
PanelRightClose, |
|
|
PanelRightOpen, |
|
|
} from "lucide-react"; |
|
|
import { useLLM } from "./hooks/useLLM"; |
|
|
import { useMCP } from "./hooks/useMCP"; |
|
|
|
|
|
import type { Tool } from "./components/ToolItem"; |
|
|
|
|
|
import { |
|
|
parsePythonicCalls, |
|
|
extractPythonicCalls, |
|
|
extractFunctionAndRenderer, |
|
|
generateSchemaFromCode, |
|
|
extractToolCallContent, |
|
|
mapArgsToNamedParams, |
|
|
getErrorMessage, |
|
|
isMobileOrTablet, |
|
|
} from "./utils"; |
|
|
|
|
|
import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt"; |
|
|
import { DB_NAME, STORE_NAME, SETTINGS_STORE_NAME } from "./constants/db"; |
|
|
|
|
|
import { DEFAULT_TOOLS, TEMPLATE } from "./tools"; |
|
|
import ToolResultRenderer from "./components/ToolResultRenderer"; |
|
|
import ToolCallIndicator from "./components/ToolCallIndicator"; |
|
|
import ToolItem from "./components/ToolItem"; |
|
|
import ResultBlock from "./components/ResultBlock"; |
|
|
import ExamplePrompts from "./components/ExamplePrompts"; |
|
|
import { MCPServerManager } from "./components/MCPServerManager"; |
|
|
|
|
|
import { LoadingScreen } from "./components/LoadingScreen"; |
|
|
|
|
|
interface RenderInfo { |
|
|
call: string; |
|
|
result?: unknown; |
|
|
renderer?: string; |
|
|
input?: Record<string, unknown>; |
|
|
error?: string; |
|
|
} |
|
|
|
|
|
interface BaseMessage { |
|
|
role: "system" | "user" | "assistant"; |
|
|
content: string; |
|
|
} |
|
|
interface ToolMessage { |
|
|
role: "tool"; |
|
|
content: string; |
|
|
renderInfo: RenderInfo[]; |
|
|
} |
|
|
type Message = BaseMessage | ToolMessage; |
|
|
|
|
|
async function getDB(): Promise<IDBPDatabase> { |
|
|
return openDB(DB_NAME, 1, { |
|
|
upgrade(db) { |
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) { |
|
|
db.createObjectStore(STORE_NAME, { |
|
|
keyPath: "id", |
|
|
autoIncrement: true, |
|
|
}); |
|
|
} |
|
|
if (!db.objectStoreNames.contains(SETTINGS_STORE_NAME)) { |
|
|
db.createObjectStore(SETTINGS_STORE_NAME, { keyPath: "key" }); |
|
|
} |
|
|
}, |
|
|
}); |
|
|
} |
|
|
|
|
|
const App: React.FC = () => { |
|
|
const [systemPrompt, setSystemPrompt] = useState<string>( |
|
|
DEFAULT_SYSTEM_PROMPT |
|
|
); |
|
|
const [isSystemPromptModalOpen, setIsSystemPromptModalOpen] = |
|
|
useState<boolean>(false); |
|
|
const [tempSystemPrompt, setTempSystemPrompt] = useState<string>(""); |
|
|
const [messages, setMessages] = useState<Message[]>([]); |
|
|
const [tools, setTools] = useState<Tool[]>([]); |
|
|
const [input, setInput] = useState<string>(""); |
|
|
const [isGenerating, setIsGenerating] = useState<boolean>(false); |
|
|
const isMobile = useMemo(isMobileOrTablet, []); |
|
|
const [selectedModelId, setSelectedModelId] = useState<string>( |
|
|
isMobile ? "350M" : "1.2B" |
|
|
); |
|
|
const [isModelDropdownOpen, setIsModelDropdownOpen] = |
|
|
useState<boolean>(false); |
|
|
const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false); |
|
|
const [isToolsPanelVisible, setIsToolsPanelVisible] = useState<boolean>(true); |
|
|
const chatContainerRef = useRef<HTMLDivElement>(null); |
|
|
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({}); |
|
|
const toolsContainerRef = useRef<HTMLDivElement>(null); |
|
|
const inputRef = useRef<HTMLInputElement>(null); |
|
|
const { |
|
|
isLoading, |
|
|
isReady, |
|
|
error, |
|
|
progress, |
|
|
loadModel, |
|
|
generateResponse, |
|
|
clearPastKeyValues, |
|
|
} = useLLM(selectedModelId); |
|
|
|
|
|
|
|
|
const { |
|
|
getMCPToolsAsOriginalTools, |
|
|
callMCPTool, |
|
|
connectAll: connectAllMCPServers, |
|
|
} = useMCP(); |
|
|
|
|
|
const loadTools = useCallback(async (): Promise<void> => { |
|
|
const db = await getDB(); |
|
|
const allTools: Tool[] = await db.getAll(STORE_NAME); |
|
|
if (allTools.length === 0) { |
|
|
const defaultTools: Tool[] = Object.entries(DEFAULT_TOOLS).map( |
|
|
([name, code], id) => ({ |
|
|
id, |
|
|
name, |
|
|
code, |
|
|
enabled: true, |
|
|
isCollapsed: false, |
|
|
}) |
|
|
); |
|
|
const tx = db.transaction(STORE_NAME, "readwrite"); |
|
|
await Promise.all(defaultTools.map((tool) => tx.store.put(tool))); |
|
|
await tx.done; |
|
|
setTools(defaultTools); |
|
|
} else { |
|
|
setTools(allTools.map((t) => ({ ...t, isCollapsed: false }))); |
|
|
} |
|
|
|
|
|
|
|
|
const mcpTools = getMCPToolsAsOriginalTools(); |
|
|
setTools((prevTools) => [...prevTools, ...mcpTools]); |
|
|
}, [getMCPToolsAsOriginalTools]); |
|
|
|
|
|
useEffect(() => { |
|
|
loadTools(); |
|
|
|
|
|
connectAllMCPServers().catch((error) => { |
|
|
console.error("Failed to connect to MCP servers:", error); |
|
|
}); |
|
|
}, [loadTools, connectAllMCPServers]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (chatContainerRef.current) { |
|
|
chatContainerRef.current.scrollTop = |
|
|
chatContainerRef.current.scrollHeight; |
|
|
} |
|
|
}, [messages]); |
|
|
|
|
|
const updateToolInDB = async (tool: Tool): Promise<void> => { |
|
|
const db = await getDB(); |
|
|
await db.put(STORE_NAME, tool); |
|
|
}; |
|
|
|
|
|
const saveToolDebounced = (tool: Tool): void => { |
|
|
if (tool.id !== undefined && debounceTimers.current[tool.id]) { |
|
|
clearTimeout(debounceTimers.current[tool.id]); |
|
|
} |
|
|
if (tool.id !== undefined) { |
|
|
debounceTimers.current[tool.id] = setTimeout(() => { |
|
|
updateToolInDB(tool); |
|
|
}, 300); |
|
|
} |
|
|
}; |
|
|
|
|
|
const clearChat = useCallback(() => { |
|
|
setMessages([]); |
|
|
clearPastKeyValues(); |
|
|
}, [clearPastKeyValues]); |
|
|
|
|
|
const addTool = async (): Promise<void> => { |
|
|
const newTool: Omit<Tool, "id"> = { |
|
|
name: "new_tool", |
|
|
code: TEMPLATE, |
|
|
enabled: true, |
|
|
isCollapsed: false, |
|
|
}; |
|
|
const db = await getDB(); |
|
|
const id = await db.add(STORE_NAME, newTool); |
|
|
setTools((prev) => { |
|
|
const updated = [...prev, { ...newTool, id: id as number }]; |
|
|
setTimeout(() => { |
|
|
if (toolsContainerRef.current) { |
|
|
toolsContainerRef.current.scrollTop = |
|
|
toolsContainerRef.current.scrollHeight; |
|
|
} |
|
|
}, 0); |
|
|
return updated; |
|
|
}); |
|
|
clearChat(); |
|
|
}; |
|
|
|
|
|
const deleteTool = async (id: number): Promise<void> => { |
|
|
if (debounceTimers.current[id]) { |
|
|
clearTimeout(debounceTimers.current[id]); |
|
|
} |
|
|
const db = await getDB(); |
|
|
await db.delete(STORE_NAME, id); |
|
|
setTools(tools.filter((tool) => tool.id !== id)); |
|
|
clearChat(); |
|
|
}; |
|
|
|
|
|
const toggleToolEnabled = (id: number): void => { |
|
|
let changedTool: Tool | undefined; |
|
|
const newTools = tools.map((tool) => { |
|
|
if (tool.id === id) { |
|
|
changedTool = { ...tool, enabled: !tool.enabled }; |
|
|
return changedTool; |
|
|
} |
|
|
return tool; |
|
|
}); |
|
|
setTools(newTools); |
|
|
if (changedTool) saveToolDebounced(changedTool); |
|
|
}; |
|
|
|
|
|
const toggleToolCollapsed = (id: number): void => { |
|
|
setTools( |
|
|
tools.map((tool) => |
|
|
tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool |
|
|
) |
|
|
); |
|
|
}; |
|
|
|
|
|
const expandTool = (id: number): void => { |
|
|
setTools( |
|
|
tools.map((tool) => |
|
|
tool.id === id ? { ...tool, isCollapsed: false } : tool |
|
|
) |
|
|
); |
|
|
}; |
|
|
|
|
|
const handleToolCodeChange = (id: number, newCode: string): void => { |
|
|
let changedTool: Tool | undefined; |
|
|
const newTools = tools.map((tool) => { |
|
|
if (tool.id === id) { |
|
|
const { functionCode } = extractFunctionAndRenderer(newCode); |
|
|
const schema = generateSchemaFromCode(functionCode); |
|
|
changedTool = { ...tool, code: newCode, name: schema.name }; |
|
|
return changedTool; |
|
|
} |
|
|
return tool; |
|
|
}); |
|
|
setTools(newTools); |
|
|
if (changedTool) saveToolDebounced(changedTool); |
|
|
}; |
|
|
|
|
|
const executeToolCall = async (callString: string): Promise<string> => { |
|
|
const parsedCall = parsePythonicCalls(callString); |
|
|
if (!parsedCall) throw new Error(`Invalid tool call format: ${callString}`); |
|
|
|
|
|
const { name, positionalArgs, keywordArgs } = parsedCall; |
|
|
const toolToUse = tools.find((t) => t.name === name && t.enabled); |
|
|
if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`); |
|
|
|
|
|
|
|
|
const isMCPTool = toolToUse.code?.includes("mcpServerId:"); |
|
|
if (isMCPTool) { |
|
|
|
|
|
const mcpServerMatch = toolToUse.code?.match(/mcpServerId: "([^"]+)"/); |
|
|
const mcpToolMatch = toolToUse.code?.match(/toolName: "([^"]+)"/); |
|
|
|
|
|
if (mcpServerMatch && mcpToolMatch) { |
|
|
const serverId = mcpServerMatch[1]; |
|
|
const toolName = mcpToolMatch[1]; |
|
|
|
|
|
|
|
|
const { functionCode } = extractFunctionAndRenderer(toolToUse.code); |
|
|
const schema = generateSchemaFromCode(functionCode); |
|
|
const paramNames = Object.keys(schema.parameters.properties); |
|
|
|
|
|
const args: Record<string, unknown> = {}; |
|
|
|
|
|
|
|
|
for ( |
|
|
let i = 0; |
|
|
i < Math.min(positionalArgs.length, paramNames.length); |
|
|
i++ |
|
|
) { |
|
|
args[paramNames[i]] = positionalArgs[i]; |
|
|
} |
|
|
|
|
|
|
|
|
Object.entries(keywordArgs).forEach(([key, value]) => { |
|
|
args[key] = value; |
|
|
}); |
|
|
|
|
|
|
|
|
const result = await callMCPTool(serverId, toolName, args); |
|
|
return JSON.stringify(result); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const { functionCode } = extractFunctionAndRenderer(toolToUse.code); |
|
|
const schema = generateSchemaFromCode(functionCode); |
|
|
const paramNames = Object.keys(schema.parameters.properties); |
|
|
|
|
|
const finalArgs: unknown[] = []; |
|
|
const requiredParams = schema.parameters.required || []; |
|
|
|
|
|
for (let i = 0; i < paramNames.length; ++i) { |
|
|
const paramName = paramNames[i]; |
|
|
if (i < positionalArgs.length) { |
|
|
finalArgs.push(positionalArgs[i]); |
|
|
} else if (Object.prototype.hasOwnProperty.call(keywordArgs, paramName)) { |
|
|
finalArgs.push(keywordArgs[paramName]); |
|
|
} else if ( |
|
|
Object.prototype.hasOwnProperty.call( |
|
|
schema.parameters.properties[paramName], |
|
|
"default" |
|
|
) |
|
|
) { |
|
|
finalArgs.push(schema.parameters.properties[paramName].default); |
|
|
} else if (!requiredParams.includes(paramName)) { |
|
|
finalArgs.push(undefined); |
|
|
} else { |
|
|
throw new Error(`Missing required argument: ${paramName}`); |
|
|
} |
|
|
} |
|
|
|
|
|
const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/); |
|
|
if (!bodyMatch) { |
|
|
throw new Error( |
|
|
"Could not parse function body. Ensure it's a standard `function` declaration." |
|
|
); |
|
|
} |
|
|
const body = bodyMatch[1]; |
|
|
const AsyncFunction = Object.getPrototypeOf( |
|
|
async function () {} |
|
|
).constructor; |
|
|
const func = new AsyncFunction(...paramNames, body); |
|
|
const result = await func(...finalArgs); |
|
|
return JSON.stringify(result); |
|
|
}; |
|
|
|
|
|
const executeToolCalls = async ( |
|
|
toolCallContent: string |
|
|
): Promise<RenderInfo[]> => { |
|
|
const toolCalls = extractPythonicCalls(toolCallContent); |
|
|
if (toolCalls.length === 0) |
|
|
return [{ call: "", error: "No valid tool calls found." }]; |
|
|
|
|
|
const results: RenderInfo[] = []; |
|
|
for (const call of toolCalls) { |
|
|
try { |
|
|
const result = await executeToolCall(call); |
|
|
const parsedCall = parsePythonicCalls(call); |
|
|
const toolUsed = parsedCall |
|
|
? tools.find((t) => t.name === parsedCall.name && t.enabled) |
|
|
: null; |
|
|
const { rendererCode } = toolUsed |
|
|
? extractFunctionAndRenderer(toolUsed.code) |
|
|
: { rendererCode: undefined }; |
|
|
|
|
|
let parsedResult; |
|
|
try { |
|
|
parsedResult = JSON.parse(result); |
|
|
} catch { |
|
|
parsedResult = result; |
|
|
} |
|
|
|
|
|
let namedParams: Record<string, unknown> = Object.create(null); |
|
|
if (parsedCall && toolUsed) { |
|
|
const schema = generateSchemaFromCode( |
|
|
extractFunctionAndRenderer(toolUsed.code).functionCode |
|
|
); |
|
|
const paramNames = Object.keys(schema.parameters.properties); |
|
|
namedParams = mapArgsToNamedParams( |
|
|
paramNames, |
|
|
parsedCall.positionalArgs, |
|
|
parsedCall.keywordArgs |
|
|
); |
|
|
} |
|
|
|
|
|
results.push({ |
|
|
call, |
|
|
result: parsedResult, |
|
|
renderer: rendererCode, |
|
|
input: namedParams, |
|
|
}); |
|
|
} catch (error) { |
|
|
const errorMessage = getErrorMessage(error); |
|
|
results.push({ call, error: errorMessage }); |
|
|
} |
|
|
} |
|
|
return results; |
|
|
}; |
|
|
|
|
|
const handleSendMessage = async (): Promise<void> => { |
|
|
if (!input.trim() || !isReady) return; |
|
|
|
|
|
const userMessage: Message = { role: "user", content: input }; |
|
|
const currentMessages: Message[] = [...messages, userMessage]; |
|
|
setMessages(currentMessages); |
|
|
setInput(""); |
|
|
setIsGenerating(true); |
|
|
|
|
|
try { |
|
|
const toolSchemas = tools |
|
|
.filter((tool) => tool.enabled) |
|
|
.map((tool) => generateSchemaFromCode(tool.code)); |
|
|
|
|
|
while (true) { |
|
|
const messagesForGeneration = [ |
|
|
{ role: "system" as const, content: systemPrompt }, |
|
|
...currentMessages, |
|
|
]; |
|
|
|
|
|
setMessages([...currentMessages, { role: "assistant", content: "" }]); |
|
|
|
|
|
let accumulatedContent = ""; |
|
|
const response = await generateResponse( |
|
|
messagesForGeneration, |
|
|
toolSchemas, |
|
|
(token: string) => { |
|
|
accumulatedContent += token; |
|
|
setMessages((current) => { |
|
|
const updated = [...current]; |
|
|
updated[updated.length - 1] = { |
|
|
role: "assistant", |
|
|
content: accumulatedContent, |
|
|
}; |
|
|
return updated; |
|
|
}); |
|
|
} |
|
|
); |
|
|
|
|
|
currentMessages.push({ role: "assistant", content: response }); |
|
|
const toolCallContent = extractToolCallContent(response); |
|
|
|
|
|
if (toolCallContent) { |
|
|
const toolResults = await executeToolCalls(toolCallContent); |
|
|
|
|
|
const toolMessage: ToolMessage = { |
|
|
role: "tool", |
|
|
content: JSON.stringify(toolResults.map((r) => r.result ?? null)), |
|
|
renderInfo: toolResults, |
|
|
}; |
|
|
currentMessages.push(toolMessage); |
|
|
setMessages([...currentMessages]); |
|
|
continue; |
|
|
} else { |
|
|
setMessages(currentMessages); |
|
|
break; |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
const errorMessage = getErrorMessage(error); |
|
|
setMessages([ |
|
|
...currentMessages, |
|
|
{ |
|
|
role: "assistant", |
|
|
content: `Error generating response: ${errorMessage}`, |
|
|
}, |
|
|
]); |
|
|
} finally { |
|
|
setIsGenerating(false); |
|
|
setTimeout(() => inputRef.current?.focus(), 0); |
|
|
} |
|
|
}; |
|
|
|
|
|
const loadSystemPrompt = useCallback(async (): Promise<void> => { |
|
|
try { |
|
|
const db = await getDB(); |
|
|
const stored = await db.get(SETTINGS_STORE_NAME, "systemPrompt"); |
|
|
if (stored && stored.value) setSystemPrompt(stored.value); |
|
|
} catch (error) { |
|
|
console.error("Failed to load system prompt:", error); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
const saveSystemPrompt = useCallback( |
|
|
async (prompt: string): Promise<void> => { |
|
|
try { |
|
|
const db = await getDB(); |
|
|
await db.put(SETTINGS_STORE_NAME, { |
|
|
key: "systemPrompt", |
|
|
value: prompt, |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error("Failed to save system prompt:", error); |
|
|
} |
|
|
}, |
|
|
[] |
|
|
); |
|
|
|
|
|
const loadSelectedModel = useCallback(async (): Promise<void> => { |
|
|
try { |
|
|
await loadModel(); |
|
|
} catch (error) { |
|
|
console.error("Failed to load model:", error); |
|
|
} |
|
|
}, [loadModel]); |
|
|
|
|
|
const loadSelectedModelId = useCallback(async (): Promise<void> => { |
|
|
try { |
|
|
const db = await getDB(); |
|
|
const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId"); |
|
|
if (stored && stored.value) { |
|
|
setSelectedModelId(stored.value); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("Failed to load selected model ID:", error); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
loadSystemPrompt(); |
|
|
}, [loadSystemPrompt]); |
|
|
|
|
|
const handleOpenSystemPromptModal = (): void => { |
|
|
setTempSystemPrompt(systemPrompt); |
|
|
setIsSystemPromptModalOpen(true); |
|
|
}; |
|
|
|
|
|
const handleSaveSystemPrompt = (): void => { |
|
|
setSystemPrompt(tempSystemPrompt); |
|
|
saveSystemPrompt(tempSystemPrompt); |
|
|
setIsSystemPromptModalOpen(false); |
|
|
}; |
|
|
|
|
|
const handleCancelSystemPrompt = (): void => { |
|
|
setTempSystemPrompt(""); |
|
|
setIsSystemPromptModalOpen(false); |
|
|
}; |
|
|
|
|
|
const handleResetSystemPrompt = (): void => { |
|
|
setTempSystemPrompt(DEFAULT_SYSTEM_PROMPT); |
|
|
}; |
|
|
|
|
|
const saveSelectedModel = useCallback( |
|
|
async (modelId: string): Promise<void> => { |
|
|
try { |
|
|
const db = await getDB(); |
|
|
await db.put(SETTINGS_STORE_NAME, { |
|
|
key: "selectedModelId", |
|
|
value: modelId, |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error("Failed to save selected model ID:", error); |
|
|
} |
|
|
}, |
|
|
[] |
|
|
); |
|
|
|
|
|
useEffect(() => { |
|
|
loadSystemPrompt(); |
|
|
loadSelectedModelId(); |
|
|
}, [loadSystemPrompt, loadSelectedModelId]); |
|
|
|
|
|
const handleModelSelect = async (modelId: string) => { |
|
|
setSelectedModelId(modelId); |
|
|
setIsModelDropdownOpen(false); |
|
|
await saveSelectedModel(modelId); |
|
|
}; |
|
|
|
|
|
const handleExampleClick = async (messageText: string): Promise<void> => { |
|
|
if (!isReady || isGenerating) return; |
|
|
setInput(messageText); |
|
|
|
|
|
const userMessage: Message = { role: "user", content: messageText }; |
|
|
const currentMessages: Message[] = [...messages, userMessage]; |
|
|
setMessages(currentMessages); |
|
|
setInput(""); |
|
|
setIsGenerating(true); |
|
|
|
|
|
try { |
|
|
const toolSchemas = tools |
|
|
.filter((tool) => tool.enabled) |
|
|
.map((tool) => generateSchemaFromCode(tool.code)); |
|
|
|
|
|
while (true) { |
|
|
const messagesForGeneration = [ |
|
|
{ role: "system" as const, content: systemPrompt }, |
|
|
...currentMessages, |
|
|
]; |
|
|
|
|
|
setMessages([...currentMessages, { role: "assistant", content: "" }]); |
|
|
|
|
|
let accumulatedContent = ""; |
|
|
const response = await generateResponse( |
|
|
messagesForGeneration, |
|
|
toolSchemas, |
|
|
(token: string) => { |
|
|
accumulatedContent += token; |
|
|
setMessages((current) => { |
|
|
const updated = [...current]; |
|
|
updated[updated.length - 1] = { |
|
|
role: "assistant", |
|
|
content: accumulatedContent, |
|
|
}; |
|
|
return updated; |
|
|
}); |
|
|
} |
|
|
); |
|
|
|
|
|
currentMessages.push({ role: "assistant", content: response }); |
|
|
const toolCallContent = extractToolCallContent(response); |
|
|
|
|
|
if (toolCallContent) { |
|
|
const toolResults = await executeToolCalls(toolCallContent); |
|
|
|
|
|
const toolMessage: ToolMessage = { |
|
|
role: "tool", |
|
|
content: JSON.stringify(toolResults.map((r) => r.result ?? null)), |
|
|
renderInfo: toolResults, |
|
|
}; |
|
|
currentMessages.push(toolMessage); |
|
|
setMessages([...currentMessages]); |
|
|
continue; |
|
|
} else { |
|
|
setMessages(currentMessages); |
|
|
break; |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
const errorMessage = getErrorMessage(error); |
|
|
setMessages([ |
|
|
...currentMessages, |
|
|
{ |
|
|
role: "assistant", |
|
|
content: `Error generating response: ${errorMessage}`, |
|
|
}, |
|
|
]); |
|
|
} finally { |
|
|
setIsGenerating(false); |
|
|
setTimeout(() => inputRef.current?.focus(), 0); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="font-sans bg-gray-900"> |
|
|
{!isReady ? ( |
|
|
<LoadingScreen |
|
|
isLoading={isLoading} |
|
|
progress={progress} |
|
|
error={error} |
|
|
loadSelectedModel={loadSelectedModel} |
|
|
selectedModelId={selectedModelId} |
|
|
isModelDropdownOpen={isModelDropdownOpen} |
|
|
setIsModelDropdownOpen={setIsModelDropdownOpen} |
|
|
handleModelSelect={handleModelSelect} |
|
|
/> |
|
|
) : ( |
|
|
<div className="flex h-screen text-white"> |
|
|
<div |
|
|
className={`flex flex-col p-4 transition-all duration-300 ${ |
|
|
isToolsPanelVisible ? "w-1/2" : "w-full" |
|
|
}`} |
|
|
> |
|
|
<div className="flex items-center justify-between mb-4"> |
|
|
<div className="flex items-center gap-3"> |
|
|
<h1 className="text-3xl font-bold text-gray-200">LFM2 MCP</h1> |
|
|
</div> |
|
|
<div className="flex items-center gap-3"> |
|
|
<div className="flex items-center text-green-400"> |
|
|
<Zap size={16} className="mr-2" /> |
|
|
Ready |
|
|
</div> |
|
|
<button |
|
|
disabled={isGenerating} |
|
|
onClick={clearChat} |
|
|
className={`h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors text-sm ${ |
|
|
isGenerating |
|
|
? "bg-gray-600 cursor-not-allowed opacity-50" |
|
|
: "bg-gray-600 hover:bg-gray-700" |
|
|
}`} |
|
|
title="Clear chat" |
|
|
> |
|
|
<RotateCcw size={14} className="mr-2" /> Clear |
|
|
</button> |
|
|
<button |
|
|
onClick={handleOpenSystemPromptModal} |
|
|
className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-gray-600 hover:bg-gray-700 text-sm" |
|
|
title="Edit system prompt" |
|
|
> |
|
|
<Settings size={16} /> |
|
|
</button> |
|
|
<button |
|
|
onClick={() => setIsMCPManagerOpen(true)} |
|
|
className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-blue-600 hover:bg-blue-700 text-sm" |
|
|
title="Manage MCP Servers" |
|
|
> |
|
|
🌐 |
|
|
</button> |
|
|
<button |
|
|
onClick={() => setIsToolsPanelVisible(!isToolsPanelVisible)} |
|
|
className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-gray-600 hover:bg-gray-700 text-sm" |
|
|
title={ |
|
|
isToolsPanelVisible |
|
|
? "Hide Tools Panel" |
|
|
: "Show Tools Panel" |
|
|
} |
|
|
> |
|
|
{isToolsPanelVisible ? ( |
|
|
<PanelRightClose size={16} /> |
|
|
) : ( |
|
|
<PanelRightOpen size={16} /> |
|
|
)} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
ref={chatContainerRef} |
|
|
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto mb-4 space-y-4" |
|
|
> |
|
|
{messages.length === 0 && isReady ? ( |
|
|
<ExamplePrompts onExampleClick={handleExampleClick} /> |
|
|
) : ( |
|
|
messages.map((msg, index) => { |
|
|
const key = `${msg.role}-${index}`; |
|
|
|
|
|
if (msg.role === "user") { |
|
|
return ( |
|
|
<div key={key} className="flex justify-end"> |
|
|
<div className="p-3 rounded-lg max-w-md bg-indigo-600"> |
|
|
<p className="text-sm whitespace-pre-wrap"> |
|
|
{msg.content} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} else if (msg.role === "assistant") { |
|
|
const isToolCall = msg.content.includes( |
|
|
"<|tool_call_start|>" |
|
|
); |
|
|
|
|
|
if (isToolCall) { |
|
|
const nextMessage = messages[index + 1]; |
|
|
const isCompleted = nextMessage?.role === "tool"; |
|
|
const hasError = |
|
|
isCompleted && |
|
|
(nextMessage as ToolMessage).renderInfo.some( |
|
|
(info) => !!info.error |
|
|
); |
|
|
|
|
|
return ( |
|
|
<div key={key} className="flex justify-start"> |
|
|
<div className="p-3 rounded-lg bg-gray-700"> |
|
|
<ToolCallIndicator |
|
|
content={msg.content} |
|
|
isRunning={!isCompleted} |
|
|
hasError={hasError} |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
return ( |
|
|
<div key={key} className="flex justify-start"> |
|
|
<div className="p-3 rounded-lg max-w-md bg-gray-700"> |
|
|
<p className="text-sm whitespace-pre-wrap"> |
|
|
{msg.content} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} else if (msg.role === "tool") { |
|
|
const visibleToolResults = msg.renderInfo.filter( |
|
|
(info) => |
|
|
info.error || (info.result != null && info.renderer) |
|
|
); |
|
|
|
|
|
if (visibleToolResults.length === 0) return null; |
|
|
|
|
|
return ( |
|
|
<div key={key} className="flex justify-start"> |
|
|
<div className="p-3 rounded-lg bg-gray-700 max-w-lg"> |
|
|
<div className="space-y-3"> |
|
|
{visibleToolResults.map((info, idx) => ( |
|
|
<div className="flex flex-col gap-2" key={idx}> |
|
|
<div className="text-xs text-gray-400 font-mono"> |
|
|
{info.call} |
|
|
</div> |
|
|
{info.error ? ( |
|
|
<ResultBlock error={info.error} /> |
|
|
) : ( |
|
|
<ToolResultRenderer |
|
|
result={info.result} |
|
|
rendererCode={info.renderer} |
|
|
input={info.input} |
|
|
/> |
|
|
)} |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
return null; |
|
|
}) |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<div className="flex"> |
|
|
<input |
|
|
ref={inputRef} |
|
|
type="text" |
|
|
value={input} |
|
|
onChange={(e) => setInput(e.target.value)} |
|
|
onKeyDown={(e) => |
|
|
e.key === "Enter" && |
|
|
!isGenerating && |
|
|
isReady && |
|
|
handleSendMessage() |
|
|
} |
|
|
disabled={isGenerating || !isReady} |
|
|
className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" |
|
|
placeholder={ |
|
|
isReady |
|
|
? "Type your message here..." |
|
|
: "Load model first to enable chat" |
|
|
} |
|
|
/> |
|
|
<button |
|
|
onClick={handleSendMessage} |
|
|
disabled={isGenerating || !isReady} |
|
|
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold p-3 rounded-r-lg transition-colors" |
|
|
> |
|
|
<Play size={20} /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{isToolsPanelVisible && ( |
|
|
<div className="w-1/2 flex flex-col p-4 border-l border-gray-700 transition-all duration-300"> |
|
|
<div className="flex justify-between items-center mb-4"> |
|
|
<h2 className="text-2xl font-bold text-teal-400">Tools</h2> |
|
|
<button |
|
|
onClick={addTool} |
|
|
className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors" |
|
|
> |
|
|
<Plus size={16} className="mr-2" /> Add Tool |
|
|
</button> |
|
|
</div> |
|
|
<div |
|
|
ref={toolsContainerRef} |
|
|
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3" |
|
|
> |
|
|
{tools.map((tool) => ( |
|
|
<ToolItem |
|
|
key={tool.id} |
|
|
tool={tool} |
|
|
onToggleEnabled={() => toggleToolEnabled(tool.id)} |
|
|
onToggleCollapsed={() => toggleToolCollapsed(tool.id)} |
|
|
onExpand={() => expandTool(tool.id)} |
|
|
onDelete={() => deleteTool(tool.id)} |
|
|
onCodeChange={(newCode) => |
|
|
handleToolCodeChange(tool.id, newCode) |
|
|
} |
|
|
/> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{isSystemPromptModalOpen && ( |
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> |
|
|
<div className="bg-gray-800 rounded-lg p-6 w-3/4 max-w-4xl max-h-3/4 flex flex-col text-gray-100"> |
|
|
<div className="flex justify-between items-center mb-4"> |
|
|
<h2 className="text-xl font-bold text-indigo-400"> |
|
|
Edit System Prompt |
|
|
</h2> |
|
|
<button |
|
|
onClick={handleCancelSystemPrompt} |
|
|
className="text-gray-400 hover:text-white" |
|
|
> |
|
|
<X size={20} /> |
|
|
</button> |
|
|
</div> |
|
|
<div className="flex-grow mb-4"> |
|
|
<textarea |
|
|
value={tempSystemPrompt} |
|
|
onChange={(e) => setTempSystemPrompt(e.target.value)} |
|
|
className="w-full h-full bg-gray-700 text-white p-4 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500" |
|
|
placeholder="Enter your system prompt here..." |
|
|
style={{ minHeight: "300px" }} |
|
|
/> |
|
|
</div> |
|
|
<div className="flex justify-between"> |
|
|
<button |
|
|
onClick={handleResetSystemPrompt} |
|
|
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors" |
|
|
> |
|
|
Reset |
|
|
</button> |
|
|
<div className="flex gap-3"> |
|
|
<button |
|
|
onClick={handleSaveSystemPrompt} |
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors" |
|
|
> |
|
|
Save |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* MCP Server Manager Modal */} |
|
|
<MCPServerManager |
|
|
isOpen={isMCPManagerOpen} |
|
|
onClose={() => setIsMCPManagerOpen(false)} |
|
|
/> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default App; |
|
|
|