fix 'c', mycofi room,remove gesture tool

This commit is contained in:
Jeff Emmett 2025-10-02 17:28:50 -04:00
parent 8fa8c388d9
commit 1b0fc57779
14 changed files with 445057 additions and 378 deletions

63
fix_draw_shapes.js Normal file
View File

@ -0,0 +1,63 @@
import fs from 'fs';
const inputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room.json';
const outputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room_fixed.json';
try {
// Read the JSON file
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
let fixedCount = 0;
let removedCount = 0;
// Process all documents
data.documents = data.documents.filter(doc => {
if (doc.state && doc.state.typeName === 'shape' && doc.state.type === 'draw') {
const segments = doc.state.props?.segments;
if (segments) {
// Check each segment for single-point issues
const validSegments = segments.filter(segment => {
if (segment.points && segment.points.length === 1) {
// For single-point segments, we have two options:
// 1. Remove the segment entirely
// 2. Add a second point to make it valid
// Let's remove single-point segments as they're likely incomplete
removedCount++;
return false;
}
return true;
});
if (validSegments.length === 0) {
// If no valid segments remain, remove the entire shape
removedCount++;
return false;
} else {
// Update the segments
doc.state.props.segments = validSegments;
fixedCount++;
}
}
}
return true;
});
// Write the fixed data
fs.writeFileSync(outputFile, JSON.stringify(data, null, 2));
console.log(`Successfully fixed draw shapes:`);
console.log(`- Fixed shapes: ${fixedCount}`);
console.log(`- Removed invalid shapes: ${removedCount}`);
console.log(`- Output saved to: ${outputFile}`);
} catch (error) {
console.error('Error fixing draw shapes:', error.message);
process.exit(1);
}

96
fix_extreme_positions.js Normal file
View File

@ -0,0 +1,96 @@
import fs from 'fs';
const inputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room.json';
const outputFile = '/home/jeffe/Github/canvas-website/src/shapes/mycofi_room_fixed.json';
try {
// Read the JSON file
const data = JSON.parse(fs.readFileSync(inputFile, 'utf8'));
let fixedCount = 0;
let removedCount = 0;
// Process all documents
data.documents = data.documents.filter(doc => {
if (doc.state && doc.state.typeName === 'shape') {
const state = doc.state;
const x = state.x || 0;
const y = state.y || 0;
// Check for extremely large coordinates that could cause hit testing issues
if (Math.abs(x) > 100000 || Math.abs(y) > 100000 ||
!isFinite(x) || !isFinite(y)) {
console.log(`Fixing shape ${state.id} with extreme position: (${x}, ${y})`);
// Reset to a reasonable position (center of canvas)
state.x = 0;
state.y = 0;
fixedCount++;
}
// Check for extremely large dimensions
if (state.props) {
const w = state.props.w || 0;
const h = state.props.h || 0;
if (w > 100000 || h > 100000 || !isFinite(w) || !isFinite(h)) {
console.log(`Fixing shape ${state.id} with extreme dimensions: ${w}x${h}`);
// Reset to reasonable default dimensions
state.props.w = Math.min(w, 200);
state.props.h = Math.min(h, 200);
fixedCount++;
}
}
// Check for invalid rotation values
if (state.rotation !== undefined && !isFinite(state.rotation)) {
console.log(`Fixing shape ${state.id} with invalid rotation: ${state.rotation}`);
state.rotation = 0;
fixedCount++;
}
// Check for draw shapes with problematic segments
if (state.type === 'draw' && state.props?.segments) {
const validSegments = state.props.segments.filter(segment => {
if (segment.points && segment.points.length === 1) {
// Remove single-point segments as they can cause hit testing issues
return false;
}
return true;
});
if (validSegments.length === 0) {
// If no valid segments remain, remove the entire shape
console.log(`Removing shape ${state.id} with no valid segments`);
removedCount++;
return false;
} else if (validSegments.length !== state.props.segments.length) {
// Update the segments
state.props.segments = validSegments;
fixedCount++;
}
}
}
return true;
});
// Write the fixed data
fs.writeFileSync(outputFile, JSON.stringify(data, null, 2));
console.log(`\\nSuccessfully fixed board data:`);
console.log(`- Fixed shapes: ${fixedCount}`);
console.log(`- Removed invalid shapes: ${removedCount}`);
console.log(`- Output saved to: ${outputFile}`);
} catch (error) {
console.error('Error fixing board data:', error.message);
process.exit(1);
}

