mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
initial ai chat src
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
name: Deploy AI chat App to static page repo
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
pages: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'src/ifcchat/**'
|
||||||
|
- '.github/workflows/publish-aichat-app.yaml'
|
||||||
|
branches:
|
||||||
|
- v0.8.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
activate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||||
|
steps:
|
||||||
|
- name: Set env
|
||||||
|
run: echo ok go
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: activate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout (recursive)
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Checkout intermediate Pages repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
|
||||||
|
ref: gh-pages
|
||||||
|
path: output
|
||||||
|
token: ${{ secrets.WEBSITE_PUBLISH }}
|
||||||
|
- name: Sync demo app into target subfolder
|
||||||
|
run: |
|
||||||
|
rsync -av --delete src/ifcchat/ output/app
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- name: Download wheels
|
||||||
|
working-directory: output
|
||||||
|
run: |
|
||||||
|
pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
|
||||||
|
- name: Commit and push if changed
|
||||||
|
working-directory: output
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'IfcOpenBot'
|
||||||
|
git config --global user.email 'IfcOpenBot@users.noreply.github.com'
|
||||||
|
|
||||||
|
git add .
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -m "$(git log --oneline -1)"
|
||||||
|
git push origin gh-pages
|
||||||
|
git push
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Deploy Pyodide Demo App to GitHub Pages
|
name: Deploy Pyodide Demo App to static page repo
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -30,21 +30,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup Pages
|
- name: Checkout intermediate Pages repo
|
||||||
uses: actions/configure-pages@v6
|
uses: actions/checkout@v4
|
||||||
- name: Upload static files as artifact
|
|
||||||
id: deployment
|
|
||||||
uses: actions/upload-pages-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
path: src/pyodide/demo-app/
|
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
|
||||||
|
ref: gh-pages
|
||||||
|
path: output
|
||||||
|
token: ${{ secrets.WEBSITE_PUBLISH }}
|
||||||
|
- name: Sync demo app into target subfolder
|
||||||
|
run: |
|
||||||
|
rsync -av --delete src/pyodide/demo-app/ output/app
|
||||||
|
- name: Commit and push if changed
|
||||||
|
working-directory: output
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'IfcOpenBot'
|
||||||
|
git config --global user.email 'IfcOpenBot@users.noreply.github.com'
|
||||||
|
|
||||||
deploy:
|
git add .
|
||||||
environment:
|
if git diff --cached --quiet; then
|
||||||
name: github-pages
|
echo "No changes to commit"
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
exit 0
|
||||||
runs-on: ubuntu-latest
|
fi
|
||||||
needs: build
|
|
||||||
steps:
|
git -m "$(git log --oneline -1)"
|
||||||
- name: Deploy to GitHub Pages
|
git push origin gh-pages
|
||||||
id: deployment
|
git push
|
||||||
uses: actions/deploy-pages@v5
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
IfcOpenShell AI Assistant
|
||||||
|
=========================
|
||||||
|
|
||||||
|
A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application.
|
||||||
|
|
||||||
|
### Setup instructions
|
||||||
|
|
||||||
|
```
|
||||||
|
pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
|
||||||
|
```
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// app.js
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const statusEl = $("status");
|
||||||
|
const msgsEl = $("msgs");
|
||||||
|
const sendBtn = $("send");
|
||||||
|
const inputEl = $("input");
|
||||||
|
const apiKeyEl = $("apiKey");
|
||||||
|
const modelEl = $("model");
|
||||||
|
const ifcFileEl = $("ifcFile");
|
||||||
|
const newBtn = $("newModel");
|
||||||
|
const downloadBtn = $("downloadIfc");
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(isBusy ? (reason || "Working…") : "Ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, text) {
|
||||||
|
if (text.ok) {
|
||||||
|
text = text.data;
|
||||||
|
}
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = `msg ${role}`;
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="role ${role}">${role}</div>
|
||||||
|
<div class="bubble"></div>`;
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>IfcOpenShell AI Assistant</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
header input,
|
||||||
|
header select {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
border-right: 1px solid #ddd;
|
||||||
|
padding: 100px 12px 12px 12px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgs {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg .role {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 12px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role.user {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg .bubble {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.user .bubble {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #eee;
|
||||||
|
width: 50%;
|
||||||
|
margin-left: auto;
|
||||||
|
border: solid 1px #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.tool .bubble {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
height: 88px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer button {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer .inner {
|
||||||
|
border: solid 1px #ddd;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
section>.row>label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
section>.row>label .small {
|
||||||
|
font-weight: normal;
|
||||||
|
display: block;
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side .row {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row button {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#model {
|
||||||
|
background: white;
|
||||||
|
color: gray;
|
||||||
|
border: solid 1px #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: dashed 1px #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row>.btn,
|
||||||
|
.btn-row>button.btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
background: #eee;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wide {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state for label-button + normal buttons */
|
||||||
|
.btn.disabled,
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.tool .bubble {
|
||||||
|
max-height: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#input:focus,
|
||||||
|
#input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 90%;
|
||||||
|
background-color: #eee;
|
||||||
|
border: solid 1px #ddd;
|
||||||
|
padding: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="side">
|
||||||
|
<div class="row">
|
||||||
|
<label>OpenAI API key<span class="small">stored in browser memory; only sent to OpenAI servers</span></label>
|
||||||
|
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="row" style="margin-bottom: 10px;">
|
||||||
|
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to OpenAI servers</span></label>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
||||||
|
<span class="material-icons">folder_open</span>
|
||||||
|
Browse
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn" id="newModel" type="button">
|
||||||
|
<span class="material-icons">add_box</span>
|
||||||
|
New IFC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn btn-wide" id="downloadIfc" type="button">
|
||||||
|
<span class="material-icons">download</span>
|
||||||
|
Download IFC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Tips</label>
|
||||||
|
<div class="status">
|
||||||
|
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
|
||||||
|
• Try “Add a new site and building named X” (will use ifc_edit).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>About</label>
|
||||||
|
<div class="status">
|
||||||
|
Chat with your IFC model. This app uses <span class="code">ifcopenshell-mcp</span> 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 <span class="code">ifcopenshell-mcp</span> on your local machine with any LLM provider that supports the MCP protocol.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="chat">
|
||||||
|
<header>
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
|
||||||
|
|
||||||
|
<strong>IfcOpenShell AI Assistant</strong>
|
||||||
|
<select id="model">
|
||||||
|
<option value="gpt-5">gpt-5</option>
|
||||||
|
<option value="gpt-4.1">gpt-4.1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
Status: <span class="status" id="status">Booting…</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="msgs" id="msgs"></div>
|
||||||
|
<div class="composer">
|
||||||
|
<div class="inner">
|
||||||
|
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
|
||||||
|
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user