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
package/lib/agents.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { Agent } from '@/lib/types'
2
+ import { readFileSync, existsSync } from 'fs'
3
+ import { loadRegistry } from '@/lib/agents-registry'
4
+
5
+ export async function getAgents(): Promise<Agent[]> {
6
+ const workspacePath = process.env.WORKSPACE_PATH || ''
7
+ const registry = loadRegistry()
8
+
9
+ return registry.map((entry) => {
10
+ let soul: string | null = null
11
+ if (entry.soulPath && workspacePath) {
12
+ try {
13
+ const fullPath = workspacePath + '/' + entry.soulPath
14
+ if (existsSync(fullPath)) {
15
+ soul = readFileSync(fullPath, 'utf-8')
16
+ }
17
+ } catch {
18
+ soul = null
19
+ }
20
+ }
21
+ return {
22
+ ...entry,
23
+ soul,
24
+ crons: [],
25
+ }
26
+ })
27
+ }
28
+
29
+ export async function getAgent(id: string): Promise<Agent | null> {
30
+ const agents = await getAgents()
31
+ return agents.find((a) => a.id === id) ?? null
32
+ }
@@ -0,0 +1,422 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
+ import {
4
+ hasImageContent,
5
+ extractImageAttachments,
6
+ buildTextPrompt,
7
+ sendViaOpenClaw,
8
+ execCli,
9
+ } from './anthropic'
10
+ import type { ApiMessage } from './validation'
11
+
12
+ // --- hasImageContent ---
13
+
14
+ describe('hasImageContent', () => {
15
+ it('returns false for plain text messages', () => {
16
+ const msgs: ApiMessage[] = [
17
+ { role: 'user', content: 'hello' },
18
+ { role: 'assistant', content: 'hi' },
19
+ ]
20
+ expect(hasImageContent(msgs)).toBe(false)
21
+ })
22
+
23
+ it('returns true when any message has image_url parts', () => {
24
+ const msgs: ApiMessage[] = [
25
+ {
26
+ role: 'user',
27
+ content: [
28
+ { type: 'text', text: 'what is this?' },
29
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } },
30
+ ],
31
+ },
32
+ ]
33
+ expect(hasImageContent(msgs)).toBe(true)
34
+ })
35
+
36
+ it('returns false when content array has only text parts', () => {
37
+ const msgs: ApiMessage[] = [
38
+ {
39
+ role: 'user',
40
+ content: [
41
+ { type: 'text', text: 'just text in array form' },
42
+ ],
43
+ },
44
+ ]
45
+ expect(hasImageContent(msgs)).toBe(false)
46
+ })
47
+
48
+ it('returns true even if only one message out of many has images', () => {
49
+ const msgs: ApiMessage[] = [
50
+ { role: 'user', content: 'first message' },
51
+ { role: 'assistant', content: 'reply' },
52
+ {
53
+ role: 'user',
54
+ content: [
55
+ { type: 'text', text: 'look at this' },
56
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,xyz' } },
57
+ ],
58
+ },
59
+ ]
60
+ expect(hasImageContent(msgs)).toBe(true)
61
+ })
62
+ })
63
+
64
+ // --- extractImageAttachments ---
65
+
66
+ describe('extractImageAttachments', () => {
67
+ it('extracts base64 data and mimeType from data URL', () => {
68
+ const msgs: ApiMessage[] = [
69
+ {
70
+ role: 'user',
71
+ content: [
72
+ { type: 'text', text: 'describe' },
73
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAA' } },
74
+ ],
75
+ },
76
+ ]
77
+ const result = extractImageAttachments(msgs)
78
+ expect(result).toEqual([
79
+ { mimeType: 'image/png', content: 'iVBORw0KGgoAAAA' },
80
+ ])
81
+ })
82
+
83
+ it('extracts multiple images from a single message', () => {
84
+ const msgs: ApiMessage[] = [
85
+ {
86
+ role: 'user',
87
+ content: [
88
+ { type: 'text', text: 'compare' },
89
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' } },
90
+ { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,BBB' } },
91
+ ],
92
+ },
93
+ ]
94
+ const result = extractImageAttachments(msgs)
95
+ expect(result).toHaveLength(2)
96
+ expect(result[0]).toEqual({ mimeType: 'image/png', content: 'AAA' })
97
+ expect(result[1]).toEqual({ mimeType: 'image/jpeg', content: 'BBB' })
98
+ })
99
+
100
+ it('extracts images from multiple messages', () => {
101
+ const msgs: ApiMessage[] = [
102
+ {
103
+ role: 'user',
104
+ content: [
105
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,FIRST' } },
106
+ ],
107
+ },
108
+ { role: 'assistant', content: 'I see it' },
109
+ {
110
+ role: 'user',
111
+ content: [
112
+ { type: 'image_url', image_url: { url: 'data:image/webp;base64,SECOND' } },
113
+ ],
114
+ },
115
+ ]
116
+ const result = extractImageAttachments(msgs)
117
+ expect(result).toHaveLength(2)
118
+ expect(result[0].content).toBe('FIRST')
119
+ expect(result[1].content).toBe('SECOND')
120
+ })
121
+
122
+ it('returns empty array when no images', () => {
123
+ const msgs: ApiMessage[] = [
124
+ { role: 'user', content: 'just text' },
125
+ ]
126
+ expect(extractImageAttachments(msgs)).toEqual([])
127
+ })
128
+
129
+ it('defaults to image/png for non-data URLs', () => {
130
+ const msgs: ApiMessage[] = [
131
+ {
132
+ role: 'user',
133
+ content: [
134
+ { type: 'image_url', image_url: { url: 'https://example.com/img.png' } },
135
+ ],
136
+ },
137
+ ]
138
+ const result = extractImageAttachments(msgs)
139
+ expect(result[0].mimeType).toBe('image/png')
140
+ })
141
+ })
142
+
143
+ // --- buildTextPrompt ---
144
+
145
+ describe('buildTextPrompt', () => {
146
+ it('combines system prompt and conversation history', () => {
147
+ const msgs: ApiMessage[] = [
148
+ { role: 'user', content: 'what is this?' },
149
+ ]
150
+ const result = buildTextPrompt('You are helpful.', msgs)
151
+ expect(result).toContain('You are helpful.')
152
+ expect(result).toContain('what is this?')
153
+ })
154
+
155
+ it('includes all user and assistant messages', () => {
156
+ const msgs: ApiMessage[] = [
157
+ { role: 'user', content: 'hello' },
158
+ { role: 'assistant', content: 'hi there' },
159
+ { role: 'user', content: 'describe the image' },
160
+ ]
161
+ const result = buildTextPrompt('system prompt', msgs)
162
+ expect(result).toContain('hello')
163
+ expect(result).toContain('hi there')
164
+ expect(result).toContain('describe the image')
165
+ })
166
+
167
+ it('extracts text from content part arrays', () => {
168
+ const msgs: ApiMessage[] = [
169
+ {
170
+ role: 'user',
171
+ content: [
172
+ { type: 'text', text: 'what do you see?' },
173
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,xxx' } },
174
+ ],
175
+ },
176
+ ]
177
+ const result = buildTextPrompt('', msgs)
178
+ expect(result).toContain('what do you see?')
179
+ expect(result).not.toContain('data:image')
180
+ })
181
+
182
+ it('skips system role messages from the messages array', () => {
183
+ const msgs: ApiMessage[] = [
184
+ { role: 'system', content: 'extra system' },
185
+ { role: 'user', content: 'question' },
186
+ ]
187
+ const result = buildTextPrompt('main system', msgs)
188
+ expect(result).toContain('main system')
189
+ expect(result).toContain('question')
190
+ expect(result).not.toContain('extra system')
191
+ })
192
+ })
193
+
194
+ // --- execCli ---
195
+
196
+ vi.mock('child_process', () => ({
197
+ execFile: vi.fn(),
198
+ }))
199
+
200
+ import { execFile as mockExecFile } from 'child_process'
201
+
202
+ describe('execCli', () => {
203
+ beforeEach(() => {
204
+ vi.mocked(mockExecFile).mockReset()
205
+ })
206
+
207
+ it('returns stdout on success', async () => {
208
+ vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
209
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, 'output', '')
210
+ return {} as ReturnType<typeof mockExecFile>
211
+ })
212
+ const result = await execCli('/usr/bin/openclaw', ['arg1'], 5000)
213
+ expect(result).toBe('output')
214
+ })
215
+
216
+ it('returns null on error', async () => {
217
+ vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
218
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(new Error('fail'), '', '')
219
+ return {} as ReturnType<typeof mockExecFile>
220
+ })
221
+ const result = await execCli('/usr/bin/openclaw', ['arg1'], 5000)
222
+ expect(result).toBeNull()
223
+ })
224
+ })
225
+
226
+ // --- sendViaOpenClaw ---
227
+
228
+ describe('sendViaOpenClaw', () => {
229
+ beforeEach(() => {
230
+ vi.stubEnv('OPENCLAW_BIN', '/usr/bin/openclaw')
231
+ vi.mocked(mockExecFile).mockReset()
232
+ vi.useFakeTimers({ shouldAdvanceTime: true })
233
+ })
234
+
235
+ afterEach(() => {
236
+ vi.unstubAllEnvs()
237
+ vi.useRealTimers()
238
+ })
239
+
240
+ it('sends chat.send then polls chat.history for response', async () => {
241
+ let callCount = 0
242
+ vi.mocked(mockExecFile).mockImplementation((_cmd, args, _opts, cb) => {
243
+ callCount++
244
+ const argsArr = args as string[]
245
+
246
+ if (argsArr.includes('chat.send')) {
247
+ // Step 1: send returns started
248
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
249
+ null,
250
+ JSON.stringify({ runId: 'run-1', status: 'started' }),
251
+ ''
252
+ )
253
+ } else if (argsArr.includes('chat.history')) {
254
+ if (callCount <= 2) {
255
+ // First poll: still processing (last msg is user)
256
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
257
+ null,
258
+ JSON.stringify({
259
+ messages: [
260
+ { role: 'user', content: [{ type: 'text', text: 'describe' }], timestamp: Date.now() },
261
+ ],
262
+ }),
263
+ ''
264
+ )
265
+ } else {
266
+ // Second poll: assistant responded
267
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
268
+ null,
269
+ JSON.stringify({
270
+ messages: [
271
+ { role: 'user', content: [{ type: 'text', text: 'describe' }], timestamp: Date.now() },
272
+ {
273
+ role: 'assistant',
274
+ content: [
275
+ { type: 'thinking', thinking: 'analyzing...' },
276
+ { type: 'text', text: 'I see a Discord bot profile for Jarvis.' },
277
+ ],
278
+ timestamp: Date.now(),
279
+ },
280
+ ],
281
+ }),
282
+ ''
283
+ )
284
+ }
285
+ }
286
+ return {} as ReturnType<typeof mockExecFile>
287
+ })
288
+
289
+ const result = await sendViaOpenClaw({
290
+ gatewayToken: 'test-token',
291
+ message: 'describe this image',
292
+ attachments: [{ mimeType: 'image/png', content: 'base64data' }],
293
+ })
294
+
295
+ expect(result).toBe('I see a Discord bot profile for Jarvis.')
296
+ // Should have called: 1 send + at least 2 history polls
297
+ expect(callCount).toBeGreaterThanOrEqual(3)
298
+ })
299
+
300
+ it('returns null when chat.send fails', async () => {
301
+ vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
302
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
303
+ new Error('spawn E2BIG'),
304
+ '',
305
+ ''
306
+ )
307
+ return {} as ReturnType<typeof mockExecFile>
308
+ })
309
+
310
+ const result = await sendViaOpenClaw({
311
+ gatewayToken: 'test-token',
312
+ message: 'test',
313
+ attachments: [],
314
+ })
315
+
316
+ expect(result).toBeNull()
317
+ })
318
+
319
+ it('returns null when send response is unexpected', async () => {
320
+ vi.mocked(mockExecFile).mockImplementation((_cmd, _args, _opts, cb) => {
321
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
322
+ null,
323
+ JSON.stringify({ error: 'bad request' }),
324
+ ''
325
+ )
326
+ return {} as ReturnType<typeof mockExecFile>
327
+ })
328
+
329
+ const result = await sendViaOpenClaw({
330
+ gatewayToken: 'test-token',
331
+ message: 'test',
332
+ attachments: [],
333
+ })
334
+
335
+ expect(result).toBeNull()
336
+ })
337
+
338
+ it('passes correct params to chat.send', async () => {
339
+ vi.mocked(mockExecFile).mockImplementation((_cmd, args, _opts, cb) => {
340
+ const argsArr = args as string[]
341
+ if (argsArr.includes('chat.send')) {
342
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
343
+ null,
344
+ JSON.stringify({ runId: 'r1', status: 'started' }),
345
+ ''
346
+ )
347
+ } else {
348
+ // Return assistant response immediately
349
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
350
+ null,
351
+ JSON.stringify({
352
+ messages: [{
353
+ role: 'assistant',
354
+ content: [{ type: 'text', text: 'ok' }],
355
+ timestamp: Date.now(),
356
+ }],
357
+ }),
358
+ ''
359
+ )
360
+ }
361
+ return {} as ReturnType<typeof mockExecFile>
362
+ })
363
+
364
+ await sendViaOpenClaw({
365
+ gatewayToken: 'my-token',
366
+ message: 'describe this',
367
+ attachments: [{ mimeType: 'image/jpeg', content: 'imgdata' }],
368
+ sessionKey: 'custom:session',
369
+ })
370
+
371
+ // Find the chat.send call
372
+ const sendCall = vi.mocked(mockExecFile).mock.calls.find(
373
+ c => (c[1] as string[]).includes('chat.send')
374
+ )
375
+ expect(sendCall).toBeTruthy()
376
+ const [bin, args] = sendCall!
377
+ expect(bin).toBe('/usr/bin/openclaw')
378
+ expect(args).toContain('--token')
379
+ expect(args).toContain('my-token')
380
+
381
+ const paramsIdx = (args as string[]).indexOf('--params')
382
+ const paramsJson = JSON.parse((args as string[])[paramsIdx + 1])
383
+ expect(paramsJson.sessionKey).toBe('custom:session')
384
+ expect(paramsJson.message).toBe('describe this')
385
+ expect(paramsJson.attachments).toHaveLength(1)
386
+ expect(paramsJson.attachments[0].mimeType).toBe('image/jpeg')
387
+ })
388
+
389
+ it('handles string content in assistant response', async () => {
390
+ vi.mocked(mockExecFile).mockImplementation((_cmd, args, _opts, cb) => {
391
+ const argsArr = args as string[]
392
+ if (argsArr.includes('chat.send')) {
393
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
394
+ null,
395
+ JSON.stringify({ runId: 'r1', status: 'started' }),
396
+ ''
397
+ )
398
+ } else {
399
+ (cb as (err: Error | null, stdout: string, stderr: string) => void)(
400
+ null,
401
+ JSON.stringify({
402
+ messages: [{
403
+ role: 'assistant',
404
+ content: 'plain string response',
405
+ timestamp: Date.now(),
406
+ }],
407
+ }),
408
+ ''
409
+ )
410
+ }
411
+ return {} as ReturnType<typeof mockExecFile>
412
+ })
413
+
414
+ const result = await sendViaOpenClaw({
415
+ gatewayToken: 'tok',
416
+ message: 'hi',
417
+ attachments: [],
418
+ })
419
+
420
+ expect(result).toBe('plain string response')
421
+ })
422
+ })
@@ -0,0 +1,220 @@
1
+ /**
2
+ * OpenClaw gateway integration for vision (image) messages.
3
+ *
4
+ * The gateway's /v1/chat/completions endpoint strips image_url content parts.
5
+ * Images work through the agent pipeline (chat.send), which is the same path
6
+ * Discord/Telegram/etc use. We invoke the CLI to send, then poll chat.history.
7
+ *
8
+ * Flow: extract images → CLI chat.send → poll chat.history → extract response
9
+ */
10
+
11
+ import { execFile } from 'child_process'
12
+ import type { ApiMessage, ContentPart } from './validation'
13
+
14
+ export interface OpenClawAttachment {
15
+ mimeType: string
16
+ content: string // base64
17
+ }
18
+
19
+ /**
20
+ * Check if any message in the array contains image_url content parts.
21
+ */
22
+ export function hasImageContent(messages: ApiMessage[]): boolean {
23
+ return messages.some(m => {
24
+ if (typeof m.content === 'string') return false
25
+ return (m.content as ContentPart[]).some(p => p.type === 'image_url')
26
+ })
27
+ }
28
+
29
+ /**
30
+ * Extract all image attachments from messages in OpenClaw's format:
31
+ * { mimeType: "image/png", content: "<base64>" }
32
+ */
33
+ export function extractImageAttachments(messages: ApiMessage[]): OpenClawAttachment[] {
34
+ const attachments: OpenClawAttachment[] = []
35
+
36
+ for (const msg of messages) {
37
+ if (typeof msg.content === 'string') continue
38
+ for (const part of msg.content as ContentPart[]) {
39
+ if (part.type === 'image_url') {
40
+ const { mediaType, data } = parseDataUrl(part.image_url.url)
41
+ attachments.push({ mimeType: mediaType, content: data })
42
+ }
43
+ }
44
+ }
45
+
46
+ return attachments
47
+ }
48
+
49
+ /**
50
+ * Build a text prompt from the system prompt and conversation messages.
51
+ * Extracts text from content arrays, skips system messages and image parts.
52
+ */
53
+ export function buildTextPrompt(systemPrompt: string, messages: ApiMessage[]): string {
54
+ const parts: string[] = []
55
+
56
+ if (systemPrompt) {
57
+ parts.push(systemPrompt)
58
+ }
59
+
60
+ for (const msg of messages) {
61
+ if (msg.role === 'system') continue
62
+
63
+ let text: string
64
+ if (typeof msg.content === 'string') {
65
+ text = msg.content
66
+ } else {
67
+ text = (msg.content as ContentPart[])
68
+ .filter(p => p.type === 'text')
69
+ .map(p => p.text)
70
+ .join('\n')
71
+ }
72
+
73
+ if (text) {
74
+ parts.push(`${msg.role}: ${text}`)
75
+ }
76
+ }
77
+
78
+ return parts.join('\n\n')
79
+ }
80
+
81
+ /**
82
+ * Run openclaw CLI and return stdout, or null on error.
83
+ */
84
+ export function execCli(
85
+ bin: string,
86
+ args: string[],
87
+ timeoutMs: number
88
+ ): Promise<string | null> {
89
+ return new Promise((resolve) => {
90
+ execFile(bin, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
91
+ if (err) {
92
+ console.error('execCli error:', err.message)
93
+ if (stderr) console.error('stderr:', stderr)
94
+ resolve(null)
95
+ return
96
+ }
97
+ resolve(stdout)
98
+ })
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Send a vision message through the OpenClaw gateway via CLI.
104
+ *
105
+ * Two-step process:
106
+ * 1. `openclaw gateway call chat.send` — fires the message (returns immediately)
107
+ * 2. Poll `openclaw gateway call chat.history` — wait for the assistant response
108
+ *
109
+ * Images must be resized client-side to fit within the OS argument size limit.
110
+ *
111
+ * Returns the assistant's response text, or null on failure.
112
+ */
113
+ export async function sendViaOpenClaw(opts: {
114
+ gatewayToken: string
115
+ message: string
116
+ attachments: OpenClawAttachment[]
117
+ sessionKey?: string
118
+ timeoutMs?: number
119
+ }): Promise<string | null> {
120
+ const openclawBin = process.env.OPENCLAW_BIN || 'openclaw'
121
+ const sessionKey = opts.sessionKey || 'agent:main:clawport'
122
+ const idempotencyKey = `clawport-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
123
+ const timeoutMs = opts.timeoutMs || 60000
124
+ const token = opts.gatewayToken
125
+
126
+ // Timestamp before sending — used to identify the new response
127
+ const sendTs = Date.now()
128
+
129
+ // Step 1: Send the message via chat.send
130
+ const sendParams = JSON.stringify({
131
+ sessionKey,
132
+ idempotencyKey,
133
+ message: opts.message,
134
+ attachments: opts.attachments,
135
+ })
136
+
137
+ const sendResult = await execCli(openclawBin, [
138
+ 'gateway', 'call', 'chat.send',
139
+ '--params', sendParams,
140
+ '--token', token,
141
+ '--json',
142
+ ], 15000)
143
+
144
+ if (sendResult === null) {
145
+ return null
146
+ }
147
+
148
+ // Verify send was accepted
149
+ try {
150
+ const sendData = JSON.parse(sendResult)
151
+ if (sendData.status !== 'started' && !sendData.runId) {
152
+ console.error('sendViaOpenClaw: unexpected send response:', sendResult)
153
+ return null
154
+ }
155
+ } catch {
156
+ console.error('sendViaOpenClaw: failed to parse send response:', sendResult)
157
+ return null
158
+ }
159
+
160
+ // Step 2: Poll chat.history for the assistant response
161
+ const pollIntervalMs = 2000
162
+ const historyParams = JSON.stringify({ sessionKey })
163
+ const deadline = sendTs + timeoutMs
164
+
165
+ while (Date.now() < deadline) {
166
+ await new Promise(r => setTimeout(r, pollIntervalMs))
167
+
168
+ const historyResult = await execCli(openclawBin, [
169
+ 'gateway', 'call', 'chat.history',
170
+ '--params', historyParams,
171
+ '--token', token,
172
+ '--json',
173
+ ], 10000)
174
+
175
+ if (!historyResult) continue
176
+
177
+ try {
178
+ const history = JSON.parse(historyResult)
179
+ const messages = history.messages || []
180
+ if (messages.length === 0) continue
181
+
182
+ const lastMsg = messages[messages.length - 1]
183
+
184
+ // Wait for an assistant message that arrived after we sent
185
+ if (lastMsg.role === 'assistant' && lastMsg.timestamp >= sendTs) {
186
+ const content = lastMsg.content
187
+ if (typeof content === 'string') return content
188
+ if (Array.isArray(content)) {
189
+ const textParts = content
190
+ .filter((p: { type: string }) => p.type === 'text')
191
+ .map((p: { text: string }) => p.text)
192
+ .join('\n')
193
+ return textParts || null
194
+ }
195
+ }
196
+ } catch {
197
+ // Parse error — try again next poll
198
+ }
199
+ }
200
+
201
+ console.error('sendViaOpenClaw: timed out waiting for response')
202
+ return null
203
+ }
204
+
205
+ function parseDataUrl(url: string): { mediaType: string; data: string } {
206
+ if (!url.startsWith('data:')) {
207
+ return { mediaType: 'image/png', data: url }
208
+ }
209
+
210
+ const commaIdx = url.indexOf(',')
211
+ if (commaIdx === -1) {
212
+ return { mediaType: 'image/png', data: url }
213
+ }
214
+
215
+ const header = url.slice(5, commaIdx)
216
+ const data = url.slice(commaIdx + 1)
217
+ const mediaType = header.split(';')[0] || 'image/png'
218
+
219
+ return { mediaType, data }
220
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared error response helper for API routes.
3
+ * Returns a consistent JSON shape: { error: string }
4
+ * so clients can distinguish "no data" from "server error".
5
+ */
6
+ export function apiErrorResponse(
7
+ err: unknown,
8
+ fallbackMessage = 'Internal server error',
9
+ status = 500
10
+ ): Response {
11
+ const message = err instanceof Error ? err.message : fallbackMessage
12
+ return new Response(JSON.stringify({ error: message }), {
13
+ status,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ })
16
+ }