mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
878acd9933
commit
8a489dfc5c
22 changed files with 1495 additions and 347 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -6,3 +6,6 @@ data.sqlite*
|
|||
|
||||
# Piper-Stimmmodell (109 MB) — siehe README, wird lokal heruntergeladen
|
||||
server/voices/*.onnx
|
||||
|
||||
# Build-Artefakt, wird aus web/src erzeugt
|
||||
web/dist/
|
||||
|
|
|
|||
395
README.md
395
README.md
|
|
@ -1,26 +1,34 @@
|
|||
# agenttwo-tools
|
||||
|
||||
> Fork von [agenttwo](https://github.com/Jeuners/agenttwo). Die Basis bleibt dort
|
||||
> unverändert; hier kommen Vision und Werkzeuge (Tool-Calling) dazu. Läuft auf eigenen Ports
|
||||
> (Backend 8788, Frontend 5174), damit beide Projekte parallel laufen können.
|
||||
> Fixes aus der Basis lassen sich per `git cherry-pick` aus dem Remote
|
||||
> `upstream` übernehmen.
|
||||
Sprachfähige Chat-Oberfläche für lokale und Cloud-Sprachmodelle. Läuft
|
||||
vollständig auf dem eigenen Rechner: Modell über [Ollama](https://ollama.com),
|
||||
Spracheingabe über [whisper.cpp](https://github.com/ggml-org/whisper.cpp),
|
||||
Sprachausgabe über [Piper](https://github.com/rhasspy/piper). Der Weg nach
|
||||
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
|
||||
[Ollama](https://ollama.com), optionaler Fallback auf
|
||||
[OpenRouter](https://openrouter.ai), Spracheingabe via
|
||||
[whisper.cpp](https://github.com/ggml-org/whisper.cpp) und deutsche
|
||||
Sprachausgabe via [Piper](https://github.com/rhasspy/piper) (Stimme: Thorsten).
|
||||
- **Werkzeuge** — das Modell rechnet, liest Projektdateien, ruft Websites ab
|
||||
und führt ein eigenes Gedächtnis. Werkzeuge mit Außenwirkung fragen vorher
|
||||
nach.
|
||||
- **Anhänge** — Bilder, Textdateien und PDFs, per Button, ⌘V oder Drag & Drop.
|
||||
- **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
|
||||
|
||||
npm-Workspace mit zwei Paketen:
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|-----------|------------------------------------------------------------------------|
|
||||
|-----------|-------------------------------------------------------------------------|
|
||||
| `server/` | Fastify + WebSocket-Backend, Ollama-/OpenRouter-Bridge, STT/TTS, SQLite |
|
||||
| `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
|
||||
|
||||
- **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
|
||||
eine `ExperimentalWarning`; ab Node 24 ist das Modul stabil.
|
||||
- [Ollama](https://ollama.com) mit einem Qwen3-Modell (`ollama pull qwen3.5`)
|
||||
- `ffmpeg` im `PATH`
|
||||
- `whisper-cli` im `PATH` (whisper.cpp) inklusive Modell
|
||||
- `ffmpeg` und `whisper-cli` (whisper.cpp) im `PATH`, inklusive Whisper-Modell
|
||||
- Piper als **Python-Modul** — der Server ruft `python3 -m piper` auf,
|
||||
nicht das gleichnamige Homebrew-Binary
|
||||
|
||||
**RAM-Hinweis (Apple Silicon):** Qwen3.5 belegt ~7 GB im Speicher — mit
|
||||
weniger als 16 GB RAM wird es eng. Dann lieber das kleinere
|
||||
[`qwen3:8b`](https://ollama.com/library/qwen3) (`ollama pull qwen3:8b`,
|
||||
~5 GB) laden und in den Einstellungen unter „Lokales Modell“ wählen bzw.
|
||||
`MODEL=qwen3:8b` in die `.env` schreiben.
|
||||
**RAM (Apple Silicon):** qwen3.5 belegt ~7 GB — mit weniger als 16 GB RAM wird
|
||||
es eng. Dann das kleinere [`qwen3:8b`](https://ollama.com/library/qwen3)
|
||||
(`ollama pull qwen3:8b`, ~5 GB) nehmen und in den Einstellungen unter
|
||||
„Lokales Modell“ wählen oder `MODEL=qwen3:8b` in die `.env` schreiben.
|
||||
|
||||
## Schnellstart (Mac)
|
||||
## Schnellstart
|
||||
|
||||
Ein Befehl von null bis zur lauffähigen App — das Skript prüft alle
|
||||
Abhängigkeiten und bietet fehlende (Homebrew, Node, ffmpeg, whisper, Piper,
|
||||
Ollama, Modell, Sprachdateien) zur Installation an:
|
||||
`setup.sh` prüft alle Abhängigkeiten und bietet fehlende zur Installation an —
|
||||
Homebrew, Node, ffmpeg, whisper.cpp, Piper, Ollama, Modell und Sprachdateien:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Jeuners/agenttwo-tools.git
|
||||
|
|
@ -52,10 +57,13 @@ cd agenttwo-tools
|
|||
npm run dev # → http://localhost:5174
|
||||
```
|
||||
|
||||
Nur prüfen ohne etwas zu ändern: `./setup.sh --check` · Ohne Nachfragen:
|
||||
`./setup.sh --yes`
|
||||
| Aufruf | Wirkung |
|
||||
|----------------------|-------------------------------------------|
|
||||
| `./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
|
||||
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"
|
||||
```
|
||||
|
||||
### Whisper-Modell
|
||||
|
||||
Wird per Default unter `~/whisper-models/ggml-large-v3-turbo.bin` erwartet:
|
||||
Whisper-Modell (~1,5 GB), per Default unter
|
||||
`~/whisper-models/ggml-large-v3-turbo.bin` erwartet:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### Piper-Stimme
|
||||
|
||||
Die Stimmdatei ist **nicht** im Repo (109 MB, über GitHubs Dateilimit).
|
||||
Einmalig herunterladen:
|
||||
Piper-Stimme (109 MB, wegen GitHubs Dateilimit nicht im Repo — die zugehörige
|
||||
`.onnx.json` liegt bereits dort):
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Die zugehörige `de_DE-thorsten-high.onnx.json` liegt bereits im Repo.
|
||||
|
||||
## 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
|
||||
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 |
|
||||
|----------------------|--------------------------------------------------|
|
||||
| `PORT` | `8788` |
|
||||
| `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 Startwert: In den Einstellungen lässt sich das lokale
|
||||
Modell pro Chat über das Dropdown „Lokales Modell“ wechseln — die Liste kommt
|
||||
live von `GET /api/ollama/models`.
|
||||
|
||||
`MODEL` ist nur der Default: In den Einstellungen lässt sich das lokale
|
||||
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
|
||||
|
||||
## 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.
|
||||
Anhängen geht auf drei Wegen: Button 📎 im Composer, Einfügen aus der
|
||||
Zwischenablage (⌘V) oder Drag & Drop auf die Eingabezeile. Eine Nachricht darf
|
||||
auch nur aus einem Anhang bestehen.
|
||||
**Bilder** setzen ein Modell mit der Fähigkeit `vision` voraus — qwen3.5 bringt
|
||||
sie mit. Sie werden zusammen mit der Nachricht gespeichert und bei Folgefragen
|
||||
erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren. Für
|
||||
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
|
||||
Folgefragen erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren.
|
||||
Für Ollama gehen sie als `images: [base64]` raus, für OpenRouter im
|
||||
OpenAI-Format als `image_url` mit data-URL — der MIME-Typ wird dabei aus den
|
||||
Magic Bytes bestimmt.
|
||||
**Text- und PDF-Dateien** landen als formatierter Block im Kontext und werden
|
||||
ebenfalls gespeichert. Unterstützt sind `.txt .json .md .csv .yaml .xml .sql`
|
||||
und gängige Code-Endungen, dazu `.pdf` — der Text wird serverseitig mit
|
||||
[pdf-parse](https://www.npmjs.com/package/pdf-parse) extrahiert. Gescannte PDFs
|
||||
ohne Textebene werden abgewiesen, OCR gibt es nicht.
|
||||
|
||||
Grenzen (`server/src/images.ts`): maximal 4 Bilder pro Nachricht, je 6 MB,
|
||||
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.
|
||||
| Grenze | Bilder (`images.ts`) | Dateien (`files.ts`) |
|
||||
|----------------------|----------------------|--------------------------------|
|
||||
| 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
|
||||
im Kontext und werden ebenfalls gespeichert — Rückfragen zur Datei funktionieren
|
||||
also auch in der nächsten Nachricht. Unterstützt: `.txt .json .md .csv .yaml
|
||||
.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).
|
||||
Der Typ wird an den Magic Bytes geprüft, nicht am Dateinamen; erlaubt sind
|
||||
PNG, JPEG, GIF und WebP. Der WebSocket hat ein Payload-Limit von 32 MB. Zu
|
||||
lange PDF-Texte werden gekürzt, nicht abgelehnt.
|
||||
|
||||
Ob das eingestellte Modell Bilder kann, verrät:
|
||||
Ob das eingestellte Modell Bilder kann:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \
|
||||
| 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
|
||||
eine Werkzeugliste mit; will das Modell eines benutzen, wird es ausgeführt und
|
||||
das Ergebnis zurückgereicht, bis eine Antwort ohne Werkzeugwunsch entsteht
|
||||
(maximal `MAX_TOOL_ROUNDS` = 5 Runden, je 15 s Zeitlimit). Abschalten lässt
|
||||
sich das in den Einstellungen.
|
||||
Der Server schickt bei jeder Anfrage eine Werkzeugliste mit. Will das Modell
|
||||
eines benutzen, wird es ausgeführt und das Ergebnis zurückgereicht, bis eine
|
||||
Antwort ohne Werkzeugwunsch entsteht — maximal `MAX_TOOL_ROUNDS` = 5 Runden,
|
||||
je 15 s Zeitlimit. Abschalten lässt sich das in den Einstellungen.
|
||||
|
||||
| Werkzeug | Zweck |
|
||||
|---|---|
|
||||
|----------------|-------------------------------------------------------------|
|
||||
| `get_time` | Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone |
|
||||
| `calculate` | Arithmetik mit eigenem Parser |
|
||||
| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen |
|
||||
| `read_file` | Textdatei unterhalb der Sandbox-Wurzel lesen |
|
||||
| `list_files` | Verzeichnis auflisten |
|
||||
| `remember` | Wichtigen Punkt als gepinnten Ankerpunkt ins Gedächtnis schreiben |
|
||||
| `recall` | Gedächtnis (Ankerpunkte) durchsuchen |
|
||||
| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown |
|
||||
| `recall` | Gedächtnis nach Ankerpunkten durchsuchen |
|
||||
| `remember` | Ankerpunkt ins Gedächtnis schreiben — **mit Rückfrage** |
|
||||
| `read_webpage` | Website laden, Hauptinhalt als Markdown — **mit Rückfrage** |
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| Semantisch | `anchors` | Ankerpunkte, destilliert in der Traumphase |
|
||||
|
||||
|
|
@ -186,112 +276,105 @@ Das Fenster ist in den Einstellungen per Slider einstellbar (2–100 Schritte).
|
|||
|
||||
### 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
|
||||
Fallback. Auslöser:
|
||||
|
||||
- automatisch nach 3 Minuten Inaktivität oder wenn 10 Schritte unkonsolidiert sind
|
||||
- manuell über 🧠 **Gedächtnis** → „Traumphase jetzt"
|
||||
Fallback. Sie läuft automatisch nach 3 Minuten Inaktivität oder wenn 10
|
||||
Schritte unkonsolidiert sind, manuell über 🧠 **Gedächtnis** → „Traumphase
|
||||
jetzt“.
|
||||
|
||||
Ankerpunkte haben eine Wichtigkeit (0–1), eine Art (`fact`, `decision`,
|
||||
`preference`, `entity`, `open_question`) und einen Ursprung (`dream`, `model`,
|
||||
`heuristic`, `test`). Ähnliche Anker werden zusammengeführt (Wort-Ähnlichkeit
|
||||
≥ 0.5, `hits` steigt). Bei jedem Traumlauf verfallen ungepinnte Anker
|
||||
(Importance × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte
|
||||
(★) bleiben dauerhaft.
|
||||
`heuristic`, `test`). Ähnliche Anker werden zusammengeführt — Wort-Ähnlichkeit
|
||||
≥ 0.5, `hits` steigt. Bei jedem Traumlauf verfallen ungepinnte Anker
|
||||
(Wichtigkeit × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte
|
||||
(★) bleiben.
|
||||
|
||||
Die wichtigsten Anker werden als System-Kontext eingespielt (Budget ~1200
|
||||
Zeichen) — das Modell beantwortet dann Fragen aus Inhalten, die nie im
|
||||
sichtbaren Chatverlauf standen.
|
||||
Die wichtigsten Anker gehen als System-Kontext mit (Budget ~1200 Zeichen) —
|
||||
das Modell beantwortet dann Fragen aus Inhalten, die nie im sichtbaren
|
||||
Chatverlauf standen.
|
||||
|
||||
### Rekonstruktion
|
||||
|
||||
Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch
|
||||
aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren" (gepinnte
|
||||
Anker bleiben erhalten). Die Eval der Heuristik und des Dream-Parsers:
|
||||
aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren“, gepinnte
|
||||
Anker bleiben erhalten. Die Eval der Heuristik und des Dream-Parsers:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
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
|
||||
Browser geöffnet ist, kann `localhost` per `fetch()` oder WebSocket erreichen.
|
||||
Deshalb prüfen sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die
|
||||
`Origin` gegen eine Allowlist (`server/src/security.ts`) — Standard sind
|
||||
`localhost`/`127.0.0.1` auf Port 5174 und 8788.
|
||||
|
||||
Läuft das Frontend woanders, die Origin ergänzen:
|
||||
`127.0.0.1`. Das allein genügt nicht: Eine beliebige im Browser geöffnete
|
||||
Webseite erreicht `localhost` per `fetch()` oder WebSocket. Deshalb prüfen
|
||||
sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die `Origin` gegen
|
||||
eine Allowlist (`server/src/security.ts`) — Standard sind `localhost` und
|
||||
`127.0.0.1` auf Port 5174 und 8788. Läuft das Frontend woanders:
|
||||
|
||||
```bash
|
||||
ALLOWED_ORIGINS=http://192.168.1.50:5174
|
||||
```
|
||||
|
||||
Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min) und `/api/tts` (30/min),
|
||||
Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf, und Fehlerdetails
|
||||
landen im Server-Log statt in der HTTP-Antwort.
|
||||
Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min), `/api/tts` (30/min),
|
||||
Traumphase (4/min) und Chats je WebSocket-Verbindung (30/min);
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
npm install
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Typecheck über beide Workspaces:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
Der Vite-Dev-Server leitet `/api` und `/ws` an das Backend weiter, deshalb
|
||||
läuft die App unter `http://localhost:5174` aus einer Herkunft.
|
||||
|
||||
## Produktion
|
||||
|
||||
```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).
|
||||
|
|
|
|||
267
package-lock.json
generated
267
package-lock.json
generated
|
|
@ -739,6 +739,22 @@
|
|||
"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": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
|
||||
|
|
@ -870,6 +886,70 @@
|
|||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
|
@ -920,6 +1000,15 @@
|
|||
"@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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
|
|
@ -1824,6 +1913,15 @@
|
|||
"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": {
|
||||
"version": "2.11.19",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
|
|
@ -1974,6 +2084,19 @@
|
|||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -2087,6 +2210,15 @@
|
|||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
|
|
@ -2247,6 +2379,12 @@
|
|||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
|
|
@ -2463,6 +2601,23 @@
|
|||
"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": {
|
||||
"version": "3.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
|
|
@ -3753,6 +3934,42 @@
|
|||
],
|
||||
"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": {
|
||||
"version": "0.40.0",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
|
||||
|
|
@ -3853,6 +4070,31 @@
|
|||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"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": {
|
||||
"version": "2.4.5",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
|
|
@ -4351,6 +4599,15 @@
|
|||
"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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
|
|
@ -4437,6 +4694,15 @@
|
|||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
|
@ -5282,6 +5548,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/static": "^10.1.3",
|
||||
"defuddle": "^0.19.3",
|
||||
"fastify": "^5.2.1",
|
||||
"linkedom": "^0.18.13",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/static": "^10.1.3",
|
||||
"defuddle": "^0.19.3",
|
||||
"fastify": "^5.2.1",
|
||||
"linkedom": "^0.18.13",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
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 * as dbmod from "./db.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 {
|
||||
streamOpenRouter,
|
||||
|
|
@ -35,6 +37,46 @@ const PORT = Number(process.env.PORT ?? 8788);
|
|||
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
||||
const MODEL = process.env.MODEL ?? "qwen3.5:latest";
|
||||
|
||||
/**
|
||||
* Wie lange auf die Freigabe eines bestätigungspflichtigen Werkzeugs gewartet
|
||||
* wird. Danach gilt "abgelehnt" — eine Antwort soll nicht ewig hängen, nur
|
||||
* weil niemand am Rechner sitzt.
|
||||
*/
|
||||
const CONFIRM_TIMEOUT_MS = 120_000;
|
||||
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
|
||||
const CHAT_LIMIT_PER_MIN = 30;
|
||||
|
||||
/**
|
||||
* 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_BATCH = 10;
|
||||
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 ---
|
||||
interface ChatOptionsPayload {
|
||||
think: boolean;
|
||||
|
|
@ -325,7 +386,54 @@ function broadcast(data: unknown) {
|
|||
}
|
||||
|
||||
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||
let activeAbort: AbortController | null = null;
|
||||
// Mehrere Antworten können parallel laufen (zweite Nachricht bei laufendem
|
||||
// Stream). Ein einzelnes Feld würde beim Abbruch nur die letzte erwischen.
|
||||
const activeAborts = new Set<AbortController>();
|
||||
const pendingConfirms = new Map<string, { name: string; decide(ok: boolean): void }>();
|
||||
/** Werkzeuge, die der Nutzer für diese Verbindung generell freigegeben hat. */
|
||||
const alwaysAllowed = new Set<string>();
|
||||
const chatLimiter = createRateLimiter(CHAT_LIMIT_PER_MIN, 60_000);
|
||||
|
||||
function denyAllConfirms() {
|
||||
for (const entry of [...pendingConfirms.values()]) entry.decide(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt den Nutzer, bevor ein Werkzeug mit Außenwirkung läuft. Bricht die
|
||||
* Verbindung weg oder bleibt die Antwort aus, gilt das als Ablehnung.
|
||||
*/
|
||||
function confirmTool(
|
||||
messageId: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
if (alwaysAllowed.has(name)) return Promise.resolve(true);
|
||||
if (socket.readyState !== WebSocket.OPEN) return Promise.resolve(false);
|
||||
|
||||
const id = randomUUID();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => decide(false), CONFIRM_TIMEOUT_MS);
|
||||
function decide(approved: boolean) {
|
||||
clearTimeout(timer);
|
||||
pendingConfirms.delete(id);
|
||||
resolve(approved);
|
||||
}
|
||||
pendingConfirms.set(id, { name, decide });
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "tool-confirm",
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
// Ungekürzt: der Nutzer muss genau sehen, was rausgeht — bei
|
||||
// read_webpage ist die vollständige URL der eigentliche Punkt.
|
||||
args: JSON.stringify(args),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on("close", denyAllConfirms);
|
||||
|
||||
socket.on("message", async (raw: Buffer) => {
|
||||
let msg: Record<string, unknown>;
|
||||
|
|
@ -336,13 +444,31 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "tool-confirm-reply") {
|
||||
const entry = pendingConfirms.get(String(msg.id ?? ""));
|
||||
if (!entry) return;
|
||||
// Der Werkzeugname kommt aus dem Server-Zustand, nicht aus der Antwort:
|
||||
// sonst könnte eine Freigabe für ein Werkzeug ein anderes freischalten.
|
||||
if (msg.decision === "always") alwaysAllowed.add(entry.name);
|
||||
entry.decide(msg.decision === "allow" || msg.decision === "always");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "abort") {
|
||||
activeAbort?.abort();
|
||||
for (const controller of activeAborts) controller.abort();
|
||||
denyAllConfirms();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== "chat") return;
|
||||
|
||||
if (!chatLimiter("chat")) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", error: "Zu viele Anfragen — kurz warten." }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = String(msg.sessionId ?? "");
|
||||
const content = String(msg.content ?? "").trim();
|
||||
|
||||
|
|
@ -423,7 +549,8 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
|
||||
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
|
||||
|
||||
activeAbort = new AbortController();
|
||||
const abort = new AbortController();
|
||||
activeAborts.add(abort);
|
||||
let full = "";
|
||||
let thinking = "";
|
||||
const callbacks = {
|
||||
|
|
@ -463,6 +590,27 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
JSON.stringify({ type: "tool-result", messageId: assistantRow.id, name, ok, durationMs }),
|
||||
);
|
||||
},
|
||||
onToolConfirm(name: string, args: Record<string, unknown>) {
|
||||
return confirmTool(assistantRow.id, name, args);
|
||||
},
|
||||
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 {
|
||||
|
|
@ -479,10 +627,10 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
},
|
||||
apiKey,
|
||||
callbacks,
|
||||
activeAbort.signal,
|
||||
abort.signal,
|
||||
);
|
||||
} else {
|
||||
await streamChat(history, system, opts, callbacks, activeAbort.signal);
|
||||
await streamChat(history, system, opts, callbacks, abort.signal);
|
||||
}
|
||||
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||
mem.appendEvent(session.id, "message", {
|
||||
|
|
@ -493,7 +641,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
JSON.stringify({
|
||||
type: "done",
|
||||
messageId: assistantRow.id,
|
||||
aborted: activeAbort.signal.aborted,
|
||||
aborted: abort.signal.aborted,
|
||||
}),
|
||||
);
|
||||
broadcast({ type: "sessions-changed" });
|
||||
|
|
@ -507,7 +655,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
}
|
||||
} catch (err) {
|
||||
const aborted =
|
||||
activeAbort.signal.aborted ||
|
||||
abort.signal.aborted ||
|
||||
(err instanceof Error && err.name === "AbortError");
|
||||
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||
if (aborted) {
|
||||
|
|
@ -522,7 +670,7 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
|||
);
|
||||
}
|
||||
} 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" }, () => {
|
||||
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)",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@ export interface OllamaOptions {
|
|||
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 {
|
||||
onThinking(text: string): void;
|
||||
onToken(text: string): void;
|
||||
|
|
@ -24,6 +42,16 @@ export interface StreamCallbacks {
|
|||
onToolCall?(name: string, args: Record<string, unknown>): void;
|
||||
/** Werkzeug ist fertig. */
|
||||
onToolResult?(name: string, ok: boolean, durationMs: number): void;
|
||||
/**
|
||||
* Holt die Freigabe des Nutzers für ein bestätigungspflichtiges Werkzeug.
|
||||
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
|
||||
*/
|
||||
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
|
||||
/**
|
||||
* 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. */
|
||||
|
|
@ -42,8 +70,22 @@ interface ChatChunk {
|
|||
};
|
||||
done?: boolean;
|
||||
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 {
|
||||
role: string;
|
||||
content: string;
|
||||
|
|
@ -90,7 +132,23 @@ async function streamOnce(
|
|||
opts: OllamaOptions,
|
||||
cb: StreamCallbacks,
|
||||
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> = {
|
||||
model: opts.model,
|
||||
messages,
|
||||
|
|
@ -139,6 +197,9 @@ async function streamOnce(
|
|||
continue;
|
||||
}
|
||||
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?.content) {
|
||||
content += chunk.message.content;
|
||||
|
|
@ -150,10 +211,13 @@ async function streamOnce(
|
|||
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),
|
||||
];
|
||||
|
||||
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++) {
|
||||
const lastRound = round === MAX_TOOL_ROUNDS;
|
||||
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
|
||||
// statt eines weiteren Aufrufwunsches.
|
||||
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) {
|
||||
await cb.onStats?.(total);
|
||||
cb.onDone();
|
||||
return;
|
||||
}
|
||||
|
|
@ -200,11 +289,18 @@ export async function streamChat(
|
|||
|
||||
for (const call of toolCalls) {
|
||||
cb.onToolCall?.(call.name, call.arguments);
|
||||
const result = await runTool(call, { signal, sessionId: opts.sessionId });
|
||||
const result = await runTool(call, {
|
||||
signal,
|
||||
sessionId: opts.sessionId,
|
||||
confirm: cb.onToolConfirm
|
||||
? (c) => cb.onToolConfirm!(c.name, c.arguments)
|
||||
: undefined,
|
||||
});
|
||||
cb.onToolResult?.(result.name, result.ok, result.durationMs);
|
||||
messages.push({ role: "tool", tool_name: result.name, content: result.content });
|
||||
}
|
||||
}
|
||||
|
||||
await cb.onStats?.(total);
|
||||
cb.onDone();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mimeFromBase64 } from "./images.js";
|
||||
import type { ChatStats } from "./ollama.js";
|
||||
|
||||
export interface OpenRouterOptions {
|
||||
model: string;
|
||||
|
|
@ -10,6 +11,8 @@ export interface StreamCallbacks {
|
|||
onThinking(text: string): void;
|
||||
onToken(text: string): void;
|
||||
onDone(): void;
|
||||
/** Messwerte, sobald die Antwort steht. Wird vor `done` abgewartet. */
|
||||
onStats?(stats: ChatStats): void | Promise<void>;
|
||||
}
|
||||
|
||||
export function getOpenRouterKey(): string | undefined {
|
||||
|
|
@ -17,7 +20,13 @@ export function getOpenRouterKey(): string | undefined {
|
|||
}
|
||||
|
||||
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");
|
||||
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
|
||||
|
|
@ -26,15 +35,17 @@ export async function listOpenRouterModels(): Promise<
|
|||
id: string;
|
||||
name: string;
|
||||
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
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
contextLength: m.context_length,
|
||||
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));
|
||||
}
|
||||
|
|
@ -65,6 +76,10 @@ export async function streamOpenRouter(
|
|||
cb: StreamCallbacks,
|
||||
signal: AbortSignal,
|
||||
): 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", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -76,6 +91,8 @@ export async function streamOpenRouter(
|
|||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
stream: true,
|
||||
// Ohne das kommt kein usage-Block und die Tokenzahlen blieben leer.
|
||||
stream_options: { include_usage: true },
|
||||
messages: [
|
||||
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
||||
...history.map(toOpenAIMessage),
|
||||
|
|
@ -95,6 +112,27 @@ export async function streamOpenRouter(
|
|||
const decoder = new TextDecoder();
|
||||
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) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
|
@ -107,6 +145,7 @@ export async function streamOpenRouter(
|
|||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (payload === "[DONE]") {
|
||||
await finish();
|
||||
cb.onDone();
|
||||
return;
|
||||
}
|
||||
|
|
@ -116,6 +155,7 @@ export async function streamOpenRouter(
|
|||
finish_reason?: string | null;
|
||||
}[];
|
||||
error?: { message?: string };
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
try {
|
||||
chunk = JSON.parse(payload);
|
||||
|
|
@ -123,14 +163,24 @@ export async function streamOpenRouter(
|
|||
continue;
|
||||
}
|
||||
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;
|
||||
if (delta?.reasoning || delta?.content) firstTokenAt ??= Date.now();
|
||||
if (delta?.reasoning) cb.onThinking(delta.reasoning);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await finish();
|
||||
cb.onDone();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ interface Bucket {
|
|||
resetAt: number;
|
||||
}
|
||||
|
||||
/** Ab dieser Größe wird aufgeräumt — reicht für jede realistische Nutzung. */
|
||||
const MAX_BUCKETS = 1000;
|
||||
|
||||
/**
|
||||
* Einfacher In-Memory-Zähler pro Zeitfenster. Bremst teure Endpunkte
|
||||
* (Whisper läuft bis zu 180 s) gegen versehentliche oder böswillige Fluten.
|
||||
|
|
@ -46,8 +49,16 @@ interface Bucket {
|
|||
export function createRateLimiter(limit: number, windowMs: number) {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Abgelaufene Zähler wegräumen, damit die Map nicht unbegrenzt wächst. */
|
||||
function sweep(now: number) {
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now >= bucket.resetAt) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return function allow(key: string): boolean {
|
||||
const now = Date.now();
|
||||
if (buckets.size > MAX_BUCKETS) sweep(now);
|
||||
const bucket = buckets.get(key);
|
||||
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ export function toolNames(): string[] {
|
|||
return REGISTRY.map((t) => t.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitlimit für das Ergebnis. Der `signal` unten bricht das Werkzeug zusätzlich
|
||||
* ab — beides zusammen, weil ein Werkzeug den Signal auch ignorieren kann und
|
||||
* die Antwort dann trotzdem nicht ewig hängen darf.
|
||||
*/
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, name: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
|
|
@ -74,9 +79,26 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
|
|||
};
|
||||
}
|
||||
|
||||
if (tool.requiresConfirmation && !(await isApproved(call, ctx))) {
|
||||
return {
|
||||
name: tool.name,
|
||||
content: JSON.stringify({
|
||||
error:
|
||||
"Vom Nutzer abgelehnt. Nicht erneut aufrufen — ohne dieses Werkzeug " +
|
||||
"weitermachen und sagen, was dadurch fehlt.",
|
||||
}),
|
||||
ok: false,
|
||||
durationMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Zeitlimit und Nutzer-Abbruch als ein Signal, das an das Werkzeug geht:
|
||||
// damit endet auch ein laufender Netzwerkabruf, statt weiterzulaufen.
|
||||
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(TOOL_TIMEOUT_MS)]);
|
||||
|
||||
try {
|
||||
const value = await withTimeout(
|
||||
tool.run(call.arguments, ctx),
|
||||
tool.run(call.arguments, { ...ctx, signal }),
|
||||
TOOL_TIMEOUT_MS,
|
||||
tool.name,
|
||||
);
|
||||
|
|
@ -98,3 +120,17 @@ export async function runTool(call: ToolCall, ctx: ToolContext): Promise<ToolRes
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freigabe für ein bestätigungspflichtiges Werkzeug. Ohne Rückkanal (Skripte,
|
||||
* Tests) gilt "abgelehnt" — die Bestätigung soll sich nicht dadurch umgehen
|
||||
* lassen, dass niemand zum Fragen da ist.
|
||||
*/
|
||||
async function isApproved(call: ToolCall, ctx: ToolContext): Promise<boolean> {
|
||||
if (!ctx.confirm) return false;
|
||||
try {
|
||||
return await ctx.confirm(call);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { Tool } from "./types.js";
|
|||
export const rememberTool: Tool = {
|
||||
name: "remember",
|
||||
description:
|
||||
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte.",
|
||||
"Speichert einen dauerhaft wichtigen Punkt als Ankerpunkt im Gedächtnis des aktuellen Chats. Nur für Fakten, Entscheidungen, Präferenzen oder offene Punkte — nicht für flüchtige Inhalte. Der Nutzer muss jeden Aufruf freigeben.",
|
||||
requiresConfirmation: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
|
|
@ -27,6 +27,10 @@ export const rememberTool: Tool = {
|
|||
const text = String(args.text ?? "").trim();
|
||||
if (!ctx.sessionId) throw new ToolError("Keine Sitzung für das Gedächtnis bekannt");
|
||||
if (text.length < 6) throw new ToolError("Text ist zu kurz, um ihn zu merken");
|
||||
// Bewusst ungepinnt: ein vom Modell gesetzter Anker soll dem normalen
|
||||
// Verfall unterliegen. Gepinnt wird nur, was der Nutzer im
|
||||
// Gedächtnis-Panel selbst mit ★ markiert — sonst überlebt ein einmal
|
||||
// untergeschobener "Fakt" jede Traumphase und jede Rekonstruktion.
|
||||
const result = upsertAnchor(ctx.sessionId, {
|
||||
text,
|
||||
kind: ANCHOR_KINDS.includes(args.kind as AnchorKind)
|
||||
|
|
@ -34,7 +38,6 @@ export const rememberTool: Tool = {
|
|||
: "fact",
|
||||
importance: 0.9,
|
||||
origin: "model",
|
||||
pinned: true,
|
||||
});
|
||||
return { stored: result, text };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export interface ToolContext {
|
|||
signal: AbortSignal;
|
||||
/** Sitzung des aktuellen Chats — für Werkzeuge mit Gedächtniszugriff. */
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Holt die Freigabe des Nutzers für ein Werkzeug mit Außenwirkung.
|
||||
* Fehlt der Rückkanal, werden bestätigungspflichtige Werkzeuge abgelehnt —
|
||||
* lieber nicht ausführen als ungefragt.
|
||||
*/
|
||||
confirm?(call: ToolCall): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
|
|
@ -17,9 +23,9 @@ export interface Tool {
|
|||
description: string;
|
||||
parameters: ToolSchema;
|
||||
/**
|
||||
* Kennzeichnet Werkzeuge mit Außenwirkung (schreibend, Netzwerk, Server).
|
||||
* Bisher gibt es nur lesende Werkzeuge; das Feld existiert, damit die
|
||||
* Bestätigungspflicht später nicht nachträglich eingezogen werden muss.
|
||||
* Kennzeichnet Werkzeuge mit Außenwirkung (Netzwerk, dauerhafter Speicher).
|
||||
* `runTool` fragt vor der Ausführung über `ToolContext.confirm` beim Nutzer
|
||||
* nach und lehnt ab, wenn keine Freigabe kommt.
|
||||
*/
|
||||
requiresConfirmation?: boolean;
|
||||
/** Gibt zurück, was dem Modell als Ergebnis gezeigt wird. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import dns from "node:dns";
|
||||
import { request as httpRequest, type IncomingMessage } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { isIP, type LookupFunction } from "node:net";
|
||||
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { Defuddle } from "defuddle/node";
|
||||
import { ToolError } from "./types.js";
|
||||
|
|
@ -9,6 +12,8 @@ const TIMEOUT_MS = 15_000;
|
|||
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_CONTENT_CHARS = 25_000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const ALLOWED_TYPES =
|
||||
/text\/html|text\/plain|application\/xhtml|application\/json|application\/xml|text\/markdown/;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 agenttwo-readweb/1.0";
|
||||
|
||||
|
|
@ -39,23 +44,53 @@ function isPrivateIP(ip: string): boolean {
|
|||
first.startsWith("fe9") || first.startsWith("fea") || first.startsWith("feb");
|
||||
}
|
||||
|
||||
/** `new URL().hostname` liefert IPv6-Literale in Klammern: [::1] -> ::1. */
|
||||
function bareHost(hostname: string): string {
|
||||
return hostname.startsWith("[") && hostname.endsWith("]")
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-Schutz: Der Server löst den Host selbst auf und weist private Bereiche
|
||||
* ab. Ohne das könnte das Modell http://localhost:8788/api/sessions lesen —
|
||||
* der Origin-Check schützt nicht vor server-eigenem fetch.
|
||||
* DNS-Auflösung, die private Adressen ablehnt — eingehängt als `lookup` der
|
||||
* HTTP-Verbindung.
|
||||
*
|
||||
* Entscheidend ist, dass genau diese Auflösung auch verbunden wird. Ein
|
||||
* getrennter Vorab-Check (wie ihn `fetch` erzwingt, das selbst noch einmal
|
||||
* auflöst) ließe DNS-Rebinding zu: öffentlich bei der Prüfung, 127.0.0.1 beim
|
||||
* Verbinden.
|
||||
*/
|
||||
const guardedLookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, options, (err, address, family) => {
|
||||
if (err) return callback(err, "", 0);
|
||||
const addresses = Array.isArray(address) ? address : [{ address, family }];
|
||||
for (const a of addresses) {
|
||||
if (isPrivateIP(a.address)) {
|
||||
return callback(new ToolError("Zugriff auf private Adressen ist gesperrt"), "", 0);
|
||||
}
|
||||
}
|
||||
callback(null, address as string, family);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Vorab-Prüfung, rein für die Fehlermeldung: so bekommt das Modell "private
|
||||
* Adresse gesperrt" statt eines generischen Verbindungsfehlers. Die
|
||||
* verbindliche Grenze ist `guardedLookup`.
|
||||
*/
|
||||
async function assertPublicHost(hostname: string): Promise<void> {
|
||||
if (isIP(hostname)) {
|
||||
if (isPrivateIP(hostname)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
|
||||
const host = bareHost(hostname);
|
||||
if (isIP(host)) {
|
||||
if (isPrivateIP(host)) throw new ToolError("Zugriff auf private Adressen ist gesperrt");
|
||||
return;
|
||||
}
|
||||
let addrs: { address: string }[];
|
||||
try {
|
||||
addrs = await lookup(hostname, { all: true, verbatim: true });
|
||||
addrs = await dns.promises.lookup(host, { all: true, verbatim: true });
|
||||
} catch {
|
||||
throw new ToolError(`Host nicht auflösbar: ${hostname}`);
|
||||
throw new ToolError(`Host nicht auflösbar: ${host}`);
|
||||
}
|
||||
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${hostname}`);
|
||||
if (addrs.length === 0) throw new ToolError(`Host nicht auflösbar: ${host}`);
|
||||
for (const a of addrs) {
|
||||
if (isPrivateIP(a.address)) {
|
||||
throw new ToolError("Zugriff auf private Adressen ist gesperrt");
|
||||
|
|
@ -76,49 +111,93 @@ function assertHttpUrl(raw: string): URL {
|
|||
return url;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: string }> {
|
||||
let url = assertHttpUrl(rawUrl).toString();
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
await assertPublicHost(new URL(url).hostname);
|
||||
|
||||
const res = await fetch(url, {
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": USER_AGENT, Accept: "text/html, text/plain, application/xhtml+xml" },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
/** Ein GET mit gepinnter Auflösung. Weiterleitungen bleiben Sache des Aufrufers. */
|
||||
function send(url: URL, signal: AbortSignal): Promise<IncomingMessage> {
|
||||
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
lookup: guardedLookup,
|
||||
signal,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: {
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: "text/html, text/plain, application/xhtml+xml",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
},
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
req.on("timeout", () => req.destroy(new ToolError("Zeitlimit beim Abruf überschritten")));
|
||||
req.on("error", (err) => {
|
||||
if (err instanceof ToolError) return reject(err);
|
||||
if (signal.aborted) return reject(new ToolError("Abruf abgebrochen"));
|
||||
reject(new ToolError(`Abruf fehlgeschlagen: ${url.host}`));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
/** Antwortkörper bis MAX_HTML_BYTES lesen, komprimierte Antworten auspacken. */
|
||||
async function readCapped(res: IncomingMessage): Promise<string> {
|
||||
const encoding = String(res.headers["content-encoding"] ?? "").toLowerCase();
|
||||
const stream =
|
||||
encoding === "gzip" ? res.pipe(createGunzip())
|
||||
: encoding === "deflate" ? res.pipe(createInflate())
|
||||
: encoding === "br" ? res.pipe(createBrotliDecompress())
|
||||
: res;
|
||||
|
||||
const 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();
|
||||
if (!reader) throw new ToolError("Leere Antwort");
|
||||
const decoder = new TextDecoder();
|
||||
let html = "";
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_HTML_BYTES) {
|
||||
void reader.cancel();
|
||||
html += decoder.decode(value, { stream: true });
|
||||
break;
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
bytes += chunk.byteLength;
|
||||
html += decoder.decode(chunk, { stream: true });
|
||||
if (bytes > MAX_HTML_BYTES) break;
|
||||
}
|
||||
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})`);
|
||||
}
|
||||
|
|
@ -126,7 +205,10 @@ async function fetchWithGuards(rawUrl: string): Promise<{ url: string; body: str
|
|||
export const readWebpageTool: Tool = {
|
||||
name: "read_webpage",
|
||||
description:
|
||||
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen.",
|
||||
"Liest eine öffentliche Website und gibt den Hauptinhalt als Markdown zurück (Titel, Autor, Text). Nur für öffentliche URLs — lokale/private Adressen werden abgewiesen. Der Nutzer muss jeden Aufruf freigeben.",
|
||||
// Der Abruf verlässt den Rechner: die URL selbst ist ein Kanal nach außen.
|
||||
// Deshalb sieht der Nutzer sie vor dem Aufruf und gibt sie frei.
|
||||
requiresConfirmation: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -134,11 +216,11 @@ export const readWebpageTool: Tool = {
|
|||
},
|
||||
required: ["url"],
|
||||
},
|
||||
async run(args) {
|
||||
async run(args, ctx) {
|
||||
const raw = String(args.url ?? "").trim();
|
||||
if (!raw) throw new ToolError("url fehlt");
|
||||
|
||||
const { url, body } = await fetchWithGuards(raw);
|
||||
const { url, body } = await fetchWithGuards(raw, ctx.signal);
|
||||
const { document } = parseHTML(body);
|
||||
const result = await Defuddle(document, url, { markdown: true });
|
||||
|
||||
|
|
|
|||
74
web/dist/assets/index-CQxQcFyH.js
vendored
74
web/dist/assets/index-CQxQcFyH.js
vendored
File diff suppressed because one or more lines are too long
10
web/dist/assets/index-CXQcDbVB.css
vendored
10
web/dist/assets/index-CXQcDbVB.css
vendored
File diff suppressed because one or more lines are too long
7
web/dist/favicon.svg
vendored
7
web/dist/favicon.svg
vendored
|
|
@ -1,7 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="agenttwo-tools">
|
||||
<rect width="32" height="32" rx="7" fill="#0a0f0c"/>
|
||||
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#1e2c23" stroke-width="1.5"/>
|
||||
<path d="M8 10.5 L13.5 16 L8 21.5" fill="none" stroke="#3ddc84" stroke-width="3.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="16.5" y="19" width="8.5" height="3" rx="1.5" fill="#ffb454"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 490 B |
15
web/dist/index.html
vendored
15
web/dist/index.html
vendored
|
|
@ -1,15 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#0a0f0c" />
|
||||
<title>agenttwo-tools — qwen3 mit Vision</title>
|
||||
<script type="module" crossorigin src="/assets/index-CQxQcFyH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CXQcDbVB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,6 +5,8 @@ import { Sidebar } from "./components/Sidebar";
|
|||
import { ChatMessage } from "./components/ChatMessage";
|
||||
import { Composer } from "./components/Composer";
|
||||
import { MemoryPanel } from "./components/MemoryPanel";
|
||||
import { ToolConfirm } from "./components/ToolConfirm";
|
||||
import { StatsBar } from "./components/StatsBar";
|
||||
import type { OpenRouterModel, OllamaModel } from "./types";
|
||||
|
||||
const VOICE_KEY = "oxagenttwo.voiceMode";
|
||||
|
|
@ -48,6 +50,22 @@ export default function App() {
|
|||
}
|
||||
}, [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 streamingRef = useRef(false);
|
||||
const awaitingDrainRef = useRef(false);
|
||||
|
|
@ -434,6 +452,10 @@ export default function App() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{chat.toolConfirm && (
|
||||
<ToolConfirm request={chat.toolConfirm} onDecide={chat.decideToolConfirm} />
|
||||
)}
|
||||
|
||||
{voice.error && (
|
||||
<div className="voice-error">
|
||||
{voice.error}
|
||||
|
|
@ -441,6 +463,13 @@ export default function App() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<StatsBar
|
||||
stats={chat.stats}
|
||||
live={chat.live}
|
||||
totals={chat.totals}
|
||||
contextLength={activeOrModel?.contextLength}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
streaming={chat.streaming}
|
||||
disabled={!chat.activeId}
|
||||
|
|
|
|||
87
web/src/components/StatsBar.tsx
Normal file
87
web/src/components/StatsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
web/src/components/ToolConfirm.tsx
Normal file
71
web/src/components/ToolConfirm.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { ToolConfirmRequest, ToolDecision } from "../types";
|
||||
|
||||
/** Kurzer Satz, was dieser Aufruf tatsächlich tut — pro Werkzeug. */
|
||||
const WHAT_HAPPENS: Record<string, string> = {
|
||||
read_webpage: "Diese Adresse wird von deinem Rechner abgerufen — inklusive allem, was in der URL steht.",
|
||||
remember: "Dieser Punkt landet dauerhaft im Gedächtnis und geht künftig in jeden Chat mit ein.",
|
||||
};
|
||||
|
||||
/** Argumente als lesbare Zeilen; unlesbares JSON fällt auf den Rohtext zurück. */
|
||||
function argLines(args: string): [string, string][] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
typeof v === "string" ? v : JSON.stringify(v),
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
/* unten als Rohtext */
|
||||
}
|
||||
return [["", args]];
|
||||
}
|
||||
|
||||
export function ToolConfirm({
|
||||
request,
|
||||
onDecide,
|
||||
}: {
|
||||
request: ToolConfirmRequest;
|
||||
onDecide: (id: string, decision: ToolDecision) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="tool-confirm" role="alertdialog" aria-label="Werkzeug freigeben">
|
||||
<div className="tool-confirm-head">
|
||||
<span className="tool-confirm-mark">⚠</span>
|
||||
<span>
|
||||
Das Modell möchte <code>{request.name}</code> ausführen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="tool-confirm-args">
|
||||
{argLines(request.args).map(([key, value], i) => (
|
||||
<div className="tool-confirm-arg" key={`${key}-${i}`}>
|
||||
{key && <dt>{key}</dt>}
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{WHAT_HAPPENS[request.name] && (
|
||||
<p className="tool-confirm-hint">{WHAT_HAPPENS[request.name]}</p>
|
||||
)}
|
||||
|
||||
<div className="tool-confirm-actions">
|
||||
<button className="btn-decide deny" onClick={() => onDecide(request.id, "deny")}>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button className="btn-decide allow" onClick={() => onDecide(request.id, "allow")}>
|
||||
Einmal zulassen
|
||||
</button>
|
||||
<button
|
||||
className="btn-decide always"
|
||||
title="Gilt für dieses Werkzeug, bis die Seite neu geladen wird"
|
||||
onClick={() => onDecide(request.id, "always")}
|
||||
>
|
||||
Immer zulassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -896,3 +896,161 @@ body {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Werkzeug-Freigabe --- */
|
||||
.tool-confirm {
|
||||
margin: 0 16px 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--accent-warm);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 180, 84, 0.07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-confirm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tool-confirm-mark {
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
|
||||
.tool-confirm-head code {
|
||||
color: var(--accent-warm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-confirm-args {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.tool-confirm-arg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-confirm-arg dt {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tool-confirm-arg dd {
|
||||
flex: 1 1 auto;
|
||||
/* Umbrechen statt abschneiden: eine gekürzte URL wäre wertlos zum Prüfen. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tool-confirm-hint {
|
||||
font-size: 0.82em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tool-confirm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-decide {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-decide:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn-decide.allow {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-decide.allow:hover {
|
||||
background: rgba(61, 220, 132, 0.1);
|
||||
}
|
||||
|
||||
.btn-decide.always {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn-decide.deny:hover {
|
||||
border-color: #f85149;
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.btn-decide:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,44 @@ export interface ToolEvent {
|
|||
durationMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rückfrage des Servers, bevor ein Werkzeug mit Außenwirkung läuft.
|
||||
* `args` ist das vollständige JSON — bei read_webpage steckt darin die URL,
|
||||
* die der Rechner sonst ungefragt abrufen würde.
|
||||
*/
|
||||
export interface ToolConfirmRequest {
|
||||
id: string;
|
||||
messageId: string;
|
||||
name: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export type ToolDecision = "allow" | "always" | "deny";
|
||||
|
||||
/** 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 {
|
||||
model: string;
|
||||
think: boolean;
|
||||
|
|
@ -90,7 +128,9 @@ export interface OpenRouterModel {
|
|||
id: string;
|
||||
name: string;
|
||||
contextLength: number;
|
||||
/** Preis je 1 Mio. Tokens in USD. */
|
||||
promptPrice: number;
|
||||
completionPrice: number;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 interface ModelInfo {
|
||||
|
|
@ -46,6 +55,21 @@ export function useChat() {
|
|||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [toolEvents, setToolEvents] = useState<Record<string, ToolEvent[]>>({});
|
||||
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
|
||||
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
|
||||
const [toolConfirms, setToolConfirms] = useState<ToolConfirmRequest[]>([]);
|
||||
const [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 [systemPrompt, setSystemPromptState] = useState(
|
||||
() => localStorage.getItem(SYSTEM_KEY) ?? "",
|
||||
|
|
@ -83,6 +107,11 @@ export function useChat() {
|
|||
);
|
||||
} else if (t === "token") {
|
||||
const text = data.text as string;
|
||||
setLive((cur) =>
|
||||
cur
|
||||
? { ...cur, tokens: cur.tokens + 1 }
|
||||
: { tokens: 1, startedAt: Date.now() },
|
||||
);
|
||||
setMessages((prev) =>
|
||||
prev.map((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 };
|
||||
});
|
||||
} 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") {
|
||||
streamingRef.current = 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") {
|
||||
void refreshSessions();
|
||||
}
|
||||
|
|
@ -184,6 +246,8 @@ export function useChat() {
|
|||
}
|
||||
streamingRef.current = true;
|
||||
setStreaming(true);
|
||||
setLive(null);
|
||||
setStats(null);
|
||||
socketRef.current?.send({
|
||||
type: "chat",
|
||||
sessionId: activeId,
|
||||
|
|
@ -199,6 +263,12 @@ export function useChat() {
|
|||
|
||||
const abort = useCallback(() => {
|
||||
socketRef.current?.send({ type: "abort" });
|
||||
setToolConfirms([]);
|
||||
}, []);
|
||||
|
||||
const decideToolConfirm = useCallback((id: string, decision: ToolDecision) => {
|
||||
socketRef.current?.send({ type: "tool-confirm-reply", id, decision });
|
||||
setToolConfirms((prev) => prev.filter((c) => c.id !== id));
|
||||
}, []);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
|
|
@ -226,6 +296,14 @@ export function useChat() {
|
|||
messages,
|
||||
streaming,
|
||||
toolEvents,
|
||||
toolConfirm: toolConfirms[0] ?? null,
|
||||
decideToolConfirm,
|
||||
stats,
|
||||
live,
|
||||
totals,
|
||||
setModelPricing: (p: { prompt: number; completion: number } | null) => {
|
||||
priceRef.current = p;
|
||||
},
|
||||
sendMessage,
|
||||
abort,
|
||||
newSession,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue