diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 516ec8f846..642ef275df 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -1,4 +1,26 @@ // app.js +import * as openaiApi from "./api_openai.js"; +import * as openrouterApi from "./api_openrouter.js"; + +const PROVIDERS = { + openai: { + api: openaiApi, + models: [ + { value: "gpt-5", label: "gpt-5" }, + { value: "gpt-4.1", label: "gpt-4.1" }, + ], + }, + openrouter: { + api: openrouterApi, + models: [ + { value: "openai/gpt-oss-20b", label: "gpt-oss-20b" }, + { value: "anthropic/claude-sonnet-4-5", label: "claude-sonnet-4-5" }, + { value: "openai/gpt-4.1", label: "gpt-4.1" }, + { value: "google/gemini-2.5-pro-preview", label: "gemini-2.5-pro" }, + ], + }, +}; + const $ = (id) => document.getElementById(id); const statusEl = $("status"); @@ -7,10 +29,19 @@ const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); const modelEl = $("model"); +const providerEl = $("provider"); const ifcFileEl = $("ifcFile"); const newBtn = $("newModel"); const downloadBtn = $("downloadIfc"); +function onProviderChange() { + const p = PROVIDERS[providerEl.value]; + modelEl.innerHTML = p.models.map(m => ``).join(""); +} + +providerEl.addEventListener("change", onProviderChange); +onProviderChange(); + function setBusy(isBusy, reason = "") { const controls = [ $("send"), @@ -75,74 +106,73 @@ function callWorker(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} +// ---- Tool schemas (should match ifcmcp.core openai_tools()) ---- 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", 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", 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", 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", 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", 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.", + 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", name: "ifc_clash", description: "Run clash/clearance checks for an element.", + 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", 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_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", 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", 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", 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", 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", 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", 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 } + 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 } } }, ]; @@ -156,85 +186,50 @@ Rules: 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(); -} +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"); - // Add user message - inputItems.push({ role: "user", content: userText }); + const { chat } = PROVIDERS[providerEl.value].api; + + messages.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({ + const response = await chat({ apiKey, model: modelEl.value, - input: inputItems, + messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages], tools, }); - // Keep ALL output items (incl reasoning/tool calls) in the running state. - inputItems.push(...(response.output ?? [])); + const message = response.choices?.[0]?.message; + if (!message) throw new Error("No message in response"); - // Show any assistant text immediately - const text = extractAssistantText(response); - if (text) addMessage("assistant", text); + messages.push(message); - const calls = (response.output ?? []).filter((x) => x.type === "function_call"); + 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.arguments ? JSON.parse(call.arguments) : {}; } + try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; } catch { args = {}; } - addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`); + addMessage("tool", `→ ${call.function.name}(${JSON.stringify(args)})`); - const toolRes = await callWorker("toolCall", { name: call.name, args }); + const toolRes = await callWorker("toolCall", { name: call.function.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), + messages.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify(toolRes.result), }); - addMessage("tool", `← ${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`); + addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`); } }