feat: Installationsroutine für macOS (setup.sh)

- prüft macOS, Homebrew, Node 22+, npm-Abhängigkeiten, ffmpeg,
  whisper.cpp inkl. Modell, Piper inkl. Stimme, Ollama-Server und
  das Sprachmodell — fehlende Teile werden auf Nachfrage installiert
- Modi: interaktiv, --check (nur Prüfung), --yes (ohne Nachfragen)
- README: Schnellstart-Abschnitt (clone → setup → dev)
- npm run setup als Alias
This commit is contained in:
Jeuner 2026-08-28 13:35:16 +02:00
parent c8b74892ef
commit ea87c350a5
19 changed files with 989 additions and 130 deletions

View file

@ -23,6 +23,7 @@ npm-Workspace mit zwei Paketen:
## Voraussetzungen
- **macOS** mit [Homebrew](https://brew.sh)
- **Node.js 22+**`server/src/db.ts` nutzt das eingebaute `node:sqlite`,
das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start
eine `ExperimentalWarning`; ab Node 24 ist das Modul stabil.
@ -32,6 +33,22 @@ npm-Workspace mit zwei Paketen:
- Piper als **Python-Modul** — der Server ruft `python3 -m piper` auf,
nicht das gleichnamige Homebrew-Binary
## Schnellstart (Mac)
Ein Befehl von null bis zur lauffähigen App — das Skript prüft alle
Abhängigkeiten und bietet fehlende (Homebrew, Node, ffmpeg, whisper, Piper,
Ollama, Modell, Sprachdateien) zur Installation an:
```bash
git clone https://github.com/Jeuners/agenttwo-tools.git
cd agenttwo-tools
./setup.sh # oder: npm run setup
npm run dev # → http://localhost:5174
```
Nur prüfen ohne etwas zu ändern: `./setup.sh --check` · Ohne Nachfragen:
`./setup.sh --yes`
Unter macOS:
```bash

232
package-lock.json generated
View file

@ -920,6 +920,205 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -3325,6 +3524,38 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -4693,6 +4924,7 @@
"dependencies": {
"@fastify/cors": "^10.0.1",
"fastify": "^5.2.1",
"pdf-parse": "^2.4.5",
"ws": "^8.18.0"
},
"devDependencies": {

View file

@ -7,6 +7,7 @@
"web"
],
"scripts": {
"setup": "bash setup.sh",
"dev": "npm run dev --workspace server & npm run dev --workspace web & wait",
"dev:server": "npm run dev --workspace server",
"dev:web": "npm run dev --workspace web",

View file

@ -12,6 +12,7 @@
"dependencies": {
"@fastify/cors": "^10.0.1",
"fastify": "^5.2.1",
"pdf-parse": "^2.4.5",
"ws": "^8.18.0"
},
"devDependencies": {

View file

@ -23,13 +23,19 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
`);
// Bestehende Datenbanken aus agenttwo kennen die Spalte noch nicht.
// Bestehende Datenbanken aus agenttwo kennen die Spalten noch nicht.
const hasImages = (
db.prepare("PRAGMA table_info(messages)").all() as unknown as { name: string }[]
).some((c) => c.name === "images");
if (!hasImages) {
db.exec("ALTER TABLE messages ADD COLUMN images TEXT");
}
const hasFiles = (
db.prepare("PRAGMA table_info(messages)").all() as unknown as { name: string }[]
).some((c) => c.name === "files");
if (!hasFiles) {
db.exec("ALTER TABLE messages ADD COLUMN files TEXT");
}
export interface SessionRow {
id: string;
@ -45,6 +51,8 @@ export interface MessageRow {
thinking: string | null;
/** JSON-Array mit base64-Bilddaten (ohne data:-Präfix), oder null. */
images: string | null;
/** JSON-Array mit Text-Datei-Anhängen ({name, content}), oder null. */
files: string | null;
created_at: number;
}
@ -92,6 +100,7 @@ export function insertMessage(
content: string,
thinking?: string | null,
images?: string[] | null,
files?: { name: string; content: string }[] | null,
): MessageRow {
const row: MessageRow = {
id: randomUUID(),
@ -100,10 +109,11 @@ export function insertMessage(
content,
thinking: thinking ?? null,
images: images?.length ? JSON.stringify(images) : null,
files: files?.length ? JSON.stringify(files) : null,
created_at: Date.now(),
};
db.prepare(
"INSERT INTO messages (id, session_id, role, content, thinking, images, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO messages (id, session_id, role, content, thinking, images, files, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(
row.id,
row.session_id,
@ -111,6 +121,7 @@ export function insertMessage(
row.content,
row.thinking,
row.images,
row.files,
row.created_at,
);
return row;
@ -127,6 +138,24 @@ export function parseImages(row: Pick<MessageRow, "images">): string[] {
}
}
/** Text-Datei-Anhänge einer Zeile als Array — leer, wenn keine oder unlesbar. */
export function parseFiles(
row: Pick<MessageRow, "files">,
): { name: string; content: string }[] {
if (!row.files) return [];
try {
const parsed: unknown = JSON.parse(row.files);
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((f) => {
const o = f as { name?: unknown; content?: unknown };
if (typeof o?.name !== "string" || typeof o?.content !== "string") return [];
return [{ name: o.name, content: o.content }];
});
} catch {
return [];
}
}
export function updateAssistantMessage(
id: string,
content: string,

134
server/src/files.ts Normal file
View file

@ -0,0 +1,134 @@
import { PDFParse } from "pdf-parse";
export const MAX_FILES_PER_MESSAGE = 4;
/** Maximale Zeichen je Textdatei — deckt JSON/CSV/Code weit ab, ohne das Kontextfenster zu sprengen. */
export const MAX_FILE_CHARS = 100_000;
/** Maximale PDF-Größe vor der Textextraktion. */
export const MAX_PDF_BYTES = 10 * 1024 * 1024;
/** Maximale Namenlänge nach Bereinigung. */
const MAX_NAME_CHARS = 120;
export interface ValidatedFile {
name: string;
content: string;
}
export interface RawFile {
name?: unknown;
content?: unknown;
encoding?: unknown;
}
export class FileError extends Error {}
/** Stripped Pfade und gefährliche Namen; "." und ".." werden abgewiesen. */
function sanitizeName(raw: unknown): string {
if (typeof raw !== "string" || !raw.trim()) {
throw new FileError("Datei ohne Namen");
}
const base = raw.split(/[\\/]/).pop() ?? "";
const name = base.replace(/[\x00-\x1F]/g, "").trim().slice(0, MAX_NAME_CHARS);
if (!name || name === "." || name === "..") {
throw new FileError("Ungültiger Dateiname");
}
return name;
}
/** Textnachweis: druckbare Zeichen plus Whitespace; sonstige Steuerzeichen → binär. */
function isText(content: string): boolean {
return !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content.slice(0, 2000));
}
function decodeBase64(input: string): Buffer {
const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
const raw = comma === -1 ? input : input.slice(comma + 1);
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(raw.replace(/\s/g, ""))) {
throw new FileError("Anhang ist kein gültiges base64");
}
return Buffer.from(raw, "base64");
}
async function extractPdfText(name: string, b64: string): Promise<string> {
const buf = decodeBase64(b64);
if (buf.length === 0) throw new FileError(`${name} ist leer`);
if (buf.length > MAX_PDF_BYTES) {
throw new FileError(`${name} ist größer als ${Math.round(MAX_PDF_BYTES / 1024 / 1024)} MB`);
}
if (!buf.subarray(0, 5).toString("latin1").startsWith("%PDF-")) {
throw new FileError(`${name} ist keine PDF-Datei`);
}
let text = "";
try {
const parser = new PDFParse({ data: new Uint8Array(buf) });
try {
const result = await parser.getText();
text = result.text;
} finally {
await parser.destroy();
}
} catch {
throw new FileError(`${name} konnte nicht gelesen werden`);
}
text = text.replace(/\x00/g, "").trim();
if (!text) {
throw new FileError(
`${name} enthält keinen extrahierbaren Text — vermutlich gescannt (OCR wird nicht unterstützt)`,
);
}
if (text.length > MAX_FILE_CHARS) {
text = text.slice(0, MAX_FILE_CHARS) + "\n[…gekürzt]";
}
return text;
}
/**
* Prüft Datei-Anhänge: Textdateien direkt, PDFs als base64 mit serverseitiger
* Textextraktion. Wirft FileError, sobald etwas nicht passt.
*/
export async function prepareFiles(raw: unknown): Promise<ValidatedFile[]> {
if (raw === undefined || raw === null) return [];
if (!Array.isArray(raw)) throw new FileError("files muss ein Array sein");
if (raw.length > MAX_FILES_PER_MESSAGE) {
throw new FileError(`Maximal ${MAX_FILES_PER_MESSAGE} Dateien pro Nachricht`);
}
const out: ValidatedFile[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new FileError("Datei hat ein ungültiges Format");
}
const o = entry as RawFile;
const name = sanitizeName(o.name);
if (o.encoding === "base64") {
if (typeof o.content !== "string" || o.content.length === 0) {
throw new FileError(`${name} ist leer`);
}
out.push({ name, content: await extractPdfText(name, o.content) });
continue;
}
if (typeof o.content !== "string" || o.content.length === 0) {
throw new FileError(`${name} ist leer`);
}
if (o.content.length > MAX_FILE_CHARS) {
throw new FileError(
`${name} ist größer als ${Math.round(MAX_FILE_CHARS / 1000)} kB`,
);
}
if (!isText(o.content)) {
throw new FileError(`${name} wirkt binär — nur Textdateien werden unterstützt`);
}
out.push({ name, content: o.content });
}
return out;
}
/** Baut einen Datei-Block für den LLM-Kontext, gekürzt auf maxChars. */
export function fileBlock(name: string, content: string, maxChars = 24_000): string {
const body =
content.length > maxChars
? content.slice(0, maxChars) + "\n[…gekürzt]"
: content;
return `[Datei: ${name}]\n\`\`\`\n${body}\n\`\`\``;
}

View file

@ -15,6 +15,7 @@ import {
} from "./openrouter.js";
import { ALLOWED_ORIGINS, isOriginAllowed, createRateLimiter } from "./security.js";
import { validateImages, ImageError, MAX_WS_PAYLOAD } from "./images.js";
import { prepareFiles, FileError, fileBlock } from "./files.js";
import { toolNames } from "./tools/index.js";
// simple .env loader (project root)
@ -81,6 +82,7 @@ app.addHook("onRequest", async (req, reply) => {
const sttLimiter = createRateLimiter(10, 60_000);
const ttsLimiter = createRateLimiter(30, 60_000);
const dreamLimiter = createRateLimiter(4, 60_000);
app.addContentTypeParser(
["application/octet-stream", "audio/*", "video/*"],
@ -145,6 +147,9 @@ app.get("/api/sessions/:id/memory", async (req, reply) => {
});
app.post("/api/sessions/:id/dream", async (req, reply) => {
if (!dreamLimiter(req.ip)) {
return reply.code(429).send({ error: "Zu viele Anfragen" });
}
const { id } = req.params as { id: string };
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
const result = await mem.dream(id, { ollamaUrl: OLLAMA_URL, model: MODEL });
@ -153,6 +158,9 @@ app.post("/api/sessions/:id/dream", async (req, reply) => {
});
app.post("/api/sessions/:id/memory/rebuild", async (req, reply) => {
if (!dreamLimiter(req.ip)) {
return reply.code(429).send({ error: "Zu viele Anfragen" });
}
const { id } = req.params as { id: string };
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
const result = mem.rebuildMemory(id);
@ -351,35 +359,56 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
return;
}
// Ein Bild allein ist eine gültige Anfrage — Text darf dann fehlen.
if (!sessionId || (!content && images.length === 0)) {
let chatFiles: { name: string; content: string }[];
try {
chatFiles = await prepareFiles(msg.files);
} catch (err) {
socket.send(
JSON.stringify({
type: "error",
error: err instanceof FileError ? err.message : "Datei abgelehnt",
}),
);
return;
}
// Ein Bild oder eine Datei allein ist eine gültige Anfrage — Text darf dann fehlen.
if (!sessionId || (!content && images.length === 0 && chatFiles.length === 0)) {
socket.send(JSON.stringify({ type: "error", error: "sessionId/content fehlt" }));
return;
}
let session = dbmod.getSession(sessionId);
if (!session) session = dbmod.createSession();
dbmod.renameSessionIfDefault(session.id, content || "Bild");
dbmod.renameSessionIfDefault(
session.id,
content || chatFiles.map((f) => f.name).join(", ") || "Bild",
);
const userMsg = dbmod.insertMessage(session.id, "user", content, null, images);
const userMsg = dbmod.insertMessage(session.id, "user", content, null, images, chatFiles);
mem.appendEvent(session.id, "message", {
role: "user",
content,
images: images.length,
files: chatFiles.map((f) => ({ name: f.name, chars: f.content.length })),
});
socket.send(JSON.stringify({ type: "user-message", message: userMsg }));
broadcast({ type: "sessions-changed" });
const opts = parseOptions(msg.options);
opts.sessionId = session.id;
const history = dbmod
.listMessages(session.id)
.slice(-opts.memorySteps)
.map((m) => {
const history = dbmod.listMessages(session.id).slice(-opts.memorySteps).map((m) => {
const imgs = dbmod.parseImages(m);
return imgs.length
? { role: m.role, content: m.content, images: imgs }
: { role: m.role, content: m.content };
let text = m.content;
const msgFiles = dbmod.parseFiles(m);
if (msgFiles.length) {
text = (text ? text + "\n\n" : "") + msgFiles.map((f) => fileBlock(f.name, f.content)).join("\n\n");
}
const base: { role: string; content: string; images?: string[] } = {
role: m.role,
content: text,
};
return imgs.length ? { ...base, images: imgs } : base;
});
const userSystem =

View file

@ -103,6 +103,9 @@ export function appendEvent(
type: "message" | "tool_call",
payload: Record<string, unknown>,
): number {
if (typeof payload.content === "string") {
payload = { ...payload, content: payload.content.slice(0, 20_000) };
}
const res = db
.prepare(
"INSERT INTO memory_events (session_id, type, payload, created_at) VALUES (?, ?, ?, ?)",

225
setup.sh Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env bash
#
# agenttwo-tools — Installationsroutine für macOS
# Prüft alle Abhängigkeiten und bietet fehlende Komponenten zur Installation an.
#
# ./setup.sh interaktiv (fragt vor jeder Installation)
# ./setup.sh --check prüft nur, ändert nichts
# ./setup.sh --yes installiert alles Fehlende ohne Nachfragen
#
set -u
cd "$(dirname "$0")"
AUTO=0
CHECK=0
for arg in "$@"; do
case "$arg" in
--yes|-y) AUTO=1 ;;
--check|-c) CHECK=1 ;;
*) echo "Unbekannte Option: $arg"; exit 1 ;;
esac
done
GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; BOLD='\033[1m'; OFF='\033[0m'
ok() { printf " ${GREEN}${OFF} %s\n" "$1"; }
warn() { printf " ${YELLOW}!${OFF} %s\n" "$1"; }
fail() { printf " ${RED}${OFF} %s\n" "$1"; }
sect() { printf "\n${BOLD}== %s ==${OFF}\n" "$1"; }
MISSING=0
note_missing() { MISSING=$((MISSING + 1)); }
have() { command -v "$1" >/dev/null 2>&1; }
ask() {
if [ "$AUTO" -eq 1 ]; then echo " → --yes: installiere automatisch"; return 0; fi
printf " ${BOLD}%s installieren? [J/n] ${OFF}" "$1"
read -r answer
[ -z "$answer" ] || [ "$answer" = "j" ] || [ "$answer" = "J" ] || [ "$answer" = "ja" ]
}
# .env lesen (nur einfache KEY=VALUE-Zeilen), ohne sie zu verändern
env_var() {
if [ -f .env ]; then
grep -E "^\s*$1=" .env | head -1 | cut -d= -f2- | sed 's/^["'\'']//; s/["'\'']$//'
fi
}
MODEL="$(env_var MODEL)"; MODEL="${MODEL:-qwen3.5:latest}"
WHISPER_MODEL="$(env_var WHISPER_MODEL)"; WHISPER_MODEL="${WHISPER_MODEL:-$HOME/whisper-models/ggml-large-v3-turbo.bin}"
WHISPER_MODEL="${WHISPER_MODEL/#\~/$HOME}"
PIPER_MODEL="$(env_var PIPER_MODEL)"; PIPER_MODEL="${PIPER_MODEL:-server/voices/de_DE-thorsten-high.onnx}"
OLLAMA_URL="$(env_var OLLAMA_URL)"; OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
sect "agenttwo-tools — Setup"
echo "Ziel: lauffähiger Voice-Chat auf diesem Mac (alles lokal)."
echo "Modell: $MODEL · Ollama: $OLLAMA_URL"
sect "1/9 macOS"
if [ "$(uname -s)" = "Darwin" ]; then
ok "macOS $(sw_vers -productVersion 2>/dev/null) · $(uname -m)"
else
fail "Kein macOS — dieses Skript ist für macOS geschrieben."
exit 1
fi
sect "2/9 Homebrew"
if have brew; then
ok "brew $(brew --version | head -1 | awk '{print $2}')"
else
note_missing
warn "Homebrew fehlt (Paketbasis für fast alles Weitere)."
if [ "$CHECK" -eq 0 ] && ask "Homebrew"; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || fail "Homebrew-Installation fehlgeschlagen"
if [ "$(uname -m)" = "arm64" ] && ! have brew; then
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
fi
have brew && ok "Homebrew installiert" || fail "brew weiterhin nicht gefunden — Terminal neu starten und Skript erneut laufen lassen"
fi
fi
sect "3/9 Node.js 22+"
NODE_OK=0
if have node; then
NODE_MAJOR="$(node -v | sed 's/^v//' | cut -d. -f1)"
if [ "${NODE_MAJOR:-0}" -ge 22 ]; then
ok "node $(node -v)"
NODE_OK=1
else
warn "node $(node -v) ist zu alt (mind. v22, wegen node:sqlite)."
fi
fi
if [ "$NODE_OK" -eq 0 ]; then
note_missing
if [ "$CHECK" -eq 0 ] && have brew && ask "Node.js 22 (brew install node@22)"; then
brew install node@22 && brew link --overwrite node@22
have node && ok "node $(node -v)" || fail "node weiterhin nicht gefunden — Terminal neu starten"
fi
fi
sect "4/9 Projekt-Abhängigkeiten (npm)"
if [ -d node_modules ] && [ -x node_modules/.bin/tsx ] && [ -x node_modules/.bin/vite ]; then
ok "node_modules vorhanden"
else
note_missing
if [ "$CHECK" -eq 0 ] && have npm && ask "npm install"; then
npm install && ok "Abhängigkeiten installiert" || fail "npm install fehlgeschlagen"
fi
fi
sect "5/9 ffmpeg"
if have ffmpeg; then
ok "ffmpeg $(ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}')"
else
note_missing
if [ "$CHECK" -eq 0 ] && have brew && ask "ffmpeg (brew install ffmpeg)"; then
brew install ffmpeg && ok "ffmpeg installiert" || fail "ffmpeg-Installation fehlgeschlagen"
fi
fi
sect "6/9 whisper.cpp (Spracheingabe)"
if have whisper-cli; then
ok "whisper-cli gefunden"
else
note_missing
if [ "$CHECK" -eq 0 ] && have brew && ask "whisper.cpp (brew install whisper-cpp)"; then
brew install whisper-cpp && ok "whisper-cli installiert" || fail "whisper-cpp-Installation fehlgeschlagen"
fi
fi
if [ -f "$WHISPER_MODEL" ]; then
ok "Whisper-Modell: $(basename "$WHISPER_MODEL") ($(( $(stat -f%z "$WHISPER_MODEL") / 1024 / 1024 )) MB)"
else
note_missing
warn "Whisper-Modell fehlt: $WHISPER_MODEL (~1,5 GB Download)."
if [ "$CHECK" -eq 0 ] && ask "Whisper-Modell herunterladen"; then
mkdir -p "$(dirname "$WHISPER_MODEL")"
curl -L -o "$WHISPER_MODEL" \
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin" \
&& ok "Whisper-Modell geladen" || fail "Download fehlgeschlagen"
fi
fi
sect "7/9 Piper (Sprachausgabe)"
if python3 -m piper --help >/dev/null 2>&1; then
ok "piper (python3 -m piper)"
else
note_missing
warn "piper fehlt. Hinweis: Homebrew-Python verweigert pip oft (externally-managed)."
if [ "$CHECK" -eq 0 ] && ask "piper-tts"; then
pip3 install piper-tts 2>/dev/null \
|| pip3 install --user piper-tts 2>/dev/null \
|| pip3 install --break-system-packages piper-tts
python3 -m piper --help >/dev/null 2>&1 && ok "piper installiert" \
|| fail "piper weiterhin nicht nutzbar — manuell: pipx install piper-tts oder venv"
fi
fi
if [ -f "$PIPER_MODEL" ]; then
ok "Piper-Stimme: $(basename "$PIPER_MODEL")"
else
note_missing
warn "Piper-Stimme fehlt: $PIPER_MODEL (~110 MB Download)."
if [ "$CHECK" -eq 0 ] && ask "Piper-Stimme herunterladen"; then
mkdir -p "$(dirname "$PIPER_MODEL")"
curl -L -o "$PIPER_MODEL" \
"https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/$(basename "$PIPER_MODEL")" \
&& ok "Stimme geladen" || fail "Download fehlgeschlagen"
fi
fi
sect "8/9 Ollama"
OLLAMA_SVC=0
if have ollama; then
ok "ollama $(ollama --version 2>/dev/null | awk '{print $NF}')"
else
note_missing
warn "Ollama fehlt (führt das Sprachmodell lokal aus)."
if [ "$CHECK" -eq 0 ] && ask "Ollama (brew install ollama)"; then
brew install ollama && ok "Ollama installiert" || fail "Ollama-Installation fehlgeschlagen"
fi
fi
if curl -s --max-time 2 "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
ok "Ollama-Server erreichbar ($OLLAMA_URL)"
OLLAMA_SVC=1
else
note_missing
warn "Ollama-Server läuft nicht auf $OLLAMA_URL."
if [ "$CHECK" -eq 0 ] && have ollama && ask "Ollama-Server starten (ollama serve im Hintergrund)"; then
nohup ollama serve >/dev/null 2>&1 & disown
sleep 3
if curl -s --max-time 2 "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
ok "Ollama-Server läuft"
OLLAMA_SVC=1
else
fail "Server nicht erreichbar — App „Ollama“ öffnen oder 'ollama serve' manuell starten"
fi
fi
fi
sect "9/9 Sprachmodell ($MODEL)"
MODEL_OK=0
if [ "$OLLAMA_SVC" -eq 1 ]; then
if ollama list 2>/dev/null | awk '{print $1}' | grep -qx "$MODEL"; then
ok "$MODEL ist geladen"
MODEL_OK=1
else
note_missing
warn "$MODEL fehlt (Download je nach Modell mehrere GB)."
if [ "$CHECK" -eq 0 ] && have ollama && ask "ollama pull $MODEL"; then
ollama pull "$MODEL" && ok "$MODEL geladen" || fail "Modell-Download fehlgeschlagen"
fi
fi
else
warn "Ohne laufenden Ollama-Server keine Modellprüfung möglich."
fi
sect "Ergebnis"
if [ "$MISSING" -eq 0 ]; then
printf "${GREEN}Alles bereit!${OFF} Starten mit:\n\n npm run dev\n → http://localhost:5174\n"
else
printf "${YELLOW}%s Punkt(e) offen.${OFF} Skript erneut laufen lassen (fehlende Teile werden nachinstalliert), dann:\n\n npm run dev\n → http://localhost:5174\n" "$MISSING"
fi
printf "\nOptional (nur für Cloud-Fallback): OPENROUTER_API_KEY in die .env eintragen.\n"
[ "$CHECK" -eq 1 ] && [ "$MISSING" -gt 0 ] && exit 1
exit 0

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -6,8 +6,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0a0f0c" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-leRe8gQm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKaNlN-w.css">
<script type="module" crossorigin src="/assets/index-CQxQcFyH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CXQcDbVB.css">
</head>
<body>
<div id="root"></div>

View file

@ -157,10 +157,10 @@ export default function App() {
: chat.options.model.replace(/:latest$/, "")) ?? "qwen3";
const handleSend = useCallback(
(text: string, images: string[] = []) => {
(text: string, images: string[] = [], files: { name: string; content: string; encoding?: string }[] = []) => {
voice.cancelSpeech();
pendingSpeechRef.current = "";
chat.sendMessage(text, images);
chat.sendMessage(text, images, files);
},
[chat, voice],
);

View file

@ -27,6 +27,22 @@ function imagesOf(message: Message): string[] {
}
}
/** Der Server legt Text-Datei-Anhänge als JSON-Array {name, content} ab. */
function filesOf(message: Message): { name: string; content: string }[] {
if (!message.files) return [];
try {
const parsed: unknown = JSON.parse(message.files);
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((f) => {
const o = f as { name?: unknown; content?: unknown };
if (typeof o?.name !== "string" || typeof o?.content !== "string") return [];
return [{ name: o.name, content: o.content }];
});
} catch {
return [];
}
}
export function ChatMessage({
message,
toolEvents = [],
@ -39,6 +55,7 @@ export function ChatMessage({
const [showThinking, setShowThinking] = useState(false);
const isUser = message.role === "user";
const images = imagesOf(message);
const chatFiles = filesOf(message);
return (
<div className={`msg ${isUser ? "msg-user" : "msg-assistant"}`}>
@ -90,6 +107,20 @@ export function ChatMessage({
</div>
)}
{chatFiles.length > 0 && (
<div className="msg-files">
{chatFiles.map((f) => (
<span
className="file-chip"
key={f.name}
title={`${f.content.length.toLocaleString("de-DE")} Zeichen`}
>
📄 {f.name} · {Math.max(1, Math.round(f.content.length / 1024))} kB
</span>
))}
</div>
)}
{isUser ? (
message.content ? (
<div className="msg-content user-content">{message.content}</div>

View file

@ -4,6 +4,20 @@ import { useEffect, useRef, useState } from "react";
const MAX_IMAGES = 4;
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
const ACCEPTED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
/** Muss zu MAX_FILES_PER_MESSAGE / MAX_FILE_CHARS im Server passen. */
const MAX_FILES = 4;
const MAX_FILE_CHARS = 100_000;
const MAX_PDF_BYTES = 10 * 1024 * 1024;
const TEXT_EXTS = [
".txt", ".json", ".md", ".markdown", ".csv", ".tsv", ".log", ".xml", ".yaml",
".yml", ".toml", ".ini", ".cfg", ".conf", ".sql", ".sh", ".bash", ".zsh",
".py", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".css", ".scss", ".html",
".htm", ".svg", ".rs", ".go", ".java", ".kt", ".rb", ".php", ".c", ".h",
".cpp", ".hpp", ".cs", ".swift", ".diff", ".patch",
];
const PDF_TYPES = ["application/pdf"];
const PDF_EXTS = [".pdf"];
const TEXT_MIMES = ["application/json", "application/xml", "application/yaml", "application/x-sh"];
interface Attachment {
id: string;
@ -13,6 +27,15 @@ interface Attachment {
name: string;
}
interface TextAttachment {
id: string;
name: string;
/** Textinhalt oder base64 (encoding: base64, nur PDF). */
content: string;
bytes: number;
encoding: "text" | "base64";
}
interface Props {
streaming: boolean;
disabled: boolean;
@ -21,7 +44,7 @@ interface Props {
injectedText: string | null;
modelLabel?: string;
onInjected: () => void;
onSend: (text: string, images: string[]) => void;
onSend: (text: string, images: string[], files?: { name: string; content: string; encoding?: string }[]) => void;
onAbort: () => void;
onMicToggle: () => void;
}
@ -44,6 +67,44 @@ function readAsAttachment(file: File): Promise<Attachment> {
});
}
async function readAsTextAttachment(file: File): Promise<TextAttachment> {
const ext = "." + (file.name.split(".").pop() ?? "").toLowerCase();
if (PDF_TYPES.includes(file.type) || PDF_EXTS.includes(ext)) {
if (file.size > MAX_PDF_BYTES) {
throw new Error(`${file.name}: größer als ${Math.round(MAX_PDF_BYTES / 1024 / 1024)} MB`);
}
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error(`${file.name} konnte nicht gelesen werden`));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(file);
});
const comma = dataUrl.indexOf(",");
return {
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
name: file.name || "PDF",
content: dataUrl.slice(comma + 1),
bytes: file.size,
encoding: "base64",
};
}
const content = await file.text();
if (content.length === 0) throw new Error(`${file.name} ist leer`);
if (content.length > MAX_FILE_CHARS) {
throw new Error(`${file.name}: größer als ${Math.round(MAX_FILE_CHARS / 1000)} kB Text`);
}
if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(content.slice(0, 2000))) {
throw new Error(`${file.name}: wirkt binär — nur Textdateien`);
}
return {
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
name: file.name || "Datei",
content,
bytes: file.size,
encoding: "text",
};
}
export function Composer({
streaming,
disabled,
@ -58,6 +119,7 @@ export function Composer({
}: Props) {
const [value, setValue] = useState("");
const [images, setImages] = useState<Attachment[]>([]);
const [textFiles, setTextFiles] = useState<TextAttachment[]>([]);
const [imgError, setImgError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const ref = useRef<HTMLTextAreaElement>(null);
@ -76,43 +138,71 @@ export function Composer({
const addFiles = async (files: File[]) => {
setImgError(null);
const usable: File[] = [];
const imgFiles: File[] = [];
const txtFiles: File[] = [];
for (const f of files) {
if (!ACCEPTED.includes(f.type)) {
setImgError(`${f.name || "Datei"}: nur PNG, JPEG, GIF oder WebP`);
continue;
}
const ext = "." + (f.name.split(".").pop() ?? "").toLowerCase();
if (ACCEPTED.includes(f.type)) {
if (f.size > MAX_IMAGE_BYTES) {
setImgError(`${f.name}: größer als ${MAX_IMAGE_BYTES / 1024 / 1024} MB`);
continue;
}
usable.push(f);
imgFiles.push(f);
} else if (
f.type.startsWith("text/") ||
TEXT_MIMES.includes(f.type) ||
PDF_TYPES.includes(f.type) ||
TEXT_EXTS.includes(ext) ||
PDF_EXTS.includes(ext)
) {
if (f.size > MAX_FILE_CHARS * 2) {
setImgError(`${f.name}: größer als ${Math.round((MAX_FILE_CHARS * 2) / 1000)} kB`);
continue;
}
txtFiles.push(f);
} else {
setImgError(`${f.name || "Datei"}: nur Bilder (PNG, JPEG, GIF, WebP) oder Textdateien`);
}
}
if (!usable.length) return;
try {
const added = await Promise.all(usable.map(readAsAttachment));
if (imgFiles.length) {
const added = await Promise.all(imgFiles.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))];
});
}
if (txtFiles.length) {
const added = await Promise.all(txtFiles.map(readAsTextAttachment));
setTextFiles((prev) => {
const free = MAX_FILES - prev.length;
if (added.length > free) setImgError(`Maximal ${MAX_FILES} Dateien pro Nachricht`);
return [...prev, ...added.slice(0, Math.max(free, 0))];
});
}
} catch (err) {
setImgError(err instanceof Error ? err.message : "Bild konnte nicht gelesen werden");
setImgError(err instanceof Error ? err.message : "Datei konnte nicht gelesen werden");
}
};
const submit = () => {
const text = value.trim();
if ((!text && images.length === 0) || streaming || disabled) return;
onSend(text, images.map((i) => i.base64));
if ((!text && images.length === 0 && textFiles.length === 0) || streaming || disabled) return;
onSend(text, images.map((i) => i.base64), textFiles.map((f) => ({
name: f.name,
content: f.content,
encoding: f.encoding === "base64" ? "base64" : undefined,
})));
setValue("");
setImages([]);
setTextFiles([]);
setImgError(null);
requestAnimationFrame(() => ref.current?.focus());
};
const canSend = (value.trim().length > 0 || images.length > 0) && !disabled;
const canSend = (value.trim().length > 0 || images.length > 0 || textFiles.length > 0) && !disabled;
return (
<div
@ -130,7 +220,7 @@ export function Composer({
void addFiles([...e.dataTransfer.files]);
}}
>
{(images.length > 0 || imgError) && (
{(images.length > 0 || textFiles.length > 0 || imgError) && (
<div className="attachments">
{images.map((img) => (
<div className="attachment" key={img.id}>
@ -144,6 +234,20 @@ export function Composer({
</button>
</div>
))}
{textFiles.map((f) => (
<div className="attachment attachment-file" key={f.id}>
<span className="attachment-file-name">
📄 {f.name} · {Math.max(1, Math.round(f.bytes / 1024))} kB
</span>
<button
className="attachment-remove"
onClick={() => setTextFiles((p) => p.filter((i) => i.id !== f.id))}
title={`${f.name} entfernen`}
>
×
</button>
</div>
))}
{imgError && <span className="attachment-error">{imgError}</span>}
</div>
)}
@ -167,19 +271,15 @@ export function Composer({
<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)"
}
disabled={disabled}
title="Bild oder Textdatei anhängen (auch per Einfügen oder Drag & Drop)"
>
🖼
📎
</button>
<input
ref={fileRef}
type="file"
accept={ACCEPTED.join(",")}
accept={[...ACCEPTED, ...TEXT_EXTS, ...PDF_EXTS].join(",")}
multiple
hidden
onChange={(e) => {
@ -202,9 +302,15 @@ export function Composer({
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
onPaste={(e) => {
const files = [...e.clipboardData.files].filter((f) =>
f.type.startsWith("image/"),
const files = [...e.clipboardData.files].filter((f) => {
const ext = "." + (f.name.split(".").pop() ?? "").toLowerCase();
return (
ACCEPTED.includes(f.type) ||
f.type.startsWith("text/") ||
TEXT_MIMES.includes(f.type) ||
TEXT_EXTS.includes(ext)
);
});
if (files.length) {
e.preventDefault();
void addFiles(files);

View file

@ -833,3 +833,45 @@ body {
.anchor-actions button:hover {
color: var(--accent);
}
/* ---------- Datei-Anhänge ---------- */
.attachment-file {
width: auto;
min-width: 120px;
max-width: 320px;
height: auto;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
.attachment-file-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-files {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.file-chip {
font-size: 12px;
color: var(--text-dim);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -12,9 +12,17 @@ export interface Message {
thinking: string | null;
/** JSON-Array mit base64-Bilddaten, wie es der Server speichert. */
images: string | null;
/** JSON-Array mit Text-Datei-Anhängen ({name, content}), wie es der Server speichert. */
files: string | null;
created_at: number;
}
export interface ChatFile {
name: string;
content: string;
encoding?: string;
}
/** Werkzeugaufruf während einer Antwort. Nur zur Laufzeit, nicht gespeichert. */
export interface ToolEvent {
name: string;

View file

@ -177,9 +177,9 @@ export function useChat() {
}, []);
const sendMessage = useCallback(
(content: string, images: string[] = []) => {
// Ein Bild ohne Text ist eine gültige Anfrage.
if ((!content.trim() && images.length === 0) || streamingRef.current || !activeId) {
(content: string, images: string[] = [], files: { name: string; content: string; encoding?: string }[] = []) => {
// Ein Bild oder eine Datei allein ist eine gültige Anfrage.
if ((!content.trim() && images.length === 0 && files.length === 0) || streamingRef.current || !activeId) {
return;
}
streamingRef.current = true;
@ -189,6 +189,7 @@ export function useChat() {
sessionId: activeId,
content,
images,
files,
options,
systemPrompt,
});