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