feat: file attachment support in chat (images + text/code files)

Text files (.md, .py, .js, .json, etc.): read client-side and injected
into the message body as a fenced code block — works with all backends
with zero model capability requirements.

Images (PNG/JPG/WebP/GIF, max 5 MB): encoded as base64 data URL on the
client and sent as a separate attachment field. Backend formats them as
OpenAI multimodal content (text + image_url) for local_openai backends.
Claude CLI and Gemini CLI see the text message with a "📎 filename.png"
note; image data is never written to session history.

- index.html: 📎 button + hidden file input in mode-select row;
  attachment-row preview area with thumbnail (images) or filename chip
- app.js: _resolveAttachment(), file reader, clearAttachment();
  sendMessage/sendOrchestrate updated to allow no-text sends when a
  file is pending; attachment spread into chat payload for images
- chat.py: Attachment model; attachment field on ChatRequest;
  llm_attachment extracted in _stream_chat and passed to complete()
- llm_client.py: attachment param through complete()/_dispatch()/_local();
  _local() builds multimodal content array for vision calls
- style.css: #attach-btn, #attachment-row, #attachment-preview, thumb

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-12 21:46:50 -04:00
parent 50c1997e91
commit 96b3c796c5
5 changed files with 215 additions and 15 deletions

View File

@@ -861,6 +861,58 @@
}
#tools-toggle.local-on:hover { box-shadow: 0 0 10px var(--amber-glow); }
#attach-btn {
background: var(--bg);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: rgba(255,255,255,0.3);
font-size: 0.95rem;
padding: 3px 7px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
#attach-btn:hover { color: rgba(255,255,255,0.6); border-color: rgba(255,255,255,0.25); }
#attachment-row {
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--border);
}
#attachment-preview {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.2rem 0.5rem;
font-size: 0.82rem;
max-width: 100%;
}
#attachment-thumb {
max-height: 2.4rem;
max-width: 3.5rem;
border-radius: 3px;
object-fit: contain;
}
#attachment-name {
color: var(--text-mid);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#attachment-clear {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0 0.15rem;
font-size: 0.78rem;
line-height: 1;
flex-shrink: 0;
}
#attachment-clear:hover { color: var(--text); }
#input {
flex: 1;
background: var(--bg);