diff --git a/public/index.html b/public/index.html index a5478ef..68609d3 100644 --- a/public/index.html +++ b/public/index.html @@ -316,8 +316,10 @@ } let inFlight = false; - async function send(text) { - if (inFlight || !text.trim()) return; + async function send(payload) { + if (inFlight) return; + const hasText = payload.text && payload.text.trim(); + if (!hasText && !payload.image) return; inFlight = true; outputEl.innerHTML = ''; let fullText = ''; @@ -325,7 +327,7 @@ const resp = await fetch('/api/chat', { method: 'POST', headers: {'Content-Type':'application/json'}, - body: JSON.stringify({text, model: 'sonnet'}), + body: JSON.stringify({ model: 'sonnet', ...payload }), }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); @@ -353,12 +355,29 @@ } catch(e) {} finally { inFlight = false; } } - // Auto-send on paste - inputEl.addEventListener('paste', () => { - setTimeout(() => { - const t = inputEl.value.trim(); - if (t) send(t); - }, 50); + // Auto-send on paste (text or image) + inputEl.addEventListener('paste', (e) => { + const items = (e.clipboardData || window.clipboardData)?.items || []; + let imgItem = null; + for (const it of items) { + if (it.kind === 'file' && it.type.startsWith('image/')) { imgItem = it; break; } + } + if (imgItem) { + e.preventDefault(); + const file = imgItem.getAsFile(); + const fr = new FileReader(); + fr.onload = () => { + const dataUrl = fr.result; + const base64 = dataUrl.split(',')[1]; + send({ image: { data: base64, media_type: file.type || 'image/png' } }); + }; + fr.readAsDataURL(file); + } else { + setTimeout(() => { + const t = inputEl.value.trim(); + if (t) send({ text: t }); + }, 50); + } }); diff --git a/server.js b/server.js index cf2f329..323dd26 100644 --- a/server.js +++ b/server.js @@ -51,7 +51,7 @@ const SYSTEM_PROMPT = [ "Respond with the answer only. Nothing else.", ].join("\n"); -async function callClaude(modelId, text, accessToken) { +async function callClaude(modelId, content, accessToken) { return fetch('https://api.anthropic.com/v1/messages', { method: 'POST', dispatcher: anthropicAgent, @@ -68,14 +68,25 @@ async function callClaude(modelId, text, accessToken) { max_tokens: 8192, stream: true, system: SYSTEM_PROMPT, - messages: [{ role: 'user', content: text }], + messages: [{ role: 'user', content }], }), }); } -app.post('/api/chat', express.json({ limit: '10mb' }), async (req, res) => { - const { text, model = 'sonnet' } = req.body; - if (!text?.trim()) return res.status(400).json({ error: 'text required' }); +app.post('/api/chat', express.json({ limit: '25mb' }), async (req, res) => { + const { text, model = 'sonnet', image } = req.body; + const hasText = text?.trim(); + if (!hasText && !image) return res.status(400).json({ error: 'text or image required' }); + + let content; + if (image?.data && image?.media_type) { + content = [ + { type: 'image', source: { type: 'base64', media_type: image.media_type, data: image.data } }, + { type: 'text', text: hasText || 'Analyze the image and answer any questions shown in it.' }, + ]; + } else { + content = text; + } const accessToken = getAccessToken(); if (!accessToken) { @@ -92,9 +103,9 @@ app.post('/api/chat', express.json({ limit: '10mb' }), async (req, res) => { const fallbackId = MODELS.haiku; try { - let apiResp = await callClaude(primaryId, text, accessToken); + let apiResp = await callClaude(primaryId, content, accessToken); if (apiResp.status === 429 && primaryId !== fallbackId) { - apiResp = await callClaude(fallbackId, text, accessToken); + apiResp = await callClaude(fallbackId, content, accessToken); } if (!apiResp.ok) { const errText = await apiResp.text();