user auth via webcryptoapi, starred boards, dashboard view
This commit is contained in:
parent
f949f323de
commit
956463d43f
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue