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

@ -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,36 +359,57 @@ 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 imgs = dbmod.parseImages(m);
return imgs.length
? { role: m.role, content: m.content, images: imgs }
: { role: m.role, content: m.content };
});
const history = dbmod.listMessages(session.id).slice(-opts.memorySteps).map((m) => {
const imgs = dbmod.parseImages(m);
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 =
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()

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 (?, ?, ?, ?)",