feat(webchat): support image attachments
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
# Webchat Attachment Support Implementation Plan
|
||||
|
||||
**Date:** 2026-02-13
|
||||
**Status:** Planning
|
||||
**Scope:** Minimal — Image attachments first (audio as follow-up)
|
||||
|
||||
## Overview
|
||||
|
||||
Enable webchat users to send image attachments through the gateway to the agent. This completes the deferred "webchat" phase from the P4 media pipeline (phase `6c_gateway_protocol_attachments` marked the protocol-level support as complete, but UI integration was deferred).
|
||||
|
||||
## Current State
|
||||
|
||||
- **Protocol:** `GatewayAttachment` type exists in `src/gateway/protocol.ts` (added in phase `6c_gateway_protocol_attachments`)
|
||||
- **Backend:** `agent.send` handler in `src/gateway/handlers/agent.ts` already accepts optional `attachments` parameter and converts them to channel `Attachment[]` format
|
||||
- **Media pipeline:** All model clients support multimodal messages (images via base64 or URL)
|
||||
- **Other channels:** Telegram, Discord, Slack, WhatsApp all extract and pass image attachments
|
||||
- **Webchat:** Currently text-only
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- Image attachments (jpeg, png, gif, webp)
|
||||
- File input UI in webchat
|
||||
- Client-side base64 encoding
|
||||
- Gateway protocol attachment parameter
|
||||
- Basic error handling (file size, type validation)
|
||||
- Tests for gateway attachment handling
|
||||
- Update `docs/plans/state.json` (mark phase `6d_webchat_attachments` complete)
|
||||
|
||||
### Out of Scope (Follow-up)
|
||||
- Audio attachments (requires different UX — separate mic button, audio preview)
|
||||
- Document attachments (PDF, etc.)
|
||||
- Image preview/thumbnail UI in message list
|
||||
- Drag-and-drop file upload
|
||||
- Multiple file selection
|
||||
- Image compression/resizing client-side
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: UI Changes (Frontend)
|
||||
|
||||
**File:** `src/gateway/ui/pages/chat.js`
|
||||
|
||||
#### 1.1 Add File Input Element
|
||||
Add a hidden file input and an "attach" button next to the send button.
|
||||
|
||||
```html
|
||||
<!-- Inside the chat input wrapper -->
|
||||
<input type="file" id="file-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" />
|
||||
<button id="attach-btn" class="btn-icon" title="Attach image">📎</button>
|
||||
<button id="send-btn" class="btn-primary">Send</button>
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Add file input and attach button to `render()` function
|
||||
- Store references in `_elements` object
|
||||
- Wire click handler: attach button triggers file input
|
||||
- Accept only image MIME types
|
||||
|
||||
#### 1.2 File Selection Handler
|
||||
Read selected file, validate, encode to base64, store in component state.
|
||||
|
||||
```js
|
||||
let _selectedFile = null; // { mimeType, data, filename }
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate type
|
||||
const supportedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!supportedTypes.includes(file.type)) {
|
||||
showSystemMessage('❌ Unsupported file type. Only JPEG, PNG, GIF, and WebP images are supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate size (5MB limit)
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_SIZE) {
|
||||
showSystemMessage('❌ File too large. Maximum size is 5MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read and encode
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1]; // Strip data URI prefix
|
||||
_selectedFile = {
|
||||
mimeType: file.type,
|
||||
data: base64,
|
||||
filename: file.name,
|
||||
};
|
||||
updateAttachmentUI();
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showSystemMessage('❌ Failed to read file.');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Implement `handleFileSelect()`
|
||||
- Validate MIME type against `['image/jpeg', 'image/png', 'image/gif', 'image/webp']`
|
||||
- Validate file size (5MB max)
|
||||
- Read file as base64 using `FileReader`
|
||||
- Store in `_selectedFile` state variable
|
||||
- Clear file input after reading
|
||||
|
||||
#### 1.3 Attachment UI Indicator
|
||||
Show selected file name and allow removal before sending.
|
||||
|
||||
```js
|
||||
function updateAttachmentUI() {
|
||||
const indicator = _elements.attachmentIndicator;
|
||||
if (!indicator) return;
|
||||
|
||||
if (_selectedFile) {
|
||||
indicator.textContent = `📎 ${_selectedFile.filename}`;
|
||||
indicator.classList.remove('hidden');
|
||||
} else {
|
||||
indicator.textContent = '';
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function clearAttachment() {
|
||||
_selectedFile = null;
|
||||
const fileInput = _elements.fileInput;
|
||||
if (fileInput) fileInput.value = '';
|
||||
updateAttachmentUI();
|
||||
}
|
||||
```
|
||||
|
||||
**UI element (add to input wrapper):**
|
||||
```html
|
||||
<div id="attachment-indicator" class="attachment-indicator hidden">
|
||||
<span class="attachment-name"></span>
|
||||
<button class="btn-clear" title="Remove">×</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Add attachment indicator element to UI
|
||||
- Implement `updateAttachmentUI()` to show/hide indicator
|
||||
- Implement `clearAttachment()` to reset state
|
||||
- Add clear button handler
|
||||
|
||||
#### 1.4 Update Send Handler
|
||||
Pass `attachments` array to `agent.send` RPC call.
|
||||
|
||||
```js
|
||||
async function sendMessage() {
|
||||
const text = _elements.input.value.trim();
|
||||
const attachment = _selectedFile;
|
||||
|
||||
if (!text && !attachment) return; // Nothing to send
|
||||
|
||||
// ... existing command parsing ...
|
||||
|
||||
// Prepare RPC params
|
||||
const params = { message: text };
|
||||
if (attachment) {
|
||||
params.attachments = [attachment];
|
||||
}
|
||||
|
||||
// Add user message to UI (with attachment indicator if present)
|
||||
let displayText = text;
|
||||
if (attachment) {
|
||||
displayText = `📎 ${attachment.filename}\n${text}`;
|
||||
}
|
||||
_elements.messages.appendChild(createMessageEl('user', displayText));
|
||||
|
||||
// Clear input and attachment
|
||||
_elements.input.value = '';
|
||||
clearAttachment();
|
||||
|
||||
// Send RPC
|
||||
try {
|
||||
await client.call('agent.send', params, handleStreamingEvent);
|
||||
} catch (err) {
|
||||
// ... error handling ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Modify `sendMessage()` to include `attachments` in RPC params if `_selectedFile` is set
|
||||
- Update user message display to show attachment indicator
|
||||
- Clear `_selectedFile` after sending
|
||||
- Allow sending attachment-only messages (text optional)
|
||||
|
||||
#### 1.5 CSS Styling
|
||||
|
||||
**File:** `src/gateway/ui/style.css`
|
||||
|
||||
```css
|
||||
/* File input and attach button */
|
||||
#attach-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#attach-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Attachment indicator */
|
||||
.attachment-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.attachment-indicator.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-indicator .btn-clear {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.attachment-indicator .btn-clear:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Add styles for attach button
|
||||
- Add styles for attachment indicator
|
||||
- Add styles for clear button
|
||||
- Ensure responsive layout
|
||||
|
||||
### Phase 2: Testing
|
||||
|
||||
#### 2.1 Backend Integration Tests
|
||||
|
||||
**File:** `src/gateway/handlers/handlers.test.ts` (extend existing tests)
|
||||
|
||||
Add tests for `agent.send` with attachments:
|
||||
|
||||
```ts
|
||||
describe('agent.send with attachments', () => {
|
||||
it('should accept image attachments and convert to channel format', async () => {
|
||||
const params = {
|
||||
message: 'What is this?',
|
||||
attachments: [
|
||||
{
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'base64data',
|
||||
filename: 'photo.jpg',
|
||||
},
|
||||
],
|
||||
};
|
||||
// ... test that agent.process receives correct Attachment[]
|
||||
});
|
||||
|
||||
it('should allow attachment-only messages', async () => {
|
||||
const params = {
|
||||
message: '',
|
||||
attachments: [{ mimeType: 'image/png', data: 'base64' }],
|
||||
};
|
||||
// ... test that message is processed
|
||||
});
|
||||
|
||||
it('should handle missing data/url gracefully', async () => {
|
||||
const params = {
|
||||
message: 'test',
|
||||
attachments: [{ mimeType: 'image/jpeg' }], // no data or url
|
||||
};
|
||||
// ... test graceful handling (attachment ignored or error)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Add tests for `agent.send` with `attachments` parameter
|
||||
- Verify conversion from `GatewayAttachment[]` to `Attachment[]`
|
||||
- Test attachment-only messages (empty text)
|
||||
- Test malformed attachments (missing data/url)
|
||||
|
||||
#### 2.2 Manual Testing Checklist
|
||||
|
||||
**Test cases:**
|
||||
1. Select and send a JPEG image → agent receives image, responds with description
|
||||
2. Select and send a PNG image → agent receives image
|
||||
3. Select an invalid file type (e.g., .txt) → error message shown, file rejected
|
||||
4. Select a large file (>5MB) → error message shown, file rejected
|
||||
5. Select an image, then clear it before sending → no attachment sent
|
||||
6. Send attachment-only message (no text) → agent receives image
|
||||
7. Send text + image → agent receives both
|
||||
8. Send message, then send another with attachment → both messages processed correctly
|
||||
9. Test on mobile (touch events for attach button)
|
||||
|
||||
**Tasks:**
|
||||
- Run full test suite: `pnpm test:run`
|
||||
- Run typecheck: `pnpm typecheck`
|
||||
- Manual testing in browser (Desktop Chrome, Firefox)
|
||||
- Manual testing on mobile (Android/iOS)
|
||||
|
||||
### Phase 3: Documentation Updates
|
||||
|
||||
#### 3.1 Update `docs/plans/state.json`
|
||||
|
||||
Add new phase entry under `p6-enhanced-media-pipeline`:
|
||||
|
||||
```json
|
||||
"6d_webchat_attachments": {
|
||||
"priority": "P6",
|
||||
"status": "completed",
|
||||
"description": "Webchat UI for image attachments: file input, base64 encoding, attachment indicator, send handler, gateway protocol integration",
|
||||
"files_modified": [
|
||||
"src/gateway/ui/pages/chat.js",
|
||||
"src/gateway/ui/style.css",
|
||||
"src/gateway/handlers/handlers.test.ts"
|
||||
],
|
||||
"test_status": "manual + integration tests passing"
|
||||
}
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Add `6d_webchat_attachments` phase
|
||||
- Update `p6-enhanced-media-pipeline` summary to include webchat
|
||||
- Update overall test count if applicable
|
||||
|
||||
#### 3.2 Update README or User Docs (if needed)
|
||||
|
||||
**File:** `README.md` (webchat section)
|
||||
|
||||
Add note about attachment support:
|
||||
|
||||
```md
|
||||
### Webchat Features
|
||||
|
||||
- Real-time chat with streaming responses
|
||||
- Markdown rendering with syntax highlighting
|
||||
- Slash commands (/help, /reset, /compact, /usage, /status, /model)
|
||||
- Web search toggle
|
||||
- **Image attachments** (JPEG, PNG, GIF, WebP up to 5MB)
|
||||
- Message actions (copy, edit)
|
||||
```
|
||||
|
||||
**Tasks:**
|
||||
- Update README webchat section (if exists)
|
||||
- Add attachment feature to feature list
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
### Modified Files
|
||||
1. `src/gateway/ui/pages/chat.js`
|
||||
- Add file input element and attach button
|
||||
- Implement `handleFileSelect()` for base64 encoding
|
||||
- Add `_selectedFile` state variable
|
||||
- Add `updateAttachmentUI()` and `clearAttachment()` helpers
|
||||
- Modify `sendMessage()` to include attachments in RPC params
|
||||
- Update user message display to show attachment indicator
|
||||
|
||||
2. `src/gateway/ui/style.css`
|
||||
- Styles for attach button
|
||||
- Styles for attachment indicator
|
||||
- Styles for clear button
|
||||
|
||||
3. `src/gateway/handlers/handlers.test.ts`
|
||||
- Add tests for `agent.send` with attachments
|
||||
- Test attachment-only messages
|
||||
- Test malformed attachments
|
||||
|
||||
4. `docs/plans/state.json`
|
||||
- Add `6d_webchat_attachments` phase entry
|
||||
|
||||
5. `README.md` (optional)
|
||||
- Update webchat feature list
|
||||
|
||||
### No Changes Needed
|
||||
- `src/gateway/protocol.ts` (already has `GatewayAttachment` type)
|
||||
- `src/gateway/handlers/agent.ts` (already handles attachments parameter)
|
||||
- `src/models/media.ts` (already has conversion logic)
|
||||
- `src/channels/types.ts` (already has `Attachment` type)
|
||||
|
||||
## Follow-up Work (Not in Scope)
|
||||
|
||||
### Audio Attachments
|
||||
- Requires different UX: separate mic button, recording UI, audio waveform preview
|
||||
- Audio transcription via Whisper (already supported in backend)
|
||||
- Estimate: 1-2 days
|
||||
|
||||
### Enhanced UX
|
||||
- Image preview/thumbnail in message list
|
||||
- Drag-and-drop file upload
|
||||
- Multiple file selection
|
||||
- Client-side image compression/resizing (reduce bandwidth)
|
||||
- Copy/paste image from clipboard
|
||||
- Estimate: 2-3 days
|
||||
|
||||
## Notes
|
||||
|
||||
- **Security:** Client-side validation is not sufficient. Backend should also validate MIME types and file sizes if needed (currently delegated to model providers).
|
||||
- **Performance:** 5MB limit keeps base64 payloads reasonable for WebSocket messages. Larger files should use URL-based attachments (out of scope).
|
||||
- **Compatibility:** `FileReader` API is widely supported (IE10+, all modern browsers).
|
||||
- **Mobile:** File input works on mobile browsers (triggers camera/gallery picker).
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- **Phase 1 (UI):** 2-3 hours
|
||||
- **Phase 2 (Testing):** 1 hour
|
||||
- **Phase 3 (Docs):** 30 minutes
|
||||
- **Total:** ~4 hours
|
||||
+10
-3
@@ -444,8 +444,15 @@
|
||||
"files_modified": ["src/channels/whatsapp/adapter.ts"]
|
||||
},
|
||||
"webchat": {
|
||||
"status": "deferred",
|
||||
"description": "Requires gateway protocol update for WebSocket attachment messages"
|
||||
"status": "completed",
|
||||
"description": "Webchat UI can send image attachments via gateway agent.send attachments parameter; agent handler accepts attachment-only messages",
|
||||
"files_modified": [
|
||||
"src/gateway/ui/pages/chat.js",
|
||||
"src/gateway/ui/chat.html",
|
||||
"src/gateway/ui/style.css",
|
||||
"src/gateway/handlers/agent.ts",
|
||||
"src/gateway/handlers/handlers.test.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1827,7 +1834,7 @@
|
||||
},
|
||||
|
||||
"overall_progress": {
|
||||
"total_test_count": 1617,
|
||||
"total_test_count": 1625,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user