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

@@ -535,6 +535,94 @@
addMessage('system', `Model: ${entry.label}`);
});
// ── File attachment ──────────────────────────────────────────
const attachBtn = document.getElementById('attach-btn');
const fileInput = document.getElementById('file-input');
const attachRow = document.getElementById('attachment-row');
const attachName = document.getElementById('attachment-name');
const attachClear = document.getElementById('attachment-clear');
const attachThumb = document.getElementById('attachment-thumb');
const _IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const _TXT_EXTS = new Set(['.md','.txt','.py','.js','.ts','.jsx','.tsx','.json','.yaml','.yml','.toml','.html','.css','.sh','.csv','.xml','.rs','.go','.java','.c','.cpp','.h','.rb','.php','.swift','.kt','.sql','.env','.ini','.cfg','.log']);
const MAX_IMAGE_B = 5 * 1024 * 1024; // 5 MB
const MAX_TEXT_B = 100 * 1024; // 100 KB
let _pendingAttach = null; // {type:'image'|'text', filename, mime_type, data}
function _isTextFile(file) {
if (file.type.startsWith('text/') || file.type === 'application/json') return true;
const ext = '.' + file.name.split('.').pop().toLowerCase();
return _TXT_EXTS.has(ext);
}
function _langHint(filename) {
const ext = filename.split('.').pop().toLowerCase();
const m = {py:'python',js:'javascript',ts:'typescript',jsx:'jsx',tsx:'tsx',json:'json',yaml:'yaml',yml:'yaml',toml:'toml',html:'html',css:'css',sh:'bash',md:'markdown',rs:'rust',go:'go',java:'java',c:'c',cpp:'cpp',h:'c',rb:'ruby',php:'php',swift:'swift',kt:'kotlin',sql:'sql'};
return m[ext] || '';
}
function clearAttachment() {
_pendingAttach = null;
fileInput.value = '';
attachRow.style.display = 'none';
if (attachThumb) { attachThumb.src = ''; attachThumb.style.display = 'none'; }
}
/**
* Resolve the pending attachment into send-ready values.
* - Text files: inject file content as a fenced code block in the message.
* displayText = serverText = injected content (what the model sees).
* - Images: keep text separate; pass image as payloadAttachment for vision APIs.
* serverText includes a 📎 filename note for non-vision backends.
*/
function _resolveAttachment(inputText) {
if (!_pendingAttach) return { displayText: inputText, serverText: inputText, payloadAttachment: null };
const { type, filename, mime_type, data } = _pendingAttach;
if (type === 'text') {
const lang = _langHint(filename);
const block = `📎 ${filename}\n\`\`\`${lang}\n${data.trimEnd()}\n\`\`\``;
const serverText = inputText ? `${inputText}\n\n${block}` : block;
return { displayText: serverText, serverText, payloadAttachment: null };
}
// Image
const note = `📎 ${filename}`;
const displayText = inputText ? `${inputText}\n${note}` : note;
return { displayText, serverText: displayText, payloadAttachment: { filename, mime_type, data } };
}
attachBtn.addEventListener('click', () => fileInput.click());
attachClear.addEventListener('click', clearAttachment);
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
fileInput.value = ''; // reset so the same file can be re-selected
const isImg = _IMG_TYPES.has(file.type);
const isTxt = !isImg && _isTextFile(file);
if (!isImg && !isTxt) { showToast('Unsupported file type'); return; }
if (isImg && file.size > MAX_IMAGE_B) { showToast('Image too large (max 5 MB)'); return; }
if (isTxt && file.size > MAX_TEXT_B) { showToast('Text file too large (max 100 KB)'); return; }
const reader = new FileReader();
reader.onload = (e) => {
_pendingAttach = { type: isImg ? 'image' : 'text', filename: file.name, mime_type: file.type || 'text/plain', data: e.target.result };
attachName.textContent = file.name;
if (isImg && attachThumb) {
attachThumb.src = e.target.result;
attachThumb.style.display = 'block';
attachRow.querySelector('#attachment-icon').style.display = 'none';
} else if (attachThumb) {
attachThumb.style.display = 'none';
attachRow.querySelector('#attachment-icon').style.display = '';
}
attachRow.style.display = 'flex';
};
isImg ? reader.readAsDataURL(file) : reader.readAsText(file);
});
// ── Sessions panel ───────────────────────────────────────────
sessionsBtn.addEventListener('click', async (e) => {
@@ -1308,8 +1396,8 @@
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || activeController) return;
const rawText = inputEl.value.trim();
if ((!rawText && !_pendingAttach) || activeController) return;
const wasNewSession = !sessionId;
@@ -1323,10 +1411,12 @@
activeController = new AbortController();
const isOtr = current_mode === 'otr';
const { displayText, serverText, payloadAttachment } = _resolveAttachment(rawText);
clearAttachment();
const userHistIdx = currentHistory.length;
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
currentHistory.push({ role: 'user', content: serverText });
const userMsgDiv = addMessage('user', displayText);
attachHistoryControls(userMsgDiv, userHistIdx);
if (isOtr) setMessageMeta(userMsgDiv, {otr: true});
scrollToBottom();
@@ -1334,7 +1424,7 @@
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
const payload = {
message: text,
message: serverText,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
@@ -1345,6 +1435,7 @@
slot: activeChatModel()?.slot || null,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
...(payloadAttachment ? { attachment: payloadAttachment } : {}),
};
await _doSend(payload, thinkingDiv, wasNewSession);
@@ -1509,8 +1600,8 @@
}
async function sendOrchestrate() {
const text = inputEl.value.trim();
if (!text || activeController) return;
const rawText = inputEl.value.trim();
if ((!rawText && !_pendingAttach) || activeController) return;
inputEl.value = '';
syncHeight();
@@ -1521,13 +1612,16 @@
activeController = new AbortController();
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
const { displayText, serverText } = _resolveAttachment(rawText);
clearAttachment();
currentHistory.push({ role: 'user', content: serverText });
const userMsgDiv = addMessage('user', displayText);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
await _doOrchestrate(serverText, thinkingDiv, userMsgDiv);
activeController = null;
setProcessing(false);