mirror of
https://github.com/Jeuners/agenttwo.git
synced 2026-09-09 15:02:31 +02:00
init: oxagenttwo — voice chat with local qwen3 (Ollama) + OpenRouter, Whisper STT, Piper TTS (Thorsten), sessions, thinking mode
This commit is contained in:
commit
752198c5fa
26 changed files with 7690 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
data.sqlite*
|
||||||
|
|
||||||
|
# Piper-Stimmmodell (109 MB) — siehe README, wird lokal heruntergeladen
|
||||||
|
server/voices/*.onnx
|
||||||
101
README.md
Normal file
101
README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# oxagenttwo
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- [Ollama](https://ollama.com) mit einem Qwen3-Modell (`ollama pull qwen3.5`)
|
||||||
|
- `ffmpeg` im `PATH`
|
||||||
|
- `whisper-cli` im `PATH` (whisper.cpp) inklusive Modell
|
||||||
|
- `piper` im `PATH`
|
||||||
|
|
||||||
|
Unter macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install ffmpeg whisper-cpp piper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Whisper-Modell
|
||||||
|
|
||||||
|
Wird per Default unter `~/whisper-models/ggml-large-v3-turbo.bin` erwartet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/whisper-models
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p server/voices
|
||||||
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENROUTER_API_KEY=sk-or-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Weitere optionale Variablen mit ihren Defaults:
|
||||||
|
|
||||||
|
| Variable | Default |
|
||||||
|
|----------------------|--------------------------------------------------|
|
||||||
|
| `PORT` | `8787` |
|
||||||
|
| `OLLAMA_URL` | `http://localhost:11434` |
|
||||||
|
| `MODEL` | `qwen3.5:latest` |
|
||||||
|
| `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` |
|
||||||
|
| `WHISPER_LANG` | `de` |
|
||||||
|
| `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` |
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Server (:8787) und Vite-Dev-Server (:5173) parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzeln:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
Typecheck über beide Workspaces:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Produktion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # baut das Frontend und startet den Server
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Server bindet bewusst nur an `127.0.0.1` — für externen Zugriff einen
|
||||||
|
Reverse-Proxy davorschalten.
|
||||||
4722
package-lock.json
generated
Normal file
4722
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "oxagenttwo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --workspace server & npm run dev --workspace web & wait",
|
||||||
|
"dev:server": "npm run dev --workspace server",
|
||||||
|
"dev:web": "npm run dev --workspace web",
|
||||||
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
|
"start": "npm run build --workspace web && npm start --workspace server"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
server/package.json
Normal file
22
server/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"fastify": "^5.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.0",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server/src/db.ts
Normal file
117
server/src/db.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dbPath = path.join(import.meta.dirname, "..", "data.sqlite");
|
||||||
|
export const db = new DatabaseSync(dbPath);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Neuer Chat',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
thinking TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageRow {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
thinking: string | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions(): SessionRow[] {
|
||||||
|
return db
|
||||||
|
.prepare("SELECT * FROM sessions ORDER BY created_at DESC")
|
||||||
|
.all() as unknown as SessionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(title = "Neuer Chat"): SessionRow {
|
||||||
|
const row: SessionRow = { id: randomUUID(), title, created_at: Date.now() };
|
||||||
|
db.prepare("INSERT INTO sessions (id, title, created_at) VALUES (?, ?, ?)").run(
|
||||||
|
row.id,
|
||||||
|
row.title,
|
||||||
|
row.created_at,
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(id: string): SessionRow | undefined {
|
||||||
|
return db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as
|
||||||
|
| SessionRow
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameSessionIfDefault(id: string, firstMessage: string) {
|
||||||
|
const s = getSession(id);
|
||||||
|
if (!s || s.title !== "Neuer Chat") return;
|
||||||
|
const title =
|
||||||
|
firstMessage.trim().slice(0, 60) + (firstMessage.length > 60 ? "…" : "");
|
||||||
|
db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run(
|
||||||
|
title || "Neuer Chat",
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(id: string) {
|
||||||
|
db.prepare("DELETE FROM messages WHERE session_id = ?").run(id);
|
||||||
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMessage(
|
||||||
|
sessionId: string,
|
||||||
|
role: string,
|
||||||
|
content: string,
|
||||||
|
thinking?: string | null,
|
||||||
|
): MessageRow {
|
||||||
|
const row: MessageRow = {
|
||||||
|
id: randomUUID(),
|
||||||
|
session_id: sessionId,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
thinking: thinking ?? null,
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO messages (id, session_id, role, content, thinking, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
).run(row.id, row.session_id, row.role, row.content, row.thinking, row.created_at);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAssistantMessage(
|
||||||
|
id: string,
|
||||||
|
content: string,
|
||||||
|
thinking: string | null,
|
||||||
|
) {
|
||||||
|
db.prepare("UPDATE messages SET content = ?, thinking = ? WHERE id = ?").run(
|
||||||
|
content,
|
||||||
|
thinking,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMessages(sessionId: string): MessageRow[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.all(sessionId) as unknown as MessageRow[];
|
||||||
|
}
|
||||||
302
server/src/index.ts
Normal file
302
server/src/index.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as dbmod from "./db.js";
|
||||||
|
import { streamChat, type OllamaOptions } from "./ollama.js";
|
||||||
|
import { transcribeAudio, synthesizeSpeech } from "./voice.js";
|
||||||
|
import {
|
||||||
|
streamOpenRouter,
|
||||||
|
listOpenRouterModels,
|
||||||
|
getOpenRouterKey,
|
||||||
|
} from "./openrouter.js";
|
||||||
|
|
||||||
|
// simple .env loader (project root)
|
||||||
|
try {
|
||||||
|
for (const line of readFileSync(
|
||||||
|
path.join(import.meta.dirname, "..", "..", ".env"),
|
||||||
|
"utf8",
|
||||||
|
).split("\n")) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keine .env vorhanden */
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 8787);
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false, bodyLimit: 32 * 1024 * 1024 });
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
|
||||||
|
app.addContentTypeParser(
|
||||||
|
["application/octet-stream", "audio/*", "video/*"],
|
||||||
|
{ parseAs: "buffer" },
|
||||||
|
(_req, body, done) => done(null, body),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/api/health", async () => ({ ok: true }));
|
||||||
|
|
||||||
|
app.get("/api/model", async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${OLLAMA_URL}/api/show`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: process.env.MODEL ?? "qwen3.5:latest" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, error: `Ollama HTTP ${res.status}` };
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
details?: { parameter_size?: string; quantization_level?: string };
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
model: process.env.MODEL ?? "qwen3.5:latest",
|
||||||
|
parameterSize: data.details?.parameter_size,
|
||||||
|
quantization: data.details?.quantization_level,
|
||||||
|
capabilities: data.capabilities ?? [],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String(err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Sessions REST ---
|
||||||
|
app.get("/api/sessions", async () => dbmod.listSessions());
|
||||||
|
app.post("/api/sessions", async () => dbmod.createSession());
|
||||||
|
app.get("/api/sessions/:id/messages", async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
if (!dbmod.getSession(id)) return reply.code(404).send({ error: "not found" });
|
||||||
|
return dbmod.listMessages(id);
|
||||||
|
});
|
||||||
|
app.delete("/api/sessions/:id", async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
dbmod.deleteSession(id);
|
||||||
|
broadcast({ type: "session-deleted", id });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Voice ---
|
||||||
|
app.post("/api/stt", async (req, reply) => {
|
||||||
|
const audio = req.body as Buffer;
|
||||||
|
if (!Buffer.isBuffer(audio) || audio.length === 0) {
|
||||||
|
return reply.code(400).send({ ok: false, error: "Kein Audio empfangen" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = await transcribeAudio(audio);
|
||||||
|
return { ok: true, text };
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
return reply
|
||||||
|
.code(500)
|
||||||
|
.send({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tts", async (req, reply) => {
|
||||||
|
const { text } = (req.body ?? {}) as { text?: string };
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return reply.code(400).send({ error: "text fehlt" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const wav = await synthesizeSpeech(text);
|
||||||
|
reply.header("Content-Type", "audio/wav").send(wav);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
return reply
|
||||||
|
.code(500)
|
||||||
|
.send({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- OpenRouter models ---
|
||||||
|
app.get("/api/openrouter/models", async () => {
|
||||||
|
if (!getOpenRouterKey()) {
|
||||||
|
return { ok: false, error: "OPENROUTER_API_KEY nicht gesetzt (.env fehlt)" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { ok: true, models: await listOpenRouterModels() };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
interface ChatOptionsPayload {
|
||||||
|
think: boolean;
|
||||||
|
temperature: number;
|
||||||
|
numPredict: number;
|
||||||
|
provider?: string;
|
||||||
|
openrouterModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(raw: unknown): OllamaOptions & {
|
||||||
|
provider: "ollama" | "openrouter";
|
||||||
|
openrouterModel: string;
|
||||||
|
} {
|
||||||
|
const o = (raw ?? {}) as Partial<ChatOptionsPayload>;
|
||||||
|
return {
|
||||||
|
model: process.env.MODEL ?? "qwen3.5:latest",
|
||||||
|
think: o.think !== false,
|
||||||
|
temperature: clamp(Number(o.temperature ?? 0.7), 0, 2),
|
||||||
|
numPredict: Math.min(Math.max(Number(o.numPredict ?? 2048), 64), 16384),
|
||||||
|
provider: o.provider === "openrouter" ? "openrouter" : "ollama",
|
||||||
|
openrouterModel:
|
||||||
|
typeof o.openrouterModel === "string" &&
|
||||||
|
/^[\w./:-]{3,120}$/.test(o.openrouterModel)
|
||||||
|
? o.openrouterModel
|
||||||
|
: "anthropic/claude-sonnet-4.5",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number, min: number, max: number) {
|
||||||
|
if (!Number.isFinite(v)) return min;
|
||||||
|
return Math.min(Math.max(v, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
function broadcast(data: unknown) {
|
||||||
|
for (const client of wss.clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||||
|
let activeAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
socket.on("message", async (raw: Buffer) => {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
socket.send(JSON.stringify({ type: "error", error: "invalid json" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "abort") {
|
||||||
|
activeAbort?.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type !== "chat") return;
|
||||||
|
|
||||||
|
const sessionId = String(msg.sessionId ?? "");
|
||||||
|
const content = String(msg.content ?? "").trim();
|
||||||
|
if (!sessionId || !content) {
|
||||||
|
socket.send(JSON.stringify({ type: "error", error: "sessionId/content fehlt" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = dbmod.getSession(sessionId);
|
||||||
|
if (!session) session = dbmod.createSession();
|
||||||
|
dbmod.renameSessionIfDefault(session.id, content);
|
||||||
|
|
||||||
|
const userMsg = dbmod.insertMessage(session.id, "user", content);
|
||||||
|
socket.send(JSON.stringify({ type: "user-message", message: userMsg }));
|
||||||
|
broadcast({ type: "sessions-changed" });
|
||||||
|
|
||||||
|
const opts = parseOptions(msg.options);
|
||||||
|
const history = dbmod
|
||||||
|
.listMessages(session.id)
|
||||||
|
.slice(-24)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
|
const assistantRow = dbmod.insertMessage(session.id, "assistant", "");
|
||||||
|
socket.send(JSON.stringify({ type: "assistant-start", message: assistantRow }));
|
||||||
|
|
||||||
|
activeAbort = new AbortController();
|
||||||
|
let full = "";
|
||||||
|
let thinking = "";
|
||||||
|
const callbacks = {
|
||||||
|
onThinking(text: string) {
|
||||||
|
thinking += text;
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({ type: "thinking", text, messageId: assistantRow.id }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onToken(text: string) {
|
||||||
|
full += text;
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({ type: "token", text, messageId: assistantRow.id }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDone() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (opts.provider === "openrouter") {
|
||||||
|
const apiKey = getOpenRouterKey();
|
||||||
|
if (!apiKey) throw new Error("OPENROUTER_API_KEY nicht gesetzt (.env fehlt)");
|
||||||
|
await streamOpenRouter(
|
||||||
|
history,
|
||||||
|
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
|
||||||
|
? (msg.systemPrompt as string)
|
||||||
|
: undefined,
|
||||||
|
{
|
||||||
|
model: opts.openrouterModel,
|
||||||
|
temperature: opts.temperature,
|
||||||
|
numPredict: opts.numPredict,
|
||||||
|
},
|
||||||
|
apiKey,
|
||||||
|
callbacks,
|
||||||
|
activeAbort.signal,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await streamChat(
|
||||||
|
history,
|
||||||
|
typeof msg.systemPrompt === "string" && msg.systemPrompt.trim()
|
||||||
|
? (msg.systemPrompt as string)
|
||||||
|
: undefined,
|
||||||
|
opts,
|
||||||
|
callbacks,
|
||||||
|
activeAbort.signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "done",
|
||||||
|
messageId: assistantRow.id,
|
||||||
|
aborted: activeAbort.signal.aborted,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
broadcast({ type: "sessions-changed" });
|
||||||
|
} catch (err) {
|
||||||
|
const aborted =
|
||||||
|
activeAbort.signal.aborted ||
|
||||||
|
(err instanceof Error && err.name === "AbortError");
|
||||||
|
dbmod.updateAssistantMessage(assistantRow.id, full.trim(), thinking.trim() || null);
|
||||||
|
if (aborted) {
|
||||||
|
socket.send(JSON.stringify({ type: "done", messageId: assistantRow.id, aborted: true }));
|
||||||
|
} else {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
messageId: assistantRow.id,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeAbort = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.server;
|
||||||
|
server.on("upgrade", (req, socket, head) => {
|
||||||
|
const url = new URL(req.url ?? "", "http://localhost");
|
||||||
|
if (url.pathname === "/ws") {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen({ port: PORT, host: "127.0.0.1" }, () => {
|
||||||
|
console.log(`[oxagenttwo] Server läuft auf http://127.0.0.1:${PORT}`);
|
||||||
|
});
|
||||||
84
server/src/ollama.ts
Normal file
84
server/src/ollama.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
export interface OllamaOptions {
|
||||||
|
model: string;
|
||||||
|
think: boolean;
|
||||||
|
temperature: number;
|
||||||
|
numPredict: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamCallbacks {
|
||||||
|
onThinking(text: string): void;
|
||||||
|
onToken(text: string): void;
|
||||||
|
onDone(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatChunk {
|
||||||
|
message?: { content?: string; thinking?: string };
|
||||||
|
done?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamChat(
|
||||||
|
history: { role: string; content: string }[],
|
||||||
|
systemPrompt: string | undefined,
|
||||||
|
opts: OllamaOptions,
|
||||||
|
cb: StreamCallbacks,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: opts.model,
|
||||||
|
messages: [
|
||||||
|
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
||||||
|
...history,
|
||||||
|
],
|
||||||
|
stream: true,
|
||||||
|
think: opts.think,
|
||||||
|
options: {
|
||||||
|
temperature: opts.temperature,
|
||||||
|
num_predict: opts.numPredict,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("http://localhost:11434/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(`Ollama HTTP ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
let nlIndex: number;
|
||||||
|
while ((nlIndex = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, nlIndex).trim();
|
||||||
|
buffer = buffer.slice(nlIndex + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
let chunk: ChatChunk;
|
||||||
|
try {
|
||||||
|
chunk = JSON.parse(line) as ChatChunk;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.error) throw new Error(chunk.error);
|
||||||
|
if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking);
|
||||||
|
if (chunk.message?.content) cb.onToken(chunk.message.content);
|
||||||
|
if (chunk.done) {
|
||||||
|
cb.onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb.onDone();
|
||||||
|
}
|
||||||
116
server/src/openrouter.ts
Normal file
116
server/src/openrouter.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
export interface OpenRouterOptions {
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
numPredict: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamCallbacks {
|
||||||
|
onThinking(text: string): void;
|
||||||
|
onToken(text: string): void;
|
||||||
|
onDone(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenRouterKey(): string | undefined {
|
||||||
|
return process.env.OPENROUTER_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOpenRouterModels(): Promise<
|
||||||
|
{ id: string; name: string; contextLength: number; promptPrice: number }[]
|
||||||
|
> {
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/models");
|
||||||
|
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
context_length: number;
|
||||||
|
pricing: { prompt: string };
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
return data.data
|
||||||
|
.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
contextLength: m.context_length,
|
||||||
|
promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamOpenRouter(
|
||||||
|
history: { role: string; content: string }[],
|
||||||
|
systemPrompt: string | undefined,
|
||||||
|
opts: OpenRouterOptions,
|
||||||
|
apiKey: string,
|
||||||
|
cb: StreamCallbacks,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "http://localhost:5173",
|
||||||
|
"X-Title": "oxagenttwo",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: opts.model,
|
||||||
|
stream: true,
|
||||||
|
messages: [
|
||||||
|
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
||||||
|
...history,
|
||||||
|
],
|
||||||
|
temperature: opts.temperature,
|
||||||
|
max_tokens: opts.numPredict,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(`OpenRouter HTTP ${res.status}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
let nlIndex: number;
|
||||||
|
while ((nlIndex = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, nlIndex).trim();
|
||||||
|
buffer = buffer.slice(nlIndex + 1);
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (payload === "[DONE]") {
|
||||||
|
cb.onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chunk: {
|
||||||
|
choices?: {
|
||||||
|
delta?: { content?: string; reasoning?: string };
|
||||||
|
finish_reason?: string | null;
|
||||||
|
}[];
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
chunk = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error");
|
||||||
|
const delta = chunk.choices?.[0]?.delta;
|
||||||
|
if (delta?.reasoning) cb.onThinking(delta.reasoning);
|
||||||
|
if (delta?.content) cb.onToken(delta.content);
|
||||||
|
if (chunk.choices?.[0]?.finish_reason && !delta?.content) {
|
||||||
|
cb.onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb.onDone();
|
||||||
|
}
|
||||||
88
server/src/voice.ts
Normal file
88
server/src/voice.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir, homedir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const pExecFile = promisify(execFile);
|
||||||
|
|
||||||
|
const WHISPER_MODEL =
|
||||||
|
process.env.WHISPER_MODEL ??
|
||||||
|
path.join(homedir(), "whisper-models", "ggml-large-v3-turbo.bin");
|
||||||
|
const PIPER_MODEL =
|
||||||
|
process.env.PIPER_MODEL ??
|
||||||
|
path.join(import.meta.dirname, "..", "voices", "de_DE-thorsten-high.onnx");
|
||||||
|
const WHISPER_LANG = process.env.WHISPER_LANG ?? "de";
|
||||||
|
|
||||||
|
export async function transcribeAudio(input: Buffer): Promise<string> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "oxa-voice-"));
|
||||||
|
const rawPath = path.join(dir, "in.raw");
|
||||||
|
const wavPath = path.join(dir, "in.wav");
|
||||||
|
try {
|
||||||
|
await writeFile(rawPath, input);
|
||||||
|
await pExecFile(
|
||||||
|
"ffmpeg",
|
||||||
|
["-y", "-i", rawPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath],
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
const { stdout } = await pExecFile(
|
||||||
|
"whisper-cli",
|
||||||
|
["-m", WHISPER_MODEL, "-f", wavPath, "-nt", "-np", "-l", WHISPER_LANG],
|
||||||
|
{ timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMarkdownForSpeech(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
||||||
|
.replace(/(\*\*|__|\*|_|~~)/g, "")
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, "")
|
||||||
|
.replace(/^\s*\|.*\|\s*$/gm, "")
|
||||||
|
.replace(/https?:\/\/\S+/g, "Link")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synthesizeSpeech(text: string): Promise<Buffer> {
|
||||||
|
const cleaned = stripMarkdownForSpeech(text);
|
||||||
|
if (!cleaned) throw new Error("Kein sprechbarer Text");
|
||||||
|
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "oxa-tts-"));
|
||||||
|
const wavPath = path.join(dir, "out.wav");
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const proc = spawn(
|
||||||
|
"python3",
|
||||||
|
["-m", "piper", "-m", PIPER_MODEL, "-f", wavPath, "--sentence-silence", "0.2"],
|
||||||
|
{ stdio: ["pipe", "ignore", "pipe"] },
|
||||||
|
);
|
||||||
|
let err = "";
|
||||||
|
proc.stderr.on("data", (d) => (err += d.toString()));
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("close", (code) =>
|
||||||
|
code === 0
|
||||||
|
? resolve()
|
||||||
|
: reject(new Error(`piper exit ${code}: ${err.slice(-400)}`)),
|
||||||
|
);
|
||||||
|
proc.stdin.write(cleaned);
|
||||||
|
proc.stdin.end();
|
||||||
|
});
|
||||||
|
return await readFile(wavPath);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/tsconfig.json
Normal file
12
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
493
server/voices/de_DE-thorsten-high.onnx.json
Normal file
493
server/voices/de_DE-thorsten-high.onnx.json
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
{
|
||||||
|
"audio": {
|
||||||
|
"sample_rate": 22050,
|
||||||
|
"quality": "high"
|
||||||
|
},
|
||||||
|
"espeak": {
|
||||||
|
"voice": "de"
|
||||||
|
},
|
||||||
|
"inference": {
|
||||||
|
"noise_scale": 0.667,
|
||||||
|
"length_scale": 1,
|
||||||
|
"noise_w": 0.8
|
||||||
|
},
|
||||||
|
"phoneme_type": "espeak",
|
||||||
|
"phoneme_map": {},
|
||||||
|
"phoneme_id_map": {
|
||||||
|
"_": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"^": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"$": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
" ": [
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"!": [
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"'": [
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"(": [
|
||||||
|
6
|
||||||
|
],
|
||||||
|
")": [
|
||||||
|
7
|
||||||
|
],
|
||||||
|
",": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"-": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
".": [
|
||||||
|
10
|
||||||
|
],
|
||||||
|
":": [
|
||||||
|
11
|
||||||
|
],
|
||||||
|
";": [
|
||||||
|
12
|
||||||
|
],
|
||||||
|
"?": [
|
||||||
|
13
|
||||||
|
],
|
||||||
|
"a": [
|
||||||
|
14
|
||||||
|
],
|
||||||
|
"b": [
|
||||||
|
15
|
||||||
|
],
|
||||||
|
"c": [
|
||||||
|
16
|
||||||
|
],
|
||||||
|
"d": [
|
||||||
|
17
|
||||||
|
],
|
||||||
|
"e": [
|
||||||
|
18
|
||||||
|
],
|
||||||
|
"f": [
|
||||||
|
19
|
||||||
|
],
|
||||||
|
"h": [
|
||||||
|
20
|
||||||
|
],
|
||||||
|
"i": [
|
||||||
|
21
|
||||||
|
],
|
||||||
|
"j": [
|
||||||
|
22
|
||||||
|
],
|
||||||
|
"k": [
|
||||||
|
23
|
||||||
|
],
|
||||||
|
"l": [
|
||||||
|
24
|
||||||
|
],
|
||||||
|
"m": [
|
||||||
|
25
|
||||||
|
],
|
||||||
|
"n": [
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
27
|
||||||
|
],
|
||||||
|
"p": [
|
||||||
|
28
|
||||||
|
],
|
||||||
|
"q": [
|
||||||
|
29
|
||||||
|
],
|
||||||
|
"r": [
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"s": [
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"t": [
|
||||||
|
32
|
||||||
|
],
|
||||||
|
"u": [
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
34
|
||||||
|
],
|
||||||
|
"w": [
|
||||||
|
35
|
||||||
|
],
|
||||||
|
"x": [
|
||||||
|
36
|
||||||
|
],
|
||||||
|
"y": [
|
||||||
|
37
|
||||||
|
],
|
||||||
|
"z": [
|
||||||
|
38
|
||||||
|
],
|
||||||
|
"æ": [
|
||||||
|
39
|
||||||
|
],
|
||||||
|
"ç": [
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"ð": [
|
||||||
|
41
|
||||||
|
],
|
||||||
|
"ø": [
|
||||||
|
42
|
||||||
|
],
|
||||||
|
"ħ": [
|
||||||
|
43
|
||||||
|
],
|
||||||
|
"ŋ": [
|
||||||
|
44
|
||||||
|
],
|
||||||
|
"œ": [
|
||||||
|
45
|
||||||
|
],
|
||||||
|
"ǀ": [
|
||||||
|
46
|
||||||
|
],
|
||||||
|
"ǁ": [
|
||||||
|
47
|
||||||
|
],
|
||||||
|
"ǂ": [
|
||||||
|
48
|
||||||
|
],
|
||||||
|
"ǃ": [
|
||||||
|
49
|
||||||
|
],
|
||||||
|
"ɐ": [
|
||||||
|
50
|
||||||
|
],
|
||||||
|
"ɑ": [
|
||||||
|
51
|
||||||
|
],
|
||||||
|
"ɒ": [
|
||||||
|
52
|
||||||
|
],
|
||||||
|
"ɓ": [
|
||||||
|
53
|
||||||
|
],
|
||||||
|
"ɔ": [
|
||||||
|
54
|
||||||
|
],
|
||||||
|
"ɕ": [
|
||||||
|
55
|
||||||
|
],
|
||||||
|
"ɖ": [
|
||||||
|
56
|
||||||
|
],
|
||||||
|
"ɗ": [
|
||||||
|
57
|
||||||
|
],
|
||||||
|
"ɘ": [
|
||||||
|
58
|
||||||
|
],
|
||||||
|
"ə": [
|
||||||
|
59
|
||||||
|
],
|
||||||
|
"ɚ": [
|
||||||
|
60
|
||||||
|
],
|
||||||
|
"ɛ": [
|
||||||
|
61
|
||||||
|
],
|
||||||
|
"ɜ": [
|
||||||
|
62
|
||||||
|
],
|
||||||
|
"ɞ": [
|
||||||
|
63
|
||||||
|
],
|
||||||
|
"ɟ": [
|
||||||
|
64
|
||||||
|
],
|
||||||
|
"ɠ": [
|
||||||
|
65
|
||||||
|
],
|
||||||
|
"ɡ": [
|
||||||
|
66
|
||||||
|
],
|
||||||
|
"ɢ": [
|
||||||
|
67
|
||||||
|
],
|
||||||
|
"ɣ": [
|
||||||
|
68
|
||||||
|
],
|
||||||
|
"ɤ": [
|
||||||
|
69
|
||||||
|
],
|
||||||
|
"ɥ": [
|
||||||
|
70
|
||||||
|
],
|
||||||
|
"ɦ": [
|
||||||
|
71
|
||||||
|
],
|
||||||
|
"ɧ": [
|
||||||
|
72
|
||||||
|
],
|
||||||
|
"ɨ": [
|
||||||
|
73
|
||||||
|
],
|
||||||
|
"ɪ": [
|
||||||
|
74
|
||||||
|
],
|
||||||
|
"ɫ": [
|
||||||
|
75
|
||||||
|
],
|
||||||
|
"ɬ": [
|
||||||
|
76
|
||||||
|
],
|
||||||
|
"ɭ": [
|
||||||
|
77
|
||||||
|
],
|
||||||
|
"ɮ": [
|
||||||
|
78
|
||||||
|
],
|
||||||
|
"ɯ": [
|
||||||
|
79
|
||||||
|
],
|
||||||
|
"ɰ": [
|
||||||
|
80
|
||||||
|
],
|
||||||
|
"ɱ": [
|
||||||
|
81
|
||||||
|
],
|
||||||
|
"ɲ": [
|
||||||
|
82
|
||||||
|
],
|
||||||
|
"ɳ": [
|
||||||
|
83
|
||||||
|
],
|
||||||
|
"ɴ": [
|
||||||
|
84
|
||||||
|
],
|
||||||
|
"ɵ": [
|
||||||
|
85
|
||||||
|
],
|
||||||
|
"ɶ": [
|
||||||
|
86
|
||||||
|
],
|
||||||
|
"ɸ": [
|
||||||
|
87
|
||||||
|
],
|
||||||
|
"ɹ": [
|
||||||
|
88
|
||||||
|
],
|
||||||
|
"ɺ": [
|
||||||
|
89
|
||||||
|
],
|
||||||
|
"ɻ": [
|
||||||
|
90
|
||||||
|
],
|
||||||
|
"ɽ": [
|
||||||
|
91
|
||||||
|
],
|
||||||
|
"ɾ": [
|
||||||
|
92
|
||||||
|
],
|
||||||
|
"ʀ": [
|
||||||
|
93
|
||||||
|
],
|
||||||
|
"ʁ": [
|
||||||
|
94
|
||||||
|
],
|
||||||
|
"ʂ": [
|
||||||
|
95
|
||||||
|
],
|
||||||
|
"ʃ": [
|
||||||
|
96
|
||||||
|
],
|
||||||
|
"ʄ": [
|
||||||
|
97
|
||||||
|
],
|
||||||
|
"ʈ": [
|
||||||
|
98
|
||||||
|
],
|
||||||
|
"ʉ": [
|
||||||
|
99
|
||||||
|
],
|
||||||
|
"ʊ": [
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"ʋ": [
|
||||||
|
101
|
||||||
|
],
|
||||||
|
"ʌ": [
|
||||||
|
102
|
||||||
|
],
|
||||||
|
"ʍ": [
|
||||||
|
103
|
||||||
|
],
|
||||||
|
"ʎ": [
|
||||||
|
104
|
||||||
|
],
|
||||||
|
"ʏ": [
|
||||||
|
105
|
||||||
|
],
|
||||||
|
"ʐ": [
|
||||||
|
106
|
||||||
|
],
|
||||||
|
"ʑ": [
|
||||||
|
107
|
||||||
|
],
|
||||||
|
"ʒ": [
|
||||||
|
108
|
||||||
|
],
|
||||||
|
"ʔ": [
|
||||||
|
109
|
||||||
|
],
|
||||||
|
"ʕ": [
|
||||||
|
110
|
||||||
|
],
|
||||||
|
"ʘ": [
|
||||||
|
111
|
||||||
|
],
|
||||||
|
"ʙ": [
|
||||||
|
112
|
||||||
|
],
|
||||||
|
"ʛ": [
|
||||||
|
113
|
||||||
|
],
|
||||||
|
"ʜ": [
|
||||||
|
114
|
||||||
|
],
|
||||||
|
"ʝ": [
|
||||||
|
115
|
||||||
|
],
|
||||||
|
"ʟ": [
|
||||||
|
116
|
||||||
|
],
|
||||||
|
"ʡ": [
|
||||||
|
117
|
||||||
|
],
|
||||||
|
"ʢ": [
|
||||||
|
118
|
||||||
|
],
|
||||||
|
"ʲ": [
|
||||||
|
119
|
||||||
|
],
|
||||||
|
"ˈ": [
|
||||||
|
120
|
||||||
|
],
|
||||||
|
"ˌ": [
|
||||||
|
121
|
||||||
|
],
|
||||||
|
"ː": [
|
||||||
|
122
|
||||||
|
],
|
||||||
|
"ˑ": [
|
||||||
|
123
|
||||||
|
],
|
||||||
|
"˞": [
|
||||||
|
124
|
||||||
|
],
|
||||||
|
"β": [
|
||||||
|
125
|
||||||
|
],
|
||||||
|
"θ": [
|
||||||
|
126
|
||||||
|
],
|
||||||
|
"χ": [
|
||||||
|
127
|
||||||
|
],
|
||||||
|
"ᵻ": [
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"ⱱ": [
|
||||||
|
129
|
||||||
|
],
|
||||||
|
"0": [
|
||||||
|
130
|
||||||
|
],
|
||||||
|
"1": [
|
||||||
|
131
|
||||||
|
],
|
||||||
|
"2": [
|
||||||
|
132
|
||||||
|
],
|
||||||
|
"3": [
|
||||||
|
133
|
||||||
|
],
|
||||||
|
"4": [
|
||||||
|
134
|
||||||
|
],
|
||||||
|
"5": [
|
||||||
|
135
|
||||||
|
],
|
||||||
|
"6": [
|
||||||
|
136
|
||||||
|
],
|
||||||
|
"7": [
|
||||||
|
137
|
||||||
|
],
|
||||||
|
"8": [
|
||||||
|
138
|
||||||
|
],
|
||||||
|
"9": [
|
||||||
|
139
|
||||||
|
],
|
||||||
|
"̧": [
|
||||||
|
140
|
||||||
|
],
|
||||||
|
"̃": [
|
||||||
|
141
|
||||||
|
],
|
||||||
|
"̪": [
|
||||||
|
142
|
||||||
|
],
|
||||||
|
"̯": [
|
||||||
|
143
|
||||||
|
],
|
||||||
|
"̩": [
|
||||||
|
144
|
||||||
|
],
|
||||||
|
"ʰ": [
|
||||||
|
145
|
||||||
|
],
|
||||||
|
"ˤ": [
|
||||||
|
146
|
||||||
|
],
|
||||||
|
"ε": [
|
||||||
|
147
|
||||||
|
],
|
||||||
|
"↓": [
|
||||||
|
148
|
||||||
|
],
|
||||||
|
"#": [
|
||||||
|
149
|
||||||
|
],
|
||||||
|
"\"": [
|
||||||
|
150
|
||||||
|
],
|
||||||
|
"↑": [
|
||||||
|
151
|
||||||
|
],
|
||||||
|
"̺": [
|
||||||
|
152
|
||||||
|
],
|
||||||
|
"̻": [
|
||||||
|
153
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"num_symbols": 256,
|
||||||
|
"num_speakers": 1,
|
||||||
|
"speaker_id_map": {},
|
||||||
|
"piper_version": "1.0.0",
|
||||||
|
"language": {
|
||||||
|
"code": "de_DE",
|
||||||
|
"family": "de",
|
||||||
|
"region": "DE",
|
||||||
|
"name_native": "Deutsch",
|
||||||
|
"name_english": "German",
|
||||||
|
"country_english": "Germany"
|
||||||
|
},
|
||||||
|
"dataset": "thorsten"
|
||||||
|
}
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>oxagenttwo — local qwen3 chat</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
web/package.json
Normal file
27
web/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.3",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"remark-gfm": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
335
web/src/App.tsx
Normal file
335
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useChat } from "./useChat";
|
||||||
|
import { useVoice } from "./useVoice";
|
||||||
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
import { ChatMessage } from "./components/ChatMessage";
|
||||||
|
import { Composer } from "./components/Composer";
|
||||||
|
import type { OpenRouterModel } from "./types";
|
||||||
|
|
||||||
|
const VOICE_KEY = "oxagenttwo.voiceMode";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const chat = useChat();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [injectedText, setInjectedText] = useState<string | null>(null);
|
||||||
|
const [voiceMode, setVoiceModeState] = useState(
|
||||||
|
() => localStorage.getItem(VOICE_KEY) === "1",
|
||||||
|
);
|
||||||
|
const [orModels, setOrModels] = useState<OpenRouterModel[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
settingsOpen &&
|
||||||
|
chat.options.provider === "openrouter" &&
|
||||||
|
orModels.length === 0
|
||||||
|
) {
|
||||||
|
fetch("/api/openrouter/models")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d: { ok: boolean; models?: OpenRouterModel[] }) => {
|
||||||
|
if (d.ok && d.models) setOrModels(d.models);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [settingsOpen, chat.options.provider, orModels.length]);
|
||||||
|
|
||||||
|
const voiceModeRef = useRef(voiceMode);
|
||||||
|
const streamingRef = useRef(false);
|
||||||
|
const awaitingDrainRef = useRef(false);
|
||||||
|
streamingRef.current = chat.streaming;
|
||||||
|
voiceModeRef.current = voiceMode;
|
||||||
|
|
||||||
|
const setVoiceMode = useCallback((on: boolean) => {
|
||||||
|
setVoiceModeState(on);
|
||||||
|
localStorage.setItem(VOICE_KEY, on ? "1" : "0");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const voice = useVoice({
|
||||||
|
onTranscript: (text) => {
|
||||||
|
if (voiceModeRef.current) {
|
||||||
|
voiceRef.current.cancelSpeech();
|
||||||
|
pendingSpeechRef.current = "";
|
||||||
|
chatRef.current.sendMessage(text);
|
||||||
|
} else {
|
||||||
|
setInjectedText(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onQueueDrained: () => {
|
||||||
|
if (awaitingDrainRef.current) {
|
||||||
|
awaitingDrainRef.current = false;
|
||||||
|
if (voiceModeRef.current && !streamingRef.current) {
|
||||||
|
void voiceRef.current.startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const voiceRef = useRef(voice);
|
||||||
|
voiceRef.current = voice;
|
||||||
|
const chatRef = useRef(chat);
|
||||||
|
chatRef.current = chat;
|
||||||
|
const pendingSpeechRef = useRef("");
|
||||||
|
const ttsCursorRef = useRef({ id: "", offset: 0 });
|
||||||
|
|
||||||
|
// auto-scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [chat.messages]);
|
||||||
|
|
||||||
|
// queue completed sentences for TTS while streaming
|
||||||
|
useEffect(() => {
|
||||||
|
if (!voiceMode) return;
|
||||||
|
const lastAssistant = [...chat.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant");
|
||||||
|
if (!lastAssistant) return;
|
||||||
|
|
||||||
|
if (ttsCursorRef.current.id !== lastAssistant.id) {
|
||||||
|
ttsCursorRef.current = { id: lastAssistant.id, offset: 0 };
|
||||||
|
pendingSpeechRef.current = "";
|
||||||
|
}
|
||||||
|
const content = lastAssistant.content;
|
||||||
|
if (content.length < ttsCursorRef.current.offset) {
|
||||||
|
ttsCursorRef.current.offset = content.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delta = content.slice(ttsCursorRef.current.offset);
|
||||||
|
if (!delta) return;
|
||||||
|
pendingSpeechRef.current += delta;
|
||||||
|
ttsCursorRef.current.offset = content.length;
|
||||||
|
|
||||||
|
const buf = pendingSpeechRef.current;
|
||||||
|
const matches = [...buf.matchAll(/[.!?…]+["')\]]?(?=\s|$)/g)];
|
||||||
|
let speakPart = "";
|
||||||
|
if (matches.length) {
|
||||||
|
const lastMatch = matches[matches.length - 1];
|
||||||
|
const end = lastMatch.index + lastMatch[0].length;
|
||||||
|
if (end >= 40) speakPart = buf.slice(0, end);
|
||||||
|
} else if (buf.length > 300) {
|
||||||
|
const brk = Math.max(buf.lastIndexOf(", "), buf.lastIndexOf(" "));
|
||||||
|
speakPart = brk > 100 ? buf.slice(0, brk + 1) : buf;
|
||||||
|
}
|
||||||
|
if (speakPart) {
|
||||||
|
pendingSpeechRef.current = buf.slice(speakPart.length);
|
||||||
|
voice.enqueueSpeech(speakPart);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// flush remainder once streaming finished
|
||||||
|
const wasStreamingRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasStreamingRef.current && !chat.streaming && voiceMode) {
|
||||||
|
const rest = pendingSpeechRef.current.trim();
|
||||||
|
if (rest) {
|
||||||
|
pendingSpeechRef.current = "";
|
||||||
|
voice.enqueueSpeech(rest);
|
||||||
|
}
|
||||||
|
if (rest || ttsCursorRef.current.id) awaitingDrainRef.current = true;
|
||||||
|
}
|
||||||
|
wasStreamingRef.current = chat.streaming;
|
||||||
|
}, [chat.streaming, voiceMode, voice]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
voice.cancelSpeech();
|
||||||
|
pendingSpeechRef.current = "";
|
||||||
|
chat.sendMessage(text);
|
||||||
|
},
|
||||||
|
[chat, voice],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAbort = useCallback(() => {
|
||||||
|
voice.cancelSpeech();
|
||||||
|
pendingSpeechRef.current = "";
|
||||||
|
chat.abort();
|
||||||
|
}, [chat, voice]);
|
||||||
|
|
||||||
|
const handleMicToggle = useCallback(() => {
|
||||||
|
if (voice.recording) {
|
||||||
|
voice.stopRecording();
|
||||||
|
} else {
|
||||||
|
voice.cancelSpeech();
|
||||||
|
void voice.startRecording();
|
||||||
|
}
|
||||||
|
}, [voice]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Sidebar
|
||||||
|
sessions={chat.sessions}
|
||||||
|
activeId={chat.activeId}
|
||||||
|
onSelect={chat.setActiveId}
|
||||||
|
onNew={() => void chat.newSession()}
|
||||||
|
onDelete={(id) => void chat.deleteSession(id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
<header className="topbar">
|
||||||
|
<div
|
||||||
|
className="model-badge"
|
||||||
|
title={chat.modelInfo?.ok ? undefined : chat.modelInfo?.error}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`status-dot ${chat.status === "open" ? "on" : "off"}`}
|
||||||
|
/>
|
||||||
|
{chat.options.provider === "openrouter"
|
||||||
|
? `☁ ${chat.options.openrouterModel}`
|
||||||
|
: chat.modelInfo?.ok
|
||||||
|
? `${chat.modelInfo.model} · ${chat.modelInfo.parameterSize} · ${chat.modelInfo.quantization}`
|
||||||
|
: "Ollama offline"}
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
{voiceMode && (voice.recording || voice.transcribing || voice.speaking) && (
|
||||||
|
<span className="voice-state">
|
||||||
|
{voice.recording
|
||||||
|
? "● hört zu"
|
||||||
|
: voice.transcribing
|
||||||
|
? "… transkribiert"
|
||||||
|
: "🔊 spricht"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`btn-settings ${voiceMode ? "toggled" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (voiceMode) {
|
||||||
|
voice.cancelSpeech();
|
||||||
|
voice.stopRecording();
|
||||||
|
}
|
||||||
|
setVoiceMode(!voiceMode);
|
||||||
|
}}
|
||||||
|
title="Sprachmodus: Antworten werden vorgelesen, danach wird automatisch wieder zugehört"
|
||||||
|
>
|
||||||
|
{voiceMode ? "🔊 Stimme: an" : "🔇 Stimme: aus"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-settings"
|
||||||
|
onClick={() => setSettingsOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
⚙ Einstellungen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<section className="settings-panel">
|
||||||
|
<label className="setting-row">
|
||||||
|
<span>Modell</span>
|
||||||
|
<select
|
||||||
|
className="provider-select"
|
||||||
|
value={chat.options.provider}
|
||||||
|
onChange={(e) =>
|
||||||
|
chat.setOptions({ provider: e.target.value as "ollama" | "openrouter" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ollama">Lokal: qwen3.5 (Ollama)</option>
|
||||||
|
<option value="openrouter">OpenRouter (Cloud)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{chat.options.provider === "openrouter" && (
|
||||||
|
<label className="setting-row column">
|
||||||
|
<span>OpenRouter-Modell ({orModels.length} verfügbar)</span>
|
||||||
|
<input
|
||||||
|
list="or-models"
|
||||||
|
value={chat.options.openrouterModel}
|
||||||
|
placeholder="z. B. anthropic/claude-sonnet-4.5"
|
||||||
|
onChange={(e) =>
|
||||||
|
chat.setOptions({ openrouterModel: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<datalist id="or-models">
|
||||||
|
{orModels.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} · {m.promptPrice === 0 ? "gratis" : `$${m.promptPrice.toFixed(2)}/M`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{chat.options.provider === "ollama" && (
|
||||||
|
<label className="setting-row checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chat.options.think}
|
||||||
|
onChange={(e) => chat.setOptions({ think: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Thinking-Mode (Modell denkt sichtbar vor der Antwort)</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="setting-row">
|
||||||
|
<span>Temperature: {chat.options.temperature.toFixed(2)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1.5}
|
||||||
|
step={0.05}
|
||||||
|
value={chat.options.temperature}
|
||||||
|
onChange={(e) =>
|
||||||
|
chat.setOptions({ temperature: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="setting-row">
|
||||||
|
<span>Max. Tokens: {chat.options.numPredict}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={256}
|
||||||
|
max={8192}
|
||||||
|
step={256}
|
||||||
|
value={chat.options.numPredict}
|
||||||
|
onChange={(e) =>
|
||||||
|
chat.setOptions({ numPredict: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="setting-row column">
|
||||||
|
<span>System-Prompt</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Optional: Verhalten des Modells steuern …"
|
||||||
|
value={chat.systemPrompt}
|
||||||
|
onChange={(e) => chat.setSystemPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="messages" ref={scrollRef}>
|
||||||
|
{chat.messages.length === 0 && (
|
||||||
|
<div className="welcome">
|
||||||
|
<div className="welcome-title">▸ oxagenttwo</div>
|
||||||
|
<p>
|
||||||
|
Echtzeit-Chat mit lokalem Qwen3 über Ollama — per Tastatur oder
|
||||||
|
Stimme (Whisper STT + Piper TTS, alles lokal).
|
||||||
|
</p>
|
||||||
|
<p className="hint">
|
||||||
|
🎙 für Sprachnachricht · 🔊 Stimme an für freihändigen Dialog
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chat.messages.map((m) => (
|
||||||
|
<ChatMessage key={m.id} message={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{voice.error && (
|
||||||
|
<div className="voice-error">
|
||||||
|
{voice.error}
|
||||||
|
<button onClick={() => voice.setError(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
streaming={chat.streaming}
|
||||||
|
disabled={!chat.activeId}
|
||||||
|
recording={voice.recording}
|
||||||
|
transcribing={voice.transcribing}
|
||||||
|
injectedText={injectedText}
|
||||||
|
onInjected={() => setInjectedText(null)}
|
||||||
|
onSend={handleSend}
|
||||||
|
onAbort={handleAbort}
|
||||||
|
onMicToggle={handleMicToggle}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
web/src/components/ChatMessage.tsx
Normal file
42
web/src/components/ChatMessage.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeHighlight from "rehype-highlight";
|
||||||
|
import type { Message } from "../types";
|
||||||
|
|
||||||
|
export function ChatMessage({ message }: { message: Message }) {
|
||||||
|
const [showThinking, setShowThinking] = useState(false);
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`msg ${isUser ? "msg-user" : "msg-assistant"}`}>
|
||||||
|
<div className="msg-role">
|
||||||
|
{isUser ? "du" : "qwen3"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isUser && message.thinking && message.thinking.length > 0 && (
|
||||||
|
<div className={`thinking ${showThinking ? "open" : ""}`}>
|
||||||
|
<button className="thinking-toggle" onClick={() => setShowThinking((v) => !v)}>
|
||||||
|
<span className={`caret ${showThinking ? "rotated" : ""}`}>▸</span>
|
||||||
|
{message.content === "" && showThinking === false
|
||||||
|
? "denkt nach …"
|
||||||
|
: `Denkprozess (${message.thinking.length.toLocaleString("de-DE")} Zeichen)`}
|
||||||
|
</button>
|
||||||
|
{showThinking && (
|
||||||
|
<pre className="thinking-body">{message.thinking}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUser ? (
|
||||||
|
<div className="msg-content user-content">{message.content}</div>
|
||||||
|
) : (
|
||||||
|
<div className="msg-content markdown">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
||||||
|
{message.content || "▍"}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
web/src/components/Composer.tsx
Normal file
99
web/src/components/Composer.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
streaming: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
recording: boolean;
|
||||||
|
transcribing: boolean;
|
||||||
|
injectedText: string | null;
|
||||||
|
onInjected: () => void;
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
onAbort: () => void;
|
||||||
|
onMicToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
streaming,
|
||||||
|
disabled,
|
||||||
|
recording,
|
||||||
|
transcribing,
|
||||||
|
injectedText,
|
||||||
|
onInjected,
|
||||||
|
onSend,
|
||||||
|
onAbort,
|
||||||
|
onMicToggle,
|
||||||
|
}: Props) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (injectedText != null) {
|
||||||
|
setValue(injectedText);
|
||||||
|
onInjected();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ref.current?.focus();
|
||||||
|
ref.current?.setSelectionRange(injectedText.length, injectedText.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [injectedText, onInjected]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text || streaming || disabled) return;
|
||||||
|
onSend(text);
|
||||||
|
setValue("");
|
||||||
|
requestAnimationFrame(() => ref.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="composer">
|
||||||
|
<button
|
||||||
|
className={`btn-mic ${recording ? "recording" : ""} ${transcribing ? "transcribing" : ""}`}
|
||||||
|
onClick={onMicToggle}
|
||||||
|
title={
|
||||||
|
recording
|
||||||
|
? "Aufnahme stoppen & senden"
|
||||||
|
: transcribing
|
||||||
|
? "Transkribiere …"
|
||||||
|
: "Sprachnachricht aufnehmen"
|
||||||
|
}
|
||||||
|
disabled={transcribing || disabled}
|
||||||
|
>
|
||||||
|
{recording ? "●" : transcribing ? "…" : "🎙"}
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
placeholder={
|
||||||
|
recording
|
||||||
|
? "Ich höre zu … (zum Beenden nochmal auf das Mikro klicken)"
|
||||||
|
: disabled
|
||||||
|
? "Keine Session aktiv — neuen Chat starten"
|
||||||
|
: "Nachricht an qwen3 … (Enter = senden, Shift+Enter = Zeilenumbruch)"
|
||||||
|
}
|
||||||
|
rows={1}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{streaming ? (
|
||||||
|
<button className="btn-send stop" onClick={onAbort}>
|
||||||
|
■ Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn-send"
|
||||||
|
disabled={!value.trim() || disabled}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Senden ▸
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
web/src/components/Sidebar.tsx
Normal file
48
web/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { Session } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessions: Session[];
|
||||||
|
activeId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ sessions, activeId, onSelect, onNew, onDelete }: Props) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-prompt">▸</span> oxagenttwo
|
||||||
|
</div>
|
||||||
|
<button className="btn-new" onClick={onNew}>
|
||||||
|
+ Neuer Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="session-list">
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<div className="empty-hint">Noch keine Sessions.</div>
|
||||||
|
)}
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`session-item ${s.id === activeId ? "active" : ""}`}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
>
|
||||||
|
<span className="session-title">{s.title}</span>
|
||||||
|
<button
|
||||||
|
className="session-delete"
|
||||||
|
title="Session löschen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(s.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
web/src/main.tsx
Normal file
11
web/src/main.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "highlight.js/styles/github-dark.css";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
71
web/src/socket.ts
Normal file
71
web/src/socket.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
thinking: string | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatOptions {
|
||||||
|
think: boolean;
|
||||||
|
temperature: number;
|
||||||
|
numPredict: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = (data: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
export class ChatSocket {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private handlers = new Set<Handler>();
|
||||||
|
private queue: string[] = [];
|
||||||
|
private closedByUser = false;
|
||||||
|
onStatus?: (status: "connecting" | "open" | "closed") => void;
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.closedByUser = false;
|
||||||
|
this.onStatus?.("connecting");
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.onStatus?.("open");
|
||||||
|
for (const item of this.queue) ws.send(item);
|
||||||
|
this.queue = [];
|
||||||
|
};
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data as string);
|
||||||
|
for (const h of this.handlers) h(data);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.onStatus?.("closed");
|
||||||
|
if (!this.closedByUser) setTimeout(() => this.connect(), 1500);
|
||||||
|
};
|
||||||
|
this.ws = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: unknown) {
|
||||||
|
const payload = JSON.stringify(data);
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(payload);
|
||||||
|
else this.queue.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closedByUser = true;
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(h: Handler): () => void {
|
||||||
|
this.handlers.add(h);
|
||||||
|
return () => this.handlers.delete(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
540
web/src/styles.css
Normal file
540
web/src/styles.css
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0a0f0c;
|
||||||
|
--bg-panel: #0e1511;
|
||||||
|
--bg-elevated: #121b15;
|
||||||
|
--border: #1e2c23;
|
||||||
|
--text: #d4e4d8;
|
||||||
|
--text-dim: #7a9484;
|
||||||
|
--accent: #3ddc84;
|
||||||
|
--accent-dim: #2a9a5f;
|
||||||
|
--accent-warm: #ffb454;
|
||||||
|
--user-bg: #16241c;
|
||||||
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Sidebar ---------- */
|
||||||
|
.sidebar {
|
||||||
|
width: 250px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-prompt {
|
||||||
|
animation: blink 1.2s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new,
|
||||||
|
.btn-settings,
|
||||||
|
.btn-send {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new:hover,
|
||||||
|
.btn-settings:hover {
|
||||||
|
background: rgba(61, 220, 132, 0.08);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item.active {
|
||||||
|
background: var(--user-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:hover .session-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-delete:hover {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Main ---------- */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.on {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.off {
|
||||||
|
background: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row.column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row textarea,
|
||||||
|
.provider-select,
|
||||||
|
.settings-panel input[list] {
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-select {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Messages ---------- */
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 20px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 80px auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome .hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-role {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-user .msg-role {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-content {
|
||||||
|
background: var(--user-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown ul,
|
||||||
|
.markdown ol {
|
||||||
|
margin: 0 0 10px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown h1,
|
||||||
|
.markdown h2,
|
||||||
|
.markdown h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown blockquote {
|
||||||
|
border-left: 3px solid var(--accent-dim);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown th,
|
||||||
|
.markdown td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :not(pre) > code {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown pre {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Thinking ---------- */
|
||||||
|
.thinking {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle:hover {
|
||||||
|
color: var(--accent-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret.rotated {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-body {
|
||||||
|
padding: 4px 12px 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Composer ---------- */
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mic {
|
||||||
|
width: 46px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mic:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mic.recording {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
color: #ff6b6b;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mic.transcribing {
|
||||||
|
border-color: var(--accent-warm);
|
||||||
|
color: var(--accent-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px rgba(255, 107, 107, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-state {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-warm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-settings.toggled {
|
||||||
|
color: var(--accent-warm);
|
||||||
|
border-color: var(--accent-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-error {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 20px 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ffb454;
|
||||||
|
background: rgba(255, 180, 84, 0.08);
|
||||||
|
border: 1px solid rgba(255, 180, 84, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-error button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 14px;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea::placeholder {
|
||||||
|
color: #4d6355;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
min-width: 96px;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:not(:disabled):hover {
|
||||||
|
background: rgba(61, 220, 132, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.stop {
|
||||||
|
color: var(--accent-warm);
|
||||||
|
border-color: var(--accent-warm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scrollbars ---------- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
38
web/src/types.ts
Normal file
38
web/src/types.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
thinking: string | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatOptions {
|
||||||
|
think: boolean;
|
||||||
|
temperature: number;
|
||||||
|
numPredict: number;
|
||||||
|
provider: "ollama" | "openrouter";
|
||||||
|
openrouterModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenRouterModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contextLength: number;
|
||||||
|
promptPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
ok: boolean;
|
||||||
|
model?: string;
|
||||||
|
parameterSize?: string;
|
||||||
|
quantization?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
194
web/src/useChat.ts
Normal file
194
web/src/useChat.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ChatSocket } from "./socket";
|
||||||
|
import type { ChatOptions, Message, Session } from "./types";
|
||||||
|
|
||||||
|
export type ConnStatus = "connecting" | "open" | "closed";
|
||||||
|
export interface ModelInfo {
|
||||||
|
ok: boolean;
|
||||||
|
model?: string;
|
||||||
|
parameterSize?: string;
|
||||||
|
quantization?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS_KEY = "oxagenttwo.options";
|
||||||
|
const SESSION_KEY = "oxagenttwo.session";
|
||||||
|
const SYSTEM_KEY = "oxagenttwo.systemPrompt";
|
||||||
|
|
||||||
|
function loadOptions(): ChatOptions {
|
||||||
|
const defaults: ChatOptions = {
|
||||||
|
think: true,
|
||||||
|
temperature: 0.7,
|
||||||
|
numPredict: 2048,
|
||||||
|
provider: "ollama",
|
||||||
|
openrouterModel: "anthropic/claude-sonnet-4.5",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(OPTIONS_KEY);
|
||||||
|
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat() {
|
||||||
|
const socketRef = useRef<ChatSocket | null>(null);
|
||||||
|
const [status, setStatus] = useState<ConnStatus>("connecting");
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(
|
||||||
|
() => localStorage.getItem(SESSION_KEY),
|
||||||
|
);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
|
||||||
|
const [systemPrompt, setSystemPromptState] = useState(
|
||||||
|
() => localStorage.getItem(SYSTEM_KEY) ?? "",
|
||||||
|
);
|
||||||
|
const [modelInfo, setModelInfo] = useState<ModelInfo | null>(null);
|
||||||
|
const streamingRef = useRef(false);
|
||||||
|
|
||||||
|
// socket setup
|
||||||
|
useEffect(() => {
|
||||||
|
const sock = new ChatSocket();
|
||||||
|
socketRef.current = sock;
|
||||||
|
sock.onStatus = setStatus;
|
||||||
|
|
||||||
|
const unsub = sock.subscribe((data) => {
|
||||||
|
const t = data.type as string;
|
||||||
|
if (t === "user-message") {
|
||||||
|
const m = data.message as Message;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.some((x) => x.id === m.id) ? prev : [...prev, m],
|
||||||
|
);
|
||||||
|
} else if (t === "assistant-start") {
|
||||||
|
const m = data.message as Message;
|
||||||
|
setMessages((prev) => [...prev, { ...m, content: "", thinking: "" }]);
|
||||||
|
} else if (t === "thinking") {
|
||||||
|
const text = data.text as string;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === data.messageId
|
||||||
|
? { ...m, thinking: (m.thinking ?? "") + text }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (t === "token") {
|
||||||
|
const text = data.text as string;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === data.messageId ? { ...m, content: m.content + text } : m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (t === "done" || t === "error") {
|
||||||
|
streamingRef.current = false;
|
||||||
|
setStreaming(false);
|
||||||
|
} else if (t === "sessions-changed" || t === "session-deleted") {
|
||||||
|
void refreshSessions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.connect();
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
sock.close();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
const res = await fetch("/api/sessions");
|
||||||
|
const list = (await res.json()) as Session[];
|
||||||
|
setSessions(list);
|
||||||
|
setActiveId((cur) => cur ?? list[0]?.id ?? null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshSessions();
|
||||||
|
fetch("/api/model")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setModelInfo)
|
||||||
|
.catch(() => setModelInfo({ ok: false, error: "Ollama nicht erreichbar" }));
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
|
// load messages on session switch
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(SESSION_KEY, activeId);
|
||||||
|
streamingRef.current = false;
|
||||||
|
setStreaming(false);
|
||||||
|
fetch(`/api/sessions/${activeId}/messages`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((list: Message[]) => setMessages(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => setMessages([]));
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
|
const setOptions = useCallback((o: Partial<ChatOptions>) => {
|
||||||
|
setOptionsState((prev) => {
|
||||||
|
const next = { ...prev, ...o };
|
||||||
|
localStorage.setItem(OPTIONS_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSystemPrompt = useCallback((p: string) => {
|
||||||
|
setSystemPromptState(p);
|
||||||
|
localStorage.setItem(SYSTEM_KEY, p);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!content.trim() || streamingRef.current || !activeId) return;
|
||||||
|
streamingRef.current = true;
|
||||||
|
setStreaming(true);
|
||||||
|
socketRef.current?.send({
|
||||||
|
type: "chat",
|
||||||
|
sessionId: activeId,
|
||||||
|
content,
|
||||||
|
options,
|
||||||
|
systemPrompt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activeId, options, systemPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const abort = useCallback(() => {
|
||||||
|
socketRef.current?.send({ type: "abort" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const newSession = useCallback(async () => {
|
||||||
|
const res = await fetch("/api/sessions", { method: "POST" });
|
||||||
|
const s = (await res.json()) as Session;
|
||||||
|
await refreshSessions();
|
||||||
|
setActiveId(s.id);
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
|
const deleteSession = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await fetch(`/api/sessions/${id}`, { method: "DELETE" });
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
setActiveId((cur) => (cur === id ? null : cur));
|
||||||
|
await refreshSessions();
|
||||||
|
},
|
||||||
|
[refreshSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
sessions,
|
||||||
|
activeId,
|
||||||
|
setActiveId,
|
||||||
|
messages,
|
||||||
|
streaming,
|
||||||
|
sendMessage,
|
||||||
|
abort,
|
||||||
|
newSession,
|
||||||
|
deleteSession,
|
||||||
|
options,
|
||||||
|
setOptions,
|
||||||
|
systemPrompt,
|
||||||
|
setSystemPrompt,
|
||||||
|
modelInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
161
web/src/useVoice.ts
Normal file
161
web/src/useVoice.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
onTranscript: (text: string) => void;
|
||||||
|
onQueueDrained: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoice({ onTranscript, onQueueDrained }: Options) {
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [transcribing, setTranscribing] = useState(false);
|
||||||
|
const [speaking, setSpeaking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const maxDurRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const queueRef = useRef<string[]>([]);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const drainedCbRef = useRef(onQueueDrained);
|
||||||
|
drainedCbRef.current = onQueueDrained;
|
||||||
|
const transcriptCbRef = useRef(onTranscript);
|
||||||
|
transcriptCbRef.current = onTranscript;
|
||||||
|
|
||||||
|
// ---- TTS ----
|
||||||
|
const playNext = useCallback(async () => {
|
||||||
|
if (busyRef.current) return;
|
||||||
|
const next = queueRef.current.shift();
|
||||||
|
if (next === undefined) {
|
||||||
|
setSpeaking(false);
|
||||||
|
drainedCbRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busyRef.current = true;
|
||||||
|
setSpeaking(true);
|
||||||
|
try {
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
const res = await fetch("/api/tts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: next }),
|
||||||
|
signal: abortRef.current.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`TTS HTTP ${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const audio = new Audio(URL.createObjectURL(blob));
|
||||||
|
audioRef.current = audio;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
audio.onended = () => resolve();
|
||||||
|
audio.onerror = () => resolve();
|
||||||
|
void audio.play().catch(() => resolve());
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(audio.src);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busyRef.current = false;
|
||||||
|
abortRef.current = null;
|
||||||
|
void playNext();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueueSpeech = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
queueRef.current.push(t);
|
||||||
|
void playNext();
|
||||||
|
},
|
||||||
|
[playNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelSpeech = useCallback(() => {
|
||||||
|
queueRef.current = [];
|
||||||
|
abortRef.current?.abort();
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.src = "";
|
||||||
|
}
|
||||||
|
busyRef.current = false;
|
||||||
|
setSpeaking(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- STT / Recording ----
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (maxDurRef.current) clearTimeout(maxDurRef.current);
|
||||||
|
maxDurRef.current = null;
|
||||||
|
recorderRef.current?.state === "recording" && recorderRef.current.stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
if (recorderRef.current?.state === "recording") return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
chunksRef.current = [];
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
|
};
|
||||||
|
recorder.onstop = async () => {
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
setRecording(false);
|
||||||
|
const blob = new Blob(chunksRef.current);
|
||||||
|
chunksRef.current = [];
|
||||||
|
if (blob.size < 2000) return;
|
||||||
|
setTranscribing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
|
body: blob,
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { ok: boolean; text?: string; error?: string };
|
||||||
|
if (!data.ok) throw new Error(data.error ?? "STT fehlgeschlagen");
|
||||||
|
const text = data.text?.trim();
|
||||||
|
if (text) transcriptCbRef.current(text);
|
||||||
|
else setError("Nichts verstanden — bitte nochmal sprechen.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setTranscribing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
recorder.start();
|
||||||
|
setRecording(true);
|
||||||
|
maxDurRef.current = setTimeout(() => stopRecording(), 30_000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}, [onTranscript, stopRecording]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
recorderRef.current?.state === "recording" && recorderRef.current.stop();
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
cancelSpeech();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recording,
|
||||||
|
transcribing,
|
||||||
|
speaking,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
enqueueSpeech,
|
||||||
|
cancelSpeech,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
web/tsconfig.json
Normal file
15
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8787",
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://127.0.0.1:8787",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue