@polderlabs/bizar 4.8.0 → 5.0.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.
Files changed (129) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
@@ -1,8 +1,9 @@
1
- // src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight
1
+ // src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight.
2
+ // v4.9: replaces Fuse.js with Levenshtein-based fuzzySearch; adds recent searches + match highlighting.
2
3
  import { useCallback, useEffect, useRef, useState } from 'react';
3
4
  import { Search, X } from 'lucide-react';
4
- import Fuse from 'fuse.js';
5
5
  import { cn } from '../lib/utils';
6
+ import { fuzzySearch, type SearchableItem, type SearchResult } from '../lib/search';
6
7
 
7
8
  export type SettingsSection = {
8
9
  id: string;
@@ -23,85 +24,106 @@ type Props = {
23
24
  onJump: (sectionId: string, fieldKey?: string) => void;
24
25
  };
25
26
 
26
- type Match = {
27
- item: SettingsField | SettingsSection;
28
- score: number;
29
- type: 'section' | 'field';
30
- sectionId: string;
31
- fieldKey?: string;
32
- };
27
+ /* ─── Recent searches (localStorage) ─── */
33
28
 
34
- function fuseSearch(items: SettingsSection[], query: string): Match[] {
35
- if (!query.trim()) return [];
29
+ const RECENT_KEY = 'bizar_settings_recent';
36
30
 
37
- const sectionFuse = new Fuse(items, {
38
- keys: ['label'],
39
- threshold: 0.4,
40
- includeScore: true,
41
- });
31
+ function getRecentSearches(): string[] {
32
+ try {
33
+ const stored = localStorage.getItem(RECENT_KEY);
34
+ return stored ? JSON.parse(stored) : [];
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
42
39
 
43
- const fieldFuse = new Fuse(
44
- items.flatMap((s) => s.fields.map((f) => ({ ...f, _sectionId: s.id }))),
45
- { keys: ['label', 'key'], threshold: 0.4, includeScore: true },
46
- );
40
+ function saveRecentSearch(query: string) {
41
+ if (!query) return;
42
+ try {
43
+ const recent = getRecentSearches().filter((q) => q !== query);
44
+ recent.unshift(query);
45
+ localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5)));
46
+ } catch {
47
+ // localStorage may be full or disabled — silently ignore
48
+ }
49
+ }
47
50
 
48
- const results: Match[] = [];
51
+ /* ─── Highlight matched terms ─── */
49
52
 
50
- for (const r of sectionFuse.search(query)) {
51
- results.push({
52
- item: r.item,
53
- score: r.score ?? 1,
54
- type: 'section',
55
- sectionId: r.item.id,
56
- });
57
- }
53
+ function highlightMatch(text: string, query: string): React.ReactNode {
54
+ if (!query) return text;
55
+ // Escape regex special characters in the user query
56
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ const regex = new RegExp(`(${escaped})`, 'gi');
58
+ const parts = text.split(regex);
59
+ return parts.map((part, i) =>
60
+ regex.test(part) ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>,
61
+ );
62
+ }
63
+
64
+ /* ─── Build SearchableItems from sections ─── */
58
65
 
59
- for (const r of fieldFuse.search(query)) {
60
- results.push({
61
- item: r.item as SettingsField,
62
- score: r.score ?? 1,
63
- type: 'field',
64
- sectionId: (r.item as SettingsField & { _sectionId: string })._sectionId,
65
- fieldKey: r.item.key,
66
+ function toSearchableItems(sections: SettingsSection[]): SearchableItem[] {
67
+ const items: SearchableItem[] = [];
68
+ for (const section of sections) {
69
+ // Section itself is searchable
70
+ items.push({
71
+ key: section.id,
72
+ label: section.label,
73
+ section: section.id,
74
+ description: `${section.fields.length} field${section.fields.length === 1 ? '' : 's'}`,
66
75
  });
76
+ // Each field is searchable
77
+ for (const field of section.fields) {
78
+ items.push({
79
+ key: field.key,
80
+ label: field.label,
81
+ section: section.id,
82
+ value: field.value,
83
+ });
84
+ }
67
85
  }
68
-
69
- // Deduplicate by sectionId+fieldKey, prefer lower score
70
- const seen = new Set<string>();
71
- return results
72
- .filter((r) => {
73
- const k = `${r.sectionId}::${r.fieldKey ?? ''}`;
74
- if (seen.has(k)) return false;
75
- seen.add(k);
76
- return true;
77
- })
78
- .sort((a, b) => a.score - b.score)
79
- .slice(0, 8);
86
+ return items;
80
87
  }
81
88
 
89
+ /* ─── Component ─── */
90
+
82
91
  export function SettingsSearch({ sections, onJump }: Props) {
83
92
  const [q, setQ] = useState('');
84
- const [matches, setMatches] = useState<Match[]>([]);
93
+ const [matches, setMatches] = useState<SearchResult[]>([]);
85
94
  const [activeIdx, setActiveIdx] = useState(0);
86
95
  const [highlighted, setHighlighted] = useState<string | null>(null);
96
+ const [focused, setFocused] = useState(false);
87
97
  const inputRef = useRef<HTMLInputElement>(null);
88
98
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
89
99
  const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
90
100
 
101
+ const allItems = useRef<SearchableItem[]>([]);
102
+ // Rebuild items when sections change
103
+ useEffect(() => {
104
+ allItems.current = toSearchableItems(sections);
105
+ }, [sections]);
106
+
91
107
  const search = useCallback(
92
- (query: string) => {
108
+ (query: string, immediate = false) => {
93
109
  if (debounceRef.current) clearTimeout(debounceRef.current);
94
110
  if (!query.trim()) {
95
111
  setMatches([]);
96
112
  setActiveIdx(0);
97
113
  return;
98
114
  }
99
- debounceRef.current = setTimeout(() => {
100
- setMatches(fuseSearch(sections, query));
115
+ const run = () => {
116
+ const results = fuzzySearch(query, allItems.current);
117
+ setMatches(results);
101
118
  setActiveIdx(0);
102
- }, 200);
119
+ };
120
+ if (immediate) {
121
+ run();
122
+ } else {
123
+ debounceRef.current = setTimeout(run, 150);
124
+ }
103
125
  },
104
- [sections],
126
+ [], // stable — allItems is a ref
105
127
  );
106
128
 
107
129
  useEffect(() => {
@@ -116,6 +138,25 @@ export function SettingsSearch({ sections, onJump }: Props) {
116
138
  };
117
139
  }, []);
118
140
 
141
+ /* ─── Quick-jump with flash ─── */
142
+
143
+ const jumpToSection = (sectionId: string, key: string) => {
144
+ // Look for the field by data-setting-id within the section
145
+ const fieldEl = document.querySelector(
146
+ `[data-section="${sectionId}"] [data-setting-id="${key}"]`,
147
+ );
148
+ if (fieldEl) {
149
+ fieldEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
150
+ fieldEl.classList.add('setting-flash');
151
+ setTimeout(() => fieldEl.classList.remove('setting-flash'), 2000);
152
+ }
153
+ // Also scroll the section Card into view
154
+ const sectionEl = document.querySelector(`[data-section="${sectionId}"]`);
155
+ if (sectionEl) {
156
+ sectionEl.scrollIntoView({ behavior: 'smooth', block: fieldEl ? 'nearest' : 'start' });
157
+ }
158
+ };
159
+
119
160
  const highlightSection = (sectionId: string) => {
120
161
  setHighlighted(sectionId);
121
162
  if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
@@ -124,6 +165,24 @@ export function SettingsSearch({ sections, onJump }: Props) {
124
165
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
125
166
  };
126
167
 
168
+ const commitSelection = (result: SearchResult) => {
169
+ saveRecentSearch(q);
170
+ if (result.key === result.section) {
171
+ // It's a section match — jump to section
172
+ highlightSection(result.section);
173
+ onJump(result.section);
174
+ } else {
175
+ // It's a field match — jump to field with flash
176
+ highlightSection(result.section);
177
+ onJump(result.section, result.key);
178
+ jumpToSection(result.section, result.key);
179
+ }
180
+ setQ('');
181
+ inputRef.current?.blur();
182
+ };
183
+
184
+ /* ─── Keyboard navigation ─── */
185
+
127
186
  const handleKey = (e: React.KeyboardEvent) => {
128
187
  if (e.key === 'Escape') {
129
188
  setQ('');
@@ -134,20 +193,45 @@ export function SettingsSearch({ sections, onJump }: Props) {
134
193
  } else if (e.key === 'ArrowUp') {
135
194
  e.preventDefault();
136
195
  setActiveIdx((i) => Math.max(i - 1, 0));
137
- } else if (e.key === 'Enter' && matches[activeIdx]) {
138
- const m = matches[activeIdx];
139
- highlightSection(m.sectionId);
140
- onJump(m.sectionId, m.fieldKey);
141
- setQ('');
196
+ } else if (e.key === 'Enter') {
197
+ e.preventDefault();
198
+ // If debounce hasn't fired yet, search immediately
199
+ if (matches.length === 0 && q.trim()) {
200
+ search(q, true);
201
+ }
202
+ if (matches[activeIdx]) {
203
+ commitSelection(matches[activeIdx]);
204
+ }
142
205
  }
143
206
  };
144
207
 
145
- const handleSelect = (m: Match) => {
146
- highlightSection(m.sectionId);
147
- onJump(m.sectionId, m.fieldKey);
148
- setQ('');
208
+ const handleSelect = (m: SearchResult) => {
209
+ commitSelection(m);
149
210
  };
150
211
 
212
+ /* ─── Recent searches helpers ─── */
213
+
214
+ const recent = getRecentSearches();
215
+
216
+ const handleRecentClick = (term: string) => {
217
+ setQ(term);
218
+ inputRef.current?.focus();
219
+ };
220
+
221
+ const clearRecent = () => {
222
+ try {
223
+ localStorage.removeItem(RECENT_KEY);
224
+ } catch {
225
+ // ignore
226
+ }
227
+ // Force re-render
228
+ setFocused((f) => f);
229
+ };
230
+
231
+ /* ─── Render ─── */
232
+
233
+ const showRecent = focused && !q.trim() && recent.length > 0;
234
+
151
235
  return (
152
236
  <div className="settings-search">
153
237
  <div className="settings-search-input-wrap">
@@ -159,6 +243,8 @@ export function SettingsSearch({ sections, onJump }: Props) {
159
243
  value={q}
160
244
  onChange={(e) => setQ(e.target.value)}
161
245
  onKeyDown={handleKey}
246
+ onFocus={() => setFocused(true)}
247
+ onBlur={() => setTimeout(() => setFocused(false), 150)}
162
248
  aria-label="Search settings"
163
249
  />
164
250
  {q && (
@@ -173,35 +259,64 @@ export function SettingsSearch({ sections, onJump }: Props) {
173
259
  )}
174
260
  </div>
175
261
 
262
+ {/* Recent searches dropdown */}
263
+ {showRecent && (
264
+ <div className="settings-search-recent">
265
+ <div className="settings-search-recent-header">
266
+ <span className="settings-search-recent-label">Recent</span>
267
+ <button
268
+ type="button"
269
+ className="settings-search-recent-clear"
270
+ onClick={clearRecent}
271
+ tabIndex={-1}
272
+ >
273
+ Clear
274
+ </button>
275
+ </div>
276
+ {recent.map((term) => (
277
+ <button
278
+ key={term}
279
+ type="button"
280
+ className="settings-search-recent-item"
281
+ onMouseDown={(e) => {
282
+ e.preventDefault();
283
+ handleRecentClick(term);
284
+ }}
285
+ >
286
+ <Search size={11} />
287
+ <span>{term}</span>
288
+ </button>
289
+ ))}
290
+ </div>
291
+ )}
292
+
293
+ {/* Search results */}
176
294
  {q && matches.length > 0 && (
177
295
  <div className="settings-search-results">
178
- {matches.map((m, idx) => {
179
- const label = m.type === 'section'
180
- ? (m.item as SettingsSection).label
181
- : (m.item as SettingsField).label;
182
- const key = m.type === 'section'
183
- ? (m.item as SettingsSection).id
184
- : (m.item as SettingsField).key;
185
- return (
186
- <button
187
- key={`${m.sectionId}::${key}`}
188
- type="button"
189
- className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
190
- onClick={() => handleSelect(m)}
191
- onMouseEnter={() => setActiveIdx(idx)}
192
- >
193
- <span className={cn('settings-search-result-type', m.type === 'section' && 'settings-search-result-type-section')}>
194
- {m.type === 'section' ? 'section' : 'field'}
195
- </span>
196
- <span className="settings-search-result-label">
197
- {label}
198
- </span>
199
- {m.type === 'field' && (
200
- <span className="settings-search-result-key mono muted">{key}</span>
296
+ {matches.map((m, idx) => (
297
+ <button
298
+ key={`${m.section}::${m.key}`}
299
+ type="button"
300
+ className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
301
+ onClick={() => handleSelect(m)}
302
+ onMouseEnter={() => setActiveIdx(idx)}
303
+ >
304
+ <span
305
+ className={cn(
306
+ 'settings-search-result-type',
307
+ m.key === m.section && 'settings-search-result-type-section',
201
308
  )}
202
- </button>
203
- );
204
- })}
309
+ >
310
+ {m.key === m.section ? 'section' : 'field'}
311
+ </span>
312
+ <span className="settings-search-result-label">
313
+ {highlightMatch(m.label, q)}
314
+ </span>
315
+ {m.key !== m.section && (
316
+ <span className="settings-search-result-key mono muted">{m.key}</span>
317
+ )}
318
+ </button>
319
+ ))}
205
320
  </div>
206
321
  )}
207
322
 
@@ -0,0 +1,182 @@
1
+ // src/web/components/VoiceNotesPanel.tsx — v5.0.0
2
+ //
3
+ // List + record + playback for voice notes.
4
+ // Wire into Memory tab as the "Voice Notes" source panel.
5
+
6
+ import { useEffect, useState } from 'react';
7
+ import { Play, Pause, Trash2, Search as SearchIcon, Mic } from 'lucide-react';
8
+ import { Card, CardTitle, CardMeta } from './Card';
9
+ import { VoiceRecorder } from './VoiceRecorder';
10
+ import { Button } from './Button';
11
+
12
+ type VoiceNote = {
13
+ id: string;
14
+ audioPath: string;
15
+ notePath: string | null;
16
+ transcript: string | null;
17
+ durationSec: number | null;
18
+ vaultPath: string | null;
19
+ createdAtMs: number;
20
+ updatedAtMs: number;
21
+ };
22
+
23
+ type Props = {
24
+ /** Vault path to scope the panel to */
25
+ vaultPath?: string;
26
+ /** Key to force reload when parent refreshes */
27
+ refreshKey?: number;
28
+ };
29
+
30
+ function NoteCard({ note, onDelete }: { note: VoiceNote; onDelete: (id: string) => void }) {
31
+ const [playing, setPlaying] = useState(false);
32
+ const [audio] = useState(() => {
33
+ if (!note.audioPath) return null;
34
+ const a = new Audio(`/api/voice/${note.id}/audio`);
35
+ a.onended = () => setPlaying(false);
36
+ return a;
37
+ });
38
+
39
+ const togglePlay = () => {
40
+ if (!audio) return;
41
+ if (playing) {
42
+ audio.pause();
43
+ audio.currentTime = 0;
44
+ setPlaying(false);
45
+ } else {
46
+ audio.play();
47
+ setPlaying(true);
48
+ }
49
+ };
50
+
51
+ const created = new Date(note.createdAtMs).toLocaleDateString('en-US', {
52
+ month: 'short',
53
+ day: 'numeric',
54
+ hour: '2-digit',
55
+ minute: '2-digit',
56
+ });
57
+
58
+ return (
59
+ <div className="voice-note-card">
60
+ <div className="voice-note-card-head">
61
+ <div className="voice-note-card-meta">
62
+ <span className="voice-note-date">{created}</span>
63
+ {note.durationSec && (
64
+ <span className="voice-note-duration">{Math.round(note.durationSec)}s</span>
65
+ )}
66
+ </div>
67
+ <div className="voice-note-card-actions">
68
+ {audio && (
69
+ <button
70
+ type="button"
71
+ className="icon-btn"
72
+ onClick={togglePlay}
73
+ aria-label={playing ? 'Pause' : 'Play'}
74
+ title={playing ? 'Pause' : 'Play'}
75
+ >
76
+ {playing ? <Pause size={13} /> : <Play size={13} />}
77
+ </button>
78
+ )}
79
+ <button
80
+ type="button"
81
+ className="icon-btn"
82
+ onClick={() => onDelete(note.id)}
83
+ aria-label="Delete note"
84
+ title="Delete note"
85
+ >
86
+ <Trash2 size={13} />
87
+ </button>
88
+ </div>
89
+ </div>
90
+
91
+ {note.transcript && (
92
+ <p className="voice-note-transcript">{note.transcript}</p>
93
+ )}
94
+
95
+ {!note.transcript && (
96
+ <p className="voice-note-no-transcript muted text-sm">No transcription available</p>
97
+ )}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export function VoiceNotesPanel({ vaultPath, refreshKey = 0 }: Props) {
103
+ const [notes, setNotes] = useState<VoiceNote[]>([]);
104
+ const [search, setSearch] = useState('');
105
+ const [loading, setLoading] = useState(true);
106
+
107
+ const loadNotes = async () => {
108
+ setLoading(true);
109
+ try {
110
+ const vaultParam = vaultPath ? `?vaultPath=${encodeURIComponent(vaultPath)}` : '';
111
+ const r = await fetch(`/api/voice/list${vaultParam}`);
112
+ const data = await r.json();
113
+ setNotes(data.notes || []);
114
+ } catch (err) {
115
+ console.error('[VoiceNotesPanel] failed to load notes:', err);
116
+ } finally {
117
+ setLoading(false);
118
+ }
119
+ };
120
+
121
+ useEffect(() => {
122
+ loadNotes();
123
+ }, [vaultPath, refreshKey]);
124
+
125
+ const handleDelete = async (id: string) => {
126
+ try {
127
+ const r = await fetch(`/api/voice/${id}`, { method: 'DELETE' });
128
+ if (r.ok || r.status === 204) {
129
+ setNotes((prev) => prev.filter((n) => n.id !== id));
130
+ }
131
+ } catch (err) {
132
+ console.error('[VoiceNotesPanel] delete failed:', err);
133
+ }
134
+ };
135
+
136
+ const filtered = notes.filter(
137
+ (n) =>
138
+ !search ||
139
+ (n.transcript?.toLowerCase().includes(search.toLowerCase()) ?? false),
140
+ );
141
+
142
+ return (
143
+ <Card>
144
+ <CardTitle>
145
+ <Mic size={14} /> Voice Notes
146
+ </CardTitle>
147
+ <CardMeta>Record and transcribe audio notes into your vault</CardMeta>
148
+
149
+ {/* Search */}
150
+ <div className="memory-search-row">
151
+ <input
152
+ type="text"
153
+ className="input search-input"
154
+ value={search}
155
+ onChange={(e) => setSearch(e.target.value)}
156
+ placeholder="Search transcripts…"
157
+ aria-label="Search voice notes"
158
+ />
159
+ </div>
160
+
161
+ {/* Recorder */}
162
+ <div className="voice-recorder-row">
163
+ <VoiceRecorder vaultPath={vaultPath} onSaved={loadNotes} />
164
+ </div>
165
+
166
+ {/* Note list */}
167
+ {loading ? (
168
+ <p className="muted text-sm">Loading notes…</p>
169
+ ) : filtered.length === 0 ? (
170
+ <p className="muted text-sm">
171
+ {search ? 'No notes match your search.' : 'No voice notes yet. Hit Record to add one.'}
172
+ </p>
173
+ ) : (
174
+ <div className="voice-note-list">
175
+ {filtered.map((n) => (
176
+ <NoteCard key={n.id} note={n} onDelete={handleDelete} />
177
+ ))}
178
+ </div>
179
+ )}
180
+ </Card>
181
+ );
182
+ }
@@ -0,0 +1,104 @@
1
+ // src/web/components/VoiceRecorder.tsx — v5.0.0
2
+ //
3
+ // MediaRecorder wrapper: records audio in webm/opus, uploads to /api/voice/upload
4
+ // on stop, then calls onSaved(notePath, transcription) so the parent can refresh
5
+ // the note list.
6
+
7
+ import { useState, useRef } from 'react';
8
+ import { Mic, Square, Loader2 } from 'lucide-react';
9
+ import { Button } from './Button';
10
+
11
+ export type VoiceRecorderProps = {
12
+ /** Vault path to store the transcript under */
13
+ vaultPath?: string;
14
+ /** Called after the note is saved with the note path and transcription text */
15
+ onSaved?: (notePath: string, transcription: string | null) => void;
16
+ };
17
+
18
+ export function VoiceRecorder({ vaultPath, onSaved }: VoiceRecorderProps) {
19
+ const [recording, setRecording] = useState(false);
20
+ const [transcribing, setTranscribing] = useState(false);
21
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
22
+ const chunksRef = useRef<Blob[]>([]);
23
+
24
+ const startRecording = async () => {
25
+ try {
26
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
27
+ // Prefer webm/opus; fall back to whatever the browser offers
28
+ const mimeType =
29
+ MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
30
+ ? 'audio/webm;codecs=opus'
31
+ : 'audio/webm';
32
+ const recorder = new MediaRecorder(stream, { mimeType });
33
+
34
+ recorder.ondataavailable = (e) => {
35
+ if (e.data && e.data.size > 0) {
36
+ chunksRef.current.push(e.data);
37
+ }
38
+ };
39
+
40
+ recorder.onstop = async () => {
41
+ const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
42
+ chunksRef.current = [];
43
+ // Stop all tracks to release the microphone
44
+ stream.getTracks().forEach((t) => t.stop());
45
+ await uploadAndTranscribe(blob);
46
+ };
47
+
48
+ recorder.start(1000); // timeslice=1s for chunk delivery
49
+ mediaRecorderRef.current = recorder;
50
+ setRecording(true);
51
+ } catch (err) {
52
+ // getUserMedia not available or permission denied
53
+ console.error('[VoiceRecorder] failed to start recording:', err);
54
+ }
55
+ };
56
+
57
+ const stopRecording = () => {
58
+ const rec = mediaRecorderRef.current;
59
+ if (!rec) return;
60
+ rec.stop();
61
+ setRecording(false);
62
+ setTranscribing(true);
63
+ };
64
+
65
+ const uploadAndTranscribe = async (blob: Blob) => {
66
+ try {
67
+ const form = new FormData();
68
+ form.append('audio', blob, 'voice-note.webm');
69
+ form.append('vaultPath', vaultPath || '');
70
+ const res = await fetch('/api/voice/upload', { method: 'POST', body: form });
71
+ if (!res.ok) {
72
+ console.error('[VoiceRecorder] upload failed:', res.status, await res.text());
73
+ setTranscribing(false);
74
+ return;
75
+ }
76
+ const { notePath, transcription } = await res.json();
77
+ onSaved?.(notePath, transcription);
78
+ } catch (err) {
79
+ console.error('[VoiceRecorder] upload error:', err);
80
+ } finally {
81
+ setTranscribing(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <div className="voice-recorder">
87
+ {!recording && !transcribing && (
88
+ <Button variant="accent" size="sm" onClick={startRecording} aria-label="Start recording">
89
+ <Mic size={14} /> Record
90
+ </Button>
91
+ )}
92
+ {recording && (
93
+ <Button variant="danger" size="sm" onClick={stopRecording} aria-label="Stop recording">
94
+ <Square size={14} /> Stop
95
+ </Button>
96
+ )}
97
+ {transcribing && (
98
+ <Button variant="secondary" size="sm" disabled>
99
+ <Loader2 size={14} className="spinning" /> Transcribing…
100
+ </Button>
101
+ )}
102
+ </div>
103
+ );
104
+ }