mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 23:12: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
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