@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.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +9 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/otel.mjs +133 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/chat.mjs +246 -170
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/server.mjs +40 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +166 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +22 -2
- package/bizar-dash/src/web/views/Settings.tsx +99 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/docker-build.test.mjs +96 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/otel.test.mjs +188 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/dash.mjs +6 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -2
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
35
|
-
if (!query.trim()) return [];
|
|
29
|
+
const RECENT_KEY = 'bizar_settings_recent';
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
51
|
+
/* ─── Highlight matched terms ─── */
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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<
|
|
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
|
-
|
|
100
|
-
|
|
115
|
+
const run = () => {
|
|
116
|
+
const results = fuzzySearch(query, allItems.current);
|
|
117
|
+
setMatches(results);
|
|
101
118
|
setActiveIdx(0);
|
|
102
|
-
}
|
|
119
|
+
};
|
|
120
|
+
if (immediate) {
|
|
121
|
+
run();
|
|
122
|
+
} else {
|
|
123
|
+
debounceRef.current = setTimeout(run, 150);
|
|
124
|
+
}
|
|
103
125
|
},
|
|
104
|
-
[
|
|
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'
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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:
|
|
146
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
+
}
|