total-recall-brain 3.12.2 → 3.13.0
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/frontend/package.json +2 -1
- package/frontend/src/App.tsx +44 -0
- package/frontend/src/api.ts +101 -1
- package/frontend/src/components/UsageChart.tsx +78 -0
- package/frontend/src/pages/DesignDocsPage.tsx +614 -0
- package/frontend/src/pages/InstructionsPage.tsx +583 -0
- package/frontend/src/pages/ModelsPage.tsx +280 -23
- package/frontend/src/pages/OkfPage.tsx +535 -0
- package/frontend/src/pages/OpenWikiPage.tsx +472 -0
- package/frontend/src/pages/SettingsPage.tsx +183 -0
- package/frontend/src/pages/SkillsPage.tsx +1054 -37
- package/frontend/src/pages/UsagePage.tsx +254 -1
- package/frontend/src/types.ts +160 -0
- package/package.json +1 -1
- package/src/server/api.mjs +2 -0
- package/src/server/auth.mjs +2 -2
- package/src/server/rest.mjs +207 -43
- package/src/server/route-manifest.json +12 -0
- package/src/server/routes/_shared.mjs +1 -1
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { searchMemory, runSandbox } from '../api';
|
|
3
|
+
import type { MemoryNode } from '../types';
|
|
4
|
+
|
|
5
|
+
// ─── Styles ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const inputStyle: React.CSSProperties = {
|
|
8
|
+
background: 'var(--bg-secondary)',
|
|
9
|
+
color: 'var(--text-primary)',
|
|
10
|
+
border: '1px solid var(--border)',
|
|
11
|
+
borderRadius: 6,
|
|
12
|
+
padding: '10px 14px',
|
|
13
|
+
fontSize: 14,
|
|
14
|
+
outline: 'none',
|
|
15
|
+
width: '100%',
|
|
16
|
+
boxSizing: 'border-box',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const cardStyle: React.CSSProperties = {
|
|
20
|
+
background: 'rgba(255,255,255,0.03)',
|
|
21
|
+
border: '1px solid var(--border)',
|
|
22
|
+
backdropFilter: 'blur(12px)',
|
|
23
|
+
borderRadius: 10,
|
|
24
|
+
padding: 24,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const outputStyle: React.CSSProperties = {
|
|
28
|
+
background: 'var(--bg-secondary)',
|
|
29
|
+
border: '1px solid var(--border)',
|
|
30
|
+
borderRadius: 8,
|
|
31
|
+
padding: 16,
|
|
32
|
+
fontFamily: 'var(--font-mono, monospace)',
|
|
33
|
+
fontSize: 13,
|
|
34
|
+
lineHeight: 1.6,
|
|
35
|
+
color: 'var(--text-secondary)',
|
|
36
|
+
whiteSpace: 'pre-wrap',
|
|
37
|
+
wordBreak: 'break-word',
|
|
38
|
+
maxHeight: 280,
|
|
39
|
+
overflowY: 'auto',
|
|
40
|
+
marginTop: 16,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const labelStyle: React.CSSProperties = {
|
|
44
|
+
fontSize: 13,
|
|
45
|
+
fontWeight: 500,
|
|
46
|
+
color: 'var(--text-secondary)',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const badgeStyle = (bg: string, fg: string): React.CSSProperties => ({
|
|
50
|
+
display: 'inline-block',
|
|
51
|
+
padding: '3px 10px',
|
|
52
|
+
borderRadius: 4,
|
|
53
|
+
fontSize: 11,
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
textTransform: 'uppercase',
|
|
56
|
+
background: bg,
|
|
57
|
+
color: fg,
|
|
58
|
+
letterSpacing: 0.4,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const nodeCardStyle: React.CSSProperties = {
|
|
62
|
+
...cardStyle,
|
|
63
|
+
padding: 18,
|
|
64
|
+
cursor: 'pointer',
|
|
65
|
+
transition: 'border-color 0.2s, transform 0.15s',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const pillStyle: React.CSSProperties = {
|
|
69
|
+
display: 'inline-block',
|
|
70
|
+
padding: '2px 8px',
|
|
71
|
+
borderRadius: 3,
|
|
72
|
+
fontSize: 11,
|
|
73
|
+
fontWeight: 500,
|
|
74
|
+
background: 'rgba(108,92,231,0.15)',
|
|
75
|
+
color: 'var(--accent)',
|
|
76
|
+
marginRight: 4,
|
|
77
|
+
marginBottom: 4,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const drawerOverlay: React.CSSProperties = {
|
|
81
|
+
position: 'fixed',
|
|
82
|
+
inset: 0,
|
|
83
|
+
background: 'rgba(0,0,0,0.5)',
|
|
84
|
+
zIndex: 8000,
|
|
85
|
+
display: 'flex',
|
|
86
|
+
justifyContent: 'flex-end',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const drawerPanel: React.CSSProperties = {
|
|
90
|
+
width: '100%',
|
|
91
|
+
maxWidth: 560,
|
|
92
|
+
height: '100%',
|
|
93
|
+
background: 'var(--bg-primary)',
|
|
94
|
+
borderLeft: '1px solid var(--border)',
|
|
95
|
+
overflowY: 'auto',
|
|
96
|
+
padding: 32,
|
|
97
|
+
animation: 'slideInRight 0.25s ease-out',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function excerpt(node: MemoryNode, maxLen = 200): string {
|
|
103
|
+
const text = node.body || node.content || node.excerpt || '';
|
|
104
|
+
if (text.length <= maxLen) return text;
|
|
105
|
+
return text.slice(0, maxLen).trimEnd() + '…';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function categoryColor(cat: string): { bg: string; fg: string } {
|
|
109
|
+
const map: Record<string, { bg: string; fg: string }> = {
|
|
110
|
+
invariant: { bg: 'rgba(255,107,107,0.15)', fg: 'var(--error)' },
|
|
111
|
+
preference: { bg: 'rgba(108,92,231,0.15)', fg: 'var(--accent)' },
|
|
112
|
+
fact: { bg: 'rgba(0,206,201,0.15)', fg: 'var(--success)' },
|
|
113
|
+
concept: { bg: 'rgba(253,203,110,0.15)', fg: 'var(--warning)' },
|
|
114
|
+
pattern: { bg: 'rgba(108,92,231,0.15)', fg: 'var(--accent)' },
|
|
115
|
+
decision: { bg: 'rgba(253,203,110,0.15)', fg: 'var(--warning)' },
|
|
116
|
+
lore: { bg: 'rgba(158,158,158,0.15)', fg: 'var(--text-secondary)' },
|
|
117
|
+
};
|
|
118
|
+
return map[cat] || { bg: 'rgba(158,158,158,0.12)', fg: 'var(--text-secondary)' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export default function OpenWikiPage() {
|
|
124
|
+
// Status
|
|
125
|
+
const [initialized, setInitialized] = useState<boolean | null>(null);
|
|
126
|
+
const [nodeCount, setNodeCount] = useState(0);
|
|
127
|
+
const [statusLoading, setStatusLoading] = useState(true);
|
|
128
|
+
|
|
129
|
+
// Ingestion
|
|
130
|
+
const [wikiPath, setWikiPath] = useState('.');
|
|
131
|
+
const [cmdOutput, setCmdOutput] = useState('');
|
|
132
|
+
const [cmdRunning, setCmdRunning] = useState(false);
|
|
133
|
+
const [cmdError, setCmdError] = useState(false);
|
|
134
|
+
|
|
135
|
+
// Node Browser
|
|
136
|
+
const [nodes, setNodes] = useState<MemoryNode[]>([]);
|
|
137
|
+
const [nodesLoading, setNodesLoading] = useState(false);
|
|
138
|
+
const [selectedNode, setSelectedNode] = useState<MemoryNode | null>(null);
|
|
139
|
+
const drawerRef = useRef<HTMLDivElement>(null);
|
|
140
|
+
|
|
141
|
+
// ── Data Fetching ──
|
|
142
|
+
|
|
143
|
+
const fetchNodes = async () => {
|
|
144
|
+
setNodesLoading(true);
|
|
145
|
+
try {
|
|
146
|
+
const results = await searchMemory('openwiki');
|
|
147
|
+
setNodes(results);
|
|
148
|
+
setNodeCount(results.length);
|
|
149
|
+
setInitialized(results.length > 0);
|
|
150
|
+
} catch {
|
|
151
|
+
setNodes([]);
|
|
152
|
+
setInitialized(false);
|
|
153
|
+
} finally {
|
|
154
|
+
setNodesLoading(false);
|
|
155
|
+
setStatusLoading(false);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
fetchNodes();
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// ── Ingestion Handlers ──
|
|
165
|
+
|
|
166
|
+
const runCliCommand = async (command: string) => {
|
|
167
|
+
setCmdRunning(true);
|
|
168
|
+
setCmdOutput('');
|
|
169
|
+
setCmdError(false);
|
|
170
|
+
try {
|
|
171
|
+
const code = `
|
|
172
|
+
const { execSync } = require('child_process');
|
|
173
|
+
try {
|
|
174
|
+
const out = execSync(${JSON.stringify(command)}, { encoding: 'utf8', timeout: 60000 });
|
|
175
|
+
console.log(out);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.error(e.stderr || e.message);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}`;
|
|
180
|
+
const result = await runSandbox(code, 60000);
|
|
181
|
+
setCmdOutput(result.output);
|
|
182
|
+
setCmdError(!result.success);
|
|
183
|
+
if (result.success) {
|
|
184
|
+
// Refresh nodes after successful ingestion
|
|
185
|
+
await fetchNodes();
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
setCmdOutput((e as Error).message);
|
|
189
|
+
setCmdError(true);
|
|
190
|
+
} finally {
|
|
191
|
+
setCmdRunning(false);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const handleInit = () => {
|
|
196
|
+
runCliCommand('npx -y openwiki --init');
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const handleIngest = () => {
|
|
200
|
+
const safePath = wikiPath.trim() || '.';
|
|
201
|
+
runCliCommand(`npx total-recall ingest openwiki ${JSON.stringify(safePath)}`);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// ── Drawer close on outside click ──
|
|
205
|
+
const handleOverlayClick = (e: React.MouseEvent) => {
|
|
206
|
+
if (drawerRef.current && !drawerRef.current.contains(e.target as Node)) {
|
|
207
|
+
setSelectedNode(null);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ── Render ──
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="page">
|
|
215
|
+
{/* Inline keyframes for drawer animation */}
|
|
216
|
+
<style>{`
|
|
217
|
+
@keyframes slideInRight {
|
|
218
|
+
from { transform: translateX(100%); opacity: 0; }
|
|
219
|
+
to { transform: translateX(0); opacity: 1; }
|
|
220
|
+
}
|
|
221
|
+
.openwiki-node-card:hover {
|
|
222
|
+
border-color: var(--accent) !important;
|
|
223
|
+
transform: translateY(-2px);
|
|
224
|
+
}
|
|
225
|
+
`}</style>
|
|
226
|
+
|
|
227
|
+
<div className="page-header">
|
|
228
|
+
<h1>📚 OpenWiki Browser</h1>
|
|
229
|
+
<p style={{ color: 'var(--text-secondary)', margin: '4px 0 0' }}>
|
|
230
|
+
Auto-document your codebase with OpenWiki and ingest nodes into memory.
|
|
231
|
+
</p>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{/* ── Status Banner ── */}
|
|
235
|
+
<div
|
|
236
|
+
id="openwiki-status-banner"
|
|
237
|
+
style={{
|
|
238
|
+
...cardStyle,
|
|
239
|
+
display: 'flex',
|
|
240
|
+
alignItems: 'center',
|
|
241
|
+
justifyContent: 'space-between',
|
|
242
|
+
flexWrap: 'wrap',
|
|
243
|
+
gap: 12,
|
|
244
|
+
marginBottom: 24,
|
|
245
|
+
padding: '16px 24px',
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
249
|
+
{statusLoading ? (
|
|
250
|
+
<span style={{ color: 'var(--text-secondary)', fontSize: 14 }}>Checking status…</span>
|
|
251
|
+
) : initialized ? (
|
|
252
|
+
<>
|
|
253
|
+
<span style={badgeStyle('rgba(0,206,201,0.15)', 'var(--success)')}>Initialized</span>
|
|
254
|
+
<span style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
|
|
255
|
+
{nodeCount} node{nodeCount !== 1 ? 's' : ''} ingested
|
|
256
|
+
</span>
|
|
257
|
+
</>
|
|
258
|
+
) : (
|
|
259
|
+
<span style={badgeStyle('rgba(255,107,107,0.15)', 'var(--error)')}>Not Initialized</span>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
<span style={{ color: 'var(--text-secondary)', fontSize: 12, fontFamily: 'var(--font-mono, monospace)' }}>
|
|
263
|
+
openwiki · total-recall integration
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* ── Ingestion Section ── */}
|
|
268
|
+
<div style={{ ...cardStyle, marginBottom: 24 }}>
|
|
269
|
+
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16 }}>
|
|
270
|
+
Ingestion
|
|
271
|
+
</h2>
|
|
272
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
273
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
274
|
+
<label htmlFor="openwiki-path" style={labelStyle}>OpenWiki Directory</label>
|
|
275
|
+
<input
|
|
276
|
+
id="openwiki-path"
|
|
277
|
+
type="text"
|
|
278
|
+
placeholder="Path to OpenWiki directory (default: current project root)"
|
|
279
|
+
value={wikiPath}
|
|
280
|
+
onChange={e => setWikiPath(e.target.value)}
|
|
281
|
+
style={inputStyle}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
286
|
+
<button
|
|
287
|
+
id="openwiki-init"
|
|
288
|
+
className="btn btn-ghost"
|
|
289
|
+
onClick={handleInit}
|
|
290
|
+
disabled={cmdRunning}
|
|
291
|
+
>
|
|
292
|
+
{cmdRunning ? '⏳' : '⚙'} Initialize OpenWiki
|
|
293
|
+
</button>
|
|
294
|
+
<button
|
|
295
|
+
id="openwiki-ingest"
|
|
296
|
+
className="btn btn-primary"
|
|
297
|
+
onClick={handleIngest}
|
|
298
|
+
disabled={cmdRunning}
|
|
299
|
+
>
|
|
300
|
+
{cmdRunning ? '⏳ Ingesting…' : '📥 Ingest into Memory'}
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{cmdOutput && (
|
|
306
|
+
<div
|
|
307
|
+
id="openwiki-cmd-output"
|
|
308
|
+
style={{ ...outputStyle, borderColor: cmdError ? 'var(--error)' : 'var(--border)' }}
|
|
309
|
+
>
|
|
310
|
+
{cmdOutput}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{/* ── Node Browser ── */}
|
|
316
|
+
<div style={{ marginBottom: 24 }}>
|
|
317
|
+
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16 }}>
|
|
318
|
+
Node Browser
|
|
319
|
+
</h2>
|
|
320
|
+
|
|
321
|
+
{nodesLoading && (
|
|
322
|
+
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-secondary)' }}>
|
|
323
|
+
Loading nodes…
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
{!nodesLoading && nodes.length === 0 && (
|
|
328
|
+
<div
|
|
329
|
+
id="openwiki-empty-state"
|
|
330
|
+
style={{
|
|
331
|
+
...cardStyle,
|
|
332
|
+
textAlign: 'center',
|
|
333
|
+
padding: '48px 24px',
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
<div style={{ fontSize: 36, marginBottom: 12 }}>📭</div>
|
|
337
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: 15, maxWidth: 400, margin: '0 auto' }}>
|
|
338
|
+
No OpenWiki nodes found. Initialize OpenWiki to auto-document your codebase.
|
|
339
|
+
</p>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
{!nodesLoading && nodes.length > 0 && (
|
|
344
|
+
<div
|
|
345
|
+
style={{
|
|
346
|
+
display: 'grid',
|
|
347
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
|
348
|
+
gap: 14,
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
{nodes.map(node => {
|
|
352
|
+
const cc = categoryColor(node.category);
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
key={node.slug}
|
|
356
|
+
id={`openwiki-node-${node.slug}`}
|
|
357
|
+
className="openwiki-node-card"
|
|
358
|
+
style={nodeCardStyle}
|
|
359
|
+
onClick={() => setSelectedNode(node)}
|
|
360
|
+
role="button"
|
|
361
|
+
tabIndex={0}
|
|
362
|
+
onKeyDown={e => { if (e.key === 'Enter') setSelectedNode(node); }}
|
|
363
|
+
>
|
|
364
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
365
|
+
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
366
|
+
{node.title}
|
|
367
|
+
</span>
|
|
368
|
+
<span style={badgeStyle(cc.bg, cc.fg)}>{node.category}</span>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
{node.tags && node.tags.length > 0 && (
|
|
372
|
+
<div style={{ marginBottom: 8 }}>
|
|
373
|
+
{node.tags.slice(0, 5).map(tag => (
|
|
374
|
+
<span key={tag} style={pillStyle}>{tag}</span>
|
|
375
|
+
))}
|
|
376
|
+
{node.tags.length > 5 && (
|
|
377
|
+
<span style={{ ...pillStyle, background: 'rgba(158,158,158,0.12)', color: 'var(--text-secondary)' }}>
|
|
378
|
+
+{node.tags.length - 5}
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
|
|
384
|
+
<p style={{ color: 'var(--text-secondary)', fontSize: 13, lineHeight: 1.5, margin: 0 }}>
|
|
385
|
+
{excerpt(node)}
|
|
386
|
+
</p>
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
})}
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{/* ── Slide-out Drawer ── */}
|
|
395
|
+
{selectedNode && (
|
|
396
|
+
<div style={drawerOverlay} onClick={handleOverlayClick}>
|
|
397
|
+
<div ref={drawerRef} style={drawerPanel} id="openwiki-node-drawer">
|
|
398
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
|
399
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
400
|
+
<h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 8px', wordBreak: 'break-word' }}>
|
|
401
|
+
{selectedNode.title}
|
|
402
|
+
</h2>
|
|
403
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
404
|
+
{(() => { const cc = categoryColor(selectedNode.category); return <span style={badgeStyle(cc.bg, cc.fg)}>{selectedNode.category}</span>; })()}
|
|
405
|
+
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontFamily: 'var(--font-mono, monospace)' }}>
|
|
406
|
+
{selectedNode.slug}
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
<button
|
|
411
|
+
id="openwiki-drawer-close"
|
|
412
|
+
className="btn btn-ghost"
|
|
413
|
+
onClick={() => setSelectedNode(null)}
|
|
414
|
+
style={{ fontSize: 18, padding: '4px 10px', flexShrink: 0 }}
|
|
415
|
+
aria-label="Close drawer"
|
|
416
|
+
>
|
|
417
|
+
✕
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
{selectedNode.tags && selectedNode.tags.length > 0 && (
|
|
422
|
+
<div style={{ marginBottom: 16 }}>
|
|
423
|
+
{selectedNode.tags.map(tag => (
|
|
424
|
+
<span key={tag} style={pillStyle}>{tag}</span>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{selectedNode.importance && (
|
|
430
|
+
<div style={{ marginBottom: 12, fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
431
|
+
<strong style={{ color: 'var(--text-primary)' }}>Importance:</strong> {selectedNode.importance}/5
|
|
432
|
+
{selectedNode.modality && (
|
|
433
|
+
<span> · <strong style={{ color: 'var(--text-primary)' }}>Modality:</strong> {selectedNode.modality}</span>
|
|
434
|
+
)}
|
|
435
|
+
{selectedNode.priority && (
|
|
436
|
+
<span> · <strong style={{ color: 'var(--text-primary)' }}>Priority:</strong> {selectedNode.priority}</span>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
|
|
441
|
+
<div
|
|
442
|
+
style={{
|
|
443
|
+
background: 'var(--bg-secondary)',
|
|
444
|
+
border: '1px solid var(--border)',
|
|
445
|
+
borderRadius: 8,
|
|
446
|
+
padding: 20,
|
|
447
|
+
fontSize: 14,
|
|
448
|
+
lineHeight: 1.7,
|
|
449
|
+
color: 'var(--text-primary)',
|
|
450
|
+
whiteSpace: 'pre-wrap',
|
|
451
|
+
wordBreak: 'break-word',
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
{selectedNode.body || selectedNode.content || selectedNode.excerpt || 'No content available.'}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{selectedNode.related && selectedNode.related.length > 0 && (
|
|
458
|
+
<div style={{ marginTop: 16 }}>
|
|
459
|
+
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>Related: </span>
|
|
460
|
+
{selectedNode.related.map(slug => (
|
|
461
|
+
<span key={slug} style={{ ...pillStyle, background: 'rgba(158,158,158,0.12)', color: 'var(--text-secondary)' }}>
|
|
462
|
+
{slug}
|
|
463
|
+
</span>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
@@ -835,6 +835,189 @@ export default function SettingsPage() {
|
|
|
835
835
|
</div>
|
|
836
836
|
</div>
|
|
837
837
|
|
|
838
|
+
{/* UCW WORKSPACE EXPORT (SSSS §16 .ucw Bundle Format) */}
|
|
839
|
+
<div style={{
|
|
840
|
+
background: 'rgba(255,255,255,0.03)',
|
|
841
|
+
border: '1px solid var(--border)',
|
|
842
|
+
borderRadius: 10,
|
|
843
|
+
padding: 20,
|
|
844
|
+
marginTop: 20,
|
|
845
|
+
backdropFilter: 'blur(12px)',
|
|
846
|
+
}}>
|
|
847
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
|
848
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
849
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
850
|
+
<polyline points="7 10 12 15 17 10" />
|
|
851
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
852
|
+
</svg>
|
|
853
|
+
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>UCW Bundle (SSSS §16)</h3>
|
|
854
|
+
<span className="badge badge-accent" style={{ fontSize: 10, padding: '2px 8px' }}>UltraChat Workspace</span>
|
|
855
|
+
</div>
|
|
856
|
+
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
857
|
+
Export your vault as a <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 4, fontFamily: 'var(--font-mono, monospace)' }}>package.ucw.json</code> bundle
|
|
858
|
+
per the SSSS §16 Universal Containerized Workspace format. Bundles can be backed up, shared, sold, and re-provisioned across any SSSS-compatible host.
|
|
859
|
+
</p>
|
|
860
|
+
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center', marginBottom: 16 }}>
|
|
861
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
862
|
+
<label style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)' }}>Export Profile</label>
|
|
863
|
+
<select
|
|
864
|
+
id="ucw-export-profile"
|
|
865
|
+
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', fontSize: 12, outline: 'none' }}
|
|
866
|
+
>
|
|
867
|
+
<option value="backup">backup — full vault snapshot</option>
|
|
868
|
+
<option value="template">template — strips tenant_private</option>
|
|
869
|
+
<option value="sale">sale — portable marketplace bundle</option>
|
|
870
|
+
</select>
|
|
871
|
+
</div>
|
|
872
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 200px' }}>
|
|
873
|
+
<label style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)' }}>Output Filename</label>
|
|
874
|
+
<input
|
|
875
|
+
id="ucw-export-filename"
|
|
876
|
+
type="text"
|
|
877
|
+
defaultValue="package.ucw.json"
|
|
878
|
+
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 12px', fontSize: 12, outline: 'none', fontFamily: 'var(--font-mono, monospace)' }}
|
|
879
|
+
/>
|
|
880
|
+
</div>
|
|
881
|
+
</div>
|
|
882
|
+
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
883
|
+
<button
|
|
884
|
+
id="ucw-export-btn"
|
|
885
|
+
className="btn btn-primary"
|
|
886
|
+
style={{ background: 'linear-gradient(135deg, var(--accent), #5a4bd1)', color: '#fff', borderRadius: 6, fontSize: 13, padding: '8px 20px' }}
|
|
887
|
+
onClick={async () => {
|
|
888
|
+
try {
|
|
889
|
+
const profile = (document.getElementById('ucw-export-profile') as HTMLSelectElement)?.value || 'backup';
|
|
890
|
+
const filename = (document.getElementById('ucw-export-filename') as HTMLInputElement)?.value || 'package.ucw.json';
|
|
891
|
+
const { runSandbox } = await import('../api');
|
|
892
|
+
const result = await runSandbox(`const { execSync } = require("child_process"); try { const out = execSync("npx ssss export . --profile ${profile} --out ${filename} 2>&1", { encoding: "utf-8", cwd: process.env.HOME + "/.agent/skills/total-recall" }); console.log(JSON.stringify({ success: true, output: out })); } catch(e) { console.log(JSON.stringify({ success: false, output: e.stdout || e.message })); }`, 60000);
|
|
893
|
+
if (result.success) {
|
|
894
|
+
setSuccess(true);
|
|
895
|
+
setTimeout(() => setSuccess(false), 5000);
|
|
896
|
+
} else {
|
|
897
|
+
setError(result.output || 'UCW export failed');
|
|
898
|
+
}
|
|
899
|
+
} catch (err: unknown) {
|
|
900
|
+
setError((err as Error).message || 'Export failed');
|
|
901
|
+
}
|
|
902
|
+
}}
|
|
903
|
+
>
|
|
904
|
+
⬇ Export .ucw Bundle
|
|
905
|
+
</button>
|
|
906
|
+
<button
|
|
907
|
+
id="ucw-validate-btn"
|
|
908
|
+
className="btn btn-ghost"
|
|
909
|
+
style={{ borderRadius: 6, fontSize: 13, padding: '8px 20px' }}
|
|
910
|
+
onClick={async () => {
|
|
911
|
+
try {
|
|
912
|
+
const { runSandbox } = await import('../api');
|
|
913
|
+
const result = await runSandbox('const { execSync } = require("child_process"); try { const out = execSync("npx ssss validate package.ucw.json 2>&1", { encoding: "utf-8", cwd: process.env.HOME + "/.agent/skills/total-recall" }); console.log(JSON.stringify({ success: true, output: out })); } catch(e) { console.log(JSON.stringify({ success: false, output: e.stdout || e.message })); }', 30000);
|
|
914
|
+
if (result.success) {
|
|
915
|
+
setSuccess(true);
|
|
916
|
+
setTimeout(() => setSuccess(false), 5000);
|
|
917
|
+
} else {
|
|
918
|
+
setError(result.output || 'Validation failed');
|
|
919
|
+
}
|
|
920
|
+
} catch (err: unknown) {
|
|
921
|
+
setError((err as Error).message || 'Validation failed');
|
|
922
|
+
}
|
|
923
|
+
}}
|
|
924
|
+
>
|
|
925
|
+
✓ Validate Bundle
|
|
926
|
+
</button>
|
|
927
|
+
<button
|
|
928
|
+
id="ucw-import-btn"
|
|
929
|
+
className="btn btn-ghost"
|
|
930
|
+
style={{ borderRadius: 6, fontSize: 13, padding: '8px 20px' }}
|
|
931
|
+
onClick={async () => {
|
|
932
|
+
try {
|
|
933
|
+
const { runSandbox } = await import('../api');
|
|
934
|
+
const result = await runSandbox('const { execSync } = require("child_process"); try { const out = execSync("npx ssss import package.ucw.json --vault . 2>&1", { encoding: "utf-8", cwd: process.env.HOME + "/.agent/skills/total-recall" }); console.log(JSON.stringify({ success: true, output: out })); } catch(e) { console.log(JSON.stringify({ success: false, output: e.stdout || e.message })); }', 60000);
|
|
935
|
+
if (result.success) {
|
|
936
|
+
setSuccess(true);
|
|
937
|
+
setTimeout(() => setSuccess(false), 5000);
|
|
938
|
+
} else {
|
|
939
|
+
setError(result.output || 'Import failed');
|
|
940
|
+
}
|
|
941
|
+
} catch (err: unknown) {
|
|
942
|
+
setError((err as Error).message || 'Import failed');
|
|
943
|
+
}
|
|
944
|
+
}}
|
|
945
|
+
>
|
|
946
|
+
⬆ Import .ucw Bundle
|
|
947
|
+
</button>
|
|
948
|
+
<button
|
|
949
|
+
id="ucw-inspect-btn"
|
|
950
|
+
className="btn btn-ghost"
|
|
951
|
+
style={{ borderRadius: 6, fontSize: 13, padding: '8px 20px' }}
|
|
952
|
+
onClick={async () => {
|
|
953
|
+
try {
|
|
954
|
+
const { runSandbox } = await import('../api');
|
|
955
|
+
const result = await runSandbox('const { execSync } = require("child_process"); try { const out = execSync("npx ssss inspect package.ucw.json --files 2>&1", { encoding: "utf-8", cwd: process.env.HOME + "/.agent/skills/total-recall" }); console.log(JSON.stringify({ success: true, output: out })); } catch(e) { console.log(JSON.stringify({ success: false, output: e.stdout || e.message })); }', 30000);
|
|
956
|
+
if (result.success) {
|
|
957
|
+
setSuccess(true);
|
|
958
|
+
setTimeout(() => setSuccess(false), 5000);
|
|
959
|
+
} else {
|
|
960
|
+
setError(result.output || 'Inspect failed');
|
|
961
|
+
}
|
|
962
|
+
} catch (err: unknown) {
|
|
963
|
+
setError((err as Error).message || 'Inspect failed');
|
|
964
|
+
}
|
|
965
|
+
}}
|
|
966
|
+
>
|
|
967
|
+
🔍 Inspect Bundle
|
|
968
|
+
</button>
|
|
969
|
+
</div>
|
|
970
|
+
<p style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 12 }}>
|
|
971
|
+
Powered by <code style={{ fontFamily: 'var(--font-mono, monospace)' }}>@ssss/cli</code> v0.7.0 — <code style={{ fontFamily: 'var(--font-mono, monospace)' }}>npx ssss export|import|validate|inspect</code>
|
|
972
|
+
</p>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
{/* SECRETS.ENC KEY VIEWER */}
|
|
976
|
+
<div style={{
|
|
977
|
+
background: 'rgba(255,255,255,0.03)',
|
|
978
|
+
border: '1px solid var(--border)',
|
|
979
|
+
borderRadius: 10,
|
|
980
|
+
padding: 20,
|
|
981
|
+
marginTop: 16,
|
|
982
|
+
backdropFilter: 'blur(12px)',
|
|
983
|
+
}}>
|
|
984
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
|
985
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
986
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
987
|
+
<path d="M7 11V7a5 5 0 0110 0v4" />
|
|
988
|
+
</svg>
|
|
989
|
+
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>Stored Secrets (secrets.enc)</h3>
|
|
990
|
+
</div>
|
|
991
|
+
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
992
|
+
These credentials are stored in your encrypted secrets file. Values are masked for security.
|
|
993
|
+
</p>
|
|
994
|
+
<div style={{ display: 'grid', gap: 8 }}>
|
|
995
|
+
{configData?.secrets && Object.entries(configData.secrets).map(([key, value]) => (
|
|
996
|
+
<div key={key} style={{
|
|
997
|
+
display: 'flex',
|
|
998
|
+
alignItems: 'center',
|
|
999
|
+
justifyContent: 'space-between',
|
|
1000
|
+
padding: '8px 12px',
|
|
1001
|
+
background: 'rgba(255,255,255,0.02)',
|
|
1002
|
+
borderRadius: 6,
|
|
1003
|
+
border: '1px solid rgba(255,255,255,0.04)',
|
|
1004
|
+
}}>
|
|
1005
|
+
<span style={{ fontSize: 13, fontFamily: 'var(--font-mono, monospace)', color: 'var(--text-primary)' }}>
|
|
1006
|
+
{key}
|
|
1007
|
+
</span>
|
|
1008
|
+
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono, monospace)', color: 'var(--text-tertiary)' }}>
|
|
1009
|
+
{value ? `${String(value).slice(0, 4)}${'•'.repeat(Math.min(20, String(value).length - 4))}` : '(not set)'}
|
|
1010
|
+
</span>
|
|
1011
|
+
</div>
|
|
1012
|
+
))}
|
|
1013
|
+
{(!configData?.secrets || Object.keys(configData.secrets).length === 0) && (
|
|
1014
|
+
<div style={{ fontSize: 13, color: 'var(--text-tertiary)', padding: '12px', textAlign: 'center' }}>
|
|
1015
|
+
No secrets configured. Add API keys through the visual settings editor above.
|
|
1016
|
+
</div>
|
|
1017
|
+
)}
|
|
1018
|
+
</div>
|
|
1019
|
+
</div>
|
|
1020
|
+
|
|
838
1021
|
{/* FOOTER ACTIONS */}
|
|
839
1022
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16, flexShrink: 0 }}>
|
|
840
1023
|
<button
|