mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 10:06:47 +00:00
600 lines
21 KiB
JavaScript
600 lines
21 KiB
JavaScript
// 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}<span class="small">stored in browser memory; only sent to provider servers</span>`;
|
|
apiKeyEl.placeholder = provider.apiKeyPlaceholder;
|
|
baseUrlRowEl.hidden = !provider.baseUrlDefault;
|
|
if (provider.baseUrlDefault) {
|
|
baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}<span class="small">override the API endpoint for OpenAI-compatible providers</span>`;
|
|
baseUrlEl.placeholder = provider.baseUrlPlaceholder;
|
|
baseUrlEl.value = provider.baseUrlDefault;
|
|
} else {
|
|
baseUrlEl.value = "";
|
|
baseUrlEl.placeholder = "";
|
|
}
|
|
modelEl.innerHTML = provider.models.map(m => `<option value="${m.value}">${m.label}</option>`).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
|
|
? `<span class="spinner"></span>`
|
|
: `Send <span class="material-icons">send</span>`;
|
|
|
|
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(`<code>${escapeHtml(code)}</code>`));
|
|
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
|
const href = sanitizeUrl(url);
|
|
if (!href) {
|
|
return `${label} (${url})`;
|
|
}
|
|
return addPlaceholder(
|
|
`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${escapeHtml(label)}</a>`
|
|
);
|
|
});
|
|
|
|
rendered = escapeHtml(rendered);
|
|
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
rendered = rendered.replace(/_([^_]+)_/g, "<em>$1</em>");
|
|
|
|
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(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
|
|
paragraphLines = [];
|
|
};
|
|
|
|
const flushQuote = () => {
|
|
if (!quoteLines.length) return;
|
|
const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("<br />");
|
|
html.push(`<blockquote><p>${quoteBody}</p></blockquote>`);
|
|
quoteLines = [];
|
|
};
|
|
|
|
const flushList = () => {
|
|
if (!listItems.length || !listType) return;
|
|
const items = listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("");
|
|
html.push(`<${listType}>${items}</${listType}>`);
|
|
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(`<pre><code${languageClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
continue;
|
|
}
|
|
|
|
if (!trimmed) {
|
|
flushAll();
|
|
continue;
|
|
}
|
|
|
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
if (headingMatch) {
|
|
flushAll();
|
|
const level = headingMatch[1].length;
|
|
html.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
|
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 = `
|
|
<div class="role ${role}">${role}${role === "tool" ? '<span class="chevron">▶</span>' : ''}</div>
|
|
<div class="bubble"></div>`;
|
|
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}`);
|
|
}
|
|
})();
|