arkaos 3.78.0 → 4.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 (77) hide show
  1. package/VERSION +1 -1
  2. package/config/agent-allowlists/laravel.yaml +1 -0
  3. package/config/agent-allowlists/node.yaml +1 -0
  4. package/config/agent-allowlists/nuxt.yaml +1 -0
  5. package/config/agent-allowlists/python.yaml +1 -0
  6. package/core/agents/__pycache__/registry_gen.cpython-313.pyc +0 -0
  7. package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
  8. package/core/agents/registry_gen.py +6 -1
  9. package/core/agents/schema.py +4 -0
  10. package/core/cognition/__pycache__/reorganizer.cpython-313.pyc +0 -0
  11. package/core/cognition/reorganizer.py +37 -7
  12. package/core/governance/__pycache__/design_system_lint.cpython-313.pyc +0 -0
  13. package/core/governance/__pycache__/design_system_lint_cli.cpython-313.pyc +0 -0
  14. package/core/knowledge/__pycache__/agent_match.cpython-313.pyc +0 -0
  15. package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
  16. package/core/knowledge/__pycache__/ingest.cpython-313.pyc +0 -0
  17. package/core/knowledge/__pycache__/sources.cpython-313.pyc +0 -0
  18. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  19. package/core/knowledge/agent_match.py +114 -0
  20. package/core/knowledge/chunker.py +45 -0
  21. package/core/knowledge/ingest.py +156 -78
  22. package/core/knowledge/sources.py +138 -0
  23. package/core/knowledge/vector_store.py +52 -0
  24. package/core/squads/__pycache__/loader.cpython-313.pyc +0 -0
  25. package/core/squads/loader.py +25 -0
  26. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  27. package/core/sync/agent_provisioner.py +19 -8
  28. package/dashboard/app/components/KnowledgeSourcesList.vue +40 -13
  29. package/dashboard/app/pages/cognition.vue +9 -4
  30. package/dashboard/app/pages/knowledge/[id].vue +669 -0
  31. package/dashboard/app/pages/knowledge/index.vue +1281 -0
  32. package/dashboard/app/types/index.d.ts +1 -1
  33. package/departments/brand/agents/ux-designer.yaml +15 -1
  34. package/departments/brand/agents/ux-researcher.yaml +73 -0
  35. package/departments/brand/agents/ux-strategist.yaml +72 -0
  36. package/departments/dev/agents/ai-engineering/ai-engineering-lead.yaml +76 -0
  37. package/departments/dev/agents/architect.yaml +9 -3
  38. package/departments/dev/agents/backend-core/laravel-eng.yaml +76 -0
  39. package/departments/dev/agents/backend-core/node-ts-eng.yaml +76 -0
  40. package/departments/dev/agents/backend-core/python-eng.yaml +76 -0
  41. package/departments/dev/agents/backend-dev.yaml +10 -4
  42. package/departments/dev/agents/data-platform/etl-eng.yaml +74 -0
  43. package/departments/dev/agents/dba.yaml +7 -3
  44. package/departments/dev/references/backend-knowledge-and-tools.md +70 -0
  45. package/departments/ecom/agents/retention-manager.yaml +13 -1
  46. package/departments/leadership/agents/culture-coach.yaml +20 -0
  47. package/departments/leadership/agents/hr-specialist.yaml +18 -0
  48. package/departments/leadership/agents/leadership-director.yaml +10 -0
  49. package/departments/org/agents/chief-of-staff.yaml +76 -0
  50. package/departments/org/agents/coo.yaml +11 -0
  51. package/departments/org/agents/okr-steward.yaml +71 -0
  52. package/departments/org/agents/org-designer.yaml +23 -0
  53. package/departments/org/skills/okr-cadence/SKILL.md +34 -0
  54. package/departments/org/skills/principles-audit/SKILL.md +36 -0
  55. package/departments/pm/agents/pm-director.yaml +21 -8
  56. package/departments/pm/agents/product-owner.yaml +24 -2
  57. package/departments/pm/agents/scrum-master.yaml +21 -0
  58. package/departments/pm/agents/strategic-pm.yaml +72 -0
  59. package/departments/pm/skills/discovery-plan/SKILL.md +7 -1
  60. package/departments/quality/agents/cqo.yaml +8 -0
  61. package/departments/saas/agents/cs-manager.yaml +19 -2
  62. package/departments/saas/agents/growth-engineer.yaml +14 -1
  63. package/departments/saas/agents/metrics-analyst.yaml +17 -1
  64. package/departments/saas/agents/revops-lead.yaml +73 -0
  65. package/departments/saas/skills/leaky-bucket/SKILL.md +28 -0
  66. package/departments/saas/skills/voc-loop/SKILL.md +29 -0
  67. package/departments/sales/agents/sales-director.yaml +9 -0
  68. package/departments/sales/agents/sdr.yaml +72 -0
  69. package/departments/strategy/agents/decision-quality.yaml +72 -0
  70. package/departments/strategy/agents/strategy-director.yaml +13 -0
  71. package/departments/strategy/skills/premortem/SKILL.md +33 -0
  72. package/knowledge/agents-registry-v2.json +1218 -78
  73. package/package.json +1 -1
  74. package/pyproject.toml +1 -1
  75. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  76. package/scripts/dashboard-api.py +376 -13
  77. package/dashboard/app/pages/knowledge.vue +0 -918
