feat: Werkzeug-Bestätigung, DNS-Pinning, Statistik-Leiste, Produktionsbetrieb (#1)

Ergebnis eines Security-Reviews des Werkzeug-Pfads plus die daraus
hervorgegangenen Verbesserungen.

Sicherheit:
- runTool wertet requiresConfirmation aus; Rückfrage über den WebSocket mit
  vollständigen Argumenten, Antwort allow/always/deny. Ohne Rückkanal gilt
  abgelehnt. read_webpage und remember sind bestätigungspflichtig
- remember pinnt nicht mehr automatisch, Modell-Anker verfallen normal
- SSRF-Guard mit gepinnter DNS-Auflösung (lookup-Hook statt fetch): geprüft
  wird genau die Adresse, die auch verbunden wird — schließt DNS-Rebinding
- ctx.signal kombiniert Abbruch und Zeitlimit bis in den Netzwerkabruf
- activeAborts als Set, Chat-Rate-Limit je Verbindung, Bucket-Cleanup

Statistik-Leiste über dem Composer: Tokens, tok/s, TTFT, Gesamtzeit und
Kontext-Füllstand, dazu die Session-Summe. Zahlen sind gemessen — bei Ollama
aus dem Abschluss-Chunk, bei OpenRouter aus dem usage-Block. Der Füllstand
rechnet gegen das tatsächlich genutzte Fenster aus /api/ps, nicht gegen die
deklarierte Länge des Modells.

Produktionsbetrieb: der Server liefert web/dist jetzt mit aus, npm start
genügt. /api und /ws behalten Vorrang.

README überarbeitet, Fork-Bezug entfernt, zerbrochene Konfigurationstabelle
repariert, API-Referenz ergänzt.

Nicht umgesetzt: Auth-/Origin-Härtung — der Server läuft bewusst lokal.
This commit is contained in:
Jeuner 2026-08-28 15:04:55 +02:00 committed by GitHub
parent 878acd9933
commit 8a489dfc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1495 additions and 347 deletions

3
.gitignore vendored
View file

@ -6,3 +6,6 @@ data.sqlite*
# Piper-Stimmmodell (109 MB) — siehe README, wird lokal heruntergeladen # Piper-Stimmmodell (109 MB) — siehe README, wird lokal heruntergeladen
server/voices/*.onnx server/voices/*.onnx
# Build-Artefakt, wird aus web/src erzeugt
web/dist/

395
README.md
View file

@ -1,26 +1,34 @@
# agenttwo-tools # agenttwo-tools
> Fork von [agenttwo](https://github.com/Jeuners/agenttwo). Die Basis bleibt dort Sprachfähige Chat-Oberfläche für lokale und Cloud-Sprachmodelle. Läuft
> unverändert; hier kommen Vision und Werkzeuge (Tool-Calling) dazu. Läuft auf eigenen Ports vollständig auf dem eigenen Rechner: Modell über [Ollama](https://ollama.com),
> (Backend 8788, Frontend 5174), damit beide Projekte parallel laufen können. Spracheingabe über [whisper.cpp](https://github.com/ggml-org/whisper.cpp),
> Fixes aus der Basis lassen sich per `git cherry-pick` aus dem Remote Sprachausgabe über [Piper](https://github.com/rhasspy/piper). Der Weg nach
> `upstream` übernehmen. außen ist optional — [OpenRouter](https://openrouter.ai) lässt sich pro Chat
zuschalten.
Voice-Chat-Oberfläche für lokale und Cloud-LLMs: lokales Qwen3 über - **Werkzeuge** — das Modell rechnet, liest Projektdateien, ruft Websites ab
[Ollama](https://ollama.com), optionaler Fallback auf und führt ein eigenes Gedächtnis. Werkzeuge mit Außenwirkung fragen vorher
[OpenRouter](https://openrouter.ai), Spracheingabe via nach.
[whisper.cpp](https://github.com/ggml-org/whisper.cpp) und deutsche - **Anhänge** — Bilder, Textdateien und PDFs, per Button, ⌘V oder Drag & Drop.
Sprachausgabe via [Piper](https://github.com/rhasspy/piper) (Stimme: Thorsten). - **Gedächtnis** — Append-Only-Log plus destillierte Ankerpunkte, deterministisch
aus dem Log rekonstruierbar.
- **Sprache** — Diktat und freihändiger Dialog, deutsche Stimme, alles lokal.
- **Messwerte** — Tokens, Durchsatz, Latenz und Kontext-Füllstand pro Antwort.
## Aufbau ## Aufbau
npm-Workspace mit zwei Paketen: npm-Workspace mit zwei Paketen:
| Pfad | Inhalt | | Pfad | Inhalt |
|-----------|------------------------------------------------------------------------| |-----------|-------------------------------------------------------------------------|
| `server/` | Fastify + WebSocket-Backend, Ollama-/OpenRouter-Bridge, STT/TTS, SQLite | | `server/` | Fastify + WebSocket-Backend, Ollama-/OpenRouter-Bridge, STT/TTS, SQLite |
| `web/` | React 18 + Vite Frontend, Markdown-Rendering, Voice-Recording | | `web/` | React 18 + Vite Frontend, Markdown-Rendering, Voice-Recording |
Der Chat läuft über einen WebSocket (`/ws`), alles andere über REST. Chats,
Nachrichten, Anhänge und Gedächtnis liegen in `server/data.sqlite`. Im
Produktionsbetrieb liefert der Server das gebaute Frontend gleich mit aus.
## Voraussetzungen ## Voraussetzungen
- **macOS** mit [Homebrew](https://brew.sh) - **macOS** mit [Homebrew](https://brew.sh)
@ -28,22 +36,19 @@ npm-Workspace mit zwei Paketen:
das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start
eine `ExperimentalWarning`; ab Node 24 ist das Modul stabil. eine `ExperimentalWarning`; ab Node 24 ist das Modul stabil.
- [Ollama](https://ollama.com) mit einem Qwen3-Modell (`ollama pull qwen3.5`) - [Ollama](https://ollama.com) mit einem Qwen3-Modell (`ollama pull qwen3.5`)
- `ffmpeg` im `PATH` - `ffmpeg` und `whisper-cli` (whisper.cpp) im `PATH`, inklusive Whisper-Modell
- `whisper-cli` im `PATH` (whisper.cpp) inklusive Modell
- Piper als **Python-Modul** — der Server ruft `python3 -m piper` auf, - Piper als **Python-Modul** — der Server ruft `python3 -m piper` auf,
nicht das gleichnamige Homebrew-Binary nicht das gleichnamige Homebrew-Binary
**RAM-Hinweis (Apple Silicon):** Qwen3.5 belegt ~7 GB im Speicher — mit **RAM (Apple Silicon):** qwen3.5 belegt ~7 GB — mit weniger als 16 GB RAM wird
weniger als 16 GB RAM wird es eng. Dann lieber das kleinere es eng. Dann das kleinere [`qwen3:8b`](https://ollama.com/library/qwen3)
[`qwen3:8b`](https://ollama.com/library/qwen3) (`ollama pull qwen3:8b`, (`ollama pull qwen3:8b`, ~5 GB) nehmen und in den Einstellungen unter
~5 GB) laden und in den Einstellungen unter „Lokales Modell“ wählen bzw. „Lokales Modell“ wählen oder `MODEL=qwen3:8b` in die `.env` schreiben.
`MODEL=qwen3:8b` in die `.env` schreiben.
## Schnellstart (Mac) ## Schnellstart
Ein Befehl von null bis zur lauffähigen App — das Skript prüft alle `setup.sh` prüft alle Abhängigkeiten und bietet fehlende zur Installation an —
Abhängigkeiten und bietet fehlende (Homebrew, Node, ffmpeg, whisper, Piper, Homebrew, Node, ffmpeg, whisper.cpp, Piper, Ollama, Modell und Sprachdateien:
Ollama, Modell, Sprachdateien) zur Installation an:
```bash ```bash
git clone https://github.com/Jeuners/agenttwo-tools.git git clone https://github.com/Jeuners/agenttwo-tools.git
@ -52,10 +57,13 @@ cd agenttwo-tools
npm run dev # → http://localhost:5174 npm run dev # → http://localhost:5174
``` ```
Nur prüfen ohne etwas zu ändern: `./setup.sh --check` · Ohne Nachfragen: | Aufruf | Wirkung |
`./setup.sh --yes` |----------------------|-------------------------------------------|
| `./setup.sh` | Fragt vor jeder Installation nach |
| `./setup.sh --check` | Prüft nur, ändert nichts |
| `./setup.sh --yes` | Installiert alles Fehlende ohne Rückfrage |
Unter macOS: ### Manuell
```bash ```bash
brew install ffmpeg whisper-cpp brew install ffmpeg whisper-cpp
@ -71,9 +79,8 @@ python3 -m piper --help >/dev/null 2>&1 && echo "piper OK"
node -e "require('node:sqlite')" 2>/dev/null && echo "node:sqlite OK" node -e "require('node:sqlite')" 2>/dev/null && echo "node:sqlite OK"
``` ```
### Whisper-Modell Whisper-Modell (~1,5 GB), per Default unter
`~/whisper-models/ggml-large-v3-turbo.bin` erwartet:
Wird per Default unter `~/whisper-models/ggml-large-v3-turbo.bin` erwartet:
```bash ```bash
mkdir -p ~/whisper-models mkdir -p ~/whisper-models
@ -81,10 +88,8 @@ curl -L -o ~/whisper-models/ggml-large-v3-turbo.bin \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin
``` ```
### Piper-Stimme Piper-Stimme (109 MB, wegen GitHubs Dateilimit nicht im Repo — die zugehörige
`.onnx.json` liegt bereits dort):
Die Stimmdatei ist **nicht** im Repo (109 MB, über GitHubs Dateilimit).
Einmalig herunterladen:
```bash ```bash
mkdir -p server/voices mkdir -p server/voices
@ -92,93 +97,178 @@ curl -L -o server/voices/de_DE-thorsten-high.onnx \
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/de_DE-thorsten-high.onnx https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/de_DE-thorsten-high.onnx
``` ```
Die zugehörige `de_DE-thorsten-high.onnx.json` liegt bereits im Repo.
## Konfiguration ## Konfiguration
`.env` im Projekt-Root (wird vom Server eingelesen, ist gitignored): `.env` im Projekt-Root, wird vom Server eingelesen und ist gitignored. Alle
Werte sind optional außer dem Schlüssel für den Cloud-Fallback:
```bash ```bash
OPENROUTER_API_KEY=sk-or-... OPENROUTER_API_KEY=sk-or-...
``` ```
Weitere optionale Variablen mit ihren Defaults: | Variable | Default | Zweck |
|-------------------|--------------------------------------------|------------------------------------------------------|
| `PORT` | `8788` | Port des Backends |
| `OLLAMA_URL` | `http://localhost:11434` | Adresse des Ollama-Servers |
| `MODEL` | `qwen3.5:latest` | Lokales Standardmodell |
| `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Modelldatei für die Spracherkennung |
| `WHISPER_LANG` | `de` | Sprache der Spracherkennung |
| `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` | Stimme für die Sprachausgabe |
| `TOOLS_ROOT` | Projektverzeichnis | Wurzel der Datei-Sandbox |
| `ALLOWED_ORIGINS` | (leer) | Zusätzliche Origins, siehe [Sicherheit](#sicherheit) |
| Variable | Default | `MODEL` ist nur der Startwert: In den Einstellungen lässt sich das lokale
|----------------------|--------------------------------------------------| Modell pro Chat über das Dropdown „Lokales Modell“ wechseln — die Liste kommt
| `PORT` | `8788` | live von `GET /api/ollama/models`.
| `OLLAMA_URL` | `http://localhost:11434` |
| `MODEL` | `qwen3.5:latest` |
| `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` |
| `WHISPER_LANG` | `de` |
`MODEL` ist nur der Default: In den Einstellungen lässt sich das lokale ## Anhänge
Ollama-Modell pro Chat über das Dropdown „Lokales Modell“ wechseln (die Liste
kommt live von `GET /api/ollama/models`).
| `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` |
| `ALLOWED_ORIGINS` | (leer — siehe Sicherheit) |
## Anhänge (Bilder & Dateien) Anhängen geht über den Button 📎, aus der Zwischenablage (⌘V) oder per
Drag & Drop auf die Eingabezeile. Eine Nachricht darf auch nur aus einem
Anhang bestehen.
`qwen3.5` bringt die Fähigkeit `vision` mit, deshalb versteht der Chat Bilder. **Bilder** setzen ein Modell mit der Fähigkeit `vision` voraus — qwen3.5 bringt
Anhängen geht auf drei Wegen: Button 📎 im Composer, Einfügen aus der sie mit. Sie werden zusammen mit der Nachricht gespeichert und bei Folgefragen
Zwischenablage (⌘V) oder Drag & Drop auf die Eingabezeile. Eine Nachricht darf erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren. Für
auch nur aus einem Anhang bestehen. Ollama gehen sie als `images: [base64]` raus, für OpenRouter im OpenAI-Format
als `image_url` mit data-URL.
**Bilder** werden zusammen mit der Nachricht in der SQLite-Datei abgelegt und bei **Text- und PDF-Dateien** landen als formatierter Block im Kontext und werden
Folgefragen erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren. ebenfalls gespeichert. Unterstützt sind `.txt .json .md .csv .yaml .xml .sql`
Für Ollama gehen sie als `images: [base64]` raus, für OpenRouter im und gängige Code-Endungen, dazu `.pdf` — der Text wird serverseitig mit
OpenAI-Format als `image_url` mit data-URL — der MIME-Typ wird dabei aus den [pdf-parse](https://www.npmjs.com/package/pdf-parse) extrahiert. Gescannte PDFs
Magic Bytes bestimmt. ohne Textebene werden abgewiesen, OCR gibt es nicht.
Grenzen (`server/src/images.ts`): maximal 4 Bilder pro Nachricht, je 6 MB, | Grenze | Bilder (`images.ts`) | Dateien (`files.ts`) |
nur PNG, JPEG, GIF und WebP. Der Typ wird an den Magic Bytes geprüft, nicht am |----------------------|----------------------|--------------------------------|
angegebenen Dateinamen; der WebSocket hat dafür ein Payload-Limit von 32 MB. | Anzahl pro Nachricht | 4 | 4 |
| Größe | 6 MB je Bild | 10 MB je PDF |
| Umfang im Kontext | — | 100 kB extrahierter Text |
| Typprüfung | Magic Bytes | Steuerzeichen erkennen Binäres |
**Text- und PDF-Dateien** (`server/src/files.ts`) landen als formatierter Block Der Typ wird an den Magic Bytes geprüft, nicht am Dateinamen; erlaubt sind
im Kontext und werden ebenfalls gespeichert — Rückfragen zur Datei funktionieren PNG, JPEG, GIF und WebP. Der WebSocket hat ein Payload-Limit von 32 MB. Zu
also auch in der nächsten Nachricht. Unterstützt: `.txt .json .md .csv .yaml lange PDF-Texte werden gekürzt, nicht abgelehnt.
.xml .sql` und gängige Code-Endungen, plus `.pdf` (Text wird serverseitig mit
[pdf-parse](https://www.npmjs.com/package/pdf-parse) extrahiert). Grenzen:
maximal 4 Dateien pro Nachricht, je 100 kB extrahierter Text (bei PDFs
gekürzt, nicht abgelehnt), 10 MB PDF-Größe; Binärdateien werden über
Steuerzeichen erkannt und abgelehnt. Gescannte PDFs ohne Textebene werden
abgewiesen (keine OCR).
Ob das eingestellte Modell Bilder kann, verrät: Ob das eingestellte Modell Bilder kann:
```bash ```bash
curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \ curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['capabilities'])" | python3 -c "import json,sys; print(json.load(sys.stdin)['capabilities'])"
``` ```
## Werkzeuge (Tool-Calling) ## Werkzeuge
`qwen3.5` meldet die Fähigkeit `tools`. Der Server schickt bei jeder Anfrage Der Server schickt bei jeder Anfrage eine Werkzeugliste mit. Will das Modell
eine Werkzeugliste mit; will das Modell eines benutzen, wird es ausgeführt und eines benutzen, wird es ausgeführt und das Ergebnis zurückgereicht, bis eine
das Ergebnis zurückgereicht, bis eine Antwort ohne Werkzeugwunsch entsteht Antwort ohne Werkzeugwunsch entsteht — maximal `MAX_TOOL_ROUNDS` = 5 Runden,
(maximal `MAX_TOOL_ROUNDS` = 5 Runden, je 15 s Zeitlimit). Abschalten lässt je 15 s Zeitlimit. Abschalten lässt sich das in den Einstellungen.
sich das in den Einstellungen.
| Werkzeug | Zweck | | Werkzeug | Zweck |
|---|---| |----------------|-------------------------------------------------------------|
| `get_time` | Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone | | `get_time` | Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone |
| `calculate` | Arithmetik mit eigenem Parser | | `calculate` | Arithmetik mit eigenem Parser |
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen | | `read_file` | Textdatei unterhalb der Sandbox-Wurzel lesen |
| `list_files` | Verzeichnis auflisten | | `list_files` | Verzeichnis auflisten |
| `remember` | Wichtigen Punkt als gepinnten Ankerpunkt ins Gedächtnis schreiben | | `recall` | Gedächtnis nach Ankerpunkten durchsuchen |
| `recall` | Gedächtnis (Ankerpunkte) durchsuchen | | `remember` | Ankerpunkt ins Gedächtnis schreiben — **mit Rückfrage** |
| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown | | `read_webpage` | Website laden, Hauptinhalt als Markdown**mit Rückfrage** |
Aktuelle Liste: `curl -s http://localhost:8788/api/tools` Aktuelle Liste: `curl -s http://localhost:8788/api/tools`
## Gedächtnis (Chat-Memory) ### Rückfrage vor Werkzeugen mit Außenwirkung
Das Gedächtnis hat drei Schichten: Werkzeuge mit `requiresConfirmation` laufen erst nach Freigabe. Der Server
fragt über den WebSocket an, 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 erfährt das und macht ohne weiter |
| Einmal zulassen | Nur dieser eine Aufruf |
| Immer zulassen | Dieses Werkzeug bis zum Neuladen der Seite, je Verbindung |
Ohne Antwort gilt nach 2 Minuten „abgelehnt“ — ebenso bei Verbindungsabbruch
und beim Stoppen der Antwort. Läuft ein Werkzeug ohne Rückkanal, etwa in einem
Skript, wird es abgelehnt statt ungefragt ausgeführt: die Bestätigung soll sich
nicht dadurch umgehen lassen, dass niemand zum Fragen da ist.
### Grenzen
Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem
Außenwirkung und sind deshalb bestätigungspflichtig:
- **`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 sieht der Nutzer
die vollständige URL vor dem Abruf.
- **`remember`** schreibt dauerhaft und sessionübergreifend. Vom Modell
gesetzte Anker werden **nicht** gepinnt und verfallen normal; 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.
Der Dateizugriff liegt in einer Sandbox: Jeder Pfad wird über `realpath`
aufgelöst — das löst auch Symlinks auf — und muss danach unterhalb der Wurzel
liegen. `.env`, `.git/` und Schlüsseldateien sind auch innerhalb der Wurzel
gesperrt.
`calculate` benutzt bewusst **kein** `eval` oder `new Function`: der Ausdruck
stammt aus einer Modellantwort, die von Nutzereingaben beeinflusst wird. Der
Parser in `tools/calculate.ts` kennt nur Zahlen und Grundrechenarten.
Ein Abbruch über „Stop“ beendet auch ein laufendes Werkzeug — `ctx.signal`
kombiniert Abbruch und Zeitlimit und wirkt bis in den offenen Netzwerkabruf.
Werkzeugaufrufe werden über der Antwort angezeigt. Sie leben nur im
Browser-Zustand und sind nach einem Neuladen weg, anders als Anhänge.
## Statistik-Leiste
Über dem Eingabefeld stehen die Messwerte der letzten Antwort:
```text
3.891 ↑ · 127 ↓ · 34,5 tok/s · TTFT 0,6 s · 5,2 s · 2 Runden ▬▬▬ 3.891 / 4.096 Σ 12.480 ↑ 2.143 ↓
```
| Wert | Bedeutung |
|------------|-------------------------------------------------------------|
| `↑` / `↓` | Tokens im Prompt / erzeugte Tokens |
| `tok/s` | Erzeugte Tokens durch reine Generierungszeit |
| `TTFT` | Zeit bis zum ersten sichtbaren Token, Denken zählt mit |
| Gesamtzeit | Wanduhr inklusive Werkzeuglaufzeit |
| Runden | Erst ab 2 — jede Werkzeugrunde ist ein eigener Modellaufruf |
| Balken | Prompt gegen das Kontextfenster, ab 90 % orange |
| `Σ` | Summe über den Chat, seit dem letzten Neuladen der Seite |
Die Zahlen sind **gemessen, nicht geschätzt**: bei Ollama stammen sie aus dem
Abschluss-Chunk (`prompt_eval_count`, `eval_count`, `eval_duration`), bei
OpenRouter aus dem `usage`-Block, für den `stream_options.include_usage`
gesetzt wird — dort kommen die Kosten aus der Preisliste dazu. Während des
Streamens gibt es diese Werte noch nicht; solange zählt die Leiste die
eingehenden Chunks und markiert das mit `≈`.
Der Kontextbalken rechnet gegen das **tatsächlich genutzte** Fenster, das
Ollama unter `GET /api/ps` meldet — nicht gegen die im Modell deklarierte
Länge. Das ist nicht
dasselbe: qwen3.5 deklariert 262144, geladen läuft es je nach Ollama-Default
mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %, während vorne
längst abgeschnitten wird.
## Gedächtnis
Drei Schichten:
| Schicht | Speicher | Zweck | | Schicht | Speicher | Zweck |
|---|---|---| |-------------------|--------------------|------------------------------------------------|
| Arbeitsgedächtnis | Nachrichtenfenster | Einstellbar (Default 10 Schritte), geht ans Modell | | Arbeitsgedächtnis | Nachrichtenfenster | Default 10 Schritte, geht ans Modell |
| Episodisch | `memory_events` | Append-Only-Log: nur INSERT, nie UPDATE/DELETE | | Episodisch | `memory_events` | Append-Only-Log: nur INSERT, nie UPDATE/DELETE |
| Semantisch | `anchors` | Ankerpunkte, destilliert in der Traumphase | | Semantisch | `anchors` | Ankerpunkte, destilliert in der Traumphase |
@ -186,112 +276,105 @@ Das Fenster ist in den Einstellungen per Slider einstellbar (2100 Schritte).
### Traumphase ### Traumphase
Die Traumphase konsolidiert neue Log-Einträge in Ankerpunkte — bevorzugt per Die Traumphase konsolidiert neue Log-Einträge zu Ankerpunkten — bevorzugt per
LLM-Extraktion (JSON-Format, Temperatur 0.2), mit einer Regex-Heuristik als LLM-Extraktion (JSON-Format, Temperatur 0.2), mit einer Regex-Heuristik als
Fallback. Auslöser: Fallback. Sie läuft automatisch nach 3 Minuten Inaktivität oder wenn 10
Schritte unkonsolidiert sind, manuell über 🧠 **Gedächtnis** → „Traumphase
- automatisch nach 3 Minuten Inaktivität oder wenn 10 Schritte unkonsolidiert sind jetzt“.
- manuell über 🧠 **Gedächtnis** → „Traumphase jetzt"
Ankerpunkte haben eine Wichtigkeit (01), eine Art (`fact`, `decision`, Ankerpunkte haben eine Wichtigkeit (01), eine Art (`fact`, `decision`,
`preference`, `entity`, `open_question`) und einen Ursprung (`dream`, `model`, `preference`, `entity`, `open_question`) und einen Ursprung (`dream`, `model`,
`heuristic`, `test`). Ähnliche Anker werden zusammengeführt (Wort-Ähnlichkeit `heuristic`, `test`). Ähnliche Anker werden zusammengeführt Wort-Ähnlichkeit
≥ 0.5, `hits` steigt). Bei jedem Traumlauf verfallen ungepinnte Anker ≥ 0.5, `hits` steigt. Bei jedem Traumlauf verfallen ungepinnte Anker
(Importance × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte (Wichtigkeit × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte
(★) bleiben dauerhaft. (★) bleiben.
Die wichtigsten Anker werden als System-Kontext eingespielt (Budget ~1200 Die wichtigsten Anker gehen als System-Kontext mit (Budget ~1200 Zeichen) —
Zeichen) — das Modell beantwortet dann Fragen aus Inhalten, die nie im das Modell beantwortet dann Fragen aus Inhalten, die nie im sichtbaren
sichtbaren Chatverlauf standen. Chatverlauf standen.
### Rekonstruktion ### Rekonstruktion
Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch
aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren" (gepinnte aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren“, gepinnte
Anker bleiben erhalten). Die Eval der Heuristik und des Dream-Parsers: Anker bleiben erhalten. Die Eval der Heuristik und des Dream-Parsers:
```bash ```bash
npm run memory:eval --workspace server npm run memory:eval --workspace server
``` ```
Endpunkte: `GET /api/sessions/:id/memory`, `POST /api/sessions/:id/dream`,
`POST /api/sessions/:id/memory/rebuild`, `PATCH|DELETE /api/anchors/:id`.
### 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.
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).
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,
sonst wird abgelehnt. Die Wurzel ist standardmäßig das Projektverzeichnis und
über `TOOLS_ROOT` einstellbar. `.env`, `.git/` und Schlüsseldateien sind auch
innerhalb der Wurzel gesperrt.
`calculate` benutzt bewusst **kein** `eval` oder `new Function`: der Ausdruck
stammt aus einer Modellantwort, die von Nutzereingaben beeinflusst wird. Der
Parser in `tools/calculate.ts` kennt nur Zahlen und Grundrechenarten.
Werkzeugaufrufe werden in der Oberfläche über der Antwort angezeigt. Sie leben
nur im Browser-Zustand und sind nach einem Neuladen weg — im Gegensatz zu
Bildern, die in der Datenbank landen.
## Sicherheit ## Sicherheit
Der Server hat **keine Authentifizierung** und lauscht deshalb bewusst nur auf Der Server hat **keine Authentifizierung** und lauscht deshalb bewusst nur auf
`127.0.0.1`. Das allein genügt aber nicht: Eine beliebige Webseite, die im `127.0.0.1`. Das allein genügt nicht: Eine beliebige im Browser geöffnete
Browser geöffnet ist, kann `localhost` per `fetch()` oder WebSocket erreichen. Webseite erreicht `localhost` per `fetch()` oder WebSocket. Deshalb prüfen
Deshalb prüfen sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die `Origin` gegen
`Origin` gegen eine Allowlist (`server/src/security.ts`) — Standard sind eine Allowlist (`server/src/security.ts`) — Standard sind `localhost` und
`localhost`/`127.0.0.1` auf Port 5174 und 8788. `127.0.0.1` auf Port 5174 und 8788. Läuft das Frontend woanders:
Läuft das Frontend woanders, die Origin ergänzen:
```bash ```bash
ALLOWED_ORIGINS=http://192.168.1.50:5174 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),
Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf, und Fehlerdetails Traumphase (4/min) und Chats je WebSocket-Verbindung (30/min);
landen im Server-Log statt in der HTTP-Antwort. Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf; Fehlerdetails landen
im Server-Log statt in der HTTP-Antwort.
Für externen Zugriff reicht ein Reverse-Proxy **nicht** — davor gehört eine Bewusst **nicht** abgesichert: Anfragen ohne `Origin`-Header werden
durchgelassen. Für Browser-Clients trägt die Prüfung, weil `fetch` und
Formular-POSTs immer eine Origin senden — jedes lokale Programm kommt aber
ungefragt an die API. Für den Einzelplatzbetrieb ist das so gewollt.
Für externen Zugriff reicht ein Reverse-Proxy **nicht**. Davor gehört eine
echte Authentifizierung. echte Authentifizierung.
## API
| Endpunkt | Methode | Zweck |
|------------------------------------|---------------|----------------------------------|
| `/ws` | WebSocket | Chat-Stream, Werkzeug-Rückfragen |
| `/api/health` | GET | Lebenszeichen |
| `/api/tools` | GET | Registrierte Werkzeuge |
| `/api/model?name=` | GET | Fähigkeiten eines Ollama-Modells |
| `/api/ollama/models` | GET | Lokal verfügbare Modelle |
| `/api/openrouter/models` | GET | Cloud-Modelle mit Preisen |
| `/api/sessions` | GET, POST | Chats auflisten, anlegen |
| `/api/sessions/:id` | DELETE | Chat samt Gedächtnis löschen |
| `/api/sessions/:id/messages` | GET | Verlauf eines Chats |
| `/api/sessions/:id/memory` | GET | Zustand und Ankerpunkte |
| `/api/sessions/:id/dream` | POST | Traumphase auslösen |
| `/api/sessions/:id/memory/rebuild` | POST | Aus dem Log rekonstruieren |
| `/api/anchors/:id` | PATCH, DELETE | Anker pinnen, löschen |
| `/api/stt` | POST | Audio zu Text (Whisper) |
| `/api/tts` | POST | Text zu Audio (Piper) |
## Entwicklung ## Entwicklung
```bash ```bash
npm install npm install
npm run dev # Server (:8788) und Vite-Dev-Server (:5174) parallel npm run dev # Server (:8788) und Vite-Dev-Server (:5174) parallel
npm run dev:server # nur Backend
npm run dev:web # nur Frontend
npm run typecheck # beide Workspaces
``` ```
Einzeln: Der Vite-Dev-Server leitet `/api` und `/ws` an das Backend weiter, deshalb
läuft die App unter `http://localhost:5174` aus einer Herkunft.
```bash
npm run dev:server
npm run dev:web
```
Typecheck über beide Workspaces:
```bash
npm run typecheck
```
## Produktion ## Produktion
```bash ```bash
npm start # baut das Frontend und startet den Server npm start # baut web/dist und startet den Server auf :8788
``` ```
Der Server bindet nur an `127.0.0.1`. Zum Aussetzen ins Netz siehe Der Server liefert das gebaute Frontend mit aus — die App läuft dann
vollständig unter `http://localhost:8788`, ohne zweiten Prozess. Fehlt
`web/dist`, stellt er nur die API bereit und sagt das beim Start; das ist der
Normalfall im Entwicklungsbetrieb, wo Vite das Frontend übernimmt.
`/api` und `/ws` behalten Vorrang vor den statischen Dateien. Unbekannte
`/api`-Pfade antworten mit JSON-404, alles andere bekommt `index.html`.
Der Server bindet ausschließlich an `127.0.0.1`; zum Aussetzen ins Netz siehe
[Sicherheit](#sicherheit). [Sicherheit](#sicherheit).

267
package-lock.json generated
View file

@ -739,6 +739,22 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fastify/accept-negotiator": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz",
"integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
@ -870,6 +886,70 @@
"ipaddr.js": "^2.1.0" "ipaddr.js": "^2.1.0"
} }
}, },
"node_modules/@fastify/send": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz",
"integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz",
"integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@fastify/static/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -920,6 +1000,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@mixmark-io/domino": { "node_modules/@mixmark-io/domino": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
@ -1824,6 +1913,15 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.19", "version": "2.11.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
@ -1850,6 +1948,18 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@ -1974,6 +2084,19 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -2087,6 +2210,15 @@
"turndown": "^7.2.0" "turndown": "^7.2.0"
} }
}, },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dequal": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -2247,6 +2379,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@ -2463,6 +2601,23 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/hast-util-is-element": { "node_modules/hast-util-is-element": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
@ -2655,6 +2810,32 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": { "node_modules/inline-style-parser": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@ -3753,6 +3934,42 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": { "node_modules/mnemonist": {
"version": "0.40.0", "version": "0.40.0",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
@ -3853,6 +4070,31 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/pdf-parse": { "node_modules/pdf-parse": {
"version": "2.4.5", "version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
@ -4313,6 +4555,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sonic-boom": { "node_modules/sonic-boom": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -4351,6 +4599,15 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/stringify-entities": { "node_modules/stringify-entities": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -4437,6 +4694,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/trim-lines": { "node_modules/trim-lines": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@ -5282,6 +5548,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"defuddle": "^0.19.3", "defuddle": "^0.19.3",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"linkedom": "^0.18.13", "linkedom": "^0.18.13",

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"defuddle": "^0.19.3", "defuddle": "^0.19.3",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"linkedom": "^0.18.13", "linkedom": "^0.18.13",

View file

@ -1,12 +1,14 @@
import Fastify from "fastify"; import Fastify from "fastify";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import * as dbmod from "./db.js"; import * as dbmod from "./db.js";
import * as mem from "./memory.js"; import * as mem from "./memory.js";
import { streamChat, type OllamaOptions } from "./ollama.js"; import { streamChat, type ChatStats, type OllamaOptions } from "./ollama.js";
import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js"; import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
import { import {
streamOpenRouter, streamOpenRouter,
@ -35,6 +37,46 @@ const PORT = Number(process.env.PORT ?? 8788);
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434"; const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
const MODEL = process.env.MODEL ?? "qwen3.5:latest"; 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;
/**
* Effektive Kontextlänge eines geladenen Ollama-Modells.
*
* Nicht dasselbe wie die im Modell deklarierte Länge: qwen3.5 meldet 262144,
* geladen läuft es je nach Ollama-Default aber mit 4096. Für einen ehrlichen
* Füllstand zählt nur, womit das Modell tatsächlich läuft und das steht in
* /api/ps. Kurz gecacht, weil sich das nur beim Nachladen ändert.
*/
const CONTEXT_TTL_MS = 30_000;
const contextCache = new Map<string, { value: number; at: number }>();
async function effectiveContextLength(model: string): Promise<number | undefined> {
const hit = contextCache.get(model);
if (hit && Date.now() - hit.at < CONTEXT_TTL_MS) return hit.value;
try {
const res = await fetch(`${OLLAMA_URL}/api/ps`);
if (!res.ok) return hit?.value;
const data = (await res.json()) as {
models?: { name?: string; model?: string; context_length?: number }[];
};
const entry = (data.models ?? []).find(
(m) => m.name === model || m.model === model,
);
if (!entry?.context_length) return hit?.value;
contextCache.set(model, { value: entry.context_length, at: Date.now() });
return entry.context_length;
} catch {
return hit?.value;
}
}
const DREAM_IDLE_MS = 180_000; const DREAM_IDLE_MS = 180_000;
const DREAM_BATCH = 10; const DREAM_BATCH = 10;
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>(); const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -268,6 +310,25 @@ app.get("/api/ollama/models", async () => {
} }
}); });
// --- Gebautes Frontend ---
//
// Nur wenn web/dist existiert: im Entwicklungsbetrieb liefert der
// Vite-Server das Frontend aus, dann soll hier nichts danebenstehen.
// Registrierung nach den API-Routen, damit /api und /ws Vorrang behalten.
const WEB_DIST = path.join(import.meta.dirname, "..", "..", "web", "dist");
const hasBuild = existsSync(path.join(WEB_DIST, "index.html"));
if (hasBuild) {
await app.register(fastifyStatic, { root: WEB_DIST });
app.setNotFoundHandler((req, reply) => {
// API-Fehler bleiben JSON; alles andere bekommt die App.
if (req.url.startsWith("/api")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
// --- WebSocket --- // --- WebSocket ---
interface ChatOptionsPayload { interface ChatOptionsPayload {
think: boolean; think: boolean;
@ -325,7 +386,54 @@ function broadcast(data: unknown) {
} }
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => { 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) => { socket.on("message", async (raw: Buffer) => {
let msg: Record<string, unknown>; let msg: Record<string, unknown>;
@ -336,13 +444,31 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
return; 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") { if (msg.type === "abort") {
activeAbort?.abort(); for (const controller of activeAborts) controller.abort();
denyAllConfirms();
return; return;
} }
if (msg.type !== "chat") 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 sessionId = String(msg.sessionId ?? "");
const content = String(msg.content ?? "").trim(); const content = String(msg.content ?? "").trim();
@ -423,7 +549,8 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
const assistantRow = dbmod.insertMessage(session.id, "assistant", ""); const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow })); socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
activeAbort = new AbortController(); const abort = new AbortController();
activeAborts.add(abort);
let full = ""; let full = "";
let thinking = ""; let thinking = "";
const callbacks = { const callbacks = {
@ -463,6 +590,27 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }), JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }),
); );
}, },
onToolConfirm(name: string, args: Record<string, unknown>) {
return confirmTool(assistantRow.id, name, args);
},
async onStats(stats: ChatStats) {
// Nur bei Ollama kennt der Server das echte Fenster; bei OpenRouter
// steht die Kontextlänge in der Modellliste, die der Client schon hat.
const contextLength =
opts.provider === "ollama"
? await effectiveContextLength(opts.model)
: undefined;
socket.send(
JSON.stringify({
type: "stats",
messageId: assistantRow.id,
model: opts.provider === "ollama" ? opts.model : opts.openrouterModel,
provider: opts.provider,
contextLength,
...stats,
}),
);
},
}; };
try { try {
@ -479,10 +627,10 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
}, },
apiKey, apiKey,
callbacks, callbacks,
activeAbort.signal, abort.signal,
); );
} else { } 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); dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
mem.appendEvent(session.id, "message", { mem.appendEvent(session.id, "message", {
@ -493,7 +641,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
JSON.stringify({ JSON.stringify({
type: "done", type: "done",
messageId: assistantRow.id, messageId: assistantRow.id,
aborted: activeAbort.signal.aborted, aborted: abort.signal.aborted,
}), }),
); );
broadcast({ type: "sessions-changed" }); broadcast({ type: "sessions-changed" });
@ -507,7 +655,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
} }
} catch (err) { } catch (err) {
const aborted = const aborted =
activeAbort.signal.aborted || abort.signal.aborted ||
(err instanceof Error && err.name === "AbortError"); (err instanceof Error && err.name === "AbortError");
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null); dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
if (aborted) { if (aborted) {
@ -522,7 +670,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
); );
} }
} finally { } finally {
activeAbort = null; activeAborts.delete(abort);
} }
}); });
}); });
@ -549,4 +697,9 @@ server.on("upgrade", (req, socket, head) => {
app.listen({ port: PORT, host: "127.0.0.1" }, () => { app.listen({ port: PORT, host: "127.0.0.1" }, () => {
console.log(`[agenttwo-tools] Server läuft auf http://127.0.0.1:${PORT}`); console.log(`[agenttwo-tools] Server läuft auf http://127.0.0.1:${PORT}`);
console.log(
hasBuild
? "[agenttwo-tools] Frontend aus web/dist wird mit ausgeliefert"
: "[agenttwo-tools] Kein web/dist — Frontend über 'npm run dev:web' (:5174)",
);
}); });

View file

@ -16,6 +16,24 @@ export interface OllamaOptions {
sessionId?: string; sessionId?: string;
} }
/**
* Messwerte einer Antwort. Zahlen kommen aus dem Abschluss-Chunk von Ollama
* bzw. dem usage-Block von OpenRouter nicht geschätzt.
*/
export interface ChatStats {
/** Tokens im Prompt der letzten Runde: das, was zuletzt im Kontext lag. */
promptTokens: number;
/** Erzeugte Tokens, über alle Werkzeugrunden summiert. */
responseTokens: number;
/** Bis zum ersten sichtbaren Token (Denken zählt mit). null, wenn keins kam. */
ttftMs: number | null;
/** Reine Generierungszeit, ohne Prompt-Auswertung und Modell-Laden. */
evalMs: number;
/** Wanduhr über alles, inklusive Werkzeuglaufzeit. */
totalMs: number;
rounds: number;
}
export interface StreamCallbacks { export interface StreamCallbacks {
onThinking(text: string): void; onThinking(text: string): void;
onToken(text: string): void; onToken(text: string): void;
@ -24,6 +42,16 @@ export interface StreamCallbacks {
onToolCall?(name: string, args: Record<string, unknown>): void; onToolCall?(name: string, args: Record<string, unknown>): void;
/** Werkzeug ist fertig. */ /** Werkzeug ist fertig. */
onToolResult?(name: string, ok: boolean, durationMs: number): void; 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>;
/**
* Messwerte, sobald die Antwort steht. Darf asynchron sein der Aufrufer
* wartet ab, damit die Zahlen sicher vor `done` beim Client sind.
*/
onStats?(stats: ChatStats): void | Promise<void>;
} }
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */ /** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
@ -42,8 +70,22 @@ interface ChatChunk {
}; };
done?: boolean; done?: boolean;
error?: string; error?: string;
/** Nur im Abschluss-Chunk. Dauern in Nanosekunden. */
prompt_eval_count?: number;
eval_count?: number;
eval_duration?: number;
} }
/** Rohwerte einer Runde, wie Ollama sie meldet. */
interface RoundStats {
promptTokens: number;
responseTokens: number;
evalMs: number;
ttftMs: number | null;
}
const NS_PER_MS = 1e6;
export interface ChatMessage { export interface ChatMessage {
role: string; role: string;
content: string; content: string;
@ -90,7 +132,23 @@ async function streamOnce(
opts: OllamaOptions, opts: OllamaOptions,
cb: StreamCallbacks, cb: StreamCallbacks,
signal: AbortSignal, signal: AbortSignal,
): Promise<{ toolCalls: ToolCall[]; content: string }> { ): Promise<{ toolCalls: ToolCall[]; content: string; stats: RoundStats }> {
const startedAt = Date.now();
let firstTokenAt: number | null = null;
const stats: RoundStats = {
promptTokens: 0,
responseTokens: 0,
evalMs: 0,
ttftMs: null,
};
/** Übernimmt die Zahlen aus dem Abschluss-Chunk. */
function collect(chunk: ChatChunk) {
stats.promptTokens = chunk.prompt_eval_count ?? 0;
stats.responseTokens = chunk.eval_count ?? 0;
stats.evalMs = Math.round((chunk.eval_duration ?? 0) / NS_PER_MS);
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: opts.model, model: opts.model,
messages, messages,
@ -139,6 +197,9 @@ async function streamOnce(
continue; continue;
} }
if (chunk.error) throw new Error(chunk.error); if (chunk.error) throw new Error(chunk.error);
if (chunk.message?.thinking || chunk.message?.content) {
firstTokenAt ??= Date.now();
}
if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking); if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking);
if (chunk.message?.content) { if (chunk.message?.content) {
content += chunk.message.content; content += chunk.message.content;
@ -150,10 +211,13 @@ async function streamOnce(
toolCalls.push({ id: call.id, name, arguments: parseArgs(call.function?.arguments) }); toolCalls.push({ id: call.id, name, arguments: parseArgs(call.function?.arguments) });
} }
} }
if (chunk.done) return { toolCalls, content }; if (chunk.done) {
collect(chunk);
return { toolCalls, content, stats };
} }
} }
return { toolCalls, content }; }
return { toolCalls, content, stats };
} }
/** /**
@ -176,15 +240,40 @@ export async function streamChat(
...history.map(toWire), ...history.map(toWire),
]; ];
const startedAt = Date.now();
const total: ChatStats = {
promptTokens: 0,
responseTokens: 0,
ttftMs: null,
evalMs: 0,
totalMs: 0,
rounds: 0,
};
/**
* Werkzeugrunden sind mehrere Ollama-Aufrufe für eine sichtbare Antwort:
* Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde
* (die größte Belegung), TTFT zählt nur die erste Runde.
*/
function fold(round: RoundStats) {
total.rounds++;
total.promptTokens = round.promptTokens || total.promptTokens;
total.responseTokens += round.responseTokens;
total.evalMs += round.evalMs;
total.ttftMs ??= round.ttftMs;
total.totalMs = Date.now() - startedAt;
}
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) { for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
const lastRound = round === MAX_TOOL_ROUNDS; const lastRound = round === MAX_TOOL_ROUNDS;
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht // In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
// statt eines weiteren Aufrufwunsches. // statt eines weiteren Aufrufwunsches.
const roundOpts = lastRound ? { ...opts, tools: false } : opts; const roundOpts = lastRound ? { ...opts, tools: false } : opts;
const { toolCalls, content } = await streamOnce(messages, roundOpts, cb, signal); const { toolCalls, content, stats } = await streamOnce(messages, roundOpts, cb, signal);
fold(stats);
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
await cb.onStats?.(total);
cb.onDone(); cb.onDone();
return; return;
} }
@ -200,11 +289,18 @@ export async function streamChat(
for (const call of toolCalls) { for (const call of toolCalls) {
cb.onToolCall?.(call.name, call.arguments); 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); cb.onToolResult?.(result.name, result.ok, result.durationMs);
messages.push({ role: "tool", tool_name: result.name, content: result.content }); messages.push({ role: "tool", tool_name: result.name, content: result.content });
} }
} }
await cb.onStats?.(total);
cb.onDone(); cb.onDone();
} }

