feat: Bilder im Chat (Vision)

qwen3.5 meldet die Fähigkeit "vision", die bisher ungenutzt blieb. Bilder
lassen sich jetzt per Button, Zwischenablage (⌘V) oder Drag & Drop anhängen;
eine Nachricht darf auch nur aus einem Bild bestehen.

- images.ts: Validierung vor dem Speichern — maximal 4 Bilder je Nachricht,
  je 6 MB, Typ per Magic Bytes statt per Dateiendung (PNG/JPEG/GIF/WebP).
  Der WebSocket bekommt dazu ein maxPayload von 32 MB, vorher war er
  unbegrenzt und Bilder machen Nachrichten deutlich größer.
- db.ts: Spalte images samt Migration für bestehende Datenbanken aus agenttwo
- ollama.ts: images-Feld je Message; openrouter.ts: OpenAI-Format mit
  image_url, MIME aus den Magic Bytes statt fest image/png
- Composer: Vorschau mit Entfernen-Button, Fehlermeldung bei zu großen oder
  nicht unterstützten Dateien; ChatMessage zeigt Bilder in der Historie
- Bilder wandern in die Chat-Historie und werden bei Folgefragen erneut
  mitgeschickt, damit Rückfragen zum selben Bild funktionieren

Getestet gegen das laufende Modell: ein rendertes PNG (roter Kreis auf weiß)
wird korrekt als japanische Flagge beschrieben. Validierung geprüft gegen
5 Bilder, Textdatei mit Bildnamen, kaputtes base64 und 7-MB-Blob — alle
abgelehnt.

Nebenbei: Branding in UI, Titel und Serverlog auf agenttwo-tools umgestellt.
Die localStorage-Keys bleiben unverändert, sonst gingen gespeicherte
Einstellungen verloren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1NXUpHbxTusqQmsjrFfbU
This commit is contained in:
Jeuner 2026-08-26 17:44:57 +02:00
parent e48228c570
commit adb57ae6e3
17 changed files with 616 additions and 71 deletions

74
web/dist/assets/index-D8cBv8n7.js vendored Normal file

File diff suppressed because one or more lines are too long

10
web/dist/assets/index-DFQmRKNP.css vendored Normal file

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-D8cBv8n7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFQmRKNP.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>oxagenttwo — local qwen3 chat</title>
<title>agenttwo-tools — qwen3 mit Vision</title>
</head>
<body>
<div id="root"></div>

View file

