clawport-ui 0.1.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/.env.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
3
|
+
import { formatDuration, estimateStorageSize } from './audio-recorder'
|
|
4
|
+
|
|
5
|
+
// --- formatDuration ---
|
|
6
|
+
|
|
7
|
+
describe('formatDuration', () => {
|
|
8
|
+
it('formats zero seconds', () => {
|
|
9
|
+
expect(formatDuration(0)).toBe('0:00')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('formats seconds under a minute', () => {
|
|
13
|
+
expect(formatDuration(5)).toBe('0:05')
|
|
14
|
+
expect(formatDuration(12)).toBe('0:12')
|
|
15
|
+
expect(formatDuration(59)).toBe('0:59')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('formats exact minutes', () => {
|
|
19
|
+
expect(formatDuration(60)).toBe('1:00')
|
|
20
|
+
expect(formatDuration(120)).toBe('2:00')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('formats minutes and seconds', () => {
|
|
24
|
+
expect(formatDuration(65)).toBe('1:05')
|
|
25
|
+
expect(formatDuration(130)).toBe('2:10')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('pads single-digit seconds with leading zero', () => {
|
|
29
|
+
expect(formatDuration(61)).toBe('1:01')
|
|
30
|
+
expect(formatDuration(9)).toBe('0:09')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles fractional seconds by flooring', () => {
|
|
34
|
+
expect(formatDuration(5.7)).toBe('0:05')
|
|
35
|
+
expect(formatDuration(65.9)).toBe('1:05')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles large values', () => {
|
|
39
|
+
expect(formatDuration(3661)).toBe('61:01')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// --- estimateStorageSize ---
|
|
44
|
+
|
|
45
|
+
describe('estimateStorageSize', () => {
|
|
46
|
+
it('estimates size from base64 data URL', () => {
|
|
47
|
+
// "data:audio/webm;base64," prefix = 23 chars
|
|
48
|
+
// base64 payload of 100 chars ≈ 75 bytes
|
|
49
|
+
const prefix = 'data:audio/webm;base64,'
|
|
50
|
+
const payload = 'A'.repeat(100)
|
|
51
|
+
const dataUrl = prefix + payload
|
|
52
|
+
const size = estimateStorageSize(dataUrl)
|
|
53
|
+
expect(size).toBe(75) // ceil(100 * 0.75)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns full length when no comma found', () => {
|
|
57
|
+
const size = estimateStorageSize('no-comma-here')
|
|
58
|
+
expect(size).toBe(13)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('handles empty payload after comma', () => {
|
|
62
|
+
const size = estimateStorageSize('data:audio/webm;base64,')
|
|
63
|
+
expect(size).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('handles realistic-sized data URL', () => {
|
|
67
|
+
const prefix = 'data:audio/webm;base64,'
|
|
68
|
+
const payload = 'A'.repeat(10000) // ~7500 bytes of audio
|
|
69
|
+
const size = estimateStorageSize(prefix + payload)
|
|
70
|
+
expect(size).toBe(7500)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const WAVEFORM_SAMPLES = 50
|
|
2
|
+
|
|
3
|
+
function pickMimeType(): string {
|
|
4
|
+
if (typeof MediaRecorder === 'undefined') return ''
|
|
5
|
+
for (const t of ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg']) {
|
|
6
|
+
if (MediaRecorder.isTypeSupported(t)) return t
|
|
7
|
+
}
|
|
8
|
+
return ''
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatDuration(seconds: number): string {
|
|
12
|
+
const m = Math.floor(seconds / 60)
|
|
13
|
+
const s = Math.floor(seconds % 60)
|
|
14
|
+
return `${m}:${s.toString().padStart(2, '0')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function blobToDataUrl(blob: Blob): Promise<string> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const reader = new FileReader()
|
|
20
|
+
reader.onloadend = () => resolve(reader.result as string)
|
|
21
|
+
reader.onerror = reject
|
|
22
|
+
reader.readAsDataURL(blob)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function estimateStorageSize(dataUrl: string): number {
|
|
27
|
+
// base64 is ~4/3 of binary size; data URL has a prefix like "data:audio/webm;base64,"
|
|
28
|
+
const commaIndex = dataUrl.indexOf(',')
|
|
29
|
+
if (commaIndex === -1) return dataUrl.length
|
|
30
|
+
return Math.ceil((dataUrl.length - commaIndex - 1) * 0.75)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AudioRecordingResult {
|
|
34
|
+
audioBlob: Blob
|
|
35
|
+
dataUrl: string
|
|
36
|
+
duration: number
|
|
37
|
+
waveform: number[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AudioRecorderHandle {
|
|
41
|
+
start: () => Promise<void>
|
|
42
|
+
stop: () => Promise<AudioRecordingResult>
|
|
43
|
+
cancel: () => void
|
|
44
|
+
getElapsed: () => number
|
|
45
|
+
isRecording: () => boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAudioRecorder(): AudioRecorderHandle {
|
|
49
|
+
let mediaRecorder: MediaRecorder | null = null
|
|
50
|
+
let audioContext: AudioContext | null = null
|
|
51
|
+
let analyser: AnalyserNode | null = null
|
|
52
|
+
let stream: MediaStream | null = null
|
|
53
|
+
let chunks: Blob[] = []
|
|
54
|
+
let startTime = 0
|
|
55
|
+
let recording = false
|
|
56
|
+
let cancelled = false
|
|
57
|
+
let rafId = 0
|
|
58
|
+
|
|
59
|
+
// Raw amplitude samples collected during recording, downsampled to WAVEFORM_SAMPLES on stop
|
|
60
|
+
const rawAmplitudes: number[] = []
|
|
61
|
+
|
|
62
|
+
function collectAmplitude() {
|
|
63
|
+
if (!analyser || !recording) return
|
|
64
|
+
const data = new Uint8Array(analyser.fftSize)
|
|
65
|
+
analyser.getByteTimeDomainData(data)
|
|
66
|
+
// RMS amplitude 0-1
|
|
67
|
+
let sum = 0
|
|
68
|
+
for (let i = 0; i < data.length; i++) {
|
|
69
|
+
const v = (data[i] - 128) / 128
|
|
70
|
+
sum += v * v
|
|
71
|
+
}
|
|
72
|
+
rawAmplitudes.push(Math.sqrt(sum / data.length))
|
|
73
|
+
rafId = requestAnimationFrame(collectAmplitude)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function downsample(raw: number[], targetLen: number): number[] {
|
|
77
|
+
if (raw.length === 0) return Array(targetLen).fill(0.1)
|
|
78
|
+
const result: number[] = []
|
|
79
|
+
const step = raw.length / targetLen
|
|
80
|
+
for (let i = 0; i < targetLen; i++) {
|
|
81
|
+
const start = Math.floor(i * step)
|
|
82
|
+
const end = Math.floor((i + 1) * step)
|
|
83
|
+
let max = 0
|
|
84
|
+
for (let j = start; j < end && j < raw.length; j++) {
|
|
85
|
+
if (raw[j] > max) max = raw[j]
|
|
86
|
+
}
|
|
87
|
+
// Clamp to 0.05-1 range so bars are always visible
|
|
88
|
+
result.push(Math.max(0.05, Math.min(1, max)))
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
async start() {
|
|
95
|
+
cancelled = false
|
|
96
|
+
chunks = []
|
|
97
|
+
rawAmplitudes.length = 0
|
|
98
|
+
|
|
99
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
100
|
+
const mimeType = pickMimeType()
|
|
101
|
+
mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
|
102
|
+
|
|
103
|
+
audioContext = new AudioContext()
|
|
104
|
+
const source = audioContext.createMediaStreamSource(stream)
|
|
105
|
+
analyser = audioContext.createAnalyser()
|
|
106
|
+
analyser.fftSize = 256
|
|
107
|
+
source.connect(analyser)
|
|
108
|
+
|
|
109
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
110
|
+
if (e.data.size > 0) chunks.push(e.data)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mediaRecorder.start(100) // 100ms timeslice for frequent chunks
|
|
114
|
+
startTime = Date.now()
|
|
115
|
+
recording = true
|
|
116
|
+
collectAmplitude()
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async stop(): Promise<AudioRecordingResult> {
|
|
120
|
+
recording = false
|
|
121
|
+
cancelAnimationFrame(rafId)
|
|
122
|
+
|
|
123
|
+
const duration = (Date.now() - startTime) / 1000
|
|
124
|
+
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
|
127
|
+
reject(new Error('Not recording'))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
mediaRecorder.onstop = async () => {
|
|
132
|
+
// Cleanup
|
|
133
|
+
stream?.getTracks().forEach(t => t.stop())
|
|
134
|
+
audioContext?.close().catch(() => {})
|
|
135
|
+
|
|
136
|
+
const blob = new Blob(chunks, { type: mediaRecorder!.mimeType || 'audio/webm' })
|
|
137
|
+
const dataUrl = await blobToDataUrl(blob)
|
|
138
|
+
const waveform = downsample(rawAmplitudes, WAVEFORM_SAMPLES)
|
|
139
|
+
|
|
140
|
+
resolve({ audioBlob: blob, dataUrl, duration, waveform })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
mediaRecorder.stop()
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
cancel() {
|
|
148
|
+
cancelled = true
|
|
149
|
+
recording = false
|
|
150
|
+
cancelAnimationFrame(rafId)
|
|
151
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
152
|
+
mediaRecorder.stop()
|
|
153
|
+
}
|
|
154
|
+
stream?.getTracks().forEach(t => t.stop())
|
|
155
|
+
audioContext?.close().catch(() => {})
|
|
156
|
+
chunks = []
|
|
157
|
+
rawAmplitudes.length = 0
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
getElapsed() {
|
|
161
|
+
if (!recording) return 0
|
|
162
|
+
return (Date.now() - startTime) / 1000
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
isRecording() {
|
|
166
|
+
return recording && !cancelled
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
addMessage,
|
|
4
|
+
markRead,
|
|
5
|
+
updateLastMessage,
|
|
6
|
+
parseMedia,
|
|
7
|
+
getOrCreateConversation,
|
|
8
|
+
loadConversations,
|
|
9
|
+
saveConversations,
|
|
10
|
+
type Message,
|
|
11
|
+
type ConversationStore,
|
|
12
|
+
type Conversation,
|
|
13
|
+
} from './conversations'
|
|
14
|
+
import type { Agent } from './types'
|
|
15
|
+
|
|
16
|
+
// --- helpers ---
|
|
17
|
+
|
|
18
|
+
function makeMessage(overrides: Partial<Message> = {}): Message {
|
|
19
|
+
return {
|
|
20
|
+
id: overrides.id ?? 'msg-1',
|
|
21
|
+
role: overrides.role ?? 'user',
|
|
22
|
+
content: overrides.content ?? 'hello',
|
|
23
|
+
timestamp: overrides.timestamp ?? 1000,
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
|
|
29
|
+
return {
|
|
30
|
+
agentId: overrides.agentId ?? 'vera',
|
|
31
|
+
messages: overrides.messages ?? [],
|
|
32
|
+
unread: overrides.unread ?? 0,
|
|
33
|
+
lastActivity: overrides.lastActivity ?? 1000,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeStore(entries: Record<string, Partial<Conversation>> = {}): ConversationStore {
|
|
38
|
+
const store: ConversationStore = {}
|
|
39
|
+
for (const [id, overrides] of Object.entries(entries)) {
|
|
40
|
+
store[id] = makeConversation({ agentId: id, ...overrides })
|
|
41
|
+
}
|
|
42
|
+
return store
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fakeAgent: Agent = {
|
|
46
|
+
id: 'vera',
|
|
47
|
+
name: 'VERA',
|
|
48
|
+
title: 'Chief Strategy Officer',
|
|
49
|
+
reportsTo: 'jarvis',
|
|
50
|
+
directReports: ['robin'],
|
|
51
|
+
soulPath: null,
|
|
52
|
+
soul: null,
|
|
53
|
+
voiceId: null,
|
|
54
|
+
color: '#a855f7',
|
|
55
|
+
emoji: '?',
|
|
56
|
+
tools: [],
|
|
57
|
+
crons: [],
|
|
58
|
+
memoryPath: null,
|
|
59
|
+
description: 'CSO. Decides what gets built.',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- addMessage ---
|
|
63
|
+
|
|
64
|
+
describe('addMessage', () => {
|
|
65
|
+
it('appends a user message without incrementing unread', () => {
|
|
66
|
+
const store = makeStore({ vera: { messages: [] } })
|
|
67
|
+
const msg = makeMessage({ role: 'user' })
|
|
68
|
+
const result = addMessage(store, 'vera', msg)
|
|
69
|
+
|
|
70
|
+
expect(result.vera.messages).toHaveLength(1)
|
|
71
|
+
expect(result.vera.messages[0]).toEqual(msg)
|
|
72
|
+
expect(result.vera.unread).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('appends an assistant message and increments unread', () => {
|
|
76
|
+
const store = makeStore({ vera: { messages: [], unread: 2 } })
|
|
77
|
+
const msg = makeMessage({ role: 'assistant' })
|
|
78
|
+
const result = addMessage(store, 'vera', msg)
|
|
79
|
+
|
|
80
|
+
expect(result.vera.messages).toHaveLength(1)
|
|
81
|
+
expect(result.vera.unread).toBe(3)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('creates a new conversation entry when agentId not in store', () => {
|
|
85
|
+
const store: ConversationStore = {}
|
|
86
|
+
const msg = makeMessage({ role: 'user' })
|
|
87
|
+
const result = addMessage(store, 'pulse', msg)
|
|
88
|
+
|
|
89
|
+
expect(result.pulse).toBeDefined()
|
|
90
|
+
expect(result.pulse.agentId).toBe('pulse')
|
|
91
|
+
expect(result.pulse.messages).toHaveLength(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not mutate the original store (immutability)', () => {
|
|
95
|
+
const store = makeStore({ vera: { messages: [] } })
|
|
96
|
+
const msg = makeMessage()
|
|
97
|
+
const result = addMessage(store, 'vera', msg)
|
|
98
|
+
|
|
99
|
+
expect(result).not.toBe(store)
|
|
100
|
+
expect(result.vera).not.toBe(store.vera)
|
|
101
|
+
expect(store.vera.messages).toHaveLength(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('preserves other agents in the store', () => {
|
|
105
|
+
const store = makeStore({
|
|
106
|
+
vera: { messages: [] },
|
|
107
|
+
pulse: { messages: [makeMessage({ id: 'existing' })] },
|
|
108
|
+
})
|
|
109
|
+
const msg = makeMessage()
|
|
110
|
+
const result = addMessage(store, 'vera', msg)
|
|
111
|
+
|
|
112
|
+
expect(result.pulse.messages).toHaveLength(1)
|
|
113
|
+
expect(result.pulse.messages[0].id).toBe('existing')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// --- markRead ---
|
|
118
|
+
|
|
119
|
+
describe('markRead', () => {
|
|
120
|
+
it('resets unread to 0', () => {
|
|
121
|
+
const store = makeStore({ vera: { unread: 5 } })
|
|
122
|
+
const result = markRead(store, 'vera')
|
|
123
|
+
expect(result.vera.unread).toBe(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns the same store reference when agentId is missing', () => {
|
|
127
|
+
const store = makeStore({})
|
|
128
|
+
const result = markRead(store, 'nonexistent')
|
|
129
|
+
expect(result).toBe(store)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('does not mutate the original store', () => {
|
|
133
|
+
const store = makeStore({ vera: { unread: 3 } })
|
|
134
|
+
const result = markRead(store, 'vera')
|
|
135
|
+
expect(store.vera.unread).toBe(3)
|
|
136
|
+
expect(result.vera.unread).toBe(0)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// --- updateLastMessage ---
|
|
141
|
+
|
|
142
|
+
describe('updateLastMessage', () => {
|
|
143
|
+
it('updates the matching message content and streaming flag', () => {
|
|
144
|
+
const store = makeStore({
|
|
145
|
+
vera: {
|
|
146
|
+
messages: [
|
|
147
|
+
makeMessage({ id: 'msg-1', content: 'old', isStreaming: true }),
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const result = updateLastMessage(store, 'vera', 'msg-1', 'new content', false)
|
|
153
|
+
expect(result.vera.messages[0].content).toBe('new content')
|
|
154
|
+
expect(result.vera.messages[0].isStreaming).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('does not touch messages with different ids', () => {
|
|
158
|
+
const store = makeStore({
|
|
159
|
+
vera: {
|
|
160
|
+
messages: [
|
|
161
|
+
makeMessage({ id: 'msg-1', content: 'keep me' }),
|
|
162
|
+
makeMessage({ id: 'msg-2', content: 'update me' }),
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const result = updateLastMessage(store, 'vera', 'msg-2', 'updated', false)
|
|
168
|
+
expect(result.vera.messages[0].content).toBe('keep me')
|
|
169
|
+
expect(result.vera.messages[1].content).toBe('updated')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('returns same store when agentId not found', () => {
|
|
173
|
+
const store = makeStore({})
|
|
174
|
+
const result = updateLastMessage(store, 'nonexistent', 'msg-1', 'x', false)
|
|
175
|
+
expect(result).toBe(store)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('returns store unchanged when msgId not found (no crash)', () => {
|
|
179
|
+
const store = makeStore({
|
|
180
|
+
vera: { messages: [makeMessage({ id: 'msg-1', content: 'original' })] },
|
|
181
|
+
})
|
|
182
|
+
const result = updateLastMessage(store, 'vera', 'no-such-id', 'x', false)
|
|
183
|
+
expect(result.vera.messages[0].content).toBe('original')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// --- getOrCreateConversation ---
|
|
188
|
+
|
|
189
|
+
describe('getOrCreateConversation', () => {
|
|
190
|
+
it('returns existing conversation when it exists in store', () => {
|
|
191
|
+
const existing = makeConversation({ agentId: 'vera', unread: 7 })
|
|
192
|
+
const store: ConversationStore = { vera: existing }
|
|
193
|
+
|
|
194
|
+
const result = getOrCreateConversation(store, fakeAgent)
|
|
195
|
+
expect(result).toBe(existing)
|
|
196
|
+
expect(result.unread).toBe(7)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('creates a new conversation with a greeting when not in store', () => {
|
|
200
|
+
const store: ConversationStore = {}
|
|
201
|
+
const result = getOrCreateConversation(store, fakeAgent)
|
|
202
|
+
|
|
203
|
+
expect(result.agentId).toBe('vera')
|
|
204
|
+
expect(result.messages).toHaveLength(1)
|
|
205
|
+
expect(result.messages[0].role).toBe('assistant')
|
|
206
|
+
expect(result.messages[0].content).toContain('VERA')
|
|
207
|
+
expect(result.unread).toBe(0)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// --- parseMedia ---
|
|
212
|
+
|
|
213
|
+
describe('parseMedia', () => {
|
|
214
|
+
it('extracts markdown image links', () => {
|
|
215
|
+
const content = 'Check this out: '
|
|
216
|
+
const media = parseMedia(content)
|
|
217
|
+
expect(media).toHaveLength(1)
|
|
218
|
+
expect(media[0].type).toBe('image')
|
|
219
|
+
expect(media[0].url).toBe('https://example.com/img.png')
|
|
220
|
+
expect(media[0].name).toBe('diagram')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('extracts bare image URLs', () => {
|
|
224
|
+
const content = 'See https://example.com/photo.jpg for reference'
|
|
225
|
+
const media = parseMedia(content)
|
|
226
|
+
expect(media).toHaveLength(1)
|
|
227
|
+
expect(media[0].type).toBe('image')
|
|
228
|
+
expect(media[0].url).toBe('https://example.com/photo.jpg')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('does not duplicate an image that appears in both markdown and bare form', () => {
|
|
232
|
+
const content = ' and also https://example.com/pic.png'
|
|
233
|
+
const media = parseMedia(content)
|
|
234
|
+
// The markdown image regex captures it first, bare regex should skip the duplicate
|
|
235
|
+
const imageMedia = media.filter(m => m.type === 'image')
|
|
236
|
+
expect(imageMedia).toHaveLength(1)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('extracts audio URLs', () => {
|
|
240
|
+
const content = 'Listen: https://example.com/sound.mp3'
|
|
241
|
+
const media = parseMedia(content)
|
|
242
|
+
expect(media).toHaveLength(1)
|
|
243
|
+
expect(media[0].type).toBe('audio')
|
|
244
|
+
expect(media[0].url).toBe('https://example.com/sound.mp3')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('extracts multiple media types from one message', () => {
|
|
248
|
+
const content = [
|
|
249
|
+
'',
|
|
250
|
+
'https://example.com/recording.wav',
|
|
251
|
+
'https://example.com/bg.webp',
|
|
252
|
+
].join('\n')
|
|
253
|
+
const media = parseMedia(content)
|
|
254
|
+
expect(media).toHaveLength(3)
|
|
255
|
+
expect(media.map(m => m.type)).toEqual(['image', 'image', 'audio'])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('handles image URLs with query strings', () => {
|
|
259
|
+
const content = ''
|
|
260
|
+
const media = parseMedia(content)
|
|
261
|
+
expect(media).toHaveLength(1)
|
|
262
|
+
expect(media[0].url).toBe('https://cdn.example.com/img.jpg?w=300&h=200')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('returns empty array when no media is present', () => {
|
|
266
|
+
const content = 'Just a plain text message with no links'
|
|
267
|
+
const media = parseMedia(content)
|
|
268
|
+
expect(media).toHaveLength(0)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('returns empty array for empty string', () => {
|
|
272
|
+
expect(parseMedia('')).toHaveLength(0)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('handles multiple audio formats', () => {
|
|
276
|
+
const content = [
|
|
277
|
+
'https://example.com/a.wav',
|
|
278
|
+
'https://example.com/b.ogg',
|
|
279
|
+
'https://example.com/c.m4a',
|
|
280
|
+
'https://example.com/d.aac',
|
|
281
|
+
].join(' ')
|
|
282
|
+
const media = parseMedia(content)
|
|
283
|
+
expect(media).toHaveLength(4)
|
|
284
|
+
expect(media.every(m => m.type === 'audio')).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// --- loadConversations / saveConversations (localStorage) ---
|
|
289
|
+
|
|
290
|
+
describe('loadConversations', () => {
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
// jsdom provides localStorage
|
|
293
|
+
localStorage.clear()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('returns empty object when nothing stored', () => {
|
|
297
|
+
const result = loadConversations()
|
|
298
|
+
expect(result).toEqual({})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('returns parsed data when valid JSON is stored', () => {
|
|
302
|
+
const data: ConversationStore = {
|
|
303
|
+
vera: makeConversation({ agentId: 'vera' }),
|
|
304
|
+
}
|
|
305
|
+
localStorage.setItem('clawport-conversations', JSON.stringify(data))
|
|
306
|
+
const result = loadConversations()
|
|
307
|
+
expect(result.vera.agentId).toBe('vera')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('returns empty object when localStorage contains invalid JSON', () => {
|
|
311
|
+
localStorage.setItem('clawport-conversations', 'not-json!!')
|
|
312
|
+
const result = loadConversations()
|
|
313
|
+
expect(result).toEqual({})
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('saveConversations', () => {
|
|
318
|
+
beforeEach(() => {
|
|
319
|
+
localStorage.clear()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('persists store to localStorage', () => {
|
|
323
|
+
const data: ConversationStore = {
|
|
324
|
+
vera: makeConversation({ agentId: 'vera' }),
|
|
325
|
+
}
|
|
326
|
+
saveConversations(data)
|
|
327
|
+
const raw = localStorage.getItem('clawport-conversations')
|
|
328
|
+
expect(raw).toBeTruthy()
|
|
329
|
+
expect(JSON.parse(raw!).vera.agentId).toBe('vera')
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { Agent } from './types'
|
|
4
|
+
|
|
5
|
+
export type MediaType = 'image' | 'audio' | 'file'
|
|
6
|
+
|
|
7
|
+
export interface MediaAttachment {
|
|
8
|
+
type: MediaType
|
|
9
|
+
url: string
|
|
10
|
+
name?: string
|
|
11
|
+
mimeType?: string
|
|
12
|
+
duration?: number
|
|
13
|
+
waveform?: number[]
|
|
14
|
+
size?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Message {
|
|
18
|
+
id: string
|
|
19
|
+
role: 'user' | 'assistant'
|
|
20
|
+
content: string
|
|
21
|
+
timestamp: number
|
|
22
|
+
media?: MediaAttachment[]
|
|
23
|
+
isStreaming?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Conversation {
|
|
27
|
+
agentId: string
|
|
28
|
+
messages: Message[]
|
|
29
|
+
unread: number
|
|
30
|
+
lastActivity: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ConversationStore = Record<string, Conversation>
|
|
34
|
+
|
|
35
|
+
const STORAGE_KEY = 'clawport-conversations'
|
|
36
|
+
|
|
37
|
+
export function loadConversations(): ConversationStore {
|
|
38
|
+
if (typeof window === 'undefined') return {}
|
|
39
|
+
try {
|
|
40
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
41
|
+
return raw ? JSON.parse(raw) : {}
|
|
42
|
+
} catch {
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saveConversations(store: ConversationStore): void {
|
|
48
|
+
if (typeof window === 'undefined') return
|
|
49
|
+
try {
|
|
50
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getOrCreateConversation(store: ConversationStore, agent: Agent): Conversation {
|
|
55
|
+
if (store[agent.id]) return store[agent.id]
|
|
56
|
+
return {
|
|
57
|
+
agentId: agent.id,
|
|
58
|
+
messages: [{
|
|
59
|
+
id: crypto.randomUUID(),
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: `I'm ${agent.name}. ${agent.description} What do you need?`,
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
}],
|
|
64
|
+
unread: 0,
|
|
65
|
+
lastActivity: Date.now(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function addMessage(store: ConversationStore, agentId: string, msg: Message): ConversationStore {
|
|
70
|
+
const conv = store[agentId] || { agentId, messages: [], unread: 0, lastActivity: Date.now() }
|
|
71
|
+
return {
|
|
72
|
+
...store,
|
|
73
|
+
[agentId]: {
|
|
74
|
+
...conv,
|
|
75
|
+
messages: [...conv.messages, msg],
|
|
76
|
+
lastActivity: Date.now(),
|
|
77
|
+
unread: msg.role === 'assistant' ? conv.unread + 1 : conv.unread,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function markRead(store: ConversationStore, agentId: string): ConversationStore {
|
|
83
|
+
if (!store[agentId]) return store
|
|
84
|
+
return { ...store, [agentId]: { ...store[agentId], unread: 0 } }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function updateLastMessage(store: ConversationStore, agentId: string, msgId: string, content: string, isStreaming: boolean): ConversationStore {
|
|
88
|
+
const conv = store[agentId]
|
|
89
|
+
if (!conv) return store
|
|
90
|
+
const msgs = conv.messages.map(m => m.id === msgId ? { ...m, content, isStreaming } : m)
|
|
91
|
+
return { ...store, [agentId]: { ...conv, messages: msgs } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseMedia(content: string): MediaAttachment[] {
|
|
95
|
+
const media: MediaAttachment[] = []
|
|
96
|
+
|
|
97
|
+
const imgRegex = /!\[([^\]]*)\]\((https?:\/\/[^\)]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\)]*)?)\)/gi
|
|
98
|
+
let m: RegExpExecArray | null
|
|
99
|
+
while ((m = imgRegex.exec(content)) !== null) {
|
|
100
|
+
media.push({ type: 'image', url: m[2], name: m[1] || 'Image' })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const bareImgRegex = /(?<!\]\()https?:\/\/\S+\.(jpg|jpeg|png|gif|webp)(\?\S*)?\b/gi
|
|
104
|
+
while ((m = bareImgRegex.exec(content)) !== null) {
|
|
105
|
+
const url = m[0]
|
|
106
|
+
if (!media.find(x => x.url === url)) {
|
|
107
|
+
media.push({ type: 'image', url })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const audioRegex = /https?:\/\/\S+\.(mp3|wav|ogg|m4a|aac)(\?\S*)?\b/gi
|
|
112
|
+
while ((m = audioRegex.exec(content)) !== null) {
|
|
113
|
+
media.push({ type: 'audio', url: m[0], name: m[0].split('/').pop() })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return media
|
|
117
|
+
}
|