View file

@ -1,4 +1,5 @@
import { mimeFromBase64 } from "./images.js"; import { mimeFromBase64 } from "./images.js";
import type { ChatStats } from "./ollama.js";
export interface OpenRouterOptions { export interface OpenRouterOptions {
model: string; model: string;
@ -10,6 +11,8 @@ export interface StreamCallbacks {
onThinking(text: string): void; onThinking(text: string): void;
onToken(text: string): void; onToken(text: string): void;
onDone(): void; onDone(): void;
/** Messwerte, sobald die Antwort steht. Wird vor `done` abgewartet. */
onStats?(stats: ChatStats): void | Promise<void>;
} }
export function getOpenRouterKey(): string | undefined { export function getOpenRouterKey(): string | undefined {
@ -17,7 +20,13 @@ export function getOpenRouterKey(): string | undefined {
} }
export async function listOpenRouterModels(): Promise< export async function listOpenRouterModels(): Promise<
{ id: string; name: string; contextLength: number; promptPrice: number }[] {
id: string;
name: string;
contextLength: number;
promptPrice: number;
completionPrice: number;
}[]
> { > {
const res = await fetch("https://openrouter.ai/api/v1/models"); const res = await fetch("https://openrouter.ai/api/v1/models");
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`); if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
@ -26,15 +35,17 @@ export async function listOpenRouterModels(): Promise<
id: string; id: string;
name: string; name: string;
context_length: number; context_length: number;
pricing: { prompt: string }; pricing: { prompt: string; completion?: string };
}[]; }[];
}; };
// Preise kommen pro Token; die Anzeige rechnet in Preis je 1 Mio. Tokens.
return data.data return data.data
.map((m) => ({ .map((m) => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
contextLength: m.context_length, contextLength: m.context_length,
promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000, promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000,
completionPrice: Number(m.pricing?.completion ?? 0) * 1_000_000,
})) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
@ -65,6 +76,10 @@ export async function streamOpenRouter(
cb: StreamCallbacks, cb: StreamCallbacks,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
// Vor dem fetch: TTFT soll die Wartezeit auf den Anbieter enthalten, nicht
// erst ab dem Eintreffen der Antwort-Header zählen (so misst es auch Ollama).
const startedAt = Date.now();
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST", method: "POST",
headers: { headers: {
@ -76,6 +91,8 @@ export async function streamOpenRouter(
body: JSON.stringify({ body: JSON.stringify({
model: opts.model, model: opts.model,
stream: true, stream: true,
// Ohne das kommt kein usage-Block und die Tokenzahlen blieben leer.
stream_options: { include_usage: true },
messages: [ messages: [
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []), ...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
...history.map(toOpenAIMessage), ...history.map(toOpenAIMessage),
@ -95,6 +112,27 @@ export async function streamOpenRouter(
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
let firstTokenAt: number | null = null;
const stats: ChatStats = {
promptTokens: 0,
responseTokens: 0,
ttftMs: null,
evalMs: 0,
totalMs: 0,
rounds: 1,
};
/**
* OpenRouter meldet keine reine Generierungszeit. Als evalMs zählt deshalb
* die Zeit ab dem ersten Token das ist die Spanne, über die tok/s
* überhaupt aussagekräftig ist.
*/
function finish(): void | Promise<void> {
stats.totalMs = Date.now() - startedAt;
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt;
return cb.onStats?.(stats);
}
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
@ -107,6 +145,7 @@ export async function streamOpenRouter(
if (!line.startsWith("data:")) continue; if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim(); const payload = line.slice(5).trim();
if (payload === "[DONE]") { if (payload === "[DONE]") {
await finish();
cb.onDone(); cb.onDone();
return; return;
} }
@ -116,6 +155,7 @@ export async function streamOpenRouter(
finish_reason?: string | null; finish_reason?: string | null;
}[]; }[];
error?: { message?: string }; error?: { message?: string };
usage?: { prompt_tokens?: number; completion_tokens?: number };
}; };
try { try {
chunk = JSON.parse(payload); chunk = JSON.parse(payload);
@ -123,14 +163,24 @@ export async function streamOpenRouter(
continue; continue;
} }
if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error"); if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error");
if (chunk.usage) {
stats.promptTokens = chunk.usage.prompt_tokens ?? 0;
stats.responseTokens = chunk.usage.completion_tokens ?? 0;
}
const delta = chunk.choices?.[0]?.delta; const delta = chunk.choices?.[0]?.delta;
if (delta?.reasoning || delta?.content) firstTokenAt ??= Date.now();
if (delta?.reasoning) cb.onThinking(delta.reasoning); if (delta?.reasoning) cb.onThinking(delta.reasoning);
if (delta?.content) cb.onToken(delta.content); if (delta?.content) cb.onToken(delta.content);
if (chunk.choices?.[0]?.finish_reason && !delta?.content) { // Der usage-Block kommt erst nach dem finish_reason-Chunk. Nur wenn er
// schon da ist, darf hier abgekürzt werden — sonst bis [DONE] weiterlesen
// und die Tokenzahlen mitnehmen.
if (chunk.choices?.[0]?.finish_reason && !delta?.content && stats.promptTokens) {
await finish();
cb.onDone(); cb.onDone();
return; return;
} }
} }
} }
await finish();
cb.onDone(); cb.onDone();
} }

View file

@ -39,6 +39,9 @@ interface Bucket {
resetAt: number; 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 * Einfacher In-Memory-Zähler pro Zeitfenster. Bremst teure Endpunkte
* (Whisper läuft bis zu 180 s) gegen versehentliche oder böswillige Fluten. * (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) { export function createRateLimiter(limit: number, windowMs: number) {
const buckets = new Map<string, Bucket>(); 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 { return function allow(key: string): boolean {
const now = Date.now(); const now = Date.now();
if (buckets.size > MAX_BUCKETS) sweep(now);
const bucket = buckets.get(key); const bucket = buckets.get(key);
if (!bucket || now >= bucket.resetAt) { if (!bucket || now >= bucket.resetAt) {

View file

@ -43,6 +43,11 @@ export function toolNames(): string[] {
return REGISTRY.map((t) => t.name); 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> { function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout( 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 { try {
const value = await withTimeout( const value = await withTimeout(
tool.run(call.arguments, ctx), tool.run(call.arguments, { ...ctx, signal }),
TOOL_TIMEOUT_MS, TOOL_TIMEOUT_MS,
tool.name, 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;
}
}

View file

@ -6,7 +6,7 @@ import type { Tool } from "./types.js";
export const rememberTool: Tool = { export const rememberTool: Tool = {
name: "remember", name: "remember",
description: 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, requiresConfirmation: true,
parameters: { parameters: {
type: "object", type: "object",
@ -27,6 +27,10 @@ export const rememberTool: Tool = {
const text = String(args.text ?? "").trim(); const text = String(args.text ?? "").trim();
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt"); 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"); 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, { const result = upsertAnchor(ctx.sessionId, {
text, text,
kind: ANCHOR_KINDS.includes(args.kind as AnchorKind) kind: ANCHOR_KINDS.includes(args.kind as AnchorKind)
@ -34,7 +38,6 @@ export const rememberTool: Tool = {
: "fact", : "fact",
importance: 0.9, importance: 0.9,
origin: "model", origin: "model",
pinned: true,
}); });
return { stored: result, text }; return { stored: result, text };
}, },

View file

@ -10,6 +10,12 @@ export interface ToolContext {
signal: AbortSignal; signal: AbortSignal;
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */ /** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
sessionId?: string; 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 { export interface Tool {
@ -17,9 +23,9 @@ export interface Tool {
description: string; description: string;
parameters: ToolSchema; parameters: ToolSchema;
/** /**
* Kennzeichnet Werkzeuge mit Außenwirkung (schreibend, Netzwerk, Server). * Kennzeichnet Werkzeuge mit Außenwirkung (Netzwerk, dauerhafter Speicher).
* Bisher gibt es nur lesende Werkzeuge; das Feld existiert, damit die * `runTool` fragt vor der Ausführung über `ToolContext.confirm` beim Nutzer
* Bestätigungspflicht später nicht nachträglich eingezogen werden muss. * nach und lehnt ab, wenn keine Freigabe kommt.
*/ */
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
/** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */ /** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */

View file

@ -1,5 +1,8 @@
import { lookup } from "node:dns/promises"; import dns from "node:dns";
import { isIP } from "node:net"; 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 { parseHTML } from "linkedom";
import { Defuddle } from "defuddle/node"; import { Defuddle } from "defuddle/node";
import { ToolError } from "./types.js"; import { ToolError } from "./types.js";
@ -9,6 +12,8 @@ const TIMEOUT_MS = 15_000;
const MAX_HTML_BYTES = 2 * 1024 * 1024; const MAX_HTML_BYTES = 2 * 1024 * 1024;
const MAX_CONTENT_CHARS = 25_000; const MAX_CONTENT_CHARS = 25_000;
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
const ALLOWED_TYPES =
/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/;
const USER_AGENT = 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"; "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"); 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 * DNS-Auflösung, die private Adressen ablehnt eingehängt als `lookup` der
* ab. Ohne das könnte das Modell http://localhost:8788/api/sessions lesen — * HTTP-Verbindung.
* der Origin-Check schützt nicht vor server-eigenem fetch. *
* 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> { async function assertPublicHost(hostname: string): Promise<void> {
if (isIP(hostname)) { const host = bareHost(hostname);
if (isPrivateIP(hostname)) throw new ToolError("Zugriff auf private Adressen ist gesperrt"); if (isIP(host)) {
if (isPrivateIP(host)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
return; return;
} }
let addrs: { address: string }[]; let addrs: { address: string }[];
try { try {
addrs = await lookup(hostname, { all: true, verbatim: true }); addrs = await dns.promises.lookup(host, { all: true, verbatim: true });
} catch { } 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) { for (const a of addrs) {
if (isPrivateIP(a.address)) { if (isPrivateIP(a.address)) {
throw new ToolError("Zugriff auf private Adressen ist gesperrt"); throw new ToolError("Zugriff auf private Adressen ist gesperrt");
@ -76,49 +111,93 @@ function assertHttpUrl(raw: string): URL {
return url; return url;
} }
async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: string }> { /** Ein GET mit gepinnter Auflösung. Weiterleitungen bleiben Sache des Aufrufers. */
let url = assertHttpUrl(rawUrl).toString(); function send(url: URL, signal: AbortSignal): Promise<IncomingMessage> {
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { return new Promise((resolve, reject) => {
await assertPublicHost(new URL(url).hostname); const req = request(
url,
const res = await fetch(url, { {
redirect: "manual", method: "GET",
headers: { "User-Agent": USER_AGENT, Accept: "text/html, text/plain, application/xhtml+xml" }, lookup: guardedLookup,
signal: AbortSignal.timeout(TIMEOUT_MS), 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();
}); });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) break;
url = new URL(location, url).toString();
assertHttpUrl(url);
continue;
}
if (!res.ok) throw new ToolError(`HTTP ${res.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)) {
throw new ToolError(`Nicht unterstützter Inhaltstyp: ${type || "unbekannt"}`);
} }
const reader = res.body?.getReader(); /** Antwortkörper bis MAX_HTML_BYTES lesen, komprimierte Antworten auspacken. */
if (!reader) throw new ToolError("Leere Antwort"); 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(); const decoder = new TextDecoder();
let html = ""; let html = "";
let bytes = 0; let bytes = 0;
for (;;) { try {
const { done, value } = await reader.read(); for await (const chunk of stream as AsyncIterable<Buffer>) {
if (done) break; bytes += chunk.byteLength;
bytes += value.byteLength; html += decoder.decode(chunk, { stream: true });
if (bytes > MAX_HTML_BYTES) { if (bytes > MAX_HTML_BYTES) break;
void reader.cancel();
html += decoder.decode(value, { stream: true });
break;
} }
html += decoder.decode(value, { stream: true }); } 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 { url, body: html }; 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(url.hostname);
const res = await send(url, signal);
const status = res.statusCode ?? 0;
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 (status < 200 || status >= 300) {
res.destroy();
throw new ToolError(`HTTP ${status} für ${url}`);
}
const type = String(res.headers["content-type"] ?? "").toLowerCase();
if (!ALLOWED_TYPES.test(type)) {
res.destroy();
throw new ToolError(`Nicht unterstützter Inhaltstyp: ${type || "unbekannt"}`);
}
return { url: url.toString(), body: await readCapped(res) };
} }
throw new ToolError(`Zu viele Weiterleitungen (> ${MAX_REDIRECTS})`); 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 = { export const readWebpageTool: Tool = {
name: "read_webpage", name: "read_webpage",
description: 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: { parameters: {
type: "object", type: "object",
properties: { properties: {
@ -134,11 +216,11 @@ export const readWebpageTool: Tool = {
}, },
required: ["url"], required: ["url"],
}, },
async run(args) { async run(args, ctx) {
const raw = String(args.url ?? "").trim(); const raw = String(args.url ?? "").trim();
if (!raw) throw new ToolError("url fehlt"); 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 { document } = parseHTML(body);
const result = await Defuddle(document, url, { markdown: true }); const result = await Defuddle(document, url, { markdown: true });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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
View file

@ -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>

View file

@ -5,6 +5,8 @@ import { Sidebar } from "./components/Sidebar";
import { ChatMessage } from "./components/ChatMessage"; import { ChatMessage } from "./components/ChatMessage";
import { Composer } from "./components/Composer"; import { Composer } from "./components/Composer";
import { MemoryPanel } from "./components/MemoryPanel"; import { MemoryPanel } from "./components/MemoryPanel";
import { ToolConfirm } from "./components/ToolConfirm";
import { StatsBar } from "./components/StatsBar";
import type { OpenRouterModel, OllamaModel } from "./types"; import type { OpenRouterModel, OllamaModel } from "./types";
const VOICE_KEY = "oxagenttwo.voiceMode"; const VOICE_KEY = "oxagenttwo.voiceMode";
@ -48,6 +50,22 @@ export default function App() {
} }
}, [settingsOpen, chat.options.provider, orModels.length, ollamaModels.length]); }, [settingsOpen, chat.options.provider, orModels.length, ollamaModels.length]);
// Bei OpenRouter kennt nur der Client Preise und Kontextlänge — sie stehen
// in der Modellliste, nicht in der Antwort des Servers.
const activeOrModel =
chat.options.provider === "openrouter"
? orModels.find((m) => m.id === chat.options.openrouterModel)
: undefined;
const setModelPricing = chat.setModelPricing;
useEffect(() => {
setModelPricing(
activeOrModel
? { prompt: activeOrModel.promptPrice, completion: activeOrModel.completionPrice }
: null,
);
}, [activeOrModel, setModelPricing]);
const voiceModeRef = useRef(voiceMode); const voiceModeRef = useRef(voiceMode);
const streamingRef = useRef(false); const streamingRef = useRef(false);
const awaitingDrainRef = useRef(false); const awaitingDrainRef = useRef(false);
@ -434,6 +452,10 @@ export default function App() {
))} ))}
</div> </div>
{chat.toolConfirm && (
<ToolConfirm request={chat.toolConfirm} onDecide={chat.decideToolConfirm} />
)}
{voice.error && ( {voice.error && (
<div className="voice-error"> <div className="voice-error">
{voice.error} {voice.error}
@ -441,6 +463,13 @@ export default function App() {
</div> </div>
)} )}
<StatsBar
stats={chat.stats}
live={chat.live}
totals={chat.totals}
contextLength={activeOrModel?.contextLength}
/>
<Composer <Composer
streaming={chat.streaming} streaming={chat.streaming}
disabled={!chat.activeId} disabled={!chat.activeId}

View file

@ -0,0 +1,87 @@
import type { ChatStats, SessionTotals } from "../types";
const nf = new Intl.NumberFormat("de-DE");
function seconds(ms: number): string {
return `${(ms / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 })} s`;
}
function rate(tokens: number, ms: number): string | null {
if (!tokens || ms <= 0) return null;
const perSecond = (tokens / ms) * 1000;
return `${perSecond.toLocaleString("de-DE", { maximumFractionDigits: 1 })} tok/s`;
}
/**
* Messwerte der laufenden bzw. letzten Antwort plus Session-Summe.
*
* Während des Streamens gibt es nur die Näherung aus gezählten Chunks die
* ist mit markiert. Die exakten Zahlen kommen vom Modell selbst, sobald die
* Antwort steht.
*/
export function StatsBar({
stats,
live,
totals,
contextLength,
}: {
stats: ChatStats | null;
live: { tokens: number; startedAt: number } | null;
totals: SessionTotals;
contextLength?: number;
}) {
const parts: string[] = [];
if (live) {
const elapsed = Date.now() - live.startedAt;
parts.push(`${nf.format(live.tokens)}`);
const r = rate(live.tokens, elapsed);
if (r) parts.push(`${r}`);
parts.push(seconds(elapsed));
} else if (stats) {
parts.push(`${nf.format(stats.promptTokens)}`);
parts.push(`${nf.format(stats.responseTokens)}`);
const r = rate(stats.responseTokens, stats.evalMs);
if (r) parts.push(r);
if (stats.ttftMs !== null) parts.push(`TTFT ${seconds(stats.ttftMs)}`);
parts.push(seconds(stats.totalMs));
if (stats.rounds > 1) parts.push(`${stats.rounds} Runden`);
}
const window = stats?.contextLength ?? contextLength;
const used = stats?.promptTokens ?? 0;
const fill = window && used ? Math.min(1, used / window) : null;
if (parts.length === 0 && totals.responses === 0) return null;
return (
<div className="stats-bar">
{parts.length > 0 && (
<span className={`stats-run ${live ? "streaming" : ""}`}>{parts.join(" · ")}</span>
)}
{fill !== null && window && (
<span
className={`stats-context ${fill > 0.9 ? "tight" : ""}`}
title={`Prompt der letzten Antwort gegen das tatsächlich genutzte Kontextfenster (${nf.format(window)} Tokens). Darüber hinaus wird vorne abgeschnitten.`}
>
<span className="stats-meter">
<span className="stats-meter-fill" style={{ width: `${fill * 100}%` }} />
</span>
{nf.format(used)} / {nf.format(window)}
</span>
)}
{totals.responses > 0 && (
<span
className="stats-totals"
title={`${totals.responses} Antworten in diesem Chat, seit dem letzten Neuladen der Seite`}
>
Σ {nf.format(totals.promptTokens)} {nf.format(totals.responseTokens)}
{totals.costUsd > 0 &&
` · $${totals.costUsd.toLocaleString("de-DE", { maximumFractionDigits: 4 })}`}
</span>
)}
</div>
);
}

View 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>
);
}

