feat: Modellauswahl, globales Gedächtnis und Session-Selbstheilung

- lokales Ollama-Modell frei wählbar (Dropdown aus /api/ollama/models,
  /api/model akzeptiert ?name=, Chat-Option model mit Regex-Validierung)
- Gedächtnis jetzt global über alle Chats: Ankerpunkte werden
  sessionübergreifend eingespielt (eigene Session bevorzugt), recall
  und Gedächtnis-Panel sehen alle Anker
- Frontend heilt tote Session-IDs: unbekannte ID verfällt beim Laden,
  Client springt auf die vom Server genutzte Session — Nachrichten
  landen nicht mehr in schleichend neuen Chats
- Origin-Allowlist um Port 5173 ergänzt (alter Dev-Server blockierte
  sonst alles mit 403)
- Rollen-Label und Composer-Platzhalter zeigen das echte Modell statt
  hartkodiertem qwen3
This commit is contained in:
Jeuner 2026-08-28 13:13:48 +02:00
parent 9bce768f95
commit c8b74892ef
12 changed files with 212 additions and 95 deletions

File diff suppressed because one or more lines are too long

74
web/dist/assets/index-leRe8gQm.js vendored Normal file

File diff suppressed because one or more lines are too long

2
web/dist/index.html vendored
View file

@ -6,7 +6,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0a0f0c" />
<title>agenttwo-tools — qwen3 mit Vision</title>
<script type="module" crossorigin src="/assets/index-BvIjR1bZ.js"></script>
<script type="module" crossorigin src="/assets/index-leRe8gQm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKaNlN-w.css">
</head>
<body>

View file

