mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-10 15:32:32 +02:00
Basis für die Tool-Calling- und Vision-Entwicklung. Die Historie von agenttwo bleibt erhalten, damit sich Fixes aus der Basis per cherry-pick übernehmen lassen; das Remote upstream zeigt dafür auf Jeuners/agenttwo. Eigene Ports, damit beide Projekte gleichzeitig laufen können: - Backend 8787 -> 8788, Vite 5173 -> 5174 samt Proxy-Zielen - Origin-Allowlist in security.ts entsprechend auf 5174/8788 - OpenRouter-Attribution (HTTP-Referer, X-Title) angepasst Die SQLite-Datei liegt wie gehabt neben dem Server, beide Instanzen haben dadurch getrennte Sessions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1NXUpHbxTusqQmsjrFfbU
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import Fastify from "fastify";
|
|
import cors from "@fastify/cors";
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import type { IncomingMessage } from "node:http";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import * as dbmod from "./db.js";
|
|
import { streamChat, type OllamaOptions } from "./ollama.js";
|
|
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
|
|
import {
|
|
streamOpenRouter,
|
|
listOpenRouterModels,
|
|
getOpenRouterKey,
|
|
} from "./openrouter.js";
|
|
import { ALLOWED_ORIGINS, isOriginAllowed, createRateLimiter } from "./security.js";
|
|
|
|
// simple .env loader (project root)
|
|
try {
|
|
for (const line of readFileSync(
|
|
path.join(import.meta.dirname, "..", "..", ".env"),
|
|
"utf8",
|
|
).split("\n")) {
|
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
}
|
|
} catch {
|
|
/* keine .env vorhanden */
|
|
}
|
|
|
|
const PORT = Number(process.env.PORT ?? 8788);
|
|
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
|
|
const app = Fastify({ logger: true, bodyLimit: MAX_AUDIO_BYTES });
|
|
|
|
// origin:true würde jede fremde Origin reflektieren — damit könnte jede vom
|
|
// Nutzer geöffnete Webseite die komplette Chat-Historie auslesen.
|
|
await app.register(cors, { origin: [...ALLOWED_ORIGINS] });
|
|
|
|
// Zweite Verteidigungslinie: CORS schützt nur Browser-Clients, die den
|
|
// Response abwarten. Ein fremder Origin wird hier hart abgewiesen.
|
|
app.addHook("onRequest", async (req, reply) => {
|
|
if (!isOriginAllowed(req.headers.origin)) {
|
|
return reply.code(403).send({ error: "origin not allowed" });
|
|
}
|
|
});
|
|
|
|
const sttLimiter = createRateLimiter(10, 60_000);
|
|
const ttsLimiter = createRateLimiter(30, 60_000);
|
|
|
|
app.addContentTypeParser(
|
|
["application/octet-stream", "audio/*", "video/*"],
|
|
{ parseAs: "buffer" },
|
|
(_req, body, done) => done(null, body),
|
|
);
|
|
|
|
app.get("/api/health", async () => ({ ok: true }));
|
|
|
|
app.get("/api/model", async () => {
|
|
try {
|
|
const res = await fetch(`${OLLAMA_URL}/api/show`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: process.env.MODEL ?? "qwen3.5:latest" }),
|
|
});
|
|
if (!res.ok) return { ok: false, error: `Ollama HTTP ${res.status}` };
|
|
const data = (await res.json()) as {
|
|
details?: { parameter_size?: string; quantization_level?: string };
|
|
capabilities?: string[];
|
|
};
|
|
return {
|
|
ok: true,
|
|
model: process.env.MODEL ?? "qwen3.5:latest",
|
|
parameterSize: data.details?.parameter_size,
|
|
quantization: data.details?.quantization_level,
|
|
capabilities: data.capabilities ?? [],
|
|
};
|
|
} catch (err) {
|
|
app.log.error({ err }, "Ollama /api/show fehlgeschlagen");
|
|
return { ok: false, error: "Ollama nicht erreichbar" };
|
|
}
|
|
});
|
|
|
|
// --- Sessions REST ---
|
|
app.get("/api/sessions", async () => dbmod.listSessions());
|
|
app.post("/api/sessions", async () => dbmod.createSession());
|
|
app.get("/api/sessions/:id/messages", async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
|
|
return dbmod.listMessages(id);
|
|
});
|
|
app.delete("/api/sessions/:id", async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
dbmod.deleteSession(id);
|
|
broadcast({ type: "session-deleted", id });
|
|
return { ok: true };
|
|
});
|
|
|
|
// --- Voice ---
|
|
app.post("/api/stt", async (req, reply) => {
|
|
if (!sttLimiter(req.ip)) {
|
|
return reply.code(429).send({ ok: false, error: "Zu viele Anfragen" });
|
|
}
|
|
const audio = req.body as Buffer;
|
|
if (!Buffer.isBuffer(audio) || audio.length === 0) {
|
|
return reply.code(400).send({ ok: false, error: "Kein Audio empfangen" });
|
|
}
|
|
try {
|
|
const text = await transcribeAudio(audio);
|
|
return { ok: true, text };
|
|
} catch (err) {
|
|
// Details nur ins Server-Log: whisper/ffmpeg-Fehler enthalten lokale Pfade.
|
|
app.log.error({ err }, "STT fehlgeschlagen");
|
|
return reply
|
|
.code(500)
|
|
.send({ ok: false, error: "Transkription fehlgeschlagen (Details im Server-Log)" });
|
|
}
|
|
});
|
|
|
|
const TTS_MAX_CHARS = 8000;
|
|
|
|
app.post("/api/tts", async (req, reply) => {
|
|
if (!ttsLimiter(req.ip)) {
|
|
return reply.code(429).send({ error: "Zu viele Anfragen" });
|
|
}
|
|
const { text } = (req.body ?? {}) as { text?: string };
|
|
if (!text || !text.trim()) {
|
|
return reply.code(400).send({ error: "text fehlt" });
|
|
}
|
|
if (text.length > TTS_MAX_CHARS) {
|
|
return reply.code(413).send({ error: `Text länger als ${TTS_MAX_CHARS} Zeichen` });
|
|
}
|
|
try {
|
|
const wav = await synthesizeSpeech(text);
|
|
reply.header("Content-Type", "audio/wav").send(wav);
|
|
} catch (err) {
|
|
app.log.error({ err }, "TTS fehlgeschlagen");
|
|
return reply
|
|
.code(500)
|
|
.send({ error: "Sprachausgabe fehlgeschlagen (Details im Server-Log)" });
|
|
}
|
|
});
|
|
|
|
// --- OpenRouter models ---
|
|
app.get("/api/openrouter/models", async () => {
|
|
if (!getOpenRouterKey()) {
|
|
return { ok: false, error: "OPENROUTER_API_KEY nicht gesetzt (.env fehlt)" };
|
|
}
|
|
try {
|
|
return { ok: true, models: await listOpenRouterModels() };
|
|
} catch (err) {
|
|
app.log.error({ err }, "OpenRouter-Modellliste fehlgeschlagen");
|
|
return { ok: false, error: "Modellliste nicht abrufbar" };
|
|
}
|
|
});
|
|
|
|
// --- WebSocket ---
|
|
interface ChatOptionsPayload {
|
|
think: boolean;
|
|
temperature: number;
|
|
numPredict: number;
|
|
provider?: string;
|
|
openrouterModel?: string;
|
|
}
|
|
|
|
function parseOptions(raw: unknown): OllamaOptions & {
|
|
provider: "ollama" | "openrouter";
|
|
openrouterModel: string;
|
|
} {
|
|
const o = (raw ?? {}) as Partial<ChatOptionsPayload>;
|
|
return {
|
|
model: process.env.MODEL ?? "qwen3.5:latest",
|
|
think: o.think !== false,
|
|
temperature: clamp(Number(o.temperature ?? 0.7), 0, 2),
|
|
numPredict: Math.min(Math.max(Number(o.numPredict ?? 2048), 64), 16384),
|
|
provider: o.provider === "openrouter" ? "openrouter" : "ollama",
|
|
openrouterModel:
|
|
typeof o.openrouterModel === "string" &&
|
|
/^[\w./:-]{3,120}$/.test(o.openrouterModel)
|
|
? o.openrouterModel
|
|
: "anthropic/claude-sonnet-4.5",
|
|
};
|
|
}
|
|
|
|
function clamp(v: number, min: number, max: number) {
|
|
if (!Number.isFinite(v)) return min;
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
function broadcast(data: unknown) {
|
|
for (const client of wss.clients) {
|
|
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
|
|
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|
let activeAbort: AbortController | null = null;
|
|
|
|
socket.on("message", async (raw: Buffer) => {
|
|
let msg: Record<string, unknown>;
|
|
try {
|
|
msg = JSON.parse(raw.toString()) as Record<string, unknown>;
|
|
} catch {
|
|
socket.send(JSON.stringify({ type: "error", error: "invalid json" }));
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "abort") {
|
|
activeAbort?.abort();
|
|
return;
|
|
}
|
|
|
|
if (msg.type !== "chat") return;
|
|
|
|
const sessionId = String(msg.sessionId ?? "");
|
|
const content = String(msg.content ?? "").trim();
|
|
if (!sessionId || !content) {
|
|
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);
|
|
|
|
const userMsg = dbmod.insertMessage(session.id, "user", content);
|
|
socket.send(JSON.stringify({ type: "user-message", message: userMsg }));
|
|
broadcast({ type: "sessions-changed" });
|
|
|
|
const opts = parseOptions(msg.options);
|
|
const history = dbmod
|
|
.listMessages(session.id)
|
|
.slice(-24)
|
|
.map((m) => ({ role: m.role, content: m.content }));
|
|
|
|
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
|
|
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
|
|
|
|
activeAbort = new AbortController();
|
|
let full = "";
|
|
let thinking = "";
|
|
const callbacks = {
|
|
onThinking(text: string) {
|
|
thinking += text;
|
|
socket.send(
|
|
JSON.stringify({ type: "thinking", text, messageId: assistantRow.id }),
|
|
);
|
|
},
|
|
onToken(text: string) {
|
|
full += text;
|
|
socket.send(
|
|
JSON.stringify({ type: "token", text, messageId: assistantRow.id }),
|
|
);
|
|
},
|
|
onDone() {},
|
|
};
|
|
|
|
try {
|
|
if (opts.provider === "openrouter") {
|
|
const apiKey = getOpenRouterKey();
|
|
if (!apiKey) throw new Error("OPENROUTER_API_KEY nicht gesetzt (.env fehlt)");
|
|
await streamOpenRouter(
|
|
history,
|
|
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
|
|
? (msg.systemPrompt as string)
|
|
: undefined,
|
|
{
|
|
model: opts.openrouterModel,
|
|
temperature: opts.temperature,
|
|
numPredict: opts.numPredict,
|
|
},
|
|
apiKey,
|
|
callbacks,
|
|
activeAbort.signal,
|
|
);
|
|
} else {
|
|
await streamChat(
|
|
history,
|
|
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
|
|
? (msg.systemPrompt as string)
|
|
: undefined,
|
|
opts,
|
|
callbacks,
|
|
activeAbort.signal,
|
|
);
|
|
}
|
|
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "done",
|
|
messageId: assistantRow.id,
|
|
aborted: activeAbort.signal.aborted,
|
|
}),
|
|
);
|
|
broadcast({ type: "sessions-changed" });
|
|
} catch (err) {
|
|
const aborted =
|
|
activeAbort.signal.aborted ||
|
|
(err instanceof Error && err.name === "AbortError");
|
|
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
|
if (aborted) {
|
|
socket.send(JSON.stringify({ type: "done", messageId: assistantRow.id, aborted: true }));
|
|
} else {
|
|
socket.send(
|
|
JSON.stringify({
|
|
type: "error",
|
|
messageId: assistantRow.id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
}),
|
|
);
|
|
}
|
|
} finally {
|
|
activeAbort = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
const server = app.server;
|
|
server.on("upgrade", (req, socket, head) => {
|
|
const url = new URL(req.url ?? "", "http://localhost");
|
|
|
|
// WebSockets unterliegen nicht der Same-Origin-Policy: ohne diese Prüfung
|
|
// könnte jede fremde Seite eine Verbindung aufbauen, Chats senden, Antworten
|
|
// mitlesen und Kosten auf dem OpenRouter-Key erzeugen (CSWSH).
|
|
if (!isOriginAllowed(req.headers.origin)) {
|
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/ws") {
|
|
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
} else {
|
|
socket.destroy();
|
|
}
|
|
});
|
|
|
|
app.listen({ port: PORT, host: "127.0.0.1" }, () => {
|
|
console.log(`[oxagenttwo] Server läuft auf http://127.0.0.1:${PORT}`);
|
|
});
|