From 956463d43f37aedd25978ad1778fec14734abe19 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 25 Aug 2025 06:48:47 +0200 Subject: [PATCH] user auth via webcryptoapi, starred boards, dashboard view --- src/App.tsx | 1 + src/components/StarBoardButton.tsx | 73 +++++-- src/css/crypto-auth.css | 37 +++- src/css/starred-boards.css | 180 +++++++++++++--- src/css/user-profile.css | 77 +++++++ src/routes/Board.tsx | 63 +++++- src/shapes/PromptShapeUtil.tsx | 65 +++--- src/ui/CustomToolbar.tsx | 328 +++++++++++++++++------------ src/ui/SettingsDialog.tsx | 121 +++++++++-- src/ui/overrides.tsx | 36 +++- src/utils/llmUtils.ts | 298 +++++++++++++++++++++++--- 11 files changed, 1008 insertions(+), 271 deletions(-) create mode 100644 src/css/user-profile.css diff --git a/src/App.tsx b/src/App.tsx index fa26a6f..1a91d4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import "@/css/style.css"; import "@/css/auth.css"; // Import auth styles import "@/css/crypto-auth.css"; // Import crypto auth styles import "@/css/starred-boards.css"; // Import starred boards styles +import "@/css/user-profile.css"; // Import user profile styles import { Default } from "@/routes/Default"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { Contact } from "@/routes/Contact"; diff --git a/src/components/StarBoardButton.tsx b/src/components/StarBoardButton.tsx index 9c53716..f227980 100644 --- a/src/components/StarBoardButton.tsx +++ b/src/components/StarBoardButton.tsx @@ -14,6 +14,9 @@ const StarBoardButton: React.FC = ({ className = '' }) => const { addNotification } = useNotifications(); const [isStarred, setIsStarred] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [showPopup, setShowPopup] = useState(false); + const [popupMessage, setPopupMessage] = useState(''); + const [popupType, setPopupType] = useState<'success' | 'error' | 'info'>('success'); // Check if board is starred on mount and when session changes useEffect(() => { @@ -25,6 +28,17 @@ const StarBoardButton: React.FC = ({ className = '' }) => } }, [session.authed, session.username, slug]); + const showPopupMessage = (message: string, type: 'success' | 'error' | 'info') => { + setPopupMessage(message); + setPopupType(type); + setShowPopup(true); + + // Auto-hide after 2 seconds + setTimeout(() => { + setShowPopup(false); + }, 2000); + }; + const handleStarToggle = async () => { if (!session.authed || !session.username || !slug) { addNotification('Please log in to star boards', 'warning'); @@ -39,23 +53,23 @@ const StarBoardButton: React.FC = ({ className = '' }) => const success = unstarBoard(session.username, slug); if (success) { setIsStarred(false); - addNotification('Board removed from starred boards', 'success'); + showPopupMessage('Board removed from starred boards', 'success'); } else { - addNotification('Failed to remove board from starred boards', 'error'); + showPopupMessage('Failed to remove board from starred boards', 'error'); } } else { // Star the board const success = starBoard(session.username, slug, slug); if (success) { setIsStarred(true); - addNotification('Board added to starred boards', 'success'); + showPopupMessage('Board added to starred boards', 'success'); } else { - addNotification('Board is already starred', 'info'); + showPopupMessage('Board is already starred', 'info'); } } } catch (error) { console.error('Error toggling star:', error); - addNotification('Failed to update starred boards', 'error'); + showPopupMessage('Failed to update starred boards', 'error'); } finally { setIsLoading(false); } @@ -67,23 +81,40 @@ const StarBoardButton: React.FC = ({ className = '' }) => } return ( - + + {/* Custom popup notification */} + {showPopup && ( +
+ {popupMessage} +
)} - - {isStarred ? 'Starred' : 'Star'} - - + ); }; diff --git a/src/css/crypto-auth.css b/src/css/crypto-auth.css index 2d8d7d1..06e9032 100644 --- a/src/css/crypto-auth.css +++ b/src/css/crypto-auth.css @@ -273,12 +273,13 @@ /* Responsive positioning for toolbar buttons */ @media (max-width: 768px) { .toolbar-login-button { - margin-right: 4px; + margin-right: 0; } /* Adjust toolbar container position on mobile */ .toolbar-container { - right: 80px !important; + right: 35px !important; + gap: 4px !important; } } @@ -474,16 +475,23 @@ /* Login Button Styles */ .login-button { - padding: 6px 12px; background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); color: white; border: none; border-radius: 4px; - font-size: 0.8rem; - font-weight: 600; cursor: pointer; + font-size: 0.75rem; + font-weight: 600; transition: all 0.2s ease; letter-spacing: 0.5px; + white-space: nowrap; + padding: 4px 8px; + height: 22px; + min-height: 22px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; } .login-button:hover { @@ -493,7 +501,24 @@ } .toolbar-login-button { - margin-right: 8px; + margin-right: 0; + height: 22px; + min-height: 22px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex-shrink: 0; + padding: 4px 8px; + font-size: 0.75rem; + border-radius: 4px; + transition: all 0.2s ease; +} + +.toolbar-login-button:hover { + background: linear-gradient(135deg, #0056b3 0%, #004085 100%); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); } /* Login Modal Overlay */ diff --git a/src/css/starred-boards.css b/src/css/starred-boards.css index fec8c73..8e0616e 100644 --- a/src/css/starred-boards.css +++ b/src/css/starred-boards.css @@ -2,34 +2,110 @@ .star-board-button { display: flex; align-items: center; - gap: 6px; - padding: 8px 12px; - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 6px; + justify-content: center; + padding: 4px 8px; + background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); + color: white; + border: none; + border-radius: 4px; cursor: pointer; - font-size: 14px; - font-weight: 500; - color: #495057; + font-size: 0.75rem; + font-weight: 600; transition: all 0.2s ease; + letter-spacing: 0.5px; white-space: nowrap; + box-sizing: border-box; + line-height: 1.1; + margin: 0; + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; +} + +/* Custom popup notification styles */ +.star-popup { + padding: 8px 12px; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: popupSlideIn 0.3s ease-out; + max-width: 200px; + text-align: center; +} + +.star-popup-success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.star-popup-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.star-popup-info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +@keyframes popupSlideIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(10px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* Toolbar-specific star button styling to match login button exactly */ +.toolbar-star-button { + padding: 4px 8px; + font-size: 0.75rem; + font-weight: 600; + border-radius: 4px; + background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); + color: white; + border: none; + transition: all 0.2s ease; + letter-spacing: 0.5px; + box-sizing: border-box; + line-height: 1.1; + margin: 0; + width: 22px; + height: 22px; + min-width: 22px; + min-height: 22px; + flex-shrink: 0; } .star-board-button:hover { - background: #e9ecef; - border-color: #dee2e6; - color: #212529; + background: linear-gradient(135deg, #0056b3 0%, #004085 100%); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); +} + +.toolbar-star-button:hover { + background: linear-gradient(135deg, #0056b3 0%, #004085 100%); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); } .star-board-button.starred { - background: #fff3cd; - border-color: #ffeaa7; - color: #856404; + background: #6B7280; + color: white; } .star-board-button.starred:hover { - background: #ffeaa7; - border-color: #fdcb6e; + background: #4B5563; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .star-board-button:disabled { @@ -38,8 +114,16 @@ } .star-icon { - font-size: 16px; + font-size: 0.8rem; transition: transform 0.2s ease; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + color: inherit; + width: 16px; + height: 16px; + text-align: center; } .star-icon.starred { @@ -48,6 +132,13 @@ .loading-spinner { animation: spin 1s linear infinite; + font-size: 12px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; } @keyframes spin { @@ -206,7 +297,7 @@ padding: 4px; border-radius: 4px; transition: all 0.2s ease; - color: #ffc107; + color: #6B7280; } .unstar-button:hover { @@ -438,26 +529,48 @@ } .star-board-button { - background: #3a3a3a; - border-color: #495057; - color: #e9ecef; + background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%); + color: white; + border: none; } .star-board-button:hover { - background: #495057; - border-color: #6c757d; - color: #f8f9fa; + background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%); + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(99, 179, 237, 0.3); } .star-board-button.starred { - background: #664d03; - border-color: #ffc107; - color: #ffc107; + background: #6B7280; + color: white; + border: none; } .star-board-button.starred:hover { - background: #856404; - border-color: #ffca2c; + background: #4B5563; + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + /* Dark mode popup styles */ + .star-popup-success { + background: #1e4d2b; + color: #d4edda; + border: 1px solid #2d5a3d; + } + + .star-popup-error { + background: #4a1e1e; + color: #f8d7da; + border: 1px solid #5a2d2d; + } + + .star-popup-info { + background: #1e4a4a; + color: #d1ecf1; + border: 1px solid #2d5a5a; } .board-screenshot { @@ -497,6 +610,15 @@ font-size: 12px; } + .toolbar-star-button { + padding: 4px 8px; + font-size: 0.7rem; + width: 28px; + height: 24px; + min-width: 28px; + min-height: 24px; + } + .star-text { display: none; } diff --git a/src/css/user-profile.css b/src/css/user-profile.css new file mode 100644 index 0000000..7cc429e --- /dev/null +++ b/src/css/user-profile.css @@ -0,0 +1,77 @@ +/* Custom User Profile Styles */ +.custom-user-profile { + position: absolute; + top: 8px; + right: 8px; + z-index: 1000; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(8px); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + padding: 8px 12px; + font-size: 14px; + font-weight: 500; + color: #333; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 8px; + user-select: none; + pointer-events: none; + transition: all 0.2s ease; + animation: profileSlideIn 0.3s ease-out; +} + +.custom-user-profile .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #10b981; + flex-shrink: 0; + animation: pulse 2s infinite; +} + +.custom-user-profile .username { + font-weight: 600; + letter-spacing: 0.5px; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .custom-user-profile { + background: rgba(45, 45, 45, 0.9); + border-color: rgba(255, 255, 255, 0.1); + color: #e9ecef; + } +} + +/* Animations */ +@keyframes profileSlideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +/* Responsive design */ +@media (max-width: 768px) { + .custom-user-profile { + top: 4px; + right: 4px; + padding: 6px 10px; + font-size: 12px; + } +} \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index a604d7e..2b89b20 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -112,9 +112,45 @@ export function Board() { // The presence should automatically update through the useSync configuration // when the session changes, but we can also try to force an update - console.log('User authenticated, presence should show:', session.username) }, [editor, session.authed, session.username]) + // Update TLDraw user preferences when editor is available and user is authenticated + useEffect(() => { + if (!editor) return + + try { + if (session.authed && session.username) { + // Update the user preferences in TLDraw + editor.user.updateUserPreferences({ + id: session.username, + name: session.username, + }); + } else { + // Set default user preferences when not authenticated + editor.user.updateUserPreferences({ + id: 'user-1', + name: 'User 1', + }); + } + } catch (error) { + console.error('Error updating TLDraw user preferences from Board component:', error); + } + + // Cleanup function to reset preferences when user logs out + return () => { + if (editor) { + try { + editor.user.updateUserPreferences({ + id: 'user-1', + name: 'User 1', + }); + } catch (error) { + console.error('Error resetting TLDraw user preferences:', error); + } + } + }; + }, [editor, session.authed, session.username]); + // Track board visit for starred boards useEffect(() => { if (session.authed && session.username && roomId) { @@ -145,10 +181,7 @@ export function Board() { // Only capture if content actually changed if (newShapeCount !== currentShapeCount || newContentHash !== currentContentHash) { - console.log('Content changed, capturing screenshot'); await captureBoardScreenshot(editor, roomId); - } else { - console.log('No content changes detected, skipping screenshot'); } }, 3000); // Wait 3 seconds to ensure changes are complete @@ -204,6 +237,28 @@ export function Board() { ClickPropagator, ]) + // Set user preferences immediately if user is authenticated + if (session.authed && session.username) { + try { + editor.user.updateUserPreferences({ + id: session.username, + name: session.username, + }); + } catch (error) { + console.error('Error setting initial TLDraw user preferences:', error); + } + } else { + // Set default user preferences when not authenticated + try { + editor.user.updateUserPreferences({ + id: 'user-1', + name: 'User 1', + }); + } catch (error) { + console.error('Error setting default TLDraw user preferences:', error); + } + } + // Note: User presence is configured through the useSync hook above // The authenticated username should appear in the people section }} diff --git a/src/shapes/PromptShapeUtil.tsx b/src/shapes/PromptShapeUtil.tsx index 0ca554f..7de8b4c 100644 --- a/src/shapes/PromptShapeUtil.tsx +++ b/src/shapes/PromptShapeUtil.tsx @@ -6,7 +6,7 @@ import { TLShape, } from "tldraw" import { getEdge } from "@/propagators/tlgraph" -import { llm } from "@/utils/llmUtils" +import { llm, getApiKey } from "@/utils/llmUtils" import { isShapeOfType } from "@/propagators/utils" import React, { useState } from "react" @@ -89,10 +89,15 @@ export class PromptShape extends BaseBoxShapeUtil { }, {} as Record) const generateText = async (prompt: string) => { + console.log("🎯 generateText called with prompt:", prompt); + const conversationHistory = shape.props.value ? shape.props.value + '\n' : '' const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const userMessage = `{"role": "user", "content": "${escapedPrompt}"}` + console.log("💬 User message:", userMessage); + console.log("📚 Conversation history:", conversationHistory); + // Update with user message and trigger scroll this.editor.updateShape({ id: shape.id, @@ -105,34 +110,45 @@ export class PromptShape extends BaseBoxShapeUtil { let fullResponse = '' - await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => { - if (partial) { - fullResponse = partial - const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') - const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` - - try { - JSON.parse(assistantMessage) + console.log("🚀 Calling llm function..."); + try { + await llm(prompt, (partial: string, done?: boolean) => { + console.log(`📝 LLM callback received - partial: "${partial}", done: ${done}`); + if (partial) { + fullResponse = partial + const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') + const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` - // Use requestAnimationFrame to ensure smooth scrolling during streaming - requestAnimationFrame(() => { - this.editor.updateShape({ - id: shape.id, - type: "Prompt", - props: { - value: conversationHistory + userMessage + '\n' + assistantMessage, - agentBinding: done ? null : "someone" - }, + console.log("🤖 Assistant message:", assistantMessage); + + try { + JSON.parse(assistantMessage) + + // Use requestAnimationFrame to ensure smooth scrolling during streaming + requestAnimationFrame(() => { + console.log("🔄 Updating shape with partial response..."); + this.editor.updateShape({ + id: shape.id, + type: "Prompt", + props: { + value: conversationHistory + userMessage + '\n' + assistantMessage, + agentBinding: done ? null : "someone" + }, + }) }) - }) - } catch (error) { - console.error('Invalid JSON message:', error) + } catch (error) { + console.error('❌ Invalid JSON message:', error) + } } - } - }) + }) + console.log("✅ LLM function completed successfully"); + } catch (error) { + console.error("❌ Error in LLM function:", error); + } // Ensure the final message is saved after streaming is complete if (fullResponse) { + console.log("💾 Saving final response:", fullResponse); const escapedResponse = fullResponse.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` @@ -148,8 +164,9 @@ export class PromptShape extends BaseBoxShapeUtil { agentBinding: null }, }) + console.log("✅ Final response saved successfully"); } catch (error) { - console.error('Invalid JSON in final message:', error) + console.error('❌ Invalid JSON in final message:', error) } } } diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index 8c77b73..3bceee8 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -31,10 +31,20 @@ export function CustomToolbar() { try { if (settings) { try { - const { keys } = JSON.parse(settings) - const hasValidKey = keys && Object.values(keys).some(key => typeof key === 'string' && key.trim() !== '') - setHasApiKey(hasValidKey) + const parsed = JSON.parse(settings) + if (parsed.keys) { + // New format with multiple providers + const hasValidKey = Object.values(parsed.keys).some(key => + typeof key === 'string' && key.trim() !== '' + ) + setHasApiKey(hasValidKey) + } else { + // Old format - single string + const hasValidKey = typeof settings === 'string' && settings.trim() !== '' + setHasApiKey(hasValidKey) + } } catch (e) { + // Fallback to old format const hasValidKey = typeof settings === 'string' && settings.trim() !== '' setHasApiKey(hasValidKey) } @@ -65,73 +75,47 @@ export function CustomToolbar() { setShowProfilePopup(false) } + const openApiKeysDialog = () => { + addDialog({ + id: "api-keys", + component: ({ onClose }: { onClose: () => void }) => ( + { + onClose() + removeDialog("api-keys") + checkApiKeys() // Refresh API key status + }} + /> + ), + }) + } + if (!isReady) return null return (
-
- - - +
+ + {session.authed && (
- {showProfilePopup && ( -
-
- Hello, {session.username}! -
- - { - e.currentTarget.style.backgroundColor = "#2563EB" - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "#3B82F6" + boxShadow: "0 2px 10px rgba(0,0,0,0.1)", + padding: "16px", + zIndex: 100000, }} > - My Dashboard - - - {!session.backupCreated && ( -
- Remember to back up your encryption keys to prevent data loss! +
+ Hello, {session.username}!
- )} - - -
- )} -
+ border: `1px solid ${hasApiKey ? "#0ea5e9" : "#f87171"}` + }}> +
+ AI API Keys + + {hasApiKey ? "✅ Configured" : "❌ Not configured"} + +
+

+ {hasApiKey + ? "Your AI models are ready to use" + : "Configure API keys to use AI features" + } +

+ +
+ + { + e.currentTarget.style.backgroundColor = "#2563EB" + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "#3B82F6" + }} + > + My Dashboard + + + {!session.backupCreated && ( +
+ Remember to back up your encryption keys to prevent data loss! +
+ )} + + +
+ )} +
)}
diff --git a/src/ui/SettingsDialog.tsx b/src/ui/SettingsDialog.tsx index df1bf92..f1c45f7 100644 --- a/src/ui/SettingsDialog.tsx +++ b/src/ui/SettingsDialog.tsx @@ -10,31 +10,124 @@ import { TldrawUiInput, } from "tldraw" import React from "react" +import { PROVIDERS } from "../lib/settings" export function SettingsDialog({ onClose }: TLUiDialogProps) { - const [apiKey, setApiKey] = React.useState(() => { - return localStorage.getItem("openai_api_key") || "" + const [apiKeys, setApiKeys] = React.useState(() => { + try { + const stored = localStorage.getItem("openai_api_key") + if (stored) { + try { + const parsed = JSON.parse(stored) + if (parsed.keys) { + return parsed.keys + } + } catch (e) { + // Fallback to old format + return { openai: stored } + } + } + return { openai: '', anthropic: '', google: '' } + } catch (e) { + return { openai: '', anthropic: '', google: '' } + } }) - const handleChange = (value: string) => { - setApiKey(value) - localStorage.setItem("openai_api_key", value) + const handleKeyChange = (provider: string, value: string) => { + const newKeys = { ...apiKeys, [provider]: value } + setApiKeys(newKeys) + + // Save to localStorage with the new structure + const settings = { + keys: newKeys, + provider: provider === 'openai' ? 'openai' : provider, // Use the actual provider + models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])), + } + console.log("💾 Saving settings to localStorage:", settings); + localStorage.setItem("openai_api_key", JSON.stringify(settings)) + } + + const validateKey = (provider: string, key: string) => { + const providerConfig = PROVIDERS.find(p => p.id === provider) + if (providerConfig && key.trim()) { + return providerConfig.validate(key) + } + return true } return ( <> - API Keys + AI API Keys - -
- - + +
+ {PROVIDERS.map((provider) => ( +
+
+ + + {provider.models[0]} + +
+ handleKeyChange(provider.id, value)} + style={{ + border: validateKey(provider.id, apiKeys[provider.id] || '') + ? undefined + : '1px solid #ef4444' + }} + /> + {apiKeys[provider.id] && !validateKey(provider.id, apiKeys[provider.id]) && ( +
+ Invalid API key format +
+ )} + +
+ ))} + +
+
+ Note: API keys are stored locally in your browser. + Make sure to use keys with appropriate usage limits for your needs. +
+
diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 61ed742..b52dd6a 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -19,7 +19,7 @@ import { EmbedShape, IEmbedShape } from "@/shapes/EmbedShapeUtil" import { moveToSlide } from "@/slides/useSlides" import { ISlideShape } from "@/shapes/SlideShapeUtil" import { getEdge } from "@/propagators/tlgraph" -import { llm } from "@/utils/llmUtils" +import { llm, getApiKey } from "@/utils/llmUtils" export const overrides: TLUiOverrides = { tools(editor, tools) { @@ -312,17 +312,26 @@ export const overrides: TLUiOverrides = { llm: { id: "llm", label: "Run LLM Prompt", - kbd: "g", + kbd: "alt+g", readonlyOk: true, onSelect: () => { + const selectedShapes = editor.getSelectedShapes() + + if (selectedShapes.length > 0) { const selectedShape = selectedShapes[0] as TLArrowShape + + if (selectedShape.type !== "arrow") { + return } const edge = getEdge(selectedShape, editor) + + if (!edge) { + return } const sourceShape = editor.getShape(edge.from) @@ -330,11 +339,15 @@ export const overrides: TLUiOverrides = { sourceShape && sourceShape.type === "geo" ? (sourceShape as TLGeoShape).props.text : "" - llm( - `Instruction: ${edge.text} - ${sourceText ? `Context: ${sourceText}` : ""}`, - localStorage.getItem("openai_api_key") || "", - (partialResponse: string) => { + + + const prompt = `Instruction: ${edge.text} + ${sourceText ? `Context: ${sourceText}` : ""}`; + + + try { + llm(prompt, (partialResponse: string) => { + editor.updateShape({ id: edge.to, type: "geo", @@ -343,8 +356,13 @@ export const overrides: TLUiOverrides = { text: partialResponse, }, }) - }, - ) + + }) + } catch (error) { + console.error("Error calling LLM:", error); + } + } else { + } }, }, diff --git a/src/utils/llmUtils.ts b/src/utils/llmUtils.ts index 802aa13..fd766e6 100644 --- a/src/utils/llmUtils.ts +++ b/src/utils/llmUtils.ts @@ -1,33 +1,283 @@ import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +import { makeRealSettings } from "@/lib/settings"; export async function llm( - //systemPrompt: string, userPrompt: string, - apiKey: string, - onToken: (partialResponse: string, done: boolean) => void, + onToken: (partialResponse: string, done?: boolean) => void, ) { - if (!apiKey) { - throw new Error("No API key found") + // Validate the callback function + if (typeof onToken !== 'function') { + throw new Error("onToken must be a function"); } - //console.log("System Prompt:", systemPrompt); - //console.log("User Prompt:", userPrompt); + + // Auto-migrate old format API keys if needed + await autoMigrateAPIKeys(); + + // Get current settings and available API keys + let settings; + try { + settings = makeRealSettings.get() + } catch (e) { + settings = null; + } + + // 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 + } + } + } + + 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") + } + } + + const model = settings.models[provider] || getDefaultModel(provider) let partial = ""; - const openai = new OpenAI({ - apiKey, - dangerouslyAllowBrowser: true, - }); - const stream = await openai.chat.completions.create({ - model: "gpt-4o", - messages: [ - { role: "system", content: 'You are a helpful assistant.' }, - { role: "user", content: userPrompt }, - ], - stream: true, - }); - for await (const chunk of stream) { - partial += chunk.choices[0]?.delta?.content || ""; - onToken(partial, false); + + try { + 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, + }); + + 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) { + throw error; + } +} + +// Auto-migration function that runs automatically +async function autoMigrateAPIKeys() { + try { + const raw = localStorage.getItem("openai_api_key"); + + if (!raw) { + return; // No key to migrate + } + + // 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 + } + } + + // 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 + } +} + +// Helper function to get default model for a provider +function getDefaultModel(provider: string): string { + switch (provider) { + case 'openai': + return 'gpt-4o' + case 'anthropic': + return 'claude-3-5-sonnet-20241022' + default: + return 'gpt-4o' + } +} + +// 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; + } + } + } + return "" + } catch (e) { + return "" + } +} + +// 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 + } + } + return null + } catch (e) { + return null } - //console.log("Generated:", partial); - onToken(partial, true); } \ No newline at end of file