@ -130,10 +130,10 @@ export default function App() {
}, [chat.streaming, voiceMode, voice]);
const handleSend = useCallback(
(text: string) => {
(text: string, images: string[] = []) => {
voice.cancelSpeech();
pendingSpeechRef.current = "";
chat.sendMessage(text);
chat.sendMessage(text, images);
},
[chat, voice],
);
@ -296,7 +296,7 @@ export default function App() {
<div className="messages" ref={scrollRef}>
{chat.messages.length === 0 && (
<div className="welcome">
<div className="welcome-title"> oxagenttwo</div>
<div className="welcome-title"> agenttwo-tools</div>
<p>
Echtzeit-Chat mit lokalem Qwen3 über Ollama per Tastatur oder
Stimme (Whisper STT + Piper TTS, alles lokal).

View file

@ -4,9 +4,33 @@ import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import type { Message } from "../types";
/**
* Leitet den MIME-Typ aus den ersten base64-Zeichen ab. "image/*" ist als
* data-URL ungültig, deshalb muss der konkrete Typ bestimmt werden.
*/
function mimeOf(b64: string): string {
if (b64.startsWith("iVBORw0KGgo")) return "image/png";
if (b64.startsWith("/9j/")) return "image/jpeg";
if (b64.startsWith("R0lGOD")) return "image/gif";
if (b64.startsWith("UklGR")) return "image/webp";
return "image/png";
}
/** Der Server legt Bilder als JSON-Array mit base64-Daten ab. */
function imagesOf(message: Message): string[] {
if (!message.images) return [];
try {
const parsed: unknown = JSON.parse(message.images);
return Array.isArray(parsed) ? parsed.filter((i): i is string => typeof i === "string") : [];
} catch {
return [];
}
}
export function ChatMessage({ message }: { message: Message }) {
const [showThinking, setShowThinking] = useState(false);
const isUser = message.role === "user";
const images = imagesOf(message);
return (
<div className={`msg ${isUser ? "msg-user" : "msg-assistant"}`}>
@ -28,8 +52,23 @@ export function ChatMessage({ message }: { message: Message }) {
</div>
)}
{images.length > 0 && (
<div className="msg-images">
{images.map((b64, i) => (
<img
key={i}
src={`data:${mimeOf(b64)};base64,${b64}`}
alt={`Anhang ${i + 1}`}
loading="lazy"
/>
))}
</div>
)}
{isUser ? (
<div className="msg-content user-content">{message.content}</div>
message.content ? (
<div className="msg-content user-content">{message.content}</div>
) : null
) : (
<div className="msg-content markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>

View file

@ -1,5 +1,18 @@
import { useEffect, useRef, useState } from "react";
/** Muss zu MAX_IMAGES_PER_MESSAGE / MAX_IMAGE_BYTES im Server passen. */
const MAX_IMAGES = 4;
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
const ACCEPTED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
interface Attachment {
id: string;
/** reines base64 ohne data:-Präfix — dieses Format erwartet der Server */
base64: string;
dataUrl: string;
name: string;
}
interface Props {
streaming: boolean;
disabled: boolean;
@ -7,11 +20,29 @@ interface Props {
transcribing: boolean;
injectedText: string | null;
onInjected: () => void;
onSend: (text: string) => void;
onSend: (text: string, images: string[]) => void;
onAbort: () => void;
onMicToggle: () => void;
}
function readAsAttachment(file: File): Promise<Attachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error(`${file.name} konnte nicht gelesen werden`));
reader.onload = () => {
const dataUrl = String(reader.result);
const comma = dataUrl.indexOf(",");
resolve({
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
base64: dataUrl.slice(comma + 1),
dataUrl,
name: file.name || "Bild",
});
};
reader.readAsDataURL(file);
});
}
export function Composer({
streaming,
disabled,
@ -24,7 +55,11 @@ export function Composer({
onMicToggle,
}: Props) {
const [value, setValue] = useState("");
const [images, setImages] = useState<Attachment[]>([]);
const [imgError, setImgError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const ref = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (injectedText != null) {
@ -37,63 +72,160 @@ export function Composer({
}
}, [injectedText, onInjected]);
const addFiles = async (files: File[]) => {
setImgError(null);
const usable: File[] = [];
for (const f of files) {
if (!ACCEPTED.includes(f.type)) {
setImgError(`${f.name || "Datei"}: nur PNG, JPEG, GIF oder WebP`);
continue;
}
if (f.size > MAX_IMAGE_BYTES) {
setImgError(`${f.name}: größer als ${MAX_IMAGE_BYTES / 1024 / 1024} MB`);
continue;
}
usable.push(f);
}
if (!usable.length) return;
try {
const added = await Promise.all(usable.map(readAsAttachment));
setImages((prev) => {
const free = MAX_IMAGES - prev.length;
if (added.length > free) setImgError(`Maximal ${MAX_IMAGES} Bilder pro Nachricht`);
return [...prev, ...added.slice(0, Math.max(free, 0))];
});
} catch (err) {
setImgError(err instanceof Error ? err.message : "Bild konnte nicht gelesen werden");
}
};
const submit = () => {
const text = value.trim();
if (!text || streaming || disabled) return;
onSend(text);
if ((!text && images.length === 0) || streaming || disabled) return;
onSend(text, images.map((i) => i.base64));
setValue("");
setImages([]);
setImgError(null);
requestAnimationFrame(() => ref.current?.focus());
};
const canSend = (value.trim().length > 0 || images.length > 0) && !disabled;
return (
<div className="composer">
<button
className={`btn-mic ${recording ? "recording" : ""} ${transcribing ? "transcribing" : ""}`}
onClick={onMicToggle}
title={
recording
? "Aufnahme stoppen & senden"
: transcribing
? "Transkribiere …"
: "Sprachnachricht aufnehmen"
}
disabled={transcribing || disabled}
>
{recording ? "●" : transcribing ? "…" : "🎙"}
</button>
<textarea
ref={ref}
value={value}
placeholder={
recording
? "Ich höre zu … (zum Beenden nochmal auf das Mikro klicken)"
: disabled
? "Keine Session aktiv — neuen Chat starten"
: "Nachricht an qwen3 … (Enter = senden, Shift+Enter = Zeilenumbruch)"
}
rows={1}
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
{streaming ? (
<button className="btn-send stop" onClick={onAbort}>
Stop
</button>
) : (
<button
className="btn-send"
disabled={!value.trim() || disabled}
onClick={submit}
>
Senden
</button>
<div
className={`composer ${dragging ? "dragging" : ""}`}
onDragOver={(e) => {
if (disabled) return;
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
if (disabled) return;
e.preventDefault();
setDragging(false);
void addFiles([...e.dataTransfer.files]);
}}
>
{(images.length > 0 || imgError) && (
<div className="attachments">
{images.map((img) => (
<div className="attachment" key={img.id}>
<img src={img.dataUrl} alt={img.name} />
<button
className="attachment-remove"
onClick={() => setImages((p) => p.filter((i) => i.id !== img.id))}
title={`${img.name} entfernen`}
>
×
</button>
</div>
))}
{imgError && <span className="attachment-error">{imgError}</span>}
</div>
)}
<div className="composer-row">
<button
className={`btn-mic ${recording ? "recording" : ""} ${transcribing ? "transcribing" : ""}`}
onClick={onMicToggle}
title={
recording
? "Aufnahme stoppen & senden"
: transcribing
? "Transkribiere …"
: "Sprachnachricht aufnehmen"
}
disabled={transcribing || disabled}
>
{recording ? "●" : transcribing ? "…" : "🎙"}
</button>
<button
className="btn-attach"
onClick={() => fileRef.current?.click()}
disabled={disabled || images.length >= MAX_IMAGES}
title={
images.length >= MAX_IMAGES
? `Maximal ${MAX_IMAGES} Bilder`
: "Bild anhängen (auch per Einfügen oder Drag & Drop)"
}
>
🖼
</button>
<input
ref={fileRef}
type="file"
accept={ACCEPTED.join(",")}
multiple
hidden
onChange={(e) => {
void addFiles([...(e.target.files ?? [])]);
e.target.value = "";
}}
/>
<textarea
ref={ref}
value={value}
placeholder={
recording
? "Ich höre zu … (zum Beenden nochmal auf das Mikro klicken)"
: disabled
? "Keine Session aktiv — neuen Chat starten"
: "Nachricht an qwen3 … (Enter = senden, Bild einfügen mit ⌘V)"
}
rows={1}
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
onPaste={(e) => {
const files = [...e.clipboardData.files].filter((f) =>
f.type.startsWith("image/"),
);
if (files.length) {
e.preventDefault();
void addFiles(files);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
{streaming ? (
<button className="btn-send stop" onClick={onAbort}>
Stop
</button>
) : (
<button className="btn-send" disabled={!canSend} onClick={submit}>
Senden
</button>
)}
</div>
</div>
);
}

View file

@ -13,7 +13,7 @@ export function Sidebar({ sessions, activeId, onSelect, onNew, onDelete }: Props
<aside className="sidebar">
<div className="sidebar-header">
<div className="brand">
<span className="brand-prompt"></span> oxagenttwo
<span className="brand-prompt"></span> agenttwo-tools
</div>
<button className="btn-new" onClick={onNew}>
+ Neuer Chat

View file

@ -538,3 +538,87 @@ body {
::-webkit-scrollbar-track {
background: transparent;
}
/* --- Bild-Anhänge (Vision) --- */
.composer.dragging {
outline: 2px dashed var(--accent, #3fb950);
outline-offset: -4px;
}
.composer-row {
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
}
.attachments {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 0 0 8px;
}
.attachment {
position: relative;
width: 64px;
height: 64px;
}
.attachment img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border, #30363d);
}
.attachment-remove {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
line-height: 18px;
padding: 0;
border-radius: 50%;
border: 1px solid var(--border, #30363d);
background: var(--bg, #0d1117);
color: var(--fg, #c9d1d9);
cursor: pointer;
}
.attachment-error {
font-size: 0.85em;
color: #f85149;
}
.btn-attach {
flex: 0 0 auto;
background: transparent;
border: 1px solid var(--border, #30363d);
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
color: inherit;
}
.btn-attach:disabled {
opacity: 0.4;
cursor: default;
}
.msg-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 6px 0;
}
.msg-images img {
max-width: min(320px, 100%);
max-height: 320px;
border-radius: 8px;
border: 1px solid var(--border, #30363d);
}

View file

@ -10,6 +10,8 @@ export interface Message {
role: "user" | "assistant" | "system";
content: string;
thinking: string | null;
/** JSON-Array mit base64-Bilddaten, wie es der Server speichert. */
images: string | null;
created_at: number;
}

View file

@ -138,14 +138,18 @@ export function useChat() {
}, []);
const sendMessage = useCallback(
(content: string) => {
if (!content.trim() || streamingRef.current || !activeId) return;
(content: string, images: string[] = []) => {
// Ein Bild ohne Text ist eine gültige Anfrage.
if ((!content.trim() && images.length === 0) || streamingRef.current || !activeId) {
return;
}
streamingRef.current = true;
setStreaming(true);
socketRef.current?.send({
type: "chat",
sessionId: activeId,
content,
images,
options,
systemPrompt,
});