diff --git a/src/ifcchat/README.md b/src/ifcchat/README.md index f20aa8788d..b2cceecc52 100644 --- a/src/ifcchat/README.md +++ b/src/ifcchat/README.md @@ -1,7 +1,7 @@ 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. +A web-based client-side (pyodide + OpenAI, Anthropic, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application. ### Setup instructions diff --git a/src/ifcchat/api_anthropic.js b/src/ifcchat/api_anthropic.js new file mode 100644 index 0000000000..0359cde936 --- /dev/null +++ b/src/ifcchat/api_anthropic.js @@ -0,0 +1,177 @@ +// This file was generated with the assistance of an AI coding tool. + +function parseArguments(argumentsText) { + if (!argumentsText) return {}; + try { + return JSON.parse(argumentsText); + } catch { + return {}; + } +} + +function toAnthropicTools(tools = []) { + return tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters, + })); +} + +function toAnthropicAssistantContent(message) { + const content = []; + + if (message.content) { + content.push({ type: "text", text: message.content }); + } + + for (const toolCall of message.tool_calls ?? []) { + content.push({ + type: "tool_use", + id: toolCall.id, + name: toolCall.function.name, + input: parseArguments(toolCall.function.arguments), + }); + } + + if (content.length === 0) { + return ""; + } + + return content.length === 1 && content[0].type === "text" ? content[0].text : content; +} + +function toAnthropicUserContent(message) { + return typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""); +} + +function toAnthropicToolResult(message) { + return { + type: "tool_result", + tool_use_id: message.tool_call_id, + content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""), + }; +} + +function splitSystemAndMessages(messages = []) { + const system = []; + const anthropicMessages = []; + let pendingToolResults = []; + + const flushToolResults = () => { + if (pendingToolResults.length === 0) return; + anthropicMessages.push({ role: "user", content: pendingToolResults }); + pendingToolResults = []; + }; + + for (const message of messages) { + if (message.role === "system") { + if (message.content) { + system.push(message.content); + } + continue; + } + + if (message.role === "tool") { + pendingToolResults.push(toAnthropicToolResult(message)); + continue; + } + + flushToolResults(); + + if (message.role === "user") { + anthropicMessages.push({ + role: "user", + content: toAnthropicUserContent(message), + }); + continue; + } + + if (message.role === "assistant") { + anthropicMessages.push({ + role: "assistant", + content: toAnthropicAssistantContent(message), + }); + } + } + + flushToolResults(); + + return { + system: system.join("\n\n"), + messages: anthropicMessages, + }; +} + +function toChatCompletionResponse(response) { + const text = []; + const toolCalls = []; + + for (const block of response.content ?? []) { + if (block.type === "text") { + text.push(block.text); + continue; + } + + if (block.type === "tool_use") { + toolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }); + } + } + + const message = { role: "assistant" }; + const content = text.join("\n").trim(); + + if (content) { + message.content = content; + } + + if (toolCalls.length) { + message.tool_calls = toolCalls; + } + + return { + choices: [ + { + message, + }, + ], + }; +} + +export async function chat({ apiKey, model, messages, tools }) { + const request = splitSystemAndMessages(messages); + const body = { + model, + max_tokens: 4096, + messages: request.messages, + tools: toAnthropicTools(tools), + }; + + if (request.system) { + body.system = request.system; + } + + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Anthropic error ${res.status}: ${text}`); + } + + return toChatCompletionResponse(await res.json()); +} diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index b367359516..22a9b0a055 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -1,10 +1,13 @@ // 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-...", models: [ { value: "gpt-5", @@ -16,8 +19,29 @@ const PROVIDERS = { }, ], }, + 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" + }, + ], + }, openrouter: { api: openrouterApi, + apiKeyLabel: "OpenRouter API key", + apiKeyPlaceholder: "sk-or-v1-...", models: [ { value: "openai/gpt-oss-20b", @@ -54,6 +78,7 @@ const msgsEl = $("msgs"); const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); +const apiKeyLabelEl = $("apiKeyLabel"); const modelEl = $("model"); const providerEl = $("provider"); const ifcFileEl = $("ifcFile"); @@ -61,8 +86,10 @@ const newBtn = $("newModel"); const downloadBtn = $("downloadIfc"); function onProviderChange() { - const p = PROVIDERS[providerEl.value]; - modelEl.innerHTML = p.models.map(m => ``).join(""); + const provider = PROVIDERS[providerEl.value]; + apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}stored in browser memory; only sent to provider servers`; + apiKeyEl.placeholder = provider.apiKeyPlaceholder; + modelEl.innerHTML = provider.models.map(m => ``).join(""); } providerEl.addEventListener("change", onProviderChange); @@ -344,4 +371,4 @@ downloadBtn.onclick = async () => { setBusy(true, "Error"); addMessage("assistant", `Worker init failed: ${e.message}`); } -})(); \ No newline at end of file +})(); diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 20129d1937..1ad3bc198d 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -16,6 +16,7 @@ @@ -26,14 +27,14 @@
- +

- +