@ -5,7 +5,7 @@ import { Sidebar } from "./components/Sidebar";
import { ChatMessage } from "./components/ChatMessage";
import { Composer } from "./components/Composer";
import { MemoryPanel } from "./components/MemoryPanel";
import type { OpenRouterModel } from "./types";
import type { OpenRouterModel, OllamaModel } from "./types";
const VOICE_KEY = "oxagenttwo.voiceMode";
@ -19,6 +19,7 @@ export default function App() {
() => localStorage.getItem(VOICE_KEY) === "1",
);
const [orModels, setOrModels] = useState<OpenRouterModel[]>([]);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
useEffect(() => {
if (
@ -33,7 +34,19 @@ export default function App() {
})
.catch(() => {});
}
}, [settingsOpen, chat.options.provider, orModels.length]);
if (
settingsOpen &&
chat.options.provider === "ollama" &&
ollamaModels.length === 0
) {
fetch("/api/ollama/models")
.then((r) => r.json())
.then((d: { ok: boolean; models?: OllamaModel[] }) => {
if (d.ok && d.models) setOllamaModels(d.models);
})
.catch(() => {});
}
}, [settingsOpen, chat.options.provider, orModels.length, ollamaModels.length]);
const voiceModeRef = useRef(voiceMode);
const streamingRef = useRef(false);
@ -139,6 +152,10 @@ export default function App() {
.catch(() => setToolNames([]));
}, []);
const modelLabel = (chat.options.provider === "openrouter"
? chat.options.openrouterModel.split("/").pop()
: chat.options.model.replace(/:latest$/, "")) ?? "qwen3";
const handleSend = useCallback(
(text: string, images: string[] = []) => {
voice.cancelSpeech();
@ -263,6 +280,36 @@ export default function App() {
</datalist>
</label>
)}
{chat.options.provider === "ollama" && (
<label className="setting-row">
<span>Lokales Modell (Ollama)</span>
<select
className="provider-select"
value={
ollamaModels.some((m) => m.name === chat.options.model)
? chat.options.model
: ""
}
onChange={(e) =>
chat.setOptions({ model: e.target.value })
}
>
{!ollamaModels.some((m) => m.name === chat.options.model) && (
<option value="">{chat.options.model} (nicht installiert)</option>
)}
{ollamaModels.map((m) => (
<option key={m.name} value={m.name}>
{m.name}
{m.parameterSize ? ` · ${m.parameterSize}` : ""}
{m.sizeGB ? ` · ${m.sizeGB} GB` : ""}
</option>
))}
{ollamaModels.length === 0 && (
<option value={chat.options.model}>{chat.options.model}</option>
)}
</select>
</label>
)}
{chat.options.provider === "ollama" && (
<label className="setting-row checkbox">
<input
@ -378,7 +425,12 @@ export default function App() {
</div>
)}
{chat.messages.map((m) => (
<ChatMessage key={m.id} message={m} toolEvents={chat.toolEvents[m.id]} />
<ChatMessage
key={m.id}
message={m}
toolEvents={chat.toolEvents[m.id]}
modelLabel={modelLabel}
/>
))}
</div>
@ -395,6 +447,7 @@ export default function App() {
recording={voice.recording}
transcribing={voice.transcribing}
injectedText={injectedText}
modelLabel={modelLabel}
onInjected={() => setInjectedText(null)}
onSend={handleSend}
onAbort={handleAbort}

View file

@ -30,9 +30,11 @@ function imagesOf(message: Message): string[] {
export function ChatMessage({
message,
toolEvents = [],
modelLabel = "qwen3",
}: {
message: Message;
toolEvents?: ToolEvent[];
modelLabel?: string;
}) {
const [showThinking, setShowThinking] = useState(false);
const isUser = message.role === "user";
@ -41,7 +43,7 @@ export function ChatMessage({
return (
<div className={`msg ${isUser ? "msg-user" : "msg-assistant"}`}>
<div className="msg-role">
{isUser ? "du" : "qwen3"}
{isUser ? "du" : modelLabel}
</div>
{!isUser && message.thinking && message.thinking.length > 0 && (

View file

@ -19,6 +19,7 @@ interface Props {
recording: boolean;
transcribing: boolean;
injectedText: string | null;
modelLabel?: string;
onInjected: () => void;
onSend: (text: string, images: string[]) => void;
onAbort: () => void;
@ -49,6 +50,7 @@ export function Composer({
recording,
transcribing,
injectedText,
modelLabel = "qwen3",
onInjected,
onSend,
onAbort,
@ -194,7 +196,7 @@ export function Composer({
? "Ich höre zu … (zum Beenden nochmal auf das Mikro klicken)"
: disabled
? "Keine Session aktiv — neuen Chat starten"
: "Nachricht an qwen3 … (Enter = senden, Bild einfügen mit ⌘V)"
: `Nachricht an ${modelLabel} … (Enter = senden, Bild einfügen mit ⌘V)`
}
rows={1}
disabled={disabled}

View file

@ -24,6 +24,7 @@ export interface ToolEvent {
}
export interface ChatOptions {
model: string;
think: boolean;
tools: boolean;
temperature: number;
@ -35,6 +36,13 @@ export interface ChatOptions {
dreamAuto: boolean;
}
export interface OllamaModel {
name: string;
sizeGB?: number;
parameterSize?: string;
quantization?: string;
}
export interface Anchor {
id: number;
session_id: string;

View file

@ -18,6 +18,7 @@ const SYSTEM_KEY = "oxagenttwo.systemPrompt";
function loadOptions(): ChatOptions {
const defaults: ChatOptions = {
model: "qwen3.5:latest",
think: true,
tools: true,
temperature: 0.7,
@ -65,6 +66,9 @@ export function useChat() {
setMessages((prev) =>
prev.some((x) => x.id === m.id) ? prev : [...prev, m],
);
// Legt der Server bei unbekannter sessionId einen neuen Chat an,
// springt der Client mit — sonst landen Nachrichten "im Leeren".
setActiveId((cur) => (cur === m.session_id ? cur : m.session_id));
} else if (t === "assistant-start") {
const m = data.message as Message;
setMessages((prev) => [...prev, { ...m, content: "", thinking: "" }]);
@ -127,16 +131,22 @@ export function useChat() {
const res = await fetch("/api/sessions");
const list = (await res.json()) as Session[];
setSessions(list);
setActiveId((cur) => cur ?? list[0]?.id ?? null);
// Tote IDs (z. B. gelöschte Sessions) verfallen und fallen auf die neueste zurück.
setActiveId((cur) =>
cur && list.some((s) => s.id === cur) ? cur : (list[0]?.id ?? null),
);
}, []);
useEffect(() => {
void refreshSessions();
fetch("/api/model")
}, [refreshSessions]);
useEffect(() => {
fetch(`/api/model?name=${encodeURIComponent(options.model)}`)
.then((r) => r.json())
.then(setModelInfo)
.catch(() => setModelInfo({ ok: false, error: "Ollama nicht erreichbar" }));
}, [refreshSessions]);
}, [options.model]);
// load messages on session switch
useEffect(() => {