View file

@ -896,3 +896,161 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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;
}
/* --- Statistik-Leiste --- */
.stats-bar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 5px 18px;
border-top: 1px solid var(--border);
font-size: 0.76em;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.stats-run.streaming {
color: var(--accent);
}
.stats-context {
display: flex;
align-items: center;
gap: 6px;
cursor: help;
}
.stats-meter {
width: 54px;
height: 4px;
border-radius: 2px;
background: var(--border);
overflow: hidden;
}
.stats-meter-fill {
display: block;
height: 100%;
background: var(--accent-dim);
transition: width 0.3s ease;
}
.stats-context.tight {
color: var(--accent-warm);
}
.stats-context.tight .stats-meter-fill {
background: var(--accent-warm);
}
.stats-totals {
margin-left: auto;
cursor: help;
opacity: 0.75;
}

View file

@ -31,6 +31,44 @@ export interface ToolEvent {
durationMs?: number; 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";
/** Messwerte einer Antwort, wie der Server sie nach `done` schickt. */
export interface ChatStats {
messageId: string;
model: string;
provider: "ollama" | "openrouter";
promptTokens: number;
responseTokens: number;
ttftMs: number | null;
evalMs: number;
totalMs: number;
rounds: number;
/** Effektives Kontextfenster; bei OpenRouter aus der Modellliste ergänzt. */
contextLength?: number;
}
/** Aufsummiert über den Chat. Lebt im Browser und ist nach Reload weg. */
export interface SessionTotals {
promptTokens: number;
responseTokens: number;
responses: number;
/** Geschätzte Kosten in USD; nur bei OpenRouter mit bekannten Preisen. */
costUsd: number;
}
export interface ChatOptions { export interface ChatOptions {
model: string; model: string;
think: boolean; think: boolean;
@ -90,7 +128,9 @@ export interface OpenRouterModel {
id: string; id: string;
name: string; name: string;
contextLength: number; contextLength: number;
/** Preis je 1 Mio. Tokens in USD. */
promptPrice: number; promptPrice: number;
completionPrice: number;
} }
export interface ModelInfo { export interface ModelInfo {

View file

@ -1,6 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ChatSocket } from "./socket"; import { ChatSocket } from "./socket";
import type { ChatOptions, Message, Session , ToolEvent } from "./types"; import type {
ChatOptions,
ChatStats,
Message,
Session,
SessionTotals,
ToolConfirmRequest,
ToolDecision,
ToolEvent,
} from "./types";
export type ConnStatus = "connecting" | "open" | "closed"; export type ConnStatus = "connecting" | "open" | "closed";
export interface ModelInfo { export interface ModelInfo {
@ -46,6 +55,21 @@ export function useChat() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [toolEvents, setToolEvents] = useState<Record<string, ToolEvent[]>>({}); 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 [stats, setStats] = useState<ChatStats | null>(null);
// Während des Streamens gibt es noch keine exakten Zahlen: der Server meldet
// sie erst am Ende. Bis dahin zählt die Leiste die eingehenden Chunks als
// Näherung und misst die Zeit ab dem ersten Token.
const [live, setLive] = useState<{ tokens: number; startedAt: number } | null>(null);
const [totals, setTotals] = useState<SessionTotals>({
promptTokens: 0,
responseTokens: 0,
responses: 0,
costUsd: 0,
});
const priceRef = useRef<{ prompt: number; completion: number } | null>(null);
const [options, setOptionsState] = useState<ChatOptions>(loadOptions); const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
const [systemPrompt, setSystemPromptState] = useState( const [systemPrompt, setSystemPromptState] = useState(
() => localStorage.getItem(SYSTEM_KEY) ?? "", () => localStorage.getItem(SYSTEM_KEY) ?? "",
@ -83,6 +107,11 @@ export function useChat() {
); );
} else if (t === "token") { } else if (t === "token") {
const text = data.text as string; const text = data.text as string;
setLive((cur) =>
cur
? { ...cur, tokens: cur.tokens + 1 }
: { tokens: 1, startedAt: Date.now() },
);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === data.messageId ? { ...m, content: m.content + text } : m, m.id === data.messageId ? { ...m, content: m.content + text } : m,
@ -112,9 +141,42 @@ export function useChat() {
); );
return { ...prev, [data.messageId as string]: updated }; return { ...prev, [data.messageId as string]: updated };
}); });
} else if (t === "stats") {
const s = data as unknown as ChatStats;
setStats(s);
setLive(null);
setTotals((prev) => {
const price = priceRef.current;
const cost =
s.provider === "openrouter" && price
? (s.promptTokens * price.prompt + s.responseTokens * price.completion) /
1_000_000
: 0;
return {
promptTokens: prev.promptTokens + s.promptTokens,
responseTokens: prev.responseTokens + s.responseTokens,
responses: prev.responses + 1,
costUsd: prev.costUsd + cost,
};
});
} 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") { } else if (t === "done" || t === "error") {
streamingRef.current = false; streamingRef.current = false;
setStreaming(false); setStreaming(false);
setLive(null);
// 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") { } else if (t === "sessions-changed" || t === "session-deleted") {
void refreshSessions(); void refreshSessions();
} }
@ -184,6 +246,8 @@ export function useChat() {
} }
streamingRef.current = true; streamingRef.current = true;
setStreaming(true); setStreaming(true);
setLive(null);
setStats(null);
socketRef.current?.send({ socketRef.current?.send({
type: "chat", type: "chat",
sessionId: activeId, sessionId: activeId,
@ -199,6 +263,12 @@ export function useChat() {
const abort = useCallback(() => { const abort = useCallback(() => {
socketRef.current?.send({ type: "abort" }); 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 () => { const newSession = useCallback(async () => {
@ -226,6 +296,14 @@ export function useChat() {
messages, messages,
streaming, streaming,
toolEvents, toolEvents,
toolConfirm: toolConfirms[0] ?? null,
decideToolConfirm,
stats,
live,
totals,
setModelPricing: (p: { prompt: number; completion: number } | null) => {
priceRef.current = p;
},
sendMessage, sendMessage,
abort, abort,
newSession, newSession,