View File

@ -10,7 +10,6 @@ import {
id: 'toggle-graph-layout', id: 'toggle-graph-layout',
label: 'Toggle Graph Layout' as TLUiTranslationKey, label: 'Toggle Graph Layout' as TLUiTranslationKey,
readonlyOk: true, readonlyOk: true,
kbd: 'g',
onSelect(_source: TLUiEventSource) { onSelect(_source: TLUiEventSource) {
const event = new CustomEvent('toggleGraphLayoutEvent'); const event = new CustomEvent('toggleGraphLayoutEvent');
window.dispatchEvent(event); window.dispatchEvent(event);

View File

@ -78,10 +78,20 @@ const unpackShape = (shape: any) => {
return prop !== undefined ? constructor(prop) : undefined; return prop !== undefined ? constructor(prop) : undefined;
}; };
// Only add text property for shapes that support it (like text shapes) // Handle text shapes properly - convert text property to richText if needed
const shapeProps = { ...props } const shapeProps = { ...props }
if (type === 'text' && props.text !== undefined) { if (type === 'text') {
shapeProps.text = cast(props.text, String) // Remove any text property as it's not valid for TLDraw text shapes
if ('text' in shapeProps) {
delete shapeProps.text
}
// Ensure richText exists for text shapes
if (!shapeProps.richText) {
shapeProps.richText = {
content: [],
type: 'doc'
}
}
} }
return { return {

View File

@ -40,7 +40,6 @@ import {
} from "@/ui/cameraUtils" } from "@/ui/cameraUtils"
import { Collection, initializeGlobalCollections } from "@/collections" import { Collection, initializeGlobalCollections } from "@/collections"
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection" import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK" import { CmdK } from "@/CmdK"
@ -76,7 +75,6 @@ const customTools = [
MarkdownTool, MarkdownTool,
PromptShapeTool, PromptShapeTool,
SharedPianoTool, SharedPianoTool,
GestureTool,
] ]
export function Board() { export function Board() {

222265
src/shapes/mycofi_room.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -9,81 +9,16 @@ import {
TLDrawShape, TLDrawShape,
TLShapePartial, TLShapePartial,
} from "tldraw" } from "tldraw"
import { DollarRecognizer } from "@/gestures"
import { DEFAULT_GESTURES } from "@/default_gestures"
export const overrides: TLUiOverrides = { export const overrides: TLUiOverrides = {
tools(editor, tools) { tools(editor, tools) {
return { return {
...tools, ...tools,
gesture: {
id: "gesture",
name: "Gesture",
icon: "👆",
kbd: "g",
label: "Gesture",
onSelect: () => {
editor.setCurrentTool("gesture")
},
},
} }
}, },
actions(editor, actions): TLUiActionsContextType { actions(editor, actions): TLUiActionsContextType {
const R = new DollarRecognizer(DEFAULT_GESTURES)
return { return {
...actions, ...actions,
recognize: {
id: "recognize",
kbd: "c",
onSelect: () => {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape || onlySelectedShape.type !== "draw") return
console.log("recognizing")
const verts = editor.getShapeGeometry(onlySelectedShape).vertices
const result = R.recognize(verts)
console.log(result)
},
},
addGesture: {
id: "addGesture",
kbd: "x",
onSelect: () => {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape || onlySelectedShape.type !== "draw") return
const name = onlySelectedShape.meta.name
if (!name) return
console.log("adding gesture:", name)
const points = editor.getShapeGeometry(onlySelectedShape).vertices
R.addGesture(name as string, points)
},
},
recognizeAndSnap: {
id: "recognizeAndSnap",
kbd: "z",
onSelect: () => {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape || onlySelectedShape.type !== "draw") return
const points = editor.getShapeGeometry(onlySelectedShape).vertices
const result = R.recognize(points)
console.log("morphing to closest:", result.name)
const newShape: TLShapePartial<TLDrawShape> = {
...onlySelectedShape,
type: "draw",
props: {
...onlySelectedShape.props,
segments: [
{
points: R.unistrokes
.find((u) => u.name === result.name)
?.originalPoints() || [],
type: "free",
},
],
},
}
editor.animateShape(newShape)
},
},
} }
}, },
} }

View File

