// app.js import * as openaiApi from "./api_openai.js"; import * as anthropicApi from "./api_anthropic.js"; import * as openrouterApi from "./api_openrouter.js"; const PROVIDERS = { openai: { api: openaiApi, apiKeyLabel: "OpenAI API key", apiKeyPlaceholder: "sk-...", baseUrlLabel: "Base URL", baseUrlPlaceholder: "https://api.openai.com/v1", baseUrlDefault: "https://api.openai.com/v1", models: [ { value: "gpt-5", label: "gpt-5" }, { value: "gpt-4.1", label: "gpt-4.1" }, ], }, anthropic: { api: anthropicApi, apiKeyLabel: "Anthropic API key", apiKeyPlaceholder: "sk-ant-...", models: [ { value: "claude-sonnet-4-6", label: "claude-sonnet-4-6" }, { value: "claude-opus-4-6", label: "claude-opus-4-6" }, { value: "claude-haiku-4-5-20251001", label: "claude-haiku-4-5" }, ], }, gemini: { api: openaiApi, apiKeyLabel: "Gemini API key", apiKeyPlaceholder: "AIza...", baseUrlLabel: "Base URL", baseUrlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/", baseUrlDefault: "https://generativelanguage.googleapis.com/v1beta/openai/", models: [ { value: "gemini-3-flash-preview", label: "gemini-3-flash-preview" }, { value: "gemini-2.5-flash", label: "gemini-2.5-flash" }, { value: "gemini-2.5-pro", label: "gemini-2.5-pro" }, ], }, openrouter: { api: openrouterApi, apiKeyLabel: "OpenRouter API key", apiKeyPlaceholder: "sk-or-v1-...", baseUrlLabel: "Base URL", baseUrlPlaceholder: "https://openrouter.ai/api/v1", baseUrlDefault: "https://openrouter.ai/api/v1", models: [ { value: "openai/gpt-oss-20b", label: "gpt-oss-20b" }, { value: "openai/gpt-oss-120b", label: "gpt-oss-120b" }, { value: "mistralai/mistral-small-3.2-24b-instruct", label: "mistral-small-3.2" }, { value: "openai/gpt-4.1", label: "gpt-4.1" }, { value: "anthropic/claude-sonnet-4-5", label: "claude-sonnet-4-5" }, { value: "google/gemini-2.5-pro-preview", label: "gemini-2.5-pro" }, ], }, }; const $ = (id) => document.getElementById(id); const statusEl = $("status"); const msgsEl = $("msgs"); const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); const apiKeyLabelEl = $("apiKeyLabel"); const baseUrlRowEl = $("baseUrlRow"); const baseUrlLabelEl = $("baseUrlLabel"); const baseUrlEl = $("baseUrl"); const thinkingIndicatorEl = $("thinkingIndicator"); const modelEl = $("model"); const providerEls = document.querySelectorAll('input[name="provider"]'); const ifcFileEl = $("ifcFile"); const newBtn = $("newModel"); const downloadBtn = $("downloadIfc"); function getProviderValue() { return document.querySelector('input[name="provider"]:checked')?.value || "openai"; } function onProviderChange() { const provider = PROVIDERS[getProviderValue()]; apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}stored in browser memory; only sent to provider servers`; apiKeyEl.placeholder = provider.apiKeyPlaceholder; baseUrlRowEl.hidden = !provider.baseUrlDefault; if (provider.baseUrlDefault) { baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}override the API endpoint for OpenAI-compatible providers`; baseUrlEl.placeholder = provider.baseUrlPlaceholder; baseUrlEl.value = provider.baseUrlDefault; } else { baseUrlEl.value = ""; baseUrlEl.placeholder = ""; } modelEl.innerHTML = provider.models.map(m => ``).join(""); } for (const providerEl of providerEls) { providerEl.addEventListener("change", onProviderChange); } onProviderChange(); function setBusy(isBusy, reason = "") { const controls = [ $("send"), $("newModel"), $("downloadIfc"), $("ifcFile"), ]; for (const el of controls) el.disabled = isBusy; $("input").disabled = isBusy; const browseBtn = $("browseBtn"); if (browseBtn) { browseBtn.classList.toggle("disabled", isBusy); browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false"); browseBtn.tabIndex = isBusy ? -1 : 0; } sendBtn.innerHTML = isBusy ? `` : `Send send`; setStatus(isBusy ? (reason || "Working…") : "Ready"); } function escapeHtml(text) { return text .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function sanitizeUrl(url) { try { const parsed = new URL(url, window.location.href); if (["http:", "https:", "mailto:"].includes(parsed.protocol)) { return parsed.href; } } catch { } return null; } function renderInlineMarkdown(text) { const placeholders = []; const addPlaceholder = (html) => { const token = `@@MD${placeholders.length}@@`; placeholders.push({ token, html }); return token; }; let rendered = text; rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`${escapeHtml(code)}`)); rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { const href = sanitizeUrl(url); if (!href) { return `${label} (${url})`; } return addPlaceholder( `${escapeHtml(label)}` ); }); rendered = escapeHtml(rendered); rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1"); rendered = rendered.replace(/\*([^*]+)\*/g, "$1"); rendered = rendered.replace(/_([^_]+)_/g, "$1"); for (const placeholder of placeholders) { rendered = rendered.replaceAll(placeholder.token, placeholder.html); } return rendered; } function renderMarkdown(text) { const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); const html = []; let paragraphLines = []; let quoteLines = []; let listType = null; let listItems = []; const flushParagraph = () => { if (!paragraphLines.length) return; html.push(`

