total-recall-brain 3.5.0 â 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +3 -0
- package/extension/content-script.js +354 -11
- package/extension/popup/popup.html +5 -0
- package/extension/popup/popup.js +25 -0
- package/frontend/src/App.tsx +29 -0
- package/frontend/src/api.ts +20 -0
- package/frontend/src/pages/ChatPage.tsx +101 -8
- package/frontend/src/pages/CollabPage.tsx +470 -0
- package/frontend/src/pages/GraphPage.tsx +62 -0
- package/frontend/src/pages/HealthPage.tsx +6 -1
- package/frontend/src/pages/HelpPage.tsx +141 -0
- package/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -1
- package/src/cli/collab.mjs +49 -0
- package/src/cli/help.mjs +1 -1
- package/src/core/dream.mjs +41 -0
- package/src/core/runtime.mjs +19 -13
- package/src/core/source-adapters.mjs +14 -6
- package/src/server/index.mjs +21 -1
- package/src/server/rest.mjs +49 -1
- package/src/server/route-manifest.json +4 -0
- package/src/server/routes/collab.mjs +286 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { sendChat, createTask, listTasks, fetchTtsStatus, fetchTtsAudio, fetchChatHistory, fetchChatThreads, deleteChatThread, listMemory, listResearch, fetchHealth, fetchGeminiModels, shareToApi, fetchExtensionStatus, checkUpdate } from '../api'
|
|
3
4
|
import type { ChatThread } from '../api'
|
|
4
|
-
import type { ChatMessage, MemoryNode, ResearchItem } from '../types'
|
|
5
|
+
import type { ChatMessage, MemoryNode, ResearchItem, UpdateCheckResult } from '../types'
|
|
5
6
|
import Graph3D from '../components/Graph3D'
|
|
6
7
|
|
|
7
8
|
|
|
@@ -23,6 +24,7 @@ function formatRelativeTime(timestamp: number): string {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrainId?: string; onBrainChange?: (id: string) => void }) {
|
|
27
|
+
const navigate = useNavigate()
|
|
26
28
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
27
29
|
const [input, setInput] = useState('')
|
|
28
30
|
const [loading, setLoading] = useState(false)
|
|
@@ -33,6 +35,7 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
|
|
|
33
35
|
|
|
34
36
|
const [voiceMode, setVoiceMode] = useState(false)
|
|
35
37
|
const [kokoroEnabled, setKokoroEnabled] = useState<boolean | null>(null)
|
|
38
|
+
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null)
|
|
36
39
|
const [deepResearchMode, setDeepResearchMode] = useState(false)
|
|
37
40
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null)
|
|
38
41
|
const [urlToast, setUrlToast] = useState<string | null>(null)
|
|
@@ -149,6 +152,24 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
|
|
|
149
152
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
|
|
150
153
|
}, [])
|
|
151
154
|
|
|
155
|
+
// Periodic update check
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
let active = true
|
|
158
|
+
const check = () => {
|
|
159
|
+
checkUpdate()
|
|
160
|
+
.then(data => {
|
|
161
|
+
if (active) setUpdateInfo(data)
|
|
162
|
+
})
|
|
163
|
+
.catch(console.error)
|
|
164
|
+
}
|
|
165
|
+
check()
|
|
166
|
+
const interval = setInterval(check, 3600000)
|
|
167
|
+
return () => {
|
|
168
|
+
active = false
|
|
169
|
+
clearInterval(interval)
|
|
170
|
+
}
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
152
173
|
// Reload memory nodes when active selected brain changes
|
|
153
174
|
useEffect(() => {
|
|
154
175
|
listMemory(activeBrainId)
|
|
@@ -564,6 +585,47 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
|
|
|
564
585
|
</div>
|
|
565
586
|
</header>
|
|
566
587
|
|
|
588
|
+
{/* Update Available Banner */}
|
|
589
|
+
{updateInfo?.updateAvailable && (
|
|
590
|
+
<div className="animate-fade-in" style={{
|
|
591
|
+
margin: '0 16px 12px',
|
|
592
|
+
padding: '12px 16px',
|
|
593
|
+
background: 'linear-gradient(135deg, rgba(63, 185, 80, 0.12), rgba(34, 197, 94, 0.12))',
|
|
594
|
+
border: '1px solid rgba(63, 185, 80, 0.3)',
|
|
595
|
+
borderRadius: 10,
|
|
596
|
+
display: 'flex',
|
|
597
|
+
alignItems: 'center',
|
|
598
|
+
gap: 12,
|
|
599
|
+
}}>
|
|
600
|
+
<span style={{ fontSize: 22, flexShrink: 0 }}>đ</span>
|
|
601
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
602
|
+
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
|
|
603
|
+
System Update Available
|
|
604
|
+
</div>
|
|
605
|
+
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
|
|
606
|
+
Version {updateInfo.latestVersion} is available. Upgrade to load the latest features.
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
<button
|
|
610
|
+
onClick={() => navigate('/health')}
|
|
611
|
+
style={{
|
|
612
|
+
flexShrink: 0,
|
|
613
|
+
padding: '6px 14px',
|
|
614
|
+
background: '#3fb950',
|
|
615
|
+
color: '#fff',
|
|
616
|
+
border: 'none',
|
|
617
|
+
borderRadius: 6,
|
|
618
|
+
fontWeight: 600,
|
|
619
|
+
fontSize: 12,
|
|
620
|
+
cursor: 'pointer',
|
|
621
|
+
whiteSpace: 'nowrap'
|
|
622
|
+
}}
|
|
623
|
+
>
|
|
624
|
+
Go to Update
|
|
625
|
+
</button>
|
|
626
|
+
</div>
|
|
627
|
+
)}
|
|
628
|
+
|
|
567
629
|
{/* Chrome Extension Banner â shows when available but not connected */}
|
|
568
630
|
{extensionStatus?.available && !extensionStatus.connected && !extensionBannerDismissed && (
|
|
569
631
|
<div style={{
|
|
@@ -623,8 +685,18 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
|
|
|
623
685
|
</div>
|
|
624
686
|
)}
|
|
625
687
|
|
|
626
|
-
<div className="chat-messages">
|
|
627
|
-
{
|
|
688
|
+
<div className="chat-messages" style={{ position: 'relative' }}>
|
|
689
|
+
<div style={{
|
|
690
|
+
position: 'absolute',
|
|
691
|
+
top: 0,
|
|
692
|
+
left: 0,
|
|
693
|
+
right: 0,
|
|
694
|
+
bottom: 0,
|
|
695
|
+
zIndex: 1,
|
|
696
|
+
pointerEvents: messages.length > 0 ? 'none' : 'auto',
|
|
697
|
+
opacity: messages.length > 0 ? 0.35 : 1,
|
|
698
|
+
transition: 'opacity 0.3s ease'
|
|
699
|
+
}}>
|
|
628
700
|
<Graph3D
|
|
629
701
|
threads={threads}
|
|
630
702
|
memoryNodes={allMemoryNodes}
|
|
@@ -637,9 +709,24 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
|
|
|
637
709
|
}}
|
|
638
710
|
selectedGroundingNodes={selectedGroundingNodes}
|
|
639
711
|
/>
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
712
|
+
</div>
|
|
713
|
+
|
|
714
|
+
<div style={{
|
|
715
|
+
position: 'relative',
|
|
716
|
+
zIndex: 2,
|
|
717
|
+
display: 'flex',
|
|
718
|
+
flexDirection: 'column',
|
|
719
|
+
gap: '16px',
|
|
720
|
+
width: '100%',
|
|
721
|
+
pointerEvents: 'auto'
|
|
722
|
+
}}>
|
|
723
|
+
{messages.map((m, index) => (
|
|
724
|
+
<div key={m.id} className={`message message-${m.role}`} style={{
|
|
725
|
+
backdropFilter: 'blur(8px)',
|
|
726
|
+
WebkitBackdropFilter: 'blur(8px)',
|
|
727
|
+
background: m.role === 'user' ? 'rgba(108, 92, 231, 0.75)' : 'rgba(18, 18, 26, 0.75)',
|
|
728
|
+
border: '1px solid rgba(255, 255, 255, 0.08)'
|
|
729
|
+
}}>
|
|
643
730
|
<div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' }}>{m.content}</div>
|
|
644
731
|
{m.role === 'assistant' && (
|
|
645
732
|
<div style={{ display: 'flex', gap: 12, marginTop: 12, fontSize: 13, color: 'var(--text-tertiary)', alignItems: 'center', userSelect: 'none' }}>
|
|
@@ -672,11 +759,17 @@ export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrain
|
|
|
672
759
|
</div>
|
|
673
760
|
))}
|
|
674
761
|
{loading && (
|
|
675
|
-
<div className="message message-assistant"
|
|
762
|
+
<div className="message message-assistant" style={{
|
|
763
|
+
backdropFilter: 'blur(8px)',
|
|
764
|
+
WebkitBackdropFilter: 'blur(8px)',
|
|
765
|
+
background: 'rgba(18, 18, 26, 0.75)',
|
|
766
|
+
border: '1px solid rgba(255, 255, 255, 0.08)'
|
|
767
|
+
}}>
|
|
676
768
|
<span className="skeleton" style={{ display: 'inline-block', width: 180, height: 16 }} />
|
|
677
769
|
</div>
|
|
678
770
|
)}
|
|
679
771
|
<div ref={messagesEnd} />
|
|
772
|
+
</div>
|
|
680
773
|
</div>
|
|
681
774
|
<div className="chat-input-bar">
|
|
682
775
|
{selectedGroundingNodes.length > 0 && (
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
// âââ Interfaces ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
4
|
+
export interface CollabGroup {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
code: string;
|
|
8
|
+
owner: string;
|
|
9
|
+
members: string[];
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Annotation {
|
|
14
|
+
id: string;
|
|
15
|
+
url: string;
|
|
16
|
+
groupCode: string;
|
|
17
|
+
author: string;
|
|
18
|
+
text: string;
|
|
19
|
+
excerpt: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ChatMessage {
|
|
24
|
+
username: string;
|
|
25
|
+
text: string;
|
|
26
|
+
created_at: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// âââ Component: AuthView âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
30
|
+
function CollabAuthView({ onAuthSuccess }: { onAuthSuccess: (token: string, username: string) => void }) {
|
|
31
|
+
const [isLogin, setIsLogin] = useState(true);
|
|
32
|
+
const [username, setUsername] = useState('');
|
|
33
|
+
const [password, setPassword] = useState('');
|
|
34
|
+
const [error, setError] = useState('');
|
|
35
|
+
const [loading, setLoading] = useState(false);
|
|
36
|
+
|
|
37
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
if (!username || !password) {
|
|
40
|
+
setError('Please fill in all fields');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
setError('');
|
|
44
|
+
setLoading(true);
|
|
45
|
+
const endpoint = isLogin ? '/api/collab/auth/login' : '/api/collab/auth/register';
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(endpoint, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ username, password }),
|
|
52
|
+
});
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
if (!response.ok) throw new Error(data.error || 'Authentication failed');
|
|
55
|
+
onAuthSuccess(data.token, data.username);
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
setError((err as Error).message || 'Connection error');
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '20px' }}>
|
|
65
|
+
<div className="glass-panel" style={{ width: '100%', maxWidth: '400px', padding: '30px', background: 'rgba(255,255,255,0.02)', border: '1px solid var(--border)', borderRadius: '12px' }}>
|
|
66
|
+
<h2 style={{ fontSize: '22px', marginBottom: '8px', textAlign: 'center' }}>Total Recall Collaboration</h2>
|
|
67
|
+
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', textAlign: 'center', marginBottom: '24px' }}>Sign in to connect with team workspaces</p>
|
|
68
|
+
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
69
|
+
<div>
|
|
70
|
+
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '6px' }}>Username</label>
|
|
71
|
+
<input type="text" value={username} onChange={e => setUsername(e.target.value)} disabled={loading} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '8px 12px', borderRadius: '6px', width: '100%' }} />
|
|
72
|
+
</div>
|
|
73
|
+
<div>
|
|
74
|
+
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '6px' }}>Password</label>
|
|
75
|
+
<input type="password" value={password} onChange={e => setPassword(e.target.value)} disabled={loading} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '8px 12px', borderRadius: '6px', width: '100%' }} />
|
|
76
|
+
</div>
|
|
77
|
+
{error && <div style={{ padding: '8px', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: '6px', color: '#ef4444', fontSize: '12px', textAlign: 'center' }}>{error}</div>}
|
|
78
|
+
<button type="submit" className="btn btn-purple" style={{ width: '100%', padding: '10px' }} disabled={loading}>
|
|
79
|
+
{loading ? 'Processing...' : isLogin ? 'Access Collab Hub' : 'Register Collab Account'}
|
|
80
|
+
</button>
|
|
81
|
+
</form>
|
|
82
|
+
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '13px' }}>
|
|
83
|
+
<button onClick={() => { setIsLogin(!isLogin); setError(''); }} style={{ background: 'none', border: 'none', color: 'var(--purple-primary)', cursor: 'pointer' }}>
|
|
84
|
+
{isLogin ? 'Create an account instead' : 'Already have an account? Login'}
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// âââ Component: CollabDashboard âââââââââââââââââââââââââââââââââââââââââââââââ
|
|
93
|
+
function CollabDashboard({ token, username, onLogout, onSelectSandbox }: { token: string, username: string, onLogout: () => void, onSelectSandbox: () => void }) {
|
|
94
|
+
const [groups, setGroups] = useState<CollabGroup[]>([]);
|
|
95
|
+
const [newGroupName, setNewGroupName] = useState('');
|
|
96
|
+
const [joinCode, setJoinCode] = useState('');
|
|
97
|
+
const [error, setError] = useState('');
|
|
98
|
+
const [success, setSuccess] = useState('');
|
|
99
|
+
const [copiedCode, setCopiedCode] = useState<string | null>(null); const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
let active = true;
|
|
103
|
+
const fetchGroups = async () => {
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch('/api/collab/groups', {
|
|
106
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
107
|
+
});
|
|
108
|
+
if (response.ok && active) {
|
|
109
|
+
setGroups(await response.json());
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(err);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
fetchGroups();
|
|
116
|
+
return () => { active = false; };
|
|
117
|
+
}, [token, refreshTrigger]);
|
|
118
|
+
|
|
119
|
+
const handleCreate = async (e: React.FormEvent) => {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
if (!newGroupName.trim()) return;
|
|
122
|
+
setError(''); setSuccess('');
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch('/api/collab/groups', {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
127
|
+
body: JSON.stringify({ name: newGroupName }),
|
|
128
|
+
});
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
if (!response.ok) throw new Error(data.error);
|
|
131
|
+
setSuccess(`Group "${data.name}" created!`);
|
|
132
|
+
setNewGroupName('');
|
|
133
|
+
setRefreshTrigger(prev => prev + 1);
|
|
134
|
+
} catch (err: unknown) { setError((err as Error).message); }
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleJoin = async (e: React.FormEvent) => {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
if (!joinCode.trim()) return;
|
|
140
|
+
setError(''); setSuccess('');
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch('/api/collab/groups/join', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
145
|
+
body: JSON.stringify({ code: joinCode }),
|
|
146
|
+
});
|
|
147
|
+
const data = await response.json();
|
|
148
|
+
if (!response.ok) throw new Error(data.error);
|
|
149
|
+
setSuccess(`Joined group "${data.group.name}"!`);
|
|
150
|
+
setJoinCode('');
|
|
151
|
+
setRefreshTrigger(prev => prev + 1);
|
|
152
|
+
} catch (err: unknown) { setError((err as Error).message); }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const copyCode = (code: string) => {
|
|
156
|
+
navigator.clipboard.writeText(code);
|
|
157
|
+
setCopiedCode(code);
|
|
158
|
+
setTimeout(() => setCopiedCode(null), 2000);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
163
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.02)', padding: '16px 20px', borderRadius: '8px', border: '1px solid var(--border)' }}>
|
|
164
|
+
<div>
|
|
165
|
+
<h2 style={{ fontSize: '18px' }}>Collab Spaces for <span style={{ color: 'var(--purple-primary)' }}>{username}</span></h2>
|
|
166
|
+
<p style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>Share annotations and real-time page chat rooms</p>
|
|
167
|
+
</div>
|
|
168
|
+
<div style={{ display: 'flex', gap: '10px' }}>
|
|
169
|
+
<button className="btn btn-purple" onClick={onSelectSandbox}>đ Open Page Sandbox</button>
|
|
170
|
+
<button className="btn btn-ghost" onClick={onLogout} style={{ color: '#f87171' }}>Logout</button>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '20px' }}>
|
|
175
|
+
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', padding: '20px', borderRadius: '8px' }}>
|
|
176
|
+
<h3 style={{ fontSize: '15px', marginBottom: '16px' }}>My Workspaces ({groups.length})</h3>
|
|
177
|
+
{groups.length === 0 ? (
|
|
178
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>No collaboration groups yet. Create or join one.</p>
|
|
179
|
+
) : (
|
|
180
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
181
|
+
{groups.map(g => (
|
|
182
|
+
<div key={g.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.02)', padding: '12px 16px', borderRadius: '6px', border: '1px solid var(--border)' }}>
|
|
183
|
+
<div>
|
|
184
|
+
<h4 style={{ fontSize: '14px', color: '#fff' }}>{g.name}</h4>
|
|
185
|
+
<p style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>Members: {g.members.length} âĸ Invite Code: <strong style={{ color: 'var(--purple-primary)' }}>{g.code}</strong></p>
|
|
186
|
+
</div>
|
|
187
|
+
<button className="btn btn-ghost btn-sm" onClick={() => copyCode(g.code)}>{copiedCode === g.code ? 'Copied' : 'Copy Code'}</button>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
195
|
+
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', padding: '16px', borderRadius: '8px' }}>
|
|
196
|
+
<h3 style={{ fontSize: '14px', marginBottom: '12px' }}>Join a Space</h3>
|
|
197
|
+
<form onSubmit={handleJoin} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
198
|
+
<input type="text" placeholder="Invite Code" value={joinCode} onChange={e => setJoinCode(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', fontSize: '13px' }} />
|
|
199
|
+
<button type="submit" className="btn btn-purple btn-sm">Join Group</button>
|
|
200
|
+
</form>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', padding: '16px', borderRadius: '8px' }}>
|
|
204
|
+
<h3 style={{ fontSize: '14px', marginBottom: '12px' }}>Create a Space</h3>
|
|
205
|
+
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
206
|
+
<input type="text" placeholder="Space Name" value={newGroupName} onChange={e => setNewGroupName(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', fontSize: '13px' }} />
|
|
207
|
+
<button type="submit" className="btn btn-purple btn-sm">Create Group</button>
|
|
208
|
+
</form>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{(error || success) && <div style={{ textAlign: 'center', padding: '8px', fontSize: '12px', color: error ? '#f87171' : '#34d399', background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', borderRadius: '6px' }}>{error || success}</div>}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// âââ Component: CollabSandbox âââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
219
|
+
function CollabSandbox({ token, username, onBack }: { token: string, username: string, onBack: () => void }) {
|
|
220
|
+
const [groups, setGroups] = useState<CollabGroup[]>([]);
|
|
221
|
+
const [selectedGroup, setSelectedGroup] = useState<CollabGroup | null>(null);
|
|
222
|
+
const [simulatedUrl, setSimulatedUrl] = useState('https://news.ycombinator.com');
|
|
223
|
+
const [activeUrl, setActiveUrl] = useState('https://news.ycombinator.com');
|
|
224
|
+
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
225
|
+
const [noteText, setNoteText] = useState('');
|
|
226
|
+
const [noteExcerpt, setNoteExcerpt] = useState('');
|
|
227
|
+
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
|
228
|
+
const [chatInput, setChatInput] = useState('');
|
|
229
|
+
const [activeUsers, setActiveUsers] = useState<string[]>([]);
|
|
230
|
+
const [systemLogs, setSystemLogs] = useState<string[]>([]);
|
|
231
|
+
const [wsConnected, setWsConnected] = useState(false);
|
|
232
|
+
|
|
233
|
+
const socketRef = useRef<WebSocket | null>(null);
|
|
234
|
+
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const fetchGroups = async () => {
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch('/api/collab/groups', {
|
|
240
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
241
|
+
});
|
|
242
|
+
if (response.ok) {
|
|
243
|
+
const data = await response.json();
|
|
244
|
+
setGroups(data);
|
|
245
|
+
if (data.length > 0) setSelectedGroup(data[0]);
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error(err);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
fetchGroups();
|
|
252
|
+
}, [token]); const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
let active = true;
|
|
256
|
+
const fetchAnnotations = async () => {
|
|
257
|
+
try {
|
|
258
|
+
const response = await fetch(`/api/collab/annotations?url=${encodeURIComponent(activeUrl)}`, {
|
|
259
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
260
|
+
});
|
|
261
|
+
if (response.ok && active) {
|
|
262
|
+
setAnnotations(await response.json());
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(err);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
fetchAnnotations();
|
|
269
|
+
return () => { active = false; };
|
|
270
|
+
}, [activeUrl, token, groups, refreshTrigger]);
|
|
271
|
+
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
274
|
+
const wsHost = window.location.host;
|
|
275
|
+
const wsUrl = `${wsProto}//${wsHost}/collab-ws?token=${token}`;
|
|
276
|
+
const ws = new WebSocket(wsUrl);
|
|
277
|
+
socketRef.current = ws;
|
|
278
|
+
|
|
279
|
+
ws.onopen = () => {
|
|
280
|
+
setWsConnected(true);
|
|
281
|
+
ws.send(JSON.stringify({ type: 'SUBSCRIBE', url: activeUrl }));
|
|
282
|
+
setSystemLogs(prev => [...prev, `Subscribed to context channel: ${activeUrl}`]);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
ws.onmessage = (event) => {
|
|
286
|
+
try {
|
|
287
|
+
const data = JSON.parse(event.data);
|
|
288
|
+
if (data.type === 'CHAT_MESSAGE') {
|
|
289
|
+
setChatMessages(prev => [...prev, data]);
|
|
290
|
+
} else if (data.type === 'USER_JOINED') {
|
|
291
|
+
setSystemLogs(prev => [...prev, `${data.username} joined this page`]);
|
|
292
|
+
setActiveUsers(prev => prev.includes(data.username) ? prev : [...prev, data.username]);
|
|
293
|
+
} else if (data.type === 'USER_LEFT') {
|
|
294
|
+
setSystemLogs(prev => [...prev, `${data.username} left`]);
|
|
295
|
+
setActiveUsers(prev => prev.filter(u => u !== data.username));
|
|
296
|
+
} else if (data.type === 'ANNOTATION_ADDED') {
|
|
297
|
+
setRefreshTrigger(prev => prev + 1);
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error(err);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
ws.onclose = () => {
|
|
305
|
+
setWsConnected(false);
|
|
306
|
+
setSystemLogs(prev => [...prev, 'Disconnected.']);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return () => { ws.close(); };
|
|
310
|
+
}, [activeUrl, token]);
|
|
311
|
+
|
|
312
|
+
useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [chatMessages, systemLogs]);
|
|
313
|
+
|
|
314
|
+
const handleUrlSubmit = (e: React.FormEvent) => {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
if (!simulatedUrl.trim()) return;
|
|
317
|
+
setChatMessages([]); setSystemLogs([]); setActiveUsers([]);
|
|
318
|
+
setActiveUrl(simulatedUrl);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleAddAnnotation = async (e: React.FormEvent) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
if (!noteText.trim() || !selectedGroup) return;
|
|
324
|
+
try {
|
|
325
|
+
const response = await fetch('/api/collab/annotations', {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
328
|
+
body: JSON.stringify({ url: activeUrl, groupCode: selectedGroup.code, text: noteText, excerpt: noteExcerpt }),
|
|
329
|
+
});
|
|
330
|
+
if (response.ok) {
|
|
331
|
+
setNoteText(''); setNoteExcerpt('');
|
|
332
|
+
setRefreshTrigger(prev => prev + 1);
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error(err);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const handleSendChat = (e: React.FormEvent) => {
|
|
340
|
+
e.preventDefault();
|
|
341
|
+
if (!chatInput.trim() || !socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) return;
|
|
342
|
+
socketRef.current.send(JSON.stringify({ type: 'CHAT_MESSAGE', text: chatInput }));
|
|
343
|
+
setChatInput('');
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
348
|
+
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'space-between', background: 'rgba(255,255,255,0.02)', padding: '12px 20px', borderRadius: '8px', border: '1px solid var(--border)', flexWrap: 'wrap' }}>
|
|
349
|
+
<button className="btn btn-ghost" onClick={onBack}>â Back to Workspaces</button>
|
|
350
|
+
<form onSubmit={handleUrlSubmit} style={{ display: 'flex', gap: '10px', flex: '1', minWidth: '250px' }}>
|
|
351
|
+
<input type="text" value={simulatedUrl} onChange={e => setSimulatedUrl(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 12px', borderRadius: '6px', flex: '1', fontFamily: 'monospace' }} />
|
|
352
|
+
<button type="submit" className="btn btn-purple">Simulate Visit</button>
|
|
353
|
+
</form>
|
|
354
|
+
<div style={{ fontSize: '13px', color: wsConnected ? '#34d399' : '#f87171' }}>â {wsConnected ? 'Connected' : 'Disconnected'}</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
358
|
+
Simulated browser context: <strong style={{ color: 'var(--purple-primary)', fontFamily: 'monospace' }}>{activeUrl}</strong>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: '20px', height: '500px' }}>
|
|
362
|
+
{/* Annotations */}
|
|
363
|
+
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', borderRadius: '8px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
364
|
+
<div style={{ flex: '1', padding: '16px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
365
|
+
<h3 style={{ fontSize: '14px', marginBottom: '8px' }}>Persistent Notes on Web Page</h3>
|
|
366
|
+
{annotations.length === 0 ? (
|
|
367
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>No notes pinned on this page context.</p>
|
|
368
|
+
) : (
|
|
369
|
+
annotations.map(ann => (
|
|
370
|
+
<div key={ann.id} style={{ background: 'rgba(255,255,255,0.02)', padding: '10px 14px', borderRadius: '6px', border: '1px solid var(--border)' }}>
|
|
371
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '4px' }}>
|
|
372
|
+
<span>By <strong>{ann.author}</strong></span>
|
|
373
|
+
<span>{new Date(ann.created_at).toLocaleTimeString()}</span>
|
|
374
|
+
</div>
|
|
375
|
+
{ann.excerpt && <blockquote style={{ borderLeft: '2px solid var(--purple-primary)', paddingLeft: '8px', fontSize: '12px', color: 'var(--text-secondary)', margin: '4px 0 8px 0', background: 'rgba(0,0,0,0.1)', padding: '4px' }}>"{ann.excerpt}"</blockquote>}
|
|
376
|
+
<p style={{ fontSize: '13px', color: '#fff' }}>{ann.text}</p>
|
|
377
|
+
</div>
|
|
378
|
+
))
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<form onSubmit={handleAddAnnotation} style={{ borderTop: '1px solid var(--border)', padding: '12px', background: 'rgba(0,0,0,0.1)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
383
|
+
<div style={{ display: 'flex', gap: '8px' }}>
|
|
384
|
+
<select value={selectedGroup?.code || ''} onChange={e => setSelectedGroup(groups.find(g => g.code === e.target.value) || null)} style={{ background: 'rgba(0,0,0,0.4)', border: '1px solid var(--border)', color: '#fff', padding: '6px', borderRadius: '6px', fontSize: '12px' }}>
|
|
385
|
+
{groups.map(g => <option key={g.code} value={g.code}>{g.name}</option>)}
|
|
386
|
+
</select>
|
|
387
|
+
<input type="text" placeholder="Excerpt (optional context)" value={noteExcerpt} onChange={e => setNoteExcerpt(e.target.value)} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', flex: '1', fontSize: '12px' }} />
|
|
388
|
+
</div>
|
|
389
|
+
<div style={{ display: 'flex', gap: '8px' }}>
|
|
390
|
+
<input type="text" placeholder="Add annotation comment..." value={noteText} onChange={e => setNoteText(e.target.value)} required style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', flex: '1', fontSize: '12px' }} />
|
|
391
|
+
<button type="submit" className="btn btn-purple btn-sm" disabled={groups.length === 0}>Pin</button>
|
|
392
|
+
</div>
|
|
393
|
+
</form>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{/* Chat Panel */}
|
|
397
|
+
<div style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid var(--border)', borderRadius: '8px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
398
|
+
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'rgba(0,0,0,0.1)' }}>
|
|
399
|
+
<h3 style={{ fontSize: '13px' }}>Live Web Room {activeUsers.length > 0 && <span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>({activeUsers.join(', ')})</span>}</h3>
|
|
400
|
+
</div>
|
|
401
|
+
<div style={{ flex: '1', padding: '12px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
402
|
+
{systemLogs.map((log, idx) => <div key={idx} style={{ fontSize: '10px', color: 'var(--text-secondary)', fontStyle: 'italic' }}>âšī¸ {log}</div>)}
|
|
403
|
+
{chatMessages.map((msg, idx) => {
|
|
404
|
+
const isSelf = msg.username.toLowerCase() === username.toLowerCase();
|
|
405
|
+
return (
|
|
406
|
+
<div key={idx} style={{ display: 'flex', flexDirection: 'column', alignSelf: isSelf ? 'flex-end' : 'flex-start', maxWidth: '85%' }}>
|
|
407
|
+
<span style={{ fontSize: '9px', color: 'var(--text-secondary)', alignSelf: isSelf ? 'flex-end' : 'flex-start' }}>{msg.username} âĸ {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
408
|
+
<div style={{ padding: '8px 12px', borderRadius: '8px', background: isSelf ? 'var(--purple-primary)' : 'rgba(255,255,255,0.04)', fontSize: '12px', color: '#fff', wordBreak: 'break-word', marginTop: '2px' }}>{msg.text}</div>
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
})}
|
|
412
|
+
<div ref={chatEndRef} />
|
|
413
|
+
</div>
|
|
414
|
+
<form onSubmit={handleSendChat} style={{ borderTop: '1px solid var(--border)', padding: '10px', display: 'flex', gap: '6px' }}>
|
|
415
|
+
<input type="text" placeholder="Send chat..." value={chatInput} onChange={e => setChatInput(e.target.value)} required disabled={!wsConnected} style={{ background: 'rgba(0,0,0,0.2)', border: '1px solid var(--border)', color: '#fff', padding: '6px 10px', borderRadius: '6px', flex: '1', fontSize: '12px' }} />
|
|
416
|
+
<button type="submit" className="btn btn-purple btn-sm" disabled={!wsConnected}>Send</button>
|
|
417
|
+
</form>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// âââ Main Integrated CollabPage Component âââââââââââââââââââââââââââââââââââââ
|
|
425
|
+
export default function CollabPage() {
|
|
426
|
+
const [token, setToken] = useState<string | null>(() => localStorage.getItem('collab_token'));
|
|
427
|
+
const [username, setUsername] = useState<string | null>(() => localStorage.getItem('collab_username'));
|
|
428
|
+
const [view, setView] = useState<'login' | 'dashboard' | 'sandbox'>(() => {
|
|
429
|
+
const savedToken = localStorage.getItem('collab_token');
|
|
430
|
+
const savedUser = localStorage.getItem('collab_username');
|
|
431
|
+
return (savedToken && savedUser) ? 'dashboard' : 'login';
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const handleAuthSuccess = (newToken: string, newUsername: string) => {
|
|
435
|
+
localStorage.setItem('collab_token', newToken);
|
|
436
|
+
localStorage.setItem('collab_username', newUsername);
|
|
437
|
+
setToken(newToken);
|
|
438
|
+
setUsername(newUsername);
|
|
439
|
+
setView('dashboard');
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const handleLogout = () => {
|
|
443
|
+
localStorage.removeItem('collab_token');
|
|
444
|
+
localStorage.removeItem('collab_username');
|
|
445
|
+
setToken(null);
|
|
446
|
+
setUsername(null);
|
|
447
|
+
setView('login');
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<div className="page" style={{ padding: '24px', maxWidth: '1100px', margin: '0 auto' }}>
|
|
452
|
+
{view === 'login' && <CollabAuthView onAuthSuccess={handleAuthSuccess} />}
|
|
453
|
+
{view === 'dashboard' && token && username && (
|
|
454
|
+
<CollabDashboard
|
|
455
|
+
token={token}
|
|
456
|
+
username={username}
|
|
457
|
+
onLogout={handleLogout}
|
|
458
|
+
onSelectSandbox={() => setView('sandbox')}
|
|
459
|
+
/>
|
|
460
|
+
)}
|
|
461
|
+
{view === 'sandbox' && token && username && (
|
|
462
|
+
<CollabSandbox
|
|
463
|
+
token={token}
|
|
464
|
+
username={username}
|
|
465
|
+
onBack={() => setView('dashboard')}
|
|
466
|
+
/>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|