Add Cloudflare Workers AI provider and multiple AI chat improvements

- Add Cloudflare Workers AI as third provider alongside Claude and Ollama
  - New cloudflare.js API handler with format conversion
  - Tool converter functions for Cloudflare's OpenAI-compatible format
  - Handle [TOOL_CALLS] and [Called tool:] text formats from Mistral
  - Robust parser that handles truncated JSON responses

- Add usage tracking with cost display
  - New usageTracker.js service for tracking token usage per session
  - UsageDetailModal component showing per-request breakdown
  - Cost display in ChatPanel header

- Add new diagram manipulation features
  - Entity scale and rotation support via modify_entity tool
  - Wikipedia search tool for researching topics before diagramming
  - Clear conversation tool to reset chat history
  - JSON import from hamburger menu (moved from ChatPanel)

- Fix connection label rotation in billboard mode
  - Labels no longer have conflicting local rotation when billboard enabled
  - Update rotation when rendering mode changes

- Improve tool calling reliability
  - Add MAX_TOOL_ITERATIONS safety limit
  - Break loop after model switch to prevent context issues
  - Increase max_tokens to 4096 to prevent truncation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-01-03 06:31:43 -06:00
parent fd81ba3be7
commit 03217f3e65
16 changed files with 1826 additions and 41 deletions

View File

@ -1,7 +1,7 @@
{
"name": "immersive",
"private": true,
"version": "0.0.8-44",
"version": "0.0.8-45",
"type": "module",
"license": "MIT",
"engines": {

View File

@ -1,5 +1,6 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com";
@ -93,6 +94,40 @@ router.post("/*path", async (req, res) => {
const data = await response.json();
console.log(`[Claude API] Response parsed. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
// Extract content for detailed tracking
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const usageRecord = trackUsage(sessionId, modifiedBody.model, data.usage, {
inputText,
outputText,
toolCalls
});
console.log(`[Claude API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
// Log cumulative session usage if session exists
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Claude API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Claude API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Claude API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Claude API] Total cost: ${formatCost(sessionStats.totalCost)}`);
}
}
}
if (data.error) {
console.error(`[Claude API] API returned error:`, data.error);
}

213
server/api/cloudflare.js Normal file
View File

@ -0,0 +1,213 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { trackUsage, getUsageSummary, formatCost, getSessionUsage } from "../services/usageTracker.js";
import { getCloudflareAccountId, getCloudflareApiToken } from "../services/providerConfig.js";
import {
claudeToolsToCloudflare,
claudeMessagesToCloudflare,
cloudflareResponseToClaude
} from "../services/toolConverter.js";
const router = Router();
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
// Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Cloudflare API] ========== REQUEST START ==========`);
const accountId = getCloudflareAccountId();
const apiToken = getCloudflareApiToken();
if (!accountId) {
console.error(`[Cloudflare API] ERROR: Account ID not configured`);
return res.status(500).json({ error: "Cloudflare account ID not configured" });
}
if (!apiToken) {
console.error(`[Cloudflare API] ERROR: API token not configured`);
return res.status(500).json({ error: "Cloudflare API token not configured" });
}
// Check for session-based request
const { sessionId, ...requestBody } = req.body;
let modifiedBody = { ...requestBody };
const model = requestBody.model;
console.log(`[Cloudflare API] Session ID: ${sessionId || 'none'}`);
console.log(`[Cloudflare API] Model: ${model}`);
console.log(`[Cloudflare API] Messages count: ${requestBody.messages?.length || 0}`);
// Build system prompt with entity context
let systemPrompt = modifiedBody.system || '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Cloudflare API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
const entityContext = buildEntityContext(session.entities);
console.log(`[Cloudflare API] Entity context added (${entityContext.length} chars)`);
systemPrompt += entityContext;
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && modifiedBody.messages) {
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
console.log(`[Cloudflare API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
}
} else {
console.log(`[Cloudflare API] WARNING: Session ${sessionId} not found`);
}
}
try {
// Convert to Cloudflare format
const cfMessages = claudeMessagesToCloudflare(modifiedBody.messages || [], systemPrompt);
const cfTools = modifiedBody.tools ? claudeToolsToCloudflare(modifiedBody.tools) : undefined;
// Build Cloudflare request body
const cfRequestBody = {
messages: cfMessages,
max_tokens: modifiedBody.max_tokens || 1024
};
// Only include tools if the model supports them
if (cfTools && cfTools.length > 0) {
cfRequestBody.tools = cfTools;
}
// Cloudflare endpoint: https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
console.log(`[Cloudflare API] Sending request to: ${endpoint}`);
console.log(`[Cloudflare API] Request body messages: ${cfMessages.length}, tools: ${cfTools?.length || 0}`);
const requestBodyJson = JSON.stringify(cfRequestBody);
console.log(`[Cloudflare API] Full request body (${requestBodyJson.length} bytes):`);
console.log(requestBodyJson);
const fetchStart = Date.now();
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiToken}`,
},
body: JSON.stringify(cfRequestBody),
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Cloudflare API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Cloudflare API] Parsing response JSON...`);
const cfData = await response.json();
if (!cfData.success) {
console.error(`[Cloudflare API] API returned error:`, cfData.errors);
return res.status(response.status).json({
error: cfData.errors?.[0]?.message || "Cloudflare API error",
details: cfData.errors
});
}
// Convert Cloudflare response to Claude format
const data = cloudflareResponseToClaude(cfData, model);
console.log(`[Cloudflare API] Response converted. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
// Track and log token usage
if (data.usage) {
// Extract content for detailed tracking
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
const inputText = typeof userMessage?.content === 'string' ? userMessage.content : null;
const outputText = data.content
?.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n') || null;
const toolCalls = data.content
?.filter(c => c.type === 'tool_use')
.map(c => ({ name: c.name, input: c.input })) || [];
const usageRecord = trackUsage(sessionId, model, data.usage, {
inputText,
outputText,
toolCalls
});
console.log(`[Cloudflare API] REQUEST USAGE: ${getUsageSummary(usageRecord)}`);
// Log cumulative session usage if session exists
if (sessionId) {
const sessionStats = getSessionUsage(sessionId);
if (sessionStats) {
console.log(`[Cloudflare API] SESSION TOTALS (${sessionStats.requestCount} requests):`);
console.log(`[Cloudflare API] Total input: ${sessionStats.totalInputTokens} tokens`);
console.log(`[Cloudflare API] Total output: ${sessionStats.totalOutputTokens} tokens`);
console.log(`[Cloudflare API] Total cost: ${formatCost(sessionStats.totalCost)}`);
}
}
}
// If session exists and response is successful, store messages
if (sessionId && response.ok && data.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new (only if it's a string, not tool results)
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Cloudflare API] Stored user message to session`);
}
// Store the assistant response (text only, not tool use blocks)
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Cloudflare API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Cloudflare API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.status(response.status).json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Cloudflare API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Cloudflare API] Error:`, error);
console.error(`[Cloudflare API] Error message:`, error.message);
console.error(`[Cloudflare API] Error stack:`, error.stack);
res.status(500).json({ error: "Failed to proxy request to Cloudflare API", details: error.message });
}
});
export default router;

View File

