/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * PlaygroundChat — chat panel that streams from Anthropic with the MCP * tool catalogue exposed via Claude's native `tools` parameter. * * Loop: * 1. User submits a prompt. * 2. We POST history + tools[] to Anthropic via @anthropic-ai/sdk * (dangerouslyAllowBrowser: true; key from BYOK localStorage). * 3. While the response contains `tool_use` blocks AND we're under the * 25-call hard cap: run each tool through `dispatch()`, push the * paired `tool_result` blocks back as a new user message, ask * Anthropic to continue. * 4. When the response has no more tool_use blocks (or we hit the cap), * flush the assistant message + render. * * Tool calls are rendered inline as collapsible cards so the agent's * reasoning trail is the page's main signal. */ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import Anthropic from '@anthropic-ai/sdk'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { ArrowUp, Check, ChevronDown, ChevronRight, Download, KeyRound, Loader2, RefreshCcw, Wrench } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getApiKeys, subscribeApiKeys, type ApiKeyConfig } from '@/services/api-keys'; import { getPlaygroundModel, setPlaygroundModel, subscribePlaygroundModel, } from '@/services/playground-model'; import { getByokModelsForSource } from '@/lib/llm/models'; import { ByokKeyModal } from '@/components/viewer/chat/ByokKeyModal'; import { anthropicToolDefinitions, dispatch, type AnthropicToolDef, type DispatchContext, type LoadedPlaygroundModel, type ToolDispatchResult, } from './playground-dispatcher'; import { playgroundFiles, formatBytes as formatFileBytes } from './playground-files'; import { playgroundUploads, usePlaygroundUploads, type UploadedFile } from './playground-uploads'; import { Paperclip, X } from 'lucide-react'; const MAX_TOOL_CALLS = 25; const MAX_TOKENS = 4096; const SYSTEM_PROMPT = `You are a BIM/IFC analyst driving @ifc-lite/mcp tools against a pre-loaded model. Be terse — the user is technical and time-pressed. Voice rules (strict): - NO greetings, preambles, sign-offs, or apologies. Skip "Sure!", "Done!", "Here's…", "Let me know if…". - NO emojis. Ever. - NO unprompted follow-up menus ("Would you like to…?", "I can also…"). The user will ask if they want more. - Aim for 1-3 short lines per reply unless the user asked for analysis. - When a tool call already prints the result inline, your text reply can be empty or one short sentence at most. - Markdown is fine for tables / data, but skip it for short confirmations. Tool etiquette: - Prefer tool calls over guessing — query_entities, get_entity, properties_unique, materials_list, geometry_volume/area, model_audit, bsdd_* are all available. - MUTATION IS ALLOWED. The full read+write surface is wired here, not just read-only. When the user asks to set, change, add, delete, create, batch-edit, etc., go ahead — call entity_set_property, entity_set_attribute, entity_create, entity_delete, entity_delete_property, mutation_batch directly. Don't refuse, don't ask "are you sure?", don't suggest they "run this in the stdio MCP instead". Browser-local model, undo via mutation_undo, save via model_save. - BCF authoring is also live: bcf_topic_create, bcf_topic_update, bcf_topic_close, bcf_viewpoint_create. The .bcfzip auto-stages after every BCF call — the user just clicks the pill. - Clash detection: for a general "find/run clashes" request on the single loaded model, call clash_check with NO a/b (every element vs every other). clash_matrix is INTER-discipline only (MEP×structure, HVAC×arch, …) and returns nothing on a single-discipline or architectural model — use it ONLY when the user explicitly asks for the discipline matrix. To turn clashes into BCF, call clash_bcf_export (rich topics: framed viewpoint + clashing components + severity/distance metadata) — NOT bcf_topic_create. It stages its own .bcfzip pill, so don't call bcf_export after. - The 3D viewer is INLINE on this page. When the user asks any 3D action (open, isolate, colorize, section, fly to, etc.), call viewer_open / viewer_* DIRECTLY. Do NOT call viewer_ask first. viewer_ask is only for when YOU are proactively suggesting the viewer. - Quote real GlobalIds + values when you cite something. Don't paraphrase. File attachments (IDS specs etc.): - The user can drag-drop .ids files (or any text file) onto the chat. When they do, a system note appears in their message: "[Attached file: foo.ids …]". Use ids_validate / ids_explain with ids_path: "foo.ids" — the playground resolves the upload behind the scenes. Do NOT ask the user to paste raw XML. - If the user mentions "this IDS" / "validate against the spec" but no attachment is in this turn, ask them to drop the .ids file onto the chat (don't ask for raw XML). Downloads (very important): - When a tool produces a file (bcf_export, model_save, export_ifc/csv/json, ids_validate), the playground UI automatically renders an inline "Get .bcf" / "Save IFC" / etc. button under that tool call. The user clicks it explicitly — files NEVER download automatically. - Don't tell the user "the file is in the Downloads panel" or "click the download button" — the button is right there, redundant. Just confirm what was produced in 1 line. - BCF auto-stages: every bcf_topic_* / bcf_viewpoint_create call already produces a fresh .bcfzip pill. DO NOT call bcf_export afterwards just to "create" the download — it's already there. Only call bcf_export if the user explicitly asks to re-export with custom settings. - After mutations, if the user is wrapping up, suggest model_save ONCE so they can grab the edited IFC. Don't keep re-suggesting.`; // ── message model ────────────────────────────────────────────────────────── interface ChatToolCall { id: string; name: string; args: Record; result?: ToolDispatchResult; startedAt: number; finishedAt?: number; } interface ChatMessage { id: string; role: 'user' | 'assistant'; text: string; toolCalls?: ChatToolCall[]; /** True while we're still streaming + looping tool calls. */ pending?: boolean; } // ── component ────────────────────────────────────────────────────────────── export function PlaygroundChat({ model, dispatchContext, }: { model: LoadedPlaygroundModel | null; /** Lets the chat thread the live viewer controller (etc.) into every * tool call so viewer_* tools can drive the inline canvas. */ dispatchContext?: () => DispatchContext; }): ReactNode { const [keys, setKeys] = useState(() => getApiKeys()); useEffect(() => subscribeApiKeys(() => setKeys(getApiKeys())), []); // Selected Claude model (Anthropic only — the playground driver uses // Anthropic's native tools API). Persisted to localStorage separately // from the viewer's chatActiveModel so the two pages can default // differently. const [selectedModel, setSelectedModel] = useState(() => getPlaygroundModel()); useEffect(() => subscribePlaygroundModel(() => setSelectedModel(getPlaygroundModel())), []); const anthropicModels = useMemo(() => getByokModelsForSource('anthropic'), []); // Shared BYOK key entry modal — same component the viewer chat uses. const [keyModalOpen, setKeyModalOpen] = useState(false); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isStreaming, setStreaming] = useState(false); const [error, setError] = useState(null); const scrollRef = useRef(null); const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); // Files attached since the last send. They land in the playgroundUploads // store (so the dispatcher can resolve them by name) AND get listed in // a "to send" array we drain on each submit. const uploads = usePlaygroundUploads(); const [pendingAttachments, setPendingAttachments] = useState([]); // Auto-scroll on new content useEffect(() => { const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages]); const tools = useMemo(() => anthropicToolDefinitions(), []); const send = useCallback( async (prompt: string, attached: UploadedFile[]) => { if (!model) { setError('Load a sample model first.'); return; } if (!keys.anthropicKey) { setError('Set an Anthropic key (top right).'); return; } setError(null); setStreaming(true); // Prepend a tiny system-note prefix when files were attached so the // agent knows the upload exists and how to reference it. Per-kind // hints so the agent picks the right tool without guessing. const attachNote = attached.length > 0 ? attached.map((u) => describeAttachment(u)).join('\n') + '\n\n' : ''; const fullPrompt = attachNote + prompt; const userMessage: ChatMessage = { id: rid(), role: 'user', text: fullPrompt }; const assistantMessage: ChatMessage = { id: rid(), role: 'assistant', text: '', toolCalls: [], pending: true, }; setMessages((m) => [...m, userMessage, assistantMessage]); try { await runConversation({ apiKey: keys.anthropicKey, modelId: selectedModel, tools, history: [...messages, userMessage], model, assistantId: assistantMessage.id, getDispatchContext: dispatchContext ?? (() => ({})), onUpdate: (patch) => { setMessages((m) => m.map((msg) => (msg.id === assistantMessage.id ? { ...msg, ...patch } : msg)), ); }, }); } catch (err) { setMessages((m) => m.map((msg) => msg.id === assistantMessage.id ? { ...msg, pending: false, text: msg.text || '— request failed —' } : msg, ), ); setError(err instanceof Error ? err.message : String(err)); } finally { setStreaming(false); } }, [keys.anthropicKey, selectedModel, model, tools, messages, dispatchContext], ); const onSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = input.trim(); if ((!trimmed && pendingAttachments.length === 0) || isStreaming) return; const attachedThisTurn = pendingAttachments; setInput(''); setPendingAttachments([]); void send(trimmed || '(see attached file)', attachedThisTurn); }; const attachFiles = useCallback(async (files: FileList | File[]) => { const list: UploadedFile[] = []; for (const f of Array.from(files)) { // 25 MB cap so a small IFC fits, but blocks rogue gigabyte drops. if (f.size > 25 * 1024 * 1024) { setError(`${f.name} is over 25 MB — too large for chat attachments. Use the sample picker instead.`); continue; } try { const entry = await playgroundUploads.add(f); list.push(entry); } catch (err) { setError(`Failed to read ${f.name}: ${err instanceof Error ? err.message : String(err)}`); } } if (list.length > 0) setPendingAttachments((prev) => [...prev, ...list]); }, []); /** Per-kind system note so the agent knows what to do with the file * without having to guess from the extension. */ function describeAttachment(u: UploadedFile): string { const ext = u.name.toLowerCase().split('.').pop() ?? ''; const head = `[Attached: ${u.name} · ${formatFileBytes(u.size)}]`; switch (ext) { case 'ids': return `${head} — IDS spec. Call ids_validate / ids_explain with ids_path: "${u.name}".`; case 'csv': case 'tsv': { // Inline a small preview so the agent can summarise without a // dedicated read_file tool. Cap at ~16 lines / 2 KB. const preview = u.text.split('\n').slice(0, 16).join('\n').slice(0, 2048); return `${head} — CSV data. First lines:\n\`\`\`\n${preview}\n\`\`\``; } case 'json': { const preview = u.text.slice(0, 2048); return `${head} — JSON. Preview:\n\`\`\`json\n${preview}${u.text.length > 2048 ? '\n…' : ''}\n\`\`\``; } case 'ifc': return `${head} — IFC file. The playground's loaded model is the primary one; treat this as background reference. Don't try to ingest it as a second model in v1.`; case 'bcf': case 'bcfzip': return `${head} — BCF bundle. The playground can only WRITE BCF in v1; tell the user this is read-only context.`; case 'xml': return `${head} — XML. If it looks like IDS, call ids_validate with ids_path: "${u.name}".`; default: { const preview = u.text.slice(0, 1024); return `${head}\n\`\`\`\n${preview}${u.text.length > 1024 ? '\n…' : ''}\n\`\`\``; } } } // ── render ────────────────────────────────────────────────────────────── return (
setKeyModalOpen(true)} selectedModel={selectedModel} anthropicModels={anthropicModels} onChangeModel={setPlaygroundModel} />
{messages.length === 0 ? ( setInput(p)} /> ) : (
    {messages.map((m) => (
  • ))}
)}
{error && (
{error}
)}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); if (e.dataTransfer.files.length > 0) void attachFiles(e.dataTransfer.files); }} className={cn('relative border-t p-3 transition-colors', dragOver ? 'border-[#d6ff3f]/60 bg-[#d6ff3f]/5' : 'border-white/10')} > {/* Pending attachment chips */} {pendingAttachments.length > 0 && (
{pendingAttachments.map((f) => ( {f.name} {formatFileBytes(f.size)} ))}
)}
{/* Attach */} { if (e.target.files) { void attachFiles(e.target.files); e.target.value = ''; // allow re-attaching the same file } }} />