mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
c8b74892ef
commit
ea87c350a5
19 changed files with 989 additions and 130 deletions
|
|
@ -12,6 +12,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"fastify": "^5.2.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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
134
server/src/files.ts
Normal 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\`\`\``;
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (?, ?, ?, ?)",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue