diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index a9b7b6ec..31ce2883 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -24,13 +24,22 @@ WORKFLOW for every request that involves code: If the user asks you to create a new file, call the edit tool with the full content immediately. Do NOT tell the user what code to write - write it yourself using the tool.`; - +const MAX_MENTIONED_FILES_TOTAL_SIZE = 100_000; const RequestBodySchema = z.object({ - messages: z.array(z.any()).max(100), - provider: z.enum(["gemini", "groq", "mistral"]).optional().default("gemini"), - fileTree: z.string().max(50_000).optional(), - userApiKey: z.string().max(256).optional(), + messages: z.array(z.any()).max(100), + provider: z.enum(["gemini", "groq", "mistral"]).optional().default("gemini"), + fileTree: z.string().max(50_000).optional(), + userApiKey: z.string().max(256).optional(), + mentionedFiles: z + .array( + z.object({ + path: z.string().max(500), + content: z.string().max(20_000), + }), + ) + .max(10) + .optional(), }); /** @@ -38,156 +47,219 @@ const RequestBodySchema = z.object({ * enforces rate limits, selects model provider, and streams model output. */ export async function POST(request: NextRequest) { - try { - - // Rate limiting: 20 requests per minute per IP - const ip = getClientIp(request); - const { allowed, remaining } = await rateLimit(ip, 20, 60_000); - - if (!allowed) { - return NextResponse.json( - { success: false, error: "Rate limit exceeded. Please wait before sending more messages." }, - { - status: 429, - headers: { - "Retry-After": "60", - "X-RateLimit-Remaining": String(remaining), - }, - } - ); - } + try { + // Rate limiting: 20 requests per minute per IP + const ip = getClientIp(request); + const { allowed, remaining } = await rateLimit(ip, 20, 60_000); - const session = await auth(); - const isAuthenticated = !!session?.user; - - const body = await request.json(); - const result = RequestBodySchema.safeParse(body); - - if (!result.success) { - return NextResponse.json( - { success: false, error: "Invalid request", details: result.error.issues }, - { status: 400 } - ); - } + if (!allowed) { + return NextResponse.json( + { + success: false, + error: + "Rate limit exceeded. Please wait before sending more messages.", + }, + { + status: 429, + headers: { + "Retry-After": "60", + "X-RateLimit-Remaining": String(remaining), + }, + }, + ); + } - const { messages, provider, fileTree, userApiKey } = result.data; + const session = await auth(); + const isAuthenticated = !!session?.user; - if (!session?.user?.id && (!userApiKey || userApiKey.trim() === "")) { - return NextResponse.json( - { success: false, error: "Unauthorized: Please log in or provide your own API key in settings." }, - { status: 401 } - ); - } + const body = await request.json(); + const result = RequestBodySchema.safeParse(body); - const systemInstruction = fileTree - ? `${SYSTEM_PROMPT}\n\nProject file tree:\n${fileTree}` - : SYSTEM_PROMPT; - - let model; - - if (provider === "gemini") { - const apiKey = userApiKey || (isAuthenticated ? process.env.GEMINI_API_KEY : undefined); - if (!apiKey) { - return NextResponse.json( - { - success: false, - error: isAuthenticated - ? "Gemini API key not configured. Add your key in AI settings." - : "Unauthorized", - }, - { status: isAuthenticated ? 400 : 401 } - ); - } - const google = createGoogleGenerativeAI({ apiKey }); - model = google("gemini-2.0-flash"); - } else if (provider === "groq") { - const apiKey = userApiKey || (isAuthenticated ? process.env.GROQ_API_KEY : undefined); - if (!apiKey) { - return NextResponse.json( - { - success: false, - error: isAuthenticated - ? "Groq API key not configured. Add your key in AI settings." - : "Unauthorized" - }, - { status: isAuthenticated ? 400 : 401 } - ); - } - const groq = createGroq({ apiKey }); - model = groq("llama-3.1-70b-versatile"); - } else if (provider === "mistral") { - const apiKey = userApiKey || (isAuthenticated ? process.env.MISTRAL_API_KEY : undefined); - if (!apiKey) { - return NextResponse.json( - { - success: false, - error: isAuthenticated - ? "Mistral API key not configured. Add your key in AI settings." - : "Unauthorized" - }, - { status: isAuthenticated ? 400 : 401 } - ); - } - const mistral = createMistral({ apiKey }); - model = mistral("mistral-small-latest"); - } else { - return NextResponse.json( - { success: false, error: "Invalid provider" }, - { status: 400 } - ); - } + if (!result.success) { + return NextResponse.json( + { + success: false, + error: "Invalid request", + details: result.error.issues, + }, + { status: 400 }, + ); + } - type MessagePart = { type: string; text: string }; - - const validRoles = ["system", "user", "assistant", "data", "tool"]; - const sanitizedMessages: Omit[] = []; - for (const raw of messages) { - if (!raw || typeof raw !== "object") { - return NextResponse.json( - { success: false, error: "Invalid request: each message must be an object" }, - { status: 400 } - ); - } - - const role = (raw as Record).role; - if (typeof role !== "string" || !validRoles.includes(role)) { - return NextResponse.json( - { success: false, error: "Invalid request: each message must have a valid role" }, - { status: 400 } - ); - } - - const m = raw as { role: "system" | "user" | "assistant" | "data" | "tool"; content?: string; parts?: MessagePart[] }; - if (Array.isArray(m.parts)) { - // Ensure each part has at least a type property - if (!m.parts.every(p => p && typeof p === "object" && "type" in p)) { - return NextResponse.json( - { success: false, error: "Invalid request: malformed parts" }, - { status: 400 } - ); - } - sanitizedMessages.push({ role: m.role as UIMessage['role'], parts: m.parts as UIMessage['parts'] }); - continue; - } - sanitizedMessages.push({ - role: m.role as UIMessage['role'], - parts: typeof m.content === "string" && m.content.trim() - ? [{ type: "text" as const, text: m.content }] - : [], - }); - } + const { messages, provider, fileTree, userApiKey, mentionedFiles } = + result.data; + + if (!session?.user?.id && (!userApiKey || userApiKey.trim() === "")) { + return NextResponse.json( + { + success: false, + error: + "Unauthorized: Please log in or provide your own API key in settings.", + }, + { status: 401 }, + ); + } + + // validate total size of mentioned files + if (mentionedFiles && mentionedFiles.length > 0) { + const totalSize = mentionedFiles.reduce( + (sum, file) => sum + file.content.length, + 0, + ); + if (totalSize > MAX_MENTIONED_FILES_TOTAL_SIZE) { + return NextResponse.json( + { + success: false, + error: + "Referenced files exceed the maximum total size. Reduce file sizes or number of mentioned files.", + }, + { status: 400 }, + ); + } + } + + let systemInstruction = SYSTEM_PROMPT; - const resultStream = streamText({ - model, - messages: await convertToModelMessages(sanitizedMessages, { - ignoreIncompleteToolCalls: true - }), - system: systemInstruction, - tools, + if (fileTree) { + systemInstruction += `\n\nProject file tree:\n${fileTree}`; + } + + let model; + + if (provider === "gemini") { + const apiKey = + userApiKey || + (isAuthenticated ? process.env.GEMINI_API_KEY : undefined); + if (!apiKey) { + return NextResponse.json( + { + success: false, + error: isAuthenticated + ? "Gemini API key not configured. Add your key in AI settings." + : "Unauthorized", + }, + { status: isAuthenticated ? 400 : 401 }, + ); + } + const google = createGoogleGenerativeAI({ apiKey }); + model = google("gemini-2.0-flash"); + } else if (provider === "groq") { + const apiKey = + userApiKey || (isAuthenticated ? process.env.GROQ_API_KEY : undefined); + if (!apiKey) { + return NextResponse.json( + { + success: false, + error: isAuthenticated + ? "Groq API key not configured. Add your key in AI settings." + : "Unauthorized", + }, + { status: isAuthenticated ? 400 : 401 }, + ); + } + const groq = createGroq({ apiKey }); + model = groq("llama-3.3-70b-versatile"); + } else if (provider === "mistral") { + const apiKey = + userApiKey || + (isAuthenticated ? process.env.MISTRAL_API_KEY : undefined); + if (!apiKey) { + return NextResponse.json( + { + success: false, + error: isAuthenticated + ? "Mistral API key not configured. Add your key in AI settings." + : "Unauthorized", + }, + { status: isAuthenticated ? 400 : 401 }, + ); + } + const mistral = createMistral({ apiKey }); + model = mistral("mistral-small-latest"); + } else { + return NextResponse.json( + { success: false, error: "Invalid provider" }, + { status: 400 }, + ); + } + + type MessagePart = { type: string; text: string }; + + const validRoles = ["system", "user", "assistant", "data", "tool"]; + const sanitizedMessages: Omit[] = []; + for (const raw of messages) { + if (!raw || typeof raw !== "object") { + return NextResponse.json( + { + success: false, + error: "Invalid request: each message must be an object", + }, + { status: 400 }, + ); + } + + const role = (raw as Record).role; + if (typeof role !== "string" || !validRoles.includes(role)) { + return NextResponse.json( + { + success: false, + error: "Invalid request: each message must have a valid role", + }, + { status: 400 }, + ); + } + + const m = raw as { + role: "system" | "user" | "assistant" | "data" | "tool"; + content?: string; + parts?: MessagePart[]; + }; + if (Array.isArray(m.parts)) { + // Ensure each part has at least a type property + if (!m.parts.every((p) => p && typeof p === "object" && "type" in p)) { + return NextResponse.json( + { success: false, error: "Invalid request: malformed parts" }, + { status: 400 }, + ); + } + sanitizedMessages.push({ + role: m.role as UIMessage["role"], + parts: m.parts as UIMessage["parts"], }); + continue; + } + sanitizedMessages.push({ + role: m.role as UIMessage["role"], + parts: + typeof m.content === "string" && m.content.trim() + ? [{ type: "text" as const, text: m.content }] + : [], + }); + } - return resultStream.toUIMessageStreamResponse(); - } catch (error: unknown) { - return handleApiError(error, "POST /api/chat"); + // add referenced files as a user message if they exist + if (mentionedFiles && mentionedFiles.length > 0) { + let referencedFilesContent = "Referenced files:\n\n"; + for (const file of mentionedFiles) { + referencedFilesContent += `File: ${file.path}\n\n${file.content}\n\n----------------------------------------\n\n`; + } + sanitizedMessages.push({ + role: "user", + parts: [{ type: "text", text: referencedFilesContent }], + }); } + + const resultStream = streamText({ + model, + messages: await convertToModelMessages(sanitizedMessages, { + ignoreIncompleteToolCalls: true, + }), + system: systemInstruction, + tools, + }); + + return resultStream.toUIMessageStreamResponse(); + } catch (error: unknown) { + return handleApiError(error, "POST /api/chat"); + } } diff --git a/modules/playground/components/ai-chat-panel.tsx b/modules/playground/components/ai-chat-panel.tsx index dfc86196..4ddc313f 100644 --- a/modules/playground/components/ai-chat-panel.tsx +++ b/modules/playground/components/ai-chat-panel.tsx @@ -1,34 +1,41 @@ "use client"; import { TIMEOUTS } from "@/lib/constants/config"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { - Bot, - Send, - Trash2, - Loader2, - Sparkles, - User, - Wrench, - Zap, - Code2, - ChevronDown, + Bot, + Send, + Trash2, + Loader2, + Sparkles, + User, + Wrench, + Zap, + Code2, + ChevronDown, + File, } from "lucide-react"; import { - useAI, - type AIProvider, - addOrUpdateFile, - deleteFileByPath, - findFileByPath, - collectFilePaths + useAI, + type AIProvider, + addOrUpdateFile, + deleteFileByPath, + findFileByPath, + collectFilePaths, } from "@/modules/playground/hooks/useAI"; import { useFileExplorer } from "@/modules/playground/hooks/useFileExplorer"; import { toast } from "sonner"; @@ -36,475 +43,750 @@ import type { TemplateFolder } from "@/modules/playground/lib/path-to-json"; import { useChat } from "@ai-sdk/react"; interface AIChatPanelProps { - templateData: TemplateFolder | null; - saveTemplateData: (data: TemplateFolder) => Promise; + templateData: TemplateFolder | null; + saveTemplateData: (data: TemplateFolder) => Promise; } interface MessagePart { - type?: string; - text?: string; - toolCallId?: string; - toolName?: string; - state?: string; - input?: Record; - [key: string]: unknown; + type?: string; + text?: string; + toolCallId?: string; + toolName?: string; + state?: string; + input?: Record; + [key: string]: unknown; } interface ExtendedMessage { - parts?: MessagePart[]; - content?: string; + 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: }, + { id: "gemini", label: "Gemini", icon: }, + { id: "groq", label: "Groq", icon: }, + { id: "mistral", label: "Mistral", icon: }, ]; +const extractMentionedFiles = ( + message: string, + templateItems: TemplateFolder["items"], +): { path: string; content: string }[] => { + const mentionedFiles: { path: string; content: string }[] = []; + const regex = /@([^\s]+)/g; + let match; + + while ((match = regex.exec(message)) !== null) { + let path = match[1]; + // strip trailing punctuation that might be attached to the mention + path = path.replace(/[,.;:!?)\]}>]+$/, ""); + const file = findFileByPath(templateItems, path); + + if (file && "content" in file && typeof file.content === "string") { + mentionedFiles.push({ + path, + content: file.content, + }); + } + } + + return mentionedFiles; +}; + export default function AIChatPanel({ - templateData, - saveTemplateData, + templateData, + saveTemplateData, }: AIChatPanelProps) { - const { - isChatOpen, - closeChat, - provider, - setProvider, - getUserApiKey, - } = useAI(); - - const { openFiles, setOpenFiles, setTemplateData } = useFileExplorer(); - const [showProviderPicker, setShowProviderPicker] = useState(false); - - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const pickerRef = useRef(null); - - // Memoize the file tree string to avoid re-computing on every render - const fileTree = useMemo( - () => templateData ? collectFilePaths(templateData.items).join("\n") : "", - [templateData] - ); - - const [inputValue, setInputValue] = useState(""); - - const { - messages, - status, - setMessages, - addToolResult, - sendMessage: chatSendMessage, - } = useChat({ - onError: (err: Error) => { - console.error("AI Chat Error:", err); - toast.error(err.message || "An error occurred"); - } + const { isChatOpen, closeChat, provider, setProvider, getUserApiKey } = + useAI(); + + const { openFiles, setOpenFiles, setTemplateData } = useFileExplorer(); + const [showProviderPicker, setShowProviderPicker] = useState(false); + const [showMentionSuggestions, setShowMentionSuggestions] = useState(false); + const [mentionSuggestions, setMentionSuggestions] = useState([]); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const pickerRef = useRef(null); + const mentionRef = useRef(null); + + // Memoize the file tree string to avoid re-computing on every render + const fileTree = useMemo( + () => (templateData ? collectFilePaths(templateData.items).join("\n") : ""), + [templateData], + ); + + const allFilePaths = useMemo( + () => (templateData ? collectFilePaths(templateData.items) : []), + [templateData], + ); + + const [inputValue, setInputValue] = useState(""); + + const { + messages, + status, + setMessages, + addToolResult, + sendMessage: chatSendMessage, + } = useChat({ + onError: (err: Error) => { + console.error("AI Chat Error:", err); + toast.error(err.message || "An error occurred"); + }, + }); + + // 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" + ); }); - // 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"); + // find the active mention token near the cursor + const updateMentionSuggestions = useCallback( + (value: string) => { + const cursorPos = inputRef.current?.selectionStart ?? value.length; + const textBeforeCursor = value.slice(0, cursorPos); + + let lastAtIndex = -1; + for (let i = cursorPos - 1; i >= 0; i--) { + if (textBeforeCursor[i] === "@") { + const afterAt = textBeforeCursor.slice(i + 1, cursorPos); + if (!afterAt.includes(" ") && !afterAt.includes("\n")) { + lastAtIndex = i; + break; + } } - ); - - const sendMessage = useCallback(() => { - const trimmed = inputValue.trim(); - if (!trimmed || isLoading || hasUnresolvedTools) return; - chatSendMessage( - { text: trimmed }, - { - body: { - provider, - fileTree, - userApiKey: getUserApiKey(provider) || undefined, - }, - } - ); - setInputValue(""); - if (inputRef.current) { - inputRef.current.style.height = "auto"; + } + + if (lastAtIndex === -1) { + setShowMentionSuggestions(false); + setMentionSuggestions([]); + return; + } + + const query = textBeforeCursor.slice(lastAtIndex + 1); + if (query.includes(" ") || query.includes("\n")) { + setShowMentionSuggestions(false); + setMentionSuggestions([]); + return; + } + + const matches = allFilePaths + .filter((path) => path.toLowerCase().includes(query.toLowerCase())) + .slice(0, 8); + + if (matches.length > 0) { + setMentionSuggestions(matches); + setShowMentionSuggestions(true); + } else { + setShowMentionSuggestions(false); + setMentionSuggestions([]); + } + }, + [allFilePaths], + ); + + const insertMention = useCallback( + (selectedPath: string) => { + const cursorPos = inputRef.current?.selectionStart ?? inputValue.length; + const textBeforeCursor = inputValue.slice(0, cursorPos); + const textAfterCursor = inputValue.slice(cursorPos); + + let lastAtIndex = -1; + for (let i = cursorPos - 1; i >= 0; i--) { + if (textBeforeCursor[i] === "@") { + const afterAt = textBeforeCursor.slice(i + 1, cursorPos); + if (!afterAt.includes(" ") && !afterAt.includes("\n")) { + lastAtIndex = i; + break; + } } - }, [inputValue, isLoading, hasUnresolvedTools, chatSendMessage, provider, fileTree, getUserApiKey]); + } - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + if (lastAtIndex === -1) return; - useEffect(() => { - if (isChatOpen) setTimeout(() => inputRef.current?.focus(), TIMEOUTS.CHAT_INPUT_FOCUS); - }, [isChatOpen]); + const newText = + textBeforeCursor.slice(0, lastAtIndex) + + "@" + + selectedPath + + textAfterCursor; + setInputValue(newText); + setShowMentionSuggestions(false); + setMentionSuggestions([]); - // Close provider picker on outside click - useEffect(() => { - const handleClick = (e: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { - setShowProviderPicker(false); - } - }; - if (showProviderPicker) document.addEventListener("mousedown", handleClick); - 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)); - } + setTimeout(() => { + if (inputRef.current) { + const newCursorPos = lastAtIndex + 1 + selectedPath.length; + inputRef.current.selectionStart = newCursorPos; + inputRef.current.selectionEnd = newCursorPos; + inputRef.current.focus(); } + }, 0); + }, + [inputValue], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + updateMentionSuggestions(newValue); + }, + [updateMentionSuggestions], + ); + + const sendMessage = useCallback(() => { + const trimmed = inputValue.trim(); + if (!trimmed || isLoading || hasUnresolvedTools) return; + + const mentionedFiles = extractMentionedFiles( + trimmed, + templateData?.items || [], + ); - 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}`; + chatSendMessage( + { text: trimmed }, + { + body: { + provider, + fileTree, + userApiKey: getUserApiKey(provider) || undefined, + mentionedFiles, + }, + }, + ); + setInputValue(""); + setShowMentionSuggestions(false); + setMentionSuggestions([]); + if (inputRef.current) { + inputRef.current.style.height = "auto"; + } + }, [ + inputValue, + isLoading, + hasUnresolvedTools, + chatSendMessage, + provider, + fileTree, + getUserApiKey, + templateData, + ]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + useEffect(() => { + if (isChatOpen) + setTimeout(() => inputRef.current?.focus(), TIMEOUTS.CHAT_INPUT_FOCUS); + }, [isChatOpen]); + + // Close provider picker and mention dropdown on outside click + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowProviderPicker(false); + } + if ( + mentionRef.current && + !mentionRef.current.contains(e.target as Node) + ) { + setShowMentionSuggestions(false); + } + }; + if (showProviderPicker || showMentionSuggestions) + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [showProviderPicker, showMentionSuggestions]); + + // Track which tool calls we've already executed to prevent double-execution + const processedToolCallIds = useRef(new Set()); + + const clearChat = () => { + setMessages([]); + processedToolCallIds.current.clear(); + }; + + // 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, + }; } - } catch (err: unknown) { - result = `Error: ${err instanceof Error ? err.message : String(err)}`; + return f; + }); } - // 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]); + 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}`; } - }, [messages, templateData, openFiles, setTemplateData, setOpenFiles, saveTemplateData, addToolResult]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - }; - - const clearChat = () => setMessages([]); - const currentProvider = PROVIDERS.find((p) => p.id === provider) || PROVIDERS[0]; - - return ( - !open && closeChat()}> - - -
-
-
- -
-
- AI Assistant - - Project Context Enabled - -
-
- + } 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(); + sendMessage(); + } + }; + + const currentProvider = + PROVIDERS.find((p) => p.id === provider) || PROVIDERS[0]; + + return ( + !open && closeChat()}> + + +
+
+
+ +
+
+ + AI Assistant + + + Project Context Enabled + +
+
+ +
+
+ +
+ {messages.length === 0 && ( +
+
+ +
+
+

+ How can I help you code? +

+

+ I can read your configuration, scaffold new components, or + debug existing files. +

+
+
+ )} + + {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}
- - -
- {messages.length === 0 && ( -
-
- -
-
-

How can I help you code?

-

- I can read your configuration, scaffold new components, or debug existing files. -

-
+
+ +
+
+ )} + {msg.role === "assistant" && ( +
+
+ +
+
+ {textContent && ( +
+ {textContent}
- )} - - {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; - + )} + {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 ( -
- {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 ? "✓" : } - -
- ); - })} -
-
- )} -
- ); - })} - - {isLoading && messages.length > 0 && messages[messages.length - 1].role !== "assistant" && ( -
- - Thinking... -
- )} -
-
- -
-
-