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:
@@ -51,6 +51,7 @@ async def complete(
|
||||
role: str = "chat",
|
||||
slot: str | None = None,
|
||||
max_tokens: int = 2048,
|
||||
attachment: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Returns (response_text, actual_backend_used).
|
||||
@@ -96,7 +97,7 @@ async def complete(
|
||||
fallback = _FALLBACK.get(primary, "claude")
|
||||
|
||||
try:
|
||||
response = await _dispatch(primary, system_prompt, messages, resolved_cfg)
|
||||
response = await _dispatch(primary, system_prompt, messages, resolved_cfg, attachment=attachment)
|
||||
return response, primary
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
@@ -116,11 +117,12 @@ async def _dispatch(
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model_cfg: dict | None,
|
||||
attachment: dict | None = None,
|
||||
) -> str:
|
||||
if backend == "gemini":
|
||||
return await _gemini(system_prompt, messages)
|
||||
if backend == "local":
|
||||
return await _local(system_prompt, messages, model_cfg)
|
||||
return await _local(system_prompt, messages, model_cfg, attachment=attachment)
|
||||
return await _claude(system_prompt, messages, model_cfg)
|
||||
|
||||
|
||||
@@ -166,11 +168,17 @@ async def _claude(system_prompt: str, messages: list[dict], model_cfg: dict | No
|
||||
return await _run(cmd, timeout=settings.timeout_claude, env=env)
|
||||
|
||||
|
||||
async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | None = None) -> str:
|
||||
async def _local(
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model_cfg: dict | None = None,
|
||||
attachment: dict | None = None,
|
||||
) -> str:
|
||||
"""OpenAI-compatible backend — Open WebUI / Ollama.
|
||||
|
||||
model_cfg is pre-resolved by complete() via model_registry.
|
||||
Falls back to registry lookup if not provided.
|
||||
attachment: optional image dict {filename, mime_type, data} for vision calls.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
@@ -200,8 +208,20 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non
|
||||
msgs: list[dict] = []
|
||||
if system_prompt:
|
||||
msgs.append({"role": "system", "content": system_prompt})
|
||||
# Strip any non-standard metadata fields before sending to the API
|
||||
msgs.extend({"role": m["role"], "content": m["content"]} for m in messages)
|
||||
|
||||
# Build message list; inject image into the last user message when present.
|
||||
for i, m in enumerate(messages):
|
||||
is_last = (i == len(messages) - 1)
|
||||
if is_last and m["role"] == "user" and attachment:
|
||||
content: list[dict] = [{"type": "text", "text": m["content"]}]
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": attachment["data"]},
|
||||
})
|
||||
msgs.append({"role": "user", "content": content})
|
||||
else:
|
||||
# Strip non-standard metadata fields before sending to the API
|
||||
msgs.append({"role": m["role"], "content": m["content"]})
|
||||
|
||||
url = api_url.rstrip("/") + chat_path
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
Reference in New Issue
Block a user