user auth via webcryptoapi, starred boards, dashboard view

This commit is contained in:
Jeff Emmett 2025-08-25 06:48:47 +02:00
parent af52e6465d
commit 18690c7129
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/auth.css"; // Import auth styles
import "@/css/crypto-auth.css"; // Import crypto auth styles import "@/css/crypto-auth.css"; // Import crypto auth styles
import "@/css/starred-boards.css"; // Import starred boards 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 { Default } from "@/routes/Default";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import { Contact } from "@/routes/Contact"; import { Contact } from "@/routes/Contact";

View File

@ -14,6 +14,9 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
const { addNotification } = useNotifications(); const { addNotification } = useNotifications();
const [isStarred, setIsStarred] = useState(false); const [isStarred, setIsStarred] = useState(false);
const [isLoading, setIsLoading] = 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 // Check if board is starred on mount and when session changes
useEffect(() => { useEffect(() => {
@ -25,6 +28,17 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
} }
}, [session.authed, session.username, slug]); }, [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 () => { const handleStarToggle = async () => {
if (!session.authed || !session.username || !slug) { if (!session.authed || !session.username || !slug) {
addNotification('Please log in to star boards', 'warning'); addNotification('Please log in to star boards', 'warning');
@ -39,23 +53,23 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
const success = unstarBoard(session.username, slug); const success = unstarBoard(session.username, slug);
if (success) { if (success) {
setIsStarred(false); setIsStarred(false);
addNotification('Board removed from starred boards', 'success'); showPopupMessage('Board removed from starred boards', 'success');
} else { } else {
addNotification('Failed to remove board from starred boards', 'error'); showPopupMessage('Failed to remove board from starred boards', 'error');
} }
} else { } else {
// Star the board // Star the board
const success = starBoard(session.username, slug, slug); const success = starBoard(session.username, slug, slug);
if (success) { if (success) {
setIsStarred(true); setIsStarred(true);
addNotification('Board added to starred boards', 'success'); showPopupMessage('Board added to starred boards', 'success');
} else { } else {
addNotification('Board is already starred', 'info'); showPopupMessage('Board is already starred', 'info');
} }
} }
} catch (error) { } catch (error) {
console.error('Error toggling star:', error); console.error('Error toggling star:', error);
addNotification('Failed to update starred boards', 'error'); showPopupMessage('Failed to update starred boards', 'error');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -67,23 +81,40 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
} }
return ( return (
<button <div style={{ position: 'relative' }}>
onClick={handleStarToggle} <button
disabled={isLoading} onClick={handleStarToggle}
className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`} disabled={isLoading}
title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'} className={`star-board-button ${className} ${isStarred ? 'starred' : ''}`}
> title={isStarred ? 'Remove from starred boards' : 'Add to starred boards'}
{isLoading ? ( >
<span className="loading-spinner"></span> {isLoading ? (
) : isStarred ? ( <span className="loading-spinner"></span>
<span className="star-icon starred"></span> ) : isStarred ? (
) : ( <span className="star-icon starred"></span>
<span className="star-icon"></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"> </div>
{isStarred ? 'Starred' : 'Star'}
</span>
</button>
); );
}; };

View File

@ -273,12 +273,13 @@
/* Responsive positioning for toolbar buttons */ /* Responsive positioning for toolbar buttons */
@media (max-width: 768px) { @media (max-width: 768px) {
.toolbar-login-button { .toolbar-login-button {
margin-right: 4px; margin-right: 0;
} }
/* Adjust toolbar container position on mobile */ /* Adjust toolbar container position on mobile */
.toolbar-container { .toolbar-container {
right: 80px !important; right: 35px !important;
gap: 4px !important;
} }
} }
@ -474,16 +475,23 @@
/* Login Button Styles */ /* Login Button Styles */
.login-button { .login-button {
padding: 6px 12px;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer; cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.2s ease; transition: all 0.2s ease;
letter-spacing: 0.5px; 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 { .login-button:hover {
@ -493,7 +501,24 @@
} }
.toolbar-login-button { .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 */ /* Login Modal Overlay */

View File

@ -2,34 +2,110 @@
.star-board-button { .star-board-button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; justify-content: center;
padding: 8px 12px; padding: 4px 8px;
background: #f8f9fa; background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border: 1px solid #e9ecef; color: white;
border-radius: 6px; border: none;
border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 0.75rem;
font-weight: 500; font-weight: 600;
color: #495057;
transition: all 0.2s ease; transition: all 0.2s ease;
letter-spacing: 0.5px;
white-space: nowrap; 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 { .star-board-button:hover {
background: #e9ecef; background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
border-color: #dee2e6; transform: translateY(-1px);
color: #212529; 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 { .star-board-button.starred {
background: #fff3cd; background: #6B7280;
border-color: #ffeaa7; color: white;
color: #856404;
} }
.star-board-button.starred:hover { .star-board-button.starred:hover {
background: #ffeaa7; background: #4B5563;
border-color: #fdcb6e; transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
} }
.star-board-button:disabled { .star-board-button:disabled {
@ -38,8 +114,16 @@
} }
.star-icon { .star-icon {
font-size: 16px; font-size: 0.8rem;
transition: transform 0.2s ease; 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 { .star-icon.starred {
@ -48,6 +132,13 @@
.loading-spinner { .loading-spinner {
animation: spin 1s linear infinite; 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 { @keyframes spin {
@ -206,7 +297,7 @@
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
transition: all 0.2s ease; transition: all 0.2s ease;
color: #ffc107; color: #6B7280;
} }
.unstar-button:hover { .unstar-button:hover {
@ -438,26 +529,48 @@
} }
.star-board-button { .star-board-button {
background: #3a3a3a; background: linear-gradient(135deg, #63b3ed 0%, #3182ce 100%);
border-color: #495057; color: white;
color: #e9ecef; border: none;
} }
.star-board-button:hover { .star-board-button:hover {
background: #495057; background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
border-color: #6c757d; color: white;
color: #f8f9fa; transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 179, 237, 0.3);
} }
.star-board-button.starred { .star-board-button.starred {
background: #664d03; background: #6B7280;
border-color: #ffc107; color: white;
color: #ffc107; border: none;
} }
.star-board-button.starred:hover { .star-board-button.starred:hover {
background: #856404; background: #4B5563;
border-color: #ffca2c; 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 { .board-screenshot {
@ -497,6 +610,15 @@
font-size: 12px; 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 { .star-text {
display: none; 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 // The presence should automatically update through the useSync configuration
// when the session changes, but we can also try to force an update // 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]) }, [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 // Track board visit for starred boards
useEffect(() => { useEffect(() => {
if (session.authed && session.username && roomId) { if (session.authed && session.username && roomId) {
@ -145,10 +181,7 @@ export function Board() {
// Only capture if content actually changed // Only capture if content actually changed
if (newShapeCount !== currentShapeCount || newContentHash !== currentContentHash) { if (newShapeCount !== currentShapeCount || newContentHash !== currentContentHash) {
console.log('Content changed, capturing screenshot');
await captureBoardScreenshot(editor, roomId); await captureBoardScreenshot(editor, roomId);
} else {
console.log('No content changes detected, skipping screenshot');
} }
}, 3000); // Wait 3 seconds to ensure changes are complete }, 3000); // Wait 3 seconds to ensure changes are complete
@ -204,6 +237,28 @@ export function Board() {
ClickPropagator, 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 // Note: User presence is configured through the useSync hook above
// The authenticated username should appear in the people section // The authenticated username should appear in the people section
}} }}

View File

@ -6,7 +6,7 @@ import {
TLShape, TLShape,
} from "tldraw" } from "tldraw"
import { getEdge } from "@/propagators/tlgraph" import { getEdge } from "@/propagators/tlgraph"
import { llm } from "@/utils/llmUtils" import { llm, getApiKey } from "@/utils/llmUtils"
import { isShapeOfType } from "@/propagators/utils" import { isShapeOfType } from "@/propagators/utils"
import React, { useState } from "react" import React, { useState } from "react"
@ -89,10 +89,15 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
}, {} as Record<string, TLShape>) }, {} as Record<string, TLShape>)
const generateText = async (prompt: string) => { const generateText = async (prompt: string) => {
console.log("🎯 generateText called with prompt:", prompt);
const conversationHistory = shape.props.value ? shape.props.value + '\n' : '' const conversationHistory = shape.props.value ? shape.props.value + '\n' : ''
const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const escapedPrompt = prompt.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const userMessage = `{"role": "user", "content": "${escapedPrompt}"}` const userMessage = `{"role": "user", "content": "${escapedPrompt}"}`
console.log("💬 User message:", userMessage);
console.log("📚 Conversation history:", conversationHistory);
// Update with user message and trigger scroll // Update with user message and trigger scroll
this.editor.updateShape<IPrompt>({ this.editor.updateShape<IPrompt>({
id: shape.id, id: shape.id,
@ -105,34 +110,45 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
let fullResponse = '' let fullResponse = ''
await llm(prompt, localStorage.getItem("openai_api_key") || "", (partial: string, done: boolean) => { console.log("🚀 Calling llm function...");
if (partial) { try {
fullResponse = partial await llm(prompt, (partial: string, done?: boolean) => {
const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') console.log(`📝 LLM callback received - partial: "${partial}", done: ${done}`);
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` if (partial) {
fullResponse = partial
try { const escapedResponse = partial.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
JSON.parse(assistantMessage) const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
// Use requestAnimationFrame to ensure smooth scrolling during streaming console.log("🤖 Assistant message:", assistantMessage);
requestAnimationFrame(() => {
this.editor.updateShape<IPrompt>({ try {
id: shape.id, JSON.parse(assistantMessage)
type: "Prompt",
props: { // Use requestAnimationFrame to ensure smooth scrolling during streaming
value: conversationHistory + userMessage + '\n' + assistantMessage, requestAnimationFrame(() => {
agentBinding: done ? null : "someone" 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) {
} catch (error) { console.error('❌ Invalid JSON message:', 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 // Ensure the final message is saved after streaming is complete
if (fullResponse) { if (fullResponse) {
console.log("💾 Saving final response:", fullResponse);
const escapedResponse = fullResponse.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n') const escapedResponse = fullResponse.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n')
const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}` const assistantMessage = `{"role": "assistant", "content": "${escapedResponse}"}`
@ -148,8 +164,9 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
agentBinding: null agentBinding: null
}, },
}) })
console.log("✅ Final response saved successfully");
} catch (error) { } 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 { try {
if (settings) { if (settings) {
try { try {
const { keys } = JSON.parse(settings) const parsed = JSON.parse(settings)
const hasValidKey = keys && Object.values(keys).some(key => typeof key === 'string' && key.trim() !== '') if (parsed.keys) {
setHasApiKey(hasValidKey) // 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) { } catch (e) {
// Fallback to old format
const hasValidKey = typeof settings === 'string' && settings.trim() !== '' const hasValidKey = typeof settings === 'string' && settings.trim() !== ''
setHasApiKey(hasValidKey) setHasApiKey(hasValidKey)
} }
@ -65,73 +75,47 @@ export function CustomToolbar() {
setShowProfilePopup(false) 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 if (!isReady) return null
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<div <div
className="toolbar-container" className="toolbar-container"
style={{ style={{
position: "fixed", position: "fixed",
top: "4px", top: "4px",
right: "120px", right: "40px",
zIndex: 99999, zIndex: 99999,
pointerEvents: "auto", pointerEvents: "auto",
display: "flex", display: "flex",
gap: "8px", gap: "6px",
alignItems: "center", alignItems: "center",
}} }}
> >
<LoginButton className="toolbar-login-button" /> <LoginButton className="toolbar-login-button" />
<StarBoardButton className="toolbar-star-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>
{session.authed && ( {session.authed && (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button <button
onClick={() => setShowProfilePopup(!showProfilePopup)} onClick={() => setShowProfilePopup(!showProfilePopup)}
style={{ style={{
padding: "8px 16px", padding: "4px 8px",
borderRadius: "4px", borderRadius: "4px",
background: "#6B7280", background: "#6B7280",
color: "white", color: "white",
@ -142,6 +126,13 @@ export function CustomToolbar() {
boxShadow: "0 2px 4px rgba(0,0,0,0.1)", boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap", whiteSpace: "nowrap",
userSelect: "none", userSelect: "none",
display: "flex",
alignItems: "center",
gap: "6px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = "#4B5563" e.currentTarget.style.background = "#4B5563"
@ -150,94 +141,151 @@ export function CustomToolbar() {
e.currentTarget.style.background = "#6B7280" e.currentTarget.style.background = "#6B7280"
}} }}
> >
{session.username} <span style={{ fontSize: "12px" }}>
{hasApiKey ? "🔑" : "❌"}
</span>
<span>{session.username}</span>
</button> </button>
{showProfilePopup && ( {showProfilePopup && (
<div <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"
style={{ style={{
display: "block", position: "absolute",
width: "100%", top: "40px",
padding: "8px 12px", right: "0",
backgroundColor: "#3B82F6", width: "250px",
color: "white", backgroundColor: "white",
border: "none",
borderRadius: "4px", borderRadius: "4px",
cursor: "pointer", boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
fontWeight: 500, padding: "16px",
textDecoration: "none", zIndex: 100000,
textAlign: "center",
marginBottom: "8px",
transition: "background 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2563EB"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#3B82F6"
}} }}
> >
My Dashboard <div style={{ marginBottom: "12px", fontWeight: "bold" }}>
</a> Hello, {session.username}!
{!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> </div>
)}
{/* API Key Status */}
<button <div style={{
onClick={handleLogout} marginBottom: "16px",
style={{ padding: "12px",
width: "100%", backgroundColor: hasApiKey ? "#f0f9ff" : "#fef2f2",
padding: "8px 12px",
backgroundColor: "#EF4444",
color: "white",
border: "none",
borderRadius: "4px", borderRadius: "4px",
cursor: "pointer", border: `1px solid ${hasApiKey ? "#0ea5e9" : "#f87171"}`
fontWeight: 500, }}>
transition: "background 0.2s", <div style={{
}} display: "flex",
onMouseEnter={(e) => { alignItems: "center",
e.currentTarget.style.backgroundColor = "#DC2626" justifyContent: "space-between",
}} marginBottom: "8px"
onMouseLeave={(e) => { }}>
e.currentTarget.style.backgroundColor = "#EF4444" <span style={{ fontWeight: "500" }}>AI API Keys</span>
}} <span style={{ fontSize: "14px" }}>
> {hasApiKey ? "✅ Configured" : "❌ Not configured"}
Sign Out </span>
</button> </div>
</div> <p style={{
)} fontSize: "12px",
</div> 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> </div>
<DefaultToolbar> <DefaultToolbar>

View File

@ -10,31 +10,124 @@ import {
TldrawUiInput, TldrawUiInput,
} from "tldraw" } from "tldraw"
import React from "react" import React from "react"
import { PROVIDERS } from "../lib/settings"
export function SettingsDialog({ onClose }: TLUiDialogProps) { export function SettingsDialog({ onClose }: TLUiDialogProps) {
const [apiKey, setApiKey] = React.useState(() => { const [apiKeys, setApiKeys] = React.useState(() => {
return localStorage.getItem("openai_api_key") || "" 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) => { const handleKeyChange = (provider: string, value: string) => {
setApiKey(value) const newKeys = { ...apiKeys, [provider]: value }
localStorage.setItem("openai_api_key", 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 ( return (
<> <>
<TldrawUiDialogHeader> <TldrawUiDialogHeader>
<TldrawUiDialogTitle>API Keys</TldrawUiDialogTitle> <TldrawUiDialogTitle>AI API Keys</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton /> <TldrawUiDialogCloseButton />
</TldrawUiDialogHeader> </TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 350 }}> <TldrawUiDialogBody style={{ maxWidth: 400 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<label>OpenAI API Key</label> {PROVIDERS.map((provider) => (
<TldrawUiInput <div key={provider.id} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
value={apiKey} <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
placeholder="Enter your OpenAI API key" <label style={{ fontWeight: "500", fontSize: "14px" }}>
onValueChange={handleChange} {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> </div>
</TldrawUiDialogBody> </TldrawUiDialogBody>
<TldrawUiDialogFooter> <TldrawUiDialogFooter>

View File

@ -19,7 +19,7 @@ import { EmbedShape, IEmbedShape } from "@/shapes/EmbedShapeUtil"
import { moveToSlide } from "@/slides/useSlides" import { moveToSlide } from "@/slides/useSlides"
import { ISlideShape } from "@/shapes/SlideShapeUtil" import { ISlideShape } from "@/shapes/SlideShapeUtil"
import { getEdge } from "@/propagators/tlgraph" import { getEdge } from "@/propagators/tlgraph"
import { llm } from "@/utils/llmUtils" import { llm, getApiKey } from "@/utils/llmUtils"
export const overrides: TLUiOverrides = { export const overrides: TLUiOverrides = {
tools(editor, tools) { tools(editor, tools) {
@ -312,17 +312,26 @@ export const overrides: TLUiOverrides = {
llm: { llm: {
id: "llm", id: "llm",
label: "Run LLM Prompt", label: "Run LLM Prompt",
kbd: "g", kbd: "alt+g",
readonlyOk: true, readonlyOk: true,
onSelect: () => { onSelect: () => {
const selectedShapes = editor.getSelectedShapes() const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length > 0) { if (selectedShapes.length > 0) {
const selectedShape = selectedShapes[0] as TLArrowShape const selectedShape = selectedShapes[0] as TLArrowShape
if (selectedShape.type !== "arrow") { if (selectedShape.type !== "arrow") {
return return
} }
const edge = getEdge(selectedShape, editor) const edge = getEdge(selectedShape, editor)
if (!edge) { if (!edge) {
return return
} }
const sourceShape = editor.getShape(edge.from) const sourceShape = editor.getShape(edge.from)
@ -330,11 +339,15 @@ export const overrides: TLUiOverrides = {
sourceShape && sourceShape.type === "geo" sourceShape && sourceShape.type === "geo"
? (sourceShape as TLGeoShape).props.text ? (sourceShape as TLGeoShape).props.text
: "" : ""
llm(
`Instruction: ${edge.text}
${sourceText ? `Context: ${sourceText}` : ""}`, const prompt = `Instruction: ${edge.text}
localStorage.getItem("openai_api_key") || "", ${sourceText ? `Context: ${sourceText}` : ""}`;
(partialResponse: string) => {
try {
llm(prompt, (partialResponse: string) => {
editor.updateShape({ editor.updateShape({
id: edge.to, id: edge.to,
type: "geo", type: "geo",
@ -343,8 +356,13 @@ export const overrides: TLUiOverrides = {
text: partialResponse, text: partialResponse,
}, },
}) })
},
) })
} catch (error) {
console.error("Error calling LLM:", error);
}
} else {
} }
}, },
}, },

View File

@ -1,33 +1,283 @@
import OpenAI from "openai"; import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import { makeRealSettings } from "@/lib/settings";
export async function llm( export async function llm(
//systemPrompt: string,
userPrompt: string, userPrompt: string,
apiKey: string, onToken: (partialResponse: string, done?: boolean) => void,
onToken: (partialResponse: string, done: boolean) => void,
) { ) {
if (!apiKey) { // Validate the callback function
throw new Error("No API key found") 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 = ""; let partial = "";
const openai = new OpenAI({
apiKey, try {
dangerouslyAllowBrowser: true, if (provider === 'openai') {
}); const openai = new OpenAI({
const stream = await openai.chat.completions.create({ apiKey,
model: "gpt-4o", dangerouslyAllowBrowser: true,
messages: [ });
{ role: "system", content: 'You are a helpful assistant.' },
{ role: "user", content: userPrompt }, const stream = await openai.chat.completions.create({
], model: model,
stream: true, messages: [
}); { role: "system", content: 'You are a helpful assistant.' },
for await (const chunk of stream) { { role: "user", content: userPrompt },
partial += chunk.choices[0]?.delta?.content || ""; ],
onToken(partial, false); 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);
} }