fix 'c', mycofi room,remove gesture tool
This commit is contained in:
parent
8fa8c388d9
commit
1b0fc57779
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
id: 'toggle-graph-layout',
|
||||
label: 'Toggle Graph Layout' as TLUiTranslationKey,
|
||||
readonlyOk: true,
|
||||
kbd: 'g',
|
||||
onSelect(_source: TLUiEventSource) {
|
||||
const event = new CustomEvent('toggleGraphLayoutEvent');
|
||||
window.dispatchEvent(event);
|
||||
|
|
|
|||
|
|
@ -78,10 +78,20 @@ const unpackShape = (shape: any) => {
|
|||
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 }
|
||||
if (type === 'text' && props.text !== undefined) {
|
||||
shapeProps.text = cast(props.text, String)
|
||||
if (type === 'text') {
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import {
|
|||
} from "@/ui/cameraUtils"
|
||||
import { Collection, initializeGlobalCollections } from "@/collections"
|
||||
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
|
||||
import { GestureTool } from "@/GestureTool"
|
||||
import { CmdK } from "@/CmdK"
|
||||
|
||||
|
||||
|
|
@ -76,7 +75,6 @@ const customTools = [
|
|||
MarkdownTool,
|
||||
PromptShapeTool,
|
||||
SharedPianoTool,
|
||||
GestureTool,
|
||||
]
|
||||
|
||||
export function Board() {
|
||||
|
|
|
|||
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
65
src/ui.tsx
65
src/ui.tsx
|
|
@ -9,81 +9,16 @@ import {
|
|||
TLDrawShape,
|
||||
TLShapePartial,
|
||||
} from "tldraw"
|
||||
import { DollarRecognizer } from "@/gestures"
|
||||
import { DEFAULT_GESTURES } from "@/default_gestures"
|
||||
|
||||
export const overrides: TLUiOverrides = {
|
||||
tools(editor, tools) {
|
||||
return {
|
||||
...tools,
|
||||
gesture: {
|
||||
id: "gesture",
|
||||
name: "Gesture",
|
||||
icon: "👆",
|
||||
kbd: "g",
|
||||
label: "Gesture",
|
||||
onSelect: () => {
|
||||
editor.setCurrentTool("gesture")
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
actions(editor, actions): TLUiActionsContextType {
|
||||
const R = new DollarRecognizer(DEFAULT_GESTURES)
|
||||
return {
|
||||
...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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,14 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
// Keyboard shortcut for adding to collection
|
||||
useEffect(() => {
|
||||
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()
|
||||
if (hasSelection && collection && !allSelectedShapesInCollection) {
|
||||
handleAddToCollection()
|
||||
|
|
@ -173,7 +180,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
id="add-to-collection"
|
||||
label="Add to Collection"
|
||||
icon="plus"
|
||||
kbd="c"
|
||||
kbd="alt+a"
|
||||
disabled={!hasSelection || !collection}
|
||||
onSelect={handleAddToCollection}
|
||||
/>
|
||||
|
|
@ -232,7 +239,6 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
id="search-text"
|
||||
label="Search Text"
|
||||
icon="search"
|
||||
kbd="s"
|
||||
onSelect={() => searchText(editor)}
|
||||
/>
|
||||
</TldrawUiMenuGroup>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Editor, useDefaultHelpers } from "tldraw"
|
||||
import { Editor, useDefaultHelpers, createShapeId } from "tldraw"
|
||||
import {
|
||||
shapeIdValidator,
|
||||
TLArrowShape,
|
||||
|
|
@ -20,8 +20,9 @@ import { moveToSlide } from "@/slides/useSlides"
|
|||
import { ISlideShape } from "@/shapes/SlideShapeUtil"
|
||||
import { getEdge } from "@/propagators/tlgraph"
|
||||
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) {
|
||||
return {
|
||||
...tools,
|
||||
|
|
@ -160,15 +161,6 @@ export const overrides: TLUiOverrides = {
|
|||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("SharedPiano"),
|
||||
},
|
||||
gesture: {
|
||||
id: "gesture",
|
||||
icon: "draw",
|
||||
label: "Gesture",
|
||||
kbd: "g",
|
||||
readonlyOk: true,
|
||||
type: "gesture",
|
||||
onSelect: () => editor.setCurrentTool("gesture"),
|
||||
},
|
||||
hand: {
|
||||
...tools.hand,
|
||||
onDoubleClick: (info: any) => {
|
||||
|
|
@ -190,7 +182,7 @@ export const overrides: TLUiOverrides = {
|
|||
zoomToSelection: {
|
||||
id: "zoom-to-selection",
|
||||
label: "Zoom to Selection",
|
||||
kbd: "z",
|
||||
kbd: "alt+z",
|
||||
onSelect: () => {
|
||||
if (editor.getSelectedShapeIds().length > 0) {
|
||||
zoomToSelection(editor)
|
||||
|
|
@ -251,7 +243,6 @@ export const overrides: TLUiOverrides = {
|
|||
moveSelectedLeft: {
|
||||
id: "move-selected-left",
|
||||
label: "Move Left",
|
||||
kbd: "ArrowLeft",
|
||||
onSelect: () => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
|
|
@ -269,7 +260,6 @@ export const overrides: TLUiOverrides = {
|
|||
moveSelectedRight: {
|
||||
id: "move-selected-right",
|
||||
label: "Move Right",
|
||||
kbd: "ArrowRight",
|
||||
onSelect: () => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
|
|
@ -287,7 +277,6 @@ export const overrides: TLUiOverrides = {
|
|||
moveSelectedUp: {
|
||||
id: "move-selected-up",
|
||||
label: "Move Up",
|
||||
kbd: "ArrowUp",
|
||||
onSelect: () => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
|
|
@ -305,7 +294,6 @@ export const overrides: TLUiOverrides = {
|
|||
moveSelectedDown: {
|
||||
id: "move-selected-down",
|
||||
label: "Move Down",
|
||||
kbd: "ArrowDown",
|
||||
onSelect: () => {
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
if (selectedShapes.length > 0) {
|
||||
|
|
@ -323,7 +311,7 @@ export const overrides: TLUiOverrides = {
|
|||
searchShapes: {
|
||||
id: "search-shapes",
|
||||
label: "Search Shapes",
|
||||
kbd: "s",
|
||||
kbd: "alt+s",
|
||||
readonlyOk: true,
|
||||
onSelect: () => searchText(editor),
|
||||
},
|
||||
|
|
@ -333,58 +321,104 @@ export const overrides: TLUiOverrides = {
|
|||
kbd: "alt+g",
|
||||
readonlyOk: true,
|
||||
onSelect: () => {
|
||||
console.log("🎯 LLM action triggered")
|
||||
|
||||
const selectedShapes = editor.getSelectedShapes()
|
||||
console.log("Selected shapes:", selectedShapes.length, selectedShapes.map(s => s.type))
|
||||
|
||||
|
||||
if (selectedShapes.length > 0) {
|
||||
const selectedShape = selectedShapes[0] as TLArrowShape
|
||||
console.log("First selected shape type:", selectedShape.type)
|
||||
|
||||
|
||||
if (selectedShape.type !== "arrow") {
|
||||
|
||||
console.log("❌ Selected shape is not an arrow, returning")
|
||||
return
|
||||
}
|
||||
const edge = getEdge(selectedShape, editor)
|
||||
console.log("Edge found:", edge)
|
||||
|
||||
|
||||
if (!edge) {
|
||||
|
||||
console.log("❌ No edge found, returning")
|
||||
return
|
||||
}
|
||||
|
||||
const sourceShape = editor.getShape(edge.from)
|
||||
const targetShape = editor.getShape(edge.to)
|
||||
console.log("Arrow direction: FROM", sourceShape?.type, "TO", targetShape?.type)
|
||||
|
||||
const sourceText =
|
||||
sourceShape && sourceShape.type === "geo"
|
||||
? (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}
|
||||
${sourceText ? `Context: ${sourceText}` : ""}`;
|
||||
console.log("Generated prompt:", prompt)
|
||||
|
||||
|
||||
try {
|
||||
console.log("🚀 Calling LLM with prompt...")
|
||||
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
|
||||
editor.updateShape({
|
||||
id: edge.to,
|
||||
type: "geo",
|
||||
props: {
|
||||
...targetShape.props,
|
||||
},
|
||||
meta: {
|
||||
...targetShape.meta,
|
||||
text: partialResponse, // Store text in meta instead of props
|
||||
},
|
||||
})
|
||||
|
||||
// Check if the target shape is a geo shape
|
||||
if (targetShape.type === "geo") {
|
||||
console.log("✅ Updating existing geo shape with LLM response")
|
||||
editor.updateShape({
|
||||
id: edge.to,
|
||||
type: "geo",
|
||||
meta: {
|
||||
...targetShape.meta,
|
||||
text: partialResponse,
|
||||
},
|
||||
})
|
||||
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) {
|
||||
console.error("Error calling LLM:", error);
|
||||
}
|
||||
} else {
|
||||
|
||||
console.log("❌ No shapes selected")
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,221 +1,212 @@
|
|||
import OpenAI from "openai";
|
||||
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(
|
||||
userPrompt: string,
|
||||
onToken: (partialResponse: string, done?: boolean) => void,
|
||||
fileSystem?: FileSystem | null,
|
||||
) {
|
||||
// Validate the callback function
|
||||
if (typeof onToken !== 'function') {
|
||||
throw new Error("onToken must be a function");
|
||||
}
|
||||
|
||||
// Auto-migrate old format API keys if needed
|
||||
await autoMigrateAPIKeys();
|
||||
// Load API keys from both localStorage and user profile
|
||||
const localOpenaiKey = localStorage.getItem("openai_api_key") || "";
|
||||
const localAnthropicKey = localStorage.getItem("anthropic_api_key") || "";
|
||||
|
||||
// Get current settings and available API keys
|
||||
let settings;
|
||||
try {
|
||||
settings = makeRealSettings.get()
|
||||
} catch (e) {
|
||||
settings = null;
|
||||
// Load from user profile if filesystem is available
|
||||
const profileKeys = await loadApiKeysFromProfile(fileSystem || null);
|
||||
|
||||
// Use profile keys if available, otherwise fall back to localStorage
|
||||
const openaiKey = profileKeys.openai || localOpenaiKey;
|
||||
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;
|
||||
}
|
||||
|
||||
// Fallback to direct localStorage if makeRealSettings fails
|
||||
if (!settings) {
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
// Try Anthropic if OpenAI not available
|
||||
else if (anthropicKey && isValidApiKey('anthropic', anthropicKey)) {
|
||||
provider = 'anthropic';
|
||||
apiKey = anthropicKey;
|
||||
}
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
// Try to get keys directly from localStorage as fallback
|
||||
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")
|
||||
}
|
||||
throw new Error("No valid API key found. Please set either 'openai_api_key' or 'anthropic_api_key' in localStorage.");
|
||||
}
|
||||
|
||||
const model = settings.models[provider] || getDefaultModel(provider)
|
||||
let partial = "";
|
||||
console.log(`✅ Using ${provider} API`);
|
||||
|
||||
// Try the selected provider
|
||||
try {
|
||||
if (provider === 'openai') {
|
||||
const openai = new OpenAI({
|
||||
apiKey,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
await tryProvider(provider, apiKey, getDefaultModel(provider), userPrompt, onToken);
|
||||
console.log(`✅ Successfully used ${provider} API`);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ ${provider} API failed:`, error.message);
|
||||
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: model,
|
||||
messages: [
|
||||
{ role: "system", content: 'You are a helpful assistant.' },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
// If the first provider failed, try the other one
|
||||
const otherProvider = provider === 'openai' ? 'anthropic' : 'openai';
|
||||
const otherKey = otherProvider === 'openai' ? openaiKey : anthropicKey;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
partial += content;
|
||||
onToken(partial, false);
|
||||
if (otherKey && isValidApiKey(otherProvider, otherKey)) {
|
||||
console.log(`🔄 Trying fallback ${otherProvider} API`);
|
||||
try {
|
||||
await tryProvider(otherProvider, otherKey, getDefaultModel(otherProvider), userPrompt, onToken);
|
||||
console.log(`✅ Successfully used fallback ${otherProvider} API`);
|
||||
return;
|
||||
} catch (fallbackError: any) {
|
||||
console.error(`❌ Fallback ${otherProvider} API also failed:`, fallbackError.message);
|
||||
}
|
||||
} 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);
|
||||
|
||||
} catch (error) {
|
||||
// If all providers failed, throw the original error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-migration function that runs automatically
|
||||
async function autoMigrateAPIKeys() {
|
||||
try {
|
||||
const raw = localStorage.getItem("openai_api_key");
|
||||
async function tryProvider(provider: string, apiKey: string, model: string, userPrompt: string, onToken: (partialResponse: string, done?: boolean) => void) {
|
||||
let partial = "";
|
||||
|
||||
if (!raw) {
|
||||
return; // No key to migrate
|
||||
if (provider === 'openai') {
|
||||
const openai = new OpenAI({
|
||||
apiKey,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
|
||||
const stream = await openai.chat.completions.create({
|
||||
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
|
||||
if (raw.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.keys && (parsed.keys.openai || parsed.keys.anthropic)) {
|
||||
return; // Already migrated
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue with migration
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's old format (starts with sk-)
|
||||
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
|
||||
} else {
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
|
||||
onToken(partial, true);
|
||||
}
|
||||
|
||||
|
||||
// Helper function to get default model for a provider
|
||||
function getDefaultModel(provider: string): string {
|
||||
switch (provider) {
|
||||
|
|
@ -231,53 +222,75 @@ function getDefaultModel(provider: string): string {
|
|||
// Helper function to get API key from settings for a specific provider
|
||||
export function getApiKey(provider: string = 'openai'): string {
|
||||
try {
|
||||
const settings = localStorage.getItem("openai_api_key")
|
||||
|
||||
if (settings) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (provider === 'openai') {
|
||||
return localStorage.getItem("openai_api_key") || "";
|
||||
} else if (provider === 'anthropic') {
|
||||
return localStorage.getItem("anthropic_api_key") || "";
|
||||
}
|
||||
return ""
|
||||
return "";
|
||||
} 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
|
||||
export function getFirstAvailableApiKey(): string | null {
|
||||
try {
|
||||
const settings = localStorage.getItem("openai_api_key")
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings)
|
||||
if (parsed.keys) {
|
||||
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
|
||||
}
|
||||
// Try OpenAI first
|
||||
const openaiKey = localStorage.getItem("openai_api_key");
|
||||
if (openaiKey && isValidApiKey('openai', openaiKey)) {
|
||||
return openaiKey;
|
||||
}
|
||||
return null
|
||||
|
||||
// Try Anthropic
|
||||
const anthropicKey = localStorage.getItem("anthropic_api_key");
|
||||
if (anthropicKey && isValidApiKey('anthropic', anthropicKey)) {
|
||||
return anthropicKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,59 +13,51 @@ import { SlideShape } from "./shapes/SlideShapeUtil"
|
|||
import { PromptShape } from "./shapes/PromptShapeUtil"
|
||||
import { SharedPianoShape } from "./shapes/SharedPianoShapeUtil"
|
||||
|
||||
// Lazy load TLDraw dependencies to avoid startup timeouts
|
||||
let customSchema: any = null
|
||||
let TLSocketRoom: any = null
|
||||
// Import TLDraw dependencies
|
||||
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
|
||||
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() {
|
||||
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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue