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-03 11:30:23 +02:00
{
value : "gpt-5.2" ,
label : "gpt-5.2"
} ,
{
value : "gpt-5.2-chat-latest" ,
label : "gpt-5.2-chat-latest"
} ,
{
value : "gpt-5" ,
label : "gpt-5"
} ,
{
value : "gpt-5-chat-latest" ,
label : "gpt-5-chat-latest"
2026-04-02 21:56:55 +02:00
} ,
2026-04-03 11:30:23 +02:00
{
value : "gpt-5-mini" ,
label : "gpt-5-mini"
} ,
{
value : "gpt-5-nano" ,
label : "gpt-5-nano"
} ,
{
value : "gpt-4.1" ,
label : "gpt-4.1"
} ,
{
value : "gpt-4.1-mini" ,
label : "gpt-4.1-mini"
} ,
{
value : "gpt-4.1-nano" ,
label : "gpt-4.1-nano"
2026-04-02 21:56:55 +02:00
} ,
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-07 09:46:45 +02:00
const compactingIndicatorEl = $ ( "compactingIndicator" ) ;
2026-04-01 15:33:23 +02:00
const modelEl = $ ( "model" ) ;
2026-04-03 11:22:43 +02:00
const providerEls = document . querySelectorAll ( 'input[name="provider"]' ) ;
2026-04-01 15:33:23 +02:00
const ifcFileEl = $ ( "ifcFile" ) ;
const newBtn = $ ( "newModel" ) ;
const downloadBtn = $ ( "downloadIfc" ) ;
2026-04-03 11:22:43 +02:00
function getProviderValue ( ) {
return document . querySelector ( 'input[name="provider"]:checked' ) ? . value || "openai" ;
}
2026-04-02 13:25:16 +02:00
function onProviderChange ( ) {
2026-04-03 11:22:43 +02:00
const provider = PROVIDERS [ getProviderValue ( ) ] ;
2026-04-03 10:26:29 +02:00
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
}
2026-04-03 11:22:43 +02:00
for ( const providerEl of providerEls ) {
providerEl . addEventListener ( "change" , onProviderChange ) ;
}
2026-04-02 13:25:16 +02:00
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…" ;
2026-04-07 09:46:45 +02:00
compactingIndicatorEl . hidden = text !== "Compacting…" ;
2026-04-03 10:59:09 +02:00
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-03 15:47:13 -06:00
type : "function" , function : { name : "ifc_new" , description : "Create a new empty IFC model in memory. Valid schemas: IFC4, IFC2X3, IFC4X3 (for IFC 4.3)." ,
parameters : { type : "object" , properties : { schema : { type : "string" , enum : [ "IFC4" , "IFC2X3" , "IFC4X3" ] } } , 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.
2026-04-07 09:46:45 +02:00
- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement).
2026-04-01 15:33:23 +02:00
- 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
2026-04-04 13:12:47 +02:00
const MAX _TOOL _RESULT _CHARS = 0 ;
2026-04-03 15:47:13 -06:00
const MAX _HISTORY _MESSAGES = 40 ;
2026-04-05 11:50:19 +02:00
const ESTIMATED _CHARS _PER _TOKEN = 4 ;
2026-04-07 09:46:45 +02:00
const MAX _ESTIMATED _TOKENS _PER _MINUTE = 24000 ;
const COMPACT _WHEN _ESTIMATED _TOKENS = 18000 ;
const KEEP _RAW _TURN _GROUPS = 1 ;
2026-04-05 11:50:19 +02:00
const minuteTokenMap = new Map ( ) ;
2026-04-03 15:47:13 -06:00
function truncateToolResult ( text ) {
2026-04-04 13:12:47 +02:00
if ( MAX _TOOL _RESULT _CHARS == 0 || text . length <= MAX _TOOL _RESULT _CHARS ) return text ;
2026-04-03 15:47:13 -06:00
return text . slice ( 0 , MAX _TOOL _RESULT _CHARS ) + "\n... (truncated)" ;
}
function trimHistory ( ) {
if ( messages . length <= MAX _HISTORY _MESSAGES ) return ;
// Find a safe cut point — don't break mid-tool-call sequence.
// Walk forward from the trim target to find a user message boundary.
let cut = messages . length - MAX _HISTORY _MESSAGES ;
while ( cut < messages . length && messages [ cut ] . role !== "user" ) {
cut ++ ;
}
if ( cut > 0 && cut < messages . length ) {
messages . splice ( 0 , cut ) ;
}
}
2026-04-05 11:50:19 +02:00
function getEstimatedTokenMinuteLog ( firstIterationMinuteBucket ) {
return Array . from ( minuteTokenMap . entries ( ) )
. filter ( ( [ minuteBucket ] ) => minuteBucket >= firstIterationMinuteBucket )
. sort ( ( [ leftMinuteBucket ] , [ rightMinuteBucket ] ) => leftMinuteBucket - rightMinuteBucket )
. map ( ( [ minuteBucket , estimatedTokens ] ) => ( {
timestamp : new Date ( minuteBucket * 60000 ) . toISOString ( ) ,
estimated _tokens : estimatedTokens ,
} ) ) ;
}
2026-04-07 09:46:45 +02:00
async function chatWithMinuteDelay ( { chat , apiKey , baseURL , model , messages , tools } ) {
const estimatedTokens = Math . max (
1 ,
Math . ceil ( JSON . stringify ( { model , messages , ... ( tools ? { tools } : { } ) } ) . length / ESTIMATED _CHARS _PER _TOKEN )
) ;
let currentMinuteBucket = Math . floor ( Date . now ( ) / 60000 ) ;
const estimateTokenUsage = ( minuteTokenMap . get ( currentMinuteBucket ) ? ? 0 ) + estimatedTokens ;
if ( estimateTokenUsage > MAX _ESTIMATED _TOKENS _PER _MINUTE ) {
currentMinuteBucket += 1 ;
await new Promise ( ( resolve ) => setTimeout ( ( ) => resolve ( ) , 60000 ) ) ;
}
minuteTokenMap . set ( currentMinuteBucket , ( minuteTokenMap . get ( currentMinuteBucket ) ? ? 0 ) + estimatedTokens ) ;
return {
minuteBucket : currentMinuteBucket ,
response : await chat ( { apiKey , baseURL , model , messages , tools } ) ,
} ;
}
async function compactHistoryWithLLM ( chat , apiKey , baseURL , model ) {
const estimatedTokens = Math . max (
1 ,
Math . ceil ( JSON . stringify ( [ { role : "system" , content : SYSTEM _INSTRUCTIONS } , ... messages ] ) . length / ESTIMATED _CHARS _PER _TOKEN )
) ;
if ( messages . length <= MAX _HISTORY _MESSAGES && estimatedTokens <= COMPACT _WHEN _ESTIMATED _TOKENS ) return null ;
const { prefix , groups } = messages . reduce ( ( acc , message ) => {
if ( message . role === "user" ) {
acc . groups . push ( [ message ] ) ;
} else if ( acc . groups . length ) {
acc . groups [ acc . groups . length - 1 ] . push ( message ) ;
} else {
acc . prefix . push ( message ) ;
}
return acc ;
} , { prefix : [ ] , groups : [ ] } ) ;
if ( groups . length <= KEEP _RAW _TURN _GROUPS ) return null ;
const compacted = [ ... prefix , ... groups . slice ( 0 , - KEEP _RAW _TURN _GROUPS ) . flat ( ) ] ;
if ( ! compacted . length ) return null ;
setStatus ( "Compacting…" ) ;
try {
const before = {
message _count : messages . length ,
turn _group _count : groups . length ,
estimated _tokens : estimatedTokens ,
} ;
const { minuteBucket , response } = await chatWithMinuteDelay ( {
chat ,
apiKey ,
baseURL ,
model ,
messages : [
{
role : "system" ,
content : "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary."
} ,
{ role : "user" , content : JSON . stringify ( compacted ) } ,
] ,
} ) ;
const summary = response . choices ? . [ 0 ] ? . message ? . content ? . trim ( ) ;
if ( ! summary ) return minuteBucket ;
messages = [
{ role : "assistant" , content : ` [Context summary] \n ${ summary } ` } ,
... groups . slice ( - KEEP _RAW _TURN _GROUPS ) . flat ( ) ,
] ;
console . log ( "History compaction before" , before ) ;
console . log ( "History compaction after" , {
message _count : messages . length ,
turn _group _count : messages . filter ( ( message ) => message . role === "user" ) . length ,
estimated _tokens : Math . max (
1 ,
Math . ceil ( JSON . stringify ( [ { role : "system" , content : SYSTEM _INSTRUCTIONS } , ... messages ] ) . length / ESTIMATED _CHARS _PER _TOKEN )
) ,
} ) ;
return minuteBucket ;
} finally {
setStatus ( "Thinking…" ) ;
}
}
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 11:22:43 +02:00
const provider = PROVIDERS [ getProviderValue ( ) ] ;
2026-04-03 10:49:36 +02:00
const { chat } = provider . api ;
const baseURL = provider . baseUrlDefault ? baseUrlEl . value . trim ( ) : undefined ;
2026-04-05 11:50:19 +02:00
let firstIterationMinuteBucket = null ;
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-07 09:46:45 +02:00
const compactedMinuteBucket = await compactHistoryWithLLM ( chat , apiKey , baseURL , modelEl . value ) ;
if ( firstIterationMinuteBucket === null && compactedMinuteBucket !== null ) {
firstIterationMinuteBucket = compactedMinuteBucket ;
2026-04-05 11:50:19 +02:00
}
2026-04-07 09:46:45 +02:00
if ( messages . length > MAX _HISTORY _MESSAGES * 2 ) trimHistory ( ) ;
2026-04-05 11:50:19 +02:00
2026-04-07 09:46:45 +02:00
const messages _with _system = [ { role : "system" , content : SYSTEM _INSTRUCTIONS } , ... messages ] ;
const { minuteBucket , response } = await chatWithMinuteDelay ( {
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-05 11:50:19 +02:00
messages : messages _with _system ,
2026-04-01 15:33:23 +02:00
tools ,
} ) ;
2026-04-07 09:46:45 +02:00
if ( firstIterationMinuteBucket === null ) {
firstIterationMinuteBucket = minuteBucket ;
}
2026-04-01 15:33:23 +02:00
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-05 11:50:19 +02:00
if ( calls . length === 0 ) {
console . log ( "Estimated token usage by minute" , getEstimatedTokenMinuteLog ( firstIterationMinuteBucket ) ) ;
return ;
}
2026-04-01 15:33:23 +02:00
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-03 15:47:13 -06:00
const fullResult = JSON . stringify ( toolRes . result ) ;
2026-04-02 13:25:16 +02:00
messages . push ( {
role : "tool" ,
tool _call _id : call . id ,
2026-04-03 15:47:13 -06:00
content : truncateToolResult ( fullResult ) ,
2026-04-01 15:33:23 +02:00
} ) ;
2026-04-03 15:47:13 -06:00
// Show full result in UI, but only truncated version goes to the LLM
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…" ) ;
2026-04-03 15:47:13 -06:00
const r = await callWorker ( "toolCall" , { name : "ifc_new" , args : { schema : "IFC4X3" } } ) ;
2026-04-01 15:33:23 +02:00
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
} ) ( ) ;