mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 08:51:58 +00:00
Full product rebrand across the entire codebase: - User-facing strings: "Agent Claw" -> "ClawPort" everywhere (title, sidebar, search, onboarding wizard, chat header, setup script) - TypeScript interfaces: ManorSettings -> ClawPortSettings, ManorMap -> OrgMap, ManorPage -> HomePage - Settings fields: manorName -> portalName, manorSubtitle -> portalSubtitle, manorEmoji -> portalEmoji, manorIcon -> portalIcon (with matching setters) - localStorage keys: manor-settings -> clawport-settings (with legacy migration), manor-theme -> clawport-theme, manor-onboarded -> clawport-onboarded, manor-conversations -> clawport-conversations, manor-kanban -> clawport-kanban - Custom events: manor:open-search -> clawport:open-search - API keys: agent:main:manor-ui -> agent:main:clawport - Workspace paths: $WORKSPACE_PATH/manor/ -> $WORKSPACE_PATH/clawport/ - Package name: manor-ui -> clawport - All documentation (README, SETUP, CLAUDE.md, BRANDING, docs/) - All test files updated to match new keys and field names - 288/288 tests passing, tsc --noEmit clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
6.2 KiB
TypeScript
221 lines
6.2 KiB
TypeScript
/**
|
|
* OpenClaw gateway integration for vision (image) messages.
|
|
*
|
|
* The gateway's /v1/chat/completions endpoint strips image_url content parts.
|
|
* Images work through the agent pipeline (chat.send), which is the same path
|
|
* Discord/Telegram/etc use. We invoke the CLI to send, then poll chat.history.
|
|
*
|
|
* Flow: extract images → CLI chat.send → poll chat.history → extract response
|
|
*/
|
|
|
|
import { execFile } from 'child_process'
|
|
import type { ApiMessage, ContentPart } from './validation'
|
|
|
|
export interface OpenClawAttachment {
|
|
mimeType: string
|
|
content: string // base64
|
|
}
|
|
|
|
/**
|
|
* Check if any message in the array contains image_url content parts.
|
|
*/
|
|
export function hasImageContent(messages: ApiMessage[]): boolean {
|
|
return messages.some(m => {
|
|
if (typeof m.content === 'string') return false
|
|
return (m.content as ContentPart[]).some(p => p.type === 'image_url')
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Extract all image attachments from messages in OpenClaw's format:
|
|
* { mimeType: "image/png", content: "<base64>" }
|
|
*/
|
|
export function extractImageAttachments(messages: ApiMessage[]): OpenClawAttachment[] {
|
|
const attachments: OpenClawAttachment[] = []
|
|
|
|
for (const msg of messages) {
|
|
if (typeof msg.content === 'string') continue
|
|
for (const part of msg.content as ContentPart[]) {
|
|
if (part.type === 'image_url') {
|
|
const { mediaType, data } = parseDataUrl(part.image_url.url)
|
|
attachments.push({ mimeType: mediaType, content: data })
|
|
}
|
|
}
|
|
}
|
|
|
|
return attachments
|
|
}
|
|
|
|
/**
|
|
* Build a text prompt from the system prompt and conversation messages.
|
|
* Extracts text from content arrays, skips system messages and image parts.
|
|
*/
|
|
export function buildTextPrompt(systemPrompt: string, messages: ApiMessage[]): string {
|
|
const parts: string[] = []
|
|
|
|
if (systemPrompt) {
|
|
parts.push(systemPrompt)
|
|
}
|
|
|
|
for (const msg of messages) {
|
|
if (msg.role === 'system') continue
|
|
|
|
let text: string
|
|
if (typeof msg.content === 'string') {
|
|
text = msg.content
|
|
} else {
|
|
text = (msg.content as ContentPart[])
|
|
.filter(p => p.type === 'text')
|
|
.map(p => p.text)
|
|
.join('\n')
|
|
}
|
|
|
|
if (text) {
|
|
parts.push(`${msg.role}: ${text}`)
|
|
}
|
|
}
|
|
|
|
return parts.join('\n\n')
|
|
}
|
|
|
|
/**
|
|
* Run openclaw CLI and return stdout, or null on error.
|
|
*/
|
|
export function execCli(
|
|
bin: string,
|
|
args: string[],
|
|
timeoutMs: number
|
|
): Promise<string | null> {
|
|
return new Promise((resolve) => {
|
|
execFile(bin, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
console.error('execCli error:', err.message)
|
|
if (stderr) console.error('stderr:', stderr)
|
|
resolve(null)
|
|
return
|
|
}
|
|
resolve(stdout)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send a vision message through the OpenClaw gateway via CLI.
|
|
*
|
|
* Two-step process:
|
|
* 1. `openclaw gateway call chat.send` — fires the message (returns immediately)
|
|
* 2. Poll `openclaw gateway call chat.history` — wait for the assistant response
|
|
*
|
|
* Images must be resized client-side to fit within the OS argument size limit.
|
|
*
|
|
* Returns the assistant's response text, or null on failure.
|
|
*/
|
|
export async function sendViaOpenClaw(opts: {
|
|
gatewayToken: string
|
|
message: string
|
|
attachments: OpenClawAttachment[]
|
|
sessionKey?: string
|
|
timeoutMs?: number
|
|
}): Promise<string | null> {
|
|
const openclawBin = process.env.OPENCLAW_BIN || 'openclaw'
|
|
const sessionKey = opts.sessionKey || 'agent:main:clawport'
|
|
const idempotencyKey = `clawport-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
const timeoutMs = opts.timeoutMs || 60000
|
|
const token = opts.gatewayToken
|
|
|
|
// Timestamp before sending — used to identify the new response
|
|
const sendTs = Date.now()
|
|
|
|
// Step 1: Send the message via chat.send
|
|
const sendParams = JSON.stringify({
|
|
sessionKey,
|
|
idempotencyKey,
|
|
message: opts.message,
|
|
attachments: opts.attachments,
|
|
})
|
|
|
|
const sendResult = await execCli(openclawBin, [
|
|
'gateway', 'call', 'chat.send',
|
|
'--params', sendParams,
|
|
'--token', token,
|
|
'--json',
|
|
], 15000)
|
|
|
|
if (sendResult === null) {
|
|
return null
|
|
}
|
|
|
|
// Verify send was accepted
|
|
try {
|
|
const sendData = JSON.parse(sendResult)
|
|
if (sendData.status !== 'started' && !sendData.runId) {
|
|
console.error('sendViaOpenClaw: unexpected send response:', sendResult)
|
|
return null
|
|
}
|
|
} catch {
|
|
console.error('sendViaOpenClaw: failed to parse send response:', sendResult)
|
|
return null
|
|
}
|
|
|
|
// Step 2: Poll chat.history for the assistant response
|
|
const pollIntervalMs = 2000
|
|
const historyParams = JSON.stringify({ sessionKey })
|
|
const deadline = sendTs + timeoutMs
|
|
|
|
while (Date.now() < deadline) {
|
|
await new Promise(r => setTimeout(r, pollIntervalMs))
|
|
|
|
const historyResult = await execCli(openclawBin, [
|
|
'gateway', 'call', 'chat.history',
|
|
'--params', historyParams,
|
|
'--token', token,
|
|
'--json',
|
|
], 10000)
|
|
|
|
if (!historyResult) continue
|
|
|
|
try {
|
|
const history = JSON.parse(historyResult)
|
|
const messages = history.messages || []
|
|
if (messages.length === 0) continue
|
|
|
|
const lastMsg = messages[messages.length - 1]
|
|
|
|
// Wait for an assistant message that arrived after we sent
|
|
if (lastMsg.role === 'assistant' && lastMsg.timestamp >= sendTs) {
|
|
const content = lastMsg.content
|
|
if (typeof content === 'string') return content
|
|
if (Array.isArray(content)) {
|
|
const textParts = content
|
|
.filter((p: { type: string }) => p.type === 'text')
|
|
.map((p: { text: string }) => p.text)
|
|
.join('\n')
|
|
return textParts || null
|
|
}
|
|
}
|
|
} catch {
|
|
// Parse error — try again next poll
|
|
}
|
|
}
|
|
|
|
console.error('sendViaOpenClaw: timed out waiting for response')
|
|
return null
|
|
}
|
|
|
|
function parseDataUrl(url: string): { mediaType: string; data: string } {
|
|
if (!url.startsWith('data:')) {
|
|
return { mediaType: 'image/png', data: url }
|
|
}
|
|
|
|
const commaIdx = url.indexOf(',')
|
|
if (commaIdx === -1) {
|
|
return { mediaType: 'image/png', data: url }
|
|
}
|
|
|
|
const header = url.slice(5, commaIdx)
|
|
const data = url.slice(commaIdx + 1)
|
|
const mediaType = header.split(';')[0] || 'image/png'
|
|
|
|
return { mediaType, data }
|
|
}
|