${renderInlineMarkdown(paragraphLines.join(" "))}

`); paragraphLines = []; }; const flushQuote = () => { if (!quoteLines.length) return; const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("
"); html.push(`

${quoteBody}

`); quoteLines = []; }; const flushList = () => { if (!listItems.length || !listType) return; const items = listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join(""); html.push(`<${listType}>${items}`); listType = null; listItems = []; }; const flushAll = () => { flushParagraph(); flushQuote(); flushList(); }; for (let index = 0; index < lines.length; index++) { const line = lines[index]; const trimmed = line.trim(); if (trimmed.startsWith("```")) { flushAll(); const language = trimmed.slice(3).trim(); const codeLines = []; index += 1; while (index < lines.length && !lines[index].trim().startsWith("```")) { codeLines.push(lines[index]); index += 1; } const languageClass = language ? ` class="language-${escapeHtml(language)}"` : ""; html.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); continue; } if (!trimmed) { flushAll(); continue; } const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { flushAll(); const level = headingMatch[1].length; html.push(`${renderInlineMarkdown(headingMatch[2])}`); continue; } const quoteMatch = trimmed.match(/^>\s?(.*)$/); if (quoteMatch) { flushParagraph(); flushList(); quoteLines.push(quoteMatch[1]); continue; } if (quoteLines.length) { flushQuote(); } const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/); if (unorderedListMatch) { flushParagraph(); if (listType && listType !== "ul") { flushList(); } listType = "ul"; listItems.push(unorderedListMatch[1]); continue; } const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/); if (orderedListMatch) { flushParagraph(); if (listType && listType !== "ol") { flushList(); } listType = "ol"; listItems.push(orderedListMatch[1]); continue; } if (listItems.length) { flushList(); } paragraphLines.push(trimmed); } flushAll(); return html.join(""); } function addMessage(role, text) { if (text.ok) { text = text.data; } if (typeof text !== "string") { text = JSON.stringify(text, null, 2); } const wrap = document.createElement("div"); wrap.className = `msg ${role}`; wrap.innerHTML = `
    ${role}${role === "tool" ? '' : ''}
    `; const bubble = wrap.querySelector(".bubble"); if (role === "assistant") { bubble.classList.add("markdown-content"); bubble.innerHTML = renderMarkdown(text); } else { bubble.textContent = text; } bubble.onclick = function () { if (bubble.scrollHeight > 100 && role === "tool") { const expanded = bubble.style.maxHeight === 'none'; bubble.style.maxHeight = expanded ? '' : 'none'; bubble.style.borderBottom = expanded ? '' : 'dotted 2px gray'; wrap.querySelector(".chevron").style.transform = expanded ? '' : 'rotate(90deg)'; } } msgsEl.insertBefore(wrap, thinkingIndicatorEl); msgsEl.scrollTop = msgsEl.scrollHeight; } function setStatus(text) { statusEl.textContent = text; thinkingIndicatorEl.hidden = text !== "Thinking…"; msgsEl.scrollTop = msgsEl.scrollHeight; } const worker = new Worker("./ifc_worker.js", { type: "module" }); function callWorker(type, payload = {}) { return new Promise((resolve, reject) => { const id = crypto.randomUUID(); const onMsg = (ev) => { const msg = ev.data; if (!msg || msg.id !== id) return; worker.removeEventListener("message", onMsg); if (msg.ok) resolve(msg); else reject(new Error(msg.error || "Worker error")); }; worker.addEventListener("message", onMsg); worker.postMessage({ id, type, payload }); }); } // ---- Tool schemas (should match ifcmcp.core openai_tools()) ---- const tools = [ { type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory.", parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.", parameters: { type: "object", properties: {}, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_tree", description: "Get the full spatial hierarchy tree.", parameters: { type: "object", properties: {}, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_info", description: "Inspect an entity by STEP id.", parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.", parameters: { type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } }, required: ["element_id"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_clash", description: "Run clash/clearance checks for an element.", parameters: { type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } }, required: ["element_id"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.", parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.", parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.", parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_schema", description: "Return IFC class documentation for an entity type.", parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false } } }, { type: "function", function: { name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false } } }, ]; const SYSTEM_INSTRUCTIONS = ` You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model. Rules: - If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree. - If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit. - If there is no model and the user wants to create one, call ifc_new. - After edits, explain what changed and suggest downloading the IFC. Be concise. Avoid dumping huge trees unless asked. `; let messages = []; // running conversation state (Chat Completions style) async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); const provider = PROVIDERS[getProviderValue()]; const { chat } = provider.api; const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; messages.push({ role: "user", content: userText }); for (let i = 0; i < 64; i++) { const response = await chat({ apiKey, baseURL, model: modelEl.value, messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages], tools, }); const message = response.choices?.[0]?.message; if (!message) throw new Error("No message in response"); messages.push(message); if (message.content) addMessage("assistant", message.content); const calls = message.tool_calls ?? []; if (calls.length === 0) return; for (const call of calls) { let args = {}; try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; } catch { args = {}; } addMessage("tool", `→ ${call.function.name}(${JSON.stringify(args)})`); const toolRes = await callWorker("toolCall", { name: call.function.name, args }); messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(toolRes.result), }); addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`); } } addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request."); } sendBtn.onclick = async () => { const text = inputEl.value.trim(); if (!text) return; inputEl.value = ""; addMessage("user", text); try { setBusy(true, "Thinking…"); await runAgentTurn(text); setBusy(false, "Ready"); } catch (e) { setBusy(false, "Error"); addMessage("assistant", `Error: ${e.message}`); } }; inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendBtn.click(); } }); ifcFileEl.onchange = async () => { const f = ifcFileEl.files?.[0]; if (!f) return; setBusy(true, "Loading IFC into Pyodide…"); const buf = await f.arrayBuffer(); try { const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]); addMessage("assistant", r.result); setBusy(false, "Ready"); } catch (e) { setStatus(true, "Error"); addMessage("assistant", `Load error: ${e.message}`); } }; newBtn.onclick = async () => { try { setBusy(true, "Creating new model…"); const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } }); addMessage("assistant", `New model: ${JSON.stringify(r.result)}`); setBusy(false, "Ready"); } catch (e) { setBusy(true, "Error"); addMessage("assistant", `Error: ${e.message}`); } }; downloadBtn.onclick = async () => { try { setBusy(true, "Exporting IFC…"); const r = await callWorker("exportIfc", {}); const blob = new Blob([r.bytes], { type: "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = r.filename || "model.ifc"; a.click(); URL.revokeObjectURL(url); setBusy(false, "Ready"); } catch (e) { setBusy(true, "Error"); addMessage("assistant", `Export error: ${e.message}`); } }; (async () => { try { setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…"); await callWorker("init", {}); setBusy(false, "Ready"); } catch (e) { setBusy(true, "Error"); addMessage("assistant", `Worker init failed: ${e.message}`); } })();