mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-28 03:05:33 +00:00
feat: Add Anthropic Claude API support to ifcchat
Add a provider selector (OpenAI / Anthropic) to the ifcchat web UI, allowing users to use their Anthropic API key with Claude models instead of only OpenAI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+182
-16
@@ -7,10 +7,57 @@ const sendBtn = $("send");
|
|||||||
const inputEl = $("input");
|
const inputEl = $("input");
|
||||||
const apiKeyEl = $("apiKey");
|
const apiKeyEl = $("apiKey");
|
||||||
const modelEl = $("model");
|
const modelEl = $("model");
|
||||||
|
const providerEl = $("provider");
|
||||||
const ifcFileEl = $("ifcFile");
|
const ifcFileEl = $("ifcFile");
|
||||||
const newBtn = $("newModel");
|
const newBtn = $("newModel");
|
||||||
const downloadBtn = $("downloadIfc");
|
const downloadBtn = $("downloadIfc");
|
||||||
|
|
||||||
|
// ---- Provider switching ----
|
||||||
|
|
||||||
|
const PROVIDER_MODELS = {
|
||||||
|
openai: ["gpt-5", "gpt-4.1"],
|
||||||
|
anthropic: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_LABELS = {
|
||||||
|
openai: "OpenAI API key",
|
||||||
|
anthropic: "Anthropic API key",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_PLACEHOLDERS = {
|
||||||
|
openai: "sk-...",
|
||||||
|
anthropic: "sk-ant-...",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
return providerEl.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProviderUI() {
|
||||||
|
const provider = getProvider();
|
||||||
|
$("apiKeyLabel").innerHTML = `${PROVIDER_LABELS[provider]}<span class="small">stored in browser memory; only sent to provider servers</span>`;
|
||||||
|
apiKeyEl.placeholder = PROVIDER_PLACEHOLDERS[provider];
|
||||||
|
|
||||||
|
// Enable/disable model optgroups and select first available model
|
||||||
|
for (const [key, models] of Object.entries(PROVIDER_MODELS)) {
|
||||||
|
const group = $(`modelGroup${key.charAt(0).toUpperCase() + key.slice(1)}`);
|
||||||
|
if (!group) continue;
|
||||||
|
const isActive = key === provider;
|
||||||
|
group.disabled = !isActive;
|
||||||
|
for (const opt of group.querySelectorAll("option")) {
|
||||||
|
opt.disabled = !isActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modelEl.value = PROVIDER_MODELS[provider][0];
|
||||||
|
|
||||||
|
// Reset conversation on provider switch
|
||||||
|
openaiInputItems = [];
|
||||||
|
anthropicMessages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
providerEl.addEventListener("change", updateProviderUI);
|
||||||
|
|
||||||
function setBusy(isBusy, reason = "") {
|
function setBusy(isBusy, reason = "") {
|
||||||
const controls = [
|
const controls = [
|
||||||
$("send"),
|
$("send"),
|
||||||
@@ -75,8 +122,7 @@ function callWorker(type, payload = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
|
// ---- Tool schemas (OpenAI Responses API format) ----
|
||||||
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
|
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
|
||||||
@@ -146,6 +192,15 @@ const tools = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Convert OpenAI tool format to Anthropic tool format
|
||||||
|
function toolsForAnthropic() {
|
||||||
|
return tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
input_schema: t.parameters,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_INSTRUCTIONS = `
|
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.
|
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
|
||||||
Rules:
|
Rules:
|
||||||
@@ -156,7 +211,15 @@ Rules:
|
|||||||
Be concise. Avoid dumping huge trees unless asked.
|
Be concise. Avoid dumping huge trees unless asked.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let inputItems = []; // running conversation state (Responses API style)
|
// ---- OpenAI state ----
|
||||||
|
let openaiInputItems = [];
|
||||||
|
|
||||||
|
// ---- Anthropic state ----
|
||||||
|
let anthropicMessages = [];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// OpenAI Responses API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
|
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
|
||||||
const res = await fetch("https://api.openai.com/v1/responses", {
|
const res = await fetch("https://api.openai.com/v1/responses", {
|
||||||
@@ -180,7 +243,7 @@ async function openAIResponsesCreate({ apiKey, model, input, tools }) {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAssistantText(response) {
|
function extractAssistantTextOpenAI(response) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const item of response.output ?? []) {
|
for (const item of response.output ?? []) {
|
||||||
if (item.type === "message" && item.role === "assistant") {
|
if (item.type === "message" && item.role === "assistant") {
|
||||||
@@ -192,27 +255,23 @@ function extractAssistantText(response) {
|
|||||||
return out.join("\n").trim();
|
return out.join("\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAgentTurn(userText) {
|
async function runOpenAITurn(userText) {
|
||||||
const apiKey = apiKeyEl.value.trim();
|
const apiKey = apiKeyEl.value.trim();
|
||||||
if (!apiKey) throw new Error("Missing API key");
|
if (!apiKey) throw new Error("Missing API key");
|
||||||
|
|
||||||
// Add user message
|
openaiInputItems.push({ role: "user", content: userText });
|
||||||
inputItems.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++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
const response = await openAIResponsesCreate({
|
const response = await openAIResponsesCreate({
|
||||||
apiKey,
|
apiKey,
|
||||||
model: modelEl.value,
|
model: modelEl.value,
|
||||||
input: inputItems,
|
input: openaiInputItems,
|
||||||
tools,
|
tools,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep ALL output items (incl reasoning/tool calls) in the running state.
|
openaiInputItems.push(...(response.output ?? []));
|
||||||
inputItems.push(...(response.output ?? []));
|
|
||||||
|
|
||||||
// Show any assistant text immediately
|
const text = extractAssistantTextOpenAI(response);
|
||||||
const text = extractAssistantText(response);
|
|
||||||
if (text) addMessage("assistant", text);
|
if (text) addMessage("assistant", text);
|
||||||
|
|
||||||
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
|
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
|
||||||
@@ -227,8 +286,7 @@ async function runAgentTurn(userText) {
|
|||||||
|
|
||||||
const toolRes = await callWorker("toolCall", { name: call.name, args });
|
const toolRes = await callWorker("toolCall", { name: call.name, args });
|
||||||
|
|
||||||
// Feed tool result back to the model
|
openaiInputItems.push({
|
||||||
inputItems.push({
|
|
||||||
type: "function_call_output",
|
type: "function_call_output",
|
||||||
call_id: call.call_id,
|
call_id: call.call_id,
|
||||||
output: JSON.stringify(toolRes.result),
|
output: JSON.stringify(toolRes.result),
|
||||||
@@ -241,6 +299,113 @@ async function runAgentTurn(userText) {
|
|||||||
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
|
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Anthropic Messages API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function anthropicMessagesCreate({ apiKey, model, system, messages, tools }) {
|
||||||
|
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({
|
||||||
|
model,
|
||||||
|
max_tokens: 4096,
|
||||||
|
system,
|
||||||
|
tools,
|
||||||
|
messages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Anthropic error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssistantTextAnthropic(response) {
|
||||||
|
const out = [];
|
||||||
|
for (const block of response.content ?? []) {
|
||||||
|
if (block.type === "text") out.push(block.text);
|
||||||
|
}
|
||||||
|
return out.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnthropicTurn(userText) {
|
||||||
|
const apiKey = apiKeyEl.value.trim();
|
||||||
|
if (!apiKey) throw new Error("Missing API key");
|
||||||
|
|
||||||
|
anthropicMessages.push({ role: "user", content: userText });
|
||||||
|
|
||||||
|
const claudeTools = toolsForAnthropic();
|
||||||
|
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const response = await anthropicMessagesCreate({
|
||||||
|
apiKey,
|
||||||
|
model: modelEl.value,
|
||||||
|
system: SYSTEM_INSTRUCTIONS,
|
||||||
|
messages: anthropicMessages,
|
||||||
|
tools: claudeTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show any assistant text
|
||||||
|
const text = extractAssistantTextAnthropic(response);
|
||||||
|
if (text) addMessage("assistant", text);
|
||||||
|
|
||||||
|
// Append the full assistant response to conversation history
|
||||||
|
anthropicMessages.push({ role: "assistant", content: response.content });
|
||||||
|
|
||||||
|
// Check for tool use
|
||||||
|
const toolUseBlocks = (response.content ?? []).filter((b) => b.type === "tool_use");
|
||||||
|
if (response.stop_reason !== "tool_use" || toolUseBlocks.length === 0) return;
|
||||||
|
|
||||||
|
// Execute each tool call and collect results
|
||||||
|
const toolResultBlocks = [];
|
||||||
|
for (const toolUse of toolUseBlocks) {
|
||||||
|
const args = toolUse.input ?? {};
|
||||||
|
|
||||||
|
addMessage("tool", `→ ${toolUse.name}(${JSON.stringify(args)})`);
|
||||||
|
|
||||||
|
let resultContent;
|
||||||
|
try {
|
||||||
|
const toolRes = await callWorker("toolCall", { name: toolUse.name, args });
|
||||||
|
resultContent = JSON.stringify(toolRes.result);
|
||||||
|
} catch (e) {
|
||||||
|
resultContent = JSON.stringify({ error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
toolResultBlocks.push({
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: toolUse.id,
|
||||||
|
content: resultContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
addMessage("tool", `← ${toolUse.name}: ${resultContent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed all tool results back as a single user message
|
||||||
|
anthropicMessages.push({ role: "user", content: toolResultBlocks });
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Unified agent turn
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function runAgentTurn(userText) {
|
||||||
|
if (getProvider() === "anthropic") {
|
||||||
|
return runAnthropicTurn(userText);
|
||||||
|
}
|
||||||
|
return runOpenAITurn(userText);
|
||||||
|
}
|
||||||
|
|
||||||
sendBtn.onclick = async () => {
|
sendBtn.onclick = async () => {
|
||||||
const text = inputEl.value.trim();
|
const text = inputEl.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -312,9 +477,10 @@ downloadBtn.onclick = async () => {
|
|||||||
try {
|
try {
|
||||||
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
|
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
|
||||||
await callWorker("init", {});
|
await callWorker("init", {});
|
||||||
|
updateProviderUI();
|
||||||
setBusy(false, "Ready");
|
setBusy(false, "Ready");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setBusy(true, "Error");
|
setBusy(true, "Error");
|
||||||
addMessage("assistant", `Worker init failed: ${e.message}`);
|
addMessage("assistant", `Worker init failed: ${e.message}`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
+21
-5
@@ -143,7 +143,8 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#model {
|
#model,
|
||||||
|
#provider {
|
||||||
background: white;
|
background: white;
|
||||||
color: gray;
|
color: gray;
|
||||||
border: solid 1px #eee;
|
border: solid 1px #eee;
|
||||||
@@ -239,14 +240,22 @@
|
|||||||
<main>
|
<main>
|
||||||
<section class="side">
|
<section class="side">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>OpenAI API key<span class="small">stored in browser memory; only sent to OpenAI servers</span></label>
|
<label>Provider</label>
|
||||||
|
<select id="provider" style="width: 100%;">
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label id="apiKeyLabel">OpenAI API key<span class="small">stored in browser memory; only sent to provider servers</span></label>
|
||||||
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div class="row" style="margin-bottom: 10px;">
|
<div class="row" style="margin-bottom: 10px;">
|
||||||
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to OpenAI servers</span></label>
|
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to provider servers</span></label>
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
||||||
@@ -296,8 +305,15 @@
|
|||||||
|
|
||||||
<strong>IfcOpenShell AI Assistant</strong>
|
<strong>IfcOpenShell AI Assistant</strong>
|
||||||
<select id="model">
|
<select id="model">
|
||||||
<option value="gpt-5">gpt-5</option>
|
<optgroup id="modelGroupOpenai" label="OpenAI">
|
||||||
<option value="gpt-4.1">gpt-4.1</option>
|
<option value="gpt-5">gpt-5</option>
|
||||||
|
<option value="gpt-4.1">gpt-4.1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup id="modelGroupAnthropic" label="Anthropic" disabled>
|
||||||
|
<option value="claude-sonnet-4-6" disabled>claude-sonnet-4-6</option>
|
||||||
|
<option value="claude-opus-4-6" disabled>claude-opus-4-6</option>
|
||||||
|
<option value="claude-haiku-4-5-20251001" disabled>claude-haiku-4-5</option>
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
Reference in New Issue
Block a user