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
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
|
+
})
|
package/lib/anthropic.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/api-error.ts
ADDED
|
@@ -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
|
+
}
|