diff --git a/modules/playground/components/ai-chat-panel.tsx b/modules/playground/components/ai-chat-panel.tsx index dfc86196..aa96d9f1 100644 --- a/modules/playground/components/ai-chat-panel.tsx +++ b/modules/playground/components/ai-chat-panel.tsx @@ -1,3 +1,21 @@ +/** + * @fileoverview AI Chat Panel - Main chat interface component for AI-assisted coding. + * @module components/ai-chat-panel + * @description Provides a sheet-based chat interface that allows users to interact with AI assistants + * (Gemini, Groq, Mistral) for code generation, file editing, and project scaffolding. + * Features include: + * - Multi-provider AI support + * - Real-time chat with streaming responses + * - File system operations (read, edit, delete, multiple edits) + * - Project context awareness using file tree + * - Tool call execution for file operations + * @requires useAI - AI provider management + * @requires useFileExplorer - File system state management + * @requires useAITools - Tool execution logic hook + * @requires ChatMessage - Message rendering component + */ + + "use client"; import { TIMEOUTS } from "@/lib/constants/config"; @@ -17,50 +35,43 @@ import { Loader2, Sparkles, User, - Wrench, + ChevronDown, Zap, Code2, - ChevronDown, } from "lucide-react"; import { useAI, type AIProvider, - addOrUpdateFile, - deleteFileByPath, - findFileByPath, collectFilePaths } from "@/modules/playground/hooks/useAI"; import { useFileExplorer } from "@/modules/playground/hooks/useFileExplorer"; import { toast } from "sonner"; import type { TemplateFolder } from "@/modules/playground/lib/path-to-json"; import { useChat } from "@ai-sdk/react"; +import { useAITools } from "@/modules/playground/hooks/useAITools"; +import { ChatMessage } from "@/modules/playground/components/chat-message"; interface AIChatPanelProps { templateData: TemplateFolder | null; saveTemplateData: (data: TemplateFolder) => Promise; } -interface MessagePart { - type?: string; - text?: string; - toolCallId?: string; - toolName?: string; - state?: string; - input?: Record; - [key: string]: unknown; -} - -interface ExtendedMessage { - parts?: MessagePart[]; - content?: string; -} - const PROVIDERS: { id: AIProvider; label: string; icon: React.ReactNode }[] = [ { id: "gemini", label: "Gemini", icon: }, { id: "groq", label: "Groq", icon: }, { id: "mistral", label: "Mistral", icon: }, ]; +// Define the FileItem type that matches what useAITools expects +interface FileItem { + id?: string; + filename: string; + fileExtension?: string; + content?: string; + originalContent?: string; + hasUnsavedChanges?: boolean; +} + export default function AIChatPanel({ templateData, saveTemplateData, @@ -75,6 +86,7 @@ export default function AIChatPanel({ const { openFiles, setOpenFiles, setTemplateData } = useFileExplorer(); const [showProviderPicker, setShowProviderPicker] = useState(false); + const [inputValue, setInputValue] = useState(""); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -86,8 +98,6 @@ export default function AIChatPanel({ [templateData] ); - const [inputValue, setInputValue] = useState(""); - const { messages, status, @@ -104,24 +114,36 @@ export default function AIChatPanel({ // v3 uses status instead of isLoading const isLoading = status === "submitted" || status === "streaming"; - // Prevent the user from sending a message if the MOST RECENT tool hasn't finished, - // to avoid the SDK "Tool result is missing" crash on the active chat stream. - // We explicitly only check the last message so older stuck tools don't permanently brick the chat. - const lastMessage = messages[messages.length - 1]; - const parts = lastMessage ? ((lastMessage as unknown) as { parts?: unknown }).parts : undefined; - const hasUnresolvedTools = lastMessage?.role === "assistant" && Array.isArray(parts) && parts.some( - (rawP: unknown) => { - if (!rawP || typeof rawP !== "object") return false; - const p = rawP as MessagePart; - return (p.type === "tool-invocation" || (typeof p.type === "string" && p.type.startsWith("tool-"))) && - (!p.state || (p.state !== "result" && p.state !== "output-available")) && - (p.toolInvocation && typeof p.toolInvocation === "object" && (p.toolInvocation as Record).state === "call"); - } - ); + // Convert openFiles to the expected type for useAITools + const openFilesForTools: FileItem[] = useMemo(() => { + return openFiles.map(f => ({ + id: f.id, + filename: f.filename, + fileExtension: f.fileExtension, + content: f.content, + originalContent: f.originalContent, + hasUnsavedChanges: f.hasUnsavedChanges + })); + }, [openFiles]); + + // Use the extracted tool logic hook + const { hasUnresolvedTools } = useAITools({ + messages, + templateData, + openFiles: openFilesForTools, + setTemplateData: (data: TemplateFolder) => setTemplateData(data), + setOpenFiles: (files: FileItem[]) => { + // Convert back to the format expected by useFileExplorer + // Use type assertion to handle the conversion + setOpenFiles(files as any); + }, + saveTemplateData, + addToolResult, + }); const sendMessage = useCallback(() => { const trimmed = inputValue.trim(); - if (!trimmed || isLoading || hasUnresolvedTools) return; + if (!trimmed || isLoading || hasUnresolvedTools()) return; chatSendMessage( { text: trimmed }, { @@ -157,158 +179,6 @@ export default function AIChatPanel({ return () => document.removeEventListener("mousedown", handleClick); }, [showProviderPicker]); - // Track which tool calls we've already executed to prevent double-execution - const processedToolCallIds = useRef(new Set()); - - // Handle incoming client-side tool calls - // In AI SDK v3, static tool parts use type: "tool-{toolName}" with: - // part.toolCallId, part.toolName, part.input, part.state - useEffect(() => { - const lastMessage = messages[messages.length - 1]; - if (lastMessage?.role !== "assistant") return; - - const rawParts: unknown[] = (lastMessage as unknown as { parts?: unknown[] }).parts ?? []; - - // Debug: log all parts to see what v3 sends - if (rawParts.length > 0) { - const toolParts = rawParts.filter((p) => typeof (p as Record).type === "string" && ((p as Record).type as string).startsWith("tool-")); - if (toolParts.length > 0) { - console.log("[AIChatPanel] Tool parts in last message:", JSON.stringify(toolParts, null, 2)); - } - } - - for (const rawPart of rawParts) { - const part = rawPart as Record; - const partType = part.type as string | undefined; - - // v3 static tool parts: type starts with "tool-" (e.g. "tool-read_file") - if (!partType?.startsWith("tool-")) continue; - - // Guard against re-execution: skip if already processed - const toolCallId = part.toolCallId as string | undefined; - if (!toolCallId) continue; - if (processedToolCallIds.current.has(toolCallId)) continue; - - // Only execute when input is fully available (not still streaming) - const state = part.state as string | undefined; - // Skip if output already provided, or if input is still streaming in - if (state === "output-available" || state === "output-streaming") continue; - // Skip if input hasn't arrived yet - if (state === "input-streaming") continue; - const toolName = (part.toolName as string | undefined) ?? partType.split("-").slice(1).join("-"); - // In v3, args live in part.input; fall back to part.args for compatibility - const args = (part.input as Record | undefined) ?? (part.args as Record | undefined) ?? {}; - - if (!toolCallId || !toolName) continue; - - let result: string; - - try { - if (toolName === "read_file") { - const { path } = args as { path?: string }; - if (!path || typeof path !== "string") { - result = `Error: read_file requires a "path" argument (e.g. "src/App.tsx")`; - } else { - const file = findFileByPath(templateData?.items || [], path); - result = (file && "content" in file && file.content !== undefined) ? file.content : `Error: File "${path}" not found`; - } - } else if (toolName === "edit_file") { - const { path, content } = args as { path?: string; content?: string }; - if (!path || typeof path !== "string") { - result = `Error: edit_file requires a "path" argument (e.g. "README.md")`; - } else if (content === undefined || content === null) { - result = `Error: edit_file requires a "content" argument with the full file contents`; - } else if (!templateData) { - result = `Error: Template data not loaded`; - } else { - const updatedItems = addOrUpdateFile(templateData.items, path, content as string); - const updatedTemplate = { ...templateData, items: updatedItems }; - setTemplateData(updatedTemplate); - - const updatedOpenFiles = openFiles.map((f) => { - const ext = f.fileExtension ? `.${f.fileExtension}` : ""; - const fullName = `${f.filename}${ext}`; - if (path.endsWith(fullName)) { - return { ...f, content: content as string, hasUnsavedChanges: true }; - } - return f; - }); - - setOpenFiles(updatedOpenFiles); - saveTemplateData(updatedTemplate).catch(console.error); - toast.success(`AI updated ${path}`); - result = `Successfully updated ${path}`; - } - } else if (toolName === "edit_multiple_files") { - const { changes } = args as { changes?: { path: string; content: string }[] }; - if (!changes || !Array.isArray(changes) || changes.length === 0) { - result = `Error: edit_multiple_files requires a "changes" array with at least one {path, content} entry`; - } else if (!templateData) { - result = `Error: Template data not loaded`; - } else { - let currentItems = templateData.items; - let currentOpenFiles = [...openFiles]; - - for (const change of changes) { - currentItems = addOrUpdateFile(currentItems, change.path, change.content); - currentOpenFiles = currentOpenFiles.map((f) => { - const ext = f.fileExtension ? `.${f.fileExtension}` : ""; - const fullName = `${f.filename}${ext}`; - if (change.path.endsWith(fullName)) { - return { ...f, content: change.content, hasUnsavedChanges: true }; - } - return f; - }); - } - - const updatedTemplate = { ...templateData, items: currentItems }; - setTemplateData(updatedTemplate); - setOpenFiles(currentOpenFiles); - saveTemplateData(updatedTemplate).catch(console.error); - toast.success(`AI scaffolded ${changes.length} files`); - result = `Successfully updated ${changes.length} files`; - } - } else if (toolName === "delete_file") { - const { path } = args as { path?: string }; - if (!path || typeof path !== "string") { - result = `Error: delete_file requires a "path" argument`; - } else if (!templateData) { - result = `Error: Template data not loaded`; - } else { - const updatedItems = deleteFileByPath(templateData.items, path); - const updatedTemplate = { ...templateData, items: updatedItems }; - setTemplateData(updatedTemplate); - - const updatedOpenFiles = openFiles.filter((f) => { - const ext = f.fileExtension ? `.${f.fileExtension}` : ""; - const fullName = `${f.filename}${ext}`; - return !path.endsWith(fullName); - }); - - setOpenFiles(updatedOpenFiles); - saveTemplateData(updatedTemplate).catch(console.error); - toast.success(`AI deleted ${path}`); - result = `Successfully deleted ${path}`; - } - } else { - result = `Error: Unknown tool ${toolName}`; - } - } catch (err: unknown) { - result = `Error: ${err instanceof Error ? err.message : String(err)}`; - } - - // Mark as processed BEFORE calling addToolResult to prevent re-execution on re-render - processedToolCallIds.current.add(toolCallId); - console.log(`[AIChatPanel] Executed tool ${toolName} (${toolCallId}), result:`, result.slice(0, 100)); - - addToolResult({ - toolCallId, - tool: toolName, - output: result, - } as Parameters[0]); - } - }, [messages, templateData, openFiles, setTemplateData, setOpenFiles, saveTemplateData, addToolResult]); - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -356,73 +226,13 @@ export default function AIChatPanel({ )} - {messages.map((msg) => { - const extended = msg as unknown as ExtendedMessage; - const rawParts: MessagePart[] = extended.parts ?? []; - - // AI SDK v3 stores user text in parts[].type=="text" - // Only genuine user messages have text parts - const textParts = rawParts.filter((p) => (p.type ?? "") === "text"); - const textContent: string = ( - textParts.map((p) => p.text ?? "").join("") || - extended.content || - "" - ); - - // v3 tool parts have type starting with "tool-" (e.g. "tool-read_file") - const toolParts: MessagePart[] = rawParts.filter( - (p) => (p.type ?? "").startsWith("tool-") - ); - - // Skip SDK-injected synthetic messages (no real text parts, no tool parts) - const isGenuineUser = msg.role === "user" && textParts.length > 0; - - return ( -
- {isGenuineUser && ( -
-
- {textContent} -
-
- -
-
- )} - {msg.role === "assistant" && ( -
-
- -
-
- {textContent && ( -
- {textContent} -
- )} - {toolParts.map((ti) => { - // In v3, tool name comes from the type suffix or toolName property - const tiName = (ti.toolName as string | undefined) ?? (ti.type as string)?.split("-").slice(1).join("-") ?? "tool"; - // Path arg lives in ti.input.path in v3 - const tiPath = (ti.input as Record | undefined)?.path as string | undefined; - const tiDone = ti.state === "output-available" || ti.state === "result"; - return ( -
-
- -
- - {tiName}({tiPath ? tiPath.split("/").pop() : ""}) {tiDone ? "✓" : } - -
- ); - })} -
-
- )} -
- ); - })} + {messages.map((msg) => ( + 0 && messages[messages.length - 1].role !== "assistant"} + /> + ))} {isLoading && messages.length > 0 && messages[messages.length - 1].role !== "assistant" && (
@@ -507,4 +317,4 @@ export default function AIChatPanel({ ); -} +} \ No newline at end of file diff --git a/modules/playground/components/chat-message.tsx b/modules/playground/components/chat-message.tsx new file mode 100644 index 00000000..6d590f6a --- /dev/null +++ b/modules/playground/components/chat-message.tsx @@ -0,0 +1,111 @@ +/** + * @fileoverview Chat message rendering component for AI conversations. + * @module components/chat-message + * @description Renders individual chat messages with support for: + * - User messages with avatar + * - AI assistant messages with bot avatar + * - Tool invocation display with status indicators + * - Loading states for streaming responses + * - Message parts (text, tool-invocation) from AI SDK v3 + * @param {ChatMessageProps} props - Message data and loading state + * @returns {JSX.Element} Rendered message bubble with appropriate styling + */ + +"use client"; + +import React from "react"; +import { Bot, User, Wrench, Loader2 } from "lucide-react"; + +interface MessagePart { + type?: string; + text?: string; + toolCallId?: string; + toolName?: string; + state?: string; + input?: Record; + args?: Record; + [key: string]: unknown; +} + +interface ExtendedMessage { + parts?: MessagePart[]; + content?: string; + role?: string; + id?: string; +} + +interface ChatMessageProps { + message: ExtendedMessage & { role?: string; id?: string }; + isLoading?: boolean; +} + +export function ChatMessage({ message, isLoading }: ChatMessageProps) { + const rawParts: MessagePart[] = message.parts ?? []; + + // AI SDK v3 stores user text in parts[].type=="text" + const textParts = rawParts.filter((p) => (p.type ?? "") === "text"); + const textContent: string = ( + textParts.map((p) => p.text ?? "").join("") || + message.content || + "" + ); + + // v3 tool parts have type starting with "tool-" (e.g. "tool-read_file") + const toolParts: MessagePart[] = rawParts.filter( + (p) => (p.type ?? "").startsWith("tool-") + ); + + // Skip SDK-injected synthetic messages (no real text parts, no tool parts) + const isGenuineUser = message.role === "user" && textParts.length > 0; + + return ( +
+ {isGenuineUser && ( +
+
+ {textContent} +
+
+ +
+
+ )} + {message.role === "assistant" && ( +
+
+ +
+
+ {textContent && ( +
+ {textContent} +
+ )} + {toolParts.map((ti) => { + const tiName = (ti.toolName as string | undefined) ?? + (ti.type as string)?.split("-").slice(1).join("-") ?? "tool"; + const tiPath = (ti.input as Record | undefined)?.path as string | undefined; + const tiDone = ti.state === "output-available" || ti.state === "result"; + return ( +
+
+ +
+ + {tiName}({tiPath ? tiPath.split("/").pop() : ""}) {tiDone ? "✓" : } + +
+ ); + })} +
+
+ )} + {isLoading && message.role !== "assistant" && ( +
+ + Thinking... +
+ )} +
+ ); +} \ No newline at end of file diff --git a/modules/playground/hooks/useAITools.ts b/modules/playground/hooks/useAITools.ts new file mode 100644 index 00000000..f049f341 --- /dev/null +++ b/modules/playground/hooks/useAITools.ts @@ -0,0 +1,256 @@ +/** + * @fileoverview Custom hook for handling AI tool execution logic. + * @module hooks/useAITools + * @description Extracts and manages all client-side tool execution logic from AI chat interactions. + * Handles four main tool types: + * - read_file: Reads file content from template data + * - edit_file: Updates a single file with new content + * - edit_multiple_files: Batch updates multiple files + * - delete_file: Removes a file from the project + * @feature Prevents duplicate tool execution using processedToolCallIds ref + * @feature Checks for unresolved tools to block message sending + * @param {UseAIToolsProps} props - Configuration and state dependencies + * @returns {Object} hasUnresolvedTools - Function to check pending tool calls + */ + +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { toast } from "sonner"; +import type { TemplateFolder } from "@/modules/playground/lib/path-to-json"; +import { + addOrUpdateFile, + deleteFileByPath, + findFileByPath, +} from "@/modules/playground/hooks/useAI"; + +interface MessagePart { + type?: string; + text?: string; + toolCallId?: string; + toolName?: string; + state?: string; + input?: Record; + toolInvocation?: Record; + args?: Record; + [key: string]: unknown; +} + +interface ExtendedMessage { + parts?: MessagePart[]; + content?: string; + role?: string; + id?: string; +} + +interface OpenFile { + id: string; + filename: string; + fileExtension?: string; + content: string; + originalContent: string; + hasUnsavedChanges?: boolean; +} + +interface UseAIToolsProps { + messages: unknown[]; + templateData: TemplateFolder | null; + openFiles: Array<{ + id?: string; + filename: string; + fileExtension?: string; + content?: string; + originalContent?: string; + hasUnsavedChanges?: boolean; + }>; + setTemplateData: (data: TemplateFolder) => void; + setOpenFiles: (files: Array<{ + id?: string; + filename: string; + fileExtension?: string; + content?: string; + originalContent?: string; + hasUnsavedChanges?: boolean; + }>) => void; + saveTemplateData: (data: TemplateFolder) => Promise; + addToolResult: (result: { toolCallId: string; tool: string; output: string }) => void; +} + +export function useAITools({ + messages, + templateData, + openFiles, + setTemplateData, + setOpenFiles, + saveTemplateData, + addToolResult, +}: UseAIToolsProps) { + // Track which tool calls we've already executed to prevent double-execution + const processedToolCallIds = useRef(new Set()); + + // Check if the most recent tool hasn't finished to prevent sending messages + const hasUnresolvedTools = useCallback(() => { + const lastMessage = messages[messages.length - 1] as ExtendedMessage | undefined; + if (lastMessage?.role !== "assistant") return false; + + const parts = (lastMessage as unknown as { parts?: unknown[] })?.parts ?? []; + return Array.isArray(parts) && parts.some((rawP: unknown) => { + if (!rawP || typeof rawP !== "object") return false; + const p = rawP as MessagePart; + const isTool = p.type === "tool-invocation" || + (typeof p.type === "string" && p.type.startsWith("tool-")); + const isUnresolved = !p.state || + (p.state !== "result" && p.state !== "output-available"); + const hasCall = p.toolInvocation && + typeof p.toolInvocation === "object" && + (p.toolInvocation as Record).state === "call"; + return isTool && isUnresolved && hasCall; + }); + }, [messages]); + + // Handle incoming client-side tool calls + useEffect(() => { + const lastMessage = messages[messages.length - 1] as ExtendedMessage | undefined; + if (lastMessage?.role !== "assistant") return; + + const rawParts: unknown[] = (lastMessage as unknown as { parts?: unknown[] }).parts ?? []; + + for (const rawPart of rawParts) { + const part = rawPart as Record; + const partType = part.type as string | undefined; + + // v3 static tool parts: type starts with "tool-" (e.g. "tool-read_file") + if (!partType?.startsWith("tool-")) continue; + + // Guard against re-execution: skip if already processed + const toolCallId = part.toolCallId as string | undefined; + if (!toolCallId) continue; + if (processedToolCallIds.current.has(toolCallId)) continue; + + // Only execute when input is fully available (not still streaming) + const state = part.state as string | undefined; + if (state === "output-available" || state === "output-streaming") continue; + if (state === "input-streaming") continue; + + const toolName = (part.toolName as string | undefined) ?? + partType.split("-").slice(1).join("-"); + const args = (part.input as Record | undefined) ?? + (part.args as Record | undefined) ?? {}; + + if (!toolCallId || !toolName) continue; + + let result: string; + + try { + if (toolName === "read_file") { + const { path } = args as { path?: string }; + if (!path || typeof path !== "string") { + result = `Error: read_file requires a "path" argument (e.g. "src/App.tsx")`; + } else { + const file = findFileByPath(templateData?.items || [], path); + result = (file && "content" in file && file.content !== undefined) + ? file.content + : `Error: File "${path}" not found`; + } + } else if (toolName === "edit_file") { + const { path, content } = args as { path?: string; content?: string }; + if (!path || typeof path !== "string") { + result = `Error: edit_file requires a "path" argument (e.g. "README.md")`; + } else if (content === undefined || content === null) { + result = `Error: edit_file requires a "content" argument with the full file contents`; + } else if (!templateData) { + result = `Error: Template data not loaded`; + } else { + const updatedItems = addOrUpdateFile(templateData.items, path, content as string); + const updatedTemplate = { ...templateData, items: updatedItems }; + setTemplateData(updatedTemplate); + + const updatedOpenFiles = openFiles.map((f) => { + const ext = f.fileExtension ? `.${f.fileExtension}` : ""; + const fullName = `${f.filename}${ext}`; + if (path.endsWith(fullName)) { + return { + ...f, + content: content as string, + hasUnsavedChanges: true + }; + } + return f; + }); + + setOpenFiles(updatedOpenFiles); + saveTemplateData(updatedTemplate).catch(console.error); + toast.success(`AI updated ${path}`); + result = `Successfully updated ${path}`; + } + } else if (toolName === "edit_multiple_files") { + const { changes } = args as { changes?: { path: string; content: string }[] }; + if (!changes || !Array.isArray(changes) || changes.length === 0) { + result = `Error: edit_multiple_files requires a "changes" array with at least one {path, content} entry`; + } else if (!templateData) { + result = `Error: Template data not loaded`; + } else { + let currentItems = templateData.items; + let currentOpenFiles = [...openFiles]; + + for (const change of changes) { + currentItems = addOrUpdateFile(currentItems, change.path, change.content); + currentOpenFiles = currentOpenFiles.map((f) => { + const ext = f.fileExtension ? `.${f.fileExtension}` : ""; + const fullName = `${f.filename}${ext}`; + if (change.path.endsWith(fullName)) { + return { ...f, content: change.content, hasUnsavedChanges: true }; + } + return f; + }); + } + + const updatedTemplate = { ...templateData, items: currentItems }; + setTemplateData(updatedTemplate); + setOpenFiles(currentOpenFiles); + saveTemplateData(updatedTemplate).catch(console.error); + toast.success(`AI scaffolded ${changes.length} files`); + result = `Successfully updated ${changes.length} files`; + } + } else if (toolName === "delete_file") { + const { path } = args as { path?: string }; + if (!path || typeof path !== "string") { + result = `Error: delete_file requires a "path" argument`; + } else if (!templateData) { + result = `Error: Template data not loaded`; + } else { + const updatedItems = deleteFileByPath(templateData.items, path); + const updatedTemplate = { ...templateData, items: updatedItems }; + setTemplateData(updatedTemplate); + + const updatedOpenFiles = openFiles.filter((f) => { + const ext = f.fileExtension ? `.${f.fileExtension}` : ""; + const fullName = `${f.filename}${ext}`; + return !path.endsWith(fullName); + }); + + setOpenFiles(updatedOpenFiles); + saveTemplateData(updatedTemplate).catch(console.error); + toast.success(`AI deleted ${path}`); + result = `Successfully deleted ${path}`; + } + } else { + result = `Error: Unknown tool ${toolName}`; + } + } catch (err: unknown) { + result = `Error: ${err instanceof Error ? err.message : String(err)}`; + } + + // Mark as processed BEFORE calling addToolResult to prevent re-execution on re-render + processedToolCallIds.current.add(toolCallId); + + addToolResult({ + toolCallId, + tool: toolName, + output: result, + }); + } + }, [messages, templateData, openFiles, setTemplateData, setOpenFiles, saveTemplateData, addToolResult]); + + return { hasUnresolvedTools }; +} \ No newline at end of file