|
|
import React, { |
|
|
useState, |
|
|
useEffect, |
|
|
useCallback, |
|
|
useRef, |
|
|
useMemo, |
|
|
} from "react"; |
|
|
import { openDB, type IDBPDatabase } from "idb"; |
|
|
import { Play, Plus, RotateCcw, Wrench } from "lucide-react"; |
|
|
import { useLLM } from "./hooks/useLLM"; |
|
|
|
|
|
import type { Tool } from "./components/ToolItem"; |
|
|
|
|
|
import { |
|
|
extractFunctionAndRenderer, |
|
|
generateSchemaFromCode, |
|
|
getErrorMessage, |
|
|
isMobileOrTablet, |
|
|
} from "./utils"; |
|
|
|
|
|
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 { LoadingScreen } from "./components/LoadingScreen"; |
|
|
|
|
|
interface RenderInfo { |
|
|
call: string; |
|
|
result?: any; |
|
|
renderer?: string; |
|
|
input?: Record<string, any>; |
|
|
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 [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 [selectedModel, setSelectedModel] = useState<string>( |
|
|
isMobile ? "350M" : "1B", |
|
|
); |
|
|
const [isModelDropdownOpen, setIsModelDropdownOpen] = |
|
|
useState<boolean>(false); |
|
|
const [isToolsPanelVisible, setIsToolsPanelVisible] = |
|
|
useState<boolean>(false); |
|
|
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(selectedModel); |
|
|
|
|
|
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 }))); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
loadTools(); |
|
|
}, [loadTools]); |
|
|
|
|
|
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); |
|
|
}; |
|
|
|
|
|
interface ToolCallPayload { |
|
|
name: string; |
|
|
arguments?: Record<string, any>; |
|
|
} |
|
|
|
|
|
const extractToolCalls = (text: string): ToolCallPayload[] => { |
|
|
const matches = Array.from( |
|
|
text.matchAll(/<tool_call>([\s\S]*?)<\/tool_call>/g), |
|
|
); |
|
|
const toolCalls: ToolCallPayload[] = []; |
|
|
|
|
|
for (const match of matches) { |
|
|
try { |
|
|
const parsed = JSON.parse(match[1].trim()); |
|
|
if (parsed && typeof parsed.name === "string") { |
|
|
toolCalls.push({ |
|
|
name: parsed.name, |
|
|
arguments: parsed.arguments ?? {}, |
|
|
}); |
|
|
} |
|
|
} catch { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
return toolCalls; |
|
|
}; |
|
|
|
|
|
const executeToolCall = async ( |
|
|
toolCall: ToolCallPayload, |
|
|
): Promise<{ |
|
|
serializedResult: string; |
|
|
rendererCode?: string; |
|
|
input: Record<string, any>; |
|
|
}> => { |
|
|
const toolToUse = tools.find((t) => t.name === toolCall.name && t.enabled); |
|
|
if (!toolToUse) |
|
|
throw new Error(`Tool '${toolCall.name}' not found or is disabled.`); |
|
|
|
|
|
const { functionCode, rendererCode } = extractFunctionAndRenderer( |
|
|
toolToUse.code, |
|
|
); |
|
|
const schema = generateSchemaFromCode(functionCode); |
|
|
const properties = schema.parameters?.properties ?? {}; |
|
|
const paramNames = Object.keys(properties); |
|
|
const requiredParams = schema.parameters?.required ?? []; |
|
|
const callArgs = toolCall.arguments ?? {}; |
|
|
|
|
|
const finalArgs: any[] = []; |
|
|
const resolvedArgs: Record<string, any> = Object.create(null); |
|
|
|
|
|
for (const paramName of paramNames) { |
|
|
const propertyConfig = properties[paramName] ?? {}; |
|
|
if (Object.prototype.hasOwnProperty.call(callArgs, paramName)) { |
|
|
const value = callArgs[paramName]; |
|
|
finalArgs.push(value); |
|
|
resolvedArgs[paramName] = value; |
|
|
} else if ( |
|
|
Object.prototype.hasOwnProperty.call(propertyConfig, "default") |
|
|
) { |
|
|
const value = propertyConfig.default; |
|
|
finalArgs.push(value); |
|
|
resolvedArgs[paramName] = value; |
|
|
} else if (!requiredParams.includes(paramName)) { |
|
|
finalArgs.push(undefined); |
|
|
resolvedArgs[paramName] = undefined; |
|
|
} else { |
|
|
throw new Error(`Missing required argument: ${paramName}`); |
|
|
} |
|
|
} |
|
|
|
|
|
for (const extraKey of Object.keys(callArgs)) { |
|
|
if (!Object.prototype.hasOwnProperty.call(resolvedArgs, extraKey)) { |
|
|
resolvedArgs[extraKey] = callArgs[extraKey]; |
|
|
} |
|
|
} |
|
|
|
|
|
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 { |
|
|
serializedResult: JSON.stringify(result), |
|
|
rendererCode, |
|
|
input: resolvedArgs, |
|
|
}; |
|
|
}; |
|
|
|
|
|
const executeToolCalls = async ( |
|
|
toolCalls: ToolCallPayload[], |
|
|
): Promise<RenderInfo[]> => { |
|
|
if (toolCalls.length === 0) { |
|
|
return [{ call: "", error: "No valid tool calls found." }]; |
|
|
} |
|
|
|
|
|
const results: RenderInfo[] = []; |
|
|
|
|
|
for (const toolCall of toolCalls) { |
|
|
const callDisplay = `<tool_call>${JSON.stringify(toolCall)}</tool_call>`; |
|
|
try { |
|
|
const { serializedResult, rendererCode, input } = |
|
|
await executeToolCall(toolCall); |
|
|
|
|
|
let parsedResult: unknown; |
|
|
try { |
|
|
parsedResult = JSON.parse(serializedResult); |
|
|
} catch { |
|
|
parsedResult = serializedResult; |
|
|
} |
|
|
|
|
|
results.push({ |
|
|
call: callDisplay, |
|
|
result: parsedResult, |
|
|
renderer: rendererCode, |
|
|
input, |
|
|
}); |
|
|
} catch (error) { |
|
|
results.push({ |
|
|
call: callDisplay, |
|
|
error: getErrorMessage(error), |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
return results; |
|
|
}; |
|
|
|
|
|
const handleSendMessage = async (): Promise<void> => { |
|
|
if (!input.trim() || !isReady) return; |
|
|
|
|
|
const userMessage: Message = { role: "user", content: input }; |
|
|
let 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 = [...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 toolCalls = extractToolCalls(response); |
|
|
|
|
|
if (toolCalls.length > 0) { |
|
|
const toolResults = await executeToolCalls(toolCalls); |
|
|
|
|
|
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 loadSelectedModel = useCallback(async (): Promise<void> => { |
|
|
try { |
|
|
await loadModel(); |
|
|
} catch (error) { |
|
|
console.error("Failed to load model:", error); |
|
|
} |
|
|
}, [selectedModel, loadModel]); |
|
|
|
|
|
const saveSelectedModel = useCallback(async (modelId: string) => { |
|
|
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); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
const loadSelectedModelId = useCallback(async (): Promise<void> => { |
|
|
try { |
|
|
const db = await getDB(); |
|
|
const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId"); |
|
|
if (stored && stored.value) { |
|
|
setSelectedModel(stored.value); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("Failed to load selected model ID:", error); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
loadSelectedModelId(); |
|
|
}, [loadSelectedModelId]); |
|
|
|
|
|
const handleModelSelect = async (modelId: string) => { |
|
|
setSelectedModel(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 = [...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 toolCalls = extractToolCalls(response); |
|
|
|
|
|
if (toolCalls.length > 0) { |
|
|
const toolResults = await executeToolCalls(toolCalls); |
|
|
|
|
|
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 min-h-screen bg-gradient-to-br from-[#031b4e] via-[#06183d] to-[#010409] text-gray-100 text-[16px] md:text-[17px]"> |
|
|
{!isReady ? ( |
|
|
<LoadingScreen |
|
|
isLoading={isLoading} |
|
|
progress={progress} |
|
|
error={error} |
|
|
loadSelectedModel={loadSelectedModel} |
|
|
selectedModelId={selectedModel} |
|
|
isModelDropdownOpen={isModelDropdownOpen} |
|
|
setIsModelDropdownOpen={setIsModelDropdownOpen} |
|
|
handleModelSelect={handleModelSelect} |
|
|
/> |
|
|
) : ( |
|
|
<div className="flex h-screen text-gray-100 w-full gap-6 py-10 px-8"> |
|
|
<div className="flex-1 flex flex-col p-6 bg-white/5 backdrop-blur-lg border border-white/10 rounded-3xl shadow-[0_35px_65px_rgba(3,27,78,0.55)] min-h-0"> |
|
|
<div className="flex items-center justify-between mb-6"> |
|
|
<div className="space-y-1"> |
|
|
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.35em] text-[#78a9ff]"> |
|
|
IBM Granite |
|
|
</span> |
|
|
<h1 className="text-3xl font-semibold text-white"> |
|
|
Granite-4.0 Tool Studio |
|
|
</h1> |
|
|
</div> |
|
|
<div className="flex items-center gap-3"> |
|
|
<button |
|
|
disabled={isGenerating} |
|
|
onClick={clearChat} |
|
|
className={`h-10 flex items-center px-4 rounded-full font-semibold text-sm transition-all border ${ |
|
|
isGenerating |
|
|
? "border-white/15 bg-white/10 text-[#a6c8ff] opacity-50 cursor-not-allowed" |
|
|
: "border-white/20 bg-white/8 text-[#d0e2ff] hover:border-[#78a9ff]/50 hover:bg-[#0f62fe]/15" |
|
|
}`} |
|
|
title="Clear chat" |
|
|
> |
|
|
<RotateCcw size={14} className="mr-2" /> Reset Thread |
|
|
</button> |
|
|
<button |
|
|
onClick={() => |
|
|
setIsToolsPanelVisible((previous) => !previous) |
|
|
} |
|
|
className={`h-10 flex items-center px-4 rounded-full font-semibold text-sm transition-all border ${ |
|
|
isToolsPanelVisible |
|
|
? "border-[#78a9ff]/60 bg-[#0f62fe]/25 text-white shadow-[0_10px_25px_rgba(15,98,254,0.25)]" |
|
|
: "border-white/20 bg-white/8 text-[#d0e2ff] hover:border-[#78a9ff]/50 hover:bg-[#0f62fe]/15" |
|
|
}`} |
|
|
> |
|
|
<Wrench size={16} className="mr-2" /> |
|
|
{isToolsPanelVisible ? "Hide Tools" : "Show Tools"} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
ref={chatContainerRef} |
|
|
className="flex-grow bg-[#0b1e3f]/80 border border-white/10 rounded-2xl p-6 overflow-y-auto mb-6 space-y-5 shadow-inner min-h-0" |
|
|
> |
|
|
{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="px-4 py-3 rounded-2xl max-w-md bg-[#0f62fe]/30 border border-[#78a9ff]/40 shadow-[0_20px_45px_rgba(10,49,140,0.25)]"> |
|
|
<p className="text-md text-white whitespace-pre-wrap"> |
|
|
{msg.content} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
if (msg.role === "assistant") { |
|
|
const isToolCall = msg.content.includes("<tool_call>"); |
|
|
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="px-4 py-3 rounded-2xl bg-white/8 border border-[#0f62fe]/30 shadow-[0_18px_50px_rgba(0,0,0,0.35)]"> |
|
|
<ToolCallIndicator |
|
|
content={msg.content} |
|
|
isRunning={!isCompleted} |
|
|
hasError={hasError} |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
return ( |
|
|
<div key={key} className="flex justify-start"> |
|
|
<div className="px-4 py-3 rounded-2xl max-w-md bg-white/8 border border-white/15 shadow-[0_18px_50px_rgba(0,0,0,0.35)]"> |
|
|
<p className="text-md text-[#d0e2ff] whitespace-pre-wrap"> |
|
|
{msg.content} |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
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-4 rounded-2xl bg-white/8 border border-white/15 max-w-lg shadow-[0_18px_50px_rgba(0,0,0,0.35)]"> |
|
|
<div className="space-y-4"> |
|
|
{visibleToolResults.map((info, idx) => ( |
|
|
<div className="flex flex-col gap-2" key={idx}> |
|
|
<div className="text-xs text-[#a6c8ff] 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 items-center gap-3"> |
|
|
<div className="flex flex-1 items-center bg-white/5 border border-white/10 rounded-2xl overflow-hidden shadow-[0_15px_45px_rgba(0,0,0,0.35)]"> |
|
|
<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-transparent px-5 py-3 text-lg text-white placeholder:text-[#a6c8ff]/70 focus:outline-none disabled:opacity-40" |
|
|
placeholder={ |
|
|
isReady |
|
|
? "Type your message here..." |
|
|
: "Load a Granite model to enable chat" |
|
|
} |
|
|
/> |
|
|
<button |
|
|
onClick={handleSendMessage} |
|
|
disabled={isGenerating || !isReady} |
|
|
className="h-full px-5 py-3 bg-[#0f62fe] hover:bg-[#0043ce] disabled:bg-[#0f62fe]/40 disabled:cursor-not-allowed text-white font-semibold transition-all" |
|
|
> |
|
|
<Play size={28} /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{isToolsPanelVisible && ( |
|
|
<div className="w-full md:w-1/2 flex flex-col p-6 bg-white/5 backdrop-blur-lg border border-white/10 rounded-3xl shadow-[0_35px_65px_rgba(3,27,78,0.55)] min-h-0"> |
|
|
<div className="flex justify-between items-center mb-6"> |
|
|
<div> |
|
|
<span className="text-xs font-semibold uppercase tracking-[0.25em] text-[#78a9ff]"> |
|
|
Tool Workspace |
|
|
</span> |
|
|
<h2 className="text-2xl font-semibold text-white mt-1"> |
|
|
Tools |
|
|
</h2> |
|
|
</div> |
|
|
<button |
|
|
onClick={addTool} |
|
|
className="flex items-center bg-gradient-to-r from-[#0f62fe] to-[#4589ff] hover:brightness-110 text-white font-semibold py-2 px-4 rounded-full transition-all shadow-[0_15px_35px_rgba(15,98,254,0.35)]" |
|
|
> |
|
|
<Plus size={16} className="mr-2" /> Add Tool |
|
|
</button> |
|
|
</div> |
|
|
<div |
|
|
ref={toolsContainerRef} |
|
|
className="flex-grow bg-[#0b1e3f]/60 border border-white/10 rounded-2xl 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> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default App; |
|
|
|