@@ -0,0 +1,1281 @@
1
+ <script setup lang="ts">
2
+ import type { KnowledgeStats, KnowledgeSearchResult, IngestRequest, IngestResponse, IngestTask } from '~/types'
3
+
4
+ const { fetchApi, apiBase } = useApi()
5
+
6
+ const { data: stats, status, error, refresh } = await fetchApi<KnowledgeStats>('/api/knowledge/stats')
7
+
8
+ const isIndexed = computed(() => (stats.value?.total_chunks ?? 0) > 0)
9
+
10
+ // --- Ingest Form State ---
11
+ const ingestUrl = ref('')
12
+ const ingestFile = ref<File | null>(null)
13
+ const ingestFileInputRef = ref<HTMLInputElement | null>(null)
14
+ const isIngesting = ref(false)
15
+ const ingestError = ref<string | null>(null)
16
+ const isDragging = ref(false)
17
+ const pasteText = ref('')
18
+ const pasteTitle = ref('')
19
+ // PR56 v2.73.0 — bulk URL ingest mode. Paste a list of URLs (one per
20
+ // line) and the backend queues one job per source.
21
+ const bulkUrls = ref('')
22
+
23
+ const activeInputMode = ref<'url' | 'file' | 'text' | 'research' | 'bulk' | 'record'>('url')
24
+
25
+ const inputModes = [
26
+ { label: 'URL', value: 'url' as const, icon: 'i-lucide-link' },
27
+ { label: 'Bulk', value: 'bulk' as const, icon: 'i-lucide-list' },
28
+ { label: 'File', value: 'file' as const, icon: 'i-lucide-upload' },
29
+ { label: 'Text', value: 'text' as const, icon: 'i-lucide-type' },
30
+ { label: 'Research', value: 'research' as const, icon: 'i-lucide-search' },
31
+ { label: 'Record', value: 'record' as const, icon: 'i-lucide-mic' },
32
+ ]
33
+
34
+ const bulkUrlCount = computed(() =>
35
+ bulkUrls.value
36
+ .split('\n')
37
+ .map((s) => s.trim())
38
+ .filter((s) => s.length > 0).length
39
+ )
40
+
41
+ function handleDrop(e: DragEvent) {
42
+ isDragging.value = false
43
+ const file = e.dataTransfer?.files?.[0]
44
+ if (file) {
45
+ ingestFile.value = file
46
+ ingestUrl.value = ''
47
+ }
48
+ }
49
+
50
+ type SourceType = IngestRequest['type'] | null
51
+
52
+ const detectedType = computed<SourceType>(() => {
53
+ const url = ingestUrl.value.trim()
54
+ if (url) {
55
+ if (url.startsWith('blob:')) return 'video'
56
+ if (/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//i.test(url)) return 'youtube'
57
+ if (/\.pdf(\?.*)?$/i.test(url)) return 'pdf'
58
+ if (/\.(mp3|wav|m4a|ogg|flac)(\?.*)?$/i.test(url)) return 'audio'
59
+ if (/\.(mp4|mov|webm|mkv|avi)(\?.*)?$/i.test(url)) return 'video'
60
+ if (/\.(md|mdx)(\?.*)?$/i.test(url)) return 'markdown'
61
+ if (/^https?:\/\//i.test(url)) return 'web'
62
+ }
63
+ if (ingestFile.value) {
64
+ const name = ingestFile.value.name.toLowerCase()
65
+ if (name.endsWith('.pdf')) return 'pdf'
66
+ if (/\.(mp3|wav|m4a|ogg|flac)$/.test(name)) return 'audio'
67
+ if (/\.(mp4|mov|webm|mkv|avi)$/.test(name)) return 'video'
68
+ if (/\.(md|mdx)$/.test(name)) return 'markdown'
69
+ }
70
+ return null
71
+ })
72
+
73
+ const typeColorMap: Record<string, 'error' | 'primary' | 'warning' | 'success' | 'neutral'> = {
74
+ youtube: 'error',
75
+ web: 'primary',
76
+ pdf: 'warning',
77
+ audio: 'success',
78
+ markdown: 'neutral',
79
+ video: 'error'
80
+ }
81
+
82
+ const typeIconMap: Record<string, string> = {
83
+ youtube: 'i-lucide-youtube',
84
+ web: 'i-lucide-globe',
85
+ pdf: 'i-lucide-file-text',
86
+ audio: 'i-lucide-headphones',
87
+ markdown: 'i-lucide-file-code',
88
+ video: 'i-lucide-video'
89
+ }
90
+
91
+ function handleFileSelect(event: Event) {
92
+ const target = event.target as HTMLInputElement
93
+ ingestFile.value = target.files?.[0] ?? null
94
+ if (ingestFile.value) {
95
+ ingestUrl.value = ''
96
+ }
97
+ }
98
+
99
+ function clearFile() {
100
+ ingestFile.value = null
101
+ if (ingestFileInputRef.value) {
102
+ ingestFileInputRef.value.value = ''
103
+ }
104
+ }
105
+
106
+ function extFromMime(mime: string): string {
107
+ const map: Record<string, string> = {
108
+ 'video/mp4': 'mp4',
109
+ 'video/quicktime': 'mov',
110
+ 'video/webm': 'webm',
111
+ 'video/x-matroska': 'mkv',
112
+ 'video/x-msvideo': 'avi'
113
+ }
114
+ return map[mime] ?? 'mp4'
115
+ }
116
+
117
+ // Upload a File via multipart. handleIngest's post-branch wiring
118
+ // (fetchJobs + connectWebSocket) tracks progress — no WS logic here.
119
+ async function uploadFile(file: File) {
120
+ const formData = new FormData()
121
+ formData.append('file', file)
122
+ return await $fetch(`${apiBase}/api/knowledge/upload-file`, {
123
+ method: 'POST',
124
+ body: formData
125
+ })
126
+ }
127
+
128
+ // --- Record mode (PR — capture the audio output the player produces) ---
129
+ // We never touch a protected/encrypted stream. We record the audio the
130
+ // machine legitimately plays (the analog hole), same category as Otter or
131
+ // Descript recording a meeting. The resulting .webm flows through the
132
+ // existing upload -> Whisper -> KB pipeline via uploadFile().
133
+ const recordTitle = ref('')
134
+ const recordSource = ref<'tab' | 'device'>('tab')
135
+ const recordDevices = ref<{ label: string, value: string }[]>([])
136
+ const selectedDeviceId = ref('')
137
+ const isRecording = ref(false)
138
+ const recordElapsed = ref(0)
139
+
140
+ let mediaRecorder: MediaRecorder | null = null
141
+ let recordStream: MediaStream | null = null
142
+ let recordChunks: Blob[] = []
143
+ let recordTimer: ReturnType<typeof setInterval> | null = null
144
+
145
+ const recordElapsedLabel = computed(() => {
146
+ const total = recordElapsed.value
147
+ const mm = String(Math.floor(total / 60)).padStart(2, '0')
148
+ const ss = String(total % 60).padStart(2, '0')
149
+ return `${mm}:${ss}`
150
+ })
151
+
152
+ const canStartRecording = computed(() => {
153
+ if (isRecording.value) return false
154
+ if (recordSource.value === 'device' && !selectedDeviceId.value) return false
155
+ return true
156
+ })
157
+
158
+ const recordSourceItems = [
159
+ { label: 'Browser tab audio (share the course tab)', value: 'tab' },
160
+ { label: 'Audio input device', value: 'device' }
161
+ ]
162
+
163
+ // Sanitize a title into a filename-safe stem. Keeps letters, digits, dash,
164
+ // underscore and space; collapses the rest to a single dash.
165
+ function safeFilename(title: string): string {
166
+ const stem = (title || 'recording')
167
+ .trim()
168
+ .replace(/[^a-zA-Z0-9 _-]+/g, '-')
169
+ .replace(/-+/g, '-')
170
+ .replace(/^-|-$/g, '')
171
+ .slice(0, 80)
172
+ return stem || 'recording'
173
+ }
174
+
175
+ function pickRecorderMime(): string | undefined {
176
+ if (typeof MediaRecorder === 'undefined') return undefined
177
+ const prefs = ['audio/webm;codecs=opus', 'audio/webm']
178
+ for (const m of prefs) {
179
+ if (MediaRecorder.isTypeSupported(m)) return m
180
+ }
181
+ return undefined
182
+ }
183
+
184
+ async function loadAudioDevices() {
185
+ if (!import.meta.client || !navigator.mediaDevices?.enumerateDevices) return
186
+ try {
187
+ const devices = await navigator.mediaDevices.enumerateDevices()
188
+ recordDevices.value = devices
189
+ .filter(d => d.kind === 'audioinput')
190
+ .map(d => ({ label: d.label || 'Microphone', value: d.deviceId }))
191
+ if (!selectedDeviceId.value && recordDevices.value.length) {
192
+ selectedDeviceId.value = recordDevices.value[0]!.value
193
+ }
194
+ } catch {
195
+ // enumeration can fail before any permission grant; ignore quietly
196
+ }
197
+ }
198
+
199
+ function stopRecordStream() {
200
+ if (recordStream) {
201
+ recordStream.getTracks().forEach(t => t.stop())
202
+ recordStream = null
203
+ }
204
+ }
205
+
206
+ function clearRecordTimer() {
207
+ if (recordTimer) {
208
+ clearInterval(recordTimer)
209
+ recordTimer = null
210
+ }
211
+ }
212
+
213
+ async function startRecording() {
214
+ if (!import.meta.client) return
215
+ ingestError.value = null
216
+ recordChunks = []
217
+ try {
218
+ if (!navigator.mediaDevices) {
219
+ ingestError.value = 'Recording is not supported in this browser.'
220
+ return
221
+ }
222
+ let audioStream: MediaStream
223
+ if (recordSource.value === 'tab') {
224
+ // Chrome only offers tab audio when video is requested; we capture
225
+ // video then immediately drop the video track and keep audio only.
226
+ recordStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
227
+ const audioTracks = recordStream.getAudioTracks()
228
+ if (!audioTracks.length) {
229
+ stopRecordStream()
230
+ ingestError.value = `No audio track was shared. Make sure 'Share tab audio' is checked, or use an audio input device.`
231
+ return
232
+ }
233
+ // Drop the video track to keep only audio.
234
+ recordStream.getVideoTracks().forEach(t => t.stop())
235
+ audioStream = new MediaStream(audioTracks)
236
+ } else {
237
+ recordStream = await navigator.mediaDevices.getUserMedia({
238
+ audio: { deviceId: selectedDeviceId.value ? { exact: selectedDeviceId.value } : undefined }
239
+ })
240
+ audioStream = recordStream
241
+ }
242
+
243
+ const mimeType = pickRecorderMime()
244
+ mediaRecorder = mimeType
245
+ ? new MediaRecorder(audioStream, { mimeType })
246
+ : new MediaRecorder(audioStream)
247
+ mediaRecorder.ondataavailable = (e: BlobEvent) => {
248
+ if (e.data && e.data.size > 0) recordChunks.push(e.data)
249
+ }
250
+ mediaRecorder.onstop = () => {
251
+ void finalizeRecording()
252
+ }
253
+ mediaRecorder.start()
254
+
255
+ isRecording.value = true
256
+ recordElapsed.value = 0
257
+ clearRecordTimer()
258
+ recordTimer = setInterval(() => {
259
+ recordElapsed.value += 1
260
+ }, 1000)
261
+ } catch (err) {
262
+ stopRecordStream()
263
+ isRecording.value = false
264
+ const name = (err as DOMException)?.name
265
+ ingestError.value = name === 'NotAllowedError'
266
+ ? 'Permission to capture audio was denied.'
267
+ : 'Could not start recording. Your browser may not support audio capture.'
268
+ }
269
+ }
270
+
271
+ function stopRecording() {
272
+ if (!isRecording.value) return
273
+ clearRecordTimer()
274
+ isRecording.value = false
275
+ // onstop -> finalizeRecording builds the file and releases tracks.
276
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
277
+ mediaRecorder.stop()
278
+ } else {
279
+ void finalizeRecording()
280
+ }
281
+ }
282
+
283
+ // Build a File from the captured chunks and push it through the SAME path
284
+ // the file-upload mode uses (uploadFile + activeTask/WS/jobs wiring).
285
+ async function finalizeRecording() {
286
+ const mime = mediaRecorder?.mimeType || 'audio/webm'
287
+ const blob = new Blob(recordChunks, { type: mime })
288
+ recordChunks = []
289
+ stopRecordStream()
290
+ mediaRecorder = null
291
+ clearRecordTimer()
292
+
293
+ if (!blob.size) {
294
+ ingestError.value = 'No audio was captured.'
295
+ return
296
+ }
297
+
298
+ const name = `${safeFilename(recordTitle.value)}-${Date.now()}.webm`
299
+ const file = new File([blob], name, { type: blob.type })
300
+
301
+ ingestError.value = null
302
+ try {
303
+ const res = await uploadFile(file) as { job_id?: string } | undefined
304
+ const jobId = res?.job_id
305
+ if (jobId) {
306
+ activeTask.value = {
307
+ id: jobId,
308
+ title: recordTitle.value || name,
309
+ source_type: 'audio',
310
+ status: 'queued',
311
+ progress_percent: 0,
312
+ progress_message: 'Queued for transcription...'
313
+ } as IngestTask
314
+ isIngesting.value = true
315
+ localStorage.setItem(ACTIVE_TASK_KEY, jobId)
316
+ }
317
+ recordTitle.value = ''
318
+ fetchJobs()
319
+ connectWebSocket()
320
+ } catch (err) {
321
+ ingestError.value = err instanceof Error ? err.message : 'Failed to queue the recording'
322
+ }
323
+ }
324
+
325
+ // Re-enumerate devices when the user picks the device source.
326
+ watch(recordSource, (mode) => {
327
+ if (mode === 'device') loadAudioDevices()
328
+ })
329
+
330
+ const canIngest = computed(() => {
331
+ if (activeInputMode.value === 'record') return false
332
+ if (activeInputMode.value === 'bulk') return bulkUrlCount.value > 0
333
+ return detectedType.value !== null
334
+ })
335
+
336
+ // --- Active Ingestion Tracking via WebSocket ---
337
+ const activeTask = ref<IngestTask | null>(null)
338
+ let ws: WebSocket | null = null
339
+
340
+ // Persist active task ID across page navigation
341
+ const ACTIVE_TASK_KEY = 'arkaos_active_ingest_task'
342
+
343
+ async function restoreActiveTask() {
344
+ const savedId = localStorage.getItem(ACTIVE_TASK_KEY)
345
+ if (!savedId) return
346
+ try {
347
+ const task = await $fetch<any>(`${apiBase}/api/tasks/${savedId}`)
348
+ if (task && task.status && !['completed', 'failed', 'cancelled'].includes(task.status)) {
349
+ activeTask.value = task
350
+ isIngesting.value = true
351
+ connectWebSocket()
352
+ } else {
353
+ localStorage.removeItem(ACTIVE_TASK_KEY)
354
+ }
355
+ } catch {
356
+ localStorage.removeItem(ACTIVE_TASK_KEY)
357
+ }
358
+ }
359
+
360
+ onMounted(() => {
361
+ restoreActiveTask()
362
+ })
363
+
364
+ function connectWebSocket() {
365
+ if (ws && ws.readyState === WebSocket.OPEN) return
366
+
367
+ const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
368
+ ws = new WebSocket(wsUrl)
369
+
370
+ ws.onmessage = (event) => {
371
+ try {
372
+ const data = JSON.parse(event.data)
373
+ const jobId = data.job_id || data.task_id
374
+
375
+ // Update active task if it matches
376
+ if (activeTask.value && jobId === activeTask.value.id) {
377
+ if (data.type === 'job_progress' || data.type === 'task_progress') {
378
+ activeTask.value.progress_percent = data.progress
379
+ activeTask.value.progress_message = data.message
380
+ activeTask.value.status = data.status
381
+ } else if (data.type === 'job_complete' || data.type === 'task_complete') {
382
+ activeTask.value.status = 'completed'
383
+ activeTask.value.progress_percent = 100
384
+ activeTask.value.output_data = { chunks_created: data.chunks_created }
385
+ isIngesting.value = false
386
+ localStorage.removeItem(ACTIVE_TASK_KEY)
387
+ refresh()
388
+ fetchJobs()
389
+ fetchKnowledgeSources()
390
+ } else if (data.type === 'job_failed' || data.type === 'task_failed') {
391
+ activeTask.value.status = 'failed'
392
+ activeTask.value.error = data.error
393
+ isIngesting.value = false
394
+ localStorage.removeItem(ACTIVE_TASK_KEY)
395
+ }
396
+ }
397
+
398
+ // Always refresh jobs table on any job event
399
+ if (data.type?.startsWith('job_')) {
400
+ fetchJobs()
401
+ }
402
+ } catch {}
403
+ }
404
+
405
+ ws.onclose = () => {
406
+ // Reconnect after 2s if still ingesting
407
+ if (isIngesting.value) {
408
+ setTimeout(connectWebSocket, 2000)
409
+ }
410
+ }
411
+ }
412
+
413
+ function disconnectWebSocket() {
414
+ if (ws) {
415
+ ws.close()
416
+ ws = null
417
+ }
418
+ }
419
+
420
+ onUnmounted(() => {
421
+ disconnectWebSocket()
422
+ clearRecordTimer()
423
+ stopRecordStream()
424
+ })
425
+
426
+ async function handleIngest() {
427
+ if (
428
+ !detectedType.value
429
+ && activeInputMode.value !== 'text'
430
+ && activeInputMode.value !== 'bulk'
431
+ ) return
432
+
433
+ ingestError.value = null
434
+
435
+ try {
436
+ // File upload — use multipart form
437
+ if (activeInputMode.value === 'file' && ingestFile.value) {
438
+ await uploadFile(ingestFile.value)
439
+ }
440
+ // blob: URL — fetch the bytes client-side, then upload as a File
441
+ else if (ingestUrl.value.trim().startsWith('blob:')) {
442
+ const blobUrl = ingestUrl.value.trim()
443
+ try {
444
+ const resp = await fetch(blobUrl)
445
+ const blob = await resp.blob()
446
+ const file = new File([blob], `video-${Date.now()}.${extFromMime(blob.type)}`, { type: blob.type })
447
+ await uploadFile(file)
448
+ } catch {
449
+ ingestError.value = 'Could not read the blob: URL (it may have been revoked, cross-origin, or DRM-protected). Download the file and use the File tab instead.'
450
+ return
451
+ }
452
+ }
453
+ // Text paste — save to temp file via API
454
+ else if (activeInputMode.value === 'text' && pasteText.value.length > 10) {
455
+ await $fetch(`${apiBase}/api/knowledge/ingest`, {
456
+ method: 'POST',
457
+ body: { source: pasteText.value.slice(0, 100), type: 'markdown', text: pasteText.value, title: pasteTitle.value },
458
+ })
459
+ }
460
+ // Bulk URL paste — one job per non-blank line, server caps at 50
461
+ else if (activeInputMode.value === 'bulk' && bulkUrlCount.value > 0) {
462
+ const sources = bulkUrls.value
463
+ .split('\n')
464
+ .map((s) => s.trim())
465
+ .filter((s) => s.length > 0)
466
+ await $fetch(`${apiBase}/api/knowledge/ingest-bulk`, {
467
+ method: 'POST',
468
+ body: { sources },
469
+ })
470
+ }
471
+ // URL or Research — standard ingest
472
+ else {
473
+ const source = ingestUrl.value.trim()
474
+ const type = detectedType.value
475
+ if (!source || !type) return
476
+ await $fetch(`${apiBase}/api/knowledge/ingest`, {
477
+ method: 'POST',
478
+ body: { source, type },
479
+ })
480
+ }
481
+
482
+ // Clear form immediately
483
+ ingestUrl.value = ''
484
+ clearFile()
485
+ pasteText.value = ''
486
+ pasteTitle.value = ''
487
+ bulkUrls.value = ''
488
+
489
+ // Refresh jobs table + connect WebSocket
490
+ fetchJobs()
491
+ connectWebSocket()
492
+ } catch (err) {
493
+ ingestError.value = err instanceof Error ? err.message : 'Failed to queue job'
494
+ }
495
+ }
496
+
497
+ function retryIngest() {
498
+ activeTask.value = null
499
+ ingestError.value = null
500
+ }
501
+
502
+ function dismissActiveTask() {
503
+ activeTask.value = null
504
+ ingestUrl.value = ''
505
+ localStorage.removeItem(ACTIVE_TASK_KEY)
506
+ clearFile()
507
+ }
508
+
509
+ // --- Jobs Table (SQLite) ---
510
+ const jobs = ref<any[]>([])
511
+ const jobsSummary = ref<any>({})
512
+
513
+ async function fetchJobs() {
514
+ try {
515
+ const response = await $fetch<{ jobs: any[], summary: any }>(`${apiBase}/api/jobs`)
516
+ jobs.value = response.jobs ?? []
517
+ jobsSummary.value = response.summary ?? {}
518
+ } catch {}
519
+ }
520
+
521
+ fetchJobs()
522
+
523
+ // --- Job row -> source detail id (make completed job rows clickable) ---
524
+ // The jobs table only carries the `source` string, not the stable
525
+ // `src-<sha1>` detail id. GET /api/knowledge/sources already returns
526
+ // `{id, source, ...}` for every indexed source, keyed on the SAME source
527
+ // string. We fetch it once and build a source->id lookup so a completed job
528
+ // row can link to /knowledge/{id} — the per-source detail page (player +
529
+ // transcript + download).
530
+ const knowledgeSources = ref<{ source: string, id: string }[]>([])
531
+
532
+ async function fetchKnowledgeSources() {
533
+ try {
534
+ const response = await $fetch<{ sources: { source: string, id: string }[] }>(
535
+ `${apiBase}/api/knowledge/sources`
536
+ )
537
+ knowledgeSources.value = response.sources ?? []
538
+ } catch {}
539
+ }
540
+
541
+ fetchKnowledgeSources()
542
+
543
+ const sourceIdBySource = computed(
544
+ () => new Map(knowledgeSources.value.map(s => [s.source, s.id]))
545
+ )
546
+
547
+ // A job row opens its detail page only when it has completed AND a matching
548
+ // indexed source exists. Failed/processing/queued jobs have no detail page.
549
+ function jobDetailId(job: { source?: string, status?: string }): string | null {
550
+ if (job.status !== 'completed' || !job.source) return null
551
+ return sourceIdBySource.value.get(job.source) ?? null
552
+ }
553
+
554
+ function formatDate(dateStr: string | undefined) {
555
+ if (!dateStr) return '-'
556
+ try {
557
+ return new Intl.DateTimeFormat('en-US', {
558
+ month: 'short',
559
+ day: 'numeric',
560
+ hour: '2-digit',
561
+ minute: '2-digit'
562
+ }).format(new Date(dateStr))
563
+ } catch {
564
+ return dateStr
565
+ }
566
+ }
567
+
568
+ // --- Search ---
569
+ const searchQuery = ref('')
570
+ const searchResults = ref<KnowledgeSearchResult[]>([])
571
+ const searchTotal = ref(0)
572
+ const searching = ref(false)
573
+ const hasSearched = ref(false)
574
+
575
+ async function handleSearch() {
576
+ if (!searchQuery.value.trim()) {
577
+ searchResults.value = []
578
+ searchTotal.value = 0
579
+ hasSearched.value = false
580
+ return
581
+ }
582
+
583
+ searching.value = true
584
+ hasSearched.value = true
585
+ try {
586
+ const { data } = await useFetch<{ results: KnowledgeSearchResult[], query: string, total: number }>(
587
+ `${apiBase}/api/knowledge/search`,
588
+ { params: { q: searchQuery.value } }
589
+ )
590
+ searchResults.value = data.value?.results ?? []
591
+ searchTotal.value = data.value?.total ?? 0
592
+ } finally {
593
+ searching.value = false
594
+ }
595
+ }
596
+
597
+ function formatScore(score: number): string {
598
+ return `${(score * 100).toFixed(0)}%`
599
+ }
600
+
601
+ // PR73 v2.91.0 — `vec_available` is the canonical PR47-era flag from
602
+ // the new VectorStore; `vss_available` is the legacy field name from
603
+ // earlier sqlite-vss builds. Treat either as "active".
604
+ const vectorSearchActive = computed(() =>
605
+ Boolean(stats.value?.vec_available || stats.value?.vss_available),
606
+ )
607
+
608
+ // PR71 v2.88.0 — delete all chunks from a given source.
609
+
610
+ const deletingSource = ref<string | null>(null)
611
+
612
+ const confirmDialog = useConfirmDialog()
613
+
614
+ async function askDeleteSource(source: string) {
615
+ if (!source) return
616
+ const ok = await confirmDialog({
617
+ title: 'Delete every indexed chunk from this source?',
618
+ description:
619
+ `${source}\n\nRemoves the source from search results but does NOT `
620
+ + 'delete the original file. You can re-ingest later if needed.',
621
+ confirmLabel: 'Delete chunks',
622
+ variant: 'danger',
623
+ })
624
+ if (!ok) return
625
+ await deleteSource(source)
626
+ }
627
+
628
+ async function deleteSource(source: string) {
629
+ deletingSource.value = source
630
+ try {
631
+ const res = await $fetch<{ deleted?: number, source?: string, error?: string }>(
632
+ `${apiBase}/api/knowledge/sources`,
633
+ { method: 'DELETE', query: { source } },
634
+ )
635
+ if (res.error) {
636
+ toast.add({
637
+ title: 'Delete failed',
638
+ description: res.error,
639
+ color: 'error',
640
+ })
641
+ return
642
+ }
643
+ const deleted = res.deleted ?? 0
644
+ // Drop the matching rows from the in-memory list without a full re-fetch.
645
+ searchResults.value = searchResults.value.filter((r) => r.source !== source)
646
+ searchTotal.value = searchResults.value.length
647
+ // Refresh stats so the chunk count in the header updates.
648
+ if (typeof refresh === 'function') {
649
+ await refresh()
650
+ }
651
+ toast.add({
652
+ title: deleted > 0
653
+ ? `Deleted ${deleted} chunk${deleted === 1 ? '' : 's'}`
654
+ : 'Nothing to delete',
655
+ description: source,
656
+ color: 'success',
657
+ })
658
+ } catch (err) {
659
+ toast.add({
660
+ title: 'Delete failed',
661
+ description: err instanceof Error ? err.message : 'unknown error',
662
+ color: 'error',
663
+ })
664
+ } finally {
665
+ deletingSource.value = null
666
+ }
667
+ }
668
+
669
+ // PR71 — highlight the search query in the preview text.
670
+ // Tolerates malformed regex (escapes special characters) and HTML-
671
+ // escapes the input so v-html'd output is safe from XSS via DB rows.
672
+ function highlightMatches(text: string, query: string): string {
673
+ const safe = escapeHtml(text || '')
674
+ const q = (query || '').trim()
675
+ if (!q) return safe
676
+ const pattern = new RegExp(`(${escapeRegex(q)})`, 'gi')
677
+ return safe.replace(
678
+ pattern,
679
+ '<mark class="bg-primary/20 text-primary rounded px-0.5">$1</mark>',
680
+ )
681
+ }
682
+
683
+ function escapeHtml(value: string): string {
684
+ return value
685
+ .replace(/&/g, '&amp;')
686
+ .replace(/</g, '&lt;')
687
+ .replace(/>/g, '&gt;')
688
+ .replace(/"/g, '&quot;')
689
+ .replace(/'/g, '&#39;')
690
+ }
691
+
692
+ function escapeRegex(value: string): string {
693
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
694
+ }
695
+ </script>
696
+
697
+ <template>
698
+ <UDashboardPanel id="knowledge">
699
+ <template #header>
700
+ <UDashboardNavbar title="Knowledge Base">
701
+ <template #leading>
702
+ <UDashboardSidebarCollapse />
703
+ </template>
704
+ <template #trailing>
705
+ <UBadge
706
+ v-if="stats?.vec_available !== undefined || stats?.vss_available !== undefined"
707
+ :label="vectorSearchActive ? 'Vector Active' : 'Vector Off'"
708
+ :color="vectorSearchActive ? 'success' : 'warning'"
709
+ variant="subtle"
710
+ />
711
+ </template>
712
+ </UDashboardNavbar>
713
+ </template>
714
+
715
+ <template #body>
716
+ <!-- Loading -->
717
+ <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
718
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
719
+ </div>
720
+
721
+ <!-- Error -->
722
+ <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
723
+ <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
724
+ <p class="text-sm text-muted">Failed to load knowledge stats.</p>
725
+ <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
726
+ </div>
727
+
728
+ <!-- Content -->
729
+ <template v-else>
730
+ <!-- Single block wrapper: prevents the first card collapsing to
731
+ height:0 as a bare flex child of UDashboardPanel #body. Mirrors
732
+ the budget.vue / tasks.vue pattern (one block child per body).
733
+ No space-y here: children already carry their own mt-* margins. -->
734
+ <div>
735
+ <!-- Add Content Section -->
736
+ <UCard>
737
+ <fieldset class="space-y-5">
738
+ <!-- Input Mode Tabs -->
739
+ <div class="flex items-center gap-1 rounded-lg bg-muted/10 p-1 w-fit">
740
+ <button
741
+ v-for="mode in inputModes"
742
+ :key="mode.value"
743
+ class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
744
+ :class="activeInputMode === mode.value ? 'bg-elevated text-highlighted shadow-sm' : 'text-muted hover:text-highlighted'"
745
+ @click="activeInputMode = mode.value"
746
+ >
747
+ <UIcon :name="mode.icon" class="size-3.5" />
748
+ {{ mode.label }}
749
+ </button>
750
+ </div>
751
+
752
+ <!-- Mode: URL -->
753
+ <div v-if="activeInputMode === 'url'" class="space-y-3">
754
+ <UInput
755
+ v-model="ingestUrl"
756
+ placeholder="Paste a YouTube URL, web page, article, or research link..."
757
+ icon="i-lucide-link"
758
+ size="xl"
759
+ class="w-full"
760
+ :ui="{ base: 'text-base' }"
761
+ @keydown.enter.prevent="canIngest && handleIngest()"
762
+ />
763
+ <div class="flex items-center gap-1.5">
764
+ <UBadge label="YouTube" color="error" variant="outline" size="xs" />
765
+ <UBadge label="Web" color="primary" variant="outline" size="xs" />
766
+ <UBadge label="Articles" color="primary" variant="outline" size="xs" />
767
+ <UBadge label="Docs" color="neutral" variant="outline" size="xs" />
768
+ <UBadge label="Video" color="error" variant="outline" size="xs" />
769
+ </div>
770
+ <p class="text-xs text-muted">
771
+ Direct video links (MP4, MOV, WebM) and <code>blob:</code> URLs are supported.
772
+ </p>
773
+ </div>
774
+
775
+ <!-- Mode: File Upload with Drag & Drop -->
776
+ <div
777
+ v-if="activeInputMode === 'file'"
778
+ class="relative rounded-xl border-2 border-dashed transition-colors p-8 text-center"
779
+ :class="isDragging ? 'border-primary bg-primary/5' : 'border-default hover:border-primary/40'"
780
+ @dragover.prevent="isDragging = true"
781
+ @dragleave.prevent="isDragging = false"
782
+ @drop.prevent="handleDrop"
783
+ >
784
+ <input
785
+ ref="ingestFileInputRef"
786
+ type="file"
787
+ accept=".pdf,.mp3,.wav,.m4a,.ogg,.flac,.md,.mdx,.txt,.mp4,.mov,.webm,.mkv,.avi"
788
+ class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
789
+ @change="handleFileSelect"
790
+ />
791
+ <div v-if="!ingestFile">
792
+ <UIcon name="i-lucide-cloud-upload" class="size-10 text-muted mx-auto mb-3" />
793
+ <p class="text-sm font-medium text-highlighted">Drop files here or click to browse</p>
794
+ <p class="text-xs text-muted mt-1">PDF, MP3, WAV, MP4, MOV, WebM, Markdown, TXT</p>
795
+ </div>
796
+ <div v-else class="flex items-center justify-center gap-3">
797
+ <UIcon :name="typeIconMap[detectedType ?? ''] ?? 'i-lucide-file'" class="size-6 text-primary" />
798
+ <div class="text-left">
799
+ <p class="text-sm font-medium text-highlighted">{{ ingestFile.name }}</p>
800
+ <p class="text-xs text-muted">{{ (ingestFile.size / 1024).toFixed(1) }} KB</p>
801
+ </div>
802
+ <UButton icon="i-lucide-x" variant="ghost" size="xs" @click.stop="clearFile" />
803
+ </div>
804
+ </div>
805
+
806
+ <!-- Mode: Text / Paste -->
807
+ <div v-if="activeInputMode === 'text'" class="space-y-3">
808
+ <textarea
809
+ v-model="pasteText"
810
+ rows="6"
811
+ placeholder="Paste or write text content here... Notes, excerpts, research findings, transcripts..."
812
+ class="w-full rounded-lg border border-default bg-transparent px-4 py-3 text-sm text-highlighted placeholder:text-muted/50 focus:border-primary focus:outline-none resize-y"
813
+ />
814
+ <UInput
815
+ v-model="pasteTitle"
816
+ placeholder="Title (optional) — e.g., 'Meeting Notes Q3', 'Research: Growth Hacking'"
817
+ icon="i-lucide-type"
818
+ size="sm"
819
+ class="w-full"
820
+ />
821
+ </div>
822
+
823
+ <!-- Mode: Bulk URLs (PR56 v2.73.0) -->
824
+ <div v-if="activeInputMode === 'bulk'" class="space-y-3">
825
+ <UTextarea
826
+ v-model="bulkUrls"
827
+ placeholder="Paste one URL per line. Up to 50 sources per batch.&#10;&#10;https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
828
+ :rows="8"
829
+ size="lg"
830
+ class="w-full font-mono text-sm"
831
+ />
832
+ <div class="flex items-center justify-between text-xs text-muted">
833
+ <span>{{ bulkUrlCount }} source{{ bulkUrlCount === 1 ? '' : 's' }} detected</span>
834
+ <span v-if="bulkUrlCount > 50" class="text-red-400">
835
+ Over the 50-source cap — extras will be rejected.
836
+ </span>
837
+ </div>
838
+ </div>
839
+
840
+ <!-- Mode: Research -->
841
+ <div v-if="activeInputMode === 'research'" class="space-y-3">
842
+ <UInput
843
+ v-model="ingestUrl"
844
+ placeholder="Enter a topic or URL to research... e.g., 'Alex Hormozi business model'"
845
+ icon="i-lucide-search"
846
+ size="xl"
847
+ class="w-full"
848
+ :ui="{ base: 'text-base' }"
849
+ @keydown.enter.prevent="canIngest && handleIngest()"
850
+ />
851
+ <p class="text-xs text-muted">ArkaOS will fetch the page, extract the content, and index it into your knowledge base.</p>
852
+ </div>
853
+
854
+ <!-- Mode: Record (capture played audio -> Whisper -> KB) -->
855
+ <div v-if="activeInputMode === 'record'" class="space-y-3">
856
+ <UInput
857
+ v-model="recordTitle"
858
+ placeholder="Title for this recording (e.g. Course — Module 3)"
859
+ icon="i-lucide-mic"
860
+ size="lg"
861
+ class="w-full"
862
+ :disabled="isRecording"
863
+ />
864
+ <USelect
865
+ v-model="recordSource"
866
+ :items="recordSourceItems"
867
+ :disabled="isRecording"
868
+ class="w-full"
869
+ />
870
+ <USelect
871
+ v-if="recordSource === 'device'"
872
+ v-model="selectedDeviceId"
873
+ :items="recordDevices"
874
+ :disabled="isRecording"
875
+ placeholder="Pick an audio input device"
876
+ class="w-full"
877
+ />
878
+ <div class="flex items-center gap-3">
879
+ <UButton
880
+ v-if="!isRecording"
881
+ label="Start recording"
882
+ icon="i-lucide-circle"
883
+ color="primary"
884
+ size="md"
885
+ :disabled="!canStartRecording"
886
+ @click="startRecording"
887
+ />
888
+ <UButton
889
+ v-else
890
+ label="Stop & transcribe"
891
+ icon="i-lucide-square"
892
+ color="error"
893
+ size="md"
894
+ @click="stopRecording"
895
+ />
896
+ <div v-if="isRecording" class="flex items-center gap-2">
897
+ <span class="relative flex size-2.5">
898
+ <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
899
+ <span class="relative inline-flex size-2.5 rounded-full bg-red-500" />
900
+ </span>
901
+ <span class="text-sm font-mono text-highlighted" aria-live="polite">{{ recordElapsedLabel }}</span>
902
+ </div>
903
+ </div>
904
+ <p class="text-xs text-muted">
905
+ Records the audio your computer plays while you watch content you have access to.
906
+ For DRM-protected video, tab audio may be silent — install a virtual audio device
907
+ (BlackHole on macOS, VB-Cable on Windows) and pick it under 'Audio input device'.
908
+ We never access the protected video stream — only the audio output.
909
+ </p>
910
+ </div>
911
+
912
+ <!-- Action Row -->
913
+ <div class="flex items-center justify-between gap-4">
914
+ <div class="flex items-center gap-2">
915
+ <template v-if="detectedType">
916
+ <UIcon :name="typeIconMap[detectedType] ?? 'i-lucide-file'" class="size-4 text-primary" />
917
+ <UBadge
918
+ :label="detectedType.charAt(0).toUpperCase() + detectedType.slice(1)"
919
+ :color="typeColorMap[detectedType] ?? 'neutral'"
920
+ variant="subtle"
921
+ size="sm"
922
+ />
923
+ </template>
924
+ <span v-else-if="activeInputMode === 'text' && pasteText" class="text-xs text-muted">
925
+ {{ pasteText.split(/\s+/).length }} words
926
+ </span>
927
+ </div>
928
+
929
+ <UButton
930
+ v-if="activeInputMode !== 'record'"
931
+ :label="
932
+ activeInputMode === 'research' ? 'Research & Index'
933
+ : activeInputMode === 'bulk' ? `Ingest ${bulkUrlCount} source${bulkUrlCount === 1 ? '' : 's'}`
934
+ : 'Ingest'
935
+ "
936
+ icon="i-lucide-zap"
937
+ size="md"
938
+ :disabled="!canIngest && !(activeInputMode === 'text' && pasteText.length > 50)"
939
+ :loading="false"
940
+ @click="handleIngest"
941
+ />
942
+ </div>
943
+
944
+ <!-- Error -->
945
+ <div v-if="ingestError" class="rounded-md border border-red-500/20 bg-red-500/5 p-3" role="alert">
946
+ <div class="flex items-center gap-2">
947
+ <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500" />
948
+ <p class="text-sm text-red-400">{{ ingestError }}</p>
949
+ </div>
950
+ </div>
951
+ </fieldset>
952
+ </UCard>
953
+
954
+ <!-- Active Ingestion Progress -->
955
+ <div v-if="activeTask" class="mt-4 rounded-lg border border-default p-6">
956
+ <div class="flex items-center justify-between gap-4 mb-4">
957
+ <div class="flex items-center gap-2 min-w-0">
958
+ <UIcon
959
+ v-if="activeTask.status === 'queued' || activeTask.status === 'processing'"
960
+ name="i-lucide-loader-2"
961
+ class="size-5 shrink-0 animate-spin text-primary"
962
+ />
963
+ <UIcon
964
+ v-else-if="activeTask.status === 'completed'"
965
+ name="i-lucide-check-circle"
966
+ class="size-5 shrink-0 text-green-500"
967
+ />
968
+ <UIcon
969
+ v-else-if="activeTask.status === 'failed'"
970
+ name="i-lucide-x-circle"
971
+ class="size-5 shrink-0 text-red-500"
972
+ />
973
+ <span class="text-sm font-medium text-highlighted truncate">{{ activeTask.title }}</span>
974
+ </div>
975
+ <div class="flex items-center gap-2 shrink-0">
976
+ <UBadge
977
+ v-if="activeTask.source_type"
978
+ :label="activeTask.source_type.charAt(0).toUpperCase() + activeTask.source_type.slice(1)"
979
+ :color="typeColorMap[activeTask.source_type] ?? 'neutral'"
980
+ variant="subtle"
981
+ size="sm"
982
+ />
983
+ <UBadge
984
+ :label="activeTask.status"
985
+ :color="activeTask.status === 'completed' ? 'success' : activeTask.status === 'failed' ? 'error' : 'primary'"
986
+ variant="subtle"
987
+ size="sm"
988
+ class="capitalize"
989
+ />
990
+ </div>
991
+ </div>
992
+
993
+ <!-- Progress Bar -->
994
+ <div v-if="activeTask.status !== 'failed'" class="space-y-2">
995
+ <UProgress :value="activeTask.progress_percent" :max="100" size="sm" />
996
+ <div class="flex items-center justify-between">
997
+ <p class="text-xs text-muted">{{ activeTask.progress_message }}</p>
998
+ <span class="text-xs font-mono text-muted">{{ activeTask.progress_percent }}%</span>
999
+ </div>
1000
+ </div>
1001
+
1002
+ <!-- Completed -->
1003
+ <div v-if="activeTask.status === 'completed'" class="mt-3 rounded-md border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950">
1004
+ <div class="flex items-center gap-2">
1005
+ <UIcon name="i-lucide-check" class="size-4 text-green-600" />
1006
+ <p class="text-sm text-green-700 dark:text-green-300">
1007
+ Ingestion complete.
1008
+ <span v-if="activeTask.output_data?.chunks_created">
1009
+ {{ activeTask.output_data.chunks_created }} chunks created.
1010
+ </span>
1011
+ </p>
1012
+ </div>
1013
+ <div class="mt-2">
1014
+ <UButton label="Dismiss" variant="ghost" size="xs" @click="dismissActiveTask" />
1015
+ </div>
1016
+ </div>
1017
+
1018
+ <!-- Failed -->
1019
+ <div v-if="activeTask.status === 'failed'" class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-950" role="alert">
1020
+ <div class="flex items-center gap-2">
1021
+ <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500" />
1022
+ <p class="text-sm text-red-700 dark:text-red-300">
1023
+ {{ activeTask.error || 'Ingestion failed.' }}
1024
+ </p>
1025
+ </div>
1026
+ <div class="mt-2 flex gap-2">
1027
+ <UButton label="Retry" variant="outline" size="xs" icon="i-lucide-refresh-cw" @click="retryIngest" />
1028
+ <UButton label="Dismiss" variant="ghost" size="xs" @click="dismissActiveTask" />
1029
+ </div>
1030
+ </div>
1031
+ </div>
1032
+
1033
+ <!-- Jobs Queue Table -->
1034
+ <div v-if="jobs.length" class="mt-4">
1035
+ <div class="flex items-center justify-between mb-3">
1036
+ <h3 class="text-sm font-semibold text-muted uppercase tracking-wider">Job Queue</h3>
1037
+ <div class="flex items-center gap-3 text-xs text-muted">
1038
+ <span v-if="jobsSummary.active">{{ jobsSummary.active }} active</span>
1039
+ <span>{{ jobsSummary.completed ?? 0 }} completed</span>
1040
+ <span v-if="jobsSummary.total_chunks">{{ jobsSummary.total_chunks }} total chunks</span>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ <div class="rounded-lg border border-default overflow-hidden">
1045
+ <table class="w-full text-sm">
1046
+ <thead>
1047
+ <tr class="border-b border-default bg-elevated/30">
1048
+ <th class="text-left py-2.5 px-4 text-xs font-semibold text-muted">Source</th>
1049
+ <th class="text-left py-2.5 px-3 text-xs font-semibold text-muted w-20">Type</th>
1050
+ <th class="text-left py-2.5 px-3 text-xs font-semibold text-muted w-40">Status</th>
1051
+ <th class="text-right py-2.5 px-3 text-xs font-semibold text-muted w-20">Chunks</th>
1052
+ <th class="text-right py-2.5 px-4 text-xs font-semibold text-muted w-32">Time</th>
1053
+ </tr>
1054
+ </thead>
1055
+ <tbody>
1056
+ <tr
1057
+ v-for="job in jobs"
1058
+ :key="job.id"
1059
+ class="border-b border-default last:border-b-0 transition-colors"
1060
+ :class="jobDetailId(job)
1061
+ ? 'cursor-pointer hover:bg-primary/5'
1062
+ : 'hover:bg-elevated/20'"
1063
+ @click="jobDetailId(job) && navigateTo(`/knowledge/${jobDetailId(job)}`)"
1064
+ >
1065
+ <td class="py-2.5 px-4">
1066
+ <div class="flex items-center gap-2 min-w-0">
1067
+ <UIcon :name="typeIconMap[job.type] ?? 'i-lucide-file'" class="size-4 shrink-0 text-muted" />
1068
+ <NuxtLink
1069
+ v-if="jobDetailId(job)"
1070
+ :to="`/knowledge/${jobDetailId(job)}`"
1071
+ class="truncate text-highlighted hover:text-primary hover:underline"
1072
+ :aria-label="`Open ${job.title} — transcript, video and knowledge`"
1073
+ @click.stop
1074
+ >
1075
+ {{ job.title }}
1076
+ </NuxtLink>
1077
+ <span v-else class="truncate text-highlighted">{{ job.title }}</span>
1078
+ </div>
1079
+ </td>
1080
+ <td class="py-2.5 px-3">
1081
+ <UBadge
1082
+ v-if="job.type"
1083
+ :label="job.type"
1084
+ :color="typeColorMap[job.type] ?? 'neutral'"
1085
+ variant="subtle"
1086
+ size="xs"
1087
+ />
1088
+ </td>
1089
+ <td class="py-2.5 px-3">
1090
+ <div class="flex items-center gap-2">
1091
+ <UIcon
1092
+ v-if="['queued','processing','downloading','transcribing','embedding'].includes(job.status)"
1093
+ name="i-lucide-loader-2"
1094
+ class="size-3.5 animate-spin text-primary shrink-0"
1095
+ />
1096
+ <UIcon v-else-if="job.status === 'completed'" name="i-lucide-check-circle" class="size-3.5 text-green-500 shrink-0" />
1097
+ <UIcon v-else-if="job.status === 'failed'" name="i-lucide-x-circle" class="size-3.5 text-red-500 shrink-0" />
1098
+ <div class="flex-1 min-w-0">
1099
+ <div v-if="['processing','downloading','transcribing','embedding'].includes(job.status)" class="space-y-1">
1100
+ <div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
1101
+ <div class="h-1.5 rounded-full bg-primary transition-all" :style="{ width: `${job.progress}%` }" />
1102
+ </div>
1103
+ <p class="text-[10px] text-muted truncate">{{ job.message }}</p>
1104
+ </div>
1105
+ <span v-else class="text-xs" :class="job.status === 'completed' ? 'text-green-400' : job.status === 'failed' ? 'text-red-400' : 'text-muted'">
1106
+ {{ job.status }}
1107
+ </span>
1108
+ </div>
1109
+ </div>
1110
+ </td>
1111
+ <td class="py-2.5 px-3 text-right">
1112
+ <span v-if="job.chunks_created" class="text-xs font-mono">{{ job.chunks_created }}</span>
1113
+ <span v-else class="text-xs text-muted">—</span>
1114
+ </td>
1115
+ <td class="py-2.5 px-4 text-right text-xs text-muted">
1116
+ <div class="flex items-center justify-end gap-1.5">
1117
+ <span>{{ formatDate(job.completed_at || job.created_at) }}</span>
1118
+ <UIcon
1119
+ v-if="jobDetailId(job)"
1120
+ name="i-lucide-chevron-right"
1121
+ class="size-3.5 text-muted shrink-0"
1122
+ aria-hidden="true"
1123
+ />
1124
+ </div>
1125
+ </td>
1126
+ </tr>
1127
+ </tbody>
1128
+ </table>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ <!-- Stats Section -->
1133
+ <div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
1134
+ <div class="rounded-lg border border-default p-4 text-center">
1135
+ <p class="text-2xl font-semibold text-highlighted">{{ stats?.total_chunks ?? 0 }}</p>
1136
+ <p class="text-xs text-muted">Total Chunks</p>
1137
+ </div>
1138
+ <div class="rounded-lg border border-default p-4 text-center">
1139
+ <p class="text-2xl font-semibold text-highlighted">{{ stats?.total_files ?? 0 }}</p>
1140
+ <p class="text-xs text-muted">Total Files</p>
1141
+ </div>
1142
+ <div class="rounded-lg border border-default p-4 text-center">
1143
+ <UBadge
1144
+ :label="vectorSearchActive ? 'Active' : 'Unavailable'"
1145
+ :color="vectorSearchActive ? 'success' : 'warning'"
1146
+ variant="subtle"
1147
+ size="sm"
1148
+ />
1149
+ <p class="text-xs text-muted mt-1">Vector Search</p>
1150
+ <p
1151
+ v-if="!vectorSearchActive && stats?.vec_unavailable_reason"
1152
+ class="text-xs text-yellow-400 mt-2 text-left"
1153
+ :title="stats.vec_unavailable_reason"
1154
+ >
1155
+ {{ stats.vec_unavailable_reason }}
1156
+ </p>
1157
+ </div>
1158
+ </div>
1159
+
1160
+ <!-- Not Indexed State -->
1161
+ <div v-if="!isIndexed" class="mt-8">
1162
+ <div class="rounded-lg border-2 border-dashed border-default p-8 text-center">
1163
+ <UIcon name="i-lucide-database" class="size-16 text-muted mx-auto" />
1164
+ <h3 class="mt-4 text-lg font-semibold text-highlighted">Knowledge base not indexed yet</h3>
1165
+ <p class="mt-2 text-sm text-muted max-w-lg mx-auto">
1166
+ Index your Obsidian vault to enable semantic search across your entire knowledge base.
1167
+ </p>
1168
+ <div class="mt-6 inline-block rounded-lg border border-default bg-elevated/50 px-6 py-4 text-left">
1169
+ <p class="text-xs text-muted mb-2">Run this command to index:</p>
1170
+ <code class="font-mono text-sm text-primary">npx arkaos index</code>
1171
+ </div>
1172
+ <p class="mt-4 text-xs text-muted max-w-md mx-auto">
1173
+ This indexes your markdown files into a local vector database for automatic context retrieval.
1174
+ The process runs locally and your data never leaves your machine.
1175
+ </p>
1176
+ </div>
1177
+ </div>
1178
+
1179
+ <!-- Indexed State -->
1180
+ <template v-else>
1181
+ <!-- Knowledge Areas -->
1182
+ <div v-if="stats?.areas?.length" class="mt-6">
1183
+ <h3 class="mb-4 text-lg font-semibold text-highlighted">Knowledge Areas</h3>
1184
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
1185
+ <div
1186
+ v-for="area in stats.areas"
1187
+ :key="area.name"
1188
+ class="rounded-lg border border-default p-4"
1189
+ >
1190
+ <h4 class="font-medium text-highlighted">{{ area.name }}</h4>
1191
+ <div class="mt-2 flex gap-4 text-xs text-muted">
1192
+ <span>{{ area.chunks }} chunks</span>
1193
+ <span>{{ area.files }} files</span>
1194
+ </div>
1195
+ </div>
1196
+ </div>
1197
+ </div>
1198
+
1199
+ <!-- Search -->
1200
+ <div class="mt-6 rounded-lg border border-default p-6">
1201
+ <h3 class="mb-4 text-lg font-semibold text-highlighted">Search Knowledge</h3>
1202
+ <form class="flex gap-2" @submit.prevent="handleSearch">
1203
+ <UInput
1204
+ v-model="searchQuery"
1205
+ class="flex-1"
1206
+ icon="i-lucide-search"
1207
+ placeholder="Search the knowledge base..."
1208
+ aria-label="Search knowledge base"
1209
+ />
1210
+ <UButton
1211
+ type="submit"
1212
+ label="Search"
1213
+ :loading="searching"
1214
+ icon="i-lucide-search"
1215
+ />
1216
+ </form>
1217
+
1218
+ <!-- Search Results -->
1219
+ <div v-if="searching" class="mt-4 flex items-center justify-center py-8">
1220
+ <UIcon name="i-lucide-loader-2" class="size-6 animate-spin text-muted" />
1221
+ </div>
1222
+
1223
+ <template v-else-if="hasSearched">
1224
+ <div v-if="searchResults.length" class="mt-4 space-y-3">
1225
+ <p class="text-xs text-muted">{{ searchTotal }} result{{ searchTotal !== 1 ? 's' : '' }} found</p>
1226
+ <div
1227
+ v-for="(result, idx) in searchResults"
1228
+ :key="result.id ?? idx"
1229
+ class="rounded-lg border border-default p-4"
1230
+ >
1231
+ <div class="mb-2 flex items-center justify-between gap-2">
1232
+ <div class="flex items-center gap-2 min-w-0">
1233
+ <UBadge v-if="result.area" :label="result.area" variant="subtle" size="sm" />
1234
+ <span v-if="result.heading" class="text-sm font-medium text-highlighted truncate">
1235
+ {{ result.heading }}
1236
+ </span>
1237
+ </div>
1238
+ <div class="flex items-center gap-2 shrink-0">
1239
+ <span class="text-xs text-muted whitespace-nowrap">
1240
+ Score: {{ formatScore(result.score) }}
1241
+ </span>
1242
+ <UButton
1243
+ v-if="result.source"
1244
+ :icon="deletingSource === result.source
1245
+ ? 'i-lucide-loader-2'
1246
+ : 'i-lucide-trash-2'"
1247
+ :loading="deletingSource === result.source"
1248
+ variant="ghost"
1249
+ color="error"
1250
+ size="xs"
1251
+ aria-label="Delete all chunks from this source"
1252
+ @click.stop="askDeleteSource(result.source)"
1253
+ />
1254
+ </div>
1255
+ </div>
1256
+ <p v-if="result.source" class="text-xs text-muted mb-1 truncate">
1257
+ <UIcon name="i-lucide-file-text" class="size-3 inline-block mr-1" />
1258
+ {{ result.source }}
1259
+ </p>
1260
+ <!-- PR71 v2.88.0 — highlight query matches in the preview -->
1261
+ <p class="text-sm text-muted line-clamp-3" v-html="highlightMatches(result.text || result.content, searchQuery)" />
1262
+ </div>
1263
+ </div>
1264
+
1265
+ <div v-else class="mt-4 text-center text-sm text-muted py-6">
1266
+ <UIcon name="i-lucide-search-x" class="size-8 text-muted mx-auto mb-2" />
1267
+ <p>No results found for "{{ searchQuery }}".</p>
1268
+ </div>
1269
+ </template>
1270
+ </div>
1271
+
1272
+ <!-- PR88c v3.25.0 — Indexed sources management -->
1273
+ <div class="mt-6">
1274
+ <KnowledgeSourcesList />
1275
+ </div>
1276
+ </template>
1277
+ </div>
1278
+ </template>
1279
+ </template>
1280
+ </UDashboardPanel>
1281
+ </template>