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,219 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildApiContent } from './multimodal'
|
|
3
|
+
import type { Message, MediaAttachment } from './conversations'
|
|
4
|
+
|
|
5
|
+
function msg(overrides: Partial<Message> = {}): Message {
|
|
6
|
+
return {
|
|
7
|
+
id: 'msg-1',
|
|
8
|
+
role: 'user',
|
|
9
|
+
content: 'hello',
|
|
10
|
+
timestamp: 1000,
|
|
11
|
+
...overrides,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function imageAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
|
|
16
|
+
return {
|
|
17
|
+
type: 'image',
|
|
18
|
+
url: 'data:image/png;base64,iVBORw0KGgoAAAA',
|
|
19
|
+
name: 'photo.png',
|
|
20
|
+
mimeType: 'image/png',
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fileAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
|
|
26
|
+
return {
|
|
27
|
+
type: 'file',
|
|
28
|
+
url: 'data:application/pdf;base64,JVBERi0',
|
|
29
|
+
name: 'report.pdf',
|
|
30
|
+
mimeType: 'application/pdf',
|
|
31
|
+
size: 245000,
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function audioAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
|
|
37
|
+
return {
|
|
38
|
+
type: 'audio',
|
|
39
|
+
url: 'data:audio/webm;base64,GkXfo59C',
|
|
40
|
+
name: 'Voice message',
|
|
41
|
+
mimeType: 'audio/webm',
|
|
42
|
+
duration: 5,
|
|
43
|
+
waveform: Array(50).fill(0.3),
|
|
44
|
+
...overrides,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- plain text messages ---
|
|
49
|
+
|
|
50
|
+
describe('buildApiContent — plain text', () => {
|
|
51
|
+
it('returns string content when no media is attached', () => {
|
|
52
|
+
const result = buildApiContent(msg({ content: 'just text' }))
|
|
53
|
+
expect(result).toBe('just text')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns string content when media array is empty', () => {
|
|
57
|
+
const result = buildApiContent(msg({ content: 'text', media: [] }))
|
|
58
|
+
expect(result).toBe('text')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns empty string for empty content with no media', () => {
|
|
62
|
+
const result = buildApiContent(msg({ content: '' }))
|
|
63
|
+
expect(result).toBe('')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// --- image attachments ---
|
|
68
|
+
|
|
69
|
+
describe('buildApiContent — images', () => {
|
|
70
|
+
it('converts a message with one image to content parts array', () => {
|
|
71
|
+
const result = buildApiContent(msg({
|
|
72
|
+
content: 'what is this?',
|
|
73
|
+
media: [imageAttachment()],
|
|
74
|
+
}))
|
|
75
|
+
expect(Array.isArray(result)).toBe(true)
|
|
76
|
+
const parts = result as Array<{ type: string }>
|
|
77
|
+
expect(parts).toHaveLength(2)
|
|
78
|
+
expect(parts[0]).toEqual({ type: 'text', text: 'what is this?' })
|
|
79
|
+
expect(parts[1]).toEqual({ type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAA' } })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('includes multiple images as separate image_url parts', () => {
|
|
83
|
+
const result = buildApiContent(msg({
|
|
84
|
+
content: 'compare these',
|
|
85
|
+
media: [
|
|
86
|
+
imageAttachment({ url: 'data:image/png;base64,AAA' }),
|
|
87
|
+
imageAttachment({ url: 'data:image/png;base64,BBB' }),
|
|
88
|
+
],
|
|
89
|
+
}))
|
|
90
|
+
const parts = result as Array<{ type: string }>
|
|
91
|
+
expect(parts).toHaveLength(3) // 1 text + 2 images
|
|
92
|
+
expect(parts.filter(p => p.type === 'image_url')).toHaveLength(2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('handles image-only message with no text content', () => {
|
|
96
|
+
const result = buildApiContent(msg({
|
|
97
|
+
content: '',
|
|
98
|
+
media: [imageAttachment()],
|
|
99
|
+
}))
|
|
100
|
+
const parts = result as Array<{ type: string }>
|
|
101
|
+
// No text part since content is empty, just the image
|
|
102
|
+
expect(parts).toHaveLength(1)
|
|
103
|
+
expect(parts[0].type).toBe('image_url')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// --- file attachments ---
|
|
108
|
+
|
|
109
|
+
describe('buildApiContent — files', () => {
|
|
110
|
+
it('adds a text label for binary files (PDF)', () => {
|
|
111
|
+
const result = buildApiContent(msg({
|
|
112
|
+
content: 'here is a doc',
|
|
113
|
+
media: [fileAttachment()],
|
|
114
|
+
}))
|
|
115
|
+
const parts = result as Array<{ type: string; text?: string }>
|
|
116
|
+
expect(parts).toHaveLength(2)
|
|
117
|
+
expect(parts[1].type).toBe('text')
|
|
118
|
+
expect(parts[1].text).toContain('report.pdf')
|
|
119
|
+
expect(parts[1].text).toContain('239 KB') // 245000 / 1024 ≈ 239
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('inlines text file content from base64 data URL', () => {
|
|
123
|
+
const textContent = 'Hello, world!'
|
|
124
|
+
const base64 = btoa(textContent)
|
|
125
|
+
const result = buildApiContent(msg({
|
|
126
|
+
content: 'check this file',
|
|
127
|
+
media: [fileAttachment({
|
|
128
|
+
name: 'notes.txt',
|
|
129
|
+
mimeType: 'text/plain',
|
|
130
|
+
url: `data:text/plain;base64,${base64}`,
|
|
131
|
+
})],
|
|
132
|
+
}))
|
|
133
|
+
const parts = result as Array<{ type: string; text?: string }>
|
|
134
|
+
expect(parts).toHaveLength(2)
|
|
135
|
+
expect(parts[1].text).toContain('Hello, world!')
|
|
136
|
+
expect(parts[1].text).toContain('Contents of notes.txt')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('inlines JSON file based on extension even without text/ mimeType', () => {
|
|
140
|
+
const jsonContent = '{"key": "value"}'
|
|
141
|
+
const base64 = btoa(jsonContent)
|
|
142
|
+
const result = buildApiContent(msg({
|
|
143
|
+
content: '',
|
|
144
|
+
media: [fileAttachment({
|
|
145
|
+
name: 'config.json',
|
|
146
|
+
mimeType: 'application/json',
|
|
147
|
+
url: `data:application/json;base64,${base64}`,
|
|
148
|
+
})],
|
|
149
|
+
}))
|
|
150
|
+
const parts = result as Array<{ type: string; text?: string }>
|
|
151
|
+
expect(parts).toHaveLength(1)
|
|
152
|
+
expect(parts[0].text).toContain('"key": "value"')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('falls back to label when file has no name', () => {
|
|
156
|
+
const result = buildApiContent(msg({
|
|
157
|
+
content: '',
|
|
158
|
+
media: [fileAttachment({ name: undefined })],
|
|
159
|
+
}))
|
|
160
|
+
const parts = result as Array<{ type: string; text?: string }>
|
|
161
|
+
expect(parts[0].text).toContain('unknown')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// --- audio attachments ---
|
|
166
|
+
|
|
167
|
+
describe('buildApiContent — audio', () => {
|
|
168
|
+
it('returns plain string for audio-only message (transcript from Whisper)', () => {
|
|
169
|
+
const result = buildApiContent(msg({
|
|
170
|
+
content: 'Hello this is my transcribed message',
|
|
171
|
+
media: [audioAttachment()],
|
|
172
|
+
}))
|
|
173
|
+
// Audio is skipped and no other non-text parts exist, so return plain string
|
|
174
|
+
// to avoid wrapping in ContentPart[] which the gateway may not handle
|
|
175
|
+
expect(result).toBe('Hello this is my transcribed message')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('returns plain string when audio is the only attachment', () => {
|
|
179
|
+
const result = buildApiContent(msg({
|
|
180
|
+
content: 'transcribed words',
|
|
181
|
+
media: [audioAttachment()],
|
|
182
|
+
}))
|
|
183
|
+
expect(typeof result).toBe('string')
|
|
184
|
+
expect(result).toBe('transcribed words')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// --- mixed media ---
|
|
189
|
+
|
|
190
|
+
describe('buildApiContent — mixed media', () => {
|
|
191
|
+
it('handles image + file together', () => {
|
|
192
|
+
const result = buildApiContent(msg({
|
|
193
|
+
content: 'look at both',
|
|
194
|
+
media: [
|
|
195
|
+
imageAttachment(),
|
|
196
|
+
fileAttachment(),
|
|
197
|
+
],
|
|
198
|
+
}))
|
|
199
|
+
const parts = result as Array<{ type: string }>
|
|
200
|
+
expect(parts).toHaveLength(3) // text + image + file label
|
|
201
|
+
expect(parts[0].type).toBe('text')
|
|
202
|
+
expect(parts[1].type).toBe('image_url')
|
|
203
|
+
expect(parts[2].type).toBe('text')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('handles image + audio (audio skipped, text preserved)', () => {
|
|
207
|
+
const result = buildApiContent(msg({
|
|
208
|
+
content: 'here is what I see',
|
|
209
|
+
media: [
|
|
210
|
+
imageAttachment(),
|
|
211
|
+
audioAttachment(),
|
|
212
|
+
],
|
|
213
|
+
}))
|
|
214
|
+
const parts = result as Array<{ type: string }>
|
|
215
|
+
expect(parts).toHaveLength(2) // text + image (audio skipped)
|
|
216
|
+
expect(parts[0].type).toBe('text')
|
|
217
|
+
expect(parts[1].type).toBe('image_url')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Message, MediaAttachment } from './conversations'
|
|
2
|
+
import type { ContentPart, MessageContent } from './validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert a Message (with optional MediaAttachments) into the API content format.
|
|
6
|
+
* - No media → plain string (backward compatible)
|
|
7
|
+
* - Images → ContentPart[] with image_url entries (OpenAI vision format)
|
|
8
|
+
* - Text files → content inlined as text parts
|
|
9
|
+
* - Binary files → descriptive label
|
|
10
|
+
* - Audio → skipped (transcript is already in msg.content from Whisper)
|
|
11
|
+
*/
|
|
12
|
+
export function buildApiContent(msg: Message): MessageContent {
|
|
13
|
+
const media = msg.media
|
|
14
|
+
if (!media || media.length === 0) return msg.content
|
|
15
|
+
|
|
16
|
+
const parts: ContentPart[] = []
|
|
17
|
+
let attachmentAdded = false
|
|
18
|
+
|
|
19
|
+
if (msg.content) {
|
|
20
|
+
parts.push({ type: 'text', text: msg.content })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const attachment of media) {
|
|
24
|
+
if (attachment.type === 'image') {
|
|
25
|
+
parts.push({ type: 'image_url', image_url: { url: attachment.url } })
|
|
26
|
+
attachmentAdded = true
|
|
27
|
+
} else if (attachment.type === 'file') {
|
|
28
|
+
const label = attachment.name || 'unknown'
|
|
29
|
+
const sizeNote = attachment.size ? ` (${Math.round(attachment.size / 1024)} KB)` : ''
|
|
30
|
+
// Attempt to inline text file content from base64 data URL
|
|
31
|
+
const inlined = tryExtractText(attachment)
|
|
32
|
+
if (inlined) {
|
|
33
|
+
parts.push({ type: 'text', text: `--- Contents of ${label} ---\n${inlined}\n--- End of file ---` })
|
|
34
|
+
} else {
|
|
35
|
+
parts.push({ type: 'text', text: `[Attached file: ${label}${sizeNote}]` })
|
|
36
|
+
}
|
|
37
|
+
attachmentAdded = true
|
|
38
|
+
}
|
|
39
|
+
// Audio: transcript already in msg.content via Whisper — skip binary
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If no attachment actually contributed to parts (e.g., audio-only message
|
|
43
|
+
// where the transcript is already in msg.content), return a plain string
|
|
44
|
+
// so the gateway doesn't receive an unnecessary ContentPart[] wrapper.
|
|
45
|
+
if (!attachmentAdded) return msg.content
|
|
46
|
+
|
|
47
|
+
return parts.length > 0 ? parts : msg.content
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TEXT_EXTENSIONS = ['txt', 'csv', 'json', 'md', 'log', 'xml', 'yaml', 'yml', 'toml']
|
|
51
|
+
|
|
52
|
+
function isTextFile(att: MediaAttachment): boolean {
|
|
53
|
+
if (att.mimeType?.startsWith('text/')) return true
|
|
54
|
+
const ext = att.name?.split('.').pop()?.toLowerCase() || ''
|
|
55
|
+
return TEXT_EXTENSIONS.includes(ext)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tryExtractText(att: MediaAttachment): string | null {
|
|
59
|
+
if (!isTextFile(att)) return null
|
|
60
|
+
if (!att.url.startsWith('data:')) return null
|
|
61
|
+
try {
|
|
62
|
+
const base64 = att.url.split(',')[1]
|
|
63
|
+
if (!base64) return null
|
|
64
|
+
return atob(base64)
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the full image pipeline:
|
|
3
|
+
* buildApiContent → JSON serialize → parse → validateChatMessages → OpenAI message mapping
|
|
4
|
+
*
|
|
5
|
+
* These tests verify that image data survives the complete journey from
|
|
6
|
+
* user attachment to the format sent to the OpenAI SDK.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import { buildApiContent } from './multimodal'
|
|
10
|
+
import { validateChatMessages, validateMessages } from './validation'
|
|
11
|
+
import type { Message, MediaAttachment } from './conversations'
|
|
12
|
+
import type { ContentPart, MessageContent } from './validation'
|
|
13
|
+
|
|
14
|
+
// --- Helpers ---
|
|
15
|
+
|
|
16
|
+
function userMessage(overrides: Partial<Message> = {}): Message {
|
|
17
|
+
return {
|
|
18
|
+
id: 'msg-1',
|
|
19
|
+
role: 'user',
|
|
20
|
+
content: 'what is this?',
|
|
21
|
+
timestamp: Date.now(),
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assistantMessage(overrides: Partial<Message> = {}): Message {
|
|
27
|
+
return {
|
|
28
|
+
id: 'msg-0',
|
|
29
|
+
role: 'assistant',
|
|
30
|
+
content: 'Hello, how can I help?',
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function imageAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
|
|
37
|
+
return {
|
|
38
|
+
type: 'image',
|
|
39
|
+
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk',
|
|
40
|
+
name: 'screenshot.png',
|
|
41
|
+
mimeType: 'image/png',
|
|
42
|
+
...overrides,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Full pipeline simulation ---
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Simulates the exact pipeline:
|
|
50
|
+
* 1. ConversationView builds apiMessages via buildApiContent
|
|
51
|
+
* 2. JSON.stringify for fetch body
|
|
52
|
+
* 3. Server parses with request.json()
|
|
53
|
+
* 4. Server validates with validateChatMessages
|
|
54
|
+
* 5. Server maps messages for OpenAI SDK
|
|
55
|
+
*/
|
|
56
|
+
function simulateFullPipeline(conversationMessages: Message[]) {
|
|
57
|
+
// Step 1: Client-side — buildApiContent for each message (ConversationView line 368-371)
|
|
58
|
+
const apiMessages = conversationMessages.map(m => ({
|
|
59
|
+
role: m.role,
|
|
60
|
+
content: buildApiContent(m),
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
// Step 2: Client-side — JSON.stringify for fetch body (ConversationView line 377)
|
|
64
|
+
const jsonBody = JSON.stringify({ messages: apiMessages })
|
|
65
|
+
|
|
66
|
+
// Step 3: Server-side — request.json() (route.ts line 29)
|
|
67
|
+
const parsedBody = JSON.parse(jsonBody)
|
|
68
|
+
|
|
69
|
+
// Step 4: Server-side — validateChatMessages (route.ts line 37)
|
|
70
|
+
const result = validateChatMessages(parsedBody)
|
|
71
|
+
|
|
72
|
+
if (!result.ok) {
|
|
73
|
+
throw new Error(`Validation failed: ${result.error}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Step 5: Server-side — map for OpenAI SDK (route.ts line 55-58)
|
|
77
|
+
const systemPrompt = 'You are a helpful assistant.'
|
|
78
|
+
const openaiMessages = [
|
|
79
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
80
|
+
...result.messages.map(m => ({ role: m.role, content: m.content })),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
return { apiMessages, jsonBody, parsedBody, validatedMessages: result.messages, openaiMessages }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// =============================================================
|
|
87
|
+
// Pipeline integration tests
|
|
88
|
+
// =============================================================
|
|
89
|
+
|
|
90
|
+
describe('full image pipeline — end-to-end', () => {
|
|
91
|
+
it('preserves image_url data through the entire pipeline', () => {
|
|
92
|
+
const img = imageAttachment()
|
|
93
|
+
const msgs: Message[] = [
|
|
94
|
+
assistantMessage(),
|
|
95
|
+
userMessage({ media: [img] }),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const { openaiMessages } = simulateFullPipeline(msgs)
|
|
99
|
+
|
|
100
|
+
// The last message should be the user message with multimodal content
|
|
101
|
+
const userMsg = openaiMessages[openaiMessages.length - 1]
|
|
102
|
+
expect(userMsg.role).toBe('user')
|
|
103
|
+
|
|
104
|
+
// content must be an array (multimodal), NOT a string
|
|
105
|
+
expect(Array.isArray(userMsg.content)).toBe(true)
|
|
106
|
+
|
|
107
|
+
const parts = userMsg.content as ContentPart[]
|
|
108
|
+
// Must have text + image_url parts
|
|
109
|
+
const imageParts = parts.filter(p => p.type === 'image_url')
|
|
110
|
+
expect(imageParts.length).toBeGreaterThanOrEqual(1)
|
|
111
|
+
|
|
112
|
+
// The image_url must contain the actual data URL, not "[object Object]" or empty
|
|
113
|
+
const imgPart = imageParts[0] as { type: 'image_url'; image_url: { url: string } }
|
|
114
|
+
expect(imgPart.image_url.url).toBe(img.url)
|
|
115
|
+
expect(imgPart.image_url.url).toContain('data:image/png;base64,')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('preserves image data through JSON serialization round-trip', () => {
|
|
119
|
+
const img = imageAttachment()
|
|
120
|
+
const msg = userMessage({ media: [img] })
|
|
121
|
+
const content = buildApiContent(msg)
|
|
122
|
+
|
|
123
|
+
// Simulate JSON round-trip (fetch body → request.json())
|
|
124
|
+
const serialized = JSON.stringify({ role: 'user', content })
|
|
125
|
+
const deserialized = JSON.parse(serialized)
|
|
126
|
+
|
|
127
|
+
// content must still be an array after round-trip
|
|
128
|
+
expect(Array.isArray(deserialized.content)).toBe(true)
|
|
129
|
+
expect(deserialized.content).toHaveLength(2) // text + image
|
|
130
|
+
|
|
131
|
+
// image_url part must survive
|
|
132
|
+
const imgPart = deserialized.content.find((p: ContentPart) => p.type === 'image_url')
|
|
133
|
+
expect(imgPart).toBeDefined()
|
|
134
|
+
expect(imgPart.image_url.url).toBe(img.url)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('validation preserves multimodal content arrays — does not stringify them', () => {
|
|
138
|
+
const imageUrl = 'data:image/png;base64,iVBORw0KGgoAAAA'
|
|
139
|
+
const messages = [
|
|
140
|
+
{
|
|
141
|
+
role: 'user',
|
|
142
|
+
content: [
|
|
143
|
+
{ type: 'text', text: 'what is this?' },
|
|
144
|
+
{ type: 'image_url', image_url: { url: imageUrl } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
const validated = validateMessages(messages)
|
|
150
|
+
expect(validated).toHaveLength(1)
|
|
151
|
+
|
|
152
|
+
// content must be the ARRAY, not a stringified version
|
|
153
|
+
const content = validated[0].content
|
|
154
|
+
expect(Array.isArray(content)).toBe(true)
|
|
155
|
+
expect(typeof content).not.toBe('string')
|
|
156
|
+
|
|
157
|
+
const parts = content as ContentPart[]
|
|
158
|
+
expect(parts).toHaveLength(2)
|
|
159
|
+
|
|
160
|
+
const imgPart = parts[1] as { type: 'image_url'; image_url: { url: string } }
|
|
161
|
+
expect(imgPart.type).toBe('image_url')
|
|
162
|
+
expect(imgPart.image_url.url).toBe(imageUrl)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('route message mapping preserves multimodal content as-is', () => {
|
|
166
|
+
// Simulate what the route does: map validated messages
|
|
167
|
+
const imageUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRg'
|
|
168
|
+
const validatedMessages = [
|
|
169
|
+
{
|
|
170
|
+
role: 'user' as const,
|
|
171
|
+
content: [
|
|
172
|
+
{ type: 'text' as const, text: 'describe this image' },
|
|
173
|
+
{ type: 'image_url' as const, image_url: { url: imageUrl } },
|
|
174
|
+
] as ContentPart[],
|
|
175
|
+
},
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
// This is what the route does on line 57
|
|
179
|
+
const mapped = validatedMessages.map(m => ({ role: m.role, content: m.content }))
|
|
180
|
+
|
|
181
|
+
// content must still be the array
|
|
182
|
+
expect(Array.isArray(mapped[0].content)).toBe(true)
|
|
183
|
+
const parts = mapped[0].content as ContentPart[]
|
|
184
|
+
const imgPart = parts[1] as { type: 'image_url'; image_url: { url: string } }
|
|
185
|
+
expect(imgPart.image_url.url).toBe(imageUrl)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('handles image-only message (no text) through full pipeline', () => {
|
|
189
|
+
// When user sends image without typing anything, content becomes a label
|
|
190
|
+
const img = imageAttachment()
|
|
191
|
+
const msgs: Message[] = [
|
|
192
|
+
userMessage({ content: '[screenshot.png]', media: [img] }),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
const { openaiMessages } = simulateFullPipeline(msgs)
|
|
196
|
+
const userMsg = openaiMessages[1] // index 0 is system
|
|
197
|
+
|
|
198
|
+
expect(Array.isArray(userMsg.content)).toBe(true)
|
|
199
|
+
const parts = userMsg.content as ContentPart[]
|
|
200
|
+
|
|
201
|
+
// Should have text part (the label) + image_url part
|
|
202
|
+
const imageParts = parts.filter(p => p.type === 'image_url')
|
|
203
|
+
expect(imageParts.length).toBe(1)
|
|
204
|
+
|
|
205
|
+
const imgPart = imageParts[0] as { type: 'image_url'; image_url: { url: string } }
|
|
206
|
+
expect(imgPart.image_url.url).toBe(img.url)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('handles multiple images in a single message', () => {
|
|
210
|
+
const img1 = imageAttachment({ url: 'data:image/png;base64,AAA', name: 'first.png' })
|
|
211
|
+
const img2 = imageAttachment({ url: 'data:image/jpeg;base64,BBB', name: 'second.jpg' })
|
|
212
|
+
const msgs: Message[] = [
|
|
213
|
+
userMessage({ content: 'compare these', media: [img1, img2] }),
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
const { openaiMessages } = simulateFullPipeline(msgs)
|
|
217
|
+
const userMsg = openaiMessages[1]
|
|
218
|
+
|
|
219
|
+
expect(Array.isArray(userMsg.content)).toBe(true)
|
|
220
|
+
const parts = userMsg.content as ContentPart[]
|
|
221
|
+
const imageParts = parts.filter(p => p.type === 'image_url') as Array<{ type: 'image_url'; image_url: { url: string } }>
|
|
222
|
+
|
|
223
|
+
expect(imageParts).toHaveLength(2)
|
|
224
|
+
expect(imageParts[0].image_url.url).toBe('data:image/png;base64,AAA')
|
|
225
|
+
expect(imageParts[1].image_url.url).toBe('data:image/jpeg;base64,BBB')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('plain text messages remain strings through the pipeline (not wrapped in array)', () => {
|
|
229
|
+
const msgs: Message[] = [
|
|
230
|
+
assistantMessage(),
|
|
231
|
+
userMessage({ content: 'just text, no images' }),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
const { openaiMessages } = simulateFullPipeline(msgs)
|
|
235
|
+
|
|
236
|
+
// Assistant message (index 1, after system) should be a string
|
|
237
|
+
expect(typeof openaiMessages[1].content).toBe('string')
|
|
238
|
+
|
|
239
|
+
// User message (index 2) should also be a string (no media)
|
|
240
|
+
expect(typeof openaiMessages[2].content).toBe('string')
|
|
241
|
+
expect(openaiMessages[2].content).toBe('just text, no images')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('conversation with mix of plain and multimodal messages', () => {
|
|
245
|
+
const img = imageAttachment()
|
|
246
|
+
const msgs: Message[] = [
|
|
247
|
+
assistantMessage({ content: 'Hi there!' }),
|
|
248
|
+
userMessage({ id: 'u1', content: 'hello' }), // plain
|
|
249
|
+
assistantMessage({ id: 'a1', content: 'How can I help?' }),
|
|
250
|
+
userMessage({ id: 'u2', content: 'what is this?', media: [img] }), // multimodal
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
const { openaiMessages } = simulateFullPipeline(msgs)
|
|
254
|
+
|
|
255
|
+
// system (0), assistant (1), user plain (2), assistant (3), user multimodal (4)
|
|
256
|
+
expect(openaiMessages).toHaveLength(5)
|
|
257
|
+
|
|
258
|
+
// Plain user message
|
|
259
|
+
expect(typeof openaiMessages[2].content).toBe('string')
|
|
260
|
+
|
|
261
|
+
// Multimodal user message
|
|
262
|
+
expect(Array.isArray(openaiMessages[4].content)).toBe(true)
|
|
263
|
+
const parts = openaiMessages[4].content as ContentPart[]
|
|
264
|
+
expect(parts.some(p => p.type === 'image_url')).toBe(true)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// =============================================================
|
|
269
|
+
// Specific regression tests for common bugs
|
|
270
|
+
// =============================================================
|
|
271
|
+
|
|
272
|
+
describe('regression — content must not become [object Object]', () => {
|
|
273
|
+
it('buildApiContent result does not stringify to [object Object]', () => {
|
|
274
|
+
const msg = userMessage({ media: [imageAttachment()] })
|
|
275
|
+
const content = buildApiContent(msg)
|
|
276
|
+
|
|
277
|
+
// If content were accidentally coerced to string, it would be "[object Object]"
|
|
278
|
+
const asString = String(content)
|
|
279
|
+
expect(asString).not.toBe('[object Object]')
|
|
280
|
+
|
|
281
|
+
// The actual content should be an array
|
|
282
|
+
expect(Array.isArray(content)).toBe(true)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('multimodal content survives template literal (catches accidental string coercion)', () => {
|
|
286
|
+
const msg = userMessage({ media: [imageAttachment()] })
|
|
287
|
+
const content = buildApiContent(msg)
|
|
288
|
+
|
|
289
|
+
// This simulates an accidental `${content}` in code
|
|
290
|
+
if (Array.isArray(content)) {
|
|
291
|
+
// Verify the array is properly structured, not accidentally stringified
|
|
292
|
+
const jsonStr = JSON.stringify(content)
|
|
293
|
+
expect(jsonStr).not.toContain('[object Object]')
|
|
294
|
+
expect(jsonStr).toContain('image_url')
|
|
295
|
+
expect(jsonStr).toContain('data:image/png;base64,')
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
describe('regression — validation must not strip image_url parts', () => {
|
|
301
|
+
it('validates and returns all content part types', () => {
|
|
302
|
+
const result = validateChatMessages({
|
|
303
|
+
messages: [{
|
|
304
|
+
role: 'user',
|
|
305
|
+
content: [
|
|
306
|
+
{ type: 'text', text: 'look at this' },
|
|
307
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } },
|
|
308
|
+
],
|
|
309
|
+
}],
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
expect(result.ok).toBe(true)
|
|
313
|
+
if (!result.ok) return
|
|
314
|
+
|
|
315
|
+
const content = result.messages[0].content
|
|
316
|
+
expect(Array.isArray(content)).toBe(true)
|
|
317
|
+
const parts = content as ContentPart[]
|
|
318
|
+
|
|
319
|
+
// Both parts must survive validation
|
|
320
|
+
expect(parts).toHaveLength(2)
|
|
321
|
+
expect(parts[0].type).toBe('text')
|
|
322
|
+
expect(parts[1].type).toBe('image_url')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('preserves the exact image URL through validation', () => {
|
|
326
|
+
const longBase64Url = 'data:image/png;base64,' + 'A'.repeat(1000)
|
|
327
|
+
const result = validateChatMessages({
|
|
328
|
+
messages: [{
|
|
329
|
+
role: 'user',
|
|
330
|
+
content: [
|
|
331
|
+
{ type: 'image_url', image_url: { url: longBase64Url } },
|
|
332
|
+
],
|
|
333
|
+
}],
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(result.ok).toBe(true)
|
|
337
|
+
if (!result.ok) return
|
|
338
|
+
|
|
339
|
+
const parts = result.messages[0].content as ContentPart[]
|
|
340
|
+
const imgPart = parts[0] as { type: 'image_url'; image_url: { url: string } }
|
|
341
|
+
expect(imgPart.image_url.url).toBe(longBase64Url)
|
|
342
|
+
})
|
|
343
|
+
})
|