2026-04-01 15:33:23 +02:00
// app.js
2026-04-02 13:25:16 +02:00
import * as openaiApi from "./api_openai.js" ;
2026-04-03 10:26:29 +02:00
import * as anthropicApi from "./api_anthropic.js" ;
2026-04-02 13:25:16 +02:00
import * as openrouterApi from "./api_openrouter.js" ;
const PROVIDERS = {
openai : {
api : openaiApi ,
2026-04-03 10:26:29 +02:00
apiKeyLabel : "OpenAI API key" ,
apiKeyPlaceholder : "sk-..." ,
2026-04-03 10:49:36 +02:00
baseUrlLabel : "Base URL" ,
baseUrlPlaceholder : "https://api.openai.com/v1" ,
baseUrlDefault : "https://api.openai.com/v1" ,
2026-04-02 13:25:16 +02:00
models : [
2026-04-02 21:56:55 +02:00
{
value : "gpt-5" ,
label : "gpt-5"
} ,
{
value : "gpt-4.1" ,
label : "gpt-4.1"
} ,
2026-04-02 13:25:16 +02:00
] ,
} ,
2026-04-03 10:26:29 +02:00
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"
} ,
] ,
} ,
2026-04-03 10:49:36 +02:00
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"
} ,
] ,
} ,
2026-04-02 13:25:16 +02:00
openrouter : {
api : openrouterApi ,
2026-04-03 10:26:29 +02:00
apiKeyLabel : "OpenRouter API key" ,
apiKeyPlaceholder : "sk-or-v1-..." ,
2026-04-03 10:49:36 +02:00
baseUrlLabel : "Base URL" ,
baseUrlPlaceholder : "https://openrouter.ai/api/v1" ,
baseUrlDefault : "https://openrouter.ai/api/v1" ,
2026-04-02 13:25:16 +02:00
models : [
2026-04-02 21:56:55 +02:00
{
2026-04-02 22:06:19 +02:00
value : "openai/gpt-oss-20b" ,
2026-04-02 21:56:55 +02:00
label : "gpt-oss-20b"
} ,
{
2026-04-02 22:06:19 +02:00
value : "openai/gpt-oss-120b" ,
label : "gpt-oss-120b"
} ,
{
value : "mistralai/mistral-small-3.2-24b-instruct" ,
label : "mistral-small-3.2"
2026-04-02 21:56:55 +02:00
} ,
{
value : "openai/gpt-4.1" ,
label : "gpt-4.1"
} ,
2026-04-02 22:06:19 +02:00
{
value : "anthropic/claude-sonnet-4-5" ,
label : "claude-sonnet-4-5"
} ,
2026-04-02 21:56:55 +02:00
{
value : "google/gemini-2.5-pro-preview" ,
label : "gemini-2.5-pro"
} ,
2026-04-02 13:25:16 +02:00
] ,
} ,
} ;
2026-04-01 15:33:23 +02:00
const $ = ( id ) => document . getElementById ( id ) ;
const statusEl = $ ( "status" ) ;
const msgsEl = $ ( "msgs" ) ;
const sendBtn = $ ( "send" ) ;
const inputEl = $ ( "input" ) ;
const apiKeyEl = $ ( "apiKey" ) ;
2026-04-03 10:26:29 +02:00
const apiKeyLabelEl = $ ( "apiKeyLabel" ) ;
2026-04-03 10:49:36 +02:00
const baseUrlRowEl = $ ( "baseUrlRow" ) ;
const baseUrlLabelEl = $ ( "baseUrlLabel" ) ;
const baseUrlEl = $ ( "baseUrl" ) ;
2026-04-03 10:59:09 +02:00
const thinkingIndicatorEl = $ ( "thinkingIndicator" ) ;
2026-04-01 15:33:23 +02:00
const modelEl = $ ( "model" ) ;
2026-04-02 13:25:16 +02:00
const providerEl = $ ( "provider" ) ;
2026-04-01 15:33:23 +02:00
const ifcFileEl = $ ( "ifcFile" ) ;
const newBtn = $ ( "newModel" ) ;
const downloadBtn = $ ( "downloadIfc" ) ;
2026-04-02 13:25:16 +02:00
function onProviderChange ( ) {
2026-04-03 10:26:29 +02:00
const provider = PROVIDERS [ providerEl . value ] ;
apiKeyLabelEl . innerHTML = ` ${ provider . apiKeyLabel } <span class="small">stored in browser memory; only sent to provider servers</span> ` ;
apiKeyEl . placeholder = provider . apiKeyPlaceholder ;
2026-04-03 10:49:36 +02:00
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 = "" ;
}
2026-04-03 10:26:29 +02:00
modelEl . innerHTML = provider . models . map ( m => ` <option value=" ${ m . value } "> ${ m . label } </option> ` ) . join ( "" ) ;
2026-04-02 13:25:16 +02:00
}
providerEl . addEventListener ( "change" , onProviderChange ) ;
onProviderChange ( ) ;
2026-04-01 15:33:23 +02:00
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 ;
}
2026-04-02 13:27:54 +02:00
sendBtn . innerHTML = isBusy
? ` <span class="spinner"></span> `
: ` Send <span class="material-icons">send</span> ` ;
2026-04-01 15:33:23 +02:00
setStatus ( isBusy ? ( reason || "Working…" ) : "Ready" ) ;
}
2026-04-03 11:03:44 +02:00
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 ( "" ) ;
}
2026-04-01 15:33:23 +02:00
function addMessage ( role , text ) {
if ( text . ok ) {
text = text . data ;
}
2026-04-03 11:03:44 +02:00
if ( typeof text !== "string" ) {
text = JSON . stringify ( text , null , 2 ) ;
}
2026-04-01 15:33:23 +02:00
const wrap = document . createElement ( "div" ) ;
wrap . className = ` msg ${ role } ` ;
wrap . innerHTML = `
2026-04-02 21:51:45 +02:00
<div class="role ${ role } "> ${ role } ${ role === "tool" ? '<span class="chevron">▶</span>' : '' } </div>
2026-04-01 15:33:23 +02:00
<div class="bubble"></div> ` ;
const bubble = wrap . querySelector ( ".bubble" ) ;
2026-04-03 11:03:44 +02:00
if ( role === "assistant" ) {
bubble . classList . add ( "markdown-content" ) ;
bubble . innerHTML = renderMarkdown ( text ) ;
} else {
bubble . textContent = text ;
}
2026-04-01 15:33:23 +02:00
bubble . onclick = function ( ) {
if ( bubble . scrollHeight > 100 && role === "tool" ) {
2026-04-02 21:51:45 +02:00
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)' ;
2026-04-01 15:33:23 +02:00
}
}
2026-04-03 10:59:09 +02:00
msgsEl . insertBefore ( wrap , thinkingIndicatorEl ) ;
2026-04-01 15:33:23 +02:00
msgsEl . scrollTop = msgsEl . scrollHeight ;
}
function setStatus ( text ) {
statusEl . textContent = text ;
2026-04-03 10:59:09 +02:00
thinkingIndicatorEl . hidden = text !== "Thinking…" ;
msgsEl . scrollTop = msgsEl . scrollHeight ;
2026-04-01 15:33:23 +02:00
}
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 } ) ;
} ) ;
}
2026-04-02 13:25:16 +02:00
// ---- Tool schemas (should match ifcmcp.core openai_tools()) ----
2026-04-01 15:33:23 +02:00
const tools = [
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
type : "function" , function : { name : "ifc_summary" , description : "Get a concise overview of the loaded IFC model." ,
parameters : { type : "object" , properties : { } , required : [ ] , additionalProperties : false } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
type : "function" , function : { name : "ifc_tree" , description : "Get the full spatial hierarchy tree." ,
parameters : { type : "object" , properties : { } , required : [ ] , additionalProperties : false } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
type : "function" , function : { name : "ifc_relations" , description : "Get relationships for an element. traverse='up' walks to IfcProject." ,
2026-04-01 15:33:23 +02:00
parameters : {
type : "object" , properties : { element _id : { type : "integer" } , traverse : { type : "string" } } ,
required : [ "element_id" ] , additionalProperties : false
2026-04-02 13:25:16 +02:00
} }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
type : "function" , function : { name : "ifc_clash" , description : "Run clash/clearance checks for an element." ,
2026-04-01 15:33:23 +02:00
parameters : {
type : "object" , properties : { element _id : { type : "integer" } , clearance : { type : "number" } , tolerance : { type : "number" } , scope : { type : "string" } } ,
required : [ "element_id" ] , additionalProperties : false
2026-04-02 13:25:16 +02:00
} }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
{
2026-04-02 13:25:16 +02:00
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 } }
2026-04-01 15:33:23 +02:00
} ,
] ;
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.
` ;
2026-04-02 13:25:16 +02:00
let messages = [ ] ; // running conversation state (Chat Completions style)
2026-04-01 15:33:23 +02:00
async function runAgentTurn ( userText ) {
const apiKey = apiKeyEl . value . trim ( ) ;
if ( ! apiKey ) throw new Error ( "Missing API key" ) ;
2026-04-03 10:49:36 +02:00
const provider = PROVIDERS [ providerEl . value ] ;
const { chat } = provider . api ;
const baseURL = provider . baseUrlDefault ? baseUrlEl . value . trim ( ) : undefined ;
2026-04-02 13:25:16 +02:00
messages . push ( { role : "user" , content : userText } ) ;
2026-04-01 15:33:23 +02:00
for ( let i = 0 ; i < 64 ; i ++ ) {
2026-04-02 13:25:16 +02:00
const response = await chat ( {
2026-04-01 15:33:23 +02:00
apiKey ,
2026-04-03 10:49:36 +02:00
baseURL ,
2026-04-01 15:33:23 +02:00
model : modelEl . value ,
2026-04-02 13:25:16 +02:00
messages : [ { role : "system" , content : SYSTEM _INSTRUCTIONS } , ... messages ] ,
2026-04-01 15:33:23 +02:00
tools ,
} ) ;
2026-04-02 13:25:16 +02:00
const message = response . choices ? . [ 0 ] ? . message ;
if ( ! message ) throw new Error ( "No message in response" ) ;
messages . push ( message ) ;
2026-04-01 15:33:23 +02:00
2026-04-02 13:25:16 +02:00
if ( message . content ) addMessage ( "assistant" , message . content ) ;
2026-04-01 15:33:23 +02:00
2026-04-02 13:25:16 +02:00
const calls = message . tool _calls ? ? [ ] ;
2026-04-01 15:33:23 +02:00
if ( calls . length === 0 ) return ;
for ( const call of calls ) {
let args = { } ;
2026-04-02 13:25:16 +02:00
try { args = call . function . arguments ? JSON . parse ( call . function . arguments ) : { } ; }
2026-04-01 15:33:23 +02:00
catch { args = { } ; }
2026-04-02 13:25:16 +02:00
addMessage ( "tool" , ` → ${ call . function . name } ( ${ JSON . stringify ( args ) } ) ` ) ;
2026-04-01 15:33:23 +02:00
2026-04-02 13:25:16 +02:00
const toolRes = await callWorker ( "toolCall" , { name : call . function . name , args } ) ;
2026-04-01 15:33:23 +02:00
2026-04-02 13:25:16 +02:00
messages . push ( {
role : "tool" ,
tool _call _id : call . id ,
content : JSON . stringify ( toolRes . result ) ,
2026-04-01 15:33:23 +02:00
} ) ;
2026-04-02 13:25:16 +02:00
addMessage ( "tool" , ` ← ${ call . function . name } : ${ JSON . stringify ( toolRes . result , null , 2 ) } ` ) ;
2026-04-01 15:33:23 +02:00
}
}
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 ) {
2026-04-02 21:35:43 +02:00
setBusy ( false , "Error" ) ;
2026-04-01 15:33:23 +02:00
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 } ` ) ;
}
2026-04-02 18:50:27 -06:00
} ) ( ) ;