feat(webchat): support image attachments

This commit is contained in:
William Valentin
2026-02-13 15:03:48 -08:00
parent 955b9e28e0
commit cc54b3a10c
7 changed files with 707 additions and 31 deletions
+15 -8
View File
@@ -21,11 +21,18 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
return {
'agent.send': async (request: GatewayRequest, send: SendFn): Promise<OutboundMessage | void> => {
const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[]; metadata?: { isCommand?: boolean; command?: string; commandArgs?: string } } | undefined;
if (!params?.message && !params?.metadata?.isCommand) {
return makeError(request.id, ErrorCode.InvalidRequest, 'message is required');
if (!params) {
return makeError(request.id, ErrorCode.InvalidRequest, 'params are required');
}
const connectionId = params.connectionId as string;
const safeParams = params;
const hasMessage = Boolean(safeParams.message && safeParams.message.trim());
const hasAttachments = Boolean(safeParams.attachments && safeParams.attachments.length > 0);
if (!hasMessage && !hasAttachments && !safeParams.metadata?.isCommand) {
return makeError(request.id, ErrorCode.InvalidRequest, 'message or attachments are required');
}
const connectionId = safeParams.connectionId as string;
if (!connectionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required (set by server)');
}
@@ -48,9 +55,9 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
return deps.laneQueue.enqueue(laneId, async () => {
deps.sessionBridge.setBusy(connectionId, true);
const commandInput = params.metadata?.isCommand && typeof params.metadata.command === 'string'
? `/${params.metadata.command}${params.metadata.commandArgs ? ` ${params.metadata.commandArgs}` : ''}`
: params.message;
const commandInput = safeParams.metadata?.isCommand && typeof safeParams.metadata.command === 'string'
? `/${safeParams.metadata.command}${safeParams.metadata.commandArgs ? ` ${safeParams.metadata.commandArgs}` : ''}`
: (safeParams.message ?? '');
if (commandInput && deps.commandRegistry?.isCommand(commandInput)) {
const sessionId = deps.sessionBridge.getSessionId(connectionId);
@@ -160,14 +167,14 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
try {
// Convert gateway attachments to channel attachments
const attachments: Attachment[] | undefined = params.attachments?.map(a => ({
const attachments: Attachment[] | undefined = safeParams.attachments?.map(a => ({
mimeType: a.mimeType,
data: a.data,
url: a.url,
filename: a.filename,
}));
const response = await agent.process(params.message!, attachments);
const response = await agent.process(safeParams.message ?? '', attachments);
deps.metrics?.incrementMessages();
send(makeEvent(request.id, 'done', { content: response }));
} catch (err) {
+22 -1
View File
@@ -323,7 +323,28 @@ describe('agent handlers', () => {
expect(sent).toHaveLength(1);
});
it('agent.send requires message', async () => {
it('agent.send accepts attachment-only requests', async () => {
const req: GatewayRequest = {
id: 12,
method: 'agent.send',
params: {
connectionId: 'conn-1',
attachments: [{ mimeType: 'image/png', data: 'iVBOR...' }],
},
};
const sent: OutboundMessage[] = [];
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
await handlers['agent.send'](req, send);
expect(mockAgent.process).toHaveBeenCalledWith('', [
{ mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: undefined },
]);
expect(sent).toHaveLength(1);
expect((sent[0] as GatewayEvent).event).toBe('done');
});
it('agent.send requires message or attachments', async () => {
const req: GatewayRequest = { id: 2, method: 'agent.send', params: { connectionId: 'conn-1' } };
const send = vi.fn();
const result = await handlers['agent.send'](req, send) as GatewayError;
+60 -3
View File
@@ -15,6 +15,8 @@
</header>
<div class="messages" id="messages"></div>
<div class="input-area">
<button id="attach" title="Attach image">Attach</button>
<input type="file" id="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple style="display:none">
<input type="text" id="input" placeholder="Type a message..." autofocus>
<button id="send">Send</button>
</div>
@@ -34,8 +36,36 @@
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const attachBtn = document.getElementById('attach');
const fileEl = document.getElementById('file');
const statusEl = document.getElementById('status');
let pendingAttachments = [];
function isSupportedImageMime(mimeType) {
return mimeType === 'image/jpeg'
|| mimeType === 'image/png'
|| mimeType === 'image/gif'
|| mimeType === 'image/webp';
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => {
const result = String(reader.result || '');
const comma = result.indexOf(',');
if (comma === -1) {
reject(new Error('Unexpected file encoding'));
return;
}
resolve(result.slice(comma + 1));
};
reader.readAsDataURL(file);
});
}
// ── WebSocket connection ───────────────────────────────────────
function connect() {
@@ -82,13 +112,13 @@
function sendMessage() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return;
if ((!text && pendingAttachments.length === 0) || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return;
requestId++;
const id = requestId;
// Display user message
appendUserMessage(text);
appendUserMessage(text || `Sent ${pendingAttachments.length} attachment(s)`);
// Create an assistant message area for tool events + final response
createAssistantArea(id);
@@ -97,14 +127,41 @@
ws.send(JSON.stringify({
id: id,
method: 'agent.send',
params: { message: text },
params: { message: text, attachments: pendingAttachments },
}));
// Clear input and disable while waiting
inputEl.value = '';
pendingAttachments = [];
fileEl.value = '';
setInputDisabled(true);
}
attachBtn.addEventListener('click', () => {
fileEl.click();
});
fileEl.addEventListener('change', async () => {
const files = Array.from(fileEl.files ?? []);
const MAX_BYTES = 5 * 1024 * 1024;
for (const file of files) {
if (!isSupportedImageMime(file.type)) {
appendErrorMessage(`Unsupported file type: ${file.type || file.name}`);
continue;
}
if (file.size > MAX_BYTES) {
appendErrorMessage(`File too large (max 5MB): ${file.name}`);
continue;
}
try {
const data = await readFileAsBase64(file);
pendingAttachments.push({ mimeType: file.type, data, filename: file.name });
} catch (err) {
appendErrorMessage(`Failed to attach ${file.name}: ${err.message}`);
}
}
});
function setInputDisabled(disabled) {
inputDisabled = disabled;
inputEl.disabled = disabled;
+135 -16
View File
@@ -12,6 +12,7 @@ let _sending = false;
let _searchMode = false;
let _slashPopupIndex = -1;
let _elements = {};
let _pendingAttachments = [];
// ── Slash Command Definitions ───────────────────────────────
@@ -114,6 +115,75 @@ function scrollToBottom() {
}
}
function isSupportedImageMime(mimeType) {
return mimeType === 'image/jpeg'
|| mimeType === 'image/png'
|| mimeType === 'image/gif'
|| mimeType === 'image/webp';
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => {
const result = String(reader.result || '');
// result is like: data:<mime>;base64,<payload>
const comma = result.indexOf(',');
if (comma === -1) {
reject(new Error('Unexpected file encoding'));
return;
}
resolve(result.slice(comma + 1));
};
reader.readAsDataURL(file);
});
}
function renderPendingAttachments() {
const el = _elements.attachments;
if (!el) {return;}
el.innerHTML = '';
if (!_pendingAttachments.length) {
el.classList.add('hidden');
return;
}
el.classList.remove('hidden');
for (let i = 0; i < _pendingAttachments.length; i++) {
const att = _pendingAttachments[i];
const chip = document.createElement('div');
chip.className = 'attachment-chip';
const name = document.createElement('span');
name.className = 'attachment-name';
name.textContent = att.filename || 'attachment';
const rm = document.createElement('button');
rm.className = 'attachment-remove';
rm.type = 'button';
rm.title = 'Remove attachment';
rm.textContent = '×';
rm.addEventListener('click', () => {
_pendingAttachments.splice(i, 1);
renderPendingAttachments();
});
chip.appendChild(name);
chip.appendChild(rm);
el.appendChild(chip);
}
}
function clearPendingAttachments() {
_pendingAttachments = [];
if (_elements.fileInput) {
_elements.fileInput.value = '';
}
renderPendingAttachments();
}
// ── Message Action Buttons ──────────────────────────────────
function getMessageText(el) {
@@ -463,33 +533,38 @@ async function loadHistory(client) {
async function sendMessage(client, overrideText) {
const input = _elements.input;
const rawText = overrideText ?? input?.value?.trim();
if (!rawText || _sending) {return;}
const rawText = (overrideText ?? input?.value ?? '').trim();
const hasText = Boolean(rawText);
const hasAttachments = Boolean(_pendingAttachments.length > 0);
if ((!hasText && !hasAttachments) || _sending) {return;}
// Check for slash commands first
const cmd = parseSlashCommand(rawText);
if (cmd) {
if (!overrideText) {input.value = '';}
hideSlashPopup();
const handled = await handleSlashCommand(cmd, client);
if (handled) {return;}
// If not fully handled (e.g. /compact), fall through to send as message
// Check for slash commands first (text only)
if (hasText) {
const cmd = parseSlashCommand(rawText);
if (cmd) {
if (!overrideText) {input.value = '';}
hideSlashPopup();
const handled = await handleSlashCommand(cmd, client);
if (handled) {return;}
// If not fully handled (e.g. /compact), fall through to send as message
}
}
_sending = true;
_elements.sendBtn.disabled = true;
if (!overrideText) {input.value = '';}
if (!overrideText && input) {input.value = '';}
// Apply search mode prefix
let messageText = rawText;
if (_searchMode && !rawText.startsWith('/')) {
if (_searchMode && hasText && !rawText.startsWith('/')) {
messageText = `Search the web for: ${rawText}`;
setSearchMode(false);
}
// Show user message (show original text, not the prefixed version)
const displayText = _searchMode ? rawText : messageText;
_elements.messages.appendChild(createMessageEl('user', rawText));
const userDisplay = hasText
? rawText
: `Sent ${_pendingAttachments.length} attachment(s)`;
_elements.messages.appendChild(createMessageEl('user', userDisplay));
scrollToBottom();
// Create placeholder for assistant response
@@ -500,7 +575,7 @@ async function sendMessage(client, overrideText) {
scrollToBottom();
try {
const stream = client.stream('agent.send', { message: messageText });
const stream = client.stream('agent.send', { message: messageText, attachments: _pendingAttachments });
stream.on('tool_start', (data) => {
const el = createToolEventEl('tool_start', data);
@@ -542,6 +617,7 @@ async function sendMessage(client, overrideText) {
} finally {
_sending = false;
if (_elements.sendBtn) {_elements.sendBtn.disabled = false;}
clearPendingAttachments();
scrollToBottom();
}
}
@@ -549,6 +625,7 @@ async function sendMessage(client, overrideText) {
// ── Search SVG Icon ─────────────────────────────────────────
const SEARCH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>';
const ATTACH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 4.75A3.75 3.75 0 0 1 11.25 1h.5A3.25 3.25 0 0 1 15 4.25V12a4.5 4.5 0 0 1-9 0V6.75a2.75 2.75 0 0 1 5.5 0V12a1.25 1.25 0 0 1-2.5 0V6.75a.75.75 0 0 0-1.5 0V12a2.75 2.75 0 0 0 5.5 0V6.75A4.25 4.25 0 0 0 5 6.75V12a6 6 0 0 0 12 0V4.25A4.75 4.75 0 0 0 11.75-.5h-.5A5.25 5.25 0 0 0 6 4.75v8a.75.75 0 0 0 1.5 0z" fill="currentColor"/></svg>';
const COPY_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" fill="currentColor"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" fill="currentColor"/></svg>';
const CHECK_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>';
const EDIT_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L3.463 11.1l-.47 1.64 1.64-.47 8.61-8.61a.25.25 0 0 0 0-.354Z" fill="currentColor"/></svg>';
@@ -570,6 +647,12 @@ export const ChatPage = {
${SEARCH_ICON}
<span class="btn-action-label">Search</span>
</button>
<button id="chat-attach" class="btn-action" title="Attach image">
${ATTACH_ICON}
<span class="btn-action-label">Attach</span>
</button>
<input id="chat-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple class="hidden" />
<div id="chat-attachments" class="chat-attachments hidden"></div>
</div>
<div class="chat-input-wrapper">
<div id="slash-popup" class="slash-popup hidden"></div>
@@ -587,6 +670,9 @@ export const ChatPage = {
input: el.querySelector('#chat-input'),
sendBtn: el.querySelector('#chat-send'),
searchBtn: el.querySelector('#chat-search'),
attachBtn: el.querySelector('#chat-attach'),
fileInput: el.querySelector('#chat-file'),
attachments: el.querySelector('#chat-attachments'),
slashPopup: el.querySelector('#slash-popup'),
};
@@ -620,6 +706,38 @@ export const ChatPage = {
setSearchMode(!_searchMode);
});
// Event: attach button
_elements.attachBtn.addEventListener('click', () => {
if (_elements.fileInput) {_elements.fileInput.click();}
});
// Event: file input change
_elements.fileInput.addEventListener('change', async () => {
const files = Array.from(_elements.fileInput.files ?? []);
if (files.length === 0) {return;}
const MAX_BYTES = 5 * 1024 * 1024;
for (const file of files) {
if (!isSupportedImageMime(file.type)) {
showSystemMessage(`Unsupported file type: ${file.type || file.name}`);
continue;
}
if (file.size > MAX_BYTES) {
showSystemMessage(`File too large (max 5MB): ${file.name}`);
continue;
}
try {
const data = await readFileAsBase64(file);
_pendingAttachments.push({ mimeType: file.type, data, filename: file.name });
} catch (err) {
showSystemMessage(`Failed to attach ${file.name}: ${err.message}`);
}
}
renderPendingAttachments();
});
// Event: send message
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
@@ -695,5 +813,6 @@ export const ChatPage = {
_searchMode = false;
_slashPopupIndex = -1;
_elements = {};
_pendingAttachments = [];
},
};
+42
View File
@@ -950,6 +950,48 @@ tr:hover td {
align-items: center;
gap: 8px;
padding: 8px 0;
flex-wrap: wrap;
}
.chat-attachments {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.attachment-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 999px;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.attachment-name {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-remove {
appearance: none;
border: 0;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
}
.attachment-remove:hover {
color: var(--text-primary);
}
.btn-action {