@ -1,6 +1,7 @@
import { Router } from "express";
import claudeRouter from "./claude.js";
import ollamaRouter from "./ollama.js";
import cloudflareRouter from "./cloudflare.js";
import sessionRouter from "./session.js";
import userRouter from "./user.js";
@ -18,6 +19,9 @@ router.use("/claude", claudeRouter);
// Ollama API proxy
router.use("/ollama", ollamaRouter);
// Cloudflare Workers AI proxy
router.use("/cloudflare", cloudflareRouter);
// Health check
router.get("/health", (req, res) => {
res.json({ status: "ok" });

View File

@ -9,6 +9,7 @@ import {
deleteSession,
getStats
} from "../services/sessionStore.js";
import { getSessionUsage, getGlobalUsage, formatCost } from "../services/usageTracker.js";
const router = Router();
@ -26,6 +27,37 @@ router.get("/debug/stats", (req, res) => {
res.json(stats);
});
/**
* GET /api/session/usage/global
* Get global token usage and cost statistics
* NOTE: Must be before /:id routes
*/
router.get("/usage/global", (req, res) => {
const usage = getGlobalUsage();
res.json({
...usage,
totalCostFormatted: formatCost(usage.totalCost),
uptimeFormatted: `${Math.round(usage.uptime / 1000 / 60)} minutes`
});
});
/**
* GET /api/session/:id/usage
* Get token usage and cost for a specific session
*/
router.get("/:id/usage", (req, res) => {
const usage = getSessionUsage(req.params.id);
if (!usage) {
return res.status(404).json({ error: "No usage data for session" });
}
res.json({
...usage,
totalCostFormatted: formatCost(usage.totalCost)
});
});
/**
* POST /api/session/create
* Create a new session or return existing one for a diagram

View File

@ -1,12 +1,13 @@
/**
* AI Provider Configuration
* Manages configuration for different AI providers (Claude, Ollama)
* Manages configuration for different AI providers (Claude, Ollama, Cloudflare)
*/
// Default configuration
const DEFAULT_PROVIDER = 'claude';
// Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
const DEFAULT_CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || '';
/**
* Get the current AI provider
@ -32,6 +33,31 @@ export function getAnthropicUrl() {
return process.env.ANTHROPIC_API_URL || 'https://api.anthropic.com';
}
/**
* Get Cloudflare Account ID
* @returns {string} Cloudflare account ID
*/
export function getCloudflareAccountId() {
return process.env.CLOUDFLARE_ACCOUNT_ID || DEFAULT_CLOUDFLARE_ACCOUNT_ID;
}
/**
* Get Cloudflare API Token
* @returns {string} Cloudflare API token
*/
export function getCloudflareApiToken() {
return process.env.CLOUDFLARE_API_TOKEN || '';
}
/**
* Get Cloudflare Workers AI base URL
* @returns {string} Cloudflare Workers AI base URL
*/
export function getCloudflareUrl() {
const accountId = getCloudflareAccountId();
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run`;
}
/**
* Get provider configuration for a specific provider
* @param {string} provider - Provider name
@ -46,6 +72,15 @@ export function getProviderConfig(provider) {
chatEndpoint: '/api/chat',
requiresAuth: false
};
case 'cloudflare':
return {
name: 'cloudflare',
baseUrl: getCloudflareUrl(),
chatEndpoint: '', // Model is appended to baseUrl
requiresAuth: true,
apiKey: getCloudflareApiToken(),
accountId: getCloudflareAccountId()
};
case 'claude':
default:
return {
@ -71,6 +106,11 @@ export function getProviderFromModel(modelId) {
return 'claude';
}
// Cloudflare models start with '@cf/' or '@hf/'
if (modelId.startsWith('@cf/') || modelId.startsWith('@hf/')) {
return 'cloudflare';
}
// Known Ollama models
const ollamaModels = [
'llama', 'mistral', 'qwen', 'codellama', 'phi',
@ -92,6 +132,9 @@ export default {
getProvider,
getOllamaUrl,
getAnthropicUrl,
getCloudflareAccountId,
getCloudflareApiToken,
getCloudflareUrl,
getProviderConfig,
getProviderFromModel
};

View File

@ -235,11 +235,429 @@ export function ollamaResponseToClaude(ollamaResponse) {
};
}
// ============================================
// Cloudflare Workers AI Converters
// ============================================
/**
* Convert Claude tool definition to Cloudflare format
* Cloudflare uses OpenAI-compatible format
*
* Claude format:
* { name: "...", description: "...", input_schema: { type: "object", properties: {...} } }
*
* Cloudflare format:
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
*
* @param {object} claudeTool - Tool in Claude format
* @returns {object} Tool in Cloudflare format
*/
export function claudeToolToCloudflare(claudeTool) {
return {
type: "function",
function: {
name: claudeTool.name,
description: claudeTool.description,
parameters: claudeTool.input_schema
}
};
}
/**
* Convert array of Claude tools to Cloudflare format
* @param {Array} claudeTools - Array of Claude tool definitions
* @returns {Array} Array of Cloudflare function definitions
*/
export function claudeToolsToCloudflare(claudeTools) {
if (!claudeTools || !Array.isArray(claudeTools)) {
return [];
}
return claudeTools.map(claudeToolToCloudflare);
}
/**
* Convert Cloudflare tool call to Claude format
*
* Cloudflare format:
* { name: "...", arguments: {...} }
*
* Claude format:
* { type: "tool_use", id: "...", name: "...", input: {...} }
*
* @param {object} cfToolCall - Tool call from Cloudflare response
* @param {number} index - Index for generating unique ID
* @returns {object} Tool call in Claude format
*/
export function cloudflareToolCallToClaude(cfToolCall, index = 0) {
// Parse arguments if it's a string
let input = cfToolCall.arguments;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch (e) {
console.warn('[ToolConverter] Failed to parse Cloudflare tool arguments:', e);
input = {};
}
}
return {
type: "tool_use",
id: `toolu_cf_${Date.now()}_${index}`,
name: cfToolCall.name,
input: input || {}
};
}
/**
* Convert Claude messages array to Cloudflare format
* Cloudflare uses OpenAI-compatible message format
*
* IMPORTANT: Cloudflare Workers AI does NOT support multi-turn tool conversations.
* It crashes with error 3043 when conversation history contains tool_calls or tool results.
* We must strip tool call history and only keep text content from past messages.
*
* @param {Array} claudeMessages - Messages in Claude format
* @param {string} systemPrompt - System prompt to prepend
* @returns {Array} Messages in Cloudflare format
*/
export function claudeMessagesToCloudflare(claudeMessages, systemPrompt) {
const cfMessages = [];
// Add system message if provided
if (systemPrompt) {
cfMessages.push({
role: "system",
content: systemPrompt
});
}
// Cloudflare doesn't support tool call history in native format - convert to text
// so the model knows what tools were called and their results
for (const msg of claudeMessages) {
if (msg.role === 'user') {
if (Array.isArray(msg.content)) {
// Convert tool_result blocks to text summaries
const textParts = [];
for (const block of msg.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_result') {
// Convert tool result to readable text so model knows it was executed
textParts.push(`[Tool Result: ${block.content}]`);
}
}
if (textParts.length > 0) {
cfMessages.push({
role: "user",
content: textParts.join('\n')
});
}
} else {
cfMessages.push({
role: "user",
content: msg.content
});
}
} else if (msg.role === 'assistant') {
// For assistant messages, convert tool_use to text descriptions
const textParts = [];
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_use') {
// Convert tool call to readable text so model knows it called this
const argsStr = JSON.stringify(block.input || {});
textParts.push(`[Called tool: ${block.name}(${argsStr})]`);
}
}
} else {
textParts.push(msg.content || '');
}
// Also handle pre-converted messages that might have tool_calls property
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
const name = tc.function?.name || tc.name || 'unknown';
const args = tc.function?.arguments || tc.arguments || '{}';
textParts.push(`[Called tool: ${name}(${typeof args === 'string' ? args : JSON.stringify(args)})]`);
}
}
const textContent = textParts.filter(t => t).join('\n');
if (textContent) {
cfMessages.push({
role: "assistant",
content: textContent
});
}
} else if (msg.role === 'tool') {
// Convert tool messages to user messages with result text
cfMessages.push({
role: "user",
content: `[Tool Result (${msg.name || 'unknown'}): ${msg.content}]`
});
}
}
return cfMessages;
}
/**
* Try to repair and parse a potentially truncated JSON object
* @param {string} jsonStr - Potentially incomplete JSON string
* @returns {object|null} - Parsed object or null if unparseable
*/
function tryRepairAndParse(jsonStr) {
// First try as-is
try {
return JSON.parse(jsonStr);
} catch (e) {
// Try adding closing brackets
const repairs = [
jsonStr + '}',
jsonStr + '"}',
jsonStr + '}}',
jsonStr + '"}}',
jsonStr + ': null}}',
jsonStr + '": null}}'
];
for (const attempt of repairs) {
try {
const parsed = JSON.parse(attempt);
if (parsed.name) { // Must have a name to be valid
return parsed;
}
} catch (e2) {
// Continue trying
}
}
return null;
}
}
/**
* Parse tool calls from text response
* Handles multiple formats:
* 1. Mistral native: [TOOL_CALLS][{"name": "...", "arguments": {...}}, ...]
* 2. History format: [Called tool: name({args})]
*
* This parser is resilient to truncation - it will extract as many valid tool calls
* as possible even if the JSON is incomplete.
*
* @param {string} text - Text response that may contain embedded tool calls
* @returns {object} - { cleanText: string, toolCalls: array }
*/
function parseTextToolCalls(text) {
if (!text) return { cleanText: '', toolCalls: [] };
const toolCalls = [];
let cleanText = text;
// Format 1: [TOOL_CALLS][...] (Mistral native format)
const toolCallMatch = text.match(/\[TOOL_CALLS\]\s*(\[[\s\S]*)/);
if (toolCallMatch) {
const toolCallsJson = toolCallMatch[1];
// First try normal JSON.parse (for complete responses)
try {
const parsedCalls = JSON.parse(toolCallsJson);
if (Array.isArray(parsedCalls)) {
const validCalls = parsedCalls
.filter(call => call && call.name)
.map(call => ({
name: call.name,
arguments: call.arguments || {}
}));
console.log(`[ToolConverter] Parsed ${validCalls.length} tool calls from [TOOL_CALLS] JSON`);
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
return { cleanText, toolCalls: validCalls };
}
} catch (e) {
console.log('[ToolConverter] [TOOL_CALLS] JSON incomplete, attempting to extract individual tool calls...');
}
// JSON is truncated - extract individual tool calls using regex
const toolCallStarts = [];
const startPattern = /\{"name"\s*:\s*"/g;
let match;
while ((match = startPattern.exec(toolCallsJson)) !== null) {
toolCallStarts.push(match.index);
}
console.log(`[ToolConverter] Found ${toolCallStarts.length} potential tool call starts in [TOOL_CALLS]`);
for (let i = 0; i < toolCallStarts.length; i++) {
const start = toolCallStarts[i];
const end = toolCallStarts[i + 1] || toolCallsJson.length;
let segment = toolCallsJson.substring(start, end).replace(/,\s*$/, '');
const parsed = tryRepairAndParse(segment);
if (parsed && parsed.name) {
toolCalls.push({
name: parsed.name,
arguments: parsed.arguments || {}
});
console.log(`[ToolConverter] Extracted tool call from [TOOL_CALLS]: ${parsed.name}`);
}
}
cleanText = text.replace(/\[TOOL_CALLS\]\s*\[[\s\S]*/, '').trim();
if (toolCalls.length > 0) {
console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [TOOL_CALLS] format`);
return { cleanText, toolCalls };
}
}
// Format 2: [Called tool: name({args})] (history format the model might mimic)
// Match patterns like: [Called tool: create_entity({"shape": "box", ...})]
const calledToolPattern = /\[Called tool:\s*(\w+)\((\{[\s\S]*?\})\)\]/g;
let calledMatch;
const calledToolMatches = [];
while ((calledMatch = calledToolPattern.exec(text)) !== null) {
calledToolMatches.push({
fullMatch: calledMatch[0],
name: calledMatch[1],
argsStr: calledMatch[2]
});
}
if (calledToolMatches.length > 0) {
console.log(`[ToolConverter] Found ${calledToolMatches.length} [Called tool:] format tool calls`);
for (const match of calledToolMatches) {
try {
const args = JSON.parse(match.argsStr);
toolCalls.push({
name: match.name,
arguments: args
});
console.log(`[ToolConverter] Extracted tool call from [Called tool:]: ${match.name}`);
// Remove this match from clean text
cleanText = cleanText.replace(match.fullMatch, '');
} catch (e) {
console.warn(`[ToolConverter] Failed to parse [Called tool:] args for ${match.name}:`, e.message);
// Try to repair the JSON
const repaired = tryRepairAndParse(match.argsStr);
if (repaired) {
toolCalls.push({
name: match.name,
arguments: repaired
});
console.log(`[ToolConverter] Repaired and extracted tool call: ${match.name}`);
cleanText = cleanText.replace(match.fullMatch, '');
}
}
}
cleanText = cleanText.trim();
if (toolCalls.length > 0) {
console.log(`[ToolConverter] Extracted ${toolCalls.length} tool calls from [Called tool:] format`);
return { cleanText, toolCalls };
}
}
// No tool calls found
return { cleanText: text, toolCalls: [] };
}
/**
* Convert Cloudflare response to Claude format
*
* Cloudflare response format:
* {
* result: {
* response: "text output",
* tool_calls: [{ name: "...", arguments: {...} }]
* },
* success: true
* }
*
* Note: Some models (like Mistral) output tool calls as text in format:
* [TOOL_CALLS][{...}]
*
* @param {object} cfResponse - Response from Cloudflare Workers AI API
* @param {string} model - Model name used
* @returns {object} Response in Claude format
*/
export function cloudflareResponseToClaude(cfResponse, model) {
const content = [];
const result = cfResponse.result || cfResponse;
// Get tool calls from proper field or parse from text
let toolCalls = result.tool_calls || [];
let textResponse = result.response || '';
// Log raw response for debugging
console.log(`[ToolConverter] Raw response (first 500 chars): ${textResponse.substring(0, 500)}`);
console.log(`[ToolConverter] Native tool_calls present: ${toolCalls.length}`);
// Check if tool calls are embedded in text response (Mistral format or history format)
if (toolCalls.length === 0 && textResponse) {
console.log(`[ToolConverter] No native tool_calls, parsing text response...`);
const parsed = parseTextToolCalls(textResponse);
console.log(`[ToolConverter] Parsed ${parsed.toolCalls.length} tool calls from text`);
if (parsed.toolCalls.length > 0) {
toolCalls = parsed.toolCalls;
textResponse = parsed.cleanText;
}
}
// Add text content if present (after removing tool calls)
if (textResponse) {
content.push({
type: "text",
text: textResponse
});
}
// Add tool calls if present
if (toolCalls.length > 0) {
for (let i = 0; i < toolCalls.length; i++) {
content.push(cloudflareToolCallToClaude(toolCalls[i], i));
}
}
// Determine stop reason
let stopReason = "end_turn";
if (toolCalls.length > 0) {
stopReason = "tool_use";
}
// Extract usage if available
const usage = {
input_tokens: result.usage?.prompt_tokens || result.usage?.input_tokens || 0,
output_tokens: result.usage?.completion_tokens || result.usage?.output_tokens || 0
};
return {
id: `msg_cf_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: model,
stop_reason: stopReason,
usage: usage
};
}
export default {
claudeToolToOllama,
claudeToolsToOllama,
ollamaToolCallToClaude,
claudeToolResultToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude
ollamaResponseToClaude,
// Cloudflare converters
claudeToolToCloudflare,
claudeToolsToCloudflare,
cloudflareToolCallToClaude,
claudeMessagesToCloudflare,
cloudflareResponseToClaude
};

View File

@ -0,0 +1,241 @@
/**
* Token usage tracking and cost estimation service
*/
// Pricing per million tokens (as of Dec 2025)
const MODEL_PRICING = {
// Claude 4.5 models
"claude-opus-4-5-20251101": { input: 5.00, output: 25.00 },
"claude-sonnet-4-5-20250929": { input: 3.00, output: 15.00 },
"claude-haiku-4-5-20251001": { input: 1.00, output: 5.00 },
// Claude 4 models
"claude-opus-4-1-20250805": { input: 15.00, output: 75.00 },
"claude-sonnet-4-20250514": { input: 3.00, output: 15.00 },
// Claude 3.7/3.5 models
"claude-3-7-sonnet-20250219": { input: 3.00, output: 15.00 },
"claude-3-5-sonnet-20241022": { input: 3.00, output: 15.00 },
"claude-3-5-haiku-20241022": { input: 0.80, output: 4.00 },
// Claude 3 models
"claude-3-opus-20240229": { input: 15.00, output: 75.00 },
"claude-3-sonnet-20240229": { input: 3.00, output: 15.00 },
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
// Cloudflare Workers AI models (approximate - based on neuron costs)
"@cf/mistralai/mistral-small-3.1-24b-instruct": { input: 0.30, output: 0.30 },
"@hf/nousresearch/hermes-2-pro-mistral-7b": { input: 0.10, output: 0.10 },
"@cf/meta/llama-3.3-70b-instruct-fp8-fast": { input: 0.20, output: 0.20 },
"@cf/meta/llama-3.1-70b-instruct": { input: 0.20, output: 0.20 },
"@cf/meta/llama-3.1-8b-instruct": { input: 0.05, output: 0.05 },
"@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": { input: 0.15, output: 0.15 },
"@cf/qwen/qwen2.5-coder-32b-instruct": { input: 0.15, output: 0.15 },
};
// Cache pricing multipliers
const CACHE_WRITE_MULTIPLIER = 1.25; // 25% more expensive to write cache
const CACHE_READ_MULTIPLIER = 0.10; // 90% cheaper to read from cache
// In-memory storage for usage tracking
const sessionUsage = new Map();
const globalUsage = {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreationTokens: 0,
totalCacheReadTokens: 0,
totalCost: 0,
requestCount: 0,
byModel: {},
startTime: Date.now()
};
/**
* Get pricing for a model, with fallback to sonnet pricing
*/
function getModelPricing(model) {
// Try exact match first
if (MODEL_PRICING[model]) {
return MODEL_PRICING[model];
}
// Try to match by model family
if (model.includes("opus")) {
return MODEL_PRICING["claude-opus-4-5-20251101"];
}
if (model.includes("haiku")) {
return MODEL_PRICING["claude-haiku-4-5-20251001"];
}
// Cloudflare models - default to cheap pricing
if (model.startsWith("@cf/") || model.startsWith("@hf/")) {
return { input: 0.10, output: 0.10 };
}
// Default to sonnet pricing
return MODEL_PRICING["claude-sonnet-4-5-20250929"];
}
/**
* Calculate cost for a request
*/
function calculateCost(model, usage) {
const pricing = getModelPricing(model);
const perMillionDivisor = 1_000_000;
let cost = 0;
// Standard input tokens
const standardInputTokens = (usage.input_tokens || 0) - (usage.cache_read_input_tokens || 0);
cost += (standardInputTokens / perMillionDivisor) * pricing.input;
// Cache read tokens (90% cheaper)
if (usage.cache_read_input_tokens) {
cost += (usage.cache_read_input_tokens / perMillionDivisor) * pricing.input * CACHE_READ_MULTIPLIER;
}
// Cache creation tokens (25% more expensive)
if (usage.cache_creation_input_tokens) {
cost += (usage.cache_creation_input_tokens / perMillionDivisor) * pricing.input * CACHE_WRITE_MULTIPLIER;
}
// Output tokens
cost += ((usage.output_tokens || 0) / perMillionDivisor) * pricing.output;
return cost;
}
/**
* Track usage for a request
* @param {string} sessionId - Session identifier
* @param {string} model - Model used
* @param {object} usage - Token usage from API response
* @param {object} content - Optional input/output content for detailed tracking
* @param {string} content.inputText - User input text
* @param {string} content.outputText - Assistant output text
* @param {array} content.toolCalls - Tool calls made
*/
export function trackUsage(sessionId, model, usage, content = {}) {
if (!usage) return null;
const cost = calculateCost(model, usage);
// Truncate text for storage (keep first 500 chars)
const truncate = (text, maxLen = 500) => {
if (!text) return null;
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
};
const usageRecord = {
timestamp: Date.now(),
model,
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
cacheReadTokens: usage.cache_read_input_tokens || 0,
cost,
inputText: truncate(content.inputText),
outputText: truncate(content.outputText),
toolCalls: content.toolCalls || []
};
// Update session usage
if (sessionId) {
if (!sessionUsage.has(sessionId)) {
sessionUsage.set(sessionId, {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheCreationTokens: 0,
totalCacheReadTokens: 0,
totalCost: 0,
requestCount: 0,
requests: [],
startTime: Date.now()
});
}
const session = sessionUsage.get(sessionId);
session.totalInputTokens += usageRecord.inputTokens;
session.totalOutputTokens += usageRecord.outputTokens;
session.totalCacheCreationTokens += usageRecord.cacheCreationTokens;
session.totalCacheReadTokens += usageRecord.cacheReadTokens;
session.totalCost += cost;
session.requestCount += 1;
session.requests.push(usageRecord);
// Keep only last 100 requests per session to limit memory
if (session.requests.length > 100) {
session.requests.shift();
}
}
// Update global usage
globalUsage.totalInputTokens += usageRecord.inputTokens;
globalUsage.totalOutputTokens += usageRecord.outputTokens;
globalUsage.totalCacheCreationTokens += usageRecord.cacheCreationTokens;
globalUsage.totalCacheReadTokens += usageRecord.cacheReadTokens;
globalUsage.totalCost += cost;
globalUsage.requestCount += 1;
// Track by model
if (!globalUsage.byModel[model]) {
globalUsage.byModel[model] = {
inputTokens: 0,
outputTokens: 0,
cost: 0,
requestCount: 0
};
}
globalUsage.byModel[model].inputTokens += usageRecord.inputTokens;
globalUsage.byModel[model].outputTokens += usageRecord.outputTokens;
globalUsage.byModel[model].cost += cost;
globalUsage.byModel[model].requestCount += 1;
return usageRecord;
}
/**
* Get usage for a session
*/
export function getSessionUsage(sessionId) {
return sessionUsage.get(sessionId) || null;
}
/**
* Get global usage stats
*/
export function getGlobalUsage() {
return {
...globalUsage,
uptime: Date.now() - globalUsage.startTime
};
}
/**
* Format cost as currency string
*/
export function formatCost(cost) {
return `$${cost.toFixed(6)}`;
}
/**
* Clear session usage (call when session ends)
*/
export function clearSessionUsage(sessionId) {
sessionUsage.delete(sessionId);
}
/**
* Get a formatted usage summary for logging
*/
export function getUsageSummary(usageRecord) {
if (!usageRecord) return "No usage data";
return [
`Input: ${usageRecord.inputTokens}`,
`Output: ${usageRecord.outputTokens}`,
usageRecord.cacheReadTokens ? `Cache read: ${usageRecord.cacheReadTokens}` : null,
usageRecord.cacheCreationTokens ? `Cache write: ${usageRecord.cacheCreationTokens}` : null,
`Cost: ${formatCost(usageRecord.cost)}`
].filter(Boolean).join(" | ");
}

View File

@ -160,6 +160,12 @@ export class DiagramManager {
if (updates.position !== undefined) {
diagramObject.position = updates.position;
}
if (updates.scale !== undefined) {
diagramObject.scale = updates.scale;
}
if (updates.rotation !== undefined) {
diagramObject.rotation = updates.rotation;
}
}
} else {
this._logger.warn('chatModifyEntity: entity not found:', target);

View File

@ -155,6 +155,34 @@ export class DiagramObject {
}
}
public set scale(value: { x: number; y: number; z: number }) {
if (this._mesh) {
this._mesh.scaling = new Vector3(value.x, value.y, value.z);
if (this._diagramEntity) {
this._diagramEntity.scale = value;
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
// Update label position since entity size changed
this.updateLabelPosition();
}
}
public set rotation(value: { x: number; y: number; z: number }) {
if (this._baseTransform) {
this._baseTransform.rotation = new Vector3(value.x, value.y, value.z);
if (this._diagramEntity) {
this._diagramEntity.rotation = value;
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
}
public set color(value: string) {
if (!this._diagramEntity || this._diagramEntity.color === value) {
return;
@ -250,6 +278,9 @@ export class DiagramObject {
this._logger.warn('Distance-based label rendering mode not yet implemented');
break;
}
// Update label position/rotation based on new mode (connections need different rotation in billboard vs fixed)
this.updateLabelPosition();
}
public updateLabelPosition() {
@ -257,11 +288,21 @@ export class DiagramObject {
this._mesh.computeWorldMatrix(true);
this._mesh.refreshBoundingInfo({});
const isBillboard = (appConfigInstance.current.labelRenderingMode || 'billboard') === 'billboard';
if (this._from && this._to) {
// Connection labels (arrows/lines)
this._label.position.y = .05;
this._label.rotation.y = Math.PI / 2;
this._labelBack.rotation.y = Math.PI;
// Only set local rotation when NOT in billboard mode
// Billboard mode handles rotation automatically - setting local rotation causes conflicts
if (!isBillboard) {
this._label.rotation.y = Math.PI / 2;
this._labelBack.rotation.y = Math.PI;
} else {
// Reset rotations for billboard mode
this._label.rotation.y = 0;
this._labelBack.rotation.y = Math.PI; // Back face still needs to be flipped
}
this._labelBack.position.z = 0.001;
} else {
// Standard object labels - convert world space to parent's local space

View File

@ -1,13 +1,16 @@
import React, {useEffect, useRef, useState} from "react";
import {ActionIcon, Alert, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea} from "@mantine/core";
import {IconAlertCircle, IconRobot, IconSend} from "@tabler/icons-react";
import {ActionIcon, Alert, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea, Tooltip, UnstyledButton} from "@mantine/core";
import {IconAlertCircle, IconCoins, IconRobot, IconSend, IconTrash} from "@tabler/icons-react";
import ChatMessage from "./ChatMessage";
import {ChatMessage as ChatMessageType, SessionMessage, ToolResult} from "../types/chatTypes";
import UsageDetailModal from "./UsageDetailModal";
import {ChatMessage as ChatMessageType, SessionMessage, SessionUsage, ToolResult} from "../types/chatTypes";
import {
clearConversationHistory,
createAssistantMessage,
createLoadingMessage,
createOrResumeSession,
createUserMessage,
getSessionUsage,
sendMessage,
syncEntitiesToSession
} from "../services/diagramAI";
@ -48,9 +51,30 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [error, setError] = useState<string | null>(null);
const [usage, setUsage] = useState<SessionUsage | null>(null);
const [usageModalOpen, setUsageModalOpen] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Fetch usage stats
const fetchUsage = async () => {
const usageData = await getSessionUsage();
if (usageData) {
setUsage(usageData);
}
};
// Clear conversation history
const handleClearConversation = async () => {
const cleared = await clearConversationHistory();
if (cleared) {
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
logger.info('Conversation cleared');
} else {
setError('Failed to clear conversation');
}
};
// Initialize or resume session on mount
useEffect(() => {
const initSession = async () => {
@ -62,6 +86,9 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
const {session, isNew} = await createOrResumeSession(diagramId);
logger.info(`Session ${isNew ? 'created' : 'resumed'}:`, session.id);
// Fetch existing usage stats for this session
await fetchUsage();
// Sync current entities to server
const entities = await getEntitiesForSync();
if (entities.length > 0) {
@ -141,6 +168,9 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
const filtered = prev.filter(m => m.id !== loadingMessage.id);
return [...filtered, createAssistantMessage(response, toolResults)];
});
// Fetch updated usage stats
await fetchUsage();
} catch (err) {
logger.error('Chat error:', err);
setMessages(prev => prev.filter(m => m.id !== loadingMessage.id));
@ -157,7 +187,7 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
}
};
return (
return <>
<Paper
shadow="xl"
style={{
@ -182,7 +212,32 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
<IconRobot size={24}/>
<Text fw={600}>Diagram Assistant</Text>
</Group>
{onClose && <CloseButton onClick={onClose}/>}
<Group gap="xs">
{usage && (
<Tooltip
label="Click for details"
position="bottom"
>
<UnstyledButton onClick={() => setUsageModalOpen(true)}>
<Group gap={4} style={{ cursor: 'pointer' }}>
<IconCoins size={16} style={{ opacity: 0.7 }} />
<Text size="xs" c="dimmed">{usage.totalCostFormatted}</Text>
</Group>
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Clear conversation" position="bottom">
<ActionIcon
variant="subtle"
color="gray"
onClick={handleClearConversation}
disabled={isLoading}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
{onClose && <CloseButton onClick={onClose}/>}
</Group>
</Group>
</Box>
@ -242,6 +297,14 @@ export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
</ActionIcon>
</Group>
</Box>
</Paper>
);
{/* Usage Detail Modal */}
<UsageDetailModal
opened={usageModalOpen}
onClose={() => setUsageModalOpen(false)}
usage={usage}
/>
</>;
}

View File

@ -0,0 +1,246 @@
import React from "react";
import {
Accordion,
Badge,
Box,
Code,
Divider,
Group,
Modal,
Paper,
ScrollArea,
Stack,
Text,
Title
} from "@mantine/core";
import {IconClock, IconCoins, IconMessage, IconRobot, IconTool, IconUser} from "@tabler/icons-react";
import {SessionUsage, UsageRequestDetail} from "../types/chatTypes";
interface UsageDetailModalProps {
opened: boolean;
onClose: () => void;
usage: SessionUsage | null;
}
function formatCost(cost: number): string {
return `$${cost.toFixed(6)}`;
}
function formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString();
}
function formatModel(model: string): string {
// Simplify model names for display
if (model.includes('opus')) return 'Opus';
if (model.includes('sonnet')) return 'Sonnet';
if (model.includes('haiku')) return 'Haiku';
return model;
}
function RequestDetailCard({request, index}: { request: UsageRequestDetail; index: number }) {
const totalTokens = request.inputTokens + request.outputTokens;
return (
<Accordion.Item value={`request-${index}`}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" style={{width: '100%'}}>
<Group gap="xs">
<Badge size="sm" variant="light" color="blue">
#{index + 1}
</Badge>
<Text size="sm" c="dimmed">
{formatTimestamp(request.timestamp)}
</Text>
<Badge size="xs" variant="outline">
{formatModel(request.model)}
</Badge>
</Group>
<Group gap="xs">
<Text size="xs" c="dimmed">
{totalTokens.toLocaleString()} tokens
</Text>
<Text size="xs" fw={500} c="green">
{formatCost(request.cost)}
</Text>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
{/* Token breakdown */}
<Paper p="xs" withBorder>
<Group justify="space-around">
<Box ta="center">
<Text size="xs" c="dimmed">Input</Text>
<Text fw={500}>{request.inputTokens.toLocaleString()}</Text>
</Box>
<Box ta="center">
<Text size="xs" c="dimmed">Output</Text>
<Text fw={500}>{request.outputTokens.toLocaleString()}</Text>
</Box>
{request.cacheReadTokens > 0 && (
<Box ta="center">
<Text size="xs" c="dimmed">Cache Read</Text>
<Text fw={500} c="teal">{request.cacheReadTokens.toLocaleString()}</Text>
</Box>
)}
{request.cacheCreationTokens > 0 && (
<Box ta="center">
<Text size="xs" c="dimmed">Cache Write</Text>
<Text fw={500} c="orange">{request.cacheCreationTokens.toLocaleString()}</Text>
</Box>
)}
</Group>
</Paper>
{/* Input text */}
{request.inputText && (
<Box>
<Group gap="xs" mb="xs">
<IconUser size={14}/>
<Text size="sm" fw={500}>Input</Text>
</Group>
<Paper p="xs" withBorder bg="dark.8">
<Text size="sm" style={{whiteSpace: 'pre-wrap'}}>
{request.inputText}
</Text>
</Paper>
</Box>
)}
{/* Tool calls */}
{request.toolCalls && request.toolCalls.length > 0 && (
<Box>
<Group gap="xs" mb="xs">
<IconTool size={14}/>
<Text size="sm" fw={500}>Tool Calls ({request.toolCalls.length})</Text>
</Group>
<Stack gap="xs">
{request.toolCalls.map((tool, i) => (
<Paper key={i} p="xs" withBorder bg="dark.8">
<Text size="sm" fw={500} c="yellow">{tool.name}</Text>
<Code block style={{fontSize: '0.75rem', marginTop: 4}}>
{JSON.stringify(tool.input, null, 2)}
</Code>
</Paper>
))}
</Stack>
</Box>
)}
{/* Output text */}
{request.outputText && (
<Box>
<Group gap="xs" mb="xs">
<IconRobot size={14}/>
<Text size="sm" fw={500}>Output</Text>
</Group>
<Paper p="xs" withBorder bg="dark.8">
<Text size="sm" style={{whiteSpace: 'pre-wrap'}}>
{request.outputText}
</Text>
</Paper>
</Box>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
}
export default function UsageDetailModal({opened, onClose, usage}: UsageDetailModalProps) {
if (!usage) return null;
const sessionDuration = usage.startTime
? Math.round((Date.now() - usage.startTime) / 1000 / 60)
: 0;
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs">
<IconCoins size={20}/>
<Title order={4}>Session Usage Details</Title>
</Group>
}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
>
<Stack gap="md">
{/* Summary stats */}
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Group gap="xs">
<IconClock size={16}/>
<Text size="sm" c="dimmed">Session duration: {sessionDuration} min</Text>
</Group>
<Group gap="xs">
<IconMessage size={16}/>
<Text size="sm" c="dimmed">{usage.requestCount} requests</Text>
</Group>
</Group>
<Group justify="space-around">
<Box ta="center">
<Text size="xs" c="dimmed">Total Input</Text>
<Text size="lg" fw={600}>{usage.totalInputTokens.toLocaleString()}</Text>
<Text size="xs" c="dimmed">tokens</Text>
</Box>
<Box ta="center">
<Text size="xs" c="dimmed">Total Output</Text>
<Text size="lg" fw={600}>{usage.totalOutputTokens.toLocaleString()}</Text>
<Text size="xs" c="dimmed">tokens</Text>
</Box>
<Box ta="center">
<Text size="xs" c="dimmed">Total Cost</Text>
<Text size="lg" fw={600} c="green">{usage.totalCostFormatted}</Text>
</Box>
</Group>
{(usage.totalCacheReadTokens > 0 || usage.totalCacheCreationTokens > 0) && (
<>
<Divider my="sm"/>
<Group justify="center" gap="xl">
{usage.totalCacheReadTokens > 0 && (
<Box ta="center">
<Text size="xs" c="dimmed">Cache Reads</Text>
<Text fw={500} c="teal">{usage.totalCacheReadTokens.toLocaleString()}</Text>
</Box>
)}
{usage.totalCacheCreationTokens > 0 && (
<Box ta="center">
<Text size="xs" c="dimmed">Cache Writes</Text>
<Text fw={500} c="orange">{usage.totalCacheCreationTokens.toLocaleString()}</Text>
</Box>
)}
</Group>
</>
)}
</Paper>
{/* Request history */}
{usage.requests && usage.requests.length > 0 && (
<Box>
<Text fw={500} mb="sm">Request History</Text>
<Accordion variant="separated">
{usage.requests.map((request, index) => (
<RequestDetailCard
key={index}
request={request}
index={index}
/>
))}
</Accordion>
</Box>
)}
{(!usage.requests || usage.requests.length === 0) && (
<Text c="dimmed" ta="center">No request history available</Text>
)}
</Stack>
</Modal>
);
}

View File

@ -1,5 +1,5 @@
import VrApp from '../../vrApp';
import React, {useEffect, useState} from "react";
import React, {useEffect, useRef, useState} from "react";
import {Affix, Burger, Group, Menu, Alert, Button, Text} from "@mantine/core";
import VrTemplate from "../vrTemplate";
import {IconStar, IconInfoCircle, IconMessageCircle} from "@tabler/icons-react";
@ -13,7 +13,7 @@ import log from "loglevel";
import {useFeatureState, useUserTier} from "../hooks/useFeatures";
import {useAuth0} from "@auth0/auth0-react";
import {GUEST_MODE_BANNER} from "../../content/upgradeCopy";
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
import {exportDiagramAsJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
import {isMobileVRDevice} from "../../util/deviceDetection";
import {DefaultScene} from "../../defaultScene";
import VREntryPrompt from "../components/VREntryPrompt";
@ -124,6 +124,89 @@ export default function VrExperience() {
const [dbName, setDbName] = useState(params.db);
const [showVRPrompt, setShowVRPrompt] = useState(false);
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
const fileInputRef = useRef<HTMLInputElement>(null);
// Import entities from JSON file
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data: DiagramExport = JSON.parse(text);
if (!data.entities || !Array.isArray(data.entities)) {
throw new Error('Invalid file format: missing entities array');
}
logger.info(`Importing ${data.entities.length} entities from ${file.name}`);
let importedCount = 0;
let connectionCount = 0;
// First pass: import non-connection entities
for (const entity of data.entities) {
if (entity.template === '#connection-template') {
continue; // Handle connections in second pass
}
// Generate new ID to avoid conflicts and ensure required fields
const newEntity = {
...entity,
id: entity._id || entity.id || v4(),
type: entity.type || 'entity',
};
// Remove PouchDB fields
delete newEntity._id;
delete newEntity._rev;
const createEvent = new CustomEvent('chatCreateEntity', {
detail: {entity: newEntity},
bubbles: true
});
document.dispatchEvent(createEvent);
importedCount++;
}
// Second pass: import connections
for (const entity of data.entities) {
if (entity.template !== '#connection-template') {
continue;
}
const newEntity = {
...entity,
id: entity._id || entity.id || v4(),
type: entity.type || 'entity',
};
delete newEntity._id;
delete newEntity._rev;
const connEvent = new CustomEvent('chatCreateEntity', {
detail: {entity: newEntity},
bubbles: true
});
document.dispatchEvent(connEvent);
connectionCount++;
}
logger.info(`Import complete: ${importedCount} entities, ${connectionCount} connections`);
alert(`Imported ${importedCount} entities and ${connectionCount} connections from "${data.name || file.name}".`);
} catch (err) {
logger.error('Failed to import file:', err);
alert(`Failed to import file: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
// Reset file input so same file can be selected again
event.target.value = '';
};
// Handle share based on current database type:
// - Public: copy current URL to clipboard
@ -413,12 +496,17 @@ export default function VrExperience() {
availableIcon={getFeatureIndicator(manageDiagramsState)}/>
)}
{/* Export JSON - Always available for creating templates */}
{/* Export/Import JSON - Always available for creating templates */}
<VrMenuItem
tip="Export current diagram as JSON file (useful for creating templates)"
label="Export JSON"
onClick={handleExportJSON}
availableIcon={null}/>
<VrMenuItem
tip="Import entities from a previously exported JSON file"
label="Import JSON"
onClick={handleImportClick}
availableIcon={null}/>
{(shouldShow(shareCollaborateState) || shouldShow(configState)) && <Menu.Divider/>}
@ -446,6 +534,14 @@ export default function VrExperience() {
availableIcon={<IconMessageCircle size={16}/>}/>
</Menu.Dropdown>
</Menu>
{/* Hidden file input for JSON import */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileImport}
accept=".json"
style={{ display: 'none' }}
/>
</Affix>
<div style={{display: 'flex', height: '100vh', width: '100vw', overflow: 'hidden'}}>

View File

@ -1,4 +1,4 @@
import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SyncEntitiesResponse, ToolResult} from "../types/chatTypes";
import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SessionUsage, SyncEntitiesResponse, ToolResult} from "../types/chatTypes";
import {clearDiagram, connectEntities, createEntity, getCameraPosition, listEntities, modifyConnection, modifyEntity, removeEntity} from "./entityBridge";
import {v4 as uuidv4} from 'uuid';
import log from 'loglevel';
@ -10,7 +10,7 @@ logger.setLevel('debug');
let currentSessionId: string | null = null;
// Model management
export type AIProvider = 'claude' | 'ollama';
export type AIProvider = 'claude' | 'ollama' | 'cloudflare';
export interface ModelInfo {
id: string;
@ -39,6 +39,44 @@ const AVAILABLE_MODELS: ModelInfo[] = [
description: 'Fastest responses, good for simple tasks',
provider: 'claude'
},
// Cloudflare Workers AI models - with tool support
{
id: '@cf/mistralai/mistral-small-3.1-24b-instruct',
name: 'Mistral Small 3.1 (CF)',
description: 'Best CF model - supports diagram tools',
provider: 'cloudflare'
},
{
id: '@hf/nousresearch/hermes-2-pro-mistral-7b',
name: 'Hermes 2 Pro (CF)',
description: 'Lightweight - supports diagram tools',
provider: 'cloudflare'
},
// Cloudflare models WITHOUT tool support - chat only
{
id: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
name: 'Llama 3.3 70B (CF)',
description: 'Powerful but NO tool support',
provider: 'cloudflare'
},
{
id: '@cf/meta/llama-3.1-8b-instruct',
name: 'Llama 3.1 8B (CF)',
description: 'Fast/cheap but NO tool support',
provider: 'cloudflare'
},
{
id: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b',
name: 'DeepSeek R1 (CF)',
description: 'Reasoning but NO tool support',
provider: 'cloudflare'
},
{
id: '@cf/qwen/qwen2.5-coder-32b-instruct',
name: 'Qwen 2.5 Coder (CF)',
description: 'Code-focused but NO tool support',
provider: 'cloudflare'
},
// Ollama models (local)
{
id: 'llama3.1',
@ -60,7 +98,7 @@ const AVAILABLE_MODELS: ModelInfo[] = [
}
];
let currentModelId: string = 'claude-sonnet-4-20250514';
let currentModelId: string = '@cf/mistralai/mistral-small-3.1-24b-instruct';
/**
* Get the API endpoint for the current model's provider
@ -70,6 +108,9 @@ function getApiEndpoint(): string {
if (model.provider === 'ollama') {
return '/api/ollama/v1/messages';
}
if (model.provider === 'cloudflare') {
return '/api/cloudflare/v1/messages';
}
return '/api/claude/v1/messages';
}
@ -177,31 +218,59 @@ export async function clearSessionHistory(): Promise<void> {
});
}
const SYSTEM_PROMPT = `You are a 3D diagram assistant helping users create and modify diagrams in a virtual reality environment.
/**
* Get token usage for current session
*/
export async function getSessionUsage(): Promise<SessionUsage | null> {
if (!currentSessionId) {
return null;
}
Available entity shapes: box, sphere, cylinder, cone, plane, person
Available colors: red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or any hex color like #ff5500)
try {
const response = await fetch(`/api/session/${currentSessionId}/usage`);
if (!response.ok) {
return null;
}
return await response.json();
} catch (err) {
logger.error('Failed to fetch session usage:', err);
return null;
}
}
IMPORTANT - Handling directional commands (left, right, forward, back, in front of me, etc.):
When the user uses relative directions, you MUST first call get_camera_position to get their current view.
The response provides:
- Their world position
- Ground-projected forward/right vectors (accounts for camera being on a moving platform)
- Pre-calculated positions for forward/back/left/right at 2m distance
const SYSTEM_PROMPT = `You are a 3D diagram assistant. You MUST use tools to perform actions - never just describe what you would do.
Use the ground-plane directions for intuitive placement - these ignore vertical look angle so "forward" means "forward on the ground" not "where I'm looking vertically".
## CRITICAL RULES
1. When the user asks to create, add, or make something CALL create_entity tool
2. When the user asks to connect things CALL connect_entities tool
3. When the user asks to change, modify, move, resize, or rotate CALL modify_entity tool
4. When the user asks to remove or delete CALL remove_entity tool
5. When the user asks what exists or to list CALL list_entities tool
6. When the user uses directions (left, right, forward, in front of me) CALL get_camera_position FIRST
7. When diagramming unfamiliar topics CALL search_wikipedia FIRST to research the concept
Position coordinates (when not using relative directions):
- x: world left/right
- y: up/down (1.5 is eye level, 0 is floor)
- z: world forward/backward
DO NOT just describe actions. DO NOT say "I will create..." without calling a tool. ALWAYS call the appropriate tool.
When creating diagrams, think about good spatial layout:
- Spread entities apart to avoid overlap (at least 0.5 units)
- Use y=1.5 for entities at eye level
- For relative placement, use get_camera_position first
## Research First
When asked to diagram a technical concept, architecture, or system you're not fully familiar with, use search_wikipedia to research it first. This ensures accurate and comprehensive diagrams.
Always use the provided tools to create, modify, or interact with entities. Be concise in your responses.`;
## Available Shapes
box, sphere, cylinder, cone, plane, person
## Available Colors
red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or hex like #ff5500)
## Position Coordinates
- x: left/right
- y: up/down (1.5 = eye level, 0 = floor)
- z: forward/backward
## Layout Guidelines
- Spread entities apart (at least 0.5 units)
- Use y=1.5 for eye-level entities
- For relative directions, call get_camera_position first
Be concise. Call tools immediately when the user requests an action.`;
const TOOLS = [
{
@ -282,7 +351,7 @@ const TOOLS = [
},
{
name: "modify_entity",
description: "Modify an existing entity's properties like color, label, or position.",
description: "Modify an existing entity's properties like color, label, position, scale, or rotation.",
input_schema: {
type: "object",
properties: {
@ -305,6 +374,42 @@ const TOOLS = [
y: {type: "number"},
z: {type: "number"}
}
},
scale: {
oneOf: [
{
type: "number",
description: "Uniform scale factor (e.g., 0.2 = double size, 0.05 = half size). Default is 0.1."
},
{
type: "object",
properties: {
x: {type: "number"},
y: {type: "number"},
z: {type: "number"}
},
description: "Non-uniform scale as {x, y, z}. Default is {x: 0.1, y: 0.1, z: 0.1}."
}
],
description: "Scale/size of the entity. Use a number for uniform scaling or {x, y, z} for non-uniform."
},
rotation: {
oneOf: [
{
type: "number",
description: "Y-axis rotation in degrees (e.g., 90 = turn right 90°, -90 = turn left 90°)"
},
{
type: "object",
properties: {
x: {type: "number", description: "Pitch in degrees"},
y: {type: "number", description: "Yaw in degrees"},
z: {type: "number", description: "Roll in degrees"}
},
description: "Full 3D rotation in degrees as {x, y, z}"
}
],
description: "Rotation in degrees. Use a number for Y-axis rotation or {x, y, z} for full 3D rotation."
}
},
required: ["target"]
@ -390,6 +495,28 @@ const TOOLS = [
},
required: ["model_id"]
}
},
{
name: "clear_conversation",
description: "Clear the conversation history to start fresh. Use this when the conversation has become too long, confusing, or when the user wants to start over. This preserves the diagram entities but clears chat history.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "search_wikipedia",
description: "Search Wikipedia for information about a topic. Use this to research concepts, architectures, technologies, or anything else that would help create more accurate and detailed diagrams. Returns summaries from multiple related articles.",
input_schema: {
type: "object",
properties: {
topic: {
type: "string",
description: "The topic or concept to search for (e.g., 'microservices architecture', 'neural network', 'kubernetes')"
}
},
required: ["topic"]
}
}
];
@ -489,6 +616,26 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
}
break;
}
case 'clear_conversation': {
const cleared = await clearConversationHistory();
result = {
toolName: 'clear_conversation',
success: cleared,
message: cleared
? 'Conversation history cleared. Starting fresh while keeping the diagram intact.'
: 'Failed to clear conversation history (no active session).'
};
break;
}
case 'search_wikipedia': {
const searchResult = await searchWikipedia(toolCall.input.topic);
result = {
toolName: 'search_wikipedia',
success: searchResult.success,
message: searchResult.message
};
break;
}
default:
result = {
toolName: 'unknown',
@ -501,6 +648,100 @@ async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
return result;
}
/**
* Clear conversation history on the server
*/
export async function clearConversationHistory(): Promise<boolean> {
if (!currentSessionId) {
logger.warn('[clearConversationHistory] No active session');
return false;
}
try {
const response = await fetch(`/api/session/${currentSessionId}/history`, {
method: 'DELETE'
});
if (response.ok) {
logger.info('[clearConversationHistory] History cleared for session:', currentSessionId);
return true;
} else {
logger.error('[clearConversationHistory] Failed:', response.status);
return false;
}
} catch (error) {
logger.error('[clearConversationHistory] Error:', error);
return false;
}
}
/**
* Search Wikipedia for a topic using multiple query variations
* Returns combined results for context
*/
async function searchWikipedia(topic: string): Promise<{ success: boolean; message: string }> {
logger.info('[searchWikipedia] Searching for:', topic);
// Generate 3 search variations
const searchQueries = [
topic,
`${topic} overview`,
`${topic} definition concept`
];
const results: string[] = [];
for (const query of searchQueries) {
try {
// Search Wikipedia API
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srlimit=2&format=json&origin=*`;
const searchResponse = await fetch(searchUrl);
const searchData = await searchResponse.json();
if (searchData.query?.search?.length > 0) {
// Get summaries for top results
for (const result of searchData.query.search.slice(0, 2)) {
const title = result.title;
// Skip if we already have this article
if (results.some(r => r.includes(`**${title}**`))) {
continue;
}
// Get article summary
const summaryUrl = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
const summaryResponse = await fetch(summaryUrl);
if (summaryResponse.ok) {
const summaryData = await summaryResponse.json();
const extract = summaryData.extract || 'No summary available.';
// Truncate long summaries
const truncated = extract.length > 500 ? extract.substring(0, 500) + '...' : extract;
results.push(`**${title}**\n${truncated}`);
}
}
}
} catch (error) {
logger.warn('[searchWikipedia] Error searching for:', query, error);
}
}
if (results.length === 0) {
return {
success: false,
message: `No Wikipedia articles found for "${topic}". Try a different search term.`
};
}
// Combine results
const combinedResults = results.slice(0, 5).join('\n\n---\n\n');
return {
success: true,
message: `## Wikipedia Research: ${topic}\n\nFound ${results.length} relevant articles:\n\n${combinedResults}\n\n---\nUse this information to create accurate diagrams. You can now create entities based on this research.`
};
}
export async function sendMessage(
userMessage: string,
conversationHistory: ChatMessage[],
@ -530,14 +771,21 @@ export async function sendMessage(
let finalResponse = '';
let continueLoop = true;
let loopCount = 0;
const MAX_TOOL_ITERATIONS = 10; // Safety limit to prevent infinite loops
while (continueLoop) {
loopCount++;
logger.debug(`[sendMessage] Loop iteration ${loopCount}`);
// Safety check: prevent infinite tool calling loops
if (loopCount > MAX_TOOL_ITERATIONS) {
logger.warn(`[sendMessage] Max tool iterations (${MAX_TOOL_ITERATIONS}) reached, breaking loop`);
break;
}
const requestBody = {
model: currentModelId,
max_tokens: 1024,
max_tokens: 4096,
system: SYSTEM_PROMPT,
tools: TOOLS,
messages,
@ -587,6 +835,7 @@ export async function sendMessage(
});
const toolResults: ClaudeContentBlock[] = [];
let modelSwitched = false;
for (const toolBlock of toolBlocks) {
logger.debug('[sendMessage] Tool call:', toolBlock.name, JSON.stringify(toolBlock.input));
@ -595,6 +844,11 @@ export async function sendMessage(
input: toolBlock.input as DiagramToolCall['input']
};
// Track if we're switching models
if (toolBlock.name === 'set_model') {
modelSwitched = true;
}
const result = await executeToolCall(toolCall);
logger.debug('[sendMessage] Tool result:', result);
allToolResults.push(result);
@ -611,7 +865,20 @@ export async function sendMessage(
role: 'user',
content: toolResults
});
logger.debug('[sendMessage] Added tool results, continuing loop...');
// If model was switched, break the loop - the new model won't have proper context
// and continuing could cause infinite loops with Cloudflare models
if (modelSwitched) {
logger.debug('[sendMessage] Model switched, ending loop to prevent context issues');
// Set a response so user knows what happened
if (!finalResponse) {
const model = getCurrentModel();
finalResponse = `Switched to ${model.name}. The new model is now active for your next message.`;
}
continueLoop = false;
} else {
logger.debug('[sendMessage] Added tool results, continuing loop...');
}
} else {
logger.debug('[sendMessage] No more tool calls, ending loop');
continueLoop = false;

View File

@ -171,6 +171,11 @@ export function removeEntity(params: RemoveEntityParams): ToolResult {
};
}
// Convert degrees to radians
function degreesToRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
export function modifyEntity(params: ModifyEntityParams): ToolResult {
const updates: Partial<DiagramEntity> = {};
@ -183,6 +188,28 @@ export function modifyEntity(params: ModifyEntityParams): ToolResult {
if (params.position) {
updates.position = params.position;
}
if (params.scale !== undefined) {
// Accept either uniform scale (number) or 3D scale object
if (typeof params.scale === 'number') {
updates.scale = {x: params.scale, y: params.scale, z: params.scale};
} else {
updates.scale = params.scale;
}
}
if (params.rotation !== undefined) {
// Accept degrees from AI, convert to radians for internal use
// Can be uniform (single number for Y rotation) or full 3D rotation
if (typeof params.rotation === 'number') {
// Single number = Y-axis rotation (most common for "turn 90 degrees")
updates.rotation = {x: 0, y: degreesToRadians(params.rotation), z: 0};
} else {
updates.rotation = {
x: degreesToRadians(params.rotation.x),
y: degreesToRadians(params.rotation.y),
z: degreesToRadians(params.rotation.z)
};
}
}
const event = new CustomEvent('chatModifyEntity', {
detail: {target: params.target, updates},
@ -190,10 +217,29 @@ export function modifyEntity(params: ModifyEntityParams): ToolResult {
});
document.dispatchEvent(event);
const changes: string[] = [];
if (params.color) changes.push(`color to ${params.color}`);
if (params.label !== undefined) changes.push(`label to "${params.label}"`);
if (params.position) changes.push(`position`);
if (params.scale !== undefined) {
if (typeof params.scale === 'number') {
changes.push(`scale to ${params.scale}`);
} else {
changes.push(`scale to (${params.scale.x}, ${params.scale.y}, ${params.scale.z})`);
}
}
if (params.rotation !== undefined) {
if (typeof params.rotation === 'number') {
changes.push(`rotation to ${params.rotation}°`);
} else {
changes.push(`rotation to (${params.rotation.x}°, ${params.rotation.y}°, ${params.rotation.z}°)`);
}
}
return {
toolName: 'modify_entity',
success: true,
message: `Modified entity "${params.target}"`
message: `Modified entity "${params.target}"${changes.length > 0 ? ': ' + changes.join(', ') : ''}`
};
}

View File

@ -40,6 +40,8 @@ export interface ModifyEntityParams {
color?: string;
label?: string;
position?: { x: number; y: number; z: number };
scale?: { x: number; y: number; z: number } | number;
rotation?: { x: number; y: number; z: number } | number; // in degrees
}
export interface ClearDiagramParams {
@ -58,6 +60,10 @@ export interface SetModelParams {
model_id: string;
}
export interface WikipediaSearchParams {
topic: string;
}
export type DiagramToolCall =
| { name: 'create_entity'; input: CreateEntityParams }
| { name: 'connect_entities'; input: ConnectEntitiesParams }
@ -69,7 +75,9 @@ export type DiagramToolCall =
| { name: 'get_camera_position'; input: Record<string, never> }
| { name: 'list_models'; input: Record<string, never> }
| { name: 'get_current_model'; input: Record<string, never> }
| { name: 'set_model'; input: SetModelParams };
| { name: 'set_model'; input: SetModelParams }
| { name: 'clear_conversation'; input: Record<string, never> }
| { name: 'search_wikipedia'; input: WikipediaSearchParams };
export const SHAPE_TO_TEMPLATE: Record<CreateEntityParams['shape'], DiagramTemplates> = {
box: DiagramTemplates.BOX,
@ -120,6 +128,7 @@ export interface SessionEntity {
text?: string;
color?: string;
position?: { x: number; y: number; z: number };
scale?: { x: number; y: number; z: number };
}
export interface SessionMessage {
@ -147,3 +156,28 @@ export interface SyncEntitiesResponse {
success: boolean;
entityCount: number;
}
export interface UsageRequestDetail {
timestamp: number;
model: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
cost: number;
inputText: string | null;
outputText: string | null;
toolCalls: Array<{ name: string; input: Record<string, unknown> }>;
}
export interface SessionUsage {
totalInputTokens: number;
totalOutputTokens: number;
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
totalCost: number;
totalCostFormatted: string;
requestCount: number;
startTime: number;
requests?: UsageRequestDetail[];
}