+ `;
+ const bubble = wrap.querySelector(".bubble");
+ bubble.textContent = text;
+ bubble.onclick = function () {
+ if (bubble.scrollHeight > 100 && role === "tool") {
+ bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
+ bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
+ }
+ }
+ msgsEl.appendChild(wrap);
+ msgsEl.scrollTop = msgsEl.scrollHeight;
+}
+
+function setStatus(text) {
+ statusEl.textContent = text;
+}
+
+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 });
+ });
+}
+
+// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
+// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
+const tools = [
+ {
+ type: "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", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
+ parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
+ },
+ {
+ type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
+ parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
+ },
+ {
+ type: "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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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 inputItems = []; // running conversation state (Responses API style)
+
+async function openAIResponsesCreate({ apiKey, model, input, tools }) {
+ const res = await fetch("https://api.openai.com/v1/responses", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ instructions: SYSTEM_INSTRUCTIONS,
+ tools,
+ input,
+ }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`OpenAI error ${res.status}: ${text}`);
+ }
+ return await res.json();
+}
+
+function extractAssistantText(response) {
+ const out = [];
+ for (const item of response.output ?? []) {
+ if (item.type === "message" && item.role === "assistant") {
+ for (const c of item.content ?? []) {
+ if (c.type === "output_text") out.push(c.text);
+ }
+ }
+ }
+ return out.join("\n").trim();
+}
+
+async function runAgentTurn(userText) {
+ const apiKey = apiKeyEl.value.trim();
+ if (!apiKey) throw new Error("Missing API key");
+
+ // Add user message
+ inputItems.push({ role: "user", content: userText });
+
+ // Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
+ for (let i = 0; i < 64; i++) {
+ const response = await openAIResponsesCreate({
+ apiKey,
+ model: modelEl.value,
+ input: inputItems,
+ tools,
+ });
+
+ // Keep ALL output items (incl reasoning/tool calls) in the running state.
+ inputItems.push(...(response.output ?? []));
+
+ // Show any assistant text immediately
+ const text = extractAssistantText(response);
+ if (text) addMessage("assistant", text);
+
+ const calls = (response.output ?? []).filter((x) => x.type === "function_call");
+ if (calls.length === 0) return;
+
+ for (const call of calls) {
+ let args = {};
+ try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
+ catch { args = {}; }
+
+ addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`);
+
+ const toolRes = await callWorker("toolCall", { name: call.name, args });
+
+ // Feed tool result back to the model
+ inputItems.push({
+ type: "function_call_output",
+ call_id: call.call_id,
+ output: JSON.stringify(toolRes.result),
+ });
+
+ addMessage("tool", `← ${call.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(true, "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}`);
+ }
+})();
\ No newline at end of file
diff --git a/src/ifcchat/ifc_worker.js b/src/ifcchat/ifc_worker.js
new file mode 100644
index 0000000000..517bc4b24c
--- /dev/null
+++ b/src/ifcchat/ifc_worker.js
@@ -0,0 +1,110 @@
+// ifc_worker.js (MODULE WORKER)
+import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
+
+let pyodide = null;
+let callToolPy = null;
+let initPromise = null;
+
+function ok(id, extra = {}, transfer = []) {
+ self.postMessage({ id, ok: true, ...extra }, transfer);
+}
+function fail(id, error) {
+ self.postMessage({ id, ok: false, error: String(error?.message || error) });
+}
+
+async function ensurePyodide() {
+ if (initPromise) return initPromise;
+
+ initPromise = (async () => {
+ // Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
+ pyodide = await loadPyodide({
+ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
+ });
+
+ await pyodide.loadPackage("micropip");
+ await pyodide.loadPackage("numpy");
+ await pyodide.loadPackage("shapely");
+ await pyodide.loadPackage("typing-extensions");
+
+ const micropip = pyodide.pyimport("micropip");
+ micropip.install("python-dateutil")
+
+ // Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
+ const pyVer = pyodide.runPython(`
+import sys
+f"{sys.version_info.major}.{sys.version_info.minor}"
+ `);
+
+ const wheelUrl =
+ pyVer === "3.13"
+ ? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
+ : "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
+
+ await micropip.install(wheelUrl);
+
+ await micropip.install([
+ "./dist/ifcquery-0.8.5-py3-none-any.whl",
+ "./dist/ifcedit-0.8.5-py3-none-any.whl",
+ "./dist/ifcopenshell_mcp-0.8.5-py3-none-any.whl",
+ "./dist/lark-1.3.1-py3-none-any.whl",
+ "./dist/isodate-0.7.2-py3-none-any.whl",
+ ])
+ await pyodide.runPythonAsync(`
+from ifcmcp.embedded import call_tool as _call_tool
+ `);
+ callToolPy = pyodide.globals.get("_call_tool");
+ })();
+
+ return initPromise;
+}
+
+function callTool(name, args) {
+ const pyArgs = pyodide.toPy(args);
+ const res = callToolPy(name, pyArgs);
+ pyArgs.destroy();
+ const resJs = res.toJs({ dict_converter: Object.fromEntries });
+ res.destroy();
+ return resJs;
+}
+
+self.onmessage = async (ev) => {
+ const { id, type, payload } = ev.data || {};
+ try {
+ if (type === "init") {
+ await ensurePyodide();
+ ok(id, { result: "ok" });
+ return;
+ }
+
+ await ensurePyodide();
+
+ if (type === "loadIfc") {
+ const { filename, bytes } = payload;
+ const path = `/tmp/${filename || "model.ifc"}`;
+ pyodide.FS.mkdirTree("/tmp");
+ pyodide.FS.writeFile(path, new Uint8Array(bytes));
+ const result = callTool("ifc_load", { path });
+ ok(id, { result });
+ return;
+ }
+
+ if (type === "exportIfc") {
+ const path = "/tmp/export.ifc";
+ const result = callTool("ifc_save", { path });
+ const data = pyodide.FS.readFile(path);
+ ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
+ return;
+ }
+
+ if (type === "toolCall") {
+ const { name, args } = payload;
+ const result = callTool(name, args || {});
+ ok(id, { result });
+ return;
+ }
+
+ throw new Error(`Unknown message type: ${type}`);
+ } catch (e) {
+ fail(id, e);
+ }
+};
\ No newline at end of file
diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html
new file mode 100644
index 0000000000..ac878ae615
--- /dev/null
+++ b/src/ifcchat/index.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+ IfcOpenShell AI Assistant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ • Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.
+ • Try “Add a new site and building named X” (will use ifc_edit).
+
+
+
+
+
+
+ Chat with your IFC model. This app uses ifcopenshell-mcp to interact with an IFC model. IFC is in its raw-serialized form provides little textual context on the meaning of attributes, the intent behind element instances and the resulting geometrical form. By using the IfcOpenShell high-level API through an MCP-like interface (Model Context Protocol; allows for structured interaction with APIs and tools) these limitations are addressed and the model can be interrogated on a higher semantic abstraction level. You can also use ifcopenshell-mcp on your local machine with any LLM provider that supports the MCP protocol.
+