user auth via webcryptoapi, starred boards, dashboard view

This commit is contained in:
Jeff Emmett 2025-08-25 06:48:47 +02:00
parent f949f323de
commit 956463d43f
11 changed files with 1008 additions and 271 deletions

View File

@ -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";

View File

@ -14,6 +14,9 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ 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<StarBoardButtonProps> = ({ 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<StarBoardButtonProps> = ({ 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<StarBoardButtonProps> = ({ className = '' }) =>
}
return (
<button
onClick={handleStarToggle}
disabled={isLoading}
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
>
{isLoading ? (
<span className="loading-spinner"></span>
) : isStarred ? (
<span className="star-icon starred"></span>
) : (
<span className="star-icon"></span>
<div style={{ position: 'relative' }}>
<button
onClick={handleStarToggle}
disabled={isLoading}
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
>
{isLoading ? (
<span className="loading-spinner"></span>
) : isStarred ? (
<span className="star-icon starred"></span>
) : (
<span className="star-icon"></span>
)}
</button>
{/* Custom popup notification */}
{showPopup && (
<div
className={`star-popup star-popup-${popupType}`}
style={{
position: 'absolute',
bottom: '40px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 100001,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{popupMessage}
</div>
)}
<span className="star-text">
{isStarred ? 'Starred' : 'Star'}
</span>
</button>
</div>
);
};

View File

@ -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 */

View File

@ -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;
}

77
src/css/user-profile.css Normal file
View File

@ -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;
}
}

View File

@ -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
}}

View File

@ -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<IPrompt> {
}, {} as Record<string, TLShape>)
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<IPrompt>({
id: shape.id,
@ -105,34 +110,45 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
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<IPrompt>({
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<IPrompt>({
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<IPrompt> {
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)
}
}
}

View File

@ -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 }) => (
<SettingsDialog
onClose={() => {
onClose()
removeDialog("api-keys")
checkApiKeys() // Refresh API key status
}}
/>
),
})
}
if (!isReady) return null
return (
<div style={{ position: "relative" }}>
<div
className="toolbar-container"
style={{
position: "fixed",
top: "4px",
right: "120px",
zIndex: 99999,
pointerEvents: "auto",
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<LoginButton className="toolbar-login-button" />
<StarBoardButton className="toolbar-star-button" />
<button
onClick={() => {
addDialog({
id: "api-keys",
component: ({ onClose }: { onClose: () => void }) => (
<SettingsDialog
onClose={() => {
onClose()
removeDialog("api-keys")
const settings = localStorage.getItem("openai_api_key")
if (settings) {
const { keys } = JSON.parse(settings)
setHasApiKey(Object.values(keys).some((key) => key))
}
}}
/>
),
})
}}
style={{
padding: "8px 16px",
borderRadius: "4px",
background: hasApiKey ? "#6B7280" : "#2F80ED",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = hasApiKey ? "#4B5563" : "#1366D6"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = hasApiKey ? "#6B7280" : "#2F80ED"
}}
>
Keys {hasApiKey ? "✅" : "❌"}
</button>
<div
className="toolbar-container"
style={{
position: "fixed",
top: "4px",
right: "40px",
zIndex: 99999,
pointerEvents: "auto",
display: "flex",
gap: "6px",
alignItems: "center",
}}
>
<LoginButton className="toolbar-login-button" />
<StarBoardButton className="toolbar-star-button" />
{session.authed && (
<div style={{ position: "relative" }}>
<button
onClick={() => setShowProfilePopup(!showProfilePopup)}
style={{
padding: "8px 16px",
padding: "4px 8px",
borderRadius: "4px",
background: "#6B7280",
color: "white",
@ -142,6 +126,13 @@ export function CustomToolbar() {
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "6px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#4B5563"
@ -150,94 +141,151 @@ export function CustomToolbar() {
e.currentTarget.style.background = "#6B7280"
}}
>
{session.username}
<span style={{ fontSize: "12px" }}>
{hasApiKey ? "🔑" : "❌"}
</span>
<span>{session.username}</span>
</button>
{showProfilePopup && (
<div
style={{
position: "absolute",
top: "40px",
right: "0",
width: "200px",
backgroundColor: "white",
borderRadius: "4px",
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
padding: "16px",
zIndex: 100000,
}}
>
<div style={{ marginBottom: "12px", fontWeight: "bold" }}>
Hello, {session.username}!
</div>
<a
href="/dashboard"
target="_blank"
rel="noopener noreferrer"
{showProfilePopup && (
<div
style={{
display: "block",
width: "100%",
padding: "8px 12px",
backgroundColor: "#3B82F6",
color: "white",
border: "none",
position: "absolute",
top: "40px",
right: "0",
width: "250px",
backgroundColor: "white",
borderRadius: "4px",
cursor: "pointer",
fontWeight: 500,
textDecoration: "none",
textAlign: "center",
marginBottom: "8px",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
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
</a>
{!session.backupCreated && (
<div style={{
marginBottom: "12px",
fontSize: "12px",
color: "#666",
padding: "8px",
backgroundColor: "#f8f8f8",
borderRadius: "4px"
}}>
Remember to back up your encryption keys to prevent data loss!
<div style={{ marginBottom: "12px", fontWeight: "bold" }}>
Hello, {session.username}!
</div>
)}
<button
onClick={handleLogout}
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "#EF4444",
color: "white",
border: "none",
{/* API Key Status */}
<div style={{
marginBottom: "16px",
padding: "12px",
backgroundColor: hasApiKey ? "#f0f9ff" : "#fef2f2",
borderRadius: "4px",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#DC2626"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#EF4444"
}}
>
Sign Out
</button>
</div>
)}
</div>
border: `1px solid ${hasApiKey ? "#0ea5e9" : "#f87171"}`
}}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "8px"
}}>
<span style={{ fontWeight: "500" }}>AI API Keys</span>
<span style={{ fontSize: "14px" }}>
{hasApiKey ? "✅ Configured" : "❌ Not configured"}
</span>
</div>
<p style={{
fontSize: "12px",
color: "#666",
margin: "0 0 8px 0"
}}>
{hasApiKey
? "Your AI models are ready to use"
: "Configure API keys to use AI features"
}
</p>
<button
onClick={openApiKeysDialog}
style={{
width: "100%",
padding: "6px 12px",
backgroundColor: hasApiKey ? "#0ea5e9" : "#ef4444",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "500",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = hasApiKey ? "#0284c7" : "#dc2626"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = hasApiKey ? "#0ea5e9" : "#ef4444"
}}
>
{hasApiKey ? "Manage Keys" : "Add API Keys"}
</button>
</div>
<a
href="/dashboard"
target="_blank"
rel="noopener noreferrer"
style={{
display: "block",
width: "100%",
padding: "8px 12px",
backgroundColor: "#3B82F6",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontWeight: "500",
textDecoration: "none",
textAlign: "center",
marginBottom: "8px",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2563EB"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#3B82F6"
}}
>
My Dashboard
</a>
{!session.backupCreated && (
<div style={{
marginBottom: "12px",
fontSize: "12px",
color: "#666",
padding: "8px",
backgroundColor: "#f8f8f8",
borderRadius: "4px"
}}>
Remember to back up your encryption keys to prevent data loss!
</div>
)}
<button
onClick={handleLogout}
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "#EF4444",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontWeight: "500",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#DC2626"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#EF4444"
}}
>
Sign Out
</button>
</div>
)}
</div>
)}
</div>
<DefaultToolbar>

View File

@ -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 (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>API Keys</TldrawUiDialogTitle>
<TldrawUiDialogTitle>AI API Keys</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label>OpenAI API Key</label>
<TldrawUiInput
value={apiKey}
placeholder="Enter your OpenAI API key"
onValueChange={handleChange}
/>
<TldrawUiDialogBody style={{ maxWidth: 400 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{PROVIDERS.map((provider) => (
<div key={provider.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<label style={{ fontWeight: "500", fontSize: "14px" }}>
{provider.name} API Key
</label>
<span style={{
fontSize: "12px",
color: "#666",
backgroundColor: "#f3f4f6",
padding: "2px 6px",
borderRadius: "4px"
}}>
{provider.models[0]}
</span>
</div>
<TldrawUiInput
value={apiKeys[provider.id] || ''}
placeholder={`Enter your ${provider.name} API key`}
onValueChange={(value) => 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]) && (
<div style={{
fontSize: "12px",
color: "#ef4444",
marginTop: "4px"
}}>
Invalid API key format
</div>
)}
<div style={{
fontSize: "11px",
color: "#666",
lineHeight: "1.4"
}}>
{provider.help && (
<a
href={provider.help}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#3b82f6", textDecoration: "none" }}
>
Learn more about {provider.name} setup
</a>
)}
</div>
</div>
))}
<div style={{
padding: "12px",
backgroundColor: "#f8fafc",
borderRadius: "6px",
border: "1px solid #e2e8f0"
}}>
<div style={{ fontSize: "12px", color: "#475569", lineHeight: "1.4" }}>
<strong>Note:</strong> API keys are stored locally in your browser.
Make sure to use keys with appropriate usage limits for your needs.
</div>
</div>
</div>
</TldrawUiDialogBody>
<TldrawUiDialogFooter>

View File

@ -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 {
}
},
},

View File

@ -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);
}