@ -102,7 +102,14 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
// Keyboard shortcut for adding to collection // Keyboard shortcut for adding to collection
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'c' && !event.ctrlKey && !event.altKey && !event.metaKey) { // Only trigger if not typing in a text input, textarea, or contenteditable element
const target = event.target as HTMLElement
const isTextInput = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.closest('[contenteditable="true"]')
if (event.key === 'c' && !event.ctrlKey && !event.altKey && !event.metaKey && !isTextInput) {
event.preventDefault() event.preventDefault()
if (hasSelection && collection && !allSelectedShapesInCollection) { if (hasSelection && collection && !allSelectedShapesInCollection) {
handleAddToCollection() handleAddToCollection()
@ -173,7 +180,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
id="add-to-collection" id="add-to-collection"
label="Add to Collection" label="Add to Collection"
icon="plus" icon="plus"
kbd="c" kbd="alt+a"
disabled={!hasSelection || !collection} disabled={!hasSelection || !collection}
onSelect={handleAddToCollection} onSelect={handleAddToCollection}
/> />
@ -232,7 +239,6 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
id="search-text" id="search-text"
label="Search Text" label="Search Text"
icon="search" icon="search"
kbd="s"
onSelect={() => searchText(editor)} onSelect={() => searchText(editor)}
/> />
</TldrawUiMenuGroup> </TldrawUiMenuGroup>

View File

@ -1,4 +1,4 @@
import { Editor, useDefaultHelpers } from "tldraw" import { Editor, useDefaultHelpers, createShapeId } from "tldraw"
import { import {
shapeIdValidator, shapeIdValidator,
TLArrowShape, TLArrowShape,
@ -20,8 +20,9 @@ import { moveToSlide } from "@/slides/useSlides"
import { ISlideShape } from "@/shapes/SlideShapeUtil" import { ISlideShape } from "@/shapes/SlideShapeUtil"
import { getEdge } from "@/propagators/tlgraph" import { getEdge } from "@/propagators/tlgraph"
import { llm, getApiKey } from "@/utils/llmUtils" import { llm, getApiKey } from "@/utils/llmUtils"
import type FileSystem from "@oddjs/odd/fs/index"
export const overrides: TLUiOverrides = { export const createOverrides = (fileSystem?: FileSystem | null): TLUiOverrides => ({
tools(editor, tools) { tools(editor, tools) {
return { return {
...tools, ...tools,
@ -160,15 +161,6 @@ export const overrides: TLUiOverrides = {
readonlyOk: true, readonlyOk: true,
onSelect: () => editor.setCurrentTool("SharedPiano"), onSelect: () => editor.setCurrentTool("SharedPiano"),
}, },
gesture: {
id: "gesture",
icon: "draw",
label: "Gesture",
kbd: "g",
readonlyOk: true,
type: "gesture",
onSelect: () => editor.setCurrentTool("gesture"),
},
hand: { hand: {
...tools.hand, ...tools.hand,
onDoubleClick: (info: any) => { onDoubleClick: (info: any) => {
@ -190,7 +182,7 @@ export const overrides: TLUiOverrides = {
zoomToSelection: { zoomToSelection: {
id: "zoom-to-selection", id: "zoom-to-selection",
label: "Zoom to Selection", label: "Zoom to Selection",
kbd: "z", kbd: "alt+z",
onSelect: () => { onSelect: () => {
if (editor.getSelectedShapeIds().length > 0) { if (editor.getSelectedShapeIds().length > 0) {
zoomToSelection(editor) zoomToSelection(editor)
@ -251,7 +243,6 @@ export const overrides: TLUiOverrides = {
moveSelectedLeft: { moveSelectedLeft: {
id: "move-selected-left", id: "move-selected-left",
label: "Move Left", label: "Move Left",
kbd: "ArrowLeft",
onSelect: () => { onSelect: () => {
const selectedShapes = editor.getSelectedShapes() const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) { if (selectedShapes.length > 0) {
@ -269,7 +260,6 @@ export const overrides: TLUiOverrides = {
moveSelectedRight: { moveSelectedRight: {
id: "move-selected-right", id: "move-selected-right",
label: "Move Right", label: "Move Right",
kbd: "ArrowRight",
onSelect: () => { onSelect: () => {
const selectedShapes = editor.getSelectedShapes() const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) { if (selectedShapes.length > 0) {
@ -287,7 +277,6 @@ export const overrides: TLUiOverrides = {
moveSelectedUp: { moveSelectedUp: {
id: "move-selected-up", id: "move-selected-up",
label: "Move Up", label: "Move Up",
kbd: "ArrowUp",
onSelect: () => { onSelect: () => {
const selectedShapes = editor.getSelectedShapes() const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) { if (selectedShapes.length > 0) {
@ -305,7 +294,6 @@ export const overrides: TLUiOverrides = {
moveSelectedDown: { moveSelectedDown: {
id: "move-selected-down", id: "move-selected-down",
label: "Move Down", label: "Move Down",
kbd: "ArrowDown",
onSelect: () => { onSelect: () => {
const selectedShapes = editor.getSelectedShapes() const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) { if (selectedShapes.length > 0) {
@ -323,7 +311,7 @@ export const overrides: TLUiOverrides = {
searchShapes: { searchShapes: {
id: "search-shapes", id: "search-shapes",
label: "Search Shapes", label: "Search Shapes",
kbd: "s", kbd: "alt+s",
readonlyOk: true, readonlyOk: true,
onSelect: () => searchText(editor), onSelect: () => searchText(editor),
}, },
@ -333,58 +321,104 @@ export const overrides: TLUiOverrides = {
kbd: "alt+g", kbd: "alt+g",
readonlyOk: true, readonlyOk: true,
onSelect: () => { onSelect: () => {
console.log("🎯 LLM action triggered")
const selectedShapes = editor.getSelectedShapes() const selectedShapes = editor.getSelectedShapes()
console.log("Selected shapes:", selectedShapes.length, selectedShapes.map(s => s.type))
if (selectedShapes.length > 0) { if (selectedShapes.length > 0) {
const selectedShape = selectedShapes[0] as TLArrowShape const selectedShape = selectedShapes[0] as TLArrowShape
console.log("First selected shape type:", selectedShape.type)
if (selectedShape.type !== "arrow") { if (selectedShape.type !== "arrow") {
console.log("❌ Selected shape is not an arrow, returning")
return return
} }
const edge = getEdge(selectedShape, editor) const edge = getEdge(selectedShape, editor)
console.log("Edge found:", edge)
if (!edge) { if (!edge) {
console.log("❌ No edge found, returning")
return return
} }
const sourceShape = editor.getShape(edge.from) const sourceShape = editor.getShape(edge.from)
const targetShape = editor.getShape(edge.to)
console.log("Arrow direction: FROM", sourceShape?.type, "TO", targetShape?.type)
const sourceText = const sourceText =
sourceShape && sourceShape.type === "geo" sourceShape && sourceShape.type === "geo"
? (sourceShape.meta as any)?.text || "" ? (sourceShape.meta as any)?.text || ""
: "" : ""
console.log("Source shape:", sourceShape?.type, "Source text:", sourceText)
console.log("Target shape:", targetShape?.type, "Will generate content here")
const prompt = `Instruction: ${edge.text} const prompt = `Instruction: ${edge.text}
${sourceText ? `Context: ${sourceText}` : ""}`; ${sourceText ? `Context: ${sourceText}` : ""}`;
console.log("Generated prompt:", prompt)
try { try {
console.log("🚀 Calling LLM with prompt...")
llm(prompt, (partialResponse: string) => { llm(prompt, (partialResponse: string) => {
console.log("📝 LLM callback received:", partialResponse.substring(0, 100) + "...")
const targetShape = editor.getShape(edge.to)
console.log("Target shape for content generation:", targetShape?.type, "ID:", edge.to)
if (!targetShape) {
console.log("❌ No target shape found")
return
}
const targetShape = editor.getShape(edge.to) as TLGeoShape // Check if the target shape is a geo shape
editor.updateShape({ if (targetShape.type === "geo") {
id: edge.to, console.log("✅ Updating existing geo shape with LLM response")
type: "geo", editor.updateShape({
props: { id: edge.to,
...targetShape.props, type: "geo",
}, meta: {
meta: { ...targetShape.meta,
...targetShape.meta, text: partialResponse,
text: partialResponse, // Store text in meta instead of props },
}, })
}) console.log("✅ Content updated in target geo shape")
} else {
console.log("🆕 Target is not a geo shape, creating new geo shape at target location")
// If it's not a geo shape, create a new geo shape at the target location
const bounds = editor.getShapePageBounds(edge.to)
console.log("Target bounds:", bounds)
if (bounds) {
console.log("✅ Creating new geo shape with LLM response at target location")
editor.createShape({
id: createShapeId(),
type: "geo",
x: bounds.x,
y: bounds.y,
props: {
w: Math.max(200, partialResponse.length * 8),
h: 100,
geo: "rectangle",
color: "black",
fill: "none",
dash: "draw",
size: "m",
},
meta: {
text: partialResponse,
},
})
console.log("✅ New geo shape created with LLM response")
}
}
}) })
} catch (error) { } catch (error) {
console.error("Error calling LLM:", error); console.error("Error calling LLM:", error);
} }
} else { } else {
console.log("❌ No shapes selected")
} }
}, },
}, },

View File

@ -1,221 +1,212 @@
import OpenAI from "openai"; import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk";
import { makeRealSettings } from "@/lib/settings"; import type FileSystem from "@oddjs/odd/fs/index";
// Helper function to validate API keys
function isValidApiKey(provider: string, key: string): boolean {
if (!key || typeof key !== 'string' || key.trim() === '') {
return false;
}
switch (provider) {
case 'openai':
return key.startsWith('sk-') && key.length > 20;
case 'anthropic':
return key.startsWith('sk-ant-') && key.length > 20;
case 'google':
return key.length > 20; // Google keys don't have a specific prefix
default:
return key.length > 10; // Generic validation for unknown providers
}
}
// Helper function to load API keys from user profile filesystem
async function loadApiKeysFromProfile(fs: FileSystem | null): Promise<{ openai: string; anthropic: string }> {
const result = { openai: '', anthropic: '' };
if (!fs) {
return result;
}
try {
// Try to read API keys from user profile settings
const settingsPath = ['private', 'settings', 'api_keys.json'];
const filePath = (window as any).webnative?.path?.file(...settingsPath);
if (filePath && await fs.exists(filePath)) {
const fileContent = await fs.read(filePath);
const settings = JSON.parse(new TextDecoder().decode(fileContent));
if (settings.openai && typeof settings.openai === 'string') {
result.openai = settings.openai;
}
if (settings.anthropic && typeof settings.anthropic === 'string') {
result.anthropic = settings.anthropic;
}
console.log("📁 Loaded API keys from user profile");
}
} catch (error) {
console.log("⚠️ Could not load API keys from user profile:", error);
}
return result;
}
// Helper function to save API keys to user profile filesystem
async function saveApiKeysToProfile(fs: FileSystem | null, openaiKey: string, anthropicKey: string): Promise<void> {
if (!fs) {
return;
}
try {
const settings = {
openai: openaiKey,
anthropic: anthropicKey,
updated: new Date().toISOString()
};
const settingsPath = ['private', 'settings', 'api_keys.json'];
const filePath = (window as any).webnative?.path?.file(...settingsPath);
if (filePath) {
const content = new TextEncoder().encode(JSON.stringify(settings, null, 2));
await fs.write(filePath, content);
await fs.publish();
console.log("💾 Saved API keys to user profile");
}
} catch (error) {
console.log("⚠️ Could not save API keys to user profile:", error);
}
}
export async function llm( export async function llm(
userPrompt: string, userPrompt: string,
onToken: (partialResponse: string, done?: boolean) => void, onToken: (partialResponse: string, done?: boolean) => void,
fileSystem?: FileSystem | null,
) { ) {
// Validate the callback function // Validate the callback function
if (typeof onToken !== 'function') { if (typeof onToken !== 'function') {
throw new Error("onToken must be a function"); throw new Error("onToken must be a function");
} }
// Auto-migrate old format API keys if needed // Load API keys from both localStorage and user profile
await autoMigrateAPIKeys(); const localOpenaiKey = localStorage.getItem("openai_api_key") || "";
const localAnthropicKey = localStorage.getItem("anthropic_api_key") || "";
// Get current settings and available API keys // Load from user profile if filesystem is available
let settings; const profileKeys = await loadApiKeysFromProfile(fileSystem || null);
try {
settings = makeRealSettings.get() // Use profile keys if available, otherwise fall back to localStorage
} catch (e) { const openaiKey = profileKeys.openai || localOpenaiKey;
settings = null; const anthropicKey = profileKeys.anthropic || localAnthropicKey;
console.log("🔑 OpenAI key present:", !!openaiKey && isValidApiKey('openai', openaiKey));
console.log("🔑 Anthropic key present:", !!anthropicKey && isValidApiKey('anthropic', anthropicKey));
console.log("📁 Profile keys loaded:", { openai: !!profileKeys.openai, anthropic: !!profileKeys.anthropic });
console.log("💾 Local keys loaded:", { openai: !!localOpenaiKey, anthropic: !!localAnthropicKey });
// Determine which provider to use
let provider: string | null = null;
let apiKey: string | null = null;
// Try OpenAI first if available
if (openaiKey && isValidApiKey('openai', openaiKey)) {
provider = 'openai';
apiKey = openaiKey;
} }
// Try Anthropic if OpenAI not available
// Fallback to direct localStorage if makeRealSettings fails else if (anthropicKey && isValidApiKey('anthropic', anthropicKey)) {
if (!settings) { provider = 'anthropic';
try { apiKey = anthropicKey;
const rawSettings = localStorage.getItem("openai_api_key");
if (rawSettings) {
settings = JSON.parse(rawSettings);
}
} catch (e) {
// Continue with default settings
}
}
// Default settings if everything fails
if (!settings) {
settings = {
provider: 'openai',
models: { openai: 'gpt-4o', anthropic: 'claude-3-5-sonnet-20241022' },
keys: { openai: '', anthropic: '', google: '' }
};
}
const availableKeys = settings.keys || {}
// Determine which provider to use based on available keys
let provider: string | null = null
let apiKey: string | null = null
// Check if we have a preferred provider with a valid key
if (settings.provider && availableKeys[settings.provider as keyof typeof availableKeys] && availableKeys[settings.provider as keyof typeof availableKeys].trim() !== '') {
provider = settings.provider
apiKey = availableKeys[settings.provider as keyof typeof availableKeys]
} else {
// Fallback: use the first available provider with a valid key
for (const [key, value] of Object.entries(availableKeys)) {
if (typeof value === 'string' && value.trim() !== '') {
provider = key
apiKey = value
break
}
}
} }
if (!provider || !apiKey) { if (!provider || !apiKey) {
// Try to get keys directly from localStorage as fallback throw new Error("No valid API key found. Please set either 'openai_api_key' or 'anthropic_api_key' in localStorage.");
try {
const directSettings = localStorage.getItem("openai_api_key");
if (directSettings) {
// Check if it's the old format (just a string)
if (directSettings.startsWith('sk-') && !directSettings.startsWith('{')) {
// This is an old format OpenAI key, use it
provider = 'openai';
apiKey = directSettings;
} else {
// Try to parse as JSON
try {
const parsed = JSON.parse(directSettings);
if (parsed.keys) {
for (const [key, value] of Object.entries(parsed.keys)) {
if (typeof value === 'string' && value.trim() !== '') {
provider = key;
apiKey = value;
break;
}
}
}
} catch (parseError) {
// If it's not JSON and starts with sk-, treat as old format OpenAI key
if (directSettings.startsWith('sk-')) {
provider = 'openai';
apiKey = directSettings;
}
}
}
}
} catch (e) {
// Continue with error handling
}
if (!provider || !apiKey) {
throw new Error("No valid API key found for any provider")
}
} }
const model = settings.models[provider] || getDefaultModel(provider) console.log(`✅ Using ${provider} API`);
let partial = "";
// Try the selected provider
try { try {
if (provider === 'openai') { await tryProvider(provider, apiKey, getDefaultModel(provider), userPrompt, onToken);
const openai = new OpenAI({ console.log(`✅ Successfully used ${provider} API`);
apiKey, } catch (error: any) {
dangerouslyAllowBrowser: true, console.error(`${provider} API failed:`, error.message);
});
// If the first provider failed, try the other one
const stream = await openai.chat.completions.create({ const otherProvider = provider === 'openai' ? 'anthropic' : 'openai';
model: model, const otherKey = otherProvider === 'openai' ? openaiKey : anthropicKey;
messages: [
{ role: "system", content: 'You are a helpful assistant.' }, if (otherKey && isValidApiKey(otherProvider, otherKey)) {
{ role: "user", content: userPrompt }, console.log(`🔄 Trying fallback ${otherProvider} API`);
], try {
stream: true, await tryProvider(otherProvider, otherKey, getDefaultModel(otherProvider), userPrompt, onToken);
}); console.log(`✅ Successfully used fallback ${otherProvider} API`);
return;
for await (const chunk of stream) { } catch (fallbackError: any) {
const content = chunk.choices[0]?.delta?.content || ""; console.error(`❌ Fallback ${otherProvider} API also failed:`, fallbackError.message);
partial += content;
onToken(partial, false);
} }
} else if (provider === 'anthropic') {
const anthropic = new Anthropic({
apiKey,
dangerouslyAllowBrowser: true,
});
const stream = await anthropic.messages.create({
model: model,
max_tokens: 4096,
messages: [
{ role: "user", content: userPrompt }
],
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
const content = chunk.delta.text || "";
partial += content;
onToken(partial, false);
}
}
} else {
throw new Error(`Unsupported provider: ${provider}`)
} }
onToken(partial, true); // If all providers failed, throw the original error
} catch (error) {
throw error; throw error;
} }
} }
// Auto-migration function that runs automatically async function tryProvider(provider: string, apiKey: string, model: string, userPrompt: string, onToken: (partialResponse: string, done?: boolean) => void) {
async function autoMigrateAPIKeys() { let partial = "";
try {
const raw = localStorage.getItem("openai_api_key"); if (provider === 'openai') {
const openai = new OpenAI({
apiKey,
dangerouslyAllowBrowser: true,
});
if (!raw) { const stream = await openai.chat.completions.create({
return; // No key to migrate model: model,
messages: [
{ role: "system", content: 'You are a helpful assistant.' },
{ role: "user", content: userPrompt },
],
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
partial += content;
onToken(partial, false);
} }
} else if (provider === 'anthropic') {
const anthropic = new Anthropic({
apiKey,
dangerouslyAllowBrowser: true,
});
// Check if it's already in new format const stream = await anthropic.messages.create({
if (raw.startsWith('{')) { model: model,
try { max_tokens: 4096,
const parsed = JSON.parse(raw); messages: [
if (parsed.keys && (parsed.keys.openai || parsed.keys.anthropic)) { { role: "user", content: userPrompt }
return; // Already migrated ],
} stream: true,
} catch (e) { });
// Continue with migration
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
const content = chunk.delta.text || "";
partial += content;
onToken(partial, false);
} }
} }
} else {
// If it's old format (starts with sk-) throw new Error(`Unsupported provider: ${provider}`)
if (raw.startsWith('sk-')) {
// Determine which provider this key belongs to
let provider = 'openai';
if (raw.startsWith('sk-ant-')) {
provider = 'anthropic';
}
const newSettings = {
provider: provider,
models: {
openai: 'gpt-4o',
anthropic: 'claude-3-5-sonnet-20241022',
google: 'gemini-1.5-flash'
},
keys: {
openai: provider === 'openai' ? raw : '',
anthropic: provider === 'anthropic' ? raw : '',
google: ''
},
prompts: {
system: 'You are a helpful assistant.'
}
};
localStorage.setItem("openai_api_key", JSON.stringify(newSettings));
}
} catch (e) {
// Silently handle migration errors
} }
onToken(partial, true);
} }
// Helper function to get default model for a provider // Helper function to get default model for a provider
function getDefaultModel(provider: string): string { function getDefaultModel(provider: string): string {
switch (provider) { switch (provider) {
@ -231,53 +222,75 @@ function getDefaultModel(provider: string): string {
// Helper function to get API key from settings for a specific provider // Helper function to get API key from settings for a specific provider
export function getApiKey(provider: string = 'openai'): string { export function getApiKey(provider: string = 'openai'): string {
try { try {
const settings = localStorage.getItem("openai_api_key") if (provider === 'openai') {
return localStorage.getItem("openai_api_key") || "";
if (settings) { } else if (provider === 'anthropic') {
try { return localStorage.getItem("anthropic_api_key") || "";
const parsed = JSON.parse(settings)
if (parsed.keys && parsed.keys[provider]) {
const key = parsed.keys[provider];
return key;
}
// Fallback to old format
if (typeof settings === 'string' && provider === 'openai') {
return settings;
}
} catch (e) {
// Fallback to old format
if (typeof settings === 'string' && provider === 'openai') {
return settings;
}
}
} }
return "" return "";
} catch (e) { } catch (e) {
return "" return "";
} }
} }
// Helper function to check API key status
export function checkApiKeyStatus(): { provider: string; hasValidKey: boolean; keyPreview: string }[] {
const results: { provider: string; hasValidKey: boolean; keyPreview: string }[] = [];
// Check OpenAI key
const openaiKey = localStorage.getItem("openai_api_key") || "";
const openaiValid = isValidApiKey('openai', openaiKey);
results.push({
provider: 'openai',
hasValidKey: openaiValid,
keyPreview: openaiKey ? `${openaiKey.substring(0, 10)}...` : 'empty'
});
// Check Anthropic key
const anthropicKey = localStorage.getItem("anthropic_api_key") || "";
const anthropicValid = isValidApiKey('anthropic', anthropicKey);
results.push({
provider: 'anthropic',
hasValidKey: anthropicValid,
keyPreview: anthropicKey ? `${anthropicKey.substring(0, 10)}...` : 'empty'
});
return results;
}
// Helper function to get the first available API key from any provider // Helper function to get the first available API key from any provider
export function getFirstAvailableApiKey(): string | null { export function getFirstAvailableApiKey(): string | null {
try { try {
const settings = localStorage.getItem("openai_api_key") // Try OpenAI first
if (settings) { const openaiKey = localStorage.getItem("openai_api_key");
const parsed = JSON.parse(settings) if (openaiKey && isValidApiKey('openai', openaiKey)) {
if (parsed.keys) { return openaiKey;
for (const [key, value] of Object.entries(parsed.keys)) {
if (typeof value === 'string' && value.trim() !== '') {
return value
}
}
}
// Fallback to old format
if (typeof settings === 'string' && settings.trim() !== '') {
return settings
}
} }
return null
// Try Anthropic
const anthropicKey = localStorage.getItem("anthropic_api_key");
if (anthropicKey && isValidApiKey('anthropic', anthropicKey)) {
return anthropicKey;
}
return null;
} catch (e) { } catch (e) {
return null return null;
}
}
// Helper function to set up API keys (for debugging/setup)
export function setupApiKey(provider: 'openai' | 'anthropic', key: string): void {
try {
if (provider === 'openai') {
localStorage.setItem("openai_api_key", key);
} else if (provider === 'anthropic') {
localStorage.setItem("anthropic_api_key", key);
}
console.log(`${provider} API key set successfully`);
console.log(`🔑 Key preview: ${key.substring(0, 10)}...`);
} catch (e) {
console.error("❌ Failed to set API key:", e);
} }
} }

View File

@ -13,59 +13,51 @@ import { SlideShape } from "./shapes/SlideShapeUtil"
import { PromptShape } from "./shapes/PromptShapeUtil" import { PromptShape } from "./shapes/PromptShapeUtil"
import { SharedPianoShape } from "./shapes/SharedPianoShapeUtil" import { SharedPianoShape } from "./shapes/SharedPianoShapeUtil"
// Lazy load TLDraw dependencies to avoid startup timeouts // Import TLDraw dependencies
let customSchema: any = null import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
let TLSocketRoom: any = null import { TLSocketRoom } from "@tldraw/sync-core"
// Create custom schema
const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
migrations: MycrozineTemplateShape.migrations,
},
Slide: {
props: SlideShape.props,
migrations: SlideShape.migrations,
},
Prompt: {
props: PromptShape.props,
migrations: PromptShape.migrations,
},
SharedPiano: {
props: SharedPianoShape.props,
migrations: SharedPianoShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
async function getTldrawDependencies() { async function getTldrawDependencies() {
if (!customSchema) {
const { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } = await import("@tldraw/tlschema")
customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
migrations: ChatBoxShape.migrations,
},
VideoChat: {
props: VideoChatShape.props,
migrations: VideoChatShape.migrations,
},
Embed: {
props: EmbedShape.props,
migrations: EmbedShape.migrations,
},
Markdown: {
props: MarkdownShape.props,
migrations: MarkdownShape.migrations,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
migrations: MycrozineTemplateShape.migrations,
},
Slide: {
props: SlideShape.props,
migrations: SlideShape.migrations,
},
Prompt: {
props: PromptShape.props,
migrations: PromptShape.migrations,
},
SharedPiano: {
props: SharedPianoShape.props,
migrations: SharedPianoShape.migrations,
},
},
bindings: defaultBindingSchemas,
})
}
if (!TLSocketRoom) {
const syncCore = await import("@tldraw/sync-core")
TLSocketRoom = syncCore.TLSocketRoom
}
return { customSchema, TLSocketRoom } return { customSchema, TLSocketRoom }
} }