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.
Files changed (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. 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: ![diagram](https://example.com/img.png)'
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 = '![pic](https://example.com/pic.png) 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
+ '![chart](https://example.com/chart.png)',
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 = '![thumb](https://cdn.example.com/img.jpg?w=300&h=200)'
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
+ }