mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
feat: Bestätigungspflicht für Werkzeuge mit Außenwirkung, DNS-Pinning
Ergebnis eines Security-Reviews. Die schwerwiegendste Kette war: Fremdinhalt aus read_webpage weist das Modell an, remember aufzurufen — der Anker landet gepinnt und sessionübergreifend im Gedächtnis und geht danach als "verlässliches Wissen" in jeden System-Prompt. Beide Werkzeuge liefen ungefragt, requiresConfirmation war nur ein Feld ohne Wirkung. Bestätigung: - runTool wertet requiresConfirmation über ToolContext.confirm aus; ohne Rückkanal (Skript, Test) gilt abgelehnt statt ungefragt ausführen - Handshake über den WebSocket: tool-confirm mit ungekürzten Argumenten, Antwort allow/always/deny. Ablehnung auch bei Timeout (2 min), Verbindungsabbruch und Stop; "always" gilt pro Verbindung - read_webpage und remember sind bestätigungspflichtig; remember pinnt nicht mehr automatisch, damit Modell-Anker normal verfallen - ToolConfirm-Komponente zeigt Werkzeug und vollständige Argumente SSRF-Guard (DNS-Rebinding): - fetch gegen node:http/https mit eigenem lookup-Hook getauscht: geprüft wird genau die Adresse, die dann auch verbunden wird. Vorher löste fetch ein zweites Mal auf — öffentlich beim Prüfen, 127.0.0.1 beim Verbinden - IPv6-Literale werden entklammert, gzip/deflate/br werden ausgepackt Nebenbei: - ctx.signal kombiniert Abbruch und Zeitlimit und wirkt bis in den Abruf - activeAborts als Set: Stop erwischt alle laufenden Antworten - Chat-Rate-Limit 30/min je Verbindung, Rate-Limiter räumt Buckets ab - web/dist untracked Nicht umgesetzt: Auth/Origin-Härtung — der Server läuft bewusst lokal, Anfragen ohne Origin-Header bleiben erlaubt (im README dokumentiert). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh
This commit is contained in:
parent
878acd9933
commit
dd0ee30165
18 changed files with 570 additions and 181 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -6,3 +6,6 @@ data.sqlite*
|
|||
|
||||
# Piper-Stimmmodell (109 MB) — siehe README, wird lokal heruntergeladen
|
||||
server/voices/*.onnx
|
||||
|
||||
# Build-Artefakt, wird aus web/src erzeugt
|
||||
web/dist/
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -166,12 +166,30 @@ sich das in den Einstellungen.
|
|||
| `calculate` | Arithmetik mit eigenem Parser |
|
||||
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
|
||||
| `list_files` | Verzeichnis auflisten |
|
||||
| `remember` | Wichtigen Punkt als gepinnten Ankerpunkt ins Gedächtnis schreiben |
|
||||
| `remember` | Wichtigen Punkt als Ankerpunkt ins Gedächtnis schreiben — **Rückfrage** |
|
||||
| `recall` | Gedächtnis (Ankerpunkte) durchsuchen |
|
||||
| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown |
|
||||
| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown — **Rückfrage** |
|
||||
|
||||
Aktuelle Liste: `curl -s http://localhost:8788/api/tools`
|
||||
|
||||
### Rückfrage vor Werkzeugen mit Außenwirkung
|
||||
|
||||
Werkzeuge mit `requiresConfirmation` laufen erst nach Freigabe. Der Server
|
||||
schickt eine Rückfrage über den WebSocket, die Oberfläche zeigt Werkzeugname
|
||||
und die **vollständigen** Argumente, und erst die Antwort löst die Ausführung
|
||||
aus:
|
||||
|
||||
| Antwort | Wirkung |
|
||||
|---|---|
|
||||
| Ablehnen | Werkzeug läuft nicht; das Modell bekommt "vom Nutzer abgelehnt" als Ergebnis und macht ohne weiter |
|
||||
| Einmal zulassen | Nur dieser eine Aufruf |
|
||||
| Immer zulassen | Dieses Werkzeug bis zum Neuladen der Seite, pro WebSocket-Verbindung |
|
||||
|
||||
Ohne Antwort gilt nach 2 Minuten „abgelehnt"; dasselbe bei Verbindungsabbruch
|
||||
und beim Stoppen der Antwort. Läuft ein Werkzeug ohne Rückkanal (Skript,
|
||||
Test), wird es abgelehnt statt ungefragt ausgeführt — die Bestätigung soll
|
||||
sich nicht dadurch umgehen lassen, dass niemand zum Fragen da ist.
|
||||
|
||||
## Gedächtnis (Chat-Memory)
|
||||
|
||||
Das Gedächtnis hat drei Schichten:
|
||||
|
|
@ -219,17 +237,29 @@ Endpunkte: `GET /api/sessions/:id/memory`, `POST /api/sessions/:id/dream`,
|
|||
|
||||
### Grenzen
|
||||
|
||||
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
|
||||
löscht oder Befehle ausführt — entsprechend braucht es noch keine
|
||||
Rückfrage pro Aufruf. Das Feld `requiresConfirmation` in
|
||||
`tools/types.ts` ist bereits vorgesehen, damit die Bestätigungspflicht nicht
|
||||
nachträglich eingezogen werden muss, sobald ein schreibendes Werkzeug dazukommt.
|
||||
Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem
|
||||
Außenwirkung und sind deshalb bestätigungspflichtig (siehe oben):
|
||||
|
||||
Ausnahme Netzwerk: `read_webpage` lädt öffentliche Websites. Der Abruf ist
|
||||
geguardet — nur http/https, private Adressbereiche werden nach DNS-Auflösung
|
||||
abgewiesen (SSRF-Schutz, auch über Weiterleitungen), 15 s Zeitlimit, 2 MB
|
||||
Fetch-Limit, 25 kB Output-Cap. Der Inhalt wird dem Modell als nicht
|
||||
vertrauenswürdig markiert (Prompt-Injection aus Webseiten).
|
||||
- **`read_webpage`** verlässt den Rechner. Die URL selbst ist dabei der
|
||||
kritische Teil: Fremdinhalt kann das Modell anweisen, Gesprächsinhalte in
|
||||
eine Adresse zu packen und so nach außen zu geben. Deshalb bekommt der
|
||||
Nutzer die vollständige URL vor dem Abruf zu sehen.
|
||||
- **`remember`** schreibt dauerhaft und sessionübergreifend ins Gedächtnis.
|
||||
Modell-Anker werden **nicht** gepinnt — sie unterliegen dem normalen Verfall.
|
||||
Gepinnt wird nur, was der Nutzer im Gedächtnis-Panel selbst mit ★ markiert.
|
||||
|
||||
Der Abruf in `read_webpage` ist mehrfach geguardet: nur http/https, private
|
||||
Adressbereiche abgewiesen (auch über Weiterleitungen), 15 s Zeitlimit, 2 MB
|
||||
Fetch-Limit, 25 kB Output-Cap. Die DNS-Auflösung ist **an die Verbindung
|
||||
gepinnt** (`lookup`-Hook in `tools/web.ts`) — geprüft wird genau die Adresse,
|
||||
die dann auch verbunden wird. Ein getrennter Vorab-Check, wie ihn `fetch`
|
||||
erzwingt, ließe DNS-Rebinding zu: öffentlich beim Prüfen, `127.0.0.1` beim
|
||||
Verbinden. Der Inhalt wird dem Modell zusätzlich als nicht vertrauenswürdig
|
||||
markiert (Prompt-Injection aus Webseiten).
|
||||
|
||||
Ein Nutzer-Abbruch („Stop") beendet auch ein laufendes Werkzeug: `ctx.signal`
|
||||
kombiniert Abbruch und Zeitlimit und geht bis in den offenen Netzwerkabruf
|
||||
durch.
|
||||
|
||||
Der Dateizugriff liegt in einer Sandbox: Jeder Pfad wird über `realpath`
|
||||
aufgelöst (löst auch Symlinks auf) und muss danach unterhalb der Wurzel liegen,
|
||||
|
|
@ -260,10 +290,17 @@ Läuft das Frontend woanders, die Origin ergänzen:
|
|||
ALLOWED_ORIGINS=http://192.168.1.50:5174
|
||||
```
|
||||
|
||||
Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min) und `/api/tts` (30/min),
|
||||
Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min), `/api/tts` (30/min),
|
||||
`/dream` (4/min) und auf Chats pro WebSocket-Verbindung (30/min),
|
||||
Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf, und Fehlerdetails
|
||||
landen im Server-Log statt in der HTTP-Antwort.
|
||||
|
||||
Bewusst **nicht** abgesichert: Anfragen ohne `Origin`-Header werden
|
||||
durchgelassen (`isOriginAllowed`). Für Browser-Clients trägt die Prüfung, denn
|
||||
`fetch` und Formular-POSTs senden immer eine Origin — jedes lokale Programm
|
||||
kommt aber ungefragt an die API. Das ist für den Einzelplatzbetrieb so
|
||||
gewollt; für alles andere gilt der Absatz unten.
|
||||
|
||||
Für externen Zugriff reicht ein Reverse-Proxy **nicht** — davor gehört eine
|
||||
echte Authentifizierung.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Fastify from "fastify";
|
|||
import cors from "@fastify/cors";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as dbmod from "./db.js";
|
||||
|
|
@ -35,6 +36,15 @@ const PORT = Number(process.env.PORT ?? 8788);
|
|||
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
||||
const MODEL = process.env.MODEL ?? "qwen3.5:latest";
|
||||
|
||||
/**
|
||||
* Wie lange auf die Freigabe eines bestätigungspflichtigen Werkzeugs gewartet
|
||||
* wird. Danach gilt "abgelehnt" — eine Antwort soll nicht ewig hängen, nur
|
||||
* weil niemand am Rechner sitzt.
|
||||
*/
|
||||
const CONFIRM_TIMEOUT_MS = 120_000;
|
||||
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
|
||||
const CHAT_LIMIT_PER_MIN = 30;
|
||||
|
||||
const DREAM_IDLE_MS = 180_000;
|
||||
const DREAM_BATCH = 10;
|
||||
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
|
@ -325,7 +335,54 @@ function broadcast(data: unknown) {
|
|||
}
|
||||
|
||||
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||
let activeAbort: AbortController | null = null;
|
||||
// Mehrere Antworten können parallel laufen (zweite Nachricht bei laufendem
|
||||
// Stream). Ein einzelnes Feld würde beim Abbruch nur die letzte erwischen.
|
||||
const activeAborts = new Set<AbortController>();
|
||||
const pendingConfirms = new Map<string, { name: string; decide(ok: boolean): void }>();
|
||||
/** Werkzeuge, die der Nutzer für diese Verbindung generell freigegeben hat. */
|
||||
const alwaysAllowed = new Set<string>();
|
||||
const chatLimiter = createRateLimiter(CHAT_LIMIT_PER_MIN, 60_000);
|
||||
|
||||
function denyAllConfirms() {
|
||||
for (const entry of [...pendingConfirms.values()]) entry.decide(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt den Nutzer, bevor ein Werkzeug mit Außenwirkung läuft. Bricht die
|
||||
* Verbindung weg oder bleibt die Antwort aus, gilt das als Ablehnung.
|
||||
*/
|
||||
function confirmTool(
|
||||
messageId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
if (alwaysAllowed.has(name)) return Promise.resolve(true);
|
||||
if (socket.readyState !== WebSocket.OPEN) return Promise.resolve(false);
|
||||
|
||||
const id = randomUUID();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => decide(false), CONFIRM_TIMEOUT_MS);
|
||||
function decide(approved: boolean) {
|
||||
clearTimeout(timer);
|
||||
pendingConfirms.delete(id);
|
||||
resolve(approved);
|
||||
}
|
||||
pendingConfirms.set(id, { name, decide });
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "tool-confirm",
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
// Ungekürzt: der Nutzer muss genau sehen, was rausgeht — bei
|
||||
// read_webpage ist die vollständige URL der eigentliche Punkt.
|
||||
args: JSON.stringify(args),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on("close", denyAllConfirms);
|
||||
|
||||
socket.on("message", async (raw: Buffer) => {
|
||||
let msg: Record<string, unknown>;
|
||||
|
|
@ -336,13 +393,31 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "tool-confirm-reply") {
|
||||
const entry = pendingConfirms.get(String(msg.id ?? ""));
|
||||
if (!entry) return;
|
||||
// Der Werkzeugname kommt aus dem Server-Zustand, nicht aus der Antwort:
|
||||
// sonst könnte eine Freigabe für ein Werkzeug ein anderes freischalten.
|
||||
if (msg.decision === "always") alwaysAllowed.add(entry.name);
|
||||
entry.decide(msg.decision === "allow" || msg.decision === "always");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "abort") {
|
||||
activeAbort?.abort();
|
||||
for (const controller of activeAborts) controller.abort();
|
||||
denyAllConfirms();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== "chat") return;
|
||||
|
||||
if (!chatLimiter("chat")) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", error: "Zu viele Anfragen — kurz warten." }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = String(msg.sessionId ?? "");
|
||||
const content = String(msg.content ?? "").trim();
|
||||
|
||||
|
|
@ -423,7 +498,8 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
|
||||
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
|
||||
|
||||
activeAbort = new AbortController();
|
||||
const abort = new AbortController();
|
||||
activeAborts.add(abort);
|
||||
let full = "";
|
||||
let thinking = "";
|
||||
const callbacks = {
|
||||
|
|
@ -463,6 +539,9 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }),
|
||||
);
|
||||
},
|
||||
onToolConfirm(name: string, args: Record<string, unknown>) {
|
||||
return confirmTool(assistantRow.id, name, args);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -479,10 +558,10 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
},
|
||||
apiKey,
|
||||
callbacks,
|
||||
activeAbort.signal,
|
||||
abort.signal,
|
||||
);
|
||||
} else {
|
||||
await streamChat(history, system, opts, callbacks, activeAbort.signal);
|
||||
await streamChat(history, system, opts, callbacks, abort.signal);
|
||||
}
|
||||
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||
mem.appendEvent(session.id, "message", {
|
||||
|
|
@ -493,7 +572,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
JSON.stringify({
|
||||
type: "done",
|
||||
messageId: assistantRow.id,
|
||||
aborted: activeAbort.signal.aborted,
|
||||
aborted: abort.signal.aborted,
|
||||
}),
|
||||
);
|
||||
broadcast({ type: "sessions-changed" });
|
||||
|
|
@ -507,7 +586,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
}
|
||||
} catch (err) {
|
||||
const aborted =
|
||||
activeAbort.signal.aborted ||
|
||||
abort.signal.aborted ||
|
||||
(err instanceof Error && err.name === "AbortError");
|
||||
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||
if (aborted) {
|
||||
|
|
@ -522,7 +601,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
);
|
||||
}
|
||||
} finally {
|
||||
activeAbort = null;
|
||||
activeAborts.delete(abort);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ export interface StreamCallbacks {
|
|||
onToolCall?(name: string, args: Record<string, unknown>): void;
|
||||
/** Werkzeug ist fertig. */
|
||||
onToolResult?(name: string, ok: boolean, durationMs: number): void;
|
||||
/**
|
||||
* Holt die Freigabe des Nutzers für ein bestätigungspflichtiges Werkzeug.
|
||||
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
|
||||
*/
|
||||
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
|
||||
|
|
@ -200,7 +205,13 @@ export async function streamChat(
|
|||
|
||||
for (const call of toolCalls) {
|
||||
cb.onToolCall?.(call.name, call.arguments);
|
||||
const result = await runTool(call, { signal, sessionId: opts.sessionId });
|
||||
const result = await runTool(call, {
|
||||
signal,
|
||||
sessionId: opts.sessionId,
|
||||
confirm: cb.onToolConfirm
|
||||
? (c) => cb.onToolConfirm!(c.name, c.arguments)
|
||||
: undefined,
|
||||
});
|
||||
cb.onToolResult?.(result.name, result.ok, result.durationMs);
|
||||
messages.push({ role: "tool", tool_name: result.name, content: result.content });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ interface Bucket {
|
|||
resetAt: number;
|
||||
}
|
||||
|
||||
/** Ab dieser Größe wird aufgeräumt — reicht für jede realistische Nutzung. */
|
||||
const MAX_BUCKETS = 1000;
|
||||
|
||||
/**
|
||||
* Einfacher In-Memory-Zähler pro Zeitfenster. Bremst teure Endpunkte
|
||||
* (Whisper läuft bis zu 180 s) gegen versehentliche oder böswillige Fluten.
|
||||
|
|
@ -46,8 +49,16 @@ interface Bucket {
|
|||
export function createRateLimiter(limit: number, windowMs: number) {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Abgelaufene Zähler wegräumen, damit die Map nicht unbegrenzt wächst. */
|
||||
function sweep(now: number) {
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now >= bucket.resetAt) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return function allow(key: string): boolean {
|
||||
const now = Date.now();
|
||||
if (buckets.size > MAX_BUCKETS) sweep(now);
|
||||
const bucket = buckets.get(key);
|
||||
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ export function toolNames(): string[] {
|
|||
return REGISTRY.map((t) => t.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitlimit für das Ergebnis. Der `signal` unten bricht das Werkzeug zusätzlich
|
||||
* ab — beides zusammen, weil ein Werkzeug den Signal auch ignorieren kann und
|
||||
* die Antwort dann trotzdem nicht ewig hängen darf.
|
||||
*/
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
|
|
@ -74,9 +79,26 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
|
|||
};
|
||||
}
|
||||
|
||||
if (tool.requiresConfirmation && !(await isApproved(call, ctx))) {
|
||||
return {
|
||||
name: tool.name,
|
||||
content: JSON.stringify({
|
||||
error:
|
||||
"Vom Nutzer abgelehnt. Nicht erneut aufrufen — ohne dieses Werkzeug " +
|
||||
"weitermachen und sagen, was dadurch fehlt.",
|
||||
}),
|
||||
ok: false,
|
||||
durationMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Zeitlimit und Nutzer-Abbruch als ein Signal, das an das Werkzeug geht:
|
||||
// damit endet auch ein laufender Netzwerkabruf, statt weiterzulaufen.
|
||||
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(TOOL_TIMEOUT_MS)]);
|
||||
|
||||
try {
|
||||
const value = await withTimeout(
|
||||
tool.run(call.arguments, ctx),
|
||||
tool.run(call.arguments, { ...ctx, signal }),
|
||||
TOOL_TIMEOUT_MS,
|
||||
tool.name,
|
||||
);
|
||||
|
|
@ -98,3 +120,17 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freigabe für ein bestätigungspflichtiges Werkzeug. Ohne Rückkanal (Skripte,
|
||||
* Tests) gilt "abgelehnt" — die Bestätigung soll sich nicht dadurch umgehen
|
||||
* lassen, dass niemand zum Fragen da ist.
|
||||
*/
|
||||
async function isApproved(call: ToolCall, ctx: ToolContext): Promise<boolean> {
|
||||
if (!ctx.confirm) return false;
|
||||
try {
|
||||
return await ctx.confirm(call);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { Tool } from "./types.js";
|
|||
export const rememberTool: Tool = {
|
||||
name: "remember",
|
||||
description:
|
||||
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte.",
|
||||
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte. Der Nutzer muss jeden Aufruf freigeben.",
|
||||
requiresConfirmation: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
|
|
@ -27,6 +27,10 @@ export const rememberTool: Tool = {
|
|||
const text = String(args.text ?? "").trim();
|
||||
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt");
|
||||
if (text.length < 6) throw new ToolError("Text ist zu kurz, um ihn zu merken");
|
||||
// Bewusst ungepinnt: ein vom Modell gesetzter Anker soll dem normalen
|
||||
// Verfall unterliegen. Gepinnt wird nur, was der Nutzer im
|
||||
// Gedächtnis-Panel selbst mit ★ markiert — sonst überlebt ein einmal
|
||||
// untergeschobener "Fakt" jede Traumphase und jede Rekonstruktion.
|
||||
const result = upsertAnchor(ctx.sessionId, {
|
||||
text,
|
||||
kind: ANCHOR_KINDS.includes(args.kind as AnchorKind)
|
||||
|
|
@ -34,7 +38,6 @@ export const rememberTool: Tool = {
|
|||
: "fact",
|
||||
importance: 0.9,
|
||||
origin: "model",
|
||||
pinned: true,
|
||||
});
|
||||
return { stored: result, text };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export interface ToolContext {
|
|||
signal: AbortSignal;
|
||||
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Holt die Freigabe des Nutzers für ein Werkzeug mit Außenwirkung.
|
||||
* Fehlt der Rückkanal, werden bestätigungspflichtige Werkzeuge abgelehnt —
|
||||
* lieber nicht ausführen als ungefragt.
|
||||
*/
|
||||
confirm?(call: ToolCall): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
|
|
@ -17,9 +23,9 @@ export interface Tool {
|
|||
description: string;
|
||||
parameters: ToolSchema;
|
||||
/**
|
||||
* Kennzeichnet Werkzeuge mit Außenwirkung (schreibend, Netzwerk, Server).
|
||||
* Bisher gibt es nur lesende Werkzeuge; das Feld existiert, damit die
|
||||
* Bestätigungspflicht später nicht nachträglich eingezogen werden muss.
|
||||
* Kennzeichnet Werkzeuge mit Außenwirkung (Netzwerk, dauerhafter Speicher).
|
||||
* `runTool` fragt vor der Ausführung über `ToolContext.confirm` beim Nutzer
|
||||
* nach und lehnt ab, wenn keine Freigabe kommt.
|
||||
*/
|
||||
requiresConfirmation?: boolean;
|
||||
/** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import dns from "node:dns";
|
||||
import { request as httpRequest, type IncomingMessage } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { isIP, type LookupFunction } from "node:net";
|
||||
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { Defuddle } from "defuddle/node";
|
||||
import { ToolError } from "./types.js";
|
||||
|
|
@ -9,6 +12,8 @@ const TIMEOUT_MS = 15_000;
|
|||
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_CONTENT_CHARS = 25_000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const ALLOWED_TYPES =
|
||||
/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 agenttwo-readweb/1.0";
|
||||
|
||||
|
|
@ -39,23 +44,53 @@ function isPrivateIP(ip: string): boolean {
|
|||
first.startsWith("fe9") || first.startsWith("fea") || first.startsWith("feb");
|
||||
}
|
||||
|
||||
/** `new URL().hostname` liefert IPv6-Literale in Klammern: [::1] -> ::1. */
|
||||
function bareHost(hostname: string): string {
|
||||
return hostname.startsWith("[") && hostname.endsWith("]")
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-Schutz: Der Server löst den Host selbst auf und weist private Bereiche
|
||||
* ab. Ohne das könnte das Modell http://localhost:8788/api/sessions lesen —
|
||||
* der Origin-Check schützt nicht vor server-eigenem fetch.
|
||||
* DNS-Auflösung, die private Adressen ablehnt — eingehängt als `lookup` der
|
||||
* HTTP-Verbindung.
|
||||
*
|
||||
* Entscheidend ist, dass genau diese Auflösung auch verbunden wird. Ein
|
||||
* getrennter Vorab-Check (wie ihn `fetch` erzwingt, das selbst noch einmal
|
||||
* auflöst) ließe DNS-Rebinding zu: öffentlich bei der Prüfung, 127.0.0.1 beim
|
||||
* Verbinden.
|
||||
*/
|
||||
const guardedLookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, options, (err, address, family) => {
|
||||
if (err) return callback(err, "", 0);
|
||||
const addresses = Array.isArray(address) ? address : [{ address, family }];
|
||||
for (const a of addresses) {
|
||||
if (isPrivateIP(a.address)) {
|
||||
return callback(new ToolError("Zugriff auf private Adressen ist gesperrt"), "", 0);
|
||||
}
|
||||
}
|
||||
callback(null, address as string, family);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Vorab-Prüfung, rein für die Fehlermeldung: so bekommt das Modell "private
|
||||
* Adresse gesperrt" statt eines generischen Verbindungsfehlers. Die
|
||||
* verbindliche Grenze ist `guardedLookup`.
|
||||
*/
|
||||
async function assertPublicHost(hostname: string): Promise<void> {
|
||||
if (isIP(hostname)) {
|
||||
if (isPrivateIP(hostname)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
|
||||
const host = bareHost(hostname);
|
||||
if (isIP(host)) {
|
||||
if (isPrivateIP(host)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
|
||||
return;
|
||||
}
|
||||
let addrs: { address: string }[];
|
||||
try {
|
||||
addrs = await lookup(hostname, { all: true, verbatim: true });
|
||||
addrs = await dns.promises.lookup(host, { all: true, verbatim: true });
|
||||
} catch {
|
||||
throw new ToolError(`Host nicht auflösbar: ${hostname}`);
|
||||
throw new ToolError(`Host nicht auflösbar: ${host}`);
|
||||
}
|
||||
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${hostname}`);
|
||||
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${host}`);
|
||||
for (const a of addrs) {
|
||||
if (isPrivateIP(a.address)) {
|
||||
throw new ToolError("Zugriff auf private Adressen ist gesperrt");
|
||||
|
|
@ -76,49 +111,93 @@ function assertHttpUrl(raw: string): URL {
|
|||
return url;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: string }> {
|
||||
let url = assertHttpUrl(rawUrl).toString();
|
||||
/** Ein GET mit gepinnter Auflösung. Weiterleitungen bleiben Sache des Aufrufers. */
|
||||
function send(url: URL, signal: AbortSignal): Promise<IncomingMessage> {
|
||||
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
lookup: guardedLookup,
|
||||
signal,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: "text/html, text/plain, application/xhtml+xml",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
},
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
req.on("timeout", () => req.destroy(new ToolError("Zeitlimit beim Abruf überschritten")));
|
||||
req.on("error", (err) => {
|
||||
if (err instanceof ToolError) return reject(err);
|
||||
if (signal.aborted) return reject(new ToolError("Abruf abgebrochen"));
|
||||
reject(new ToolError(`Abruf fehlgeschlagen: ${url.host}`));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Antwortkörper bis MAX_HTML_BYTES lesen, komprimierte Antworten auspacken. */
|
||||
async function readCapped(res: IncomingMessage): Promise<string> {
|
||||
const encoding = String(res.headers["content-encoding"] ?? "").toLowerCase();
|
||||
const stream =
|
||||
encoding === "gzip" ? res.pipe(createGunzip())
|
||||
: encoding === "deflate" ? res.pipe(createInflate())
|
||||
: encoding === "br" ? res.pipe(createBrotliDecompress())
|
||||
: res;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let html = "";
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
bytes += chunk.byteLength;
|
||||
html += decoder.decode(chunk, { stream: true });
|
||||
if (bytes > MAX_HTML_BYTES) break;
|
||||
}
|
||||
} catch {
|
||||
// Abbruch mitten im Strom: was schon da ist, reicht dem Extraktor meist.
|
||||
if (!html) throw new ToolError("Antwort konnte nicht gelesen werden");
|
||||
} finally {
|
||||
res.destroy();
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(
|
||||
rawUrl: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ url: string; body: string }> {
|
||||
let url = assertHttpUrl(rawUrl);
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
await assertPublicHost(new URL(url).hostname);
|
||||
await assertPublicHost(url.hostname);
|
||||
|
||||
const res = await fetch(url, {
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": USER_AGENT, Accept: "text/html, text/plain, application/xhtml+xml" },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
const res = await send(url, signal);
|
||||
const status = res.statusCode ?? 0;
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) break;
|
||||
url = new URL(location, url).toString();
|
||||
assertHttpUrl(url);
|
||||
if (status >= 300 && status < 400) {
|
||||
const location = res.headers.location;
|
||||
res.destroy();
|
||||
if (!location) throw new ToolError(`Weiterleitung ohne Ziel (HTTP ${status})`);
|
||||
url = assertHttpUrl(new URL(location, url).toString());
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) throw new ToolError(`HTTP ${res.status} für ${url}`);
|
||||
if (status < 200 || status >= 300) {
|
||||
res.destroy();
|
||||
throw new ToolError(`HTTP ${status} für ${url}`);
|
||||
}
|
||||
|
||||
const type = (res.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (!/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/.test(type)) {
|
||||
const type = String(res.headers["content-type"] ?? "").toLowerCase();
|
||||
if (!ALLOWED_TYPES.test(type)) {
|
||||
res.destroy();
|
||||
throw new ToolError(`Nicht unterstützter Inhaltstyp: ${type || "unbekannt"}`);
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new ToolError("Leere Antwort");
|
||||
const decoder = new TextDecoder();
|
||||
let html = "";
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_HTML_BYTES) {
|
||||
void reader.cancel();
|
||||
html += decoder.decode(value, { stream: true });
|
||||
break;
|
||||
}
|
||||
html += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return { url, body: html };
|
||||
return { url: url.toString(), body: await readCapped(res) };
|
||||
}
|
||||
throw new ToolError(`Zu viele Weiterleitungen (> ${MAX_REDIRECTS})`);
|
||||
}
|
||||
|
|
@ -126,7 +205,10 @@ async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: str
|
|||
export const readWebpageTool: Tool = {
|
||||
name: "read_webpage",
|
||||
description:
|
||||
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen.",
|
||||
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen. Der Nutzer muss jeden Aufruf freigeben.",
|
||||
// Der Abruf verlässt den Rechner: die URL selbst ist ein Kanal nach außen.
|
||||
// Deshalb sieht der Nutzer sie vor dem Aufruf und gibt sie frei.
|
||||
requiresConfirmation: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -134,11 +216,11 @@ export const readWebpageTool: Tool = {
|
|||
},
|
||||
required: ["url"],
|
||||
},
|
||||
async run(args) {
|
||||
async run(args, ctx) {
|
||||
const raw = String(args.url ?? "").trim();
|
||||
if (!raw) throw new ToolError("url fehlt");
|
||||
|
||||
const { url, body } = await fetchWithGuards(raw);
|
||||
const { url, body } = await fetchWithGuards(raw, ctx.signal);
|
||||
const { document } = parseHTML(body);
|
||||
const result = await Defuddle(document, url, { markdown: true });
|
||||
|
||||
|
|
|
|||
74
web/dist/assets/index-CQxQcFyH.js
vendored
74
web/dist/assets/index-CQxQcFyH.js
vendored
File diff suppressed because one or more lines are too long
10
web/dist/assets/index-CXQcDbVB.css
vendored
10
web/dist/assets/index-CXQcDbVB.css
vendored
File diff suppressed because one or more lines are too long
7
web/dist/favicon.svg
vendored
7
web/dist/favicon.svg
vendored
|
|
@ -1,7 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agenttwo-tools">
|
||||
<rect width="32" height="32" rx="7" fill="#0a0f0c"/>
|
||||
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#1e2c23" stroke-width="1.5"/>
|
||||
<path d="M8 10.5 L13.5 16 L8 21.5" fill="none" stroke="#3ddc84" stroke-width="3.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="16.5" y="19" width="8.5" height="3" rx="1.5" fill="#ffb454"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 490 B |
15
web/dist/index.html
vendored
15
web/dist/index.html
vendored
|
|
@ -1,15 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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-CQxQcFyH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CXQcDbVB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,6 +5,7 @@ import { Sidebar } from "./components/Sidebar";
|
|||
import { ChatMessage } from "./components/ChatMessage";
|
||||
import { Composer } from "./components/Composer";
|
||||
import { MemoryPanel } from "./components/MemoryPanel";
|
||||
import { ToolConfirm } from "./components/ToolConfirm";
|
||||
import type { OpenRouterModel, OllamaModel } from "./types";
|
||||
|
||||
const VOICE_KEY = "oxagenttwo.voiceMode";
|
||||
|
|
@ -434,6 +435,10 @@ export default function App() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{chat.toolConfirm && (
|
||||
<ToolConfirm request={chat.toolConfirm} onDecide={chat.decideToolConfirm} />
|
||||
)}
|
||||
|
||||
{voice.error && (
|
||||
<div className="voice-error">
|
||||
{voice.error}
|
||||
|
|
|
|||
71
web/src/components/ToolConfirm.tsx
Normal file
71
web/src/components/ToolConfirm.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { ToolConfirmRequest, ToolDecision } from "../types";
|
||||
|
||||
/** Kurzer Satz, was dieser Aufruf tatsächlich tut — pro Werkzeug. */
|
||||
const WHAT_HAPPENS: Record<string, string> = {
|
||||
read_webpage: "Diese Adresse wird von deinem Rechner abgerufen — inklusive allem, was in der URL steht.",
|
||||
remember: "Dieser Punkt landet dauerhaft im Gedächtnis und geht künftig in jeden Chat mit ein.",
|
||||
};
|
||||
|
||||
/** Argumente als lesbare Zeilen; unlesbares JSON fällt auf den Rohtext zurück. */
|
||||
function argLines(args: string): [string, string][] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
typeof v === "string" ? v : JSON.stringify(v),
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
/* unten als Rohtext */
|
||||
}
|
||||
return [["", args]];
|
||||
}
|
||||
|
||||
export function ToolConfirm({
|
||||
request,
|
||||
onDecide,
|
||||
}: {
|
||||
request: ToolConfirmRequest;
|
||||
onDecide: (id: string, decision: ToolDecision) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="tool-confirm" role="alertdialog" aria-label="Werkzeug freigeben">
|
||||
<div className="tool-confirm-head">
|
||||
<span className="tool-confirm-mark">⚠</span>
|
||||
<span>
|
||||
Das Modell möchte <code>{request.name}</code> ausführen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="tool-confirm-args">
|
||||
{argLines(request.args).map(([key, value], i) => (
|
||||
<div className="tool-confirm-arg" key={`${key}-${i}`}>
|
||||
{key && <dt>{key}</dt>}
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{WHAT_HAPPENS[request.name] && (
|
||||
<p className="tool-confirm-hint">{WHAT_HAPPENS[request.name]}</p>
|
||||
)}
|
||||
|
||||
<div className="tool-confirm-actions">
|
||||
<button className="btn-decide deny" onClick={() => onDecide(request.id, "deny")}>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button className="btn-decide allow" onClick={() => onDecide(request.id, "allow")}>
|
||||
Einmal zulassen
|
||||
</button>
|
||||
<button
|
||||
className="btn-decide always"
|
||||
title="Gilt für dieses Werkzeug, bis die Seite neu geladen wird"
|
||||
onClick={() => onDecide(request.id, "always")}
|
||||
>
|
||||
Immer zulassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -896,3 +896,108 @@ body {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Werkzeug-Freigabe --- */
|
||||
.tool-confirm {
|
||||
margin: 0 16px 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--accent-warm);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 180, 84, 0.07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-confirm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tool-confirm-mark {
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
|
||||
.tool-confirm-head code {
|
||||
color: var(--accent-warm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-confirm-args {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.tool-confirm-arg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-confirm-arg dt {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tool-confirm-arg dd {
|
||||
flex: 1 1 auto;
|
||||
/* Umbrechen statt abschneiden: eine gekürzte URL wäre wertlos zum Prüfen. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tool-confirm-hint {
|
||||
font-size: 0.82em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tool-confirm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-decide {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-decide:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn-decide.allow {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-decide.allow:hover {
|
||||
background: rgba(61, 220, 132, 0.1);
|
||||
}
|
||||
|
||||
.btn-decide.always {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn-decide.deny:hover {
|
||||
border-color: #f85149;
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.btn-decide:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,20 @@ export interface ToolEvent {
|
|||
durationMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rückfrage des Servers, bevor ein Werkzeug mit Außenwirkung läuft.
|
||||
* `args` ist das vollständige JSON — bei read_webpage steckt darin die URL,
|
||||
* die der Rechner sonst ungefragt abrufen würde.
|
||||
*/
|
||||
export interface ToolConfirmRequest {
|
||||
id: string;
|
||||
messageId: string;
|
||||
name: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export type ToolDecision = "allow" | "always" | "deny";
|
||||
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
think: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatSocket } from "./socket";
|
||||
import type { ChatOptions, Message, Session , ToolEvent } from "./types";
|
||||
import type {
|
||||
ChatOptions,
|
||||
Message,
|
||||
Session,
|
||||
ToolConfirmRequest,
|
||||
ToolDecision,
|
||||
ToolEvent,
|
||||
} from "./types";
|
||||
|
||||
export type ConnStatus = "connecting" | "open" | "closed";
|
||||
export interface ModelInfo {
|
||||
|
|
@ -46,6 +53,9 @@ export function useChat() {
|
|||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [toolEvents, setToolEvents] = useState<Record<string, ToolEvent[]>>({});
|
||||
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
|
||||
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
|
||||
const [toolConfirms, setToolConfirms] = useState<ToolConfirmRequest[]>([]);
|
||||
const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
|
||||
const [systemPrompt, setSystemPromptState] = useState(
|
||||
() => localStorage.getItem(SYSTEM_KEY) ?? "",
|
||||
|
|
@ -112,9 +122,23 @@ export function useChat() {
|
|||
);
|
||||
return { ...prev, [data.messageId as string]: updated };
|
||||
});
|
||||
} else if (t === "tool-confirm") {
|
||||
setToolConfirms((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: data.id as string,
|
||||
messageId: data.messageId as string,
|
||||
name: data.name as string,
|
||||
args: data.args as string,
|
||||
},
|
||||
]);
|
||||
} else if (t === "done" || t === "error") {
|
||||
streamingRef.current = false;
|
||||
setStreaming(false);
|
||||
// Der Server hat jede offene Rückfrage bereits selbst entschieden.
|
||||
setToolConfirms((prev) =>
|
||||
prev.filter((c) => c.messageId !== (data.messageId as string)),
|
||||
);
|
||||
} else if (t === "sessions-changed" || t === "session-deleted") {
|
||||
void refreshSessions();
|
||||
}
|
||||
|
|
@ -199,6 +223,12 @@ export function useChat() {
|
|||
|
||||
const abort = useCallback(() => {
|
||||
socketRef.current?.send({ type: "abort" });
|
||||
setToolConfirms([]);
|
||||
}, []);
|
||||
|
||||
const decideToolConfirm = useCallback((id: string, decision: ToolDecision) => {
|
||||
socketRef.current?.send({ type: "tool-confirm-reply", id, decision });
|
||||
setToolConfirms((prev) => prev.filter((c) => c.id !== id));
|
||||
}, []);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
|
|
@ -226,6 +256,8 @@ export function useChat() {
|
|||
messages,
|
||||
streaming,
|
||||
toolEvents,
|
||||
toolConfirm: toolConfirms[0] ?? null,
|
||||
decideToolConfirm,
|
||||
sendMessage,
|
||||
abort,
|
